S8WatchSchedulerJob.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. using Admin.NET.Plugin.AiDOP.Service.S8;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  3. using Furion.Schedule;
  4. using Microsoft.Extensions.Configuration;
  5. using Microsoft.Extensions.DependencyInjection;
  6. using Microsoft.Extensions.Logging;
  7. using System.Text.Json;
  8. namespace Admin.NET.Plugin.AiDOP.Job;
  9. /// <summary>
  10. /// G-08:S8 自动监控主链定时调度作业。
  11. /// 固定租户 / 工厂上下文下,按周期触发一次 G-01 主链(<see cref="S8WatchSchedulerService.CreateExceptionsAsync"/>)。
  12. /// 本轮仅完成"正式定时触发接入";不在调度层追加业务逻辑、不改写主链入参语义、不做真实 SQL / 认证链路 / SLA 治理。
  13. /// 周期由 [Period] 属性固化为默认值(30 分钟);运行期调整走 Admin.NET 作业管理,不走配置中心。
  14. /// 开关:默认通过 Admin.NET 作业管理的触发器状态控制;本作业构造不自动启动。
  15. /// </summary>
  16. [JobDetail("job_s8_watch_scheduler", Description = "S8 自动监控主链定时调度",
  17. GroupName = "default", Concurrent = false)]
  18. [Period(S8WatchSchedulerJob.IntervalMs, TriggerId = "trigger_s8_watch_scheduler", Description = "S8-SCHED-EXEC-1:DB 驱动派发,1 分钟节拍")]
  19. public class S8WatchSchedulerJob : IJob
  20. {
  21. private readonly IServiceScopeFactory _scopeFactory;
  22. private readonly IConfiguration _configuration;
  23. private readonly ILogger _logger;
  24. // S8-SCHED-EXEC-1:节拍 5min → 1min;单规则节奏由 watch_rule.poll_interval_seconds + next_run_at 控制,
  25. // Job 仅做 tick;未到期规则在 PickReadyRulesAsync 阶段自然过滤。
  26. public const int IntervalMs = 60000;
  27. // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:业务执行节拍配置项与下限。
  28. // 业务节拍由 appsettings 的 S8:Scheduler:WatchTickIntervalMs 承载,默认 300000 ms (5 分钟);
  29. // 硬件唤醒拍由 [Period(60000, ...)] 注解承载;下限 60000 ms 与硬件拍对齐,配置小于下限按下限处理。
  30. private const int DefaultWatchTickIntervalMs = 300000;
  31. private const int MinWatchTickIntervalMs = 60000;
  32. // 单 tick 最多抢锁规则数;规模上来后可下放到 appsettings。当前 dev 仅 3 条 demo,给 32 留余量。
  33. private const int BatchSize = 32;
  34. // 首次激活打印锚点。
  35. private static int _firstActivationLogged;
  36. // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:禁用提示锚点 + 业务节拍 due-skip 锚点。
  37. // _firstDisabledLogged 控制禁用日志单进程内只输出一次,避免每 tick 刷日志。
  38. // _lastBusinessTickUtcTicks 记录上一次进入业务体(RunDispatchTickAsync)时的 UTC ticks,
  39. // 用于"硬件拍 60s + 业务拍 5min"模式下的 due-skip 判定。
  40. // _firstParseFailLogged 控制 AIDOP_* env 解析失败 warn 单进程内只输出一次(首个失败 env 名)。
  41. private static int _firstDisabledLogged;
  42. private static long _lastBusinessTickUtcTicks;
  43. private static int _firstParseFailLogged;
  44. // S8-SCHEDULER-CONFIG-ORDER-FIX(合入 P0):AIDOP_* env override 名称。
  45. // Furion 4.9.8.24 把 Configuration/*.{Environment}.json 追加在 ASP.NET Core
  46. // EnvironmentVariablesConfigurationProvider 之后,JSON 覆盖了 env,故此处用
  47. // Environment.GetEnvironmentVariable 做 S8 调度开关专项 override;
  48. // 命名前缀 AIDOP_ 与 SqlSugarSetup.cs 既有约定(AIDOP_DB_WAIT_MAX_SECONDS 等)一致。
  49. private const string EnvS8WatchTickMs = "AIDOP_S8_SCHEDULER_WATCH_TICK_MS";
  50. public S8WatchSchedulerJob(
  51. IServiceScopeFactory scopeFactory,
  52. IConfiguration configuration,
  53. ILoggerFactory loggerFactory)
  54. {
  55. _scopeFactory = scopeFactory;
  56. _configuration = configuration;
  57. _logger = loggerFactory.CreateLogger(nameof(S8WatchSchedulerJob));
  58. }
  59. public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
  60. {
  61. // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:环境级 + S8 业务级双开关 gate。
  62. // 任一为 false 时直接早退;早退前不访问任何业务 service / DB。
  63. // env override(合入 CONFIG-ORDER-FIX):env 不存在/空白 → 沿用 JSON;
  64. // 存在但 bool.TryParse 失败 → 保留 JSON 值 + 单次 warn(首个失败 env 名,不输出 value)。
  65. var gate = S8JobSwitch.Evaluate(_configuration, S8BackgroundJob.WatchScheduler);
  66. if (gate.ParseFailEnvName != null && Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0)
  67. {
  68. _logger.LogWarning(
  69. "S8WatchSchedulerJob env override 解析失败:{EnvName},已沿用配置值",
  70. gate.ParseFailEnvName);
  71. }
  72. if (!gate.Enabled)
  73. {
  74. if (Interlocked.Exchange(ref _firstDisabledLogged, 1) == 0)
  75. {
  76. _logger.LogInformation(
  77. "S8WatchSchedulerJob 被配置禁用:Scheduler:Enabled={Scheduler} S8:Scheduler:Enabled={S8Scheduler} {JobKey}={JobEnabled}",
  78. gate.EnvironmentEnabled, gate.MasterEnabled,
  79. S8JobSwitch.JobEnabledKey(S8BackgroundJob.WatchScheduler), gate.JobEnabled);
  80. }
  81. return;
  82. }
  83. // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:业务节拍 due-skip。
  84. // 硬件唤醒拍仍由 [Period(60000, ...)] 维持,但业务体只在配置的 WatchTickIntervalMs 周期上执行。
  85. // 首次启用时 _lastBusinessTickUtcTicks==0,立即放行;后续 tick 不足 interval 时早退。
  86. // 早退前不调用 S8WatchSchedulerService、不查询/写入数据库。
  87. // env override(合入 CONFIG-ORDER-FIX):env 存在且 int.TryParse 成功 → 覆盖 JSON;
  88. // 解析失败 → 保留 JSON 值 + 单次 warn(与 bool 解析失败共用 _firstParseFailLogged 锚点)。
  89. // clamp 规则与 P0 一致:小于 MinWatchTickIntervalMs(60000)时强制按下限处理。
  90. var tickIntervalMs = _configuration.GetValue("S8:Scheduler:WatchTickIntervalMs", DefaultWatchTickIntervalMs);
  91. var envTickRaw = Environment.GetEnvironmentVariable(EnvS8WatchTickMs);
  92. if (!string.IsNullOrWhiteSpace(envTickRaw))
  93. {
  94. if (int.TryParse(envTickRaw, out var v)) tickIntervalMs = v;
  95. else if (Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0)
  96. {
  97. _logger.LogWarning(
  98. "S8WatchSchedulerJob env override 解析失败:{EnvName},已沿用配置值",
  99. EnvS8WatchTickMs);
  100. }
  101. }
  102. if (tickIntervalMs < MinWatchTickIntervalMs) tickIntervalMs = MinWatchTickIntervalMs;
  103. var nowUtcTicks = DateTime.UtcNow.Ticks;
  104. var lastTicks = Interlocked.Read(ref _lastBusinessTickUtcTicks);
  105. if (lastTicks != 0)
  106. {
  107. var elapsedMs = (nowUtcTicks - lastTicks) / TimeSpan.TicksPerMillisecond;
  108. if (elapsedMs < tickIntervalMs) return;
  109. }
  110. Interlocked.Exchange(ref _lastBusinessTickUtcTicks, nowUtcTicks);
  111. var triggeredAt = DateTime.Now;
  112. using var scope = _scopeFactory.CreateScope();
  113. var scheduler = scope.ServiceProvider.GetRequiredService<S8WatchSchedulerService>();
  114. try
  115. {
  116. var lockedBy = BuildLockedBy();
  117. var scopes = await scheduler.ListEnabledScopesAsync();
  118. if (Interlocked.Exchange(ref _firstActivationLogged, 1) == 0)
  119. {
  120. _logger.LogInformation(
  121. "S8WatchSchedulerJob 首次激活:IntervalMs={IntervalMs} TickIntervalMs={TickInterval} BatchSize={Batch} ScopeCount={ScopeCount} LockedBy={LockedBy}",
  122. IntervalMs, tickIntervalMs, BatchSize, scopes.Count, lockedBy);
  123. }
  124. foreach (var tenantScope in scopes)
  125. {
  126. var summary = await scheduler.RunDispatchTickAsync(
  127. tenantScope.TenantId, tenantScope.FactoryId, BatchSize, lockedBy);
  128. // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:完全空跑(6 项摘要全 0)降为 LogDebug;
  129. // 任何非零字段(picked/failed/perRuleFailed/created/refreshed/leaseReleased)保持 LogInformation。
  130. if (IsEmptyTick(summary))
  131. _logger.LogDebug(BuildTraceLine(triggeredAt, "success", summary, null, tenantScope.TenantId, tenantScope.FactoryId));
  132. else
  133. _logger.LogInformation(BuildTraceLine(triggeredAt, "success", summary, null, tenantScope.TenantId, tenantScope.FactoryId));
  134. }
  135. }
  136. catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
  137. {
  138. _logger.LogInformation(BuildTraceLine(triggeredAt, "cancelled", null, null));
  139. }
  140. catch (Exception ex)
  141. {
  142. // RunDispatchTickAsync 内部已吞所有单规则异常;此处仅兜底。
  143. _logger.LogError(ex, BuildTraceLine(triggeredAt, "error", null, TruncateSummary(ex.Message)));
  144. }
  145. }
  146. private static string BuildLockedBy()
  147. {
  148. try
  149. {
  150. var pid = Environment.ProcessId;
  151. return $"{Environment.MachineName}-{pid}";
  152. }
  153. catch
  154. {
  155. return "unknown-host";
  156. }
  157. }
  158. // 单行 JSON 留痕:tick 维度 6 字段(触发时间 / 执行结果 / picked / created / refreshed / pending / failed / leaseReleased / runId)。
  159. private static string BuildTraceLine(
  160. DateTime triggeredAt,
  161. string result,
  162. S8DispatchTickResult? summary,
  163. string? failureSummary,
  164. long? tenantId = null,
  165. long? factoryId = null)
  166. {
  167. var payload = new
  168. {
  169. job = "S8WatchSchedulerJob",
  170. triggeredAt = triggeredAt.ToString("yyyy-MM-dd HH:mm:ss"),
  171. result,
  172. tenantId,
  173. factoryId,
  174. tickId = summary?.TickId,
  175. runId = summary?.RunId,
  176. picked = summary?.Picked ?? 0,
  177. success = summary?.Success ?? 0,
  178. ruleFailed = summary?.Failed ?? 0,
  179. created = summary?.Created ?? 0,
  180. refreshed = summary?.Refreshed ?? 0,
  181. pending = summary?.Pending ?? 0,
  182. perRuleFailed = summary?.PerRuleFailed ?? 0,
  183. leaseReleased = summary?.LeaseReleased ?? 0,
  184. failureSummary
  185. };
  186. return "S8WatchSchedulerJob 留痕 " + JsonSerializer.Serialize(payload);
  187. }
  188. private static string? TruncateSummary(string? raw)
  189. {
  190. if (string.IsNullOrWhiteSpace(raw)) return null;
  191. var oneLine = raw.Replace('\n', ' ').Replace('\r', ' ').Trim();
  192. return oneLine.Length <= 200 ? oneLine : oneLine[..200];
  193. }
  194. // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:完全空跑判定。
  195. // 6 项摘要全 0 → 视为本 tick 无任何业务变化,日志降级到 Debug。
  196. private static bool IsEmptyTick(S8DispatchTickResult? summary)
  197. {
  198. if (summary == null) return true;
  199. return summary.Picked == 0
  200. && summary.Failed == 0
  201. && summary.PerRuleFailed == 0
  202. && summary.Created == 0
  203. && summary.Refreshed == 0
  204. && summary.LeaseReleased == 0;
  205. }
  206. }