☰
Quartz.NET 升级演练数据播种器:用真实 3.20.0 写入的数据验证 4.0 迁移
2026/10/8 19:22:50 网站建设 项目流程
  • 任务调度
  • 后端

【免费下载链接】quartznet

Quartz Enterprise Scheduler .NET

项目地址:https://gitcode.com/gh_mirrors/qu/quartznet
点击查看免费下载

导读

Quartz.Tests.Integration.Seeder是 Quartz.NET 仓库中一个特殊的控制台项目:它引用已发布的 Quartz 3.20.0 包,向一套 3.20 数据库 schema 中写入与真实旧版本完全一致的业务数据,从而为 4.0 升级链路提供"旧数据"来源。本文将以 src/Quartz.Tests.Integration.Seeder/README.md 为主线,完整讲解该播种器的设计动机、种子内容、命令行用法与 fixture 再生成流程,并深入其源码,说明每一步底层实现。读完本文,你将理解 Quartz.NET 是如何在"新旧程序集身份冲突"的前提下,用真实版本数据验证数据库迁移脚本与 JSON 兼容性的。

为什么需要一个独立的播种进程

Quartz 4.0 升级的最大风险,不是空 schema 能否迁移,而是数据库里已经存在的 3.x 行能否被 4.0 正确读取。仓库中的UpgradeRehearsalTest需要在"非空"的 3.20 schema 上执行 4.0 迁移,LegacyJsonPayloadTest需要读取"3.20 进程真实产出"的 blob 数据,而不是手工誊写的字面量。

但这里有一个绕不开的矛盾:已发布的Quartz3.20.0 包与本仓库的Quartz具有相同的程序集身份(assembly identity),因此没有任何一个进程可以同时引用两者。解决方式就是让播种器成为独立的进程——它只编译并运行在 3.20.0 上,用 3.20 自己的 ADO job store 写入数据;测试进程再以 4.0 读取这些数据。正如 LegacySeeder.cs 的注释所写:它"用一个已发布的 Quartz 3.20.0 及其自身的 ADO job store,向 3.20 schema 填充 4.0 升级必须承载的行"。

版本锁定也是刻意的。在 Quartz.Tests.Integration.Seeder.csproj 中,三个包引用全部使用字面版本号并通过VersionOverride="3.20.0"固定,绝不使用浮动版本;原因正如项目文件注释所说——fixture 的全部意义就在于一个具名的已发布版本。同时该程序集刻意不签名(SignAssembly=false),因为它只是临时进程,永远不会发布。

播种器写入什么数据

播种器在同一个调度器名(scheduler name)和同一个表前缀(table prefix)下,写入以下六类数据(见 README):

1. 五大家族触发器 + blob 触发器

3.20 的 ADO 存储为五种触发器家族提供了持久化委托(persistence delegate):simple、cron、calendar-interval、daily-time-interval、recurrence(见 LegacySeeder.cs 的AllFamilies数组)。播种器在seed组中各写一个;随后又在blob组中再写同样的家族——这组由BlobStorageOverride拒绝提供持久化委托,强制它们进入QRTZ_BLOB_TRIGGERS表,使该表持有每种形状的真实负载。

为什么必须用这种方式构造 blob?QRTZ_BLOB_TRIGGERS在正常情况下只保存"没有持久化委托的触发器"——实践中就是应用自定义的ITrigger实现。但播种器不能用应用自己的触发器:blob 会命名本程序集里的类型,而读取它的 4.0 进程没有该程序集,行只会证明"无法解析的 blob 依然无法解析"。所以 BlobStorageOverride.cs 选择对blob这一个触发器组拒绝委托,让出厂自带的触发器类型走 blob 路径——这样 blob 里的字节就是 3.20 序列化器写出的标准负载,正是 4.0 读取器必须理解的格式。

实现上,它只重写了 3.20 六个已发布 driver delegate(SQLite、SqlServer、PostgreSQL、MySQL、Oracle、Firebird)各自的一个protected virtual方法FindTriggerPersistenceDelegate:当触发器组名为blob时返回null,其余逻辑——每一条 SQL、每一个参数绑定——全部是发布版原样。

2. 携带 EXECUTION_GROUP 与 PREFERRED_NODE 的触发器

