AidopJobGate.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  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. /// 心跳时把「本进程是否为执行机」读回并发布到 <see cref="AidopRunnerState"/>。指派是**人工动作**
  26. /// (超管在 MDP 运行监控页操作,或直接改库),切换**不需要重启进程**,
  27. /// 最迟在 <see cref="AidopRunnerState.FreshnessWindow"/> 内生效。</para>
  28. ///
  29. /// <para><b>指派的对象是槽位,不是实例</b>(2026-09-28 起):指派存在
  30. /// <c>ado_etl_runner_designation.slot_code</c>,与进程侧 <c>AidopInstanceIdentity.SlotCode</c>
  31. /// (来自 <c>AIDOP_ETL_SLOT</c> / <c>AiDOP:Jobs:Slot</c>)比对。
  32. /// 原来指派存在 <c>ado_etl_instance.is_runner</c> 上,而 <c>instance_id</c> 是
  33. /// 「机器名:进程ID:启动时间」,于是**执行机每次重启都必然丢指派**、全库变成零台存活执行机,
  34. /// 定时 ETL 静默停摆——2026-09-28 实测 28 条 <c>AUTO_NIGHTLY</c> 无人可领即由此而来。
  35. /// 角色是部署槽位的属性,不是进程化身的属性。
  36. /// <b>未声明槽位的进程永不是执行机</b>,这比旧模型更严:旧模型下任何注册上来的进程都能被指派。
  37. /// 另需注意这不是退回 9-25 之前的配置模型——配置只声明**身份**(我是哪个槽位),
  38. /// 唯一**权威**仍是库里那条指派记录。</para>
  39. ///
  40. /// <para><b>热路径不查库</b>:本类仍然是纯内存计算。指派位由注册器在心跳时
  41. /// 读回并发布到 <see cref="AidopRunnerState"/>,这里只读那个投影。
  42. /// 这一点是刻意的——<see cref="ShouldRun"/> 的调用面是 21 个 <c>IJob</c> 加 2 个 Worker,
  43. /// 其中 <c>ModuleRebuildWorker</c> 每 5 秒轮询一次;若每次求值都查库,
  44. /// 闸门自身就成了新的连接压力源,等于把事故成因再造一遍。</para>
  45. ///
  46. /// <para><b>配置已降级为回退值</b>:<see cref="EnabledKey"/> 与 <see cref="EnvEnabledName"/>
  47. /// 不再是真相源,仅在注册器尚未首次发布、或持续查库失败导致指派状态过期时兜底。
  48. /// 部署脚本里若还写着「执行机设 <c>AIDOP_JOBS_ENABLED=true</c>」,那句话已经失效,须同步更正。</para>
  49. ///
  50. /// <para><b><see cref="DefaultEnabled"/> 语义已变</b>:它曾是 <c>true</c>,理由是
  51. /// 「部署忘了配就整条 ETL 静默停摆」。在人工指派模型下,**没有指派就不跑是预期行为**,
  52. /// 不是故障;自动开启反而会让任意一台新起的进程擅自跑全量。故改为 <c>false</c>。
  53. /// 上线后须人工指派一台执行机,否则定时 ETL 不会运行——这是设计,不是 bug。</para>
  54. ///
  55. /// <para><b>env override 的历史原因</b>:Furion 4.9.8.24 把 <c>Configuration/*.json</c> 装配在
  56. /// ASP.NET Core EnvironmentVariables provider **之后**,JSON 会盖掉 env(见
  57. /// <see cref="Admin.NET.Plugin.AiDOP.Infrastructure.S8.S8JobSwitch"/> 的同名说明),
  58. /// 故回退路径绕开配置系统直读 <c>Environment.GetEnvironmentVariable</c>。</para>
  59. ///
  60. /// <para><b>不纳管的后台服务</b>:<c>ModuleRebuildWorker</c> 与 <c>S1DashboardRebuildWorker</c>
  61. /// 保持常开——它们消费的队列既来自定时作业也来自页面「数据重算」按钮,
  62. /// 关掉会让手工触发永远停在 QUEUED。**但它们领取时必须按执行机身份过滤**:
  63. /// 非执行机只领手工任务,不领 <c>AUTO</c> / <c>BOOTSTRAP</c>,
  64. /// 见 <c>ModuleRebuildJobStore.ClaimNextQueuedAsync</c> 的 <c>runner</c> 参数。
  65. /// <see cref="EtlInstanceRegistrar"/> 同样常开,它是本闸门的数据来源,
  66. /// 被闸门关掉会构成死锁(没人心跳 → 没人发布指派 → 永远不是执行机)。</para>
  67. ///
  68. /// <para><b>调用约定</b>:必须在 <c>ExecuteAsync</c> 的**第一步**求值并在 false 时立即 return。
  69. /// 本类不访问数据库、不解析租户、不创建 scope,
  70. /// 因此即便 <c>[Period(..., RunOnStart = true)]</c> 在启动瞬间触发也不会碰到任何业务链路。</para>
  71. /// </summary>
  72. public static class AidopJobGate
  73. {
  74. /// <summary>配置键。**已降级为回退值**,仅在指派状态未知时生效。</summary>
  75. public const string EnabledKey = "AiDOP:Jobs:Enabled";
  76. /// <summary>env override 名。同样只作用于回退路径。</summary>
  77. public const string EnvEnabledName = "AIDOP_JOBS_ENABLED";
  78. /// <summary>
  79. /// 指派未知且配置与 env 都缺失时的取值。
  80. /// <c>false</c> = 没指派就不跑(人工指派模型下的预期行为)。改回 true 前请先读本类注释。
  81. /// </summary>
  82. public const bool DefaultEnabled = false;
  83. private static int _firstDisabledLogged;
  84. private static int _firstParseFailLogged;
  85. private static int _firstFallbackLogged;
  86. /// <summary>
  87. /// 本实例是否为 ETL 执行机。纯内存计算,无副作用,可在任意线程反复调用。
  88. /// </summary>
  89. public static bool IsRunner => Evaluate(out _, out _);
  90. /// <summary>
  91. /// 指派状态是否已知(注册器已心跳成功且未过期)。
  92. /// <c>false</c> 表示 <see cref="IsRunner"/> 当前取自配置回退而非数据库指派——
  93. /// 监控页应当把这种状态显式呈现,否则运维会误以为页面上的指派已经生效。
  94. /// </summary>
  95. public static bool IsAssignmentKnown => AidopRunnerState.TryGetFresh(out _);
  96. /// <summary>
  97. /// 作业入口守卫。返回 false 时调用方必须立即 return。
  98. /// 禁用时全进程只 warn 一次(21 个作业各刷一行没有意义)。
  99. /// </summary>
  100. /// <param name="jobName">作业名,仅用于首次日志定位。</param>
  101. /// <param name="logger">调用方 logger;为 null 时不记日志,判定结果不变。</param>
  102. public static bool ShouldRun(string jobName, ILogger? logger = null)
  103. {
  104. var enabled = Evaluate(out var parseFailEnvName, out var fromAssignment);
  105. if (parseFailEnvName != null && Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0)
  106. logger?.LogWarning(
  107. "环境变量 {EnvName} 不是合法的 bool,已忽略并沿用 {Key} 的配置值", parseFailEnvName, EnabledKey);
  108. if (!fromAssignment && Interlocked.Exchange(ref _firstFallbackLogged, 1) == 0)
  109. logger?.LogWarning(
  110. "执行机指派状态未知({Registrar} 尚未首次心跳,或持续查库失败已过期),"
  111. + "本次回退到配置 {Key}={Value}。若该状态持续存在,请检查 ado_etl_instance 是否可写",
  112. nameof(EtlInstanceRegistrar), EnabledKey, enabled);
  113. if (!enabled && Interlocked.Exchange(ref _firstDisabledLogged, 1) == 0)
  114. logger?.LogInformation(
  115. "本实例不是 ETL 执行机,AiDOP 定时作业与轮询后台全部跳过(首个:{Job})。"
  116. + "页面上的手工重算不受影响。本实例 instanceId={InstanceId} slot={Slot}。"
  117. + "指派方式:在 MDP 运行监控页把该槽位指派为执行机,无需重启进程;"
  118. + "若 slot 为「未声明」,须先给该进程配 {SlotEnv} 或 {SlotKey}",
  119. jobName, AidopInstanceIdentity.InstanceId,
  120. AidopInstanceIdentity.SlotCode ?? "(未声明)",
  121. AidopInstanceIdentity.SlotEnvName, AidopInstanceIdentity.SlotConfigKey);
  122. return enabled;
  123. }
  124. /// <summary>
  125. /// 求值顺序:数据库指派(新鲜时)优先,否则回退配置。
  126. /// </summary>
  127. /// <param name="parseFailEnvName">回退路径上首个 <c>bool.TryParse</c> 失败的 env 名;无失败为 null。</param>
  128. /// <param name="fromAssignment">true = 取自数据库指派;false = 取自配置回退。</param>
  129. private static bool Evaluate(out string? parseFailEnvName, out bool fromAssignment)
  130. {
  131. parseFailEnvName = null;
  132. if (AidopRunnerState.TryGetFresh(out var assigned))
  133. {
  134. fromAssignment = true;
  135. return assigned;
  136. }
  137. fromAssignment = false;
  138. return ResolveFromConfig(out parseFailEnvName);
  139. }
  140. private static bool ResolveFromConfig(out string? parseFailEnvName)
  141. {
  142. parseFailEnvName = null;
  143. bool fromJson;
  144. try
  145. {
  146. fromJson = Furion.App.GetConfig<bool?>(EnabledKey, true) ?? DefaultEnabled;
  147. }
  148. catch
  149. {
  150. // 宿主未就绪(单元测试直接 new 作业类)时按默认值走,不让守卫本身成为故障点
  151. fromJson = DefaultEnabled;
  152. }
  153. var raw = Environment.GetEnvironmentVariable(EnvEnabledName);
  154. if (string.IsNullOrWhiteSpace(raw)) return fromJson;
  155. if (bool.TryParse(raw.Trim(), out var parsed)) return parsed;
  156. parseFailEnvName = EnvEnabledName;
  157. return fromJson;
  158. }
  159. }