Przeglądaj źródła

fix(mdp): finalize in-flight transform runs as ABORTED on host shutdown

宿主关停时,mdp_transform_run_log 里在飞的那一行没有归宿:走得完就落终态,
走不完就永远停在 RUNNING。而 RUNNING + end_time IS NULL 正是 S8 Authority
判定「有更新的一轮在飞」的依据,一条收不了口的行会把该 (job_code, tenant_id)
的健康判定长期按住。实库现存 399 条这样的残留。

新增 TransformRunLogFinalizer,接入全部 17 个 producer、39 处 run-log 生命周期:

· 判据是 IHostApplicationLifetime.ApplicationStopping,不是传进来的
  CancellationToken —— controller 上的 token 会被模型绑定到 RequestAborted,
  客户端断开时同样取消,拿它当判据会把用户关浏览器记成宿主关停。
· 收口写 ABORTED 而非 FAILED:关停不是算错,混进去会稀释「转换失败」这个信号。
  也不复用 CANCELED —— 它已被 S0DimRefreshService 占用为业务级取消。
· 条件 UPDATE(id + status='RUNNING' + end_time IS NULL)保证只改没落终态的行,
  正常跑完的一轮命中 0 行,重复调用幂等。
· 收口走 CopyNew() 的新连接并显式清 Ado.CancellationToken。这是实测教训:
  首版只做到「不传 token」,冒烟时 finalizer 确实触发了,UPDATE 却抛
  OperationCanceledException、一行没落 —— SqlSugar 把 token 挂在 Ado provider
  上,关停时它已取消,后续每条 SQL 连坐。删掉这两句收口会静默失效。
· finalizer 从不抛异常:它只在 finally / catch 里被调用,抛出会顶掉真正的业务异常。

契约测试 13 条,含两条反回归:凡写 INSERT ... RUNNING 的文件必须接收口器、
凡 MarkXxxRunFailedAsync 必须被 IsHostStopping 挡过 —— 接线是 39 处机械改动,
将来新增 producer 的人不会读到本批结论,交给测试守。

冒烟(隔离实例 5025 + SIGTERM):在飞的 S2 轮次收口为 ABORTED,
error_message=HOST_SHUTDOWN;同期 241 轮正常完成(237 SKIPPED_NO_CHANGE +
4 SUCCESS)无一被误改。

本批不清理历史残留、不动调度、不改 schema(status 已是 varchar(30))。
YY968XX 1 dzień temu
rodzic
commit
fb8bb5609f
21 zmienionych plików z 725 dodań i 64 usunięć
  1. 3 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  2. 3 3
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/ModuleRebuildPipelineGuardTests.cs
  3. 253 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/TransformRunLogFinalizerContractTests.cs
  4. 20 4
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimRefreshService.cs
  5. 28 5
      server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcMdpSyncService.cs
  6. 11 2
      server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/S7MdpSyncTransformService.cs
  7. 129 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/TransformRunLogFinalizer.cs
  8. 38 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/IpqcInspectionMdpSyncService.cs
  9. 11 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/S6MdpSyncTransformService.cs
  10. 10 2
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspectionMdpSyncService.cs
  11. 24 4
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/OutsourceIssueMdpSyncService.cs
  12. 24 4
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/ProductionIssueMdpSyncService.cs
  13. 24 4
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/ProductionReceiptMdpSyncService.cs
  14. 24 4
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/ProductionReturnMdpSyncService.cs
  15. 24 4
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/PurchaseReceiptMdpSyncService.cs
  16. 11 2
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/S5MdpSyncTransformService.cs
  17. 24 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs
  18. 17 3
      server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/S4MdpSyncTransformService.cs
  19. 18 3
      server/Plugins/Admin.NET.Plugin.AiDOP/Production/S2MdpSyncTransformService.cs
  20. 11 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseOrderCompletionMdpSyncService.cs
  21. 18 3
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs

+ 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.558</AssemblyVersion>
-    <FileVersion>1.0.558</FileVersion>
-    <Version>1.0.558</Version>
+    <AssemblyVersion>1.0.559</AssemblyVersion>
+    <FileVersion>1.0.559</FileVersion>
+    <Version>1.0.559</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 3 - 3
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/ModuleRebuildPipelineGuardTests.cs

