using System.Reflection; using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild; using Admin.NET.Plugin.AiDOP.Entity.SmartOps; using Admin.NET.Plugin.AiDOP.Infrastructure; using Furion.Schedule; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.Infrastructure; /// /// 工作包 B 的守卫测试:ETL 执行机闸门与领取过滤。 /// /// 背景:2026-09-24 aidopdev 连接耗尽(1040)。治理后执行机身份改为 /// 数据库驱动 + 人工指派,本组测试守住四条不变式,防止后续改动无声回归。 /// 详见 doc/plan/165-aidopdev连接耗尽根治执行任务书.md 工作包 B。 /// /// 本测试项目没有数据库夹具(见 csproj:只有 xunit + 项目引用), /// 故涉及 SQL 的部分用「反射断言契约 + 源码扫描断言落地」两道, /// 纯内存部分(闸门求值、回退)则做真实行为断言。 /// public class AidopJobGateTests : IDisposable { private readonly string _originalEnv; public AidopJobGateTests() { _originalEnv = Environment.GetEnvironmentVariable(AidopJobGate.EnvEnabledName); AidopRunnerState.ResetForTests(); } public void Dispose() { Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, _originalEnv); AidopRunnerState.ResetForTests(); } // ───────────────────── 1. 所有 IJob 必须过闸门 ───────────────────── /// /// 扫描插件里**所有** 实现类,断言其源码包含 /// AidopJobGate.ShouldRun。 /// /// 2026-09-25 实测 21 个实现类**全部**已满足,故本测试首次运行就应为绿。 /// 若它变红,几乎一定是有人新增了 Job 却忘了写闸门——那台非执行机会立刻开始 /// 跑全量,正是本次事故的成因。**不要通过加例外名单来让它变绿。** /// [Fact] public void EveryJob_CallsGate() { var jobTypes = typeof(AidopJobGate).Assembly.GetTypes() .Where(t => t is { IsAbstract: false, IsInterface: false } && typeof(IJob).IsAssignableFrom(t)) .OrderBy(t => t.Name) .ToList(); Assert.True(jobTypes.Count >= 21, $"IJob 实现类只找到 {jobTypes.Count} 个,少于 2026-09-25 实测的 21 个;" + "若确属删除作业请更新此下限,若是反射失效请先查清"); var missing = new List(); foreach (var t in jobTypes) { var src = TryReadSource(t); Assert.True(src != null, $"找不到 {t.Name} 的源码文件,测试的路径推断需同步更新"); if (!src.Contains("AidopJobGate.ShouldRun", StringComparison.Ordinal)) missing.Add(t.Name); } Assert.True(missing.Count == 0, "以下 Job 未调用 AidopJobGate.ShouldRun,非执行机上会照常跑:" + string.Join(", ", missing)); } // ───────────────────── 2. 领取过滤:非执行机只领手工 ───────────────────── /// /// ClaimNextQueuedAsync 必须带 bool runner 参数。 /// 有人把它删掉、或改成可选参数并默认 true,非执行机就会重新开始领 AUTO。 /// [Fact] public void ClaimContract_HasRunnerParameter() { var m = typeof(IModuleRebuildJobStore).GetMethod(nameof(IModuleRebuildJobStore.ClaimNextQueuedAsync)); Assert.NotNull(m); var p = m!.GetParameters().SingleOrDefault(x => x.Name == "runner"); Assert.True(p != null, "ClaimNextQueuedAsync 缺少 runner 参数"); Assert.Equal(typeof(bool), p!.ParameterType); Assert.False(p.IsOptional, "runner 不得是可选参数:漏传时会静默按默认值放行"); } /// 手工判定的规范定义。非执行机只能领这一类。 [Theory] [InlineData("MANUAL", null, true)] [InlineData("manual", null, true)] // 大小写不敏感 [InlineData("AUTO", null, false)] [InlineData("BOOTSTRAP", null, false)] [InlineData("AUTO_NIGHTLY", null, false)] // 工作包 C 将新增,必须天然落在白名单外 [InlineData("AUTO", 1001L, true)] // 带 requested_by 即视为人工触发 public void IsManualTrigger_Semantics(string triggerType, long? requestedBy, bool expected) { Assert.Equal(expected, ModuleRebuildTriggerType.IsManualTrigger(triggerType, requestedBy)); } /// /// 规范定义必须真的落到 SQL 上。表达式树要翻译成 SQL,无法直接复用 /// IsManualTrigger,故这里扫源码确认两边没有走散。 /// [Fact] public void ClaimQuery_AppliesManualWhitelistForNonRunner() { var src = ReadPluginSource("DataPlatform/MdpRebuild/ModuleRebuildStore.cs"); Assert.Contains("WhereIF(!runner", src, StringComparison.Ordinal); Assert.Contains("ModuleRebuildTriggerType.Manual", src, StringComparison.Ordinal); Assert.Contains("x.RequestedBy != null", src, StringComparison.Ordinal); // 黑名单写法会在新增 AUTO_NIGHTLY 之类触发器时漏网,明令禁止 Assert.DoesNotContain("!= ModuleRebuildTriggerType.Auto", src, StringComparison.Ordinal); } // ───────────────────── 3. 指派唯一性由写入侧保证 ───────────────────── /// /// 「最多一台执行机」现在由 ado_etl_runner_designation 的**结构**保证: /// 主键固定为 ,表里放不下第二行。 /// /// 这条不变式没有被放弃,而是被更强的机制取代了。旧实现靠 /// AssignRunnerAsync 在同一事务内先清后置——那依赖调用方守规矩, /// 且指派寄生在实例行上,进程重启就会丢(2026-09-28 的队列孤立事故)。 /// 现在并发指派会被主键与条件更新挡住。**不要改成按 instanceId 指派**。 /// [Fact] public void RunnerDesignation_IsStructurallyUnique() { Assert.NotNull(typeof(IEtlInstanceStore).GetMethod(nameof(IEtlInstanceStore.AssignRunnerSlotAsync))); Assert.NotNull(typeof(IEtlInstanceStore).GetMethod(nameof(IEtlInstanceStore.GetDesignatedSlotAsync))); // 指派必须接槽位,不得退回按实例指派 var assign = typeof(IEtlInstanceStore).GetMethod(nameof(IEtlInstanceStore.AssignRunnerSlotAsync))!; Assert.Contains(assign.GetParameters(), p => p.Name == "slotCode"); Assert.DoesNotContain(assign.GetParameters(), p => p.Name == "instanceId"); var src = ReadPluginSource("Infrastructure/EtlInstanceStore.cs"); Assert.Contains("AdoEtlRunnerDesignation.SingletonId", src, StringComparison.Ordinal); // 每一处读写都必须钉在那唯一一行上;漏掉 Where 会写出/读到第二行 var singletonRefs = src.Split("AdoEtlRunnerDesignation.SingletonId").Length - 1; Assert.True(singletonRefs >= 3, $"引用单行主键的位置只有 {singletonRefs} 处,读写路径可能有遗漏"); } /// /// 指派必须落在跨重启稳定的槽位上,不得再挂回进程级标识。 /// /// 根因守卫:instance_id 是「机器名:进程ID:启动时间」, /// 把角色存在它上面意味着执行机每重启一次就必然丢指派、全库零台存活执行机、 /// 定时 ETL 静默停摆。2026-09-28 实测 28 条 AUTO_NIGHTLY 无人可领即由此而来。 /// 若有人把判定改回读 ado_etl_instance.is_runner,这条测试必须变红。 /// [Fact] public void Registrar_ResolvesRunnerBySlot_NotByInstanceId() { var src = ReadPluginSource("Infrastructure/EtlInstanceRegistrar.cs"); Assert.Contains("GetDesignatedSlotAsync", src, StringComparison.Ordinal); Assert.Contains("AidopInstanceIdentity.SlotCode", src, StringComparison.Ordinal); // 回读 is_runner 就是旧模型,必须彻底断开 Assert.DoesNotContain("x.IsRunner)", src, StringComparison.Ordinal); Assert.DoesNotContain("Select(x => x.IsRunner", src, StringComparison.Ordinal); } /// /// 未声明槽位的进程永不是执行机。 /// /// 这比旧模型更严:旧模型下任何注册上来的进程都能被指派, /// 开发机被指派正是 2026-09-24 连接耗尽事故的成因之一。 /// 判定里那条「槽位为空直接返回 false」的短路不得删除。 /// [Fact] public void Registrar_SlotlessInstance_CanNeverBeRunner() { var src = ReadPluginSource("Infrastructure/EtlInstanceRegistrar.cs"); Assert.Contains("string.IsNullOrEmpty(slot)", src, StringComparison.Ordinal); // 槽位比对必须是精确相等,不能退化成「包含」或大小写不敏感的模糊匹配 Assert.Contains("string.Equals(slot, designated, StringComparison.Ordinal)", src, StringComparison.Ordinal); } /// /// 同槽位多个存活实例时失败关闭(两边都不跑),而不是抢着跑。 /// /// 没有租约就没有仲裁者,此时抢着跑等于双跑全量——那正是 2026-09-24 /// 连接耗尽的成因。代价只是滚动重启重叠的几十秒空窗。 /// 若将来要改成自动接管,必须先引入租约,不能简单地把这个判断删掉。 /// [Fact] public void Registrar_SameSlotConflict_FailsClosed() { var src = ReadPluginSource("Infrastructure/EtlInstanceRegistrar.cs"); Assert.Contains("others == 0", src, StringComparison.Ordinal); Assert.Contains("x.InstanceId != instanceId", src, StringComparison.Ordinal); } /// /// 夜间自清与 QUEUED 回收都不得误伤页面手工任务。 /// /// ModuleRebuildTriggerType.IsManualTrigger 把带 requested_by /// 的行也算人工触发,所以批量收口的 WHERE 必须同时带 RequestedBy == null; /// 少了这一支,超管在监控页发起的重算会被夜间作业清掉。 /// [Fact] public void BulkCancel_NeverTouchesManualOrRunning() { var store = typeof(IModuleRebuildJobStore); Assert.NotNull(store.GetMethod("CancelUnstartedByTriggerAsync")); Assert.NotNull(store.GetMethod("FailStaleQueuedAsync")); var src = ReadPluginSource("DataPlatform/MdpRebuild/ModuleRebuildStore.cs"); // 锚在实现上,不能只匹配方法名:接口声明与实现签名同文,先命中声明会读到空的方法体 var idx = src.IndexOf("public async Task CancelUnstartedByTriggerAsync", StringComparison.Ordinal); Assert.True(idx > 0, "找不到批量收口的实现,测试的定位串需同步更新"); var body = src[idx..Math.Min(src.Length, idx + 1600)]; Assert.Contains("x.Status == ModuleRebuildStatus.Queued", body, StringComparison.Ordinal); Assert.Contains("x.RequestedBy == null", body, StringComparison.Ordinal); } /// /// QUEUED 收口阈值必须给足。GlobalMaxParallelScopes = 1 且单 scope 跑 10~20 分钟, /// 夜间扇出 28 个 scope 时队尾**合法**等待可达 7 小时,阈值定短了会误杀正常排队。 /// [Fact] public void QueuedStaleThreshold_LeavesRoomForLegitimateWaiting() { Assert.True(ModuleRebuildLock.QueuedStaleAfter >= TimeSpan.FromHours(12), "QUEUED 收口阈值不足 12 小时,夜间全量队尾会被误杀;改小前请先核算 scope 数 × 单次耗时"); Assert.True(ModuleRebuildLock.QueuedStaleAfter > ModuleRebuildLock.StaleAfter, "QUEUED 阈值必须明显宽于 RUNNING 无心跳阈值:前者是排队等待,后者是进程已死"); } /// 注册器只报到、不自荐。自动选举已在 B-D1 明确否决。 [Fact] public void Registrar_NeverSelfAssigns() { var src = ReadPluginSource("Infrastructure/EtlInstanceRegistrar.cs"); Assert.Contains("IsRunner = false", src, StringComparison.Ordinal); Assert.DoesNotContain("IsRunner = true", src, StringComparison.Ordinal); } // ───────────────────── 4. 指派未知时回退到配置 ───────────────────── [Fact] public void IsRunner_UsesAssignment_WhenFresh() { // 配置回退方向刻意设成相反值,确保断言命中的是指派而不是回退 Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, "false"); AidopRunnerState.Publish(true); Assert.True(AidopJobGate.IsRunner); Assert.True(AidopJobGate.IsAssignmentKnown); AidopRunnerState.Publish(false); Assert.False(AidopJobGate.IsRunner); Assert.True(AidopJobGate.IsAssignmentKnown); } [Fact] public void IsRunner_FallsBackToConfig_WhenAssignmentUnknown() { AidopRunnerState.Invalidate(); Assert.False(AidopJobGate.IsAssignmentKnown); Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, "true"); Assert.True(AidopJobGate.IsRunner); Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, "false"); Assert.False(AidopJobGate.IsRunner); } /// /// 指派未知且配置缺失时不跑。 /// 曾是 true(「别静默停摆」), /// 在人工指派模型下改成了 false:没指派就不跑是**预期行为**。 /// 改回 true 会让任意一台新起的进程擅自跑全量。 /// [Fact] public void DefaultIsOff_WhenNothingConfigured() { Assert.False(AidopJobGate.DefaultEnabled); AidopRunnerState.Invalidate(); Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, null); // 宿主未就绪时 Furion.App.GetConfig 会抛,闸门内部吞掉并落到 DefaultEnabled Assert.False(AidopJobGate.IsRunner); } /// 查库失败必须立即打回未知,不得沿用上一次的指派值。 [Fact] public void Invalidate_DropsPreviousAssignment() { Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, "false"); AidopRunnerState.Publish(true); Assert.True(AidopJobGate.IsRunner); // 指派可能已被运维改掉,继续沿用会让两台实例同时自认执行机 AidopRunnerState.Invalidate(); Assert.False(AidopJobGate.IsAssignmentKnown); Assert.False(AidopJobGate.IsRunner); } [Fact] public void FreshnessWindow_CoversSeveralHeartbeats() { // 窗口需明显长于心跳周期,否则偶发一次心跳失败就会退化为配置回退 Assert.True(AidopRunnerState.FreshnessWindow >= EtlInstanceRegistrar.HeartbeatInterval * 2, "新鲜度窗口至少要容纳两次心跳"); } // ───────────────────── 源码定位 ───────────────────── private static string PluginRoot => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); private static string ReadPluginSource(string relativePath) { var full = Path.Combine(PluginRoot, relativePath); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return File.ReadAllText(full); } /// /// 按类型名在插件目录下找同名 .cs。作业类都是「一类一文件」, /// 找不到即说明命名约定被打破,此时应让测试失败而不是悄悄跳过。 /// private static string TryReadSource(Type t) { var hits = Directory.GetFiles(PluginRoot, t.Name + ".cs", SearchOption.AllDirectories); return hits.Length == 0 ? null : File.ReadAllText(hits[0]); } }