调度与后台作业
本章讲怎样在应用里按计划运行后台工作:编写作业、登记作业与触发器、选择计划与 misfire 策略、重试与执行时限、运行时管理作业、多实例部署下的集群分发与领导选举。读者是要给特性加定时任务,或者要在多个实例间分配作业的开发者。
完整 API 见 Scheduling.Abstractions 包。
作业、触发器与运行
调度子系统沿用 Quartz 的分法:作业描述「做什么」,触发器描述「什么时候做」。一个作业可以挂多个触发器,每个触发器有自己的计划、数据、优先级和 misfire 策略,各自推算下一次运行。作业没有触发器时只能手动触发,定义照样留在存储里。触发器每到期一次,或者被手动触发、失败重试、失联恢复一次,就产生一次运行(JobRun)。
运行与一次请求同构。JobDispatcher 为每次运行创建新的 DI 作用域和新的调用上下文,经应用的完整管道执行 IJob.RunAsync,所以租户、授权、审计、追踪中间件对作业照常生效。上下文的关联 ID(xihan.correlation)就是运行 ID,来源是 InvocationProtocols.Job,目标是作业名,日志和追踪里能按运行 ID 串起一次执行。作业返回失败的 Result 记为失败;抛出的异常被翻译成 SCHEDULING_UNEXPECTED_001,同样记为失败。
引擎和这些规则都在抽象包里(ADR-0020):计划推算、misfire、并发阻塞、重试、失联恢复、历史只实现一份。Provider 只需实现作业存储 IJobStore 与领导选举 ILeaderElection,内存实现和数据库实现因此行为一致,并且通过同一个一致性套件。框架不引入 Quartz.NET,cron、日历、misfire、集群分发都是自实现的,也没有 Scheduling.Quartz 包。
选择 Provider
调度提供两个能力(RFC-0013《调度与领导选举》):
| 能力 | 服务 | 默认实现 | 显式 Provider |
|---|---|---|---|
scheduling.jobs@1 | IJobScheduler、JobSchedulerEngine、IJobStore | Scheduling.InMemory | Scheduling.Database |
scheduling.leader@1 | ILeaderElection | Scheduling.InMemory(进程内租约) | Scheduling.Database(租约行)、Scheduling.Kubernetes(Lease 对象) |
三种常见部署:
| 部署 | 装配 | 效果 |
|---|---|---|
| 单实例 | InMemorySchedulingFeature(Profiles.Full 已包含) | 作业、触发器、历史都在进程内,重启后运行时登记的作业与历史丢失 |
| Kubernetes 多副本,作业只需在一个 Pod 上跑 | Full 组合加 KubernetesSchedulingFeature | 存储仍在各自进程内,只有持有 Lease 的 Pod 按计划运行集群唯一的作业;运行时登记的作业不在副本间共享 |
| 多实例,需要持久化与分发 | 持久化 Provider 加 DatabaseSchedulingFeature | 所有节点共用一个库,每条到期触发器由先抢到的节点运行,节点失联由领导者恢复 |
内存存储不在实例之间共享
只装配 Scheduling.InMemory 时,每个实例都有自己的存储和自己的进程内租约,每个实例都当自己是领导者,集群唯一的作业会在每个实例上各跑一份。多实例部署至少要换掉领导选举(Scheduling.Kubernetes),需要跨实例共享运行时作业、历史与失联恢复时换 Scheduling.Database。
编写与登记作业
作业实现 IJob,是作用域服务,可以注入作用域内的其他服务。运行信息在 JobRun 里:Id、Name、TriggerName(手动运行为空)、ScheduledAt、Trigger(Schedule、Manual、Recovery、Retry)、Attempt(第几次尝试,从 1 起)、RetryOf(重试时为首次运行的 ID)与 Data。
using XiHan.Framework.Primitives;
using XiHan.Framework.Scheduling.Abstractions.Jobs;
public interface IReportService
{
ValueTask<Result> GenerateAsync(string format, CancellationToken cancellationToken);
}
public sealed class NightlyReportJob(IReportService reports) : IJob
{
public ValueTask<Result> RunAsync(JobRun run, InvocationContext context, CancellationToken cancellationToken)
{
// Data 已按 作业 ← 触发器 ← 手动触发 逐层合并
var format = run.Data["format"]?.GetValue<string>() ?? "pdf";
return reports.GenerateAsync(format, cancellationToken);
}
}cancellationToken 在三种情况下取消:有人请求取消这次运行(CancelRunsAsync)、超过作业的执行时限、宿主停止。作业要把它传给下游调用,否则时限与取消都要等作业自己返回才生效。管道内的其他代码可以用 JobDispatcher.GetRun(context) 取到当前运行。
登记有四个扩展方法,都在 IServiceCollection 上,可以在 Program.cs 里调用,也可以在特性的 Configure 里调用:
| 方法 | 作用 |
|---|---|
AddJob<TJob>(name, schedule) | 登记作业类型和一个代码作业;计划不是 Manual 时生成一个名为 default 的触发器 |
AddJob<TJob>(JobDefinition) | 登记作业类型和一个完整定义的代码作业(多个触发器、数据、并发、重试、时限) |
AddJobType<TJob>() / AddJobType<TJob>(key) | 只登记作业类型(键缺省为类型全名),供运行时用 ScheduleAsync 新建的作业引用 |
AddJobCalendar(name, calendar) | 按名登记排除日历,触发器的计划用 Calendar 引用 |
同名重复 AddJob 是编程错误,启动时抛出。代码登记的作业在引擎首个周期写入存储,来源记为 Code。
using System.Text.Json.Nodes;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Primitives;
using XiHan.Framework.Scheduling.Abstractions;
using XiHan.Framework.Scheduling.Abstractions.Calendars;
using XiHan.Framework.Scheduling.Abstractions.Jobs;
using XiHan.Framework.Scheduling.Abstractions.Schedules;
using XiHan.Framework.Scheduling.InMemory;
var builder = WebApplication.CreateBuilder(args);
var shanghai = TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai");
builder.Services.AddJobCalendar("holidays", new HolidayCalendar([new DateOnly(2026, 10, 1), new DateOnly(2026, 10, 2)], shanghai));
// 最简写法:一个 cron 触发器
builder.Services.AddJob<CleanupJob>("cleanup.nightly", JobSchedule.FromCron("0 3 * * *", "Asia/Shanghai"));
// 完整定义:两个触发器、作业数据、重试与执行时限
builder.Services.AddJob<NightlyReportJob>(new JobDefinition("reports.nightly", typeof(NightlyReportJob))
{
Triggers =
[
new JobTriggerDefinition("workdays", JobSchedule.FromCron("0 0 6 * * MON-FRI", "Asia/Shanghai") with { Calendar = "holidays" }),
new JobTriggerDefinition("month-end", JobSchedule.FromCron("0 0 22 L * ?", "Asia/Shanghai"))
{
Data = new JsonObject { ["format"] = "xlsx" },
Priority = 8,
MisfirePolicy = MisfirePolicy.Skip,
},
],
Data = new JsonObject { ["format"] = "pdf" },
Retry = JobRetryPolicy.Exponential(3, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(30)),
Timeout = TimeSpan.FromMinutes(20),
RequestsRecovery = true,
});
builder.AddXiHan(xihan => xihan.UseFeature<InMemorySchedulingFeature>());
var app = builder.Build();
app.UseXiHan();
app.Run();
public sealed class CleanupJob : IJob
{
public ValueTask<Result> RunAsync(JobRun run, InvocationContext context, CancellationToken cancellationToken)
=> ValueTask.FromResult(Result.Success());
}NightlyReportJob 来自上一个示例,IReportService 的实现由应用自己注册。
JobDefinition 的其余选项:
| 属性 | 缺省 | 说明 |
|---|---|---|
Triggers | 空 | 空为只能手动触发;同一作业内触发器名重复是 SCHEDULING_VALIDATION_001 |
Data | 空 | 传给作业的数据,被触发器 Data、手动触发的数据逐层覆盖同名键 |
AllowConcurrent | false | false 时整个集群同一作业同一时刻最多一次运行 |
RunOnEveryInstance | false | true 时每个实例按自己的时钟各自运行,不经存储抢占 |
RequestsRecovery | false | 运行所在节点失联时由领导者重新入队一次(JobTrigger.Recovery) |
Retry | 空 | 失败运行的重试策略,见重试与执行时限 |
Timeout | 空 | 单次执行时限,大于零、不超过 30 天(JobDefinition.TimeoutLimit) |
Description | 空 | 说明 |
作业名、触发器名与领导租约的资源名用同一个文法:^[a-z0-9][a-z0-9._-]{0,127}$(JobName.MaxLength = 128)。JobName 可以从字符串隐式转换,不合法时抛 ArgumentException;名字来自外部输入时先用 JobName.IsValid 判断。
JobTriggerDefinition 有 Priority(缺省 5,JobTriggerDefinition.DefaultPriority;到期的触发器多于本周期可运行名额时,大者先运行)、MisfirePolicy(缺省 RunOnce)、Data 与 Description。触发器的计划不能是 Manual:没有触发器的作业已经是只手动触发。
计划
JobSchedule 有四种:
| 写法 | 含义 |
|---|---|
JobSchedule.Manual | 只手动触发 |
JobSchedule.Every(interval, repeatCount) | 固定延迟:上一次运行完成后再等一个间隔;repeatCount 是总运行次数,空为无限 |
JobSchedule.FromCron(expression, timeZoneId) | cron 表达式;时区是 TimeZoneInfo 的 ID(IANA 或 Windows 均可),空为 UTC,找不到的时区抛 ArgumentException |
JobSchedule.Once(at) | 在 at 运行一次 |
每种计划都可以用 with 加三个窗口条件:StartAt(之前不运行,间隔计划的首次落在 StartAt + 间隔)、EndAt(之后没有下一次,触发器转为 Complete)与 Calendar(排除日历名)。计划能和文本互相转换:ToString() 产生 every 00:10:00 x5、cron 0 0 9 * * MON-FRI tz=Asia/Shanghai、once 2026-11-01T00:00:00.0000000+00:00,尾部可带 start=、end=、calendar=;JobSchedule.Parse / TryParse 读回。存储以这段文本持久化,管理界面也可以直接让用户填它。
cron 文法
CronExpression 精确到秒,采用 Quartz 风格的文法:
| 段数 | 各段 |
|---|---|
| 5 段 | 分 时 日 月 周,秒为 0 |
| 6 段 | 秒 分 时 日 月 周 |
| 7 段 | 6 段再加 年(1970 到 2099) |
每段支持 *、数值、列表 a,b、范围 a-b、步长 */n、a/n 与 a-b/n;日与周可写 ?(等同 *);月可写 JAN 到 DEC,周可写 SUN 到 SAT,不区分大小写。日段另有 L(当月最后一天)、L-n、nW(离 n 日最近的工作日,不跨月)、LW;周段另有 nL / FRIL(当月最后一个周 n)与 n#k / FRI#2(当月第 k 个周 n,k 为 1 到 5)。日和周同时限定时任一匹配即可。
不是 Quartz 文法的超集
0 与 7 都是周日,1 是周一。Quartz 的 1 是周日、7 是周六,从 Quartz 照搬带星期数字的表达式会错一天,而且不会报错;写成 MON、FRI 这类名字可以避开。
与 Quartz 的差异:
| 写法 | 这里 | Quartz |
|---|---|---|
| 星期数字 | 0 / 7 = 周日,1 = 周一 … 6 = 周六 | 1 = 周日 … 7 = 周六,没有 0 |
周段单独的 L | 不支持,解析报错 | 周六 |
反向范围(FRI-MON、22-2) | 不支持,解析报错 | 跨界回绕 |
| 5 段(不带秒) | 支持,秒为 0 | 不支持,至少 6 段 |
| 日与周同时限定 | 取并集,不要求其一为 ? | Java Quartz 要求其一为 ? |
带时区的 cron 按该时区的墙上时间匹配:夏令时跳过的时刻不运行,重复出现的时刻只运行第一次。向前最多搜索 5 年,找不到匹配(如 2 月 30 日)即没有下一次。
排除日历
日历实现 IJobCalendar.IsExcluded(at)。推算下一次时跳过被排除的时刻,间隔计划的下一次也顺延到第一个不被排除的时刻;一次性计划的时刻被排除就不再运行。连续 10000 个候选时刻都被排除时视为没有下一次,触发器转为 Complete。
| 日历 | 排除 |
|---|---|
HolidayCalendar(dates, timeZone) | 指定日期(整天) |
WeeklyCalendar(excludedDays, timeZone) | 每周指定星期几;不给星期时排除周六、周日 |
DailyCalendar(from, to, timeZone) | 每天一个时段(from 含、to 不含),from 晚于 to 表示跨午夜 |
CronCalendar(expression, timeZone) | 与 cron 匹配的秒,如 * * 0-7 * * * 排除每天 0 点到 8 点 |
CompositeCalendar(calendars) | 任一成员排除即排除 |
时区参数都缺省为 UTC。触发器引用了未登记的日历名,登记时返回 SCHEDULING_VALIDATION_001。
misfire
触发器的到期时刻落后当前时刻超过 MisfireThreshold(缺省 60 秒)时算 misfire。进程停机、作业暂停、节点失联、上一次运行没结束而被阻塞,都会造成 misfire。处理按触发器的 MisfirePolicy:
| 策略 | 行为 |
|---|---|
RunOnce(缺省) | 立即运行一次,计划时刻记为现在,再从现在推算下一次 |
Skip | 不补运行,从现在推算下一次 |
RunAll | 按原计划时刻逐次补齐,每个轮询周期补一次,直到追平 |
没有 misfire 时,下一次从计划时刻推算而不是从实际开始时刻推算,cron 的节奏不会漂移。
并发、本地作业与失联恢复
AllowConcurrent = false(缺省)的作业在整个集群同一时刻最多一次运行。按计划的触发遇到上一次没结束时,触发器转为 Blocked,到期时刻不变;上一次结束时存储把它改回 Waiting,届时按 misfire 策略处理。确认占用与写入阻塞由存储一次完成(BlockTriggerAsync),上一次恰好在这时结束,触发器就直接回到 Waiting。间隔计划的触发器在运行标为完成之后才回到等待,不会被自己的上一次阻塞。手动 TriggerAsync 遇到同样情况直接返回 SCHEDULING_CONFLICT_001。
RunOnEveryInstance = true 的作业是本地作业:每个实例按自己的时钟推算并运行,不经存储抢占,运行同样写入历史。本地作业的「不允许并发」只在本实例内判定,跳过的那次从现在推算下一次。本地触发器集合每 10 个轮询周期从存储刷新一次,本实例改动后立即刷新,所以别的节点运行时改了本地作业,本节点最多 10 个周期后看到。
RequestsRecovery = true 的作业,运行所在节点失联时(心跳超过 NodeTimeout,缺省 90 秒),领导者把那次运行记为 Lost,再以一次性触发器(recovery- 前缀,JobTrigger.Recovery)重新入队一次,数据与原运行相同。失联恢复只在共享存储(Scheduling.Database)下发生。
重试与执行时限
JobDefinition.Retry 与 Timeout 是任务的最大重试次数与超时(ADR-0060)。
重试策略 JobRetryPolicy:
| 成员 | 取值 | 说明 |
|---|---|---|
MaxRetries | 1 到 100(JobRetryPolicy.MaxRetriesLimit) | 失败后最多再运行几次,连同首次一组最多运行 MaxRetries + 1 次 |
Delay | 大于零,不超过 30 天(JobRetryPolicy.DelayLimit) | 第一次重试前的等待 |
Backoff | Fixed(缺省)/ Exponential | Fixed 每次等 Delay;Exponential 第 n 次重试等 Delay × 2^(n-1) |
MaxDelay | 空,或大于零且不超过 30 天 | 指数退避的上限;空时只受 30 天约束 |
用 JobRetryPolicy.Fixed(maxRetries, delay) 或 JobRetryPolicy.Exponential(maxRetries, delay, maxDelay) 创建,越界值在构造时抛 ArgumentOutOfRangeException;DelayFor(n) 返回第 n 次重试前的等待。
重试的规则:
- 只有失败的运行重试:作业返回失败、抛出异常或超过执行时限。被取消(
CancelRunsAsync、宿主停止)与失联(Lost)的运行不重试。 - 重试是一次新运行:
Trigger为Retry,Attempt加一,RetryOf指向首次运行的 ID,Data与失败的那次相同。运行历史里按RetryOf能把一组尝试串起来。 - 集群唯一的作业把重试写成存储里的一次性触发器(
retry-前缀),到期时刻是失败时刻加等待,任一节点抢占运行,进程重启不丢。不允许并发的作业照常独占。 RunOnEveryInstance的作业在失败的那个实例上进程内等待,不进存储;实例停止时未到期的重试直接丢弃。- 手动
TriggerAsync失败也按策略重试,调用方拿到的是第一次的结果。
执行时限 Timeout 到点时取消作业拿到的令牌。作业返回之前时限已过,运行记为失败,错误码 SCHEDULING_TIMEOUT_001,并按重试策略重试。.NET 不能安全地中止任务:作业不理会取消令牌时,运行要等它自己返回才结束,MaxConcurrentRuns 的名额也一直被占着。时限内先发生了取消请求或宿主停止,运行仍记为取消。
运行时管理:IJobScheduler
IJobScheduler(实现是 JobSchedulerEngine)是单例,所有操作都是批量的:以集合为入参,逐项返回结果,单条写法是 JobSchedulerExtensions 里的扩展方法。
| 方法 | 作用 | 主要错误 |
|---|---|---|
ScheduleAsync(definitions) | 新增或整体替换作业与触发器,立即生效;集群里的其他节点下一个周期看到 | SCHEDULING_NOTFOUND_002、SCHEDULING_VALIDATION_001 |
UnscheduleAsync(names) | 删除作业与触发器;进行中的运行照常结束,历史保留 | SCHEDULING_NOTFOUND_001 |
PauseAsync / ResumeAsync(names) | 暂停或恢复作业的全部触发器;暂停期间手动触发仍可用,恢复后错过的按 misfire 策略处理 | SCHEDULING_NOTFOUND_001 |
PauseTriggersAsync / ResumeTriggersAsync(keys) | 按 TriggerKey(job, trigger) 暂停或恢复单个触发器 | SCHEDULING_NOTFOUND_003 |
TriggerAsync(requests) | 在本实例立即运行并等待结果,按顺序逐项;不受暂停和领导权限制;本实例调度器启动之前与开始停止之后都不起新的运行 | SCHEDULING_NOTFOUND_001、SCHEDULING_CONFLICT_001、SCHEDULING_UNAVAILABLE_001 |
EnqueueAsync(requests) | 插入一个立即到期的一次性触发器(manual- 前缀),由集群任一节点运行,不等待;返回触发器名 | SCHEDULING_NOTFOUND_001 |
CancelRunsAsync(runIds) | 本实例的运行立即取消令牌,其他节点的在下一个轮询周期取消 | SCHEDULING_NOTFOUND_004 |
StatusAsync(name) / StatusesAsync() | 作业状态:记录、是否暂停、各触发器的状态与上一次 / 下一次时刻与次数、运行中数量、最近一次完成的结果 | — |
RunsAsync(query) / CountRunsAsync(query) | 运行历史与同一组过滤条件下的总数 | SCHEDULING_VALIDATION_002(RunsAsync 分页越界) |
RestoreAsync(names) | 把运行时改过的代码作业恢复为代码定义 | SCHEDULING_NOTFOUND_005 |
JobTriggerRequest(Name, Data) 能从 JobName 隐式转换,手动触发带的数据覆盖作业数据的同名键。
运行时为每个租户建一个同步作业:
using System.Text.Json.Nodes;
using XiHan.Framework.Primitives;
using XiHan.Framework.Scheduling.Abstractions.Jobs;
using XiHan.Framework.Scheduling.Abstractions.Schedulers;
using XiHan.Framework.Scheduling.Abstractions.Schedules;
public sealed class TenantSyncJob : IJob
{
public ValueTask<Result> RunAsync(JobRun run, InvocationContext context, CancellationToken cancellationToken)
=> ValueTask.FromResult(Result.Success());
}
public sealed class TenantSyncPlanner(IJobScheduler scheduler)
{
public async ValueTask<Result> EnableAsync(string tenantId, CancellationToken cancellationToken)
{
// 作业名不合法会抛 ArgumentException:租户 ID 只能含小写字母、数字、点、下划线与连字符
var definition = new JobDefinition($"tenant-{tenantId}.sync", JobSchedule.Every(TimeSpan.FromMinutes(10)), typeof(TenantSyncJob))
{
Data = new JsonObject { ["tenant"] = tenantId },
Retry = JobRetryPolicy.Fixed(3, TimeSpan.FromSeconds(30)),
};
return await scheduler.ScheduleAsync(definition, cancellationToken);
}
public async ValueTask<Result> DisableAsync(string tenantId, CancellationToken cancellationToken)
=> await scheduler.UnscheduleAsync($"tenant-{tenantId}.sync", cancellationToken);
}运行时作业引用的类型要事先用 builder.Services.AddJobType<TenantSyncJob>() 登记,否则 ScheduleAsync 返回 SCHEDULING_NOTFOUND_002。多节点部署时每个节点都要登记同一组类型:节点抢到一个类型未登记的触发器时,把触发器改为 Error 并记录原因,不再运行它。
查询状态与历史:
using XiHan.Framework.Scheduling.Abstractions.Jobs;
using XiHan.Framework.Scheduling.Abstractions.Schedulers;
public sealed class ReportJobMonitor(IJobScheduler scheduler)
{
public async ValueTask<(DateTimeOffset? NextRun, long Failures, IReadOnlyList<JobRunRecord> Recent)> SummaryAsync(CancellationToken cancellationToken)
{
var name = new JobName("reports.nightly");
var status = (await scheduler.StatusAsync(name, cancellationToken)).ValueOrThrow();
var failed = new JobRunQuery { Job = name, States = [JobRunState.Failed] };
var failures = (await scheduler.CountRunsAsync(failed, cancellationToken)).ValueOrThrow();
var recent = (await scheduler.RunsAsync(failed with { Limit = 20 }, cancellationToken)).ValueOrThrow();
return (status.HasValue ? status.ValueOrThrow().NextRun : null, failures, recent);
}
}JobRunQuery 按作业、状态集合、计划时刻范围(From 含、To 不含)、触发器名、执行节点、有无错误码过滤,Skip 缺省 0,Limit 缺省 100、最大 1000(JobRunQuery.MaxLimit);结果按计划时刻降序。CountRunsAsync 用同一组过滤项,不看分页(ADR-0053)。分页越界(Skip 为负、Limit 不在 1~1000)不在构造时抛出,RunsAsync 返回 SCHEDULING_VALIDATION_002(HTTP 400),不进存储——查询常由请求绑定而来,越界是调用方可以改正的输入(ADR-0082)。
代码作业的运行时修改
管理界面常要改代码作业的计划。ScheduleAsync 替换一个代码作业时,来源记为 Overridden,并保留代码定义的指纹 CodeFingerprint。指纹是类型键、数据、标志、说明、各触发器(名字、计划文本、数据、优先级、misfire 策略、说明)的 SHA-256;重试策略与执行时限设置了才计入。下次启动同步代码作业时:
- 存储里是
Overridden且指纹与当前代码定义相同:代码没改,保留运行时的修改。 - 其余情况:按代码写入,来源回到
Code;覆盖了运行时修改的记一条警告日志。 - 暂停状态一律保留。
RestoreAsync 随时把作业恢复为代码定义,保留暂停状态与同名触发器的进度;作业没有代码定义(来源 Runtime)时返回 SCHEDULING_NOTFOUND_005。删除代码作业只到下次启动为止,启动时会按代码重新写入。JobTypeRegistry.Keys 列出已登记的类型键,供界面新建作业时选择。
集群:引擎与存储
JobSchedulerEngine 由 Provider 特性装配,在应用进入服务状态后开始轮询,每个 PollInterval(缺省 1 秒)执行一个周期:
- 应用不在服务状态(启动中、排空中)时什么都不做,只释放持有的领导租约:排空要等依赖调度的特性逐个停完,这期间不再续着租约挡住别人,在服务的另一个实例下一个周期就接手,不等租约到期(ADR-0091)。
- 首个周期把代码登记的作业写入存储;失败时记日志,下个周期重试。
- 存储共享时写节点心跳。
- 续约领导租约,没有时尝试获取(资源
LeaseResource,缺省scheduling.jobs;存活LeaseTimeToLive,缺省 30 秒)。 - 领导者每
HousekeepingInterval(缺省 60 秒)维护一次:恢复失联节点,删除结束早于HistoryRetention(缺省 7 天,0 为不删)的历史。 - 取消本节点上被请求取消的运行。
- 运行到期的本地触发器与本实例的本地重试。
- 抢占到期触发器并在后台运行:名额 =
min(AcquireBatchSize, MaxConcurrentRuns − 本节点运行中数量),缺省为 16 与 32。
IJobStore.IsShared 决定集群唯一的作业怎样保证只跑一次。共享存储(数据库实现)靠抢占:AcquireDueAsync 原子地把到期触发器改成 Acquired(nodeId),多个节点同时调用时每条只被一个节点拿到;TryBeginRunAsync(exclusive: true) 原子地占用作业的 ActiveRunId,实现全集群串行。不共享的存储(内存实现)靠领导权:只有持有租约的实例按计划运行集群唯一的作业,没有领导权的实例把抢到的按计划触发器放回等待;入队、重试这类一次性触发器只有本进程看得到,不受领导权限制。
触发器的状态:
| 状态 | 含义 |
|---|---|
Waiting | 等待到期 |
Paused | 已暂停 |
Acquired | 已被某节点抢占;间隔计划在运行结束前保持这个状态 |
Blocked | 到期但作业不允许并发、上一次未结束 |
Complete | 没有下一次(一次性已运行、次数用尽、超过 EndAt) |
Error | 无法运行(作业类型未登记),原因在 TriggerRecord.Error |
停止时引擎先停轮询,再等待进行中的运行结束;宿主的停止令牌触发时取消这些运行,再给它们收尾的时间,最后释放领导租约(排空中的周期已经释放过的,这里什么也不做)。引擎随 Provider 特性启动;启动之前与开始停止之后,本实例的 TriggerAsync 返回 SCHEDULING_UNAVAILABLE_001(HTTP 503)、不起新的运行——这两段时间没有轮询照看运行,停止也只等已经开始的运行,起了就无人等待。EnqueueAsync 只写存储,不受影响。
领导选举
ILeaderElection.TryAcquireAsync(resource, timeToLive) 在资源空闲或租约已过期时授予 LeaderLease,被他人持有时返回 SCHEDULING_CONFLICT_002。LeaderLease 有 Holder、ExpiresAt、IsHeld,RenewAsync 延长一个存活时间(已过期或被接管时返回 SCHEDULING_CONFLICT_002),ReleaseAsync 立即放弃,可以重复调用。租约不可重入。
| 实现 | 持有者 | 存储 | 不可达时 |
|---|---|---|---|
InMemoryLeaderElection | 每次获取新生成的随机值 | 进程内字典,只在本进程内互斥 | — |
DatabaseLeaderElection | 节点 ID(XiHan:Scheduling:NodeId),每次获取另带随机令牌 | 与作业同库的一行,并发戳接管过期租约 | SCHEDULING_UNAVAILABLE_002,持有状态不变 |
KubernetesLeaderElection | Pod 名 | coordination.k8s.io/v1 Lease,resourceVersion 裁决并发 | SCHEDULING_UNAVAILABLE_002,下个周期重试 |
应用自己的单例工作也可以用它竞争领导权:
using XiHan.Framework.Scheduling.Abstractions.Jobs;
using XiHan.Framework.Scheduling.Abstractions.Leadership;
public sealed class IndexRebuilder(ILeaderElection election)
{
public async ValueTask<bool> TryRebuildAsync(CancellationToken cancellationToken)
{
var acquired = await election.TryAcquireAsync(new JobName("search.rebuild"), TimeSpan.FromMinutes(5), cancellationToken);
if (!acquired.TryGetValue(out var lease))
{
// 别的实例正在做,或选举存储不可达
return false;
}
await using (lease)
{
// 长于存活时间的工作要定期 RenewAsync,并在续约失败时停下
return true;
}
}
}在 Kubernetes 上,示例应用 framework/samples/KubernetesApp 在 Pod 内装配 KubernetesSchedulingFeature,在本地退回 Full 组合自带的进程内选举,并用 /leader 端点展示 JobSchedulerEngine.IsLeader:
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Profiles.Full;
using XiHan.Framework.Scheduling.Abstractions.Leadership;
using XiHan.Framework.Scheduling.Abstractions.Schedulers;
using XiHan.Framework.Scheduling.Kubernetes;
var builder = WebApplication.CreateBuilder(args);
var inCluster = Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST") is not null;
builder.AddXiHan(xihan =>
{
xihan.UseFullProfile();
if (inCluster)
{
xihan.UseFeature<KubernetesSchedulingFeature>();
}
});
var app = builder.Build();
app.UseXiHan();
app.MapGet("/leader", (JobSchedulerEngine scheduler, ILeaderElection election) =>
new LeaderView(scheduler.NodeId, election.GetType().Name, scheduler.IsLeader));
app.Run();
public sealed record LeaderView(string NodeId, string Election, bool IsLeader);Lease 名缺省是 xihan-scheduler,用 XiHan:Scheduling:Kubernetes:Lease 修改;Pod 的 ServiceAccount 需要对该 Lease 有 get、update、patch 权限,外加 leases 的 create。xihan pack 按应用清单里的 leader.lease 生成这份 Role 与环境变量,细节见 Scheduling.Kubernetes 包与部署与打包。
事件
装配了消息能力时,引擎经 IMessagePublisher 发布 CloudEvents 信封:Source 为 /scheduling,Subject 为运行 ID(作业级事件为作业名),Data 是 SchedulingEventData 的 JSON(作业、触发器名、运行 ID、触发来源、状态、各时刻、错误码与消息、Attempt、RetryOf、节点)。没有发布者时跳过,发布失败只记日志,不影响运行。
主题(SchedulingTopics) | 时机 |
|---|---|
scheduling.job.scheduled / scheduling.job.unscheduled | 作业登记或替换 / 删除 |
scheduling.job.paused / scheduling.job.resumed | 作业暂停 / 恢复 |
scheduling.run.started | 运行开始 |
scheduling.run.succeeded / scheduling.run.failed / scheduling.run.canceled | 运行结束 |
scheduling.run.lost | 运行所在节点失联 |
订阅方式见消息与事件。
消息代理是 Kafka 且集群关了自动建主题(auto.create.topics.enable=false)时,这 9 个主题(带 TopicPrefix)要预先建好,没人订阅的也要建,否则发布失败、只记日志,见 Messaging.Kafka。
配置
引擎选项在 XiHan:Scheduling(SchedulerOptions),内存与数据库两个 Provider 读同一节。值不合法(非数字、超出范围)时启动抛 ArgumentException。
| 键 | 类型 | 缺省 | 说明 |
|---|---|---|---|
PollIntervalSeconds | 正数 | 1 | 轮询周期 |
NodeId | 字符串 | 机器名:进程 ID:8 位随机 | 节点身份:心跳、抢占、租约持有者都用它;多节点时每个节点唯一 |
MaxConcurrentRuns | ≥ 1 | 32 | 本节点同时运行的上限 |
AcquireBatchSize | ≥ 1 | 16 | 一个周期最多抢占的触发器数 |
MisfireThresholdSeconds | ≥ 0 | 60 | 落后超过它算 misfire |
LeaseResource | 作业名文法 | scheduling.jobs | 领导租约的资源名 |
LeaseTimeToLiveSeconds | 正数 | 30 | 领导租约的存活时间 |
NodeTimeoutSeconds | 正数 | 90 | 心跳超过它即失联 |
HousekeepingIntervalSeconds | 正数 | 60 | 领导者维护周期 |
HistoryRetentionDays | ≥ 0 | 7 | 运行历史保留天数,0 为不删 |
{
"XiHan": {
"Scheduling": {
"PollIntervalSeconds": 1,
"MaxConcurrentRuns": 32,
"MisfireThresholdSeconds": 60,
"LeaseTimeToLiveSeconds": 30,
"HistoryRetentionDays": 7
}
}
}各 Provider 自己的键(XiHan:Scheduling:InMemory:MaxRuns、XiHan:Scheduling:Database:HealthProbeIntervalSeconds、XiHan:Scheduling:Kubernetes:*)见对应包页。
错误码
| 错误码 | 含义 |
|---|---|
SCHEDULING_NOTFOUND_001 | 作业未登记 |
SCHEDULING_NOTFOUND_002 | 作业类型未登记 |
SCHEDULING_NOTFOUND_003 | 触发器不存在 |
SCHEDULING_NOTFOUND_004 | 运行不存在或已结束 |
SCHEDULING_NOTFOUND_005 | 作业没有代码定义,不能恢复 |
SCHEDULING_VALIDATION_001 | 作业定义不合法:触发器名重复、日历未登记 |
SCHEDULING_VALIDATION_002 | 运行历史查询的分页越界:Skip 为负或 Limit 不在 1~1000(HTTP 400) |
SCHEDULING_CONFLICT_001 | 作业正在运行且不允许并发 |
SCHEDULING_CONFLICT_002 | 领导租约被其他持有者占用 |
SCHEDULING_CONFLICT_003 | 运行被取消 |
SCHEDULING_UNAVAILABLE_001 | 调度器未运行:本实例的调度器尚未启动,或已开始停止(宿主排空),TriggerAsync 不起新的运行;启动后照常 |
SCHEDULING_UNAVAILABLE_002 | 领导选举的存储(数据库、API Server)不可达或超时 |
SCHEDULING_UNAVAILABLE_003 | 作业存储不可达 |
SCHEDULING_UNAVAILABLE_004 | 作业存储容量已满(内存实现的运行历史全部在运行中) |
SCHEDULING_TIMEOUT_001 | 运行超过作业的执行时限 |
SCHEDULING_UNEXPECTED_001 | 作业抛出未处理异常 |
错误的消息模板在消息目录 scheduling 里,十种区域都有译文,见国际化。
与其他能力的关系
- 工作流的
Delay活动与Start节点的定时启动复用本包的CronExpression,文法相同,见工作流。 - 数据库 Provider 经
persistence.relational@1的仓储读写,每个存储操作是一次独立的管道调用与事务,不加入调用方的工作单元,见持久化。 - 作业运行经过完整管道,多租户、授权、审计的行为与执行管道一致。
相关
- Scheduling.Abstractions
- Scheduling.InMemory
- Scheduling.Database
- Scheduling.Kubernetes
- 测试与一致性套件
- 规范:RFC-0013《调度与领导选举》;ADR-0020、ADR-0053、ADR-0060