@@ -13,7 +13,7 @@ public class ModuleRebuildPipelineGuardTests
     [Fact]
     public async Task S2_UnscopedRunFull_Throws()
     {
-        var svc = new S2MdpSyncTransformService(null!, null!, null!, null!);
+        var svc = new S2MdpSyncTransformService(null!, null!, null!, null!, null!);
         var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => svc.RunFullAsync());
         Assert.Contains("作用域", ex.Message);
     }
@@ -21,7 +21,7 @@ public class ModuleRebuildPipelineGuardTests
     [Fact]
     public async Task S3_UnscopedRunFull_Throws()
     {
-        var svc = new S3MdpSyncTransformService(null!, null!, null!, null!);
+        var svc = new S3MdpSyncTransformService(null!, null!, null!, null!, null!);
         var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => svc.RunFullAsync());
         Assert.Contains("作用域", ex.Message);
     }
@@ -29,7 +29,7 @@ public class ModuleRebuildPipelineGuardTests
     [Fact]
     public async Task S4_UnscopedRunFull_Throws()
     {
-        var svc = new S4MdpSyncTransformService(null!, null!, null!);
+        var svc = new S4MdpSyncTransformService(null!, null!, null!, null!);
         var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => svc.RunFullAsync());
         Assert.Contains("作用域", ex.Message);
     }

+ 253 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/TransformRunLogFinalizerContractTests.cs

@@ -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);
+    }
+}

+ 20 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimRefreshService.cs

@@ -22,6 +22,7 @@ public sealed class S0DimRefreshService : ITransient
     };
 
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly S0DimMaterializer _materializer;
     private readonly S0DimReconciler _reconciler;
     private readonly ILogger<S0DimRefreshService> _logger;
@@ -31,9 +32,11 @@ public sealed class S0DimRefreshService : ITransient
         ISqlSugarClient db,
         S0DimMaterializer materializer,
         S0DimReconciler reconciler,
-        ILogger<S0DimRefreshService> logger)
+        ILogger<S0DimRefreshService> logger,
+        TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _materializer = materializer;
         _reconciler = reconciler;
         _logger = logger;
@@ -68,9 +71,22 @@ public sealed class S0DimRefreshService : ITransient
             // run log 没有 stale 回收机制,而 LastRunAsync 会把 RUNNING 行当成「最近一次结果」返回。
             // 若取消时直接抛出,这条记录会永久停在 RUNNING,读者无法区分「正在跑」与「早就断了」。
             // 故先收口再重抛。CompleteRunLogAsync 不接收 ct —— 否则它自己也会被同一个 token 取消。
-            result.Status = "CANCELED";
-            result.DurationMs = (int)(DateTime.Now - startedAt).TotalMilliseconds;
-            await CompleteRunLogAsync(result, startedAt);
+            // 取消有两种来源,收口方式不同,不能混为一谈:
+            // · 宿主关停 → ABORTED(基础设施事件,没人需要为此排查)
+            // · 其它取消 → CANCELED(调用方主动放弃,本就是业务动作)
+            // 判据只能是 ApplicationStopping:传进来的 ct 在客户端断开时同样会取消。
+            // 这里刻意不用 finally —— 正常路径的 CompleteRunLogAsync 在 try 之外,
+            // 套 finally 会把已经跑完的一轮误收口成 ABORTED。
+            if (_runLogFinalizer.IsHostStopping)
+            {
+                await _runLogFinalizer.FinalizeIfHostStoppingAsync(result.RunLogId, startedAt);
+            }
+            else
+            {
+                result.Status = "CANCELED";
+                result.DurationMs = (int)(DateTime.Now - startedAt).TotalMilliseconds;
+                await CompleteRunLogAsync(result, startedAt);
+            }
             _logger.LogWarning("[S0Dim] refresh canceled tenant={Tenant} batch={Batch} 已完成 {Done}/{Total} 个维度",
                 tenantId, batchId, result.Items.Count, defs.Count);
             throw;

+ 28 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcMdpSyncService.cs

@@ -40,11 +40,13 @@ public class FqcMdpSyncService : ITransient
     private const string StdResultDetailTable = "mdp_std_fqc_result_detail";
 
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly MdpSourcePullDispatcher _pullDispatcher;
 
