using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Infrastructure; /// /// mdp_transform_run_log宿主关停收口器。 /// /// /// 解决什么:每个 transform producer 都是「先 INSERT 一行 RUNNING,再进 try」。 /// 进程被关停时,这一行的归宿取决于代码能不能走完 —— 走得完就落一个终态,走不完就永远停在 /// RUNNING。而 RUNNING + end_time IS NULL 正是 S8 Authority 判定「有更新的一轮在飞」 /// 的依据,一条收不了口的行会把该 (job_code, tenant_id) 的健康判定按住不放。 /// /// 为什么不复用现成状态 /// /// FAILED 表示这一轮真的算错了,需要有人去看。关停不是算错, /// 混进去会把「转换失败」这个信号稀释掉。 /// CANCELED 已被 /// 占用为**业务级取消**(调用方主动放弃)。关停是基础设施事件,不是业务决定。 /// /// 故单开 status 列是 varchar(30), /// 且实库现存值本就不止三态(另有 SKIPPED_NO_CHANGE / SUCCESS_WITH_WARNING / /// CANCELED),新增一个取值不需要改表结构,也不会撞上任何穷举式消费 /// —— 全部消费方用的都是等值过滤,没有 enum、没有 switch、没有 status IN (...) /// /// 判据是 ,不是传进来的 /// CancellationToken这一点是本类的核心,不可退让:controller 上的 /// CancellationToken 参数会被 ASP.NET Core 模型绑定到 HttpContext.RequestAborted, /// 它在**客户端断开**时同样会取消。若拿 token 当判据,用户关掉浏览器就会被记成宿主关停。 /// 而 ApplicationStopping 只由真实的进程关停信号触发(SIGTERM / Ctrl+C / /// StopApplication(),本仓无任何代码调用后者),语义唯一。 /// /// 能力边界(别拿它当兜底):本类是 producer 侧的收口,只有在进程真的**展开了调用栈** /// 时才会执行。SIGKILL、宿主关停超时后被强杀、进程崩溃这三种情况下没有任何用户代码会跑, /// 它一行也救不了 —— 那类残留只能靠消费侧或启动期回收处理,不在本类职责内。 /// public sealed class TransformRunLogFinalizer : ITransient { /// 因宿主关停而中断的一轮。既不是失败,也不是业务取消。 public const string StatusAborted = "ABORTED"; /// 写进 error_message 的收口原因,便于事后区分残留来源。 public const string ReasonHostShutdown = "HOST_SHUTDOWN"; /// /// 条件收口语句。WHERE 三个条件缺一不可: /// id 定位本轮、status='RUNNING'end_time IS NULL 保证 /// 只改「还没落终态」的行 —— 正常跑完的一轮已被 SUCCESS/FAILED 收口,此处影响行数为 0, /// 不会把成功改写成中断。重复调用同样幂等。 /// 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 _logger; public TransformRunLogFinalizer( ISqlSugarClient db, IHostApplicationLifetime lifetime, ILogger logger) { _db = db; _lifetime = lifetime; _logger = logger; } /// 宿主是否已进入关停流程。 public bool IsHostStopping => _lifetime.ApplicationStopping.IsCancellationRequested; /// /// 宿主正在关停时,把仍为 RUNNING 的这一轮收口为 。 /// /// 真正改到行返回 ;未关停、行已落终态、或写库失败均返回 /// /// 本方法从不抛异常:它总是从 finallycatch 里被调用, /// 一旦抛出就会顶掉真正的业务异常,把「转换为什么断了」这个信息弄丢。 /// 本方法不接收 CancellationToken:触发它的那个 token 此刻必然已经取消, /// 传进去只会让这条收口语句自己也被取消 —— 那正是它要修的问题。 /// public async Task 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; } } }