| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129 |
- 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;
- }
- }
- }
|