TransformRunLogFinalizer.cs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  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>管理员协作式取消导致的中断。与宿主关停分开记,便于事后区分。</summary>
  43. public const string ReasonAdminCancel = "ADMIN_CANCEL";
  44. /// <summary>
  45. /// 条件收口语句。<c>WHERE</c> 三个条件缺一不可:
  46. /// <c>id</c> 定位本轮、<c>status='RUNNING'</c> 与 <c>end_time IS NULL</c> 保证
  47. /// 只改「还没落终态」的行 —— 正常跑完的一轮已被 SUCCESS/FAILED 收口,此处影响行数为 0,
  48. /// 不会把成功改写成中断。重复调用同样幂等。
  49. /// </summary>
  50. public const string FinalizeSql =
  51. """
  52. UPDATE mdp_transform_run_log
  53. SET status=@Status, end_time=@EndTime, duration_ms=@DurationMs,
  54. error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
  55. WHERE id=@Id AND status='RUNNING' AND end_time IS NULL
  56. """;
  57. private readonly ISqlSugarClient _db;
  58. private readonly IHostApplicationLifetime _lifetime;
  59. private readonly ILogger<TransformRunLogFinalizer> _logger;
  60. public TransformRunLogFinalizer(
  61. ISqlSugarClient db,
  62. IHostApplicationLifetime lifetime,
  63. ILogger<TransformRunLogFinalizer> logger)
  64. {
  65. _db = db;
  66. _lifetime = lifetime;
  67. _logger = logger;
  68. }
  69. /// <summary>宿主是否已进入关停流程。</summary>
  70. public bool IsHostStopping => _lifetime.ApplicationStopping.IsCancellationRequested;
  71. /// <summary>
  72. /// 管理员取消时把仍为 RUNNING 的这一轮收口为 <see cref="StatusAborted"/>。
  73. /// 与 <see cref="FinalizeIfHostStoppingAsync"/> 不同,它不看宿主关停状态,
  74. /// 且走调用方自己的连接(取消发生在进程正常运行时,不存在 token 连坐的问题)。
  75. /// </summary>
  76. public async Task<bool> FinalizeAsAdminCancelledAsync(long runLogId, DateTime startedAt)
  77. {
  78. if (runLogId <= 0) return false;
  79. var finishedAt = DateTime.Now;
  80. var affected = await _db.Ado.ExecuteCommandAsync(
  81. FinalizeSql,
  82. new SugarParameter("@Status", StatusAborted),
  83. new SugarParameter("@EndTime", finishedAt),
  84. new SugarParameter("@DurationMs", (int)Math.Max(0, (finishedAt - startedAt).TotalMilliseconds)),
  85. new SugarParameter("@ErrorMessage", ReasonAdminCancel + ":管理员取消,本轮在阶段边界中止"),
  86. new SugarParameter("@Id", runLogId));
  87. if (affected > 0)
  88. _logger.LogWarning("[TransformRunLogFinalizer] runLogId={RunLogId} 因管理员取消收口为 {Status}", runLogId, StatusAborted);
  89. return affected > 0;
  90. }
  91. /// <summary>
  92. /// 宿主正在关停时,把仍为 <c>RUNNING</c> 的这一轮收口为 <see cref="StatusAborted"/>。
  93. /// </summary>
  94. /// <returns>真正改到行返回 <see langword="true"/>;未关停、行已落终态、或写库失败均返回 <see langword="false"/>。</returns>
  95. /// <remarks>
  96. /// <para>本方法<b>从不抛异常</b>:它总是从 <c>finally</c> 或 <c>catch</c> 里被调用,
  97. /// 一旦抛出就会顶掉真正的业务异常,把「转换为什么断了」这个信息弄丢。</para>
  98. /// <para>本方法<b>不接收 CancellationToken</b>:触发它的那个 token 此刻必然已经取消,
  99. /// 传进去只会让这条收口语句自己也被取消 —— 那正是它要修的问题。</para>
  100. /// </remarks>
  101. public async Task<bool> FinalizeIfHostStoppingAsync(long runLogId, DateTime startedAt)
  102. {
  103. if (runLogId <= 0) return false;
  104. if (!IsHostStopping) return false;
  105. try
  106. {
  107. // 必须换一条全新连接执行 —— 这是实测教训,不是保守写法。
  108. //
  109. // 首次冒烟时本方法确实被触发了(IsHostStopping=true、runLogId 正确),
  110. // 但 UPDATE 自己抛了 OperationCanceledException,收口一行没落。
  111. // 原因:SqlSugar 把 CancellationToken 挂在 Ado provider 上,
  112. // 转换过程中传过 token 的调用会把它留在客户端实例里;
  113. // 关停时那个 token 已经取消,于是「不传 token」根本不够 ——
  114. // 后续任何一条 SQL,包括这条收口语句,都会被连坐取消。
  115. //
  116. // CopyNew() 拿到不带这段运行期状态的新客户端;再显式清一次 token 兜底。
  117. var db = _db.CopyNew();
  118. db.Ado.CancellationToken = null;
  119. var finishedAt = DateTime.Now;
  120. var affected = await db.Ado.ExecuteCommandAsync(
  121. FinalizeSql,
  122. new SugarParameter("@Status", StatusAborted),
  123. new SugarParameter("@EndTime", finishedAt),
  124. new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
  125. new SugarParameter("@ErrorMessage", ReasonHostShutdown + ":宿主关停,本轮未跑完即收口"),
  126. new SugarParameter("@Id", runLogId));
  127. if (affected > 0)
  128. _logger.LogWarning("[TransformRunLogFinalizer] runLogId={RunLogId} 因宿主关停收口为 {Status}", runLogId, StatusAborted);
  129. return affected > 0;
  130. }
  131. catch (Exception ex)
  132. {
  133. // 关停途中写库失败是可预期的(连接池可能已开始释放)。记一笔即可,
  134. // 绝不向上抛:这条路径上「原始异常」比「收口失败」重要得多。
  135. _logger.LogWarning(ex, "[TransformRunLogFinalizer] runLogId={RunLogId} 关停收口写入失败", runLogId);
  136. return false;
  137. }
  138. }
  139. }