
Backstage 后端调度器数据库结构详解backstage_backend_tasks__tasks表如何支撑分布式任务调度【免费下载链接】backstageBackstage is an open framework for building developer portals项目地址: https://gitcode.com/GitHub_Trending/ba/backstage本文以 report-scheduler.sql.md 这一自动生成的 SQL 结构报告为骨架完整继承其中对backstage_backend_tasks__tasks表的字段、类型、可空性与主键描述并进一步结合backstage/backend-defaults调度器入口的 Knex 迁移脚本、行类型定义与TaskWorker/PluginTaskSchedulerJanitor等实现源码解释每一列在“多副本协同领取、超时兜底、取消、手动触发”等场景下的真实语义与写入时机。读完本文你将能够读懂该表的每个字段如何映射到DbTasksRow类型理解next_run_start_at在三种调度模式duration / cron / manual下各自的取值规则掌握current_run_ticket乐观锁、current_run_expires_at超时与last_run_*审计列的协作方式从而在排查 Backstage 后端定时任务问题时能够直接定位到数据库行状态。一、报告文件的定位由yarn build:api-reports自动生成的结构快照packages/backend-defaults/report-scheduler.sql.md是backstage/backend-defaults包在构建阶段为backstage/backend-defaults/scheduler入口自动产出的 SQL 结构报告。文件顶部明确声明Do not edit this file. It is a report generated byyarn build:api-reports它的用途是把调度器入口entrypoint所依赖的数据库表结构以 Markdown 表格形式“固化”下来便于开发者在评审迁移脚本、诊断 schema 漂移时无需连接数据库即可查阅当前构建所期望的字段集合。因此本文所有字段级描述均以该报告为准同时以 tables.ts 中DbTasksRow类型与 migrations/scheduler/ 目录下的三个迁移脚本作为实现事实的交叉验证。二、表结构全量继承backstage_backend_tasks__tasks字段表下面这张表是对report-scheduler.sql.md的完整继承未做任何字段删减仅按原文顺序列出ColumnTypeNullableMax LengthDefaultcurrent_run_expires_attimestamp with time zonetrue--current_run_started_attimestamp with time zonetrue--current_run_tickettexttrue--idcharacter varyingfalse255-last_run_ended_attimestamp with time zonetrue--last_run_error_jsontexttrue--next_run_start_attimestamp with time zonetrue--settings_jsontextfalse--唯一索引/主键backstage_backend_tasks__tasks_pkeyid—— unique primary对应地tables.ts 以 TypeScript 类型形式给出了同一张表的“读写视图”可作为对报告表格的交叉验证export const DB_MIGRATIONS_TABLE backstage_backend_tasks__knex_migrations; export const DB_TASKS_TABLE backstage_backend_tasks__tasks; export type DbTasksRow { id: string; settings_json: string; next_run_start_at?: Date | string; // This can be null when in manual trigger mode current_run_ticket?: string; current_run_started_at?: Date | string; current_run_expires_at?: Date | string; last_run_error_json?: string; last_run_ended_at?: Date | string; };两处信息严格一致id与settings_json非空其余 6 列均可空且报告中的Max Length 255对应初始迁移脚本里table.string(id)的默认 255 长度。三、三个 Knex 迁移脚本字段演进的时间线该表的 8 个列并非一次性建立而是由三个迁移脚本分阶段产出全部位于 packages/backend-defaults/migrations/scheduler/3.1 初始建表20210928160613_init.js对应 20210928160613_init.js 的up()await knex.schema.createTable(backstage_backend_tasks__tasks, table { table.comment(Tasks used for scheduling work on multiple workers); table.string(id).primary().notNullable() .comment(The unique ID of this particular task); table.text(settings_json).notNullable() .comment(JSON serialized object with properties for this task); table.dateTime(next_run_start_at).notNullable() .comment(The next time that the task should be started); table.text(current_run_ticket).nullable() .comment(A unique ticket for the current task run); table.dateTime(current_run_started_at).nullable() .comment(The time that the current task run started); table.dateTime(current_run_expires_at).nullable() .comment(The time that the current task run will time out); });注意此时next_run_start_at是NOT NULL因为最早版本只支持基于时长duration的调度任务必须有一个明确的下次启动时间。3.2 支持手动触发20240712211735_nullable_next_run.js20240712211735_nullable_next_run.js 将该列改为可空以支持cadence: manual手动触发模式——此时任务没有固定的下次启动时间next_run_start_at保持NULL仅在调用trigger时短暂置为当前时间。该迁移同时提供了down()先把next_run_start_at IS NULL的任务行删除再回滚非空约束。这解释了DbTasksRow中next_run_start_at的注释“This can be null when in manual trigger mode”的由来也正是报告里该列Nullable true的原因。3.3 增加审计列20250411000000_last_run.js20250411000000_last_run.js 追加了两个可空列table.text(last_run_error_json, longtext).nullable() .comment(JSON serialized error object from the last task run, if it failed); table.dateTime(last_run_ended_at).nullable() .comment(The last time that the task ended);这两列是“上一次运行”的审计信息last_run_ended_at记录上一次运行结束时刻last_run_error_json记录失败时的错误 JSON 序列化文本成功时为NULL。它们的引入让运维人员无需翻阅日志即可在数据库里看到“上一次为什么失败”。迁移执行由 migrateBackendTasks.ts 统一驱动export async function migrateBackendTasks(knex: Knex): Promisevoid { await knex.migrate.latest({ directory: migrationsDir, tableName: DB_MIGRATIONS_TABLE, }); }其中migrationsDir通过resolvePackagePath(backstage/backend-defaults, migrations/scheduler)指向本节所述的三个脚本迁移历史写入独立的backstage_backend_tasks__knex_migrations表与业务数据表解耦。DefaultSchedulerService在获取到数据库客户端后且未配置database.migrations.skip会调用该方法完成建表与升级。四、settings_json与任务设置 Schema三种调度模式的编码报告里settings_json是text NOT NULL。该列存储的不是任意 JSON而是受 zod 约束的TaskSettingsV2定义于 types.tsexport const taskSettingsV2Schema z.object({ version: z.literal(2), cadence: /* cron | manual | ISO 8601 Duration 三选一 */, timeoutAfterDuration: z.string().refine(isValidOptionalDurationString, { message: Invalid duration, expecting ISO Period, }), initialDelayDuration: z.string().optional().refine(isValidOptionalDurationString, {...}), });cadence字段是三选一的字符串cron 表达式、字面量manual、或 ISO 8601 Duration如PT10M、P1D。TaskWorker.persistTask在写入settings_json前会先用taskSettingsV2Schema.parse(settings)做一轮强校验防止写入无法再被读回的非法内容。这解释了为什么该列既是NOT NULL又是text内容是有约束的 JSON 文本长度不固定需要独立校验。五、current_run_*三列乐观锁与超时兜底current_run_ticket、current_run_started_at、current_run_expires_at三列构成一个“运行中状态”的复合标记任一任务正在被某一 worker 持有时current_run_ticket会被写入一个 UUID作为本次运行的票据/锁current_run_started_at为领取时刻current_run_expires_at为超时时刻由timeoutAfterDuration计算得出。5.1 领取tryClaimTask的原子条件更新TaskWorker.ts 中const rows await this.knexDbTasksRow(DB_TASKS_TABLE) .where(id, , this.taskId) .whereNull(current_run_ticket) .update({ current_run_ticket: ticket, current_run_started_at: startedAt, current_run_expires_at: expiresAt, }); return rows 1;关键点WHERE current_run_ticket IS NULL让“只有一个 worker 能成功领取”成为数据库层面的原子保证——即使有多个副本并发到达同一next_run_start_at最终也只会有 1 行被更新其余返回 0 行并进入claim-lost分支。5.2 心跳checkLiveness校验票据执行期间claimAndRun会周期性地调用checkLiveness(ticket)它按id current_run_ticket 票据反查自己持有的锁是否仍在若票据已被外部取消、超时清理清空则触发AbortController中止任务执行。该心跳间隔由workCheckFrequency默认 5 秒决定对应报告里current_run_ticket在运行期间的稳定存在。5.3 释放tryReleaseTask计算下一轮时间任务正常结束或抛出异常时都会调用tryReleaseTask(ticket, settings, error?)const rows await this.knexDbTasksRow(DB_TASKS_TABLE) .where(id, , this.taskId) .where(current_run_ticket, , ticket) .update({ next_run_start_at: nextRun, current_run_ticket: this.knex.raw(null), current_run_started_at: this.knex.raw(null), current_run_expires_at: this.knex.raw(null), last_run_ended_at: this.knex.fn.now(), last_run_error_json: error ? serializeError(error) : this.knex.raw(null), });注意这里同时清空三个current_run_*列、写入last_run_*审计列并按调度模式重算next_run_start_at。六、next_run_start_at的计算Duration / Cron / Manual 三分支next_run_start_at在报告里是可空的timestamp with time zone。它的取值逻辑集中在TaskWorker.computeNextRunStartAt与persistTask并针对 SQLite / MySQL / PostgreSQL 分别下发生成 SQL调度模式persistTask初始startAt/nextStartAtcomputeNextRunStartAt下一轮cadence manual均为NULL见 3.2 迁移返回NULLcadence为 ISO Duration如PT10M立即开始下一轮 now cadencegreatest(next_run_start_at interval X second, now())SQLite 使用datetime(...)cadence为 cron用CronTime(...).sendAt()计算首次触发时间若为* * * * *则立即触发下一次 cron 命中时间从源码结构看PostgreSQL/MySQL 走greatest(now(), next_run_start_at interval)的语义即“不早于现在、不早于上次计划时间加周期”SQLite 用datetime(...)与max(datetime(next_run_start_at, ?), datetime(now))达到同样效果。这一细节决定了在多副本部署下即使某副本短暂落后恢复后也不会立即“追赶”已错过的所有周期而是从“now”起步。七、last_run_*与 Janitor超时与取消的兜底写入last_run_error_json与last_run_ended_at除了常规tryReleaseTask路径写入之外还有两个非常关键的写入方7.1 手动取消TaskWorker.cancelTaskWorker.ts 中cancel(taskId)按“任务存在 current_run_ticket非空”两个前置条件一次性完成const updatedRows await knexDbTasksRow(DB_TASKS_TABLE) .where(id, , taskId) .where(current_run_ticket, , row.current_run_ticket) .update({ next_run_start_at: nextRun, current_run_ticket: knex.raw(null), current_run_started_at: knex.raw(null), current_run_expires_at: knex.raw(null), last_run_ended_at: knex.fn.now(), last_run_error_json: serializeError(new Error(Task was cancelled)), });取消的“失败信息”以Task was cancelled的形式写入last_run_error_json这解释了报告中last_run_error_json的 comment “JSON serialized error object from the last task run, if it failed”——“失败”包括超时、异常与人为取消三种来源。7.2 超时清理PluginTaskSchedulerJanitor每分钟扫描PluginTaskSchedulerJanitor.ts 是一个独立的清理循环由DefaultSchedulerService在启动时以Duration.fromObject({ minutes: 1 })的间隔拉起非测试环境tasks await this.knexDbTasksRow(DB_TASKS_TABLE) .where(current_run_expires_at, , this.knex.fn.now()) .update({ current_run_ticket: dbNull, current_run_started_at: dbNull, current_run_expires_at: dbNull, last_run_ended_at: this.knex.fn.now(), last_run_error_json: serializeError(new Error(Task timed out)), }) .returning([id]);它的职责是把current_run_expires_at now()的“失联运行”例如 worker 崩溃、进程被 kill视为超时清空current_run_*三列并把失败原因写成Task timed out。SQLite 与 MySQL 分支下由于RETURNING语义差异先select(id)再whereIn(...).update(...)两步完成逻辑等价。该清理与checkLiveness心跳共同构成“任务超时必被回收”的保证——即使持锁 worker 突然失联下一分钟内current_run_ticket也会被清空其他副本才能重新领取。7.3 手动触发TaskWorker.triggertrigger(taskId)只把next_run_start_at更新为now()前提是current_run_ticket IS NULL若已有运行中的票据则抛出ConflictErrorconst updatedRows await knexDbTasksRow(DB_TASKS_TABLE) .where(id, , taskId) .whereNull(current_run_ticket) .update({ next_run_start_at: knex.fn.now() }); if (updatedRows 1) { throw new ConflictError(Task ${taskId} is currently running); }这是manual模式唯一能推动任务进入运行态的入口也是next_run_start_at在manual任务上短暂变为非空的唯一来源。八、HTTP 观察面TaskWorker.taskStates把整行翻译成 API 视图TaskWorker.ts 里的taskStates(knex)会把整张表逐行映射为TaskApiTasksResponse[taskState]其映射规则恰好与报告中的列一一对应若current_run_started_at非空返回{ status: running, startedAt, timesOutAt, lastRunEndedAt, lastRunError }否则返回{ status: idle, startsAt, lastRunEndedAt, lastRunError }。这个映射被PluginTaskSchedulerImpl.getRouter()中的GET /.backstage/scheduler/v1/tasks使用因此运维侧看到“running / idle”的状态本质上是由current_run_started_at是否为NULL决定的startsAt则来自next_run_start_atmanual模式下为undefined。这意味着该表既是调度内部状态的存储也是对外可观测性的数据源。九、小结从报告到实现的三组语义身份与配置id主键character varying(255)settings_jsontext NOT NULL受TaskSettingsV2约束唯一标识一个任务并承载其调度参数运行时状态next_run_start_at、current_run_ticket、current_run_started_at、current_run_expires_at四列组合成“下一次何时可领取 当前是否被领取 何时超时”的分布式锁视图审计与兜底last_run_ended_at与last_run_error_json覆盖“正常结束、异常、超时、取消”四种终结路径配合PluginTaskSchedulerJanitor的每分钟扫描使该表在多副本部署下具备自我修复能力。在只读视角下排查一个“任务没有按时运行”或“任务卡住”的问题可以从该报告对应的这张表切入先确认settings_json中cadence是否为预期模式再看current_run_ticket是否为NULL若长期非空则说明某 worker 持锁异常等待 Janitor 兜底最后用last_run_error_json与last_run_ended_at复盘上一次失败原因从而把“调度器行为”与“数据库行状态”一一对应起来。【免费下载链接】backstageBackstage is an open framework for building developer portals项目地址: https://gitcode.com/GitHub_Trending/ba/backstage创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考