S8WatchSchedulerJob.cs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. using Admin.NET.Plugin.AiDOP.Service.S8;
  2. using Furion.Schedule;
  3. using Microsoft.Extensions.DependencyInjection;
  4. using Microsoft.Extensions.Logging;
  5. using System.Text.Json;
  6. namespace Admin.NET.Plugin.AiDOP.Job;
  7. /// <summary>
  8. /// G-08:S8 自动监控主链定时调度作业。
  9. /// 固定租户 / 工厂上下文下,按周期触发一次 G-01 主链(<see cref="S8WatchSchedulerService.CreateExceptionsAsync"/>)。
  10. /// 本轮仅完成"正式定时触发接入";不在调度层追加业务逻辑、不改写主链入参语义、不做真实 SQL / 认证链路 / SLA 治理。
  11. /// 周期由 [Period] 属性固化为默认值(30 分钟);运行期调整走 Admin.NET 作业管理,不走配置中心。
  12. /// 开关:默认通过 Admin.NET 作业管理的触发器状态控制;本作业构造不自动启动。
  13. /// </summary>
  14. [JobDetail("job_s8_watch_scheduler", Description = "S8 自动监控主链定时调度",
  15. GroupName = "default", Concurrent = false)]
  16. [Period(S8WatchSchedulerJob.IntervalMs, TriggerId = "trigger_s8_watch_scheduler", Description = "默认 30 分钟一次")]
  17. public class S8WatchSchedulerJob : IJob
  18. {
  19. private readonly IServiceScopeFactory _scopeFactory;
  20. private readonly ISchedulerFactory _schedulerFactory;
  21. private readonly ILogger _logger;
  22. // G-08 调度周期(毫秒)。属性固化锚点,见 G08-02 配置分类表第 2 项。
  23. // 既供 [Period] 使用,也供首次激活日志打印,避免两处数值漂移。
  24. public const int IntervalMs = 1800000;
  25. // G-08 固定租户 / 工厂上下文。与 G-01 验收口径一致。
  26. private const long DefaultTenantId = 1;
  27. private const long DefaultFactoryId = 1;
  28. // G-08 作业与触发器 ID。用于自动停调度时精确定位本作业。
  29. private const string JobId = "job_s8_watch_scheduler";
  30. // G08-05 运行期自动停调度:连续失败阈值(代码常量锚点,见 G08-02 配置分类表第 7 项)。
  31. private const int FailurePauseThreshold = 2;
  32. // G08-02 首次作业激活可见性:作业首次激活时打印一次关键运行参数的生效值,后续周期不重复。
  33. // 仅覆盖属性固化与代码常量两类;appsettings 与作业管理两类的当前值不在此打印。
  34. private static int _firstActivationLogged;
  35. // G08-05 连续失败计数(仅同进程内有效;跨进程由 Admin.NET 作业管理手工介入,见 G08-03)。
  36. private static int _consecutiveFailureCount;
  37. public S8WatchSchedulerJob(
  38. IServiceScopeFactory scopeFactory,
  39. ISchedulerFactory schedulerFactory,
  40. ILoggerFactory loggerFactory)
  41. {
  42. _scopeFactory = scopeFactory;
  43. _schedulerFactory = schedulerFactory;
  44. _logger = loggerFactory.CreateLogger(nameof(S8WatchSchedulerJob));
  45. }
  46. public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
  47. {
  48. if (Interlocked.Exchange(ref _firstActivationLogged, 1) == 0)
  49. {
  50. _logger.LogInformation(
  51. "S8WatchSchedulerJob 首次作业激活参数:IntervalMs={IntervalMs} Concurrent=false TenantId={TenantId} FactoryId={FactoryId}",
  52. IntervalMs, DefaultTenantId, DefaultFactoryId);
  53. }
  54. var triggeredAt = DateTime.Now;
  55. using var scope = _scopeFactory.CreateScope();
  56. var scheduler = scope.ServiceProvider.GetRequiredService<S8WatchSchedulerService>();
  57. try
  58. {
  59. var results = await scheduler.CreateExceptionsAsync(DefaultTenantId, DefaultFactoryId);
  60. var created = results.Count(r => r.Created);
  61. var skipped = results.Count(r => r.Skipped);
  62. var failed = results.Count(r => !r.Created && !r.Skipped);
  63. var firstFailure = results.FirstOrDefault(r => !r.Created && !r.Skipped);
  64. _logger.LogInformation(BuildTraceLine(
  65. triggeredAt: triggeredAt,
  66. result: "success",
  67. hits: results.Count,
  68. created: created,
  69. skipped: skipped,
  70. failed: failed,
  71. failureSummary: TruncateSummary(firstFailure?.ErrorMessage ?? firstFailure?.Reason)));
  72. // 本轮主链未抛异常 → 重置连续失败计数(单条 create_failed 不计入调度失败)。
  73. Interlocked.Exchange(ref _consecutiveFailureCount, 0);
  74. }
  75. catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
  76. {
  77. _logger.LogInformation(BuildTraceLine(
  78. triggeredAt: triggeredAt,
  79. result: "cancelled",
  80. hits: 0, created: 0, skipped: 0, failed: 0,
  81. failureSummary: null));
  82. // 取消不计入连续失败。
  83. }
  84. catch (Exception ex)
  85. {
  86. _logger.LogError(ex, BuildTraceLine(
  87. triggeredAt: triggeredAt,
  88. result: "error",
  89. hits: 0, created: 0, skipped: 0, failed: 0,
  90. failureSummary: TruncateSummary(ex.Message)));
  91. var count = Interlocked.Increment(ref _consecutiveFailureCount);
  92. if (count >= FailurePauseThreshold)
  93. {
  94. TryAutoPause(count, ex);
  95. }
  96. }
  97. }
  98. // G08-05 运行期自动停调度:命中 FailurePauseThreshold 后调用框架原生暂停作业能力;
  99. // 不自建停调度通道;停调度原因单独留痕一行,便于检索。
  100. private void TryAutoPause(int failureCount, Exception lastError)
  101. {
  102. try
  103. {
  104. var scheduleResult = _schedulerFactory.TryPauseJob(JobId, out _);
  105. _logger.LogWarning(
  106. "S8WatchSchedulerJob 自动停调度:FailureCount={FailureCount} Threshold={Threshold} ScheduleResult={ScheduleResult} Reason={Reason}",
  107. failureCount, FailurePauseThreshold, scheduleResult, TruncateSummary(lastError.Message));
  108. }
  109. catch (Exception pauseEx)
  110. {
  111. // 停调度本身失败不重试、不回退;仅记录,等待人工介入。
  112. _logger.LogError(pauseEx,
  113. "S8WatchSchedulerJob 自动停调度调用失败:FailureCount={FailureCount}",
  114. failureCount);
  115. }
  116. }
  117. // G08-04 结构化留痕:单行 JSON 字符串,承载最低 6 字段(触发时间 / 执行结果 / 命中数 / 创建数 / 跳过数 / 失败摘要)。
  118. // 不建新表、不上日志库、不上链路追踪;失败摘要仅截断首条错误,不做异常归集。
  119. private static string BuildTraceLine(
  120. DateTime triggeredAt, string result, int hits, int created, int skipped, int failed, string? failureSummary)
  121. {
  122. var payload = new
  123. {
  124. job = "S8WatchSchedulerJob",
  125. triggeredAt = triggeredAt.ToString("yyyy-MM-dd HH:mm:ss"),
  126. result,
  127. hits,
  128. created,
  129. skipped,
  130. failed,
  131. failureSummary
  132. };
  133. return "S8WatchSchedulerJob 留痕 " + JsonSerializer.Serialize(payload);
  134. }
  135. private static string? TruncateSummary(string? raw)
  136. {
  137. if (string.IsNullOrWhiteSpace(raw)) return null;
  138. var oneLine = raw.Replace('\n', ' ').Replace('\r', ' ').Trim();
  139. return oneLine.Length <= 200 ? oneLine : oneLine[..200];
  140. }
  141. }