fix(service-messaging): SQL outbox 的 UPDATE 不再写 updated_at —— pnpm dev 控制台停止刷屏 (#4765) - #4766
Merged
Merged
Conversation
… 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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
Contributor
Author
|
Docs drift check: 四个文件都看过了,不需要改——而且其中一个反而给这个修复背了书。
本 PR 没有公开 API 或行为变化(写进去的行完全一致),所以没有文档需要跟着动。 Generated by Claude Code |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4765.
现象
pnpm dev(showcase)启动后,控制台永远停不下来地循环输出同一行 WARN,稳定 48 行/秒:空闲、没人访问、没有浏览器连上来的 dev server 也照刷不误。真正的 warn / error 瞬间被冲走。
根因
告警来自
stripReadonlyFields(packages/objectql/src/validation/rule-validator.ts,#2948):调用方在非 system 上下文的 UPDATE 里显式带了readonly字段,平台剥掉它并 warn 一次。用
--stack-trace-limit=80抓到的调用方是@objectstack/service-messaging的两个 SQL outbox:SqlNotificationOutbox.claimSqlNotificationOutbox.claimDigestSqlHttpOutbox.claim三处
claim*()的第一步都是无条件的「reap stale in_flight」谓词 UPDATE(visibility-timeout 回收),payload 里带了updated_at。而stripReadonlyFields是按 payload 的字段告警的,不看命中了几行 —— 所以哪怕 outbox 是空的、一行都没 reap 到,每次调用照样 warn 一次。算式正好对得上:
NotificationDispatcher每 tick 跑claim+claimDigest,HttpDispatcher每 tick 跑claim,各自遍历 8 个 partition → 每 tick(2 + 1) × 8 = 24次;dispatcher 默认intervalMs = 500→ 48 行/秒。而
updated_at本来就轮不到调用方写:ObjectQL 内建 hooksys_stamp_audit_update在每一次 update 上无条件盖record.updated_at = now。outbox 传的值是先被剥掉、再被平台盖回去 —— 纯冗余,行为上完全是 no-op,只剩噪音。改动
从两个 outbox 的所有 UPDATE payload 里删掉
updated_at(claim/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,同时锁住status/claimed_by/claimed_at),不是无差别地删;ack()仍然递增attempts、写error/next_attempt_at;Date实例。把
updated_at加回任一处,测试就红(已实测:2 failed)。真机验证
在真实
pnpm dev(examples/app-showcase)上跑过:修复后启动完成(约 4 秒)之后控制台不再有任何输出,直到 Ctrl+C。
不在本 PR 范围内
修复后剩下的 102 行全是一次性的启动诊断,都是既有的、与本 bug 无关的问题,没有一条会循环:
showcase_task.cover的 image 值不是 opaquesys_fileid → 10 条记录写入失败;approval节点类型;showcase.export_datacapability 没有 owning package。看着都值得单独开 issue,但塞进这个 PR 只会让 diff 失焦。
Generated by Claude Code