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