-    public FqcMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
+    public FqcMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher, TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _pullDispatcher = pullDispatcher;
     }
 
@@ -89,11 +91,14 @@ public class FqcMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
         finally
         {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
             await _db.Ado.ExecuteCommandAsync("SELECT RELEASE_LOCK(@k)", new List<SugarParameter> { new("@k", FqcRefreshLockKey) });
         }
     }
@@ -175,9 +180,15 @@ public class FqcMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     /// <summary>
@@ -285,9 +296,15 @@ public class FqcMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new FqcInboundResult
         {
@@ -353,9 +370,15 @@ public class FqcMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new FqcInboundResult
         {

+ 11 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/S7MdpSyncTransformService.cs

@@ -15,6 +15,7 @@ namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
 public class S7MdpSyncTransformService : ITransient
 {
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly SysNoticeService _sysNoticeService;
     private readonly ILogger<S7MdpSyncTransformService> _logger;
     private readonly SmartOps.KpiCalcDispatcher _kpiCalcDispatcher;
@@ -36,9 +37,11 @@ public class S7MdpSyncTransformService : ITransient
         SmartOps.KpiCalcDispatcher kpiCalcDispatcher,
         SmartOps.KpiDimensionRunService dimensionRun,
         SmartOps.IKpiTargetResolver kpiTargetResolver,
-        InventoryMdpSyncService inventoryMdpSync)
+        InventoryMdpSyncService inventoryMdpSync,
+        TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _sysNoticeService = sysNoticeService;
         _logger = logger;
         _kpiCalcDispatcher = kpiCalcDispatcher;
@@ -111,9 +114,15 @@ public class S7MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message, batchId);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkTransformRunFailedAsync(runLogId, now, ex.Message, batchId);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     // ─────────────────────────────────────────────────────────────────────────

+ 129 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/TransformRunLogFinalizer.cs

@@ -0,0 +1,129 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure;
+
+/// <summary>
+/// <c>mdp_transform_run_log</c> 的<b>宿主关停收口器</b>。
+/// </summary>
+/// <remarks>
+/// <para><b>解决什么</b>:每个 transform producer 都是「先 INSERT 一行 RUNNING,再进 try」。
+/// 进程被关停时,这一行的归宿取决于代码能不能走完 —— 走得完就落一个终态,走不完就永远停在
+/// <c>RUNNING</c>。而 <c>RUNNING + end_time IS NULL</c> 正是 S8 Authority 判定「有更新的一轮在飞」
+/// 的依据,一条收不了口的行会把该 (job_code, tenant_id) 的健康判定按住不放。</para>
+///
+/// <para><b>为什么不复用现成状态</b>:</para>
+/// <list type="bullet">
+///   <item><description><c>FAILED</c> 表示这一轮真的算错了,需要有人去看。关停不是算错,
+///   混进去会把「转换失败」这个信号稀释掉。</description></item>
+///   <item><description><c>CANCELED</c> 已被 <see cref="DataPlatform.S0Dim.S0DimRefreshService"/>
+///   占用为**业务级取消**(调用方主动放弃)。关停是基础设施事件,不是业务决定。</description></item>
+/// </list>
+/// <para>故单开 <see cref="StatusAborted"/>。<c>status</c> 列是 <c>varchar(30)</c>,
+/// 且实库现存值本就不止三态(另有 <c>SKIPPED_NO_CHANGE</c> / <c>SUCCESS_WITH_WARNING</c> /
+/// <c>CANCELED</c>),新增一个取值不需要改表结构,也不会撞上任何穷举式消费
+/// —— 全部消费方用的都是等值过滤,没有 enum、没有 switch、没有 <c>status IN (...)</c>。</para>
+///
+/// <para><b>判据是 <see cref="IHostApplicationLifetime.ApplicationStopping"/>,不是传进来的
+/// <c>CancellationToken</c>。</b>这一点是本类的核心,不可退让:controller 上的
+/// <c>CancellationToken</c> 参数会被 ASP.NET Core 模型绑定到 <c>HttpContext.RequestAborted</c>,
+/// 它在**客户端断开**时同样会取消。若拿 token 当判据,用户关掉浏览器就会被记成宿主关停。
+/// 而 <c>ApplicationStopping</c> 只由真实的进程关停信号触发(SIGTERM / Ctrl+C /
+/// <c>StopApplication()</c>,本仓无任何代码调用后者),语义唯一。</para>
+///
+/// <para><b>能力边界(别拿它当兜底)</b>:本类是 producer 侧的收口,只有在进程真的**展开了调用栈**
+/// 时才会执行。SIGKILL、宿主关停超时后被强杀、进程崩溃这三种情况下没有任何用户代码会跑,
+/// 它一行也救不了 —— 那类残留只能靠消费侧或启动期回收处理,不在本类职责内。</para>
+/// </remarks>
+public sealed class TransformRunLogFinalizer : ITransient
+{
+    /// <summary>因宿主关停而中断的一轮。既不是失败,也不是业务取消。</summary>
+    public const string StatusAborted = "ABORTED";
+
+    /// <summary>写进 <c>error_message</c> 的收口原因,便于事后区分残留来源。</summary>
+    public const string ReasonHostShutdown = "HOST_SHUTDOWN";
+
+    /// <summary>
+    /// 条件收口语句。<c>WHERE</c> 三个条件缺一不可:
+    /// <c>id</c> 定位本轮、<c>status='RUNNING'</c> 与 <c>end_time IS NULL</c> 保证
+    /// 只改「还没落终态」的行 —— 正常跑完的一轮已被 SUCCESS/FAILED 收口,此处影响行数为 0,
+    /// 不会把成功改写成中断。重复调用同样幂等。
+    /// </summary>
+    public const string FinalizeSql =
+        """
+        UPDATE mdp_transform_run_log
+        SET status=@Status, end_time=@EndTime, duration_ms=@DurationMs,
+            error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
+        WHERE id=@Id AND status='RUNNING' AND end_time IS NULL
+        """;
+
+    private readonly ISqlSugarClient _db;
+    private readonly IHostApplicationLifetime _lifetime;
+    private readonly ILogger<TransformRunLogFinalizer> _logger;
+
+    public TransformRunLogFinalizer(
+        ISqlSugarClient db,
+        IHostApplicationLifetime lifetime,
+        ILogger<TransformRunLogFinalizer> logger)
+    {
+        _db = db;
+        _lifetime = lifetime;
+        _logger = logger;
+    }
+
+    /// <summary>宿主是否已进入关停流程。</summary>
+    public bool IsHostStopping => _lifetime.ApplicationStopping.IsCancellationRequested;
+
+    /// <summary>
+    /// 宿主正在关停时,把仍为 <c>RUNNING</c> 的这一轮收口为 <see cref="StatusAborted"/>。
+    /// </summary>
+    /// <returns>真正改到行返回 <see langword="true"/>;未关停、行已落终态、或写库失败均返回 <see langword="false"/>。</returns>
+    /// <remarks>
+    /// <para>本方法<b>从不抛异常</b>:它总是从 <c>finally</c> 或 <c>catch</c> 里被调用,
+    /// 一旦抛出就会顶掉真正的业务异常,把「转换为什么断了」这个信息弄丢。</para>
+    /// <para>本方法<b>不接收 CancellationToken</b>:触发它的那个 token 此刻必然已经取消,
+    /// 传进去只会让这条收口语句自己也被取消 —— 那正是它要修的问题。</para>
+    /// </remarks>
+    public async Task<bool> FinalizeIfHostStoppingAsync(long runLogId, DateTime startedAt)
+    {
+        if (runLogId <= 0) return false;
+        if (!IsHostStopping) return false;
+
+        try
+        {
+            // 必须换一条全新连接执行 —— 这是实测教训,不是保守写法。
+            //
+            // 首次冒烟时本方法确实被触发了(IsHostStopping=true、runLogId 正确),
+            // 但 UPDATE 自己抛了 OperationCanceledException,收口一行没落。
+            // 原因:SqlSugar 把 CancellationToken 挂在 Ado provider 上,
+            // 转换过程中传过 token 的调用会把它留在客户端实例里;
+            // 关停时那个 token 已经取消,于是「不传 token」根本不够 ——
+            // 后续任何一条 SQL,包括这条收口语句,都会被连坐取消。
+            //
+            // CopyNew() 拿到不带这段运行期状态的新客户端;再显式清一次 token 兜底。
+            var db = _db.CopyNew();
+            db.Ado.CancellationToken = null;
+
+            var finishedAt = DateTime.Now;
+            var affected = await db.Ado.ExecuteCommandAsync(
+                FinalizeSql,
+                new SugarParameter("@Status", StatusAborted),
+                new SugarParameter("@EndTime", finishedAt),
+                new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+                new SugarParameter("@ErrorMessage", ReasonHostShutdown + ":宿主关停,本轮未跑完即收口"),
+                new SugarParameter("@Id", runLogId));
+
+            if (affected > 0)
+                _logger.LogWarning("[TransformRunLogFinalizer] runLogId={RunLogId} 因宿主关停收口为 {Status}", runLogId, StatusAborted);
+
+            return affected > 0;
+        }
+        catch (Exception ex)
+        {
+            // 关停途中写库失败是可预期的(连接池可能已开始释放)。记一笔即可,
+            // 绝不向上抛:这条路径上「原始异常」比「收口失败」重要得多。
+            _logger.LogWarning(ex, "[TransformRunLogFinalizer] runLogId={RunLogId} 关停收口写入失败", runLogId);
+            return false;
+        }
+    }
+}

+ 38 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/IpqcInspectionMdpSyncService.cs

@@ -41,12 +41,14 @@ public class IpqcInspectionMdpSyncService : ITransient
     private const string SourceZtid = "pbxfxp";
 
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly MdpSourcePullDispatcher _pullDispatcher;
     private readonly List<string> _tenantSkipWarnings = new();
 
-    public IpqcInspectionMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
+    public IpqcInspectionMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher, TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _pullDispatcher = pullDispatcher;
     }
 
