TransformRunLogFinalizerContractTests.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. using Admin.NET.Plugin.AiDOP.Infrastructure;
  2. using Microsoft.Extensions.Hosting;
  3. using Xunit;
  4. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  5. /// <summary>
  6. /// <c>mdp_transform_run_log</c> 关停收口的契约。
  7. ///
  8. /// <para><b>要守什么</b>:每个 transform producer 都先 <c>INSERT</c> 一行 <c>RUNNING</c> 再进 try。
  9. /// 宿主关停时这一行必须落到一个**能与失败区分开**的终态,否则两件事同时坏掉:
  10. /// ① <c>RUNNING + end_time IS NULL</c> 会被 S8 Authority 当成「有更新的一轮在飞」,
  11. /// 把该 (job_code, tenant_id) 的健康判定长期按住;
  12. /// ② 若图省事记成 <c>FAILED</c>,"转换失败"这个信号就被关停噪声稀释,
  13. /// 真出问题时没人再信那个计数。</para>
  14. ///
  15. /// <para><b>为什么要有反回归段</b>:本仓有 17 个 producer、39 处 run-log 生命周期,
  16. /// 且还在长出新的。靠"记得接线"守不住 —— 所以段③直接扫源码,
  17. /// 任何新增的 producer 只要写了 <c>INSERT ... RUNNING</c> 却没接收口器,这里就会红。</para>
  18. ///
  19. /// <para><b>覆盖边界</b>:真正的 <c>UPDATE</c> 需要库,测试环境没有。故本文件守三样**不需要库**的东西:
  20. /// ① 收口语句的结构不变量(条件 UPDATE 的三个条件缺一不可);
  21. /// ② 不需要碰库就能判定的分支(未关停 / 非法 id 一律不写库);
  22. /// ③ 全量 producer 的接线覆盖。</para>
  23. /// </summary>
  24. public class TransformRunLogFinalizerContractTests
  25. {
  26. // ══════════════════ 段①:收口语句的结构不变量 ══════════════════
  27. /// <summary>
  28. /// <c>WHERE</c> 必须同时带 <c>status='RUNNING'</c> 与 <c>end_time IS NULL</c>。
  29. /// 少任何一个,都会在「正常跑完的一轮」上生效 —— 把 SUCCESS 改写成 ABORTED。
  30. /// 这是本次改动最危险的失手方式,且一旦发生静默无声。
  31. /// </summary>
  32. [Fact]
  33. public void FinalizeSql_OnlyTouchesRowsThatNeverReachedTerminalState()
  34. {
  35. var sql = TransformRunLogFinalizer.FinalizeSql;
  36. var where = sql[sql.IndexOf("WHERE", StringComparison.Ordinal)..];
  37. Assert.Contains("id=@Id", where);
  38. Assert.Contains("status='RUNNING'", where);
  39. Assert.Contains("end_time IS NULL", where);
  40. }
  41. /// <summary>
  42. /// 必须是按主键的单行 UPDATE。没有 <c>id=@Id</c> 的收口语句会横扫整表,
  43. /// 把别的租户、别的 job 正在跑的行一起收口掉。
  44. /// </summary>
  45. [Fact]
  46. public void FinalizeSql_IsSingleRowByPrimaryKey()
  47. {
  48. var sql = TransformRunLogFinalizer.FinalizeSql;
  49. Assert.Contains("UPDATE mdp_transform_run_log", sql);
  50. Assert.Contains("id=@Id", sql);
  51. Assert.DoesNotContain("job_code", sql);
  52. Assert.DoesNotContain("start_time <", sql);
  53. }
  54. /// <summary>
  55. /// 必须写 <c>end_time</c>。这不只是补个字段:S8 Authority 的 in-flight 判据就是
  56. /// <c>end_time IS NULL</c>,不写 end_time 等于没收口,Authority 照旧被按住。
  57. /// </summary>
  58. [Fact]
  59. public void FinalizeSql_WritesEndTime_SoAuthorityNoLongerSeesItInFlight()
  60. {
  61. Assert.Contains("end_time=@EndTime", TransformRunLogFinalizer.FinalizeSql);
  62. }
  63. /// <summary>
  64. /// <c>ABORTED</c> 必须与既有取值都不相同。实库现存 6 种取值
  65. /// (RUNNING / SUCCESS / SUCCESS_WITH_WARNING / SKIPPED_NO_CHANGE / FAILED / CANCELED),
  66. /// 其中 <c>CANCELED</c> 已被 S0Dim 占用为业务级取消 —— 复用它会让
  67. /// 「基础设施关停」与「调用方主动放弃」永久混在一起,事后无法分开。
  68. /// </summary>
  69. [Fact]
  70. public void AbortedStatus_DoesNotCollideWithExistingVocabulary()
  71. {
  72. string[] existing = ["RUNNING", "SUCCESS", "SUCCESS_WITH_WARNING", "SKIPPED_NO_CHANGE", "FAILED", "CANCELED"];
  73. Assert.DoesNotContain(TransformRunLogFinalizer.StatusAborted, existing);
  74. Assert.Equal("ABORTED", TransformRunLogFinalizer.StatusAborted);
  75. Assert.Equal("HOST_SHUTDOWN", TransformRunLogFinalizer.ReasonHostShutdown);
  76. }
  77. /// <summary>
  78. /// 取值必须放得进 <c>status varchar(30)</c>,否则写入会被截断或报错。
  79. /// </summary>
  80. [Fact]
  81. public void AbortedStatus_FitsStatusColumn()
  82. {
  83. Assert.True(TransformRunLogFinalizer.StatusAborted.Length <= 30);
  84. }
  85. /// <summary>
  86. /// 收口必须走 <c>CopyNew()</c> 的新连接,并显式清掉 <c>Ado.CancellationToken</c>。
  87. ///
  88. /// <para><b>这条是实测换来的,不是防御性写法</b>:首版实现只是"不传 token",
  89. /// 冒烟时 <c>IsHostStopping</c> 为真、<c>runLogId</c> 也对,收口 UPDATE 却抛
  90. /// <c>OperationCanceledException</c>,一行没落。原因是 SqlSugar 把 token 挂在 Ado provider 上,
  91. /// 转换过程中传过 token 的调用会把它留在客户端实例里;关停时它已取消,
  92. /// 于是后续每条 SQL 都被连坐。谁把这两句删掉,收口就会**静默失效** ——
  93. /// 没有异常、没有报错,只是又开始攒孤儿。
  94. /// </para>
  95. /// </summary>
  96. [Fact]
  97. public void Finalize_RunsOnAFreshConnection_NotTheCallersCancelledOne()
  98. {
  99. var src = File.ReadAllText(Path.Combine(
  100. PluginDir().FullName, "Infrastructure", "TransformRunLogFinalizer.cs"));
  101. var body = src[src.IndexOf("public async Task<bool> FinalizeIfHostStoppingAsync", StringComparison.Ordinal)..];
  102. Assert.Contains("CopyNew()", body);
  103. Assert.Contains("Ado.CancellationToken = null", body);
  104. Assert.DoesNotContain("await _db.Ado.ExecuteCommandAsync", body);
  105. }
  106. // ══════════════════ 段②:不碰库就能定下来的分支 ══════════════════
  107. private sealed class FakeLifetime : IHostApplicationLifetime
  108. {
  109. private readonly CancellationTokenSource _stopping = new();
  110. public CancellationToken ApplicationStarted => CancellationToken.None;
  111. public CancellationToken ApplicationStopping => _stopping.Token;
  112. public CancellationToken ApplicationStopped => CancellationToken.None;
  113. public void StopApplication() => _stopping.Cancel();
  114. }
  115. /// <summary>
  116. /// <c>db</c> 传 <see langword="null"/> 是**刻意**的:这些分支一旦碰库就会 NRE。
  117. /// 用它来证明「没关停就绝不写库」,比断言一个 mock 的调用次数更硬。
  118. /// </summary>
  119. private static TransformRunLogFinalizer Finalizer(IHostApplicationLifetime lifetime) =>
  120. new(null!, lifetime, Microsoft.Extensions.Logging.Abstractions.NullLogger<TransformRunLogFinalizer>.Instance);
  121. [Fact]
  122. public void IsHostStopping_TracksApplicationStopping_NotAnyCancellationToken()
  123. {
  124. var lifetime = new FakeLifetime();
  125. var finalizer = Finalizer(lifetime);
  126. Assert.False(finalizer.IsHostStopping);
  127. lifetime.StopApplication();
  128. Assert.True(finalizer.IsHostStopping);
  129. }
  130. /// <summary>
  131. /// 宿主没关停 → 一行都不许改。客户端断开、业务超时、普通异常都走这条路,
  132. /// 它们各自已有自己的终态,收口器不得插手。
  133. /// </summary>
  134. [Fact]
  135. public async Task Finalize_DoesNothing_WhenHostIsNotStopping()
  136. {
  137. var finalizer = Finalizer(new FakeLifetime());
  138. Assert.False(await finalizer.FinalizeIfHostStoppingAsync(12345L, DateTime.Now));
  139. }
  140. /// <summary>
  141. /// 没有有效 runLogId(INSERT 还没成功就被关停)时直接返回,不得拿 0 / 负数去 UPDATE。
  142. /// </summary>
  143. [Theory]
  144. [InlineData(0L)]
  145. [InlineData(-1L)]
  146. public async Task Finalize_DoesNothing_WhenRunLogIdIsNotUsable(long runLogId)
  147. {
  148. var lifetime = new FakeLifetime();
  149. lifetime.StopApplication();
  150. Assert.False(await Finalizer(lifetime).FinalizeIfHostStoppingAsync(runLogId, DateTime.Now));
  151. }
  152. /// <summary>
  153. /// 收口器**永不抛异常**。它总是从 finally / catch 里被调用,
  154. /// 一旦抛出就会顶掉真正的业务异常,让「转换为什么断了」永久丢失。
  155. /// 这里让它在关停状态下拿 null 的 db 去写库 —— 内部必然出错,但对外必须是 false。
  156. /// </summary>
  157. [Fact]
  158. public async Task Finalize_SwallowsItsOwnFailure_SoItNeverMasksTheOriginalException()
  159. {
  160. var lifetime = new FakeLifetime();
  161. lifetime.StopApplication();
  162. var result = await Finalizer(lifetime).FinalizeIfHostStoppingAsync(12345L, DateTime.Now);
  163. Assert.False(result);
  164. }
  165. // ══════════════════ 段③:全量 producer 接线的反回归 ══════════════════
  166. private static DirectoryInfo PluginDir()
  167. {
  168. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  169. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
  170. dir = dir.Parent;
  171. Assert.NotNull(dir);
  172. return new DirectoryInfo(Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP"));
  173. }
  174. /// <summary>
  175. /// 凡是往 <c>mdp_transform_run_log</c> 写 <c>RUNNING</c> 的文件,都必须接了收口器。
  176. ///
  177. /// <para>这条是本文件存在的主要理由:接线是 39 处的机械改动,
  178. /// 将来新增 producer 的人几乎一定不会读到本批的结论。让编译器管不着的事由测试管。</para>
  179. /// </summary>
  180. [Fact]
  181. public void EveryProducerThatOpensARunLog_IsWiredToTheFinalizer()
  182. {
  183. var missing = new List<string>();
  184. var wired = 0;
  185. foreach (var file in PluginDir().GetFiles("*.cs", SearchOption.AllDirectories))
  186. {
  187. var text = File.ReadAllText(file.FullName);
  188. if (!text.Contains("INSERT INTO mdp_transform_run_log", StringComparison.Ordinal)) continue;
  189. if (text.Contains("_runLogFinalizer", StringComparison.Ordinal)) wired++;
  190. else missing.Add(file.Name);
  191. }
  192. Assert.True(wired > 0, "一个 producer 都没扫到,说明扫描口径已失效(INSERT 写法或表名变了)");
  193. Assert.Empty(missing);
  194. }
  195. /// <summary>
  196. /// 每一处 <c>MarkXxxRunFailedAsync</c> 都必须被 <c>IsHostStopping</c> 挡过。
  197. /// 漏掉一处,那条路径上的关停就会继续被记成 FAILED —— 而这正是本批要消除的语义污染。
  198. /// </summary>
  199. [Fact]
  200. public void EveryFailureMarking_IsGuardedAgainstHostShutdown()
  201. {
  202. var unguarded = new List<string>();
  203. foreach (var file in PluginDir().GetFiles("*.cs", SearchOption.AllDirectories))
  204. {
  205. var lines = File.ReadAllLines(file.FullName);
  206. for (var i = 0; i < lines.Length; i++)
  207. {
  208. var l = lines[i];
  209. var isFailureCall =
  210. l.Contains("await Mark", StringComparison.Ordinal) && l.Contains("RunFailedAsync(", StringComparison.Ordinal)
  211. || l.Contains("await FailRunLogAsync(", StringComparison.Ordinal);
  212. if (!isFailureCall) continue;
  213. // 守卫就在上一行;private 方法自身的定义不算调用点。
  214. if (i > 0 && lines[i - 1].Contains("!_runLogFinalizer.IsHostStopping", StringComparison.Ordinal)) continue;
  215. unguarded.Add($"{file.Name}:{i + 1}");
  216. }
  217. }
  218. Assert.Empty(unguarded);
  219. }
  220. }