Skip to content

fix(service-queue,platform-objects): sys_job_queue 的 completed 行按声明式 retention 到期即清(#5179) - #5192

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5179-job-queue-completed-prune
Aug 4, 2026
Merged

fix(service-queue,platform-objects): sys_job_queue 的 completed 行按声明式 retention 到期即清(#5179)#5192
os-zhuang merged 2 commits into
mainfrom
claude/issue-5179-job-queue-completed-prune

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5179

采纳了 issue 里允许的「声明式 retention」反驳路线

issue 的裁定给了两条路(适配器内清理 / 声明式 retention),并要求走后者时说明理由。本 PR 走声明式,理由是三条硬事实,不是偏好:

  1. ADR-0057 §3.3 明写「一个平台级 reaper,不是 N 个插件各扫各的」。 LifecycleService 由 engine 插件无条件注册(packages/objectql/src/plugin.ts:288,注释原话:"a declared retention that drives no sweeper is dead surface"),每个有数据引擎的 kernel 都在跑,默认每小时一次。同包的兄弟表 sys_job_run 早就是这么做的(retention: { maxAge: '30d' }),sys_job_queue 是这一族里唯一漏掉的。在适配器 poll 循环里再造一个清理器,等于给同一张表加第二个扫除者。
  2. retention.onlyWhen 就是为这种表造的。 spec 里它的 describe 原话:"for tables that interleave live workflow state with terminal history"。sys_job_queue 正是这种表 —— pending/running 是活的、completed 是历史、dlq 等人看。reaper 已经把它并进删除条件(lifecycle-service.ts:654 → :783),且已有回归用例(lifecycle-service.test.ts 的 "merges retention.onlyWhen into the reap filter (mixed tables, ADR-0057 data lifecycle follow-ups: retire per-plugin sweepers, dev telemetry datasource + db:clean, Studio surface, PG rotation (tracking) #2834)")。
  3. 该表的写入方是适配器自己,不是用户数据 —— 这正是无人值守删除在这里安全、而对业务表不安全的原因;窗口写在对象定义上,运维能看见,还能经 lifecycle settings 命名空间按环境覆盖,不用改代码。

落地内容

packages/platform-objects/src/audit/sys-job-queue.object.ts:

lifecycle: {
  class: 'transient',
  retention: { maxAge: '7d', onlyWhen: { status: 'completed' } },
}
  • 只清 completed pending/running 是未投递的活儿,dlq/failed 是死信队列,存在的意义就是等人处理(listFailed / replay / purgeFailed 仍是唯一出口),任何年龄都不自动清
  • retention 不用 ttl TTL 没有行过滤器,而 dlq 行同样会写 completed_at —— 一个 ttl: { field: 'completed_at' } 会把死信队列一起吃掉。
  • class: 'transient'(workflow / ephemeral state),不是 telemetry 按 ADR-0057 §3.6,telemetry/event/audit 类在注册了 telemetry datasource 的部署里会被改路由到另一个库(engine getDriver 第 3 步,优先级高于 manifest 的 defaultDatasource: 'cloud')。把一个还在投递的工作队列换库是迁移,不是清理;transient 按 §3.6 明确留在主库。

保留窗与去重窗口的关系(代码注释里写死了,不只在这里)

去重是拿终态行的 created_atidempotencyWindowMs(默认 24h)比,reaper 的 cutoff 用的是同一根 created_at —— 所以「保留窗 ≥ 去重窗」就等价于「去重还需要的行一定还没被清」,两条规则之间没有时钟偏差。7d 给了一周的投递历史可查,对默认去重窗有 7 倍余量。

这条不变量现在是被强制的,不是巧合:DbQueueAdapter 读对象上声明的窗口(新导出 completedRetentionWindowMs()),构造时若 idempotencyWindowMs 比它长就直接抛错并报出两个数字 —— 而不是等几天后冒出重复投递、日志里一行都没有。注释落在三处:对象定义、DbQueueAdapterOptions.idempotencyWindowMspublish() 的去重点。

关于 PM 补充里的两点

  • 批量上限:声明式路线下清理不是逐行删,而是每次 sweep 一条 DELETE ... WHERE created_at < ? AND status = 'completed'(lifecycle-service.ts:780),不存在「一次 poll 删 N 行」的循环。REAP_GUARD 的 500×20 分批只用于注册了 reap guard 的对象。这是 sys_activity(14d)、sys_job_run(30d)这些量级更大的表一直以来的姿态,没有为本表另立一套的理由;若认为首扫存量表的单条大 DELETE 需要分批,那是 LifecycleService 对所有 lifecycle 表的共性问题,已另开 finding(见下)。
  • 日志不吵:LifecycleService 每次 sweep 只打一行聚合 info([lifecycle] sweep: N policy(ies) applied, ~M rows reaped, ...),而且注释原话就是 "cleanup must not re-feed the tables it drains"。本表只是被计入这行既有日志,没有新增 logger,也不会随 poll(秒级)刷屏。

测试

新增 packages/services/service-queue/src/job-queue-retention.test.ts(10 例),其中 sweep() 忠实镜像 reaper 的 where 构造({ created_at: { $lt: cutoff }, ...onlyWhen } + multi: true + 系统上下文),并且读声明本身而不是把窗口再抄一遍:

  • 声明钉死:class / maxAge: '7d' / onlyWhen 恰好是 { status: 'completed' } / 无 ttl / 无 archive;
  • 保留窗 ≥ 默认去重窗(有人调短声明就红,而不是等生产出重复投递);
  • 长跑:60 天 × 每天 publish→completed→sweep,每一步行数 ≤ 8(不做策略则是 60 行永久残留);
  • 十年前的 dlq / failed / pending / running 行一律不被清,dlq 依然可 listFailed;
  • 去重窗口内(23h)的 completed 行经 sweep 后仍在,重复 publish 仍被去重返回同一 id;
  • 超过保留窗后行被清,同 key 重新被接受(此时早已出了去重窗,这是去重契约本身,不是破坏);
  • 30 天没人处理的 pending 行 sweep 动不了,handler 一注册照常投递。

命令与结果见报告;plugin-email(队列消费方)全量 170 例一并跑绿,未受影响。


Generated by Claude Code

…rows expire on a declared ADR-0057 retention (#5179)

DbQueueAdapter marked delivered messages `completed` and nothing ever touched
the row again: `purge()` had zero production callers, `purgeFailed()` is a
manual dead-letter API, and the object declared no lifecycle policy — so the
queue table only ever grew (one permanent row per queued email since #5160).

sys_job_queue now declares `lifecycle: { class: 'transient', retention:
{ maxAge: '7d', onlyWhen: { status: 'completed' } } }`, enforced by the one
platform-owned LifecycleService reaper (ADR-0057 §3.3) on its existing hourly
sweep — no new sweeper in the adapter's poll loop, no new configuration.
`pending`/`running` (live work) and `failed`/`dlq` (the dead-letter queue) are
never swept at any age.

The dedup window becomes an enforced invariant rather than a coincidence:
publish dedups terminal rows by `created_at` against `idempotencyWindowMs`, the
reaper cuts off on the same axis, and DbQueueAdapter now reads the declared
window (`completedRetentionWindowMs()`) and throws at construction if the
idempotency window is configured longer than it.

`class: 'transient'` and not `telemetry`: per ADR-0057 §3.6 a
telemetry/event/audit class relocates the table to the dedicated `telemetry`
datasource wherever one is registered, and moving a live work queue's storage
would be a migration, not a cleanup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 8:31am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/service-queue.

4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-queue)
  • content/docs/plugins/packages.mdx (via @objectstack/platform-objects, @objectstack/service-queue)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-queue)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…ctQL's own dispatch (#4550)

`check:engine-double-contract` flagged the new fake engine in
job-queue-retention.test.ts: its `delete()` hand-mirrored the engine's guard
(`if (opts?.where?.id == null) throw`) instead of routing through
`assertEngineDeleteDispatch`. A mirror is looser than the producer on exactly
the shape a copy always drops — `where: { id: { $in: [...] } }` reads as an id
and is a multi-row predicate the real engine rejects without `multi` — and a
double looser than the engine it stands in for is how #4434 shipped a dead REST
route with its suite green.

Routes through the producer's predicate, same shape as the other 14 pinned
doubles, and adds the `@objectstack/objectql` devDependency the import needs
(the precedent set in plugin-email by b169f21, and in plugin-approvals /
plugin-sharing before it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd

Copy link
Copy Markdown
Contributor Author

追加提交 7d43e4d8 —— check:engine-double-contract 点名了本 PR 新增的假引擎:

PINNED: job-queue-retention.test.ts declares 1 engine double whose delete() does not route through assertEngineDeleteDispatch

已按其余 14 个 pinned double 的同一形状修正:假引擎的 delete()assertEngineDeleteDispatch(opts) 开头,按返回的 dispatch 分 multi / by-id 两支实现,不再手抄引擎的守卫(手抄漏掉的正是 where: { id: { $in: [...] } } 这种「看着像 id、其实是多行谓词」的形状)。为此给 @objectstack/service-queue 加了 @objectstack/objectql devDependency(先例:b169f217 的 plugin-email、以及 plugin-approvals / plugin-sharing),pnpm-lock.yaml 随之更新。

本地复核:

$ node scripts/check-engine-double-contract.mjs
check-engine-double-contract: OK — 15 pinned, 31 in the DEBT ledger, 1 exempt.

$ pnpm --filter @objectstack/service-queue exec vitest run --maxWorkers=2
 Test Files  3 passed (3)
      Tests  30 passed (30)

顺带记一笔:这个 devDependency 一加,db-queue-adapter.test.ts 那条 DEBT 台账条目自述的阻塞理由(「service-queue does not depend on objectql」)就不成立了,它的 closes 只剩第二步。按派发指令「只修这一处」没有在本 PR 里动它,已另开 #5198 记录。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 08:40
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit e98fb14 Aug 4, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5179-job-queue-completed-prune branch August 4, 2026 08:51
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 4, 2026
…bjectstack-ai#5193) (objectstack-ai#5201)

SKIP_OBJECTS group (2) — ADR-0057 decision 5 "stop the amplifier" — already
listed sys_job / sys_job_run / sys_automation_run, but not their highest-volume
sibling sys_job_queue. The audit writers register for all objects and there is
no system-context exemption, so DbQueueAdapter's own writes were mirrored into
sys_audit_log AND sys_activity: at least three per message (publish insert,
lease pending->running, terminal ->completed), plus a retry update per failure
and the objectstack-ai#5192 reaper's periodic DELETE — on every email since objectstack-ai#5160 routed
delivery through the queue. Each beforeUpdate also paid an extra findOne
snapshot of the row it was about to change.

sys_job_queue is engine-owned plumbing (managedBy: 'engine-owned',
enable.apiMethods: ['get','list'], lifecycle.class: 'transient' since objectstack-ai#5179)
that no user can write, so the rows carried no compliance value.

Tests pin the whole message lifecycle (insert/lease/complete/reaper delete)
producing zero rows, the four siblings sharing one exemption group, the skipped
snapshot read (with a business-object control), and that ordinary writes are
still audited.


Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-queue: completed 任务行无人清理 —— purge() 零生产调用方、sys_job_queue 未声明 retention,队列表只增不减

2 participants