XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

调度与后台作业 ​

本章讲怎样在应用里按计划运行后台工作:编写作业、登记作业与触发器、选择计划与 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@1IJobScheduler、JobSchedulerEngine、IJobStoreScheduling.InMemoryScheduling.Database
scheduling.leader@1ILeaderElectionScheduling.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。

csharp
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。

csharp
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、手动触发的数据逐层覆盖同名键
AllowConcurrentfalsefalse 时整个集群同一作业同一时刻最多一次运行
RunOnEveryInstancefalsetrue 时每个实例按自己的时钟各自运行,不经存储抢占
RequestsRecoveryfalse运行所在节点失联时由领导者重新入队一次(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:

成员取值说明
MaxRetries1 到 100(JobRetryPolicy.MaxRetriesLimit)失败后最多再运行几次,连同首次一组最多运行 MaxRetries + 1 次
Delay大于零,不超过 30 天(JobRetryPolicy.DelayLimit)第一次重试前的等待
BackoffFixed(缺省)/ ExponentialFixed 每次等 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 隐式转换,手动触发带的数据覆盖作业数据的同名键。

运行时为每个租户建一个同步作业:

csharp
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 并记录原因,不再运行它。

查询状态与历史:

csharp
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;重试策略与执行时限设置了才计入。下次启动同步代码作业时:

  1. 存储里是 Overridden 且指纹与当前代码定义相同:代码没改,保留运行时的修改。
  2. 其余情况:按代码写入,来源回到 Code;覆盖了运行时修改的记一条警告日志。
  3. 暂停状态一律保留。

RestoreAsync 随时把作业恢复为代码定义,保留暂停状态与同名触发器的进度;作业没有代码定义(来源 Runtime)时返回 SCHEDULING_NOTFOUND_005。删除代码作业只到下次启动为止,启动时会按代码重新写入。JobTypeRegistry.Keys 列出已登记的类型键,供界面新建作业时选择。

集群:引擎与存储 ​

JobSchedulerEngine 由 Provider 特性装配,在应用进入服务状态后开始轮询,每个 PollInterval(缺省 1 秒)执行一个周期:

  1. 应用不在服务状态(启动中、排空中)时什么都不做,只释放持有的领导租约:排空要等依赖调度的特性逐个停完,这期间不再续着租约挡住别人,在服务的另一个实例下一个周期就接手,不等租约到期(ADR-0091)。
  2. 首个周期把代码登记的作业写入存储;失败时记日志,下个周期重试。
  3. 存储共享时写节点心跳。
  4. 续约领导租约,没有时尝试获取(资源 LeaseResource,缺省 scheduling.jobs;存活 LeaseTimeToLive,缺省 30 秒)。
  5. 领导者每 HousekeepingInterval(缺省 60 秒)维护一次:恢复失联节点,删除结束早于 HistoryRetention(缺省 7 天,0 为不删)的历史。
  6. 取消本节点上被请求取消的运行。
  7. 运行到期的本地触发器与本实例的本地重试。
  8. 抢占到期触发器并在后台运行:名额 = 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,持有状态不变
KubernetesLeaderElectionPod 名coordination.k8s.io/v1 Lease,resourceVersion 裁决并发SCHEDULING_UNAVAILABLE_002,下个周期重试

应用自己的单例工作也可以用它竞争领导权:

csharp
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:

csharp
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≥ 132本节点同时运行的上限
AcquireBatchSize≥ 116一个周期最多抢占的触发器数
MisfireThresholdSeconds≥ 060落后超过它算 misfire
LeaseResource作业名文法scheduling.jobs领导租约的资源名
LeaseTimeToLiveSeconds正数30领导租约的存活时间
NodeTimeoutSeconds正数90心跳超过它即失联
HousekeepingIntervalSeconds正数60领导者维护周期
HistoryRetentionDays≥ 07运行历史保留天数,0 为不删
json
{
  "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 的仓储读写,每个存储操作是一次独立的管道调用与事务,不加入调用方的工作单元,见持久化。
  • 作业运行经过完整管道,多租户、授权、审计的行为与执行管道一致。

相关 ​

Released under The MIT License