@@ -90,9 +92,15 @@ public class IpqcInspectionMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     /// <summary>本库 MySQL 贴源 source_system(与 MdpDbPullExecutor 落 stg 的取值一致,决定 uk_source_key 归属)。</summary>
@@ -147,9 +155,15 @@ public class IpqcInspectionMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     /// <summary>
@@ -234,9 +248,15 @@ public class IpqcInspectionMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new IpqcInspectionInboundResult
         {
@@ -298,9 +318,15 @@ public class IpqcInspectionMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new IpqcInspectionInboundResult
         {
@@ -361,9 +387,15 @@ public class IpqcInspectionMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new IpqcInspectionInboundResult
         {

+ 11 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/S6MdpSyncTransformService.cs

@@ -16,6 +16,7 @@ namespace Admin.NET.Plugin.AiDOP.Manufacturing;
 public class S6MdpSyncTransformService : ITransient
 {
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly SysNoticeService _sysNoticeService;
     private readonly ILogger<S6MdpSyncTransformService> _logger;
     private readonly SmartOps.KpiCalcDispatcher _kpiCalcDispatcher;
@@ -36,9 +37,11 @@ public class S6MdpSyncTransformService : ITransient
         ILogger<S6MdpSyncTransformService> logger,
         SmartOps.KpiCalcDispatcher kpiCalcDispatcher,
         SmartOps.KpiDimensionRunService dimensionRun,
-        SmartOps.IKpiTargetResolver kpiTargetResolver)
+        SmartOps.IKpiTargetResolver kpiTargetResolver,
+        TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _sysNoticeService = sysNoticeService;
         _logger = logger;
         _kpiCalcDispatcher = kpiCalcDispatcher;
@@ -121,9 +124,15 @@ public class S6MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message, batchId);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkTransformRunFailedAsync(runLogId, now, ex.Message, batchId);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     // ─────────────────────────────────────────────────────────────────────────

+ 10 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspectionMdpSyncService.cs

@@ -33,10 +33,12 @@ public class IqcInspectionMdpSyncService : ITransient
     private const string FlowBizType = "S5_IQC_INSPBILL";
 
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
 
-    public IqcInspectionMdpSyncService(ISqlSugarClient db)
+    public IqcInspectionMdpSyncService(ISqlSugarClient db, TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
     }
 
     /// <summary>
@@ -63,9 +65,15 @@ public class IqcInspectionMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await FailRunLogAsync(runLogId, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await FailRunLogAsync(runLogId, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     /// <summary>

+ 24 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/OutsourceIssueMdpSyncService.cs

@@ -39,11 +39,13 @@ public class OutsourceIssueMdpSyncService : ITransient
     private const string StdDetailTable = "mdp_std_outsource_issue_detail";
 
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly MdpSourcePullDispatcher _pullDispatcher;
 
-    public OutsourceIssueMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
+    public OutsourceIssueMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher, TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _pullDispatcher = pullDispatcher;
     }
 
@@ -79,9 +81,15 @@ public class OutsourceIssueMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     /// <summary>双模式入站:执行器抽主/明细 → pull stg,再跑 CA 标准层头/明细(读 stg)。</summary>
@@ -116,9 +124,15 @@ public class OutsourceIssueMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new OutsourceIssueInboundResult
         {
@@ -180,9 +194,15 @@ public class OutsourceIssueMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new OutsourceIssueInboundResult
         {

+ 24 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/ProductionIssueMdpSyncService.cs

@@ -32,11 +32,13 @@ public class ProductionIssueMdpSyncService : ITransient
     private const string SourceZtid = "pbxfxp";
 
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly MdpSourcePullDispatcher _pullDispatcher;
 
-    public ProductionIssueMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
+    public ProductionIssueMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher, TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _pullDispatcher = pullDispatcher;
     }
 
@@ -81,9 +83,15 @@ public class ProductionIssueMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     /// <summary>双模式入站:执行器抽头落 stg,再跑标准层(读 stg)。</summary>
@@ -130,9 +138,15 @@ public class ProductionIssueMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new ProductionIssueInboundResult
         {
@@ -263,9 +277,15 @@ public class ProductionIssueMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new ProductionIssueInboundResult
         {

+ 24 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/ProductionReceiptMdpSyncService.cs

@@ -33,11 +33,13 @@ public class ProductionReceiptMdpSyncService : ITransient
     private const string SourceZtid = "pbxfxp";
 
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly MdpSourcePullDispatcher _pullDispatcher;
 
-    public ProductionReceiptMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
+    public ProductionReceiptMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher, TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _pullDispatcher = pullDispatcher;
     }
 
@@ -70,9 +72,15 @@ public class ProductionReceiptMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     /// <summary>
@@ -108,9 +116,15 @@ public class ProductionReceiptMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new ProductionReceiptInboundResult
         {
@@ -169,9 +183,15 @@ public class ProductionReceiptMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new ProductionReceiptInboundResult
         {

+ 24 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/ProductionReturnMdpSyncService.cs

@@ -37,11 +37,13 @@ public class ProductionReturnMdpSyncService : ITransient
     private const string StdDetailTable = "mdp_std_production_return_detail";
 
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly MdpSourcePullDispatcher _pullDispatcher;
 
-    public ProductionReturnMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
+    public ProductionReturnMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher, TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _pullDispatcher = pullDispatcher;
     }
 
@@ -75,9 +77,15 @@ public class ProductionReturnMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     /// <summary>双模式入站:执行器抽主/明细 → stg,再跑 WOD 标准层头/明细(读 stg)。</summary>
@@ -112,9 +120,15 @@ public class ProductionReturnMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new ProductionReturnInboundResult
         {
@@ -178,9 +192,15 @@ public class ProductionReturnMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new ProductionReturnInboundResult
         {

+ 24 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/PurchaseReceiptMdpSyncService.cs

@@ -33,11 +33,13 @@ public class PurchaseReceiptMdpSyncService : ITransient
     private const string SourceZtid = "pbxfxp";
 
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly MdpSourcePullDispatcher _pullDispatcher;
 
-    public PurchaseReceiptMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
+    public PurchaseReceiptMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher, TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _pullDispatcher = pullDispatcher;
     }
 
@@ -70,9 +72,15 @@ public class PurchaseReceiptMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     /// <summary>
@@ -108,9 +116,15 @@ public class PurchaseReceiptMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new PurchaseReceiptInboundResult
         {
@@ -174,9 +188,15 @@ public class PurchaseReceiptMdpSyncService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
 
         return new PurchaseReceiptInboundResult
         {

+ 11 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/S5MdpSyncTransformService.cs

@@ -17,6 +17,7 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 public class S5MdpSyncTransformService : ITransient
 {
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly SysNoticeService _sysNoticeService;
     private readonly ILogger<S5MdpSyncTransformService> _logger;
     private const string JobCode = "S5_MDP_SYNC_TRANSFORM";
@@ -47,9 +48,11 @@ public class S5MdpSyncTransformService : ITransient
         SmartOps.KpiDimensionRunService dimensionRun,
         IKpiTargetResolver kpiTargetResolver,
         InventoryMdpSyncService inventoryMdpSync,
-        PurchaseReceiptMdpSyncService purchaseReceiptMdpSync)
+        PurchaseReceiptMdpSyncService purchaseReceiptMdpSync,
+        TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _sysNoticeService = sysNoticeService;
         _logger = logger;
         _kpiCalcDispatcher = kpiCalcDispatcher;
@@ -151,9 +154,15 @@ public class S5MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message, batchId);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkTransformRunFailedAsync(runLogId, now, ex.Message, batchId);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     // ─────────────────────────────────────────────────────────────────────────

+ 24 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs

@@ -12,6 +12,7 @@ public class S1MdpSyncTransformService : ITransient
 {
     private const string JobCode = "S1_MDP_SYNC_TRANSFORM";
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly SmartOpsKpiAtomicBuildService _atomicBuild;
     private readonly MdpModuleStagingPuller _stagingPuller;
     private readonly IKpiTargetResolver _kpiTargetResolver;
@@ -22,9 +23,11 @@ public class S1MdpSyncTransformService : ITransient
         SmartOpsKpiAtomicBuildService atomicBuild,
         MdpModuleStagingPuller stagingPuller,
         IKpiTargetResolver kpiTargetResolver,
-        IS1MdpFullRunLock fullRunLock)
+        IS1MdpFullRunLock fullRunLock,
+        TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _atomicBuild = atomicBuild;
         _stagingPuller = stagingPuller;
         _kpiTargetResolver = kpiTargetResolver;
@@ -89,11 +92,16 @@ public class S1MdpSyncTransformService : ITransient
                 var summary = ex is OperationCanceledException
                     ? "HTTP 请求已取消或服务中断"
                     : ex.Message;
-                await MarkTransformRunFailedAsync(runLogId, now, summary);
+                // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+                // 上面那句 summary 之所以把两种取消写在一起,正是因为 token 分不开它们;
+                // 判据只能是 ApplicationStopping。
+                if (!_runLogFinalizer.IsHostStopping)
+                    await MarkTransformRunFailedAsync(runLogId, now, summary);
                 throw;
             }
             finally
             {
+                await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
                 heartbeatCts.Cancel();
                 try { await heartbeat; } catch (OperationCanceledException) { }
             }
@@ -131,9 +139,15 @@ public class S1MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     private async Task EnsureS1RuntimeObjectsAsync()
@@ -291,9 +305,15 @@ public class S1MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     /// <summary>Phase3:已停用。源→stg 改由执行器路径;保留方法供紧急回滚对照,勿再调用。</summary>

+ 17 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/S4MdpSyncTransformService.cs

@@ -14,13 +14,15 @@ public class S4MdpSyncTransformService : ITransient
     private const string JobCode = "S4_MDP_SYNC_TRANSFORM";
 
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly MdpModuleStagingPuller _stagingPuller;
     private readonly IKpiTargetResolver _kpiTargetResolver;
     private MdpRebuildScope _runScope = null!;
 
-    public S4MdpSyncTransformService(ISqlSugarClient db, MdpModuleStagingPuller stagingPuller, IKpiTargetResolver kpiTargetResolver)
+    public S4MdpSyncTransformService(ISqlSugarClient db, MdpModuleStagingPuller stagingPuller, IKpiTargetResolver kpiTargetResolver, TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _stagingPuller = stagingPuller;
         _kpiTargetResolver = kpiTargetResolver;
     }
@@ -69,9 +71,15 @@ public class S4MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     private static async Task ReportAsync(Func<ModuleProgressUpdate, Task>? report, ModuleProgressUpdate update)
@@ -224,9 +232,15 @@ public class S4MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     private async Task<int> CountSharedStagingRowsAsync()

+ 18 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Production/S2MdpSyncTransformService.cs

@@ -12,6 +12,7 @@ public class S2MdpSyncTransformService : ITransient
 {
     private const string JobCode = "S2_MDP_SYNC_TRANSFORM";
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly SmartOpsKpiAtomicBuildService _atomicBuild;
     private readonly MdpModuleStagingPuller _stagingPuller;
     private readonly IKpiTargetResolver _kpiTargetResolver;
@@ -21,9 +22,11 @@ public class S2MdpSyncTransformService : ITransient
         ISqlSugarClient db,
         SmartOpsKpiAtomicBuildService atomicBuild,
         MdpModuleStagingPuller stagingPuller,
-        IKpiTargetResolver kpiTargetResolver)
+        IKpiTargetResolver kpiTargetResolver,
+        TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _atomicBuild = atomicBuild;
         _stagingPuller = stagingPuller;
         _kpiTargetResolver = kpiTargetResolver;
@@ -79,9 +82,15 @@ public class S2MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     private static async Task ReportAsync(Func<ModuleProgressUpdate, Task>? report, ModuleProgressUpdate update)
@@ -138,9 +147,15 @@ public class S2MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     [Obsolete("Phase3: use MdpModuleStagingPuller / executors")]

+ 11 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseOrderCompletionMdpSyncService.cs

@@ -39,6 +39,7 @@ public class PurchaseOrderCompletionMdpSyncService : ITransient
     public const string StatusUnknown = "UNKNOWN";
 
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly DataPlatform.MdpSourceScopeFactory _scopeFactory;
     private readonly AidopPoCompletionOptions _opt;
     private readonly IHostEnvironment _env;
@@ -49,9 +50,11 @@ public class PurchaseOrderCompletionMdpSyncService : ITransient
         DataPlatform.MdpSourceScopeFactory scopeFactory,
         IOptions<AidopPoCompletionOptions> opt,
         IHostEnvironment env,
-        ILogger<PurchaseOrderCompletionMdpSyncService> logger)
+        ILogger<PurchaseOrderCompletionMdpSyncService> logger,
+        TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _scopeFactory = scopeFactory;
         _opt = opt.Value;
         _env = env;
@@ -200,9 +203,15 @@ public class PurchaseOrderCompletionMdpSyncService : ITransient
         catch (Exception ex)
         {
             _logger.LogError(ex, "[PoCompletion] 批次 {BatchId} 失败", batchId);
-            await FailRunLogAsync(runLogId, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await FailRunLogAsync(runLogId, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     // ── 源绑定与 fail-closed ────────────────────────────────────────────────────────

+ 18 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs

@@ -11,6 +11,7 @@ namespace Admin.NET.Plugin.AiDOP.Supply;
 public class S3MdpSyncTransformService : ITransient
 {
     private readonly ISqlSugarClient _db;
+    private readonly TransformRunLogFinalizer _runLogFinalizer;
     private readonly SmartOpsKpiAtomicBuildService _atomicBuild;
     private readonly MdpModuleStagingPuller _stagingPuller;
     private readonly IKpiTargetResolver _kpiTargetResolver;
@@ -21,9 +22,11 @@ public class S3MdpSyncTransformService : ITransient
         ISqlSugarClient db,
         SmartOpsKpiAtomicBuildService atomicBuild,
         MdpModuleStagingPuller stagingPuller,
-        IKpiTargetResolver kpiTargetResolver)
+        IKpiTargetResolver kpiTargetResolver,
+        TransformRunLogFinalizer runLogFinalizer)
     {
         _db = db;
+        _runLogFinalizer = runLogFinalizer;
         _atomicBuild = atomicBuild;
         _stagingPuller = stagingPuller;
         _kpiTargetResolver = kpiTargetResolver;
@@ -82,9 +85,15 @@ public class S3MdpSyncTransformService : ITransient
             }
             catch (Exception ex)
             {
-                await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+                // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+                if (!_runLogFinalizer.IsHostStopping)
+                    await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
                 throw;
             }
+            finally
+            {
+                await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+            }
         }
         finally
         {
@@ -169,9 +178,15 @@ public class S3MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
+            if (!_runLogFinalizer.IsHostStopping)
+                await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
+        }
     }
 
     [Obsolete("Phase3: use MdpModuleStagingPuller / executors")]