| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- 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);
- }
- }
|