using Admin.NET.Plugin.AiDOP.Service.S8; using Furion.Schedule; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Text.Json; namespace Admin.NET.Plugin.AiDOP.Job; /// /// G-08:S8 自动监控主链定时调度作业。 /// 固定租户 / 工厂上下文下,按周期触发一次 G-01 主链()。 /// 本轮仅完成"正式定时触发接入";不在调度层追加业务逻辑、不改写主链入参语义、不做真实 SQL / 认证链路 / SLA 治理。 /// 周期由 [Period] 属性固化为默认值(30 分钟);运行期调整走 Admin.NET 作业管理,不走配置中心。 /// 开关:默认通过 Admin.NET 作业管理的触发器状态控制;本作业构造不自动启动。 /// [JobDetail("job_s8_watch_scheduler", Description = "S8 自动监控主链定时调度", GroupName = "default", Concurrent = false)] [Period(S8WatchSchedulerJob.IntervalMs, TriggerId = "trigger_s8_watch_scheduler", Description = "S8-SCHED-EXEC-1:DB 驱动派发,1 分钟节拍")] public class S8WatchSchedulerJob : IJob { private readonly IServiceScopeFactory _scopeFactory; private readonly IConfiguration _configuration; private readonly ILogger _logger; // S8-SCHED-EXEC-1:节拍 5min → 1min;单规则节奏由 watch_rule.poll_interval_seconds + next_run_at 控制, // Job 仅做 tick;未到期规则在 PickReadyRulesAsync 阶段自然过滤。 public const int IntervalMs = 60000; // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:业务执行节拍配置项与下限。 // 业务节拍由 appsettings 的 S8:Scheduler:WatchTickIntervalMs 承载,默认 300000 ms (5 分钟); // 硬件唤醒拍由 [Period(60000, ...)] 注解承载;下限 60000 ms 与硬件拍对齐,配置小于下限按下限处理。 private const int DefaultWatchTickIntervalMs = 300000; private const int MinWatchTickIntervalMs = 60000; // 默认租户 / 工厂上下文。与 G-01 / S8-SCHED-SCHEMA-1 验收口径一致。 private const long DefaultTenantId = 1; private const long DefaultFactoryId = 1; // 单 tick 最多抢锁规则数;规模上来后可下放到 appsettings。当前 dev 仅 3 条 demo,给 32 留余量。 private const int BatchSize = 32; // 首次激活打印锚点。 private static int _firstActivationLogged; // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:禁用提示锚点 + 业务节拍 due-skip 锚点。 // _firstDisabledLogged 控制禁用日志单进程内只输出一次,避免每 tick 刷日志。 // _lastBusinessTickUtcTicks 记录上一次进入业务体(RunDispatchTickAsync)时的 UTC ticks, // 用于"硬件拍 60s + 业务拍 5min"模式下的 due-skip 判定。 // _firstParseFailLogged 控制 AIDOP_* env 解析失败 warn 单进程内只输出一次(首个失败 env 名)。 private static int _firstDisabledLogged; private static long _lastBusinessTickUtcTicks; private static int _firstParseFailLogged; // S8-SCHEDULER-CONFIG-ORDER-FIX(合入 P0):AIDOP_* env override 名称。 // Furion 4.9.8.24 把 Configuration/*.{Environment}.json 追加在 ASP.NET Core // EnvironmentVariablesConfigurationProvider 之后,JSON 覆盖了 env,故此处用 // Environment.GetEnvironmentVariable 做 S8 调度开关专项 override; // 命名前缀 AIDOP_ 与 SqlSugarSetup.cs 既有约定(AIDOP_DB_WAIT_MAX_SECONDS 等)一致。 private const string EnvSchedulerEnabled = "AIDOP_SCHEDULER_ENABLED"; private const string EnvS8SchedulerEnabled = "AIDOP_S8_SCHEDULER_ENABLED"; private const string EnvS8WatchTickMs = "AIDOP_S8_SCHEDULER_WATCH_TICK_MS"; public S8WatchSchedulerJob( IServiceScopeFactory scopeFactory, IConfiguration configuration, ILoggerFactory loggerFactory) { _scopeFactory = scopeFactory; _configuration = configuration; _logger = loggerFactory.CreateLogger(nameof(S8WatchSchedulerJob)); } public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) { // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:环境级 + S8 业务级双开关 gate。 // 任一为 false 时直接早退;早退前不访问任何业务 service / DB。 // env override(合入 CONFIG-ORDER-FIX):env 不存在/空白 → 沿用 JSON; // 存在但 bool.TryParse 失败 → 保留 JSON 值 + 单次 warn(首个失败 env 名,不输出 value)。 var schedulerEnabled = _configuration.GetValue("Scheduler:Enabled", true); var s8SchedulerEnabled = _configuration.GetValue("S8:Scheduler:Enabled", false); string? parseFailEnvName = null; var envSchedulerRaw = Environment.GetEnvironmentVariable(EnvSchedulerEnabled); if (!string.IsNullOrWhiteSpace(envSchedulerRaw)) { if (bool.TryParse(envSchedulerRaw, out var v)) schedulerEnabled = v; else parseFailEnvName ??= EnvSchedulerEnabled; } var envS8SchedulerRaw = Environment.GetEnvironmentVariable(EnvS8SchedulerEnabled); if (!string.IsNullOrWhiteSpace(envS8SchedulerRaw)) { if (bool.TryParse(envS8SchedulerRaw, out var v)) s8SchedulerEnabled = v; else parseFailEnvName ??= EnvS8SchedulerEnabled; } if (parseFailEnvName != null && Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0) { _logger.LogWarning( "S8WatchSchedulerJob env override 解析失败:{EnvName},已沿用配置值", parseFailEnvName); } if (!schedulerEnabled || !s8SchedulerEnabled) { if (Interlocked.Exchange(ref _firstDisabledLogged, 1) == 0) { _logger.LogInformation( "S8WatchSchedulerJob 被配置禁用:Scheduler:Enabled={Scheduler} S8:Scheduler:Enabled={S8Scheduler}", schedulerEnabled, s8SchedulerEnabled); } return; } // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:业务节拍 due-skip。 // 硬件唤醒拍仍由 [Period(60000, ...)] 维持,但业务体只在配置的 WatchTickIntervalMs 周期上执行。 // 首次启用时 _lastBusinessTickUtcTicks==0,立即放行;后续 tick 不足 interval 时早退。 // 早退前不调用 S8WatchSchedulerService、不查询/写入数据库。 // env override(合入 CONFIG-ORDER-FIX):env 存在且 int.TryParse 成功 → 覆盖 JSON; // 解析失败 → 保留 JSON 值 + 单次 warn(与 bool 解析失败共用 _firstParseFailLogged 锚点)。 // clamp 规则与 P0 一致:小于 MinWatchTickIntervalMs(60000)时强制按下限处理。 var tickIntervalMs = _configuration.GetValue("S8:Scheduler:WatchTickIntervalMs", DefaultWatchTickIntervalMs); var envTickRaw = Environment.GetEnvironmentVariable(EnvS8WatchTickMs); if (!string.IsNullOrWhiteSpace(envTickRaw)) { if (int.TryParse(envTickRaw, out var v)) tickIntervalMs = v; else if (Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0) { _logger.LogWarning( "S8WatchSchedulerJob env override 解析失败:{EnvName},已沿用配置值", EnvS8WatchTickMs); } } if (tickIntervalMs < MinWatchTickIntervalMs) tickIntervalMs = MinWatchTickIntervalMs; var nowUtcTicks = DateTime.UtcNow.Ticks; var lastTicks = Interlocked.Read(ref _lastBusinessTickUtcTicks); if (lastTicks != 0) { var elapsedMs = (nowUtcTicks - lastTicks) / TimeSpan.TicksPerMillisecond; if (elapsedMs < tickIntervalMs) return; } Interlocked.Exchange(ref _lastBusinessTickUtcTicks, nowUtcTicks); if (Interlocked.Exchange(ref _firstActivationLogged, 1) == 0) { _logger.LogInformation( "S8WatchSchedulerJob 首次激活:IntervalMs={IntervalMs} TickIntervalMs={TickInterval} BatchSize={Batch} TenantId={Tenant} FactoryId={Factory} LockedBy={LockedBy}", IntervalMs, tickIntervalMs, BatchSize, DefaultTenantId, DefaultFactoryId, BuildLockedBy()); } var triggeredAt = DateTime.Now; using var scope = _scopeFactory.CreateScope(); var scheduler = scope.ServiceProvider.GetRequiredService(); try { var lockedBy = BuildLockedBy(); var summary = await scheduler.RunDispatchTickAsync(DefaultTenantId, DefaultFactoryId, BatchSize, lockedBy); // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:完全空跑(6 项摘要全 0)降为 LogDebug; // 任何非零字段(picked/failed/perRuleFailed/created/refreshed/leaseReleased)保持 LogInformation。 if (IsEmptyTick(summary)) _logger.LogDebug(BuildTraceLine(triggeredAt, "success", summary, null)); else _logger.LogInformation(BuildTraceLine(triggeredAt, "success", summary, null)); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { _logger.LogInformation(BuildTraceLine(triggeredAt, "cancelled", null, null)); } catch (Exception ex) { // RunDispatchTickAsync 内部已吞所有单规则异常;此处仅兜底。 _logger.LogError(ex, BuildTraceLine(triggeredAt, "error", null, TruncateSummary(ex.Message))); } } private static string BuildLockedBy() { try { var pid = Environment.ProcessId; return $"{Environment.MachineName}-{pid}"; } catch { return "unknown-host"; } } // 单行 JSON 留痕:tick 维度 6 字段(触发时间 / 执行结果 / picked / created / refreshed / pending / failed / leaseReleased / runId)。 private static string BuildTraceLine( DateTime triggeredAt, string result, S8DispatchTickResult? summary, string? failureSummary) { var payload = new { job = "S8WatchSchedulerJob", triggeredAt = triggeredAt.ToString("yyyy-MM-dd HH:mm:ss"), result, tickId = summary?.TickId, runId = summary?.RunId, picked = summary?.Picked ?? 0, success = summary?.Success ?? 0, ruleFailed = summary?.Failed ?? 0, created = summary?.Created ?? 0, refreshed = summary?.Refreshed ?? 0, pending = summary?.Pending ?? 0, perRuleFailed = summary?.PerRuleFailed ?? 0, leaseReleased = summary?.LeaseReleased ?? 0, 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]; } // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:完全空跑判定。 // 6 项摘要全 0 → 视为本 tick 无任何业务变化,日志降级到 Debug。 private static bool IsEmptyTick(S8DispatchTickResult? summary) { if (summary == null) return true; return summary.Picked == 0 && summary.Failed == 0 && summary.PerRuleFailed == 0 && summary.Created == 0 && summary.Refreshed == 0 && summary.LeaseReleased == 0; } }