| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- 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;
- /// <summary>
- /// 工作包 B 的守卫测试:ETL 执行机闸门与领取过滤。
- ///
- /// <para>背景:2026-09-24 <c>aidopdev</c> 连接耗尽(1040)。治理后执行机身份改为
- /// 数据库驱动 + 人工指派,本组测试守住四条不变式,防止后续改动无声回归。
- /// 详见 <c>doc/plan/165-aidopdev连接耗尽根治执行任务书.md</c> 工作包 B。</para>
- ///
- /// <para>本测试项目没有数据库夹具(见 csproj:只有 xunit + 项目引用),
- /// 故涉及 SQL 的部分用「反射断言契约 + 源码扫描断言落地」两道,
- /// 纯内存部分(闸门求值、回退)则做真实行为断言。</para>
- /// </summary>
- 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 必须过闸门 ─────────────────────
- /// <summary>
- /// 扫描插件里**所有** <see cref="IJob"/> 实现类,断言其源码包含
- /// <c>AidopJobGate.ShouldRun</c>。
- ///
- /// <para>2026-09-25 实测 21 个实现类**全部**已满足,故本测试首次运行就应为绿。
- /// 若它变红,几乎一定是有人新增了 Job 却忘了写闸门——那台非执行机会立刻开始
- /// 跑全量,正是本次事故的成因。**不要通过加例外名单来让它变绿。**</para>
- /// </summary>
- [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<string>();
- 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. 领取过滤:非执行机只领手工 ─────────────────────
- /// <summary>
- /// <c>ClaimNextQueuedAsync</c> 必须带 <c>bool runner</c> 参数。
- /// 有人把它删掉、或改成可选参数并默认 true,非执行机就会重新开始领 AUTO。
- /// </summary>
- [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 不得是可选参数:漏传时会静默按默认值放行");
- }
- /// <summary>手工判定的规范定义。非执行机只能领这一类。</summary>
- [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));
- }
- /// <summary>
- /// 规范定义必须真的落到 SQL 上。表达式树要翻译成 SQL,无法直接复用
- /// <c>IsManualTrigger</c>,故这里扫源码确认两边没有走散。
- /// </summary>
- [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. 指派唯一性由写入侧保证 ─────────────────────
- /// <summary>
- /// 「最多一台执行机」现在由 <c>ado_etl_runner_designation</c> 的**结构**保证:
- /// 主键固定为 <see cref="AdoEtlRunnerDesignation.SingletonId"/>,表里放不下第二行。
- ///
- /// <para>这条不变式没有被放弃,而是被更强的机制取代了。旧实现靠
- /// <c>AssignRunnerAsync</c> 在同一事务内先清后置——那依赖调用方守规矩,
- /// 且指派寄生在实例行上,进程重启就会丢(2026-09-28 的队列孤立事故)。
- /// 现在并发指派会被主键与条件更新挡住。**不要改成按 instanceId 指派**。</para>
- /// </summary>
- [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} 处,读写路径可能有遗漏");
- }
- /// <summary>
- /// 指派必须落在跨重启稳定的槽位上,不得再挂回进程级标识。
- ///
- /// <para><b>根因守卫</b>:<c>instance_id</c> 是「机器名:进程ID:启动时间」,
- /// 把角色存在它上面意味着执行机每重启一次就必然丢指派、全库零台存活执行机、
- /// 定时 ETL 静默停摆。2026-09-28 实测 28 条 AUTO_NIGHTLY 无人可领即由此而来。
- /// 若有人把判定改回读 <c>ado_etl_instance.is_runner</c>,这条测试必须变红。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 未声明槽位的进程永不是执行机。
- ///
- /// <para>这比旧模型更严:旧模型下任何注册上来的进程都能被指派,
- /// 开发机被指派正是 2026-09-24 连接耗尽事故的成因之一。
- /// 判定里那条「槽位为空直接返回 false」的短路不得删除。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 同槽位多个存活实例时失败关闭(两边都不跑),而不是抢着跑。
- ///
- /// <para>没有租约就没有仲裁者,此时抢着跑等于双跑全量——那正是 2026-09-24
- /// 连接耗尽的成因。代价只是滚动重启重叠的几十秒空窗。
- /// 若将来要改成自动接管,必须先引入租约,不能简单地把这个判断删掉。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 夜间自清与 QUEUED 回收都不得误伤页面手工任务。
- ///
- /// <para><c>ModuleRebuildTriggerType.IsManualTrigger</c> 把带 <c>requested_by</c>
- /// 的行也算人工触发,所以批量收口的 WHERE 必须同时带 <c>RequestedBy == null</c>;
- /// 少了这一支,超管在监控页发起的重算会被夜间作业清掉。</para>
- /// </summary>
- [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<int> 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);
- }
- /// <summary>
- /// QUEUED 收口阈值必须给足。<c>GlobalMaxParallelScopes = 1</c> 且单 scope 跑 10~20 分钟,
- /// 夜间扇出 28 个 scope 时队尾**合法**等待可达 7 小时,阈值定短了会误杀正常排队。
- /// </summary>
- [Fact]
- public void QueuedStaleThreshold_LeavesRoomForLegitimateWaiting()
- {
- Assert.True(ModuleRebuildLock.QueuedStaleAfter >= TimeSpan.FromHours(12),
- "QUEUED 收口阈值不足 12 小时,夜间全量队尾会被误杀;改小前请先核算 scope 数 × 单次耗时");
- Assert.True(ModuleRebuildLock.QueuedStaleAfter > ModuleRebuildLock.StaleAfter,
- "QUEUED 阈值必须明显宽于 RUNNING 无心跳阈值:前者是排队等待,后者是进程已死");
- }
- /// <summary>注册器只报到、不自荐。自动选举已在 B-D1 明确否决。</summary>
- [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);
- }
- /// <summary>
- /// 指派未知且配置缺失时不跑。
- /// <see cref="AidopJobGate.DefaultEnabled"/> 曾是 true(「别静默停摆」),
- /// 在人工指派模型下改成了 false:没指派就不跑是**预期行为**。
- /// 改回 true 会让任意一台新起的进程擅自跑全量。
- /// </summary>
- [Fact]
- public void DefaultIsOff_WhenNothingConfigured()
- {
- Assert.False(AidopJobGate.DefaultEnabled);
- AidopRunnerState.Invalidate();
- Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, null);
- // 宿主未就绪时 Furion.App.GetConfig 会抛,闸门内部吞掉并落到 DefaultEnabled
- Assert.False(AidopJobGate.IsRunner);
- }
- /// <summary>查库失败必须立即打回未知,不得沿用上一次的指派值。</summary>
- [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);
- }
- /// <summary>
- /// 按类型名在插件目录下找同名 .cs。作业类都是「一类一文件」,
- /// 找不到即说明命名约定被打破,此时应让测试失败而不是悄悄跳过。
- /// </summary>
- 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]);
- }
- }
|