AidopJobGate.cs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. using Microsoft.Extensions.Logging;
  2. namespace Admin.NET.Plugin.AiDOP.Infrastructure;
  3. /// <summary>
  4. /// ETL 执行机闸门:决定**本实例**是否运行 AiDOP 的定时作业与轮询型后台服务。
  5. ///
  6. /// <para><b>为什么需要</b>:修复前所有 <c>[Cron]</c>/<c>[Period]</c> 作业与
  7. /// <c>Startup.cs</c> 注册的 HostedService 都是无条件启用的,于是「谁启动进程谁就是一台完整的 ETL 引擎」。
  8. /// 多台开发机 + 165 演示容器同时连 <c>aidopdev</c> 时,同一套作业被重复执行 N 份。
  9. /// 2026-09-24 该库连接耗尽(1040 Too many connections)。</para>
  10. ///
  11. /// <para><b>取证口径更正(2026-09-25)</b>:本注释早前写的「9 月 22 日单日重算净执行时长约 103 小时」
  12. /// 与「同一瞬间有 2–3 个重算并行即多实例」两条都不准确,勿再引用。
  13. /// 按 <c>submitted_at</c> 自然日(库内已是 CST,**不要再做 <c>CONVERT_TZ</c>**)重算,
  14. /// 9-22 为 40.1 净小时、9-23 为 54.1、9-24 为 28.0;而 <c>GlobalMaxParallelScopes=2</c>
  15. /// 本来就允许单实例内两个重算并行,1.67 倍完全落在单实例正常范围内。
  16. /// 多实例的真实证据是**单个 scope 的 AUTO 成功次数超过小时触发上限**(S1 某租户 9-22 跑了 33 次 &gt; 24)。
  17. /// 但多实例的负载贡献远小于直觉:入队去重(<c>ModuleRebuildService.EnqueueAsync</c> 的 409)
  18. /// 吸收了绝大部分重复,全天总量 1063 次 ≈ 单实例理论值 999。
  19. /// **结论:本闸门解决的是开发/测试/演示共用一库的卫生问题,不是主要减载手段**;
  20. /// 真正的减载是「定时全量改跳过窗口 + 夜间串行」与「贴源批量写入」。
  21. /// 详见 <c>doc/plan/165-aidopdev连接耗尽根治执行任务书.md</c> §3.3。</para>
  22. ///
  23. /// <para><b>执行机身份来自数据库,不再来自配置</b>(2026-09-25 起):
  24. /// 每个进程由 <see cref="EtlInstanceRegistrar"/> 注册进 <c>ado_etl_instance</c> 并定期心跳,
  25. /// 其中 <c>is_runner=1</c> 的那一行是当前执行机。指派是**人工动作**
  26. /// (超管在 MDP 运行监控页操作,或直接改库),切换**不需要重启进程**,
  27. /// 最迟在 <see cref="AidopRunnerState.FreshnessWindow"/> 内生效。</para>
  28. ///
  29. /// <para><b>热路径不查库</b>:本类仍然是纯内存计算。指派位由注册器在心跳时
  30. /// 读回并发布到 <see cref="AidopRunnerState"/>,这里只读那个投影。
  31. /// 这一点是刻意的——<see cref="ShouldRun"/> 的调用面是 21 个 <c>IJob</c> 加 2 个 Worker,
  32. /// 其中 <c>ModuleRebuildWorker</c> 每 5 秒轮询一次;若每次求值都查库,
  33. /// 闸门自身就成了新的连接压力源,等于把事故成因再造一遍。</para>
  34. ///
  35. /// <para><b>配置已降级为回退值</b>:<see cref="EnabledKey"/> 与 <see cref="EnvEnabledName"/>
  36. /// 不再是真相源,仅在注册器尚未首次发布、或持续查库失败导致指派状态过期时兜底。
  37. /// 部署脚本里若还写着「执行机设 <c>AIDOP_JOBS_ENABLED=true</c>」,那句话已经失效,须同步更正。</para>
  38. ///
  39. /// <para><b><see cref="DefaultEnabled"/> 语义已变</b>:它曾是 <c>true</c>,理由是
  40. /// 「部署忘了配就整条 ETL 静默停摆」。在人工指派模型下,**没有指派就不跑是预期行为**,
  41. /// 不是故障;自动开启反而会让任意一台新起的进程擅自跑全量。故改为 <c>false</c>。
  42. /// 上线后须人工指派一台执行机,否则定时 ETL 不会运行——这是设计,不是 bug。</para>
  43. ///
  44. /// <para><b>env override 的历史原因</b>:Furion 4.9.8.24 把 <c>Configuration/*.json</c> 装配在
  45. /// ASP.NET Core EnvironmentVariables provider **之后**,JSON 会盖掉 env(见
  46. /// <see cref="Admin.NET.Plugin.AiDOP.Infrastructure.S8.S8JobSwitch"/> 的同名说明),
  47. /// 故回退路径绕开配置系统直读 <c>Environment.GetEnvironmentVariable</c>。</para>
  48. ///
  49. /// <para><b>不纳管的后台服务</b>:<c>ModuleRebuildWorker</c> 与 <c>S1DashboardRebuildWorker</c>
  50. /// 保持常开——它们消费的队列既来自定时作业也来自页面「数据重算」按钮,
  51. /// 关掉会让手工触发永远停在 QUEUED。**但它们领取时必须按执行机身份过滤**:
  52. /// 非执行机只领手工任务,不领 <c>AUTO</c> / <c>BOOTSTRAP</c>,
  53. /// 见 <c>ModuleRebuildJobStore.ClaimNextQueuedAsync</c> 的 <c>runner</c> 参数。
  54. /// <see cref="EtlInstanceRegistrar"/> 同样常开,它是本闸门的数据来源,
  55. /// 被闸门关掉会构成死锁(没人心跳 → 没人发布指派 → 永远不是执行机)。</para>
  56. ///
  57. /// <para><b>调用约定</b>:必须在 <c>ExecuteAsync</c> 的**第一步**求值并在 false 时立即 return。
  58. /// 本类不访问数据库、不解析租户、不创建 scope,
  59. /// 因此即便 <c>[Period(..., RunOnStart = true)]</c> 在启动瞬间触发也不会碰到任何业务链路。</para>
  60. /// </summary>
  61. public static class AidopJobGate
  62. {
  63. /// <summary>配置键。**已降级为回退值**,仅在指派状态未知时生效。</summary>
  64. public const string EnabledKey = "AiDOP:Jobs:Enabled";
  65. /// <summary>env override 名。同样只作用于回退路径。</summary>
  66. public const string EnvEnabledName = "AIDOP_JOBS_ENABLED";
  67. /// <summary>
  68. /// 指派未知且配置与 env 都缺失时的取值。
  69. /// <c>false</c> = 没指派就不跑(人工指派模型下的预期行为)。改回 true 前请先读本类注释。
  70. /// </summary>
  71. public const bool DefaultEnabled = false;
  72. private static int _firstDisabledLogged;
  73. private static int _firstParseFailLogged;
  74. private static int _firstFallbackLogged;
  75. /// <summary>
  76. /// 本实例是否为 ETL 执行机。纯内存计算,无副作用,可在任意线程反复调用。
  77. /// </summary>
  78. public static bool IsRunner => Evaluate(out _, out _);
  79. /// <summary>
  80. /// 指派状态是否已知(注册器已心跳成功且未过期)。
  81. /// <c>false</c> 表示 <see cref="IsRunner"/> 当前取自配置回退而非数据库指派——
  82. /// 监控页应当把这种状态显式呈现,否则运维会误以为页面上的指派已经生效。
  83. /// </summary>
  84. public static bool IsAssignmentKnown => AidopRunnerState.TryGetFresh(out _);
  85. /// <summary>
  86. /// 作业入口守卫。返回 false 时调用方必须立即 return。
  87. /// 禁用时全进程只 warn 一次(21 个作业各刷一行没有意义)。
  88. /// </summary>
  89. /// <param name="jobName">作业名,仅用于首次日志定位。</param>
  90. /// <param name="logger">调用方 logger;为 null 时不记日志,判定结果不变。</param>
  91. public static bool ShouldRun(string jobName, ILogger? logger = null)
  92. {
  93. var enabled = Evaluate(out var parseFailEnvName, out var fromAssignment);
  94. if (parseFailEnvName != null && Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0)
  95. logger?.LogWarning(
  96. "环境变量 {EnvName} 不是合法的 bool,已忽略并沿用 {Key} 的配置值", parseFailEnvName, EnabledKey);
  97. if (!fromAssignment && Interlocked.Exchange(ref _firstFallbackLogged, 1) == 0)
  98. logger?.LogWarning(
  99. "执行机指派状态未知({Registrar} 尚未首次心跳,或持续查库失败已过期),"
  100. + "本次回退到配置 {Key}={Value}。若该状态持续存在,请检查 ado_etl_instance 是否可写",
  101. nameof(EtlInstanceRegistrar), EnabledKey, enabled);
  102. if (!enabled && Interlocked.Exchange(ref _firstDisabledLogged, 1) == 0)
  103. logger?.LogInformation(
  104. "本实例不是 ETL 执行机,AiDOP 定时作业与轮询后台全部跳过(首个:{Job})。"
  105. + "页面上的手工重算不受影响。指派方式:在 MDP 运行监控页把 ado_etl_instance 中"
  106. + " instanceId={InstanceId} 的 is_runner 置 1,无需重启进程",
  107. jobName, AidopInstanceIdentity.InstanceId);
  108. return enabled;
  109. }
  110. /// <summary>
  111. /// 求值顺序:数据库指派(新鲜时)优先,否则回退配置。
  112. /// </summary>
  113. /// <param name="parseFailEnvName">回退路径上首个 <c>bool.TryParse</c> 失败的 env 名;无失败为 null。</param>
  114. /// <param name="fromAssignment">true = 取自数据库指派;false = 取自配置回退。</param>
  115. private static bool Evaluate(out string? parseFailEnvName, out bool fromAssignment)
  116. {
  117. parseFailEnvName = null;
  118. if (AidopRunnerState.TryGetFresh(out var assigned))
  119. {
  120. fromAssignment = true;
  121. return assigned;
  122. }
  123. fromAssignment = false;
  124. return ResolveFromConfig(out parseFailEnvName);
  125. }
  126. private static bool ResolveFromConfig(out string? parseFailEnvName)
  127. {
  128. parseFailEnvName = null;
  129. bool fromJson;
  130. try
  131. {
  132. fromJson = Furion.App.GetConfig<bool?>(EnabledKey, true) ?? DefaultEnabled;
  133. }
  134. catch
  135. {
  136. // 宿主未就绪(单元测试直接 new 作业类)时按默认值走,不让守卫本身成为故障点
  137. fromJson = DefaultEnabled;
  138. }
  139. var raw = Environment.GetEnvironmentVariable(EnvEnabledName);
  140. if (string.IsNullOrWhiteSpace(raw)) return fromJson;
  141. if (bool.TryParse(raw.Trim(), out var parsed)) return parsed;
  142. parseFailEnvName = EnvEnabledName;
  143. return fromJson;
  144. }
  145. }