TransformRunLogFinalizer.cs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. using Microsoft.Extensions.Hosting;
  2. using Microsoft.Extensions.Logging;
  3. namespace Admin.NET.Plugin.AiDOP.Infrastructure;
  4. /// <summary>
  5. /// <c>mdp_transform_run_log</c> 的<b>宿主关停收口器</b>。
  6. /// </summary>
  7. /// <remarks>
  8. /// <para><b>解决什么</b>:每个 transform producer 都是「先 INSERT 一行 RUNNING,再进 try」。
  9. /// 进程被关停时,这一行的归宿取决于代码能不能走完 —— 走得完就落一个终态,走不完就永远停在
  10. /// <c>RUNNING</c>。而 <c>RUNNING + end_time IS NULL</c> 正是 S8 Authority 判定「有更新的一轮在飞」
  11. /// 的依据,一条收不了口的行会把该 (job_code, tenant_id) 的健康判定按住不放。</para>
  12. ///
  13. /// <para><b>为什么不复用现成状态</b>:</para>
  14. /// <list type="bullet">
  15. /// <item><description><c>FAILED</c> 表示这一轮真的算错了,需要有人去看。关停不是算错,
  16. /// 混进去会把「转换失败」这个信号稀释掉。</description></item>
  17. /// <item><description><c>CANCELED</c> 已被 <see cref="DataPlatform.S0Dim.S0DimRefreshService"/>
  18. /// 占用为**业务级取消**(调用方主动放弃)。关停是基础设施事件,不是业务决定。</description></item>
  19. /// </list>
  20. /// <para>故单开 <see cref="StatusAborted"/>。<c>status</c> 列是 <c>varchar(30)</c>,
  21. /// 且实库现存值本就不止三态(另有 <c>SKIPPED_NO_CHANGE</c> / <c>SUCCESS_WITH_WARNING</c> /
  22. /// <c>CANCELED</c>),新增一个取值不需要改表结构,也不会撞上任何穷举式消费
  23. /// —— 全部消费方用的都是等值过滤,没有 enum、没有 switch、没有 <c>status IN (...)</c>。</para>
  24. ///
  25. /// <para><b>判据是 <see cref="IHostApplicationLifetime.ApplicationStopping"/>,不是传进来的
  26. /// <c>CancellationToken</c>。</b>这一点是本类的核心,不可退让:controller 上的
  27. /// <c>CancellationToken</c> 参数会被 ASP.NET Core 模型绑定到 <c>HttpContext.RequestAborted</c>,
  28. /// 它在**客户端断开**时同样会取消。若拿 token 当判据,用户关掉浏览器就会被记成宿主关停。
  29. /// 而 <c>ApplicationStopping</c> 只由真实的进程关停信号触发(SIGTERM / Ctrl+C /
  30. /// <c>StopApplication()</c>,本仓无任何代码调用后者),语义唯一。</para>
  31. ///
  32. /// <para><b>能力边界(别拿它当兜底)</b>:本类是 producer 侧的收口,只有在进程真的**展开了调用栈**
  33. /// 时才会执行。SIGKILL、宿主关停超时后被强杀、进程崩溃这三种情况下没有任何用户代码会跑,
  34. /// 它一行也救不了 —— 那类残留只能靠消费侧或启动期回收处理,不在本类职责内。</para>
  35. /// </remarks>
  36. public sealed class TransformRunLogFinalizer : ITransient
  37. {
  38. /// <summary>因宿主关停而中断的一轮。既不是失败,也不是业务取消。</summary>
  39. public const string StatusAborted = "ABORTED";
  40. /// <summary>写进 <c>error_message</c> 的收口原因,便于事后区分残留来源。</summary>
  41. public const string ReasonHostShutdown = "HOST_SHUTDOWN";
  42. /// <summary>
  43. /// 条件收口语句。<c>WHERE</c> 三个条件缺一不可:
  44. /// <c>id</c> 定位本轮、<c>status='RUNNING'</c> 与 <c>end_time IS NULL</c> 保证
  45. /// 只改「还没落终态」的行 —— 正常跑完的一轮已被 SUCCESS/FAILED 收口,此处影响行数为 0,
  46. /// 不会把成功改写成中断。重复调用同样幂等。
  47. /// </summary>
  48. public const string FinalizeSql =
  49. """
  50. UPDATE mdp_transform_run_log
  51. SET status=@Status, end_time=@EndTime, duration_ms=@DurationMs,
  52. error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
  53. WHERE id=@Id AND status='RUNNING' AND end_time IS NULL
  54. """;
  55. private readonly ISqlSugarClient _db;
  56. private readonly IHostApplicationLifetime _lifetime;
  57. private readonly ILogger<TransformRunLogFinalizer> _logger;
  58. public TransformRunLogFinalizer(
  59. ISqlSugarClient db,
  60. IHostApplicationLifetime lifetime,
  61. ILogger<TransformRunLogFinalizer> logger)
  62. {
  63. _db = db;
  64. _lifetime = lifetime;
  65. _logger = logger;
  66. }
  67. /// <summary>宿主是否已进入关停流程。</summary>
  68. public bool IsHostStopping => _lifetime.ApplicationStopping.IsCancellationRequested;
  69. /// <summary>
  70. /// 宿主正在关停时,把仍为 <c>RUNNING</c> 的这一轮收口为 <see cref="StatusAborted"/>。
  71. /// </summary>
  72. /// <returns>真正改到行返回 <see langword="true"/>;未关停、行已落终态、或写库失败均返回 <see langword="false"/>。</returns>
  73. /// <remarks>
  74. /// <para>本方法<b>从不抛异常</b>:它总是从 <c>finally</c> 或 <c>catch</c> 里被调用,
  75. /// 一旦抛出就会顶掉真正的业务异常,把「转换为什么断了」这个信息弄丢。</para>
  76. /// <para>本方法<b>不接收 CancellationToken</b>:触发它的那个 token 此刻必然已经取消,
  77. /// 传进去只会让这条收口语句自己也被取消 —— 那正是它要修的问题。</para>
  78. /// </remarks>
  79. public async Task<bool> FinalizeIfHostStoppingAsync(long runLogId, DateTime startedAt)
  80. {
  81. if (runLogId <= 0) return false;
  82. if (!IsHostStopping) return false;
  83. try
  84. {
  85. // 必须换一条全新连接执行 —— 这是实测教训,不是保守写法。
  86. //
  87. // 首次冒烟时本方法确实被触发了(IsHostStopping=true、runLogId 正确),
  88. // 但 UPDATE 自己抛了 OperationCanceledException,收口一行没落。
  89. // 原因:SqlSugar 把 CancellationToken 挂在 Ado provider 上,
  90. // 转换过程中传过 token 的调用会把它留在客户端实例里;
  91. // 关停时那个 token 已经取消,于是「不传 token」根本不够 ——
  92. // 后续任何一条 SQL,包括这条收口语句,都会被连坐取消。
  93. //
  94. // CopyNew() 拿到不带这段运行期状态的新客户端;再显式清一次 token 兜底。
  95. var db = _db.CopyNew();
  96. db.Ado.CancellationToken = null;
  97. var finishedAt = DateTime.Now;
  98. var affected = await db.Ado.ExecuteCommandAsync(
  99. FinalizeSql,
  100. new SugarParameter("@Status", StatusAborted),
  101. new SugarParameter("@EndTime", finishedAt),
  102. new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
  103. new SugarParameter("@ErrorMessage", ReasonHostShutdown + ":宿主关停,本轮未跑完即收口"),
  104. new SugarParameter("@Id", runLogId));
  105. if (affected > 0)
  106. _logger.LogWarning("[TransformRunLogFinalizer] runLogId={RunLogId} 因宿主关停收口为 {Status}", runLogId, StatusAborted);
  107. return affected > 0;
  108. }
  109. catch (Exception ex)
  110. {
  111. // 关停途中写库失败是可预期的(连接池可能已开始释放)。记一笔即可,
  112. // 绝不向上抛:这条路径上「原始异常」比「收口失败」重要得多。
  113. _logger.LogWarning(ex, "[TransformRunLogFinalizer] runLogId={RunLogId} 关停收口写入失败", runLogId);
  114. return false;
  115. }
  116. }
  117. }