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;
}
}