S8WatchSchedulerJob.cs 7.4 KB

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