fix(objectql): seedAutonumber 把非「表未建」的读故障上抛,不再从 0 重发自增号 (#5979) - #6114
Conversation
The fallback autonumber path seeds its in-memory counter from MAX(existing)
in the store. That seeding read sat behind a bare `} catch { return 0; }`, so
every failure — connection drop, timeout, permission denial, query error — was
answered with the same 0 a genuinely empty table produces.
Those are opposite facts (ADR-0110 D3), and this is the costly half of the
#4728 / #4825 / #5108 family: against a table already holding N rows, one flaky
read restarts the sequence at 1 and issues autonumbers that COLLIDE with
existing ones. The insert SUCCEEDS, nothing is logged, and the collision lands
in a business identifier — a value written wrong that no retry and no restart
repairs. The hazard was already named in the #4371 comment directly above the
read; the read was fixed there, the catch was not.
The catch now discriminates by error TYPE through the shared
`isMissingTableError` predicate (`@objectstack/metadata/errors`, #4825) rather
than a hand-rolled `code === '42P01'` copy — a second "which driver errors are
benign" vocabulary is the exact debt that module exists to retire, and
`check:durability-log-level` exempts only this declared name:
- table never provisioned -> seed from 0 (no rows exist, 1 collides with
nothing) — unchanged behaviour;
- every other read failure -> propagate; allocate nothing, write nothing.
Import route: `@objectstack/objectql` gains a direct dependency on
`@objectstack/metadata` and imports the leaf `/errors` subpath, which exists
precisely for cross-package consumers. This closes no cycle — `metadata` does
not reach `objectql`, and `objectql -> metadata-protocol -> metadata` already
existed, so the direct edge is a shortcut of a path the graph already had.
Also removes the now-stale `seedAutonumber` entry from
scripts/durability-read-invention.baseline.json (shrink-only: the gate fails on
a stale entry, so the fix must delete it in the same commit).
Tests: packages/objectql/src/engine-autonumber-seed-outage.test.ts — 12 cases
over a fake DRIVER (no engine write-verb dispatch involved) covering the three
missing-table driver spellings, four outage classes, original-error
propagation, the collision scenario itself, and the unchanged normal path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…d-autonumber-outage
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
|
ACCEPT(执行席 PM 验收,rc.4 优先序) 逐项核过:① 两处 gate-invisible 接缝的裁定: 翻 ready + auto-merge,进队列。落地后即触发 #5351+#5696 同批派发(同文件串行解除)。 Generated by Claude Code |
…tack-ai#6249) (objectstack-ai#6467) 引擎兜底路径的自增号播种此前是一次 `limit: 5000`、无排序、无过滤的 `find`,把「任意 5000 行窗口内的最大值」当成全表 MAX。对象超过 5000 行、 或某 scope 的行被其他 scope 挤出窗口时,播种低于真实 MAX,计数器从已被 占用的号段起号 —— 对 `unique` 记录号字段就是直接发出重复业务标识符。 改为完整扫描:`keysetWalk` 按 `id` 游标分页(非 offset,objectstack-ai#4363),前缀 下推为 `$startsWith`,数值最大值在引擎侧逐值解析。刻意不委托给 `orderBy desc + limit 1` 或聚合 `max` —— 两者按文本排序,字典序等于 数值序仅当 scope 内全部补零到同一定宽,而格式语言不保证(无 `{0..0}` 槽位时渲染裸计数器,`'9' > '10'`;定宽越过后溢出)。 扫描无法走完时拒绝播种并大声失败,不用已读部分的下界起号 —— 与 objectstack-ai#6114 对读故障的处置同族。声明 `supports.autonumber` 的驱动不受影响。 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We Co-authored-by: Claude <noreply@anthropic.com>
Fixes #5979
前提复核
单据是线索不是规格,四条前提逐条在
origin/main(复核时be59695,推送前已并到830f3f16f)实读复核 —— 全部成立,行号有位移,实质无变化。seedAutonumber()结尾是裸} catch { return 0; }packages/objectql/src/engine.ts:2063声明,catch 在:2102-2104(单据写的 ~2046 / ~2085-2087 是更早提交的行号,位移约 17 行)if (next == null) next = await this.seedAutonumber(...)是活路径:2049,在applyAutonumbers内,仅当 driver 不声明supports.autonumber时进入try上方有 #4371 注释,含 "the catch below would have swallowed the guard's rejection into 'seed from 0', i.e. duplicate autonumbers":2070-2074—— 仓内既有的书面预言packages/objectql/src/engine.ts::seedAutonumber条目scripts/durability-read-invention.baseline.json,verdict: unfixed-degradation,tracked_by: "#5979"处置
seedAutonumber的 catch 改为按错误类型判别,其余一律上抛:只动这一个 catch(加必要 import)。
delete()/update()的前置行门区域、recomputeSummaries一带,以及 engine.ts 其他任何区域,一行未碰 —— 见下方「必答项」。isMissingTableError引入路线:选了「直接依赖 +/errors子路径」先实测,再选路。 依赖图用脚本跑了两个方向:
即:
@objectstack/metadata的生产依赖闭包不含objectql(不成环),而objectql -> metadata-protocol -> metadata这条路在 main 上早已存在。所以加一条直接边不是新边,是把图里已有的一条路径抄了近道 —— 既没有新的构建序约束,也没有新的安装体积(metadata及其 chokidar/glob/js-yaml 本来就在 objectql 的安装闭包里)。于是采用
packages/metadata/src/errors.ts头注释里的方案三(从现有归属处刻意导出),这也正是该子路径存在的理由 —— 它只 re-export 一个叶子模块,schema-sync-errors.ts自身零 import,所以跨包边始终是叶子边,拿不到 manager / loaders / YAML 那一堆东西。@objectstack/metadata-protocol已经这么用了两处(protocol.ts:15、sys-metadata-repository.ts:58),本 PR 是第三个同形消费者。没有走「沉到公共依赖」(方案二),理由三条,按三轴讲清楚:
@objectstack/metadata-core确实是更漂亮的落点(objectql 与 metadata 都已依赖它,它只依赖 spec + zod,且它的 package description 里本来就写着 "errors";[engine-double-contract] 把 assertEngineDeleteDispatch 下沉到 @objectstack/metadata-core —— 七条 metadata-protocol 基线条目唯一存在的关闭路线(#4987 只修了处方文字) #5619 把 engine dispatch 谓词沉进去正是这个先例)。但 [engine-double-contract] 把 assertEngineDeleteDispatch 下沉到 @objectstack/metadata-core —— 七条 metadata-protocol 基线条目唯一存在的关闭路线(#4987 只修了处方文字) #5619 有一个成环的强制因素(反向 import 会闭合 turbo 拒绝的环),这里没有:不成环,搬迁就纯属自选动作。而metadata/errors.ts的作者是刻意把方案二挂起的("explicitly not precluded … out of scope on the round that needed it",并留了 "a single, greppable seam to delete if the maintainer later takes option 2")—— 在一个 bug 修复 PR 里替维护者拍掉这个挂起的决定,既越界又会撞上正在处理MetadataProtocol.listCommits把 commit store 读不到答成[]—— ADR-0067 时间线上「无历史」与「读不到」不可分辨(零日志,JSDoc 里写着这是设计) #5980(listCommits,同样 import 这个子路径)的并行开发席。方案二仍然完全开着,本 PR 没有关上它,只是没有顺手拍。MetadataProtocol.listCommits把 commit store 读不到答成[]—— ADR-0067 时间线上「无历史」与「读不到」不可分辨(零日志,JSDoc 里写着这是设计) #5980 在途的文件。所以这一轴反而反对方案二。关键的是:闸门按谓词名判别,与 import 路径无关(
READ_FAILURE_DISCRIMINATORS只登记isMissingTableError这个名字),所以两条路线在闸门面前等价,选择完全由上面三轴决定。手抄一份code === '42P01'是明确被排除的第三种做法 —— 那正是 #5841 修掉的「第二套良性词表」缺陷。同 PR 摘掉了 baseline 里现已失效的
seedAutonumber条目(shrink-only:闸门对陈旧条目直接报红,修复必须同 PR 删条目)。测试与反向验证
新增
packages/objectql/src/engine-autonumber-seed-outage.test.ts,12 例,贴engine-autonumber-defer.test.ts的既有 harness(fake driver,不是 fake engine,所以本单不涉及引擎写动词 dispatch 契约,assertEngineDeleteDispatch/assertEngineUpdateDispatch均无需出场)。42P01/ MySQLER_NO_SUCH_TABLE/ SQLite 纯 message)→ 从 0 起号、不抛;driver.create一次未被调用;D-0007,故障期间写入失败而非发出D-0001;恢复后续号为D-0008(顺带证明失败的播种没有污染内存计数器);反向验证:方向先写死,再运行
预测写在
predictions.md后才执行。方向是普通的红向(不是 #5046 的「诊断变多」,也不是 #5018 的倒挂):被删的肢是错误路径上的一个谓词,不是喂给下游闸门的计数。肢 1 —— 把 catch 临时改回
} catch { return 0; },跑新用例实测失败断言原文,正是缺陷本身 —— 读故障期间插入成功了:
肢 2 —— baseline 条目,两个方向都验
✗ 1 stale entr(ies) … the seam no longer invents an unreported answer, so delete the entry→packages/objectql/src/engine.ts::seedAutonumber✗ 1 read seam(s) invent an empty answer for a read that failed, and tell nobody→packages/objectql/src/engine.ts:2111 (in seedAutonumber())✓ read-seam invention … none invents an unreported empty answer两肢、四个方向,预测与实测全部相符,没有翻不红的肢。
命令与真实输出
以下为合并
origin/main(830f3f1)之后的最终态数字。重活全部经flock /tmp/os-heavy-verify.lock串行。补充说明,不编造:
read-seam invention的「type-discriminated benign branch」计数由 5 → 6、baselined 由 3 → 2,差额就是本 PR 这一处。plugin-metadata-event-outage.test.ts)。@objectstack/objectql有typecheck脚本(tsc --noEmit),不属于 [P2] framework: 66 个包用 tsup 构建、无人做类型检查 —— 实测 18 个包共 380 处 code-tier 错误(#4118 的 framework 侧对应) #4311 台账里没有该脚本的包。git diff --name-only HEAD origin/main预检:main 这 15 个提交未触碰engine.ts/ baseline / objectql 的package.json,合并无冲突;但因 main 动了packages/objectql(加了测试文件),仍按 AGENTS §9/§10 重装、重建依赖链并完整重跑了上表。必答项
#5929(同文件
delete()前置行门恒真)—— 否,未触其面,未改其定价。依据:本 PR 对
engine.ts的改动只有两处,git diff可逐行核对 —— import 区加 1 条isMissingTableError导入,以及seedAutonumber(:2063起)结尾那个 catch。delete()及其前置行门区域一行未动。两者也无语义耦合:seedAutonumber只在applyAutonumbers的插入路径上被调用。#5846(单 id
update三读前置状态)—— 否,同上。依据:
update()与其前置读同样一行未动;seedAutonumber不在 update 路径上(调用点:2049位于applyAutonumbers,只由插入路径进入)。#4825 / #5108 同族收官件 —— seedAutonumber 邻域的残留(只报告,未顺手修):
仓库自己的权威(
check-durability-degradation-log-level.mjs)在三个包 64 个读接缝上给出:本 PR 后无未登记违规,baseline 只剩 2 条 ——metadata-protocol/protocol.ts::listCommits—— 真残留,已由MetadataProtocol.listCommits把 commit store 读不到答成[]—— ADR-0067 时间线上「无历史」与「读不到」不可分辨(零日志,JSDoc 里写着这是设计) #5980 单独跟踪(闸门建议的修法与本 PR 同形);objectql/engine.ts::referenceExists——reviewed-legitimate,Promise< boolean | null >是声明过的三态,不是编值。闸门看不到的两处,如实记录供 PM 分诊(均未改动、未开单,因为本单要求只报告):
engine.ts:3870catch → return { verified: false, conclusive: false }—— 与referenceExists同形的显式三态(conclusive就是「读没读成」位),看起来站得住;engine.ts:4686catch → return records——sys_file读不到时原样返回入参、不记一行日志。它不编造空值(不落在闸门词表内),降级对调用方可见(拿到裸 id 而非 hydrate 后的引用),属功能性降级而非持久性降级;是否值得开单请 PM 定夺。#4636(
loadMetaFromDbpackageId,决策箱在途)—— 无任何交叠。依据:不同包、不同文件(
@objectstack/metadata的 loader vs@objectstack/objectql的 engine),不同接缝(元数据加载 vs 自增号播种)。本 PR 唯一与@objectstack/metadata的接触是从其/errors子路径 import 一个纯谓词函数,不触及 loader 的任何代码路径。Generated by Claude Code