3.18 引入了EXECUTION_GROUP,3.19 引入了PREFERRED_NODE。两者在 3.20 上都是真实存在的列,且必须在升级后原样保留。播种器在 LegacySeeder.cs 中写入一个名为pinned的触发器,同时设置.WithExecutionGroup("seeded-execution-group")与.WithPreferredNode(options.InstanceId)。

3. 全部六种日历 + 链式日历

播种器写入annual、holiday、monthly、weekly、daily、cron六种日历,外加一个"链式"组合(CronCalendar作为HolidayCalendar的基日历),见 LegacySeeder.cs。每种日历都附带探测时刻(probe instants)与 3.20 给出的答案(IsTimeIncluded结果),记录在 manifest 中。升级后 4.0 会对同样时刻给出同样问题,若日历 blob 反序列化后变成了"包含一切"的形态,测试就会失败——这正是探测点设计的意义。

为保证探测有效,LegacySeeder.cs 还做了一个防御:如果某日历的所有探测点答案全部相同(要么全包含、要么全不包含),就抛异常拒绝记录——因为这样的探测集无法发现"日历丢失了排除规则"的缺陷。

4. 覆盖全部值类型的 JobDataMap

3.20 写入的 job data map 必须覆盖4.0 的 JSON 写入门(write gate)允许的每一种值类型,因为这些值才是应用升级后仍能继续存储的值,其 3.x 写出的 blob 必须继续可读。SeedValues(见 SeedValues.cs)用一张声明表同时驱动写入与 manifest 描述,共 16 项:

KeyKind值
textstring"staging"
flagbooltrue
countint42
biglong9_000_000_000L
ratiodouble2.5d
smallfloat1.5f
moneydecimal12.34m
letterchar'q'
momentdateTime2024-07-01T03:30:00Z
offsetMomentdateTimeOffset同一时刻的DateTimeOffset
spantimeSpan42 秒
idguid固定 GUID
daydateOnly2024-07-01
timeOfDaytimeOnly03:30:00
weekdayenumDayOfWeek.Friday
labelsdictionary{ "alpha": "1", "beta": "2" }

其中Dictionary<string, string>有它自己的意义:这就是 issue #3582 的形状——3.x 的 Newtonsoft 写入器会为它装饰$type,而 4.0 的写入器不会。

此外还有一个门外的值:JobKey。4.0 会拒绝写入它,但 3.x 数据库完全可能存着一个。播种器把它放在独立的一个 job(名为exotic)上,而不是放在所有触发器指向的那个 worker job 上——否则一个不可读的条目会把整个演练拖垮(见 SeedValues.cs 与 LegacySeeder.cs)。

5. 暂停的触发器组与暂停的 Job 组

播种器构造了"暂停前存储"与"暂停后存储"两种成员(见 LegacySeeder.cs):

  • 暂停触发器组(pausedtriggers):先存before触发器,再调用PauseTriggers暂停该组,然后存after触发器。后者只能因为组已暂停而处于暂停状态——这正是QRTZ_PAUSED_TRIGGER_GRPS表存在的意义。
  • 暂停 Job 组(pausedjobs):3.x 暂停 Job 组时什么都不会记录,所以暂停后存入的after触发器并不是暂停的。manifest 特意把这一点记录下来,因为这是操作者在升级中最可能感到意外的事实。

6. 一条"执行中被放弃"的 FIRED_TRIGGERS 行

最后一种种子数据模拟的是节点崩溃:播种器先调度一个孤儿 job(orphan),启动调度器使其触发,LegacyWorkerJob在执行中阻塞(数据 map 中的blockForever键),随后进程被整体杀掉,留下一条仍在飞行中的QRTZ_FIRED_TRIGGERS行——这正是升级后 4.0 需要恢复的行。干净的关机会把这行清理掉,所以播种器在成功路径上也是用Environment.Exit(0)结束而非优雅关闭(见 Program.cs)。

LegacyWorkerJob(见 LegacyWorkerJob.cs)也是所有种子行共用的唯一 job 类型:这样 4.0 进程只需要一个类型别名就能运行 3.20 存储的一切。它的类型名会写入JOB_CLASS_NAME,而它命名的程序集是演练进程没有的——这正是每个"升级时重命名了 job 类型"的应用所处的境地。manifest 会原样携带存储拼写,供演练用UseTypeLoader(o => o.Map(...))映射(这也是迁移指南教给升级用户的机制,在这里得到了真实数据的检验)。

