Browse Source

fix(s8): ignore superseded transform runs in authority health | server 1.0.557

YY968XX 4 days ago
parent
commit
69c46a66c3

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.556</AssemblyVersion>
-    <FileVersion>1.0.556</FileVersion>
-    <Version>1.0.556</Version>
+    <AssemblyVersion>1.0.557</AssemblyVersion>
+    <FileVersion>1.0.557</FileVersion>
+    <Version>1.0.557</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 247 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8AuthorityInFlightOrphanFilterTests.cs

@@ -0,0 +1,247 @@
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Health;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8 Authority 健康判定的「in-flight 判据」契约。
+///
+/// <para><b>要守的是什么</b>:生产侧写 <c>mdp_transform_run_log</c> 的形态是
+/// 「<c>INSERT RUNNING</c> → <c>try { UPDATE SUCCESS } catch { UPDATE FAILED }</c>」,<b>没有 finally</b>。
+/// 进程被 SIGTERM / kill / 宿主终止时没有异常投递到托管层,那一行就永远停在 <c>end_time IS NULL</c>。
+/// 实测全库近 400 条这样的历史残留、最早回溯一个多月、横跨八个 job family。
+/// 若健康判定把它们全当「当前尚在执行」,Authority 会被长期误判为 Degraded。</para>
+///
+/// <para><b>但反方向同样致命</b>:如果为了让健康变绿就把所有 <c>RUNNING</c> 都过滤掉,
+/// 一次真正卡住的生产运行将再也不会被拦截 —— 而现有 schema 没有
+/// instance_id / pid / heartbeat,<b>根本无法证明进程已死</b>。
+/// 所以判据只能是「已被同 job+tenant 的更晚终态执行覆盖」这一条**可证明**的事实,
+/// 绝不能退化成按时间猜(近 30 天正常长任务实测可达 7583 秒)。</para>
+///
+/// <para><b>覆盖边界说明</b>:抑制判据本身写在 <see cref="S8MdpAuthorityHealthResolver"/> 的裸 SQL 里,
+/// 无库不可执行,故本文件分两段守:
+/// ① SQL 的<b>结构不变量</b>(tenant 必须参与关联、必须严格 <c>&gt;</c>、终态口径必须是 <c>end_time IS NOT NULL</c>)——
+///    针对的正是最容易写错、且写错后静默的三处;
+/// ② 判据产出之后的<b>下游后果</b>(有/无 in-flight 分别得到什么 State 与 ReasonCode)——
+///    走纯函数 <see cref="S8AuthorityHealthEvaluator"/>,真断言最终判定,不是断言字符串。</para>
+/// </summary>
+public class S8AuthorityInFlightOrphanFilterTests
+{
+    private const long TenantA = 838257186181189L;
+    private const long TenantB = 797403760988229L;
+    private const string Dataset = "ORDER_DELIVERY_RISK";
+
+    private static readonly DateTime Now = new(2026, 9, 18, 12, 0, 0);
+    private static readonly TimeSpan Stale = TimeSpan.FromHours(1);
+
+    // ══════════════════ 段①:抑制判据的 SQL 结构不变量 ══════════════════
+
+    private static string ResolverSource()
+    {
+        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);
+        var path = Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP",
+            "Service", "S8", "Rules", "Health", "S8MdpAuthorityHealthResolver.cs");
+        Assert.True(File.Exists(path), $"源码路径需同步更新:{path}");
+        return File.ReadAllText(path);
+    }
+
+    /// <summary>取 QueryNewerInFlightAsync 的 SQL 字面量,去掉 XML 文档注释(注释要点名被禁的写法,那是说明不是实现)。</summary>
+    private static string InFlightSql()
+    {
+        var code = string.Join('\n', ResolverSource().Split('\n')
+            .Where(l => !l.TrimStart().StartsWith("///", StringComparison.Ordinal)));
+        // 必须定位到「方法定义」而不是调用点 —— 文件里先出现的是 Evaluate 里的调用行。
+        var i = code.IndexOf("private async Task<DateTime?> QueryNewerInFlightAsync", StringComparison.Ordinal);
+        Assert.True(i >= 0, "未找到 QueryNewerInFlightAsync 的方法定义(签名若变更需同步本测试)");
+        var j = code.IndexOf("SqlQueryAsync", i, StringComparison.Ordinal);
+        Assert.True(j > i, "未找到该方法的 SQL 结束位置");
+        return code[i..j];
+    }
+
+    /// <summary>抑制子查询必须存在 —— 否则历史残留会全数回流成 in-flight。</summary>
+    [Fact]
+    public void 抑制子查询存在()
+    {
+        var sql = InFlightSql();
+        Assert.Contains("NOT EXISTS", sql, StringComparison.Ordinal);
+        Assert.Contains("mdp_transform_run_log later", sql, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// tenant_id 必须参与关联。
+    /// 只按 job_code 关联会让「A 租户跑完」压掉「B 租户真的卡住」—— 跨租户静默放行,
+    /// 这是本判据最危险的写错方式。
+    /// </summary>
+    [Fact]
+    public void 抑制判据必须同时匹配job与tenant()
+    {
+        var sql = InFlightSql();
+        Assert.Matches(new Regex(@"later\.job_code\s*=\s*r\.job_code"), sql);
+        Assert.Matches(new Regex(@"later\.tenant_id\s*=\s*r\.tenant_id"), sql);
+    }
+
+    /// <summary>
+    /// 必须严格 <c>&gt;</c>。用 <c>&gt;=</c> 会让同一时刻开始的另一条终态行把本行压掉,
+    /// 而「同时开始」恰恰不构成「更晚跑完」的证据。
+    /// </summary>
+    [Fact]
+    public void 更晚判定必须严格大于不得用大于等于()
+    {
+        var sql = InFlightSql();
+        Assert.Matches(new Regex(@"later\.start_time\s*>\s*r\.start_time"), sql);
+        Assert.DoesNotMatch(new Regex(@"later\.start_time\s*>=\s*r\.start_time"), sql);
+    }
+
+    /// <summary>
+    /// 「更晚那条」必须是<b>终态</b>。若漏掉这一条,另一条同样悬着的 RUNNING
+    /// 就能把更早的 RUNNING 压掉 —— 两条都没结束,却互相证明对方结束了。
+    /// 终态口径沿用本文件既有的 end_time IS NOT NULL(与 QueryNewerTerminalFailureAsync 一致)。
+    /// </summary>
+    [Fact]
+    public void 更晚那条必须已终态()
+    {
+        var sql = InFlightSql();
+        Assert.Matches(new Regex(@"later\.end_time\s+IS\s+NOT\s+NULL"), sql);
+    }
+
+    /// <summary>候选侧的原有条件一条都不能丢:未终态 + 晚于锚点 + 本 job/tenant。</summary>
+    [Fact]
+    public void 候选侧原有条件保持不变()
+    {
+        var sql = InFlightSql();
+        Assert.Matches(new Regex(@"r\.end_time\s+IS\s+NULL"), sql);
+        Assert.Matches(new Regex(@"r\.start_time\s*>\s*@Anchor"), sql);
+        Assert.Matches(new Regex(@"r\.job_code\s*=\s*@JobCode"), sql);
+        Assert.Matches(new Regex(@"r\.tenant_id\s*=\s*@TenantId"), sql);
+        Assert.Contains("MIN(r.start_time)", sql, StringComparison.Ordinal);
+    }
+
+    /// <summary>不得借本批塞入按时间猜 orphan 的逻辑(固定 timeout 会误伤 7583 秒的合法长任务)。</summary>
+    [Fact]
+    public void 不得引入时间阈值猜测()
+    {
+        var sql = InFlightSql();
+        foreach (var forbidden in new[] { "INTERVAL", "NOW()", "TIMESTAMPDIFF", "DATE_SUB" })
+            Assert.DoesNotContain(forbidden, sql, StringComparison.OrdinalIgnoreCase);
+    }
+
+    /// <summary>本批是只读判定修正:抑制逻辑里不得出现任何写操作。</summary>
+    [Fact]
+    public void 判据保持只读()
+    {
+        var sql = InFlightSql();
+        foreach (var w in new[] { "UPDATE ", "DELETE ", "INSERT ", "TRUNCATE" })
+            Assert.DoesNotContain(w, sql, StringComparison.OrdinalIgnoreCase);
+    }
+
+    // ══════════════════ 段②:判据产出之后的下游后果(纯函数真断言)══════════════════
+
+    private static S8AuthorityObservation Obs(DateTime? lastSuccessEndAt, DateTime? inFlightStartAt) => new()
+    {
+        DatasetCode = Dataset,
+        TenantId = TenantA,
+        ObservedAt = Now,
+        SpecFound = true,
+        ProducerTrusted = true,
+        LastSuccessEndAt = lastSuccessEndAt,
+        HasNewerTerminalFailure = false,
+        NewerTerminalFailureStatus = null,
+        OldestNewerInFlightStartAt = inFlightStartAt,
+        SnapshotInvariant = S8SnapshotInvariant.NotApplicable,
+        SnapshotDetail = null,
+        StaleWindow = Stale
+    };
+
+    /// <summary>
+    /// 历史残留被抑制后(判据返回 null),健康判定不得再出现 IN_FLIGHT / STUCK_RUN。
+    /// 这是本批要消掉的那个误判。
+    /// </summary>
+    [Fact]
+    public void 被抑制后不再判InFlight()
+    {
+        var r = S8AuthorityHealthEvaluator.Evaluate(
+            Obs(lastSuccessEndAt: Now.AddMinutes(-30), inFlightStartAt: null));
+
+        Assert.Equal(S8AuthorityHealthState.Healthy, r.State);
+        Assert.NotEqual(S8AuthorityHealthReason.InFlight, r.ReasonCode);
+        Assert.NotEqual(S8AuthorityHealthReason.StuckRun, r.ReasonCode);
+    }
+
+    /// <summary>
+    /// 尚无更晚终态的 RUNNING 必须继续拦截 —— 反向守卫。
+    /// 若这条挂了,说明有人为了"健康变绿"把真正在飞/卡住的运行也过滤掉了。
+    /// </summary>
+    [Fact]
+    public void 未被抑制时仍判InFlight()
+    {
+        var r = S8AuthorityHealthEvaluator.Evaluate(
+            Obs(lastSuccessEndAt: Now.AddMinutes(-30), inFlightStartAt: Now.AddMinutes(-10)));
+
+        Assert.Equal(S8AuthorityHealthState.Degraded, r.State);
+        Assert.Equal(S8AuthorityHealthReason.InFlight, r.ReasonCode);
+    }
+
+    /// <summary>StuckWindow 语义不得被本批改动:超过 StaleWindow 仍应升级为 STUCK_RUN。</summary>
+    [Fact]
+    public void Stuck窗口语义不变()
+    {
+        var r = S8AuthorityHealthEvaluator.Evaluate(
+            Obs(lastSuccessEndAt: Now.AddHours(-5), inFlightStartAt: Now.AddHours(-3)));
+
+        Assert.Equal(S8AuthorityHealthState.Degraded, r.State);
+        Assert.Equal(S8AuthorityHealthReason.StuckRun, r.ReasonCode);
+    }
+
+    /// <summary>
+    /// 锚点语义不得被本批改动。
+    ///
+    /// <para>注意这里的真实语义(写测试时我先猜错过):<b>没有任何成功运行时,评估器在分支③
+    /// 就短路成 <c>Unknown / NO_SUCCESSFUL_RUN</c>,根本走不到 in-flight 的分支⑤</b>。
+    /// 也就是说 in-flight 判据只在「已有成功、且之后出现未终态运行」时才有意义 ——
+    /// resolver 里 <c>anchor = lastSuccess ?? lookbackFrom</c> 的回退分支,
+    /// 对最终健康判定并不产生 in-flight 后果。</para>
+    ///
+    /// <para>本测试锁死这一点:无论 in-flight 传不传值,无成功时结论都必须一致。</para>
+    /// </summary>
+    [Fact]
+    public void 锚点语义不变_无成功时短路为NoSuccessfulRun()
+    {
+        var withInFlight = S8AuthorityHealthEvaluator.Evaluate(
+            Obs(lastSuccessEndAt: null, inFlightStartAt: Now.AddMinutes(-10)));
+        var withoutInFlight = S8AuthorityHealthEvaluator.Evaluate(
+            Obs(lastSuccessEndAt: null, inFlightStartAt: null));
+
+        Assert.Equal(S8AuthorityHealthState.Unknown, withInFlight.State);
+        Assert.Equal(S8AuthorityHealthReason.NoSuccessfulRun, withInFlight.ReasonCode);
+        Assert.Equal(withoutInFlight.State, withInFlight.State);
+        Assert.Equal(withoutInFlight.ReasonCode, withInFlight.ReasonCode);
+    }
+
+    /// <summary>抑制只作用于 in-flight 这一路;更晚终态失败的拦截不受影响。</summary>
+    [Fact]
+    public void 终态失败拦截不受影响()
+    {
+        var o = Obs(lastSuccessEndAt: Now.AddMinutes(-30), inFlightStartAt: null);
+        var r = S8AuthorityHealthEvaluator.Evaluate(new S8AuthorityObservation
+        {
+            DatasetCode = o.DatasetCode,
+            TenantId = TenantB,
+            ObservedAt = o.ObservedAt,
+            SpecFound = true,
+            ProducerTrusted = true,
+            LastSuccessEndAt = o.LastSuccessEndAt,
+            HasNewerTerminalFailure = true,
+            NewerTerminalFailureStatus = "FAILED",
+            OldestNewerInFlightStartAt = null,
+            SnapshotInvariant = S8SnapshotInvariant.NotApplicable,
+            StaleWindow = Stale
+        });
+
+        Assert.Equal(S8AuthorityHealthState.Degraded, r.State);
+        Assert.NotEqual(S8AuthorityHealthReason.InFlight, r.ReasonCode);
+    }
+}

+ 28 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Health/S8MdpAuthorityHealthResolver.cs

@@ -206,6 +206,26 @@ public class S8MdpAuthorityHealthResolver : IS8AuthorityHealthResolver, ITransie
     /// 锚点之后是否存在尚未终态的运行,取最早那条的开始时刻。
     /// <para><c>end_time IS NULL</c> 与 <c>status = 'RUNNING'</c> 在该表上实测完全等价(298/298 双向成立),
     /// 用前者是因为它对将来新增的非终态状态同样成立。</para>
+    ///
+    /// <para><b>排除已被后续执行取代的历史残留</b>:生产侧写 run_log 的形态是
+    /// 「<c>INSERT RUNNING</c> → <c>try { … UPDATE SUCCESS } catch { UPDATE FAILED }</c>」,
+    /// <b>没有 finally</b>。进程被 SIGTERM / kill / 宿主终止时没有异常投递到托管层,
+    /// 于是那一行永远停在 <c>end_time IS NULL</c>。实测全库有近 400 条这样的历史残留,
+    /// 最早可回溯一个多月、横跨八个 job family。</para>
+    ///
+    /// <para>判据不是「很旧」,而是<b>已被同 <c>job_code</c> + <c>tenant_id</c> 的更晚终态执行覆盖</b>:
+    /// 既然后来那一轮已经跑完,先前那条就不可能还是「当前这一轮尚在执行」——
+    /// 调度侧 <c>[JobDetail(Concurrent = false)]</c> 排除了同作业并发。
+    /// 反之,<b>尚无更晚终态的 <c>RUNNING</c> 必须保守地继续计为 in-flight</b>:
+    /// 现有 schema 没有 instance_id / pid / heartbeat,<b>无法证明进程已死</b>,
+    /// 此时判「已结束」等于凭时间猜测。绝不用固定 timeout 猜 —— 近 30 天正常长任务实测可达 7583 秒。</para>
+    ///
+    /// <para>「终态」沿用本文件既有口径 <c>end_time IS NOT NULL</c>(与
+    /// <see cref="QueryNewerTerminalFailureAsync"/> 一致),不在此新建全局状态体系。
+    /// 严格 <c>&gt;</c> 比较:时间相等不算「更晚」。</para>
+    ///
+    /// <para><b>这是只读判定的修正,不改数据</b>:历史残留行原样留在库里,
+    /// status 仍是 RUNNING,由后续独立批次决定是否回填。</para>
     /// </summary>
     private async Task<DateTime?> QueryNewerInFlightAsync(long tenantId, string jobCode, DateTime anchor)
     {
@@ -217,6 +237,14 @@ public class S8MdpAuthorityHealthResolver : IS8AuthorityHealthResolver, ITransie
               AND r.tenant_id = @TenantId
               AND r.end_time IS NULL
               AND r.start_time > @Anchor
+              AND NOT EXISTS (
+                    SELECT 1
+                    FROM mdp_transform_run_log later
+                    WHERE later.job_code = r.job_code
+                      AND later.tenant_id = r.tenant_id
+                      AND later.start_time > r.start_time
+                      AND later.end_time IS NOT NULL
+              )
             """;
 
         var rows = await _db.Ado.SqlQueryAsync<RunRow>(sql, new SugarParameter[]