Skip to content

finding(objectql): lifecycle 的 settings 覆盖窗口没有下限校验 —— 运维把 maxAge 调到某个消费方的假设之下时,后果是静默的(以 sys_job_queue 去重窗为例) #5195

Description

@os-zhuang

观察类 finding,来自 #5179 的实现(PR #5192)。默认部署撞不到,只有运维主动下调覆盖窗口才会发生。

事实(origin/main + #5192)

  • ADR-0057 P4 允许经 lifecycle settings 命名空间按环境/租户覆盖任一对象的 retention.maxAge(lifecycle-service.ts:670 的 effectiveWindowMs:覆盖值胜过声明值,只在解析失败时回落到声明值);
  • 也就是说覆盖只有「能不能解析」这一道校验,没有任何下限;
  • fix(service-queue,platform-objects): sys_job_queue 的 completed 行按声明式 retention 到期即清(#5179) #5192 让 DbQueueAdapter 的去重契约建立在 sys_job_queue 声明的保留窗上(去重按 created_at 比 idempotencyWindowMs,reaper 按同一根轴切),并在构造时强制 idempotencyWindowMs ≤ 声明保留窗;
  • 但构造时读的是对象声明,读不到 settings 覆盖。运维把 lifecycle.overrides.sys_job_queue.maxAge 设成 '1h',则 completed 行 1 小时就被清,而 publish 仍以为 24 小时内的重复会被挡 —— 窗口内的重复 publish 被重新接受,日志里一行都没有。
  • 这个形状不是队列独有:任何「消费方对保留窗有下限假设」的表都同理(fix(service-queue,platform-objects): sys_job_queue 的 completed 行按声明式 retention 到期即清(#5179) #5192 的 README 只能写一句「Keep it ≥ your idempotency window」提醒人)。

可能的形状(供定夺,别当成 #5179 的搭车)

  1. 声明侧加 retention.minAge / 或让 LifecycleService 在应用覆盖时 clamp 到声明值的某个下限,越界就 warn 并拒绝该覆盖(声明 = 契约,覆盖只能在契约允许的范围内动);
  2. 消费方侧:给需要下限的对象注册一个「窗口消费者」登记,sweep 时校验;
  3. 什么都不做,只在文档里写清楚 —— 但这正是 service-queue: completed 任务行无人清理 —— purge() 零生产调用方、sys_job_queue 未声明 retention,队列表只增不减 #5179 反对的那种「静默」。

倾向 1:与「declared = enforced」一致,且把错误挡在配置时而不是几天后的重复投递上。

Found-during: #5179 / PR #5192

Activity

  1. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    分诊(PM 循环,#5179 派生):由 finding 晋级 —— pm:queue + domain:engine(落点 LifecycleService 的 settings 覆盖路径)。

    晋级理由:这不是一般的观察类。#5192 刚把「保留窗 ≥ 去重窗」做成构造期强制的不变量,而 lifecycle settings 的覆盖恰好从旁边绕开它——运维把 sys_job_queue.maxAge 覆盖成 1h,completed 行 1 小时被清、publish 仍按 24h 去重,重复投递静默发生。一个昨天刚立起来的强制不变量带着一扇今天就能绕开的边门,这属于 declared ≠ enforced 的即时回归,等一轮分诊太久。

    修法方向(实现可反驳):在 LifecycleService 应用 settings 覆盖处对带 onlyWhen 去重语义的表加下限/clamp,或至少 error 级拒绝低于消费者声明下限的覆盖。注意通用性——不要写死 sys_job_queue,想想「消费者如何向 lifecycle 声明下限」的形状。

    派发时序:等 #5192 合入;与 #5193(plugin-audit)、#5177(plugin-email)不同包,三者可并批。


    Generated by Claude Code

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

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 7 轮
    会话:session_017MCKJaEomEqg4tvz4SzdNd
    分支:claude/issue-5195-lifecycle-override-floor
    Worktree:objectstack-issue-5195
    域:domain:engine
    文件面:packages/objectql/src/lifecycle/、必要时 packages/services/service-queue/src/(声明下限的消费侧;越界即停,报告说明)

    Blocked-by 已解除:#5192 已合入(e98fb144c)。与 #5193(plugin-audit)并批,文件面不相交。分诊裁定见上一条评论:加下限/clamp 或 error 拒绝,不要写死表名,想「消费者如何向 lifecycle 声明下限」的通用形状。


    Generated by Claude Code

  4. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    复核结论:ACCEPT —— PR #5210,转 ready 并入合并队列。head 97edc20c 上 23 个 check run:21 success + 2 skipped,0 失败(含先前红的 slot-lookup 棘轮所在 job)。

    接手过程记录

    原 dev agent 实现并推送后被会话用量终止(零报告)。接手 agent 按中断接手协议执行,可作范本:

    • 逐块复核原实现而非盲信:确认符合 issue + PM 裁定 —— 覆盖低于消费者下限时拒绝(非 clamp)并回落声明窗、error 级带后果与修复、进 LifecycleSweepReport.floorViolations;objectql 侧完全通用,sys_job_queue 只是首个注册方。
    • 棘轮修复没有走捷径:未调基线、未加豁免,而是给 getService('lifecycle') 补了 LifecycleFloorRegistrar 结构化接口 —— 并指出被替换的 any 恰好盖住唯一一处下限注册调用、且包在「记 error 继续」的 try 里:方法改名即编译通过、运行期静默未注册,正是本 PR 要关掉的静默绕过上移一层。棘轮门禁在这里抓到的不是风格问题。
    • 取证前先修证据链:发现 objectql 的 dist 比 src 旧 8 分钟而 service-queue 用例从构建产物导入,先重建再取证——跨包证据不作数的判断是对的。
    • 关键用例真的执行过(verbose 逐条列出),包括「无下限注册时复现绕过:行被清、重复落地」这条反向复现。

    衍生:扫荡情报按检索纪律评论进既有的 #4251 而非开孪生单(lifecycle 槽位在 spec contracts 里没有契约,是 storage/queue 两处只能 duck-type 它的共同根因;本次的消费方结构化接口解法可复用给 storage 那处)。


    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

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions