|
|
@@ -0,0 +1,156 @@
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8;
|
|
|
+using Furion.Schedule;
|
|
|
+using Microsoft.Extensions.DependencyInjection;
|
|
|
+using Microsoft.Extensions.Logging;
|
|
|
+using System.Text.Json;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Job;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// G-08:S8 自动监控主链定时调度作业。
|
|
|
+/// 固定租户 / 工厂上下文下,按周期触发一次 G-01 主链(<see cref="S8WatchSchedulerService.CreateExceptionsAsync"/>)。
|
|
|
+/// 本轮仅完成"正式定时触发接入";不在调度层追加业务逻辑、不改写主链入参语义、不做真实 SQL / 认证链路 / SLA 治理。
|
|
|
+/// 周期由 [Period] 属性固化为默认值(30 分钟);运行期调整走 Admin.NET 作业管理,不走配置中心。
|
|
|
+/// 开关:默认通过 Admin.NET 作业管理的触发器状态控制;本作业构造不自动启动。
|
|
|
+/// </summary>
|
|
|
+[JobDetail("job_s8_watch_scheduler", Description = "S8 自动监控主链定时调度",
|
|
|
+ GroupName = "default", Concurrent = false)]
|
|
|
+[Period(S8WatchSchedulerJob.IntervalMs, TriggerId = "trigger_s8_watch_scheduler", Description = "默认 30 分钟一次")]
|
|
|
+public class S8WatchSchedulerJob : IJob
|
|
|
+{
|
|
|
+ private readonly IServiceScopeFactory _scopeFactory;
|
|
|
+ private readonly ISchedulerFactory _schedulerFactory;
|
|
|
+ private readonly ILogger _logger;
|
|
|
+
|
|
|
+ // G-08 调度周期(毫秒)。属性固化锚点,见 G08-02 配置分类表第 2 项。
|
|
|
+ // 既供 [Period] 使用,也供首次激活日志打印,避免两处数值漂移。
|
|
|
+ public const int IntervalMs = 1800000;
|
|
|
+
|
|
|
+ // G-08 固定租户 / 工厂上下文。与 G-01 验收口径一致。
|
|
|
+ private const long DefaultTenantId = 1;
|
|
|
+ private const long DefaultFactoryId = 1;
|
|
|
+
|
|
|
+ // G-08 作业与触发器 ID。用于自动停调度时精确定位本作业。
|
|
|
+ private const string JobId = "job_s8_watch_scheduler";
|
|
|
+
|
|
|
+ // G08-05 运行期自动停调度:连续失败阈值(代码常量锚点,见 G08-02 配置分类表第 7 项)。
|
|
|
+ private const int FailurePauseThreshold = 2;
|
|
|
+
|
|
|
+ // G08-02 首次作业激活可见性:作业首次激活时打印一次关键运行参数的生效值,后续周期不重复。
|
|
|
+ // 仅覆盖属性固化与代码常量两类;appsettings 与作业管理两类的当前值不在此打印。
|
|
|
+ private static int _firstActivationLogged;
|
|
|
+
|
|
|
+ // G08-05 连续失败计数(仅同进程内有效;跨进程由 Admin.NET 作业管理手工介入,见 G08-03)。
|
|
|
+ private static int _consecutiveFailureCount;
|
|
|
+
|
|
|
+ public S8WatchSchedulerJob(
|
|
|
+ IServiceScopeFactory scopeFactory,
|
|
|
+ ISchedulerFactory schedulerFactory,
|
|
|
+ ILoggerFactory loggerFactory)
|
|
|
+ {
|
|
|
+ _scopeFactory = scopeFactory;
|
|
|
+ _schedulerFactory = schedulerFactory;
|
|
|
+ _logger = loggerFactory.CreateLogger(nameof(S8WatchSchedulerJob));
|
|
|
+ }
|
|
|
+
|
|
|
+ public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
|
|
|
+ {
|
|
|
+ if (Interlocked.Exchange(ref _firstActivationLogged, 1) == 0)
|
|
|
+ {
|
|
|
+ _logger.LogInformation(
|
|
|
+ "S8WatchSchedulerJob 首次作业激活参数:IntervalMs={IntervalMs} Concurrent=false TenantId={TenantId} FactoryId={FactoryId}",
|
|
|
+ IntervalMs, DefaultTenantId, DefaultFactoryId);
|
|
|
+ }
|
|
|
+
|
|
|
+ var triggeredAt = DateTime.Now;
|
|
|
+ using var scope = _scopeFactory.CreateScope();
|
|
|
+ var scheduler = scope.ServiceProvider.GetRequiredService<S8WatchSchedulerService>();
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var results = await scheduler.CreateExceptionsAsync(DefaultTenantId, DefaultFactoryId);
|
|
|
+ var created = results.Count(r => r.Created);
|
|
|
+ var skipped = results.Count(r => r.Skipped);
|
|
|
+ var failed = results.Count(r => !r.Created && !r.Skipped);
|
|
|
+ var firstFailure = results.FirstOrDefault(r => !r.Created && !r.Skipped);
|
|
|
+ _logger.LogInformation(BuildTraceLine(
|
|
|
+ triggeredAt: triggeredAt,
|
|
|
+ result: "success",
|
|
|
+ hits: results.Count,
|
|
|
+ created: created,
|
|
|
+ skipped: skipped,
|
|
|
+ failed: failed,
|
|
|
+ failureSummary: TruncateSummary(firstFailure?.ErrorMessage ?? firstFailure?.Reason)));
|
|
|
+ // 本轮主链未抛异常 → 重置连续失败计数(单条 create_failed 不计入调度失败)。
|
|
|
+ Interlocked.Exchange(ref _consecutiveFailureCount, 0);
|
|
|
+ }
|
|
|
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
|
+ {
|
|
|
+ _logger.LogInformation(BuildTraceLine(
|
|
|
+ triggeredAt: triggeredAt,
|
|
|
+ result: "cancelled",
|
|
|
+ hits: 0, created: 0, skipped: 0, failed: 0,
|
|
|
+ failureSummary: null));
|
|
|
+ // 取消不计入连续失败。
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ _logger.LogError(ex, BuildTraceLine(
|
|
|
+ triggeredAt: triggeredAt,
|
|
|
+ result: "error",
|
|
|
+ hits: 0, created: 0, skipped: 0, failed: 0,
|
|
|
+ failureSummary: TruncateSummary(ex.Message)));
|
|
|
+ var count = Interlocked.Increment(ref _consecutiveFailureCount);
|
|
|
+ if (count >= FailurePauseThreshold)
|
|
|
+ {
|
|
|
+ TryAutoPause(count, ex);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // G08-05 运行期自动停调度:命中 FailurePauseThreshold 后调用框架原生暂停作业能力;
|
|
|
+ // 不自建停调度通道;停调度原因单独留痕一行,便于检索。
|
|
|
+ private void TryAutoPause(int failureCount, Exception lastError)
|
|
|
+ {
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var scheduleResult = _schedulerFactory.TryPauseJob(JobId, out _);
|
|
|
+ _logger.LogWarning(
|
|
|
+ "S8WatchSchedulerJob 自动停调度:FailureCount={FailureCount} Threshold={Threshold} ScheduleResult={ScheduleResult} Reason={Reason}",
|
|
|
+ failureCount, FailurePauseThreshold, scheduleResult, TruncateSummary(lastError.Message));
|
|
|
+ }
|
|
|
+ catch (Exception pauseEx)
|
|
|
+ {
|
|
|
+ // 停调度本身失败不重试、不回退;仅记录,等待人工介入。
|
|
|
+ _logger.LogError(pauseEx,
|
|
|
+ "S8WatchSchedulerJob 自动停调度调用失败:FailureCount={FailureCount}",
|
|
|
+ failureCount);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // G08-04 结构化留痕:单行 JSON 字符串,承载最低 6 字段(触发时间 / 执行结果 / 命中数 / 创建数 / 跳过数 / 失败摘要)。
|
|
|
+ // 不建新表、不上日志库、不上链路追踪;失败摘要仅截断首条错误,不做异常归集。
|
|
|
+ private static string BuildTraceLine(
|
|
|
+ DateTime triggeredAt, string result, int hits, int created, int skipped, int failed, string? failureSummary)
|
|
|
+ {
|
|
|
+ var payload = new
|
|
|
+ {
|
|
|
+ job = "S8WatchSchedulerJob",
|
|
|
+ triggeredAt = triggeredAt.ToString("yyyy-MM-dd HH:mm:ss"),
|
|
|
+ result,
|
|
|
+ hits,
|
|
|
+ created,
|
|
|
+ skipped,
|
|
|
+ failed,
|
|
|
+ failureSummary
|
|
|
+ };
|
|
|
+ return "S8WatchSchedulerJob 留痕 " + JsonSerializer.Serialize(payload);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string? TruncateSummary(string? raw)
|
|
|
+ {
|
|
|
+ if (string.IsNullOrWhiteSpace(raw)) return null;
|
|
|
+ var oneLine = raw.Replace('\n', ' ').Replace('\r', ' ').Trim();
|
|
|
+ return oneLine.Length <= 200 ? oneLine : oneLine[..200];
|
|
|
+ }
|
|
|
+}
|