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 的这一轮收口为 。
///
/// 真正改到行返回 ;未关停、行已落终态、或写库失败均返回 。
///
/// 本方法从不抛异常:它总是从 finally 或 catch 里被调用,
/// 一旦抛出就会顶掉真正的业务异常,把「转换为什么断了」这个信息弄丢。
/// 本方法不接收 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;
}
}
}