|
@@ -0,0 +1,253 @@
|
|
|
|
|
+using Admin.NET.Plugin.AiDOP.Infrastructure;
|
|
|
|
|
+using Microsoft.Extensions.Hosting;
|
|
|
|
|
+using Xunit;
|
|
|
|
|
+
|
|
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// <c>mdp_transform_run_log</c> 关停收口的契约。
|
|
|
|
|
+///
|
|
|
|
|
+/// <para><b>要守什么</b>:每个 transform producer 都先 <c>INSERT</c> 一行 <c>RUNNING</c> 再进 try。
|
|
|
|
|
+/// 宿主关停时这一行必须落到一个**能与失败区分开**的终态,否则两件事同时坏掉:
|
|
|
|
|
+/// ① <c>RUNNING + end_time IS NULL</c> 会被 S8 Authority 当成「有更新的一轮在飞」,
|
|
|
|
|
+/// 把该 (job_code, tenant_id) 的健康判定长期按住;
|
|
|
|
|
+/// ② 若图省事记成 <c>FAILED</c>,"转换失败"这个信号就被关停噪声稀释,
|
|
|
|
|
+/// 真出问题时没人再信那个计数。</para>
|
|
|
|
|
+///
|
|
|
|
|
+/// <para><b>为什么要有反回归段</b>:本仓有 17 个 producer、39 处 run-log 生命周期,
|
|
|
|
|
+/// 且还在长出新的。靠"记得接线"守不住 —— 所以段③直接扫源码,
|
|
|
|
|
+/// 任何新增的 producer 只要写了 <c>INSERT ... RUNNING</c> 却没接收口器,这里就会红。</para>
|
|
|
|
|
+///
|
|
|
|
|
+/// <para><b>覆盖边界</b>:真正的 <c>UPDATE</c> 需要库,测试环境没有。故本文件守三样**不需要库**的东西:
|
|
|
|
|
+/// ① 收口语句的结构不变量(条件 UPDATE 的三个条件缺一不可);
|
|
|
|
|
+/// ② 不需要碰库就能判定的分支(未关停 / 非法 id 一律不写库);
|
|
|
|
|
+/// ③ 全量 producer 的接线覆盖。</para>
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public class TransformRunLogFinalizerContractTests
|
|
|
|
|
+{
|
|
|
|
|
+ // ══════════════════ 段①:收口语句的结构不变量 ══════════════════
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// <c>WHERE</c> 必须同时带 <c>status='RUNNING'</c> 与 <c>end_time IS NULL</c>。
|
|
|
|
|
+ /// 少任何一个,都会在「正常跑完的一轮」上生效 —— 把 SUCCESS 改写成 ABORTED。
|
|
|
|
|
+ /// 这是本次改动最危险的失手方式,且一旦发生静默无声。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void FinalizeSql_OnlyTouchesRowsThatNeverReachedTerminalState()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = TransformRunLogFinalizer.FinalizeSql;
|
|
|
|
|
+ var where = sql[sql.IndexOf("WHERE", StringComparison.Ordinal)..];
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Contains("id=@Id", where);
|
|
|
|
|
+ Assert.Contains("status='RUNNING'", where);
|
|
|
|
|
+ Assert.Contains("end_time IS NULL", where);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 必须是按主键的单行 UPDATE。没有 <c>id=@Id</c> 的收口语句会横扫整表,
|
|
|
|
|
+ /// 把别的租户、别的 job 正在跑的行一起收口掉。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void FinalizeSql_IsSingleRowByPrimaryKey()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = TransformRunLogFinalizer.FinalizeSql;
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Contains("UPDATE mdp_transform_run_log", sql);
|
|
|
|
|
+ Assert.Contains("id=@Id", sql);
|
|
|
|
|
+ Assert.DoesNotContain("job_code", sql);
|
|
|
|
|
+ Assert.DoesNotContain("start_time <", sql);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 必须写 <c>end_time</c>。这不只是补个字段:S8 Authority 的 in-flight 判据就是
|
|
|
|
|
+ /// <c>end_time IS NULL</c>,不写 end_time 等于没收口,Authority 照旧被按住。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void FinalizeSql_WritesEndTime_SoAuthorityNoLongerSeesItInFlight()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.Contains("end_time=@EndTime", TransformRunLogFinalizer.FinalizeSql);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// <c>ABORTED</c> 必须与既有取值都不相同。实库现存 6 种取值
|
|
|
|
|
+ /// (RUNNING / SUCCESS / SUCCESS_WITH_WARNING / SKIPPED_NO_CHANGE / FAILED / CANCELED),
|
|
|
|
|
+ /// 其中 <c>CANCELED</c> 已被 S0Dim 占用为业务级取消 —— 复用它会让
|
|
|
|
|
+ /// 「基础设施关停」与「调用方主动放弃」永久混在一起,事后无法分开。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void AbortedStatus_DoesNotCollideWithExistingVocabulary()
|
|
|
|
|
+ {
|
|
|
|
|
+ string[] existing = ["RUNNING", "SUCCESS", "SUCCESS_WITH_WARNING", "SKIPPED_NO_CHANGE", "FAILED", "CANCELED"];
|
|
|
|
|
+
|
|
|
|
|
+ Assert.DoesNotContain(TransformRunLogFinalizer.StatusAborted, existing);
|
|
|
|
|
+ Assert.Equal("ABORTED", TransformRunLogFinalizer.StatusAborted);
|
|
|
|
|
+ Assert.Equal("HOST_SHUTDOWN", TransformRunLogFinalizer.ReasonHostShutdown);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 取值必须放得进 <c>status varchar(30)</c>,否则写入会被截断或报错。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void AbortedStatus_FitsStatusColumn()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.True(TransformRunLogFinalizer.StatusAborted.Length <= 30);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 收口必须走 <c>CopyNew()</c> 的新连接,并显式清掉 <c>Ado.CancellationToken</c>。
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// <para><b>这条是实测换来的,不是防御性写法</b>:首版实现只是"不传 token",
|
|
|
|
|
+ /// 冒烟时 <c>IsHostStopping</c> 为真、<c>runLogId</c> 也对,收口 UPDATE 却抛
|
|
|
|
|
+ /// <c>OperationCanceledException</c>,一行没落。原因是 SqlSugar 把 token 挂在 Ado provider 上,
|
|
|
|
|
+ /// 转换过程中传过 token 的调用会把它留在客户端实例里;关停时它已取消,
|
|
|
|
|
+ /// 于是后续每条 SQL 都被连坐。谁把这两句删掉,收口就会**静默失效** ——
|
|
|
|
|
+ /// 没有异常、没有报错,只是又开始攒孤儿。
|
|
|
|
|
+ /// </para>
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Finalize_RunsOnAFreshConnection_NotTheCallersCancelledOne()
|
|
|
|
|
+ {
|
|
|
|
|
+ var src = File.ReadAllText(Path.Combine(
|
|
|
|
|
+ PluginDir().FullName, "Infrastructure", "TransformRunLogFinalizer.cs"));
|
|
|
|
|
+ var body = src[src.IndexOf("public async Task<bool> FinalizeIfHostStoppingAsync", StringComparison.Ordinal)..];
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Contains("CopyNew()", body);
|
|
|
|
|
+ Assert.Contains("Ado.CancellationToken = null", body);
|
|
|
|
|
+ Assert.DoesNotContain("await _db.Ado.ExecuteCommandAsync", body);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ══════════════════ 段②:不碰库就能定下来的分支 ══════════════════
|
|
|
|
|
+
|
|
|
|
|
+ private sealed class FakeLifetime : IHostApplicationLifetime
|
|
|
|
|
+ {
|
|
|
|
|
+ private readonly CancellationTokenSource _stopping = new();
|
|
|
|
|
+ public CancellationToken ApplicationStarted => CancellationToken.None;
|
|
|
|
|
+ public CancellationToken ApplicationStopping => _stopping.Token;
|
|
|
|
|
+ public CancellationToken ApplicationStopped => CancellationToken.None;
|
|
|
|
|
+ public void StopApplication() => _stopping.Cancel();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// <c>db</c> 传 <see langword="null"/> 是**刻意**的:这些分支一旦碰库就会 NRE。
|
|
|
|
|
+ /// 用它来证明「没关停就绝不写库」,比断言一个 mock 的调用次数更硬。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ private static TransformRunLogFinalizer Finalizer(IHostApplicationLifetime lifetime) =>
|
|
|
|
|
+ new(null!, lifetime, Microsoft.Extensions.Logging.Abstractions.NullLogger<TransformRunLogFinalizer>.Instance);
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void IsHostStopping_TracksApplicationStopping_NotAnyCancellationToken()
|
|
|
|
|
+ {
|
|
|
|
|
+ var lifetime = new FakeLifetime();
|
|
|
|
|
+ var finalizer = Finalizer(lifetime);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.False(finalizer.IsHostStopping);
|
|
|
|
|
+ lifetime.StopApplication();
|
|
|
|
|
+ Assert.True(finalizer.IsHostStopping);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 宿主没关停 → 一行都不许改。客户端断开、业务超时、普通异常都走这条路,
|
|
|
|
|
+ /// 它们各自已有自己的终态,收口器不得插手。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public async Task Finalize_DoesNothing_WhenHostIsNotStopping()
|
|
|
|
|
+ {
|
|
|
|
|
+ var finalizer = Finalizer(new FakeLifetime());
|
|
|
|
|
+
|
|
|
|
|
+ Assert.False(await finalizer.FinalizeIfHostStoppingAsync(12345L, DateTime.Now));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 没有有效 runLogId(INSERT 还没成功就被关停)时直接返回,不得拿 0 / 负数去 UPDATE。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Theory]
|
|
|
|
|
+ [InlineData(0L)]
|
|
|
|
|
+ [InlineData(-1L)]
|
|
|
|
|
+ public async Task Finalize_DoesNothing_WhenRunLogIdIsNotUsable(long runLogId)
|
|
|
|
|
+ {
|
|
|
|
|
+ var lifetime = new FakeLifetime();
|
|
|
|
|
+ lifetime.StopApplication();
|
|
|
|
|
+
|
|
|
|
|
+ Assert.False(await Finalizer(lifetime).FinalizeIfHostStoppingAsync(runLogId, DateTime.Now));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 收口器**永不抛异常**。它总是从 finally / catch 里被调用,
|
|
|
|
|
+ /// 一旦抛出就会顶掉真正的业务异常,让「转换为什么断了」永久丢失。
|
|
|
|
|
+ /// 这里让它在关停状态下拿 null 的 db 去写库 —— 内部必然出错,但对外必须是 false。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public async Task Finalize_SwallowsItsOwnFailure_SoItNeverMasksTheOriginalException()
|
|
|
|
|
+ {
|
|
|
|
|
+ var lifetime = new FakeLifetime();
|
|
|
|
|
+ lifetime.StopApplication();
|
|
|
|
|
+
|
|
|
|
|
+ var result = await Finalizer(lifetime).FinalizeIfHostStoppingAsync(12345L, DateTime.Now);
|
|
|
|
|
+
|
|
|
|
|
+ Assert.False(result);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ══════════════════ 段③:全量 producer 接线的反回归 ══════════════════
|
|
|
|
|
+
|
|
|
|
|
+ private static DirectoryInfo PluginDir()
|
|
|
|
|
+ {
|
|
|
|
|
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
|
|
|
+ while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
|
|
|
|
|
+ dir = dir.Parent;
|
|
|
|
|
+ Assert.NotNull(dir);
|
|
|
|
|
+ return new DirectoryInfo(Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 凡是往 <c>mdp_transform_run_log</c> 写 <c>RUNNING</c> 的文件,都必须接了收口器。
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// <para>这条是本文件存在的主要理由:接线是 39 处的机械改动,
|
|
|
|
|
+ /// 将来新增 producer 的人几乎一定不会读到本批的结论。让编译器管不着的事由测试管。</para>
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void EveryProducerThatOpensARunLog_IsWiredToTheFinalizer()
|
|
|
|
|
+ {
|
|
|
|
|
+ var missing = new List<string>();
|
|
|
|
|
+ var wired = 0;
|
|
|
|
|
+
|
|
|
|
|
+ foreach (var file in PluginDir().GetFiles("*.cs", SearchOption.AllDirectories))
|
|
|
|
|
+ {
|
|
|
|
|
+ var text = File.ReadAllText(file.FullName);
|
|
|
|
|
+ if (!text.Contains("INSERT INTO mdp_transform_run_log", StringComparison.Ordinal)) continue;
|
|
|
|
|
+ if (text.Contains("_runLogFinalizer", StringComparison.Ordinal)) wired++;
|
|
|
|
|
+ else missing.Add(file.Name);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Assert.True(wired > 0, "一个 producer 都没扫到,说明扫描口径已失效(INSERT 写法或表名变了)");
|
|
|
|
|
+ Assert.Empty(missing);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 每一处 <c>MarkXxxRunFailedAsync</c> 都必须被 <c>IsHostStopping</c> 挡过。
|
|
|
|
|
+ /// 漏掉一处,那条路径上的关停就会继续被记成 FAILED —— 而这正是本批要消除的语义污染。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void EveryFailureMarking_IsGuardedAgainstHostShutdown()
|
|
|
|
|
+ {
|
|
|
|
|
+ var unguarded = new List<string>();
|
|
|
|
|
+
|
|
|
|
|
+ foreach (var file in PluginDir().GetFiles("*.cs", SearchOption.AllDirectories))
|
|
|
|
|
+ {
|
|
|
|
|
+ var lines = File.ReadAllLines(file.FullName);
|
|
|
|
|
+ for (var i = 0; i < lines.Length; i++)
|
|
|
|
|
+ {
|
|
|
|
|
+ var l = lines[i];
|
|
|
|
|
+ var isFailureCall =
|
|
|
|
|
+ l.Contains("await Mark", StringComparison.Ordinal) && l.Contains("RunFailedAsync(", StringComparison.Ordinal)
|
|
|
|
|
+ || l.Contains("await FailRunLogAsync(", StringComparison.Ordinal);
|
|
|
|
|
+ if (!isFailureCall) continue;
|
|
|
|
|
+
|
|
|
|
|
+ // 守卫就在上一行;private 方法自身的定义不算调用点。
|
|
|
|
|
+ if (i > 0 && lines[i - 1].Contains("!_runLogFinalizer.IsHostStopping", StringComparison.Ordinal)) continue;
|
|
|
|
|
+ unguarded.Add($"{file.Name}:{i + 1}");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Empty(unguarded);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|