AidopJobGateTests.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. using System.Reflection;
  2. using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. using Furion.Schedule;
  5. using Xunit;
  6. namespace Admin.NET.Plugin.AiDOP.Tests.Infrastructure;
  7. /// <summary>
  8. /// 工作包 B 的守卫测试:ETL 执行机闸门与领取过滤。
  9. ///
  10. /// <para>背景:2026-09-24 <c>aidopdev</c> 连接耗尽(1040)。治理后执行机身份改为
  11. /// 数据库驱动 + 人工指派,本组测试守住四条不变式,防止后续改动无声回归。
  12. /// 详见 <c>doc/plan/165-aidopdev连接耗尽根治执行任务书.md</c> 工作包 B。</para>
  13. ///
  14. /// <para>本测试项目没有数据库夹具(见 csproj:只有 xunit + 项目引用),
  15. /// 故涉及 SQL 的部分用「反射断言契约 + 源码扫描断言落地」两道,
  16. /// 纯内存部分(闸门求值、回退)则做真实行为断言。</para>
  17. /// </summary>
  18. public class AidopJobGateTests : IDisposable
  19. {
  20. private readonly string _originalEnv;
  21. public AidopJobGateTests()
  22. {
  23. _originalEnv = Environment.GetEnvironmentVariable(AidopJobGate.EnvEnabledName);
  24. AidopRunnerState.ResetForTests();
  25. }
  26. public void Dispose()
  27. {
  28. Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, _originalEnv);
  29. AidopRunnerState.ResetForTests();
  30. }
  31. // ───────────────────── 1. 所有 IJob 必须过闸门 ─────────────────────
  32. /// <summary>
  33. /// 扫描插件里**所有** <see cref="IJob"/> 实现类,断言其源码包含
  34. /// <c>AidopJobGate.ShouldRun</c>。
  35. ///
  36. /// <para>2026-09-25 实测 21 个实现类**全部**已满足,故本测试首次运行就应为绿。
  37. /// 若它变红,几乎一定是有人新增了 Job 却忘了写闸门——那台非执行机会立刻开始
  38. /// 跑全量,正是本次事故的成因。**不要通过加例外名单来让它变绿。**</para>
  39. /// </summary>
  40. [Fact]
  41. public void EveryJob_CallsGate()
  42. {
  43. var jobTypes = typeof(AidopJobGate).Assembly.GetTypes()
  44. .Where(t => t is { IsAbstract: false, IsInterface: false } && typeof(IJob).IsAssignableFrom(t))
  45. .OrderBy(t => t.Name)
  46. .ToList();
  47. Assert.True(jobTypes.Count >= 21,
  48. $"IJob 实现类只找到 {jobTypes.Count} 个,少于 2026-09-25 实测的 21 个;" +
  49. "若确属删除作业请更新此下限,若是反射失效请先查清");
  50. var missing = new List<string>();
  51. foreach (var t in jobTypes)
  52. {
  53. var src = TryReadSource(t);
  54. Assert.True(src != null, $"找不到 {t.Name} 的源码文件,测试的路径推断需同步更新");
  55. if (!src.Contains("AidopJobGate.ShouldRun", StringComparison.Ordinal))
  56. missing.Add(t.Name);
  57. }
  58. Assert.True(missing.Count == 0,
  59. "以下 Job 未调用 AidopJobGate.ShouldRun,非执行机上会照常跑:" + string.Join(", ", missing));
  60. }
  61. // ───────────────────── 2. 领取过滤:非执行机只领手工 ─────────────────────
  62. /// <summary>
  63. /// <c>ClaimNextQueuedAsync</c> 必须带 <c>bool runner</c> 参数。
  64. /// 有人把它删掉、或改成可选参数并默认 true,非执行机就会重新开始领 AUTO。
  65. /// </summary>
  66. [Fact]
  67. public void ClaimContract_HasRunnerParameter()
  68. {
  69. var m = typeof(IModuleRebuildJobStore).GetMethod(nameof(IModuleRebuildJobStore.ClaimNextQueuedAsync));
  70. Assert.NotNull(m);
  71. var p = m!.GetParameters().SingleOrDefault(x => x.Name == "runner");
  72. Assert.True(p != null, "ClaimNextQueuedAsync 缺少 runner 参数");
  73. Assert.Equal(typeof(bool), p!.ParameterType);
  74. Assert.False(p.IsOptional, "runner 不得是可选参数:漏传时会静默按默认值放行");
  75. }
  76. /// <summary>手工判定的规范定义。非执行机只能领这一类。</summary>
  77. [Theory]
  78. [InlineData("MANUAL", null, true)]
  79. [InlineData("manual", null, true)] // 大小写不敏感
  80. [InlineData("AUTO", null, false)]
  81. [InlineData("BOOTSTRAP", null, false)]
  82. [InlineData("AUTO_NIGHTLY", null, false)] // 工作包 C 将新增,必须天然落在白名单外
  83. [InlineData("AUTO", 1001L, true)] // 带 requested_by 即视为人工触发
  84. public void IsManualTrigger_Semantics(string triggerType, long? requestedBy, bool expected)
  85. {
  86. Assert.Equal(expected, ModuleRebuildTriggerType.IsManualTrigger(triggerType, requestedBy));
  87. }
  88. /// <summary>
  89. /// 规范定义必须真的落到 SQL 上。表达式树要翻译成 SQL,无法直接复用
  90. /// <c>IsManualTrigger</c>,故这里扫源码确认两边没有走散。
  91. /// </summary>
  92. [Fact]
  93. public void ClaimQuery_AppliesManualWhitelistForNonRunner()
  94. {
  95. var src = ReadPluginSource("DataPlatform/MdpRebuild/ModuleRebuildStore.cs");
  96. Assert.Contains("WhereIF(!runner", src, StringComparison.Ordinal);
  97. Assert.Contains("ModuleRebuildTriggerType.Manual", src, StringComparison.Ordinal);
  98. Assert.Contains("x.RequestedBy != null", src, StringComparison.Ordinal);
  99. // 黑名单写法会在新增 AUTO_NIGHTLY 之类触发器时漏网,明令禁止
  100. Assert.DoesNotContain("!= ModuleRebuildTriggerType.Auto", src, StringComparison.Ordinal);
  101. }
  102. // ───────────────────── 3. 指派唯一性由写入侧保证 ─────────────────────
  103. /// <summary>
  104. /// 「最多一台执行机」靠 <c>AssignRunnerAsync</c> 在同一事务内先清后置。
  105. /// MySQL 无部分索引,数据库层面表达不了这条约束;拆成两条自动提交语句,
  106. /// 中间那一瞬会出现零台或(并发时)两台执行机。
  107. /// </summary>
  108. [Fact]
  109. public void AssignRunner_ClearsOthersInSameTransaction()
  110. {
  111. Assert.NotNull(typeof(IEtlInstanceStore).GetMethod(nameof(IEtlInstanceStore.AssignRunnerAsync)));
  112. var src = ReadPluginSource("Infrastructure/EtlInstanceStore.cs");
  113. Assert.Contains("UseTranAsync", src, StringComparison.Ordinal);
  114. var tranIdx = src.IndexOf("UseTranAsync", StringComparison.Ordinal);
  115. var clearIdx = src.IndexOf("x.IsRunner && x.InstanceId != id", StringComparison.Ordinal);
  116. var setIdx = src.IndexOf("IsRunner = true", StringComparison.Ordinal);
  117. Assert.True(clearIdx > tranIdx, "清空其余指派必须发生在事务内");
  118. Assert.True(setIdx > clearIdx, "必须先清后置,否则会短暂出现两台执行机");
  119. }
  120. /// <summary>注册器只报到、不自荐。自动选举已在 B-D1 明确否决。</summary>
  121. [Fact]
  122. public void Registrar_NeverSelfAssigns()
  123. {
  124. var src = ReadPluginSource("Infrastructure/EtlInstanceRegistrar.cs");
  125. Assert.Contains("IsRunner = false", src, StringComparison.Ordinal);
  126. Assert.DoesNotContain("IsRunner = true", src, StringComparison.Ordinal);
  127. }
  128. // ───────────────────── 4. 指派未知时回退到配置 ─────────────────────
  129. [Fact]
  130. public void IsRunner_UsesAssignment_WhenFresh()
  131. {
  132. // 配置回退方向刻意设成相反值,确保断言命中的是指派而不是回退
  133. Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, "false");
  134. AidopRunnerState.Publish(true);
  135. Assert.True(AidopJobGate.IsRunner);
  136. Assert.True(AidopJobGate.IsAssignmentKnown);
  137. AidopRunnerState.Publish(false);
  138. Assert.False(AidopJobGate.IsRunner);
  139. Assert.True(AidopJobGate.IsAssignmentKnown);
  140. }
  141. [Fact]
  142. public void IsRunner_FallsBackToConfig_WhenAssignmentUnknown()
  143. {
  144. AidopRunnerState.Invalidate();
  145. Assert.False(AidopJobGate.IsAssignmentKnown);
  146. Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, "true");
  147. Assert.True(AidopJobGate.IsRunner);
  148. Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, "false");
  149. Assert.False(AidopJobGate.IsRunner);
  150. }
  151. /// <summary>
  152. /// 指派未知且配置缺失时不跑。
  153. /// <see cref="AidopJobGate.DefaultEnabled"/> 曾是 true(「别静默停摆」),
  154. /// 在人工指派模型下改成了 false:没指派就不跑是**预期行为**。
  155. /// 改回 true 会让任意一台新起的进程擅自跑全量。
  156. /// </summary>
  157. [Fact]
  158. public void DefaultIsOff_WhenNothingConfigured()
  159. {
  160. Assert.False(AidopJobGate.DefaultEnabled);
  161. AidopRunnerState.Invalidate();
  162. Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, null);
  163. // 宿主未就绪时 Furion.App.GetConfig 会抛,闸门内部吞掉并落到 DefaultEnabled
  164. Assert.False(AidopJobGate.IsRunner);
  165. }
  166. /// <summary>查库失败必须立即打回未知,不得沿用上一次的指派值。</summary>
  167. [Fact]
  168. public void Invalidate_DropsPreviousAssignment()
  169. {
  170. Environment.SetEnvironmentVariable(AidopJobGate.EnvEnabledName, "false");
  171. AidopRunnerState.Publish(true);
  172. Assert.True(AidopJobGate.IsRunner);
  173. // 指派可能已被运维改掉,继续沿用会让两台实例同时自认执行机
  174. AidopRunnerState.Invalidate();
  175. Assert.False(AidopJobGate.IsAssignmentKnown);
  176. Assert.False(AidopJobGate.IsRunner);
  177. }
  178. [Fact]
  179. public void FreshnessWindow_CoversSeveralHeartbeats()
  180. {
  181. // 窗口需明显长于心跳周期,否则偶发一次心跳失败就会退化为配置回退
  182. Assert.True(AidopRunnerState.FreshnessWindow >= EtlInstanceRegistrar.HeartbeatInterval * 2,
  183. "新鲜度窗口至少要容纳两次心跳");
  184. }
  185. // ───────────────────── 源码定位 ─────────────────────
  186. private static string PluginRoot =>
  187. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  188. private static string ReadPluginSource(string relativePath)
  189. {
  190. var full = Path.Combine(PluginRoot, relativePath);
  191. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  192. return File.ReadAllText(full);
  193. }
  194. /// <summary>
  195. /// 按类型名在插件目录下找同名 .cs。作业类都是「一类一文件」,
  196. /// 找不到即说明命名约定被打破,此时应让测试失败而不是悄悄跳过。
  197. /// </summary>
  198. private static string TryReadSource(Type t)
  199. {
  200. var hits = Directory.GetFiles(PluginRoot, t.Name + ".cs", SearchOption.AllDirectories);
  201. return hits.Length == 0 ? null : File.ReadAllText(hits[0]);
  202. }
  203. }