运行播种器:命令行参数

播种器的命令行由 SeedOptions.cs 定义并校验。完整参数如下:

参数含义默认值
--dialect数据库方言必填:sqlite|sqlServer|postgres|mysql_innodb|oracle|firebird
--connection-string对应数据库的连接字符串必填
--serializer序列化器必填:json(Newtonsoft)|stj(System.Text.Json)
--output写入seed.json的目录必填
--table-prefix播种所用的表前缀QRTZU_
--scheduler-name调度器实例名Quartz320Upgrade
--instance-id调度器实例 idseed-node
--schema先运行的 fresh-install 脚本可选(仅 SQLite)
--fixture-output转储 blob 列的目录可选

SeedOptions.Parse会强制要求四个必填参数,并校验--serializer只能是json或stj;参数解析失败时返回退出码 2 并打印用法(见 Program.cs)。

方言名与仓库中database/tables/tables_<dialect>.sql的拼写保持一致,因此调用者传给播种器的词与命名脚本的词是同一个(见 LegacyDialect.cs)。LegacyDialect负责把方言名翻译成三件事:3.20 的DbProvider认识的 provider 名(如SQLite-Microsoft、SqlServer、Npgsql、MySqlConnector、OracleODPManaged、Firebird)、播种器自己的 driver delegate 类型、以及播种器回读数据用的DbConnection。

--schema刻意只支持 SQLite:其他方言的 fresh-install 脚本都是为该数据库自带的命令行客户端写的(GO、/、SET TERM等批处理分隔符),演练测试正是通过那个客户端在容器内原样运行脚本——这是唯一能保证"告诉用户运行的脚本,就是实际运行的脚本"的方式。播种器若在这里自行拆分脚本,只会变成一份更糟的副本。而 SQLite 没有这样的客户端也不需要容器,SchemaScript.cs 就自己按分号切分语句,并专门避开BEGIN … END触发器体——那是脚本中唯一的复合结构。

seed.json:演练断言的词汇表

播种结束时,Program.cs 会把 manifest 以UTF-8 无 BOM格式写入seed.json(与仓库其他文件一致)。

SeedManifest(见 SeedManifest.cs)的定位非常关键:它记录的是3.20 实际"存储"了什么——从 3.20 调度器与数据库表本身读回,而不是播种器"请求"了什么。因为演练要回答的问题是"4.0 是否看到 3.20 写的东西",而一份基于播种器意图构建的 manifest 可能两边一致却都与数据库不符。manifest 中因此包含:从表中读回的TRIGGER_TYPE/TRIGGER_STATE、从列中读回的JOB_CLASS_NAME原始拼写、暂停触发器组行、被放弃的 fired trigger 行、日历探测点及其答案、job data map 的值类型与不变文本形式(文本而非原始值,因为 JSON 无法区分decimal与double,且断言要验证 4.0 的强类型访问器能把存储形态还原成 3.20 收到的类型)。

SeedManifest.cs还有一个实现细节:它被源码编译进Quartz.Tests.Integration而非程序集引用——因为播种器构建在已发布的 3.20 包上,任何对它的程序集引用都会撞上程序集身份冲突。因此该文件的一切都是 BCL-only,绝不能出现任何 Quartz 类型。

当传入--fixture-output时,BlobDump.cs 会把四个 blob 列逐字节写出为文件(job-data-map.json、trigger-job-data-map.json、七个calendar-*.json、按序列化器各自可用的trigger-*.json)。逐字节是硬要求:不做任何重排、缩进或再编码,列里是什么,文件里就是什么。Oracle 的 LOB 类型会被转成字节流,其他数据库直接取byte[]。

重新生成提交的 fixtures

