尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Quartz.NET 与 Wolverine 深度集成实战:7 个可运行示例拆解双运行时协作模式

Quartz.NET 与 Wolverine 深度集成实战:7 个可运行示例拆解双运行时协作模式 任务调度后端【免费下载链接】quartznetQuartz Enterprise Scheduler .NET项目地址https://gitcode.com/gh_mirrors/qu/quartznet点击查看免费下载导读本文基于 Quartz.NET 仓库中的可运行示例项目 src/Quartz.Examples.Wolverine其配套文档为 docs/documentation/quartz-4.x/how-tos/wolverine.md系统讲解调度器 Quartz.NET 与消息总线 Wolverine 双运行时在同一进程中的五种协作方式。文章将带你掌握如何让 Quartz 的 Job 以 Cron 表达式周期性地把消息发布进 Wolverine如何从 Wolverine 处理器中调度一次性任务并按关联 ID 批量取消如何持久化序列化后的原始消息字节并在触发时发送如何正确理解延迟相关配置的真正含义以及如何让调度器生命周期服从消息运行时、并让触发器的写入与业务数据和出站消息同处一个事务。读完你既能直接运行示例也能理解每一步背后的源码级原理。一、项目定位为什么需要这样一个示例自 Wolverine 6.34 起对应 JasperFx/wolverine#4307Wolverine 本身也拥有了基于 Cron 表达式的周期性调度能力。于是 Quartz.NET 与 Wolverine 在定时发布消息这件事上出现了功能重叠两个库如何选型、如何共存、如何协作就成了真实问题。该示例项目存在的意义正是并排对比第 7 部分用 Wolverine 自己的opts.Schedules.ScheduleRecurring注册周期调度第 1 部分用 Quartz 触发器二者形成可运行的同题对比展示五种协作方式从Job 发布消息到处理器调度一次性任务、原始消息字节延后发送、调度器由 Wolverine 运行时启动、触发器与业务数据同事务提交防止文档与代码漂移配套的 How-To 文档中每一段 C# 代码都取自本项目并由WolverineHowToTest见 src/Quartz.Tests.Unit/WolverineHowToTest.cs对照编译与运行结果校验文档不会出现无法编译的示例。项目自身的 csprojsrc/Quartz.Examples.Wolverine/Quartz.Examples.Wolverine.csproj同时引用了 src/Quartz/Quartz.csproj 与 src/Quartz.Weasel.PostgreSQL/Quartz.Weasel.PostgreSQL.csproj后者的目的是在持久化模式下让 Quartz 的表通过 Weasel 加入 Wolverine 统一管理的数据库 Schema。二、运行示例内存模式、冒烟模式与 Postgres 持久化模式项目是一个可直接运行的net10.0控制台应用默认模式不需要任何外部服务——Wolverine 使用内存本地传输Quartz 使用内存 JobStore。1. 直接运行内存模式dotnet run --project src/Quartz.Examples.Wolverine控制台会打印Quartz.NET Wolverine, in-memory mode...随后持续运行直到 CtrlC。2. 冒烟模式--smoke用压缩时钟跑完七个部分逐一断言每个部分都真实发生过然后退出dotnet run --project src/Quartz.Examples.Wolverine -- --smoke冒烟模式的意义在于参见 src/Quartz.Examples.Wolverine/Smoke.cs构建服务器可以证明七个部分仍然工作而不仅是进程能启动。一个能启动、什么都没调度、退出码 0的运行比不检查更糟。它刻意不是单元测试——它验证的是两个托管运行时在启动顺序上达成一致、一个调度循环、一条消息总线这些都是测试替身无法给出真相的部分。3. Postgres 持久化模式设置QUARTZ_WOLVERINE_POSTGRES环境变量后另外两个部分Wolverine 节点代理、Quartz 加入应用自身事务才会完整呈现——Wolverine 代理没有消息存储根本不会运行而共享事务需要持久化 JobStoreQUARTZ_WOLVERINE_POSTGRESHostlocalhost;Databasequartz;Usernamequartz;Passwordquartz \ dotnet run --project src/Quartz.Examples.Wolverine -- --smoke在该模式下启动时会在目标数据库中自动创建 SchemaQuartz 的表由 src/Quartz.Weasel.PostgreSQL/Quartz.Weasel.PostgreSQL.csproj 通过 Weasel 与 Wolverine 的表一并创建与迁移并额外建一张第 6 部分写入用的小表refunds见 src/Quartz.Examples.Wolverine/Refunds.cscreate table if not exists refunds (order_id text not null, amount numeric not null)。运行选项集中在 src/Quartz.Examples.Wolverine/ExampleOptions.cs--smoke会把第 1 部分的 Cron 改为每 2 秒触发、第 2 部分提醒延迟改为 2 秒、第 7 部分改为每 5 秒一次非冒烟模式则分别使用0 0 3 ? * MON-FRI、30 分钟和15 3 * * MON-FRI等真实部署表达式。三、七个部分总览文件演示内容Part1RecurringPublishing.cs一个IJobTInput按 Cron 调度周期性地向 Wolverine 发布消息Part2OneOffFromHandler.cs从处理器中调度一次性触发并按触发器分组取消某个订单的所有触发Part3RawMessageData.cs存储序列化后的消息封套触发时用SendRawMessageAsync发送原始字节Part4TunedLatency.csIdleWaitTime、MaxBatchSize、BatchTriggerAcquisitionFireAheadTimeWindow三个延迟配置的真实含义Part5StartedByWolverine.csAutoStart false调度器由 Wolverine 运行时启动Part6EnlistedTransaction.cs一个事务同时容纳业务行、Wolverine 出站封套与 Quartz 触发器Part7WolverineSchedules.csWolverine 自己的opts.Schedules.ScheduleRecurring与第 1 部分正面比较每个文件开头都有一段注释说明该部分的用途第 1 与第 7 部分的开头注释分别是同一组对比的两个半场。消息定义集中在 src/Quartz.Examples.Wolverine/Messages.cs——它们是对调度器一无所知的普通 record因为Quartz 发布进 Wolverine处理器像读取任何其他生产者发来的消息一样读取正是集成的要点它们必须是 public因为 Wolverine 会把处理器分派编译进动态程序集而动态程序集看不到 internal 类型。四、Part 1Job 按 Cron 向 Wolverine 发布消息4.1 核心代码ReconciliationJob实现了IJobReconciliationWindow见 src/Quartz.Examples.Wolverine/Part1RecurringPublishing.cs触发时通过构造器注入的IMessageBus发布消息public sealed class ReconciliationJob : IJobReconciliationWindow { private readonly IMessageBus bus; public async ValueTask Execute( IJobExecutionContext context, ReconciliationWindow input, CancellationToken cancellationToken default) { // 用调度器自己的时钟而不是 DateTimeOffset.UtcNow // 错过触发而延迟执行的触发器仍报告它原定计划的时间 // 那正是本次运行所覆盖的时间窗口。 DateTimeOffset to context.ScheduledFireTimeUtc ?? context.FireTimeUtc; RunReconciliation message new(to - input.Length, to); await bus.PublishAsync(message); } }注册在Program.cs的AddQuartz内调用public const string NightlyCron 0 0 3 ? * MON-FRI; public static void Register(IQuartzBuilder q, string cron) { q.ScheduleJobReconciliationJob(trigger trigger .WithIdentity(reconciliation, recurring) .WithCronSchedule(cron, x x .InTimeZone(TimeZoneInfo.Utc) .WithMisfireInstruction(CronTriggerMisfireInstruction.DoNothing)) .UsingInput(new ReconciliationWindow(TimeSpan.FromDays(1)))); }4.2 这里 Quartz 相比 Wolverine 自有调度的增量价值文件头注释与第 7 部分构成对照明确指出 Quartz 触发器为定时发布场景额外提供了Misfire 指令进程宕机期间错过的那次触发有明确处置策略DoNothing直接跳到下一次FireAndProceed补发一条追账消息而 Wolverine 自有调度只有跳过这一种固定行为可运行期变更的调度调度存在 JobStore 中而非注册代码里宿主运行期间可以增删、移动、暂停触发器Job 数据、优先级、重试策略、跳过日期日历IJobTInput把输入作为类型化参数交给 Job触发频率没有下限——只要表达式允许可以比每 5 秒更快。同时要注意两库唯一的交汇点就是 Job 本身——IJobTInput解析IMessageBus并发布下游任何环节都无法区分这条消息来自调度器还是来自某个处理器。五、Part 2从 Wolverine 处理器调度一次性 Job并按关联 ID 批量取消5.1 问题背景Wolverine 缺少对已调度消息的句柄Wolverine 的IMessageBus.ScheduleAsync返回ValueTask——没有 id、没有令牌、没有任何可用于取消的句柄其 Saga 超时TimeoutMessage同样是完成 Saga 不会撤回超时超时仍会触发。唯一的取消 APIIScheduledMessages.CancelAsync只能按消息类型、执行时间或封套 id 过滤没有关联 ID 过滤器所以取消订单 42 的一切只能靠应用自己保存每个封套的 Guid且在无存储的宿主上还是静默的空操作。5.2 Quartz 的解法触发器分组作为关联轴OneOffJobOptions.Group设置触发器键的分组Job 键保持QRTZ_SCHEDULED/typeof(TJob).Name——每个 Job 类型一个持久 Job多个触发器因此为同一订单安排的所有触发共享同一分组整个分组可以一次调用完成列出、暂停或取消public static class OrderGroup { public static string For(string orderId) $order:{orderId}; } public static class OrderPlacedHandler { public static async Task Handle(OrderPlaced message, IScheduler scheduler, CancellationToken cancellationToken) { ScheduledOneOffJob scheduled await scheduler.ScheduleJobPaymentReminderJob, PaymentReminder( new PaymentReminder(message.OrderId, message.Amount), ExampleOptions.Current.ReminderDelay, new OneOffJobOptions { Group OrderGroup.For(message.OrderId) }, cancellationToken); // 调用返回触发器的键与存储所说将首次触发的时间 // 因此scheduled for是即将发生的时刻而不是请求中的期望值。 Ledger.Record(Events.ReminderScheduled, ${scheduled.TriggerKey} at {scheduled.FirstFireTimeUtc:u}); } } public static class OrderPaidHandler { public static async Task Handle(OrderPaid message, IScheduler scheduler, CancellationToken cancellationToken) { ListTriggerKey cancelled await scheduler.UnscheduleJobs( GroupMatcherTriggerKey.GroupEquals(OrderGroup.For(message.OrderId)), cancellationToken); Ledger.Record(Events.RemindersCancelled, ${cancelled.Count} for {message.OrderId}); } }这是一等公民的存储操作而非应用自行维护的簿记matcher 在触发器所在处求值无需把键列表往返传回本进程也不存在刚才列出的触发器在删除前恰好触发的窗口返回值是被真正撤销的键集合调用方据此得知是否赶在触发之前取消了。Program.cs中OrderPlaced用PublishAsync本地队列可并行处理而取消演示用InvokeAsync顺序执行——先到先处理保证先安排、后取消的次序成立。六、Part 3存储序列化封套触发时发送原始字节6.1 这是 Wolverine 为调度集成预留的接缝Wolverine 官方文档发送原始消息数据一节明确写道一个示例用例是集成 Quartz.NET 或 Hangfire 这样的调度库你可能会持久化一个 byte[]以便在特定时间通过 Wolverine 发送该消息。这正是本部分的内容。Wolverine 提供的是一半——如何发送原始数据另一半在特定时间以及 byte[] 的产出则要靠 Quartz 与Options.DefaultSerializer.WriteMessage(message)见 src/Quartz.Examples.Wolverine/Part3RawMessageData.cspublic const string EndpointName archive; public static async ValueTaskTriggerKey ScheduleSendTMessage( IScheduler scheduler, IWolverineRuntime runtime, TMessage message, TimeSpan delay, CancellationToken cancellationToken default) where TMessage : notnull { // 在做出决定的这一刻序列化而不是在触发时刻。 DeferredEnvelope envelope new( EndpointName, typeof(TMessage).ToMessageTypeName(), runtime.Options.DefaultSerializer.WriteMessage(message)); ScheduledOneOffJob scheduled await scheduler.ScheduleJobDeferredEnvelopeJob, DeferredEnvelope( envelope, delay, new OneOffJobOptions { Group deferred-envelopes }, cancellationToken); return scheduled.TriggerKey; }DeferredEnvelope记录承载三个字段目标端点名、消息类型名由typeof(T).ToMessageTypeName()产生从而尊重[MessageIdentity]别名而非绕过它使用原始FullName、序列化后的消息体字节。6.2 触发时如何还原发送public async ValueTask Execute(IJobExecutionContext context, DeferredEnvelope input, ...) { IDestinationEndpoint endpoint bus.EndpointFor(input.EndpointName); await endpoint.SendRawMessageAsync(input.Data, configure: envelope { envelope.MessageType input.MessageTypeName; // 用存储的名称而非 SetMessageTypeT() envelope.Destination endpoint.Uri; // 必需SendRawMessageAsync 不会替你设置 }); }两个细节值得注意SendRawMessageAsync没有路由可依Wolverine 按消息的 .NET 类型路由而这里没有活消息所以目标端点必须随字节一起存储Destination也必须显式设置——Wolverine 6.35.0 中DestinationEndpoint.SendRawMessageAsync只赋值Sender而把Destination留空执行器随后通过envelope.Destination!记录成功与失败日志一条处理得完美无缺的原始消息最终会在日志调用处抛NullReferenceException这一行规避了它。6.3 为什么值得这么做对比第 2 部分发布活对象封套在决定做出的时刻被序列化负载不会在触发时从已经前进了的应用状态重新推导消息契约在其下演变也不会改变已存储触发的含义——这与 Outbox 模式是同一个论点。Quartz 提供的另一半则是承载这些字节直到到期负载作为 Job 的类型化输入与即将发送它的触发器落在同一个存储、同一把锁、同一个事务里。Program.cs中该部分以一个真实调用收尾Part3RawMessageData.ScheduleSend(scheduler, runtime, new ArchiveOrders(nightly housekeeping, OlderThanDays: 90), delay)。七、Part 4三个延迟配置的真实含义这是常见误区的澄清见 src/Quartz.Examples.Wolverine/Part4TunedLatency.cs。当人们看到IdleWaitTime默认 30 秒、而 Wolverine 的ScheduledJobPollingTime是 5 秒时会得出Quartz 投递到期消息慢六倍的结论——这个读法是错的源码可以证明QuartzSchedulerThread获取的是未来IdleWaitTime内到期的触发器NoLaterThan now IdleWaitTime而非现在到期的获取后它会等待精确的触发时刻而不是睡满整个间隔每一个进程内变更——ScheduleJob、AddTrigger、RescheduleJob、DeleteJob——都会调用SignalSchedulingChange唤醒循环除非触发器晚于下一次巡查那也会被发现。因此从 Wolverine 处理器经由本进程IScheduler安排的触发器永远不会等下一次扫描调度调用本身就唤醒了循环所以IdleWaitTime界定的是本节点未在进程内得知的工作的发现延迟另一个节点写入共享数据库的触发器、或从宕机节点恢复的触发器。它是跨节点的拾取界限、一次获取的提前量不是进程内调度的分辨率——调低它不会让本地安排的 Job 提前触发。示例采用的取值并明确这是总线型调度器合理想要的值示例本身在默认值下行为完全相同q.ConfigureScheduler(options { // 默认 30 秒。只影响本节点发现非自身安排的触发器有多快 // 因此它是集群设置而不是延迟设置。 options.IdleWaitTime TimeSpan.FromSeconds(10); // 默认 automatic持久化存储为线程池大小内存存储为 1。 // 不得超过 ThreadPoolOptions.MaxConcurrency默认 10。 options.MaxBatchSize 10; // 默认 TimeSpan.Zero只批量已到期触发器此设置还会 // 批量首触发后半秒内到期的触发器并提前至多这么多触发。 options.BatchTriggerAcquisitionFireAheadTimeWindow TimeSpan.FromMilliseconds(500); });什么值得调、为什么IdleWaitTime仅在集群部署需要更快地拾取其他节点的触发器时调整代价是每个节点每个间隔一次数据库往返最小 1 秒MaxBatchSize默认 automatic非集群持久化存储取线程池大小、集群或内存取 1。一批结束于now 与首触发器触发时间取较晚者加上提前窗口因此在默认窗口下它取走所有已到期触发器且不多取。集群、总线型、同时触发大量小 Job 的调度器或许想调高上限受ThreadPoolOptions.MaxConcurrency约束BatchTriggerAcquisitionFireAheadTimeWindow默认TimeSpan.Zero。加宽它会把首触发后不久到期的触发器一并成批获取并提前至多窗口时长触发。把它设为你愿意提前触发的散布范围。八、Part 5AutoStart false调度器由 Wolverine 运行时启动8.1 选项本身QuartzHostedServiceOptions.AutoStart false让调度器保持已构建、已初始化、已绑定但处于SchedulerStatus.Created的状态见 src/Quartz.Examples.Wolverine/Part5StartedByWolverine.cs。一切读取调度器的东西——ISchedulerRegistry、Dashboard、HTTP API——仍然能看到它只是在有人调用IScheduler.Start之前什么都不会触发。关停不受影响托管服务仍会停止它创建的每个调度器无论是否启动过。该选项自身的文档点名的正是这个场景拥有自己的领导者选举的库。8.2 谁来按下 Start诚实答案取决于有没有消息存储无持久化示例默认、CI 编译运行的形态时Wolverine不运行任何代理WolverineRuntime.startAgentsAsync()开头就是if (Storage is NullMessageStore) { ...; return; }于是NodeAgentController根本不会被构建容器中的IAgentFamily注册永远不会被读取——AddSingularAgentT()会编译、会注册、然后静默永不启动。因此无存储形态的正确做法是一个普通的IHostedServiceSchedulerStarter在UseWolverine之后注册——托管服务按注册顺序启动所以WolverineRuntime先于调度器就绪public sealed class SchedulerStarter : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) { IScheduler scheduler await schedulerFactory.GetScheduler(cancellationToken); await scheduler.Start(cancellationToken); } public Task StopAsync(CancellationToken cancellationToken) Task.CompletedTask; // 交给 Quartz 托管服务 }有持久化时Wolverine 的代理机制在运行SingularAgent是集群中一个节点做这件事的受支持说法但也值得精确SingularAgent.EvaluateAssignmentsAsync选取assignments.Nodes.FirstOrDefault(x !x.IsLeader) ?? assignments.Nodes.FirstOrDefault()——每集群一次且偏爱非领导者仅在只有一个节点时才落到领导者上。它不是绑定领导者的Wolverine 自己的领导者绑定族LeaderPinnedListenerFamily是给传输监听器用的、内部注册用户无法加入。严格只跑在领导者上意味着自己写IAgentFamily并调用AssignmentGrid.RunOnLeader——这比本例值得的机制更多。QuartzSchedulerAgent的stopAsync用Standby而非Shutdown代理稍后可能重新指派到本节点而同一容器中已关停的调度器无法再启动它还持有自己启动的调度器引用并检查Status避免在宿主关停顺序Quartz 托管服务先关调度器、Wolverine 后停代理下向工厂再次索取已关停实例而抛异常。8.3 注意这里没让 Wolverine 决定什么不是哪个节点可以触发这条触发器——集群化持久化 Quartz 存储已用自己的锁回答了这个问题每个节点都跑调度器每条触发器仍只触发一次。这部分买到的是调度器自身生命周期从属于消息运行时总线先起来第一个 Job 才能发布进它。九、Part 6一个事务容纳业务行、出站封套与触发器9.1 问题调度某件事作为副作用的集成都会撞上处理器写一行、让 Wolverine 发一条消息、让 Quartz 安排一次跟进三件事中任意两件可能提交而第三件没有。Wolverine 的 Outbox 已经绑定了前两件IScheduler.EnlistTransaction让第三件加入。9.2 EnlistTransaction 做什么在返回的作用域存续期间、在当前异步流程上持久化 JobStore 使用给定事务及其所属连接而不是自己开连接。Quartz 对QRTZ_TRIGGERS的 INSERT 于是成为调用方事务里的一条语句回滚会把触发器一并带走。9.3 五个必须知道的注意点来自 src/Quartz/SchedulerEnlistmentExtensions.cs 自身文档且全部在本例中生效必须显式开启store.ConfigureStore(o o.AcceptEnlistedTransactions true)对应配置键quartz.jobStore.acceptEnlistedTransactions。不开启时存储仍开自己的连接enlist 会抛异常而不是被静默忽略仅有环境TransactionScope不够因为 JobStore 为自己打开的连接被刻意排除在它之外。共享同一连接也是避免事务升级为分布式事务的手段——Npgsql 根本不支持分布式事务征用随当前异步上下文流动必须在它要覆盖的调度调用所在的同一作用域建立。在异步辅助方法内部建立不会带回调用方——这正是下面的using写在处理器体内、而不藏在ScheduleTransactionally方法里的原因提交必须在 using 块内释放作用域就是向调度循环发出出现了一条触发器的信号在提交前释放会让循环去寻找一条它还看不见的行触发器只好等下一次获取扫描征用生效期间 JobStore 的锁在调用方事务里只有事务完成才释放让征用事务保持短小长事务会阻塞触发器获取、misfire 处理器和集群签到。消息处理器合适批量 Job 征用后干一分钟不合适。另外两个存储必须在同一个数据库——Quartz 的表和 Wolverine 的封套表可以在不同 schema但一个DbTransaction无法跨越两台服务器。9.4 为什么手开事务而不是用 [Transactional]Wolverine 的事务中间件提供其持久化提供者所提供的东西而在 6.35.0 上裸 ADO.NET 的 Postgres 包什么也不提供Wolverine.Postgresql与Wolverine.RDBMS都没有定义IPersistenceFrameProvider——提供者随文档存储或 ORM 而来裸 ADO.NET 两者都不是。对一个普通PersistMessagesWithPostgresql声明[Transactional] Handle(T msg, NpgsqlTransaction tx)能编译然后在运行时以JasperFx was unable to resolve a variable of type Npgsql.NpgsqlTransaction失败。引入 Marten 或 EF Core 可以修复多数 Wolverine 应用本来就有但手开版本正好展示 Outbox 实际在哪接入也让提交落在征用作用域内它该在的位置public static async Task Handle( ApproveRefund message, MessageContext context, IWolverineRuntime runtime, IScheduler scheduler, CancellationToken cancellationToken) { IMessageDatabase database (IMessageDatabase) runtime.Storage; await using NpgsqlConnection connection new(ExampleOptions.Current.PostgresConnectionString!); await connection.OpenAsync(cancellationToken); await using DbTransaction transaction await connection.BeginTransactionAsync(cancellationToken); // Wolverine 的出站封套写入此事务而不是立即发送。 await context.EnlistInOutboxAsync(new DatabaseEnvelopeTransaction(database, transaction)); // 1. 应用自身状态 await using (NpgsqlCommand command new( insert into refunds (order_id, amount) values (order_id, amount), connection, (NpgsqlTransaction) transaction)) { command.Parameters.AddWithValue(order_id, message.OrderId); command.Parameters.AddWithValue(amount, message.Amount); await command.ExecuteNonQueryAsync(cancellationToken); } // 2. 一条不得先于上面那行提交而发送的消息 await context.PublishAsync(new SendPaymentReminder(message.OrderId, message.Amount)); // 3. 触发器与两者同事务提交在作用域内调度器在触发器可见后才被唤醒 using (scheduler.EnlistTransaction(transaction)) { await scheduler.ScheduleJobPaymentReminderJob, PaymentReminder( new PaymentReminder(message.OrderId, message.Amount), TimeSpan.FromDays(7), new OneOffJobOptions { Group OrderGroup.For(message.OrderId) }, cancellationToken); await transaction.CommitAsync(cancellationToken); } // 释放 Outbox 扣住的封套。提交前没有任何东西离开进程。 await context.FlushOutgoingMessagesAsync(); }只有当QUARTZ_WOLVERINE_POSTGRES被设置、Program.cs以store.UsePersistentStore(...)配置UsePostgresUseSystemTextJsonSerializerUseWeaselForPostgres()Quartz 的表由 Weasel 在启动时与 Wolverine 的表一同创建迁移AcceptEnlistedTransactions true时ApproveRefundHandler才会注册运行。十、Part 7Wolverine 自己的周期调度10.1 注册与运行机制opts.Schedules.ScheduleRecurring用 Cronos 解析的 Cron 表达式发布消息五字段或前导秒字段的六字段在调度自身时区读取——UTC除非给CronSchedule传入其他时区见 src/Quartz.Examples.Wolverine/Part7WolverineSchedules.cspublic const string NightlyCron 15 3 * * MON-FRI; public const string ScheduleName expire-unpaid-orders; public static void Register(WolverineOptions opts, string cron) { CronSchedule schedule new(cron, TimeZoneInfo.Utc); opts.Schedules.ScheduleRecurring(ScheduleName, schedule, occurrence new ExpireUnpaidOrders(occurrence)); }运行机制每个集群一个SingularAgent把每个调度的下一次出现提前通过普通调度消息管道预排程因此投递、持久化、重放都是 Wolverine 早已交付的机制。每次出现携带形如{name}:{occurrenceUtc:O}的确定性去重 id——故障转移导致的重发会在消费侧被折叠而不是跑两次IRecurringScheduleControl支持运行时暂停、恢复与查询。ExpireUnpaidOrders的OccurrenceUtc是调度所说本次运行属于的时刻——与第 1 部分context.ScheduledFireTimeUtc语义一致而非代理实际动手发布消息的时刻。10.2 它刻意不做的事以及第 1 部分仍存在的理由错过的出现被跳过而非被决策没有可选 misfire 策略调度集合在UseWolverine时由代码固定宿主运行期间无法增、移、改快于每 5 秒被拒绝在注册调用点持久化调度消息按DurabilitySettings.ScheduledJobPollingTime重放更快的节奏无法兑现示例在冒烟模式用*/5 * * * * *而第 1 部分的0/2表达式若注册到 Wolverine 侧会被当场拒绝。注册位于UseWolverine内部而非AddQuartz内部——这正是要点这条调度属于 WolverineQuartz 根本不知道它存在。十一、程序装配顺序与冒烟断言Program.cssrc/Quartz.Examples.Wolverine/Program.cs的装配顺序本身就是集成规范先UseWolverine使其运行时成为第一个托管服务IHostApplicationBuilder上的 lambda 是必填的与IHostBuilder重载不同显式设置opts.ApplicationAssembly让处理器发现不依赖调用栈测试运行器与同进程第二宿主下依然成立对应 JasperFx/wolverine#3776、#3778PublishMessageArchiveOrders().ToLocalQueue(Part3RawMessageData.EndpointName)让第 3 部分的按名寻址无需消息代理Part7WolverineSchedules.Register在此注册再AddQuartz注册第 1、4 部分有数据库时用UsePersistentStore Postgres SystemTextJson 序列化 Weasel 建表 AcceptEnlistedTransactions true否则什么都不配——内存存储本就是调度器的默认回退UseInMemoryStore()只是重申默认AddQuartzHostedService(hosted { AutoStart false; WaitForJobsToComplete true; })Part5StartedByWolverine.Register按options.HasDatabase分支注册SingularAgent或普通IHostedService。启动后程序发布/调用一组演示消息OrderPlaced安排提醒、OrderPaid证明取消、Part3RawMessageData.ScheduleSend存储原始封套有数据库时再发布ApproveRefund驱动第 6 部分。每个部分的每次可观测行为都通过Ledgersrc/Quartz.Examples.Wolverine/Ledger.cs事件名常量集中在Events杜绝字符串笔误导致的漂移记录并打印--smoke则在 20 秒内轮询等待全部必需事件出现包括part1:reconciliation-published、part2:reminder-fired、part2:reminders-cancelled、part3:raw-envelope-delivered、part5:scheduler-started、part7:wolverine-schedule-published持久化模式额外要求part6:refund-approved。取消成立与否按订单号断言Smoke.PaidOrderId A-1002不得出现在任何ReminderFired详情中而不是按计数——因为持久化模式下第 6 部分会发布自己的提醒计数会随配置变成两种含义。十二、配套文档与测试保障How-To 页面docs/documentation/quartz-4.x/how-tos/wolverine.md——本文所有代码块的完整叙述版防漂移测试src/Quartz.Tests.Unit/WolverineHowToTest.cs 对照该页面校验代码可编译且行为一致示例入口与消息定义src/Quartz.Examples.Wolverine/Program.cs、src/Quartz.Examples.Wolverine/Messages.cs依赖相关源码src/Quartz/SchedulerEnlistmentExtensions.csEnlistTransaction语义、src/Quartz.Weasel.PostgreSQLWeasel 建表/迁移。一句话总结整个项目的方法论每个 C# 代码块都被示例项目编译校验文档无法偏离可编译的代码——这既是本文所有代码可直接复制运行的保证也是 Quartz.NET 与 Wolverine 集成知识长期保鲜的机制。赞分享任务调度后端【免费下载链接】quartznetQuartz Enterprise Scheduler .NET项目地址https://gitcode.com/gh_mirrors/qu/quartznet点击查看免费下载相关推荐TinyUSB 双角色动态切换实战详解 dynamic_switch 示例的 Device/Host 运行时模式切换TinyUSB 双角色动态切换实战详解 dynamic_switch 示例的 Device/Host 运行时模式切换 本指南以 TinyUSB 仓库中的 dy嵌入式驱动开发通信物联网在 Alluxio 上运行 Apache Hive两种集成模式、配置清单与实战示例在 Alluxio 上运行 Apache Hive两种集成模式、配置清单与实战示例 本篇指南围绕 Apache Hive 与 Alluxio 的集成展开详细存储分布式文件系统缓存大数据Craft.js 可拖拽页面编辑器实战examples/basic 示例运行与源码级拆解Craft.js 可拖拽页面编辑器实战examples/basic 示例运行与源码级拆解 examples/basic 是 Craft.js 官方 Basic前端上一篇Apache Druid Broker 节点配置完全指南从运行时参数到源码级原理下一篇IronClaw 事件系统架构指南Durable Events、Projection 与 Transport Streams 的可重放治理规则创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表