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