src/Quartz.Tests.Unit/TestData/Legacy/3.20/由两次 SQLite 运行产出(其自身 README 也如此说明),分为stj/与newtonsoft/两个子目录。注意它们不是同一份数据的两种编码,而是两种序列化器在 3.x 默认设置下真实写出的不同形状:System.Text.Json 是带TriggerType判别字段的判别形式;Newtonsoft(RegisterTriggerConverters保持默认false)则是携带$type的普通对象图,且Dictionary<string, string>值自带$type(#3582 形状)。

从仓库根目录,先执行dotnet build src/Quartz.Tests.Integration.Seeder,然后运行(务必先删除临时数据库文件,schema 脚本是 fresh install,不会执行第二次):

dotnet artifacts/bin/Quartz.Tests.Integration.Seeder/debug/Quartz.Tests.Integration.Seeder.dll \ --dialect sqlite --connection-string "Data Source=/tmp/seed-stj.db;" --serializer stj \ --schema src/Quartz.Tests.Integration/SchemaBaselines/3.20/tables_sqlite.sql \ --table-prefix QRTZ_ --output /tmp/seed-stj \ --fixture-output src/Quartz.Tests.Unit/TestData/Legacy/3.20 dotnet artifacts/bin/Quartz.Tests.Integration.Seeder/debug/Quartz.Tests.Integration.Seeder.dll \ --dialect sqlite --connection-string "Data Source=/tmp/seed-json.db;" --serializer json \ --schema src/Quartz.Tests.Integration/SchemaBaselines/3.20/tables_sqlite.sql \ --table-prefix QRTZ_ --output /tmp/seed-json \ --fixture-output src/Quartz.Tests.Unit/TestData/Legacy/3.20

fixtures 中的时间戳属于捕获时刻本身:重新生成会改变StartTimeUtc与NextFireTimeUtc,因此没有任何断言针对它们。

一个值得注意的 fixture 缺口:newtonsoft/下没有trigger-daily-time-interval.json,而且不可能有。原因是一个已发布 3.20 的缺陷:在 3.x 默认设置下,DailyTimeIntervalTriggerImpl被写成普通对象图,其StartTimeOfDay与EndTimeOfDay是Quartz.TimeOfDay对象,而TimeOfDay既没有无参构造函数也没有[JsonConstructor]——所以3.20 自己也读不回这个 blob(SelectTrigger会抛 "Unable to find a constructor to use for type Quartz.TimeOfDay")。BlobStorageOverride.Families(见 BlobStorageOverride.cs)记录了同一事实:Newtonsoft 只写四个家族,System.Text.Json 由于判别形式可以往返,写全部五个。因此也不存在"在真实环境中工作过"的该行,演练无从断言 4.0 读取它。

与测试链路的关系

播种器是两条测试证据链的源头(见 src/Quartz.Tests.Integration/Impl/AdoJobStore/UpgradeRehearsalTest.cs):

  • UpgradeRehearsalTest:在QRTZU_前缀下构建 3.20 schema,由播种器填充(每种序列化器一次、各用独立调度器名——因为 Quartz 所有表以SCHED_NAME为键,两个调度器可以共享一套 schema),然后依次运行database/migrations/4.0/下的schema_30_to_40_upgrade_<dialect>.sql与schema_30_to_40_indexes_…及之后所有迁移,最后启动 4.0 调度器,把每一行种子数据与播种器写下的 manifest 对照断言。该测试被标记为[NonParallelizable]:每次运行都要在与其他迁移 fixture 共用的数据库上重建 schema,而 Firebird 会以死锁(SQLSTATE 40001)拒绝并发的元数据更新。
  • LegacyJsonPayloadTest:直接读取src/Quartz.Tests.Unit/TestData/Legacy/3.20/下的字节文件。这些文件的来源("3.x 写的")从注释升级为可复现的事实——字节是 3.20 进程真实产出的,且产出命令被写了下来。

小结

Quartz.Tests.Integration.Seeder展示了大型开源项目做升级兼容性验证的一种严谨手法:用与被测版本程序集身份冲突的旧版本包,在一个独立进程中真实写入数据,再让新版本读取并对照清单。它同时覆盖了触发器五大家族、blob 存储、日历链、job data map 全类型、暂停组语义、崩溃残留行与新旧序列化差异,把"升级后数据是否幸存"从口号变成了逐行可断言的工程事实。

  • 任务调度
  • 后端

【免费下载链接】quartznet

Quartz Enterprise Scheduler .NET

项目地址:https://gitcode.com/gh_mirrors/qu/quartznet
点击查看免费下载

相关推荐

上一篇:Audio Slicer 终极指南:如何用智能音频分割工具提升你的音频处理效率400倍
下一篇:VC++运行库修复终极指南:一键解决软件启动兼容性问题

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询