Skip to content

fix(service-messaging): pnpm dev 控制台每秒刷 48 行 Field 'updated_at' is read-only —— outbox 的 claim/ack 显式写了平台自动盖的审计列 #4765

Description

@os-zhuang

现象

pnpm dev(showcase)启动后,控制台永远停不下来地循环输出同一行 WARN:

2026-08-03T03:49:37.101Z WARN Field 'updated_at' is read-only — ignoring incoming change (#2948)

实测稳定 48 行/秒,一直刷到进程退出。120 秒的 dev 日志里 1044 行输出,其中 456 行是这一条(其余是正常的启动日志);再跑久一点这一条会淹没所有真实日志。空闲、没人访问、没有浏览器连上来的 dev server 也照刷不误。

复现

pnpm install && pnpm build
pnpm dev # examples/app-showcase

启动完成约 2 秒后开始刷。

根因

告警来自 packages/objectql/src/validation/rule-validator.ts:420stripReadonlyFields#2948):调用方在非 system 上下文的 UPDATE 里显式带了 readonly 字段,平台把它剥掉并 warn 一次。

刷屏的调用方是 @objectstack/service-messaging 的两个 SQL outbox——堆栈(--stack-trace-limit=80 抓的)指向:

  • SqlNotificationOutbox.claim
  • SqlNotificationOutbox.claimDigest
  • SqlHttpOutbox.claim

三处 claim*()第一步都是无条件的 "reap stale in_flight" 谓词 UPDATE(visibility-timeout 回收),payload 里带了 updated_at: nowstripReadonlyFields 是按 payload 的字段告警的,不看命中了几行——所以哪怕 outbox 是空的、一行都没 reap 到,每次调用照样 warn 一次。

算一下正好对得上:NotificationDispatcher 每 tick 跑 claim + claimDigestHttpDispatcher 每 tick 跑 claim,各自遍历 8 个 partition → 每 tick (2 + 1) × 8 = 24 次 warn;dispatcher 默认 intervalMs = 50048 行/秒

updated_at 本来就轮不到调用方写:packages/objectql/src/plugin.ts 的内建 hook sys_stamp_audit_update每一次 update 上无条件盖 record.updated_at = now。也就是说 outbox 传的这个值先被剥掉、再被平台盖回去——纯冗余,行为上完全是 no-op,只剩噪音。

顺带暴露的一个隐患

同模块的 audit-timestamp.ts 写得很清楚:created_at / updated_at 在 Postgres/MySQL 上是原生 TIMESTAMP 列,必须写 Date,写裸 epoch-ms 数字会被真·timestamp 列拒绝(就是当初弄坏 sys_notification_delivery 保留期清理的那个 bug)。

enqueue() 遵守了这条(const now = new Date()),但 claim() / claimDigest() / ack() / redeliver() 传的是 opts.now ?? Date.now()epoch-ms 数字。现在没炸,只是因为它在 UPDATE 路径上被 stripReadonlyFields 剥掉了——一旦哪天这些写入改走 system 上下文(system 上下文跳过剥离),这个数字就会直接落到 Postgres 上。

所以正确的修法不是"让它别 warn",而是把这个本就不该传的字段删掉

修复方向

从两个 SQL outbox 的所有 UPDATE payload 里删掉 updated_at,交给 sys_stamp_audit_update 盖:

  • packages/services/service-messaging/src/sql-outbox.tsclaim() ×2、claimDigest() ×2、ack()
  • packages/services/service-messaging/src/sql-http-outbox.tsclaim() ×2、ack()redeliver()

INSERT 路径(enqueue())不动:那里 created_at 是有意义的,且 insert 不走 stripReadonlyFields

加一个回归测试:outbox 交给 engine 的 update payload 里不得出现 updated_at

影响

纯噪音 + 上面那个隐患,没有行为变化(值本来就被剥掉又盖回去)。但它让 pnpm dev 的控制台基本不可用——真实的 warn/error 会被瞬间冲走。

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions