Skip to content

fix(service-messaging): SQL outbox 的 UPDATE 不再写 updated_at —— pnpm dev 控制台停止刷屏 (#4765) - #4766

Merged
os-zhuang merged 1 commit into
mainfrom
claude/pnpm-dev-console-loop-u82shp
Aug 3, 2026
Merged

fix(service-messaging): SQL outbox 的 UPDATE 不再写 updated_at —— pnpm dev 控制台停止刷屏 (#4765)#4766
os-zhuang merged 1 commit into
mainfrom
claude/pnpm-dev-console-loop-u82shp

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4765.

现象

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

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

空闲、没人访问、没有浏览器连上来的 dev server 也照刷不误。真正的 warn / error 瞬间被冲走。

根因

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

--stack-trace-limit=80 抓到的调用方是 @objectstack/service-messaging 的两个 SQL outbox:

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

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

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

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

改动

从两个 outbox 的所有 UPDATE payload 里删掉 updated_atclaim / claimDigest / ack / redeliver),交给平台 hook 盖。存进去的行没有任何变化。

INSERT 路径(enqueue()不动:那里 created_at 是调用方拥有的,且 insert 不走 stripReadonlyFields,两个审计列继续写成 Date

两个类的 docstring 里都留了原因(#4765 + #2948 + 那个 48/秒的算式),免得下一个作者"好心"把字段加回来。

顺带清掉的一个隐患

packages/services/service-messaging/src/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 路径上被剥掉了,从没到达 driver。一旦这些写入哪天改走 system 上下文(system 上下文跳过剥离),这个数字就会直接落到 Postgres 上。删掉字段同时解决了这个。

顺带确认过 DbQueueAdapter 有同样形状的写法但没有这个问题:它的每一次写都带 context: SYSTEM_CTX,system 上下文本来就跳过剥离。messaging 的两个 outbox 是唯一没走 system 上下文的那个。

测试

新增 packages/services/service-messaging/src/sql-outbox-audit-columns.test.ts(9 例):用一个记录型的假 IDataEngine 断言两个 outbox 交给 engine 的 UPDATE payload 里不出现 updated_at,同时锁住

  • 「一行都不可 claim」的场景 —— 无条件 reap 正是刷屏的来源,这一例专门盯它;
  • claim 仍然写自己的列(status / claimed_by / claimed_at),不是无差别地删;
  • ack() 仍然递增 attempts、写 error / next_attempt_at
  • INSERT 仍写两个审计列,且是 Date 实例。

updated_at 加回任一处,测试就红(已实测:2 failed)。

pnpm --filter @objectstack/service-messaging test       # 15 files, 160 passed
pnpm --filter @objectstack/service-messaging typecheck  # clean
eslint packages/services/service-messaging/src          # clean

真机验证

在真实 pnpm devexamples/app-showcase)上跑过:

输出行数 其中该条 WARN
修复前(120s) 5469 5378
修复后(90s) 102 0

修复后启动完成(约 4 秒)之后控制台不再有任何输出,直到 Ctrl+C。

不在本 PR 范围内

修复后剩下的 102 行全是一次性的启动诊断,都是既有的、与本 bug 无关的问题,没有一条会循环:

  • showcase 种子数据里 showcase_task.cover 的 image 值不是 opaque sys_file id → 10 条记录写入失败;
  • 8 个 flow 引用了没有注册 executor 的 approval 节点类型;
  • showcase.export_data capability 没有 owning package。

看着都值得单独开 issue,但塞进这个 PR 只会让 diff 失焦。


Generated by Claude Code

… dev` 控制台停止刷屏 (#4765)

空闲的 dev server 每秒稳定刷 48 行同一条 WARN,直到进程退出:

    WARN Field 'updated_at' is read-only — ignoring incoming change (#2948)

`SqlNotificationOutbox.claim()` / `.claimDigest()` 和 `SqlHttpOutbox.claim()`
的第一步都是无条件的「reap stale in_flight」谓词 UPDATE —— visibility-timeout
回收,每个 dispatcher tick 都跑,不管有没有行真的过期 —— 而这些 payload 里都带了
`updated_at`。该列是 `readonly`,归 ObjectQL 内建的 `sys_stamp_audit_update`
hook 所有,所以值先被 `stripReadonlyFields` 剥掉、再被平台盖回去:一次纯 no-op
的写,代价是一条 WARN。三条 claim 路径 × 8 个 partition × dispatcher 的 500ms
tick = 48 行/秒,真正的 warn 和 error 全被冲走。

两个 outbox 的所有 UPDATE payload(`claim`、`claimDigest`、`ack`、`redeliver`)
现在都不再带 `updated_at`,交给平台 hook 盖 —— 存进去的行没有任何变化。INSERT
路径不动,照旧写两个审计列,且写成 `Date`:那里 `created_at` 是调用方拥有的,而
Postgres 的原生 `TIMESTAMP` 列会拒绝裸 epoch-ms 数字。

顺带清掉一个隐患:`enqueue()` 一直守着 `new Date()` 这条规矩,但 UPDATE 路径传的
是 epoch-ms 数字。之所以没炸,仅仅因为它在到达 driver 之前就被剥掉了 —— 一旦这些
写入哪天改走 system 上下文(system 上下文跳过剥离),这个数字就会直接落库。

回归测试断言两个 outbox 交给 engine 的 UPDATE payload 里不出现 `updated_at`,
其中包含「一行都不可 claim」的场景 —— 因为无条件 reap 正是刷屏的来源;同时锁住
INSERT 仍写 `Date`。已在真实 `pnpm dev`(showcase)上验证:120 秒 5469 行输出、
其中 5378 行是这条 WARN,修复后 90 秒 102 行,启动完成后不再有任何输出。

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

vercel Bot commented Aug 3, 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 3, 2026 3:59am

Request Review

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

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-messaging.

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

  • content/docs/automation/webhooks.mdx (via @objectstack/service-messaging)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-messaging)
  • content/docs/plugins/packages.mdx (via @objectstack/service-messaging)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-messaging)

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.

Copy link
Copy Markdown
Contributor Author

Docs drift check: 四个文件都看过了,不需要改——而且其中一个反而给这个修复背了书。

  • content/docs/automation/webhooks.mdx §4.4 里那段 atomic-claim 的 SQL 示意本来就没写 updated_at
    UPDATE sys_http_delivery
    SET status = 'in_flight', claimed_by = $node_id, claimed_at = $now
    WHERE id IN (...) AND status = 'pending';
    也就是说文档一直描述的是正确行为,是代码从文档上漂走了;这个 PR 把代码改回文档说的样子。同文件第 152 行关于 updated_atField.datetime UTC instant、而非上面那些 epoch-ms 列的注解也依然准确——恰恰是本 PR 顺带清掉的那个 epoch-ms 隐患所对应的约束。
  • content/docs/kernel/services-checklist.mdxcontent/docs/plugins/packages.mdx:没有任何内容涉及 outbox 的审计列或 claim 语义。
  • content/docs/releases/implementation-status.mdx:无相关内容;且按 AGENTS.md,content/docs/releases/ 是 release-owned,本来也不能在代码 PR 里改。

本 PR 没有公开 API 或行为变化(写进去的行完全一致),所以没有文档需要跟着动。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 3, 2026 04:10
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit 040ecd2 Aug 3, 2026
21 checks passed
@os-zhuang
os-zhuang deleted the claude/pnpm-dev-console-loop-u82shp branch August 3, 2026 04:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants