Skip to content

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

Description

@os-zhuang

维护者在 #5160(邮件队列投递)复盘中质疑「发完不该删吗」,PM 核实后确认为真缺陷。

事实(均对 origin/main 核过)

  • DbQueueAdapter 把成功任务标为 status: 'completed'(db-queue-adapter.ts:338)后再无任何机制触碰它;
  • purge(queue) / purgeFailed(messageId) 存在,但 purge 在生产代码里零调用方(仅测试),purgeFailed 只处理 dlq/failed 且是手动 API;
  • sys_job_queue 的对象定义未声明任何 retention;driver-sql 有现成的 retention/rotation 机制,该表没有用上;
  • 结论:每一次队列投递(plugin-email: 邮件投递接入持久化队列 —— send 走 email.send.async / sys_job_queue(重试+DLQ),可配置开关 #5160 落地后包括每一封队列模式的邮件)都在 sys_job_queue 留下一行永久的 completed 记录,表只增不减。

一个必须尊重的既有语义

completed 行不是纯垃圾:db-queue-adapter.ts:106 注明 completed/dlq 的去重按 created_at 窗口进行。清理必须保留 ≥ 去重窗口的近期行,否则窗口内的重复 publish 会被重新接受 —— 清理不能破坏这个约定,要有用例钉住。

处置方向(PM 裁定,实现可反驳)

  • completed 行:超过保留窗(≥ 去重窗口,具体长度实现定并说明)即清,清理动作挂在适配器自己的 poll 周期上(它已有定时循环,不要新造调度器);
  • dlq / failed 行:不动 —— 它们是死信队列,存在的意义就是等人看(listFailed / purgeFailed 是既有出口);
  • 清理量打 info 一行(清了几行、窗口多长),静默清理与静默堆积同样不可接受;
  • 若实现发现「声明式 retention(对象定义上)比适配器内清理更贴平台惯例」,可以反驳改道,但要说明为何该表适合声明式(它的写入方是适配器自己,不是用户数据)。

与在飞单的关系

落在 packages/services/service-queue,与 #5161 / #5177(plugin-email)、#5172(platform-objects + plugin-email)文件面不相交,可并批派发。

验收

  • 长跑场景(N 次 publish→completed)后表行数有界;
  • 去重窗口内的行不被清、窗口语义有回归用例;
  • dlq 行永不被自动清;
  • 默认部署无需任何新配置即获得清理行为。

Activity

  1. self-assigned this
    on Aug 4, 2026
  2. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 6 轮
    会话:session_017MCKJaEomEqg4tvz4SzdNd
    分支:claude/issue-5179-job-queue-completed-prune
    Worktree:objectstack-issue-5179
    域:domain:services
    文件面:packages/services/service-queue/src/(越界即停,报告说明)

    与 #5161 并批(plugin-email 包,文件面不相交)。裁定在 issue body:completed 超保留窗即清(窗口 ≥ 去重窗口)、dlq/failed 不动、清理挂现有 poll 周期、清理量打 info。


    Generated by Claude Code

  3. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    复核结论:ACCEPT —— PR #5192,转 ready 并入合并队列。修复 head 7d43e4d8 上 7 个工作流全绿。

    dev 走了我预留的反驳路线,并且推翻得对

    我的裁定是「清理挂适配器 poll 周期」;dev 采纳 issue 里预留的「声明式 retention」路线,三条硬事实我逐条核实成立:

    1. ADR-0057 §3.3:一个平台 reaper,不是 N 个插件各扫各的 —— 兄弟表 sys_job_run 在 main 上就是 retention: { maxAge: '30d' },sys_job_queue 是这一族唯一漏掉的。我裁定时没查这条 ADR,在适配器里再造清理器等于给同一张表加第二个扫除者。
    2. retention.onlyWhen 就是为「活状态与终态历史混居的表」造的,reaper 合并 onlyWhen 有既有回归用例钉着 —— 不是新造机制。
    3. class: 'transient' 而非 telemetry 的区分是对的:telemetry 类在注册了 telemetry datasource 的部署里会被改路由到另一个库——把还在投递的工作队列换库是迁移不是清理。

    落地:retention: { maxAge: '7d', onlyWhen: { status: 'completed' } }。只清 completed;pending/running 是活儿、dlq/failed 任何年龄都不自动清(死信队列的意义就是等人看)。用 retention 不用 ttl 的理由也对——dlq 行同样写 completed_at,TTL 没有行过滤器会把死信一起吃掉。

    本 PR 最好的一处:不变量是被强制的

    「保留窗 ≥ 去重窗」没有停留在注释:completedRetentionWindowMs() 直接读对象声明,声明被删就抛错(文案指回 platform-objects,禁止在适配器里私自扫表);构造时 idempotencyWindowMs 超过保留窗直接拒绝并报出两个数字。有人日后调短声明,红的是构造与测试,不是几天后生产环境冒出重复投递。

    我派发词里的两点顾虑均按事实化解:批量上限在声明式路线下不适用(reaper 是单条条件 DELETE,与 sys_activity 14d 等更大的表同姿态);日志走 LifecycleService 既有的单行聚合 sweep 日志,零新增 logger。

    门禁往返

    check:engine-double-contract 判红一次(今日第三例,job-queue-retention.test.ts:62 假引擎手抄守卫),修复 7d43e4d8 与 #5173 的 b169f217 同形。模式已记 #5197(finding,今日命中率 3/3)。

    长跑用例值得点名:60 天 × 每天 publish→completed→sweep,每一步断言行数 ≤ 8(不做策略则 60 行永久残留)—— 验收判据「表行数有界」是被逐步断言的,不是抽一次样。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions