fix(service-queue,platform-objects): sys_job_queue 的 completed 行按声明式 retention 到期即清(#5179) - #5192
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…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
|
追加提交
已按其余 14 个 pinned double 的同一形状修正:假引擎的 本地复核: 顺带记一笔:这个 devDependency 一加, Generated by Claude Code |
…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>
Fixes #5179
采纳了 issue 里允许的「声明式 retention」反驳路线
issue 的裁定给了两条路(适配器内清理 / 声明式 retention),并要求走后者时说明理由。本 PR 走声明式,理由是三条硬事实,不是偏好:
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 循环里再造一个清理器,等于给同一张表加第二个扫除者。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)")。lifecyclesettings 命名空间按环境覆盖,不用改代码。落地内容
packages/platform-objects/src/audit/sys-job-queue.object.ts: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类在注册了telemetrydatasource 的部署里会被改路由到另一个库(enginegetDriver第 3 步,优先级高于 manifest 的defaultDatasource: 'cloud')。把一个还在投递的工作队列换库是迁移,不是清理;transient按 §3.6 明确留在主库。保留窗与去重窗口的关系(代码注释里写死了,不只在这里)
去重是拿终态行的
created_at跟idempotencyWindowMs(默认 24h)比,reaper 的 cutoff 用的是同一根created_at轴 —— 所以「保留窗 ≥ 去重窗」就等价于「去重还需要的行一定还没被清」,两条规则之间没有时钟偏差。7d 给了一周的投递历史可查,对默认去重窗有 7 倍余量。这条不变量现在是被强制的,不是巧合:
DbQueueAdapter读对象上声明的窗口(新导出completedRetentionWindowMs()),构造时若idempotencyWindowMs比它长就直接抛错并报出两个数字 —— 而不是等几天后冒出重复投递、日志里一行都没有。注释落在三处:对象定义、DbQueueAdapterOptions.idempotencyWindowMs、publish()的去重点。关于 PM 补充里的两点
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(见下)。[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;listFailed;命令与结果见报告;
plugin-email(队列消费方)全量 170 例一并跑绿,未受影响。Generated by Claude Code