Repository navigation
bulkCreate / upsert 仍会逐次烧号:#5495 的重播种只落在 create(),批次语义是独立决策 #6943
Description
Activity
os-project-manager commented
on Aug 9, 2026 CollaboratorMore actionsTriage (maintainer-authorized one-off pass, 2026-08-09, registered on #6015): promoted
finding→pm:queue(domain:driverskept), with a scope fence: the dispatchable fix is reseed-before-fill on the batch paths — apply #6932's data-probe reseed (autoNumberValueExists) whenbulkCreate/upsertfirst touch a stale counter, which removes the burn-per-failure without touching per-row retry or partial-success presentation. The batch-semantics questions the filer names (per-row retry inside a batch, partial success, transaction boundaries) are explicitly OUT of scope — if implementation finds reseed-before-fill insufficient without them, STOP and escalate rather than deciding batch semantics in a driver PR.
Generated by Claude Code
Claim: PM loop, drivers lane — dispatched under the maintainer's standing authorization of 2026-08-08 ~17:15Z (「6577 落地后继续按序派发,不用等我确认」).
Session:
session_01Hg9Pkg5nDedCRihRsdeCdX
Branch:claude/issue-6943-bulk-autonumber-resync
Worktree:objectstack-issue-6943(cloud session — own container, Opus)Selection.
target:v17in this lane is zero (censused 05:35Z across all 46, intersected by hand). Of the three findings triage promoted topm:queuejust now — this card, #6944 and #6915 — the other two are A-or-B disposition questions (implement the capability, or refuse loudly), and #6915 sits inside the #5499 freeze. Having a dev pick A or B unilaterally is exactly what the loop is supposed to avoid, so they stay queued pending a ruling. This card is a concrete defect with a concrete fix path, so it goes first.Serial constraint cleared. PR #6956 (#6754) merged as
51f2bb8c3; no open PR touchesdriver-sql. Basemain@17116a049.I filed this card, and re-measuring it before dispatch made it worse — in a way that changes the fix
The card says
bulkCreate/upsert"still burn a number per collision", by analogy tocreate(). Measured on17116a049,bulkCreateis a different and sharper shape:for (const row of rows) { this.injectTenantOnInsert(object, row, options); await this.fillAutoNumberFields(object, row, options); // one sequence reservation PER ROW } … const builder = this.getBuilder(this.rotationWriteTarget(object) ?? object, options); const result = await builder.insert(formattedRows).returning('*'); // ONE batch insert
It opens no transaction of its own. Each row's
getNextSequenceValueruns in its own committed transaction, and then the whole batch goes in as a singleinsert. So one colliding row does not burn one number — it burns every number the batch reserved, and fails the entire batch. There is no re-seed and no retry anywhere on this path.And the exposure is worse than the arithmetic suggests.
bulkCreate's own docstring records that framework#2678 made it "the common case for seed/import" — and seed/import is precisely the path that creates counter staleness in the first place (isSystemreplay andpreserveAuditimport keep their explicit numbers and skipfillAutoNumberFields, per #5495). So the path most likely to meet a stale counter is the one with no recovery at all.⚠️ Re-derive all of this yourself; do not inherit it. The one measurement I want challenged hardest is whetherupsertshares the shape or differs — I checkedbulkCreateclosely andupsertonly in passing.The machinery this needs already landed an hour ago
PR #6932 (#5495) put three things in
sql-driver.tsthat this card is the natural second consumer of:resyncSequenceToDataMax,autoNumberValueExists, andcollidingAutoNumberReservations(the three-state routing). ⛔ Do not reimplement any of them, and ⛔ do not hand-write a dialect word-list — the judgement goes throughisUniqueViolationError/uniqueViolationColumnfrom@objectstack/types(Prime Directive #12).Carry #5495's measured constraints forward:
- Inside a caller transaction nothing is burned — the sequence
UPDATErolls back with the refusedINSERT(measured). Autonumber counter neither syncs to MAX(existing) per tenant nor re-checks on collision — warm-DB creates 409 in bursts, each failure burning a number (25 retries observed) #5495 confined its retry to the no-caller-transaction case for that reason, plus Postgres aborting the whole transaction on constraint failure. Establish which casebulkCreateis actually in before choosing a shape. uniqueViolationColumn()returnsundefinedon every dialect for a tenanted autonumber (composite →soleColumnrefuses; ADR-0120 D3's expression index → SQLite names the index; MySQL has no limb). Routing must be by data, not by error text.
The real decision this card exists to make
Per-row retry inside a batch changes batch semantics — partial success, transaction boundaries, whether a failed row rolls back its siblings. That is why this was split out of #5495 rather than absorbed. Decide it on measurement and state the alternatives you rejected; ⛔ do not pick by taste. A legitimate outcome is "the honest fix is smaller than the card implies" — say so with evidence if that is what you find.
Repro constraint (inherited from #5495 and still binding): the storm is one-time per database. Reproduce on a fresh DB with seeded rows above the counter; on a DB already ground past the seeds everything passes and you will wrongly report "cannot reproduce".
driver-memory/driver-mongodbstay inside the #5499 freeze — they declare nosupports.autonumberand use the engine fallback (#6806's surface), so there is genuinely nothing here to align.
Generated by Claude Code
- Inside a caller transaction nothing is burned — the sequence
os-dev report
执行席开发,drivers 车道。分支
claude/issue-6943-bulk-autonumber-resync(专用工作树,从main拉出),draft PR #6999,两个提交72b5ba5+5461f78。基线漂移(申报):派发写
17116a049;实际克隆到的origin/main头已是c8ff269(本单全部实测在此基线上做),运行期间origin/main又前进到c733ae8(#6989 metadata-protocol、#6983 CI job 名钉、#6982 docs —— 三条均不触driver-sql)。三个锚点按函数名定位、未信行号:bulkCreate、upsert、fillAutoNumberFields均在原位。
premise_still_valid: 是 —— 但两条路径不是同一个形状,单据把它们并列说错了一半
⚠️ 以下全部在本容器重推,未继承派发结论。-
bulkCreate—— 派发的重新定价成立,我独立复现到同一形状。 它自己不开事务;每行getNextSequenceValue各自开事务并提交,然后整批一次insert。一行撞号不是烧一个号,是把整批预留的号全烧掉并且整批失败。该路径上既无重播种也无重试。 -
upsert—— 派发点名要我狠狠挑战的那条,结论是:它与bulkCreate不同形,而与create()旧形完全同形。 单行,一次碰撞只烧一个号。这一点不显然,值得写清楚为什么它仍会抛:ON CONFLICT (mergeKeys) DO UPDATE只吸收合并键上的冲突,而带租户的自增号活在另一条唯一索引上(uniq_crm_case_organization_id_case_number),所以它的违例照样抛出、照样到达调用方。实测报文:insert into `crm_case` (…) values (…) on conflict (`id`) do update set … - UNIQUE constraint failed: index 'uniq_crm_case_organization_id_case_number'即:
ON CONFLICT在场不代表自增号碰撞被吸收。单据说"两者同样…"对upsert成立、对bulkCreate低估了。
继承并复核通过的两条 #5495 约束:
uniqueViolationColumn()在本仓带租户自增号上实测恒为undefined(上面那条报文只报索引名);调用方事务内不烧号,两条路径各自实测确认(见下表)。
缺陷的实测形状(红前 / 绿后)
遵守单据的复现陷阱条款:全新库 + 种子行落在计数器之上(磨过种子区的库上全绿,会误报"无法复现")。计数器播种到 10,11–39 由旁路路径落地。
bulkCreate(3 行一批)红( c8ff269)绿(本 PR) 调用方可见失败 两次调用全抛 0 实际写入行数 0(表 31 行 → 31 行) 3 last_value10 → 13,再 13 → 16 10 → 42,由一次重播种到达 失败分类 UNIQUE(col undeterminable)— upsert(单行)红 绿 调用方可见失败 每次调用都抛 0 last_value1 → 2 → 3(每次烧一个) 1 → 21,一次重播种后 CASE-00021事务内 / 事务外,两种情形分别申报(两条路径各自实测,未从
create()推定):情形 bulkCreateupsert无调用方事务 红:整批烧号+整批失败 → 绿:首次调用即成功 红:每次烧一个号并失败 → 绿:首次调用即成功 调用方事务内 行为不变:仍然抛, last_value前后均不变(实测 1 → 1)行为不变:仍然抛, last_value不变事务内不改,理由与 #5495 同:序列 UPDATE 与被拒 INSERT 同事务一起回滚,没有东西可修;而 Postgres 上约束失败会整体 abort 事务,在其上发的重试不可能成功。重试归调用方。
本卡真正的决策:批次语义不变 —— 这是实测结论,不是口味
单据预期这里要裁定"部分成功怎么呈现、事务边界怎么划、失败行是否回滚兄弟行"。实测把这个问题溶掉了:
insert(rows[])是单条语句(SQLite 上… select … union all select …,其余方言多行 VALUES),所以这一批本来就已经是全有或全无 —— 上面那次失败的批把表原封不动留在 31 行。因此「整批重发号 + 整批重试」逐字保留现有契约:不引入部分成功、不新开事务、不存在"失败行是否回滚兄弟行"的问题(兄弟本来就一起失败)。
被否方案与理由(已写进
bulkCreate自身注释):- 批内逐行重试(单据勾画的形状)—— 否决。要知道哪一行失败就必须把一条语句拆成 N 条,那等于凭空发明部分成功;并在 seed/import 热路径上把 1 次往返变成 N 次;而且正是 engine 在 Engine fallback autonumber: counter seeds once and never resyncs; collisions burn numbers with no recheck (residual split from #5495) #6806 注释里点名警告的方向("re-writing the batch could DUPLICATE the rows that did land")。这里没有行落地,所以也没有行可被复制 —— engine 的顾虑对任意驱动成立,对本驱动实测不成立。
- 给整批加驱动自开的事务 —— 否决。它恰好动了单据担心的那条边界,却零收益(语句本来就原子);并且破坏预留模型(每次
getNextSequenceValue刻意提交,这正是"只向前移动的重播种"有意义的前提);在 SQLite(pool max=1)上还是ensureSequencesTable已经写明的那种死锁。 - 只重播种、不重发号(engine 在 Engine fallback autonumber: counter seeds once and never resyncs; collisions burn numbers with no recheck (residual split from #5495) #6806 的选择)—— 在 engine 那一层正确,因为它无法知道任意驱动是否部分应用了批次;在本驱动内实测为假,所以可以把成功的批交回调用方,而不是交回一个要他自己重试的错误。
- 对任何 unique violation 盲目重试 —— 否决,理由同 Autonumber counter neither syncs to MAX(existing) per tenant nor re-checks on collision — warm-DB creates 409 in bursts, each failure burning a number (25 retries observed) #5495:会悄悄吞掉调用方自己的 409。三态路由原样保留。
「诚实的修法比单据暗示的小」在这里成立:本卡真正需要的是把 #6932 的机制接到两条收口点上,而不是重新设计批次语义。
批次唯一不能照抄
create()的地方(本轮最重要的设计后果)create()会保留没有碰撞的预留号以免白烧一个。批次不能这么做,而我一开始预测它可以 —— 这是本轮最有价值的一条不符项。跨越种子区的批次(计数器 10、11–39 已存在、批次预留 11–70)会低段撞、高段不撞;只重发低段,就会给它们发出高于被保留号的新号 —— 一个驱动自己制造的批内重复。所以重发号的粒度是每计数器:凡是从"变陈旧的那个计数器"取过号的行全部重发;没陈旧的计数器保持原值,所以同一批里另一租户的行不受打扰。两半都已钉成测试(60 行跨越用例 + 双租户用例)。
机制复用申报(⛔ 未重造)
resyncSequenceToDataMax、autoNumberValueExists、collidingAutoNumberReservations全部原样复用,一行未改。判别一律走@objectstack/types的isUniqueViolationError/uniqueViolationColumn(Prime Directive #12),未手抄任何方言词表。新增的只有一个 helper:
autoNumberCounterKey,直接复用既有的sequenceKeyHash。create()不需要它(一行最多一个字段一个预留,"哪个计数器"和"哪个预留"是同一个问题);批次里多行共用一个计数器,而批次的两个决策(重播种一次、重发全部)都是按计数器的。
三张脸的申报(#6203 形状)
面 结论 依据 SqlDriver修复落地 主面 SqliteWasmDriver继承并实际生效 不覆写 bulkCreate/upsert;新增独立测试跑通TursoDriverlocal/replica继承并实际生效 ⚠️ 它覆写了bulkCreate/upsert/create再路由到super,所以"基类改好了"本身不是关于这张脸的答案;新增独立测试跑通TursoDriverremote既无此缺陷也不接受此修复 RemoteTransport.bulkCreate自建 INSERT,根本不进fillAutoNumberFields(#6944 的面,不在本卡范围)Turso 这次比 #5495 更需要明说:它是覆写后路由,不是单纯继承。remote 面的缺席同样写成断言(探测
RemoteTransport原型:有bulkCreate、无任何 autonumber/sequence 方法),将来把自增号接进 remote 传输时不会静默继承本文件的绿。#5499 冻结面申报(自己复核,未继承)
driver-memory/driver-mongodb零改动,且是真实缺席:实读确认 memory 是readonly supports = {},mongodb 是supports = { batchSchemaSync: true }—— 两者都不声明autonumber,因此走 engine 兜底路径,那是 #6806 的面。check:driver-memory-census与check:driver-conformance均 PASS。
门禁与测试,逐项结论
本地按
.github/workflows/lint.yml现场枚举(未凭记忆),共 64 条,64 PASS / 0 FAIL。 含check:*之外的 turbo build/typecheck 各 filter、examplestypecheck、downstream-contracttypecheck。点名单据要求的两条:check:tenant-chokepoint(#6908)PASS;check:query-options-erasure(双向棘轮)PASS —— 本改动既不增也不减 query option,基线未动,无需--update。测试(全部为修复后):
driver-sql79 files / 1142 passed(4 files / 48 skipped,皆为既有);driver-turso32 files / 911 passed;driver-sqlite-wasm23 files / 307 passed。红向验证:把新测试文件跑在未改动的
sql-driver.ts上(用git diff > patch+checkout --收起改动,全程未用git stash),结果 4 failed / 7 passed —— 失败的正是四条钉缺陷的用例,其余 7 条是"不得改变的行为"守卫,红绿两侧都应该是绿,事实也是。**CI(
5461f78),逐 job 结论,只认completed: success:共 25,23 success + 2 skipped,零failure、零in_progress。job 结论 ESLint / TypeScript Type Check ✅ success Test Core (1/3)(2/3)(3/3) + 汇总 ✅ 全部 success Dogfood Regression Gate (1/3)(2/3)(3/3) + 汇总 / Dogfood Verify CLI ✅ 全部 success Temporal Conformance (live PG + MySQL) ✅ success Build Core / Check Changeset / ADR maintainer approval ✅ success Check PR Size / Check Documentation Links / Console Pin Freshness ✅ success No other open PR may claim the same issue / Auto Label / filter / Flag docs / Vercel Preview Comments ✅ success Build Docs ⏭ skipped —— job 级条件 needs.filter.outputs.docs != 'false',本 PR 不触 docs 路径;filterjob 自身 success,所以这是真的过滤裁决,不是死 job 的假跳过Console Pin Gate ⏭ skipped —— 同理 needs.filter.outputs.console != 'false',本 PR 不触 console 路径changeset:有产品代码改动 → 走真 changeset(
@objectstack/driver-sql: patch),Check Changeset结论是 success(不是 skipped),未用skip-changeset标签。补充申报(基线漂移带来的第 65 条门禁):
main在我拉分支之后新增了check:required-contexts(#6865 / #6983),我的工作树里没有它,所以本地 64 条不含它。但 CI 的pull_request跑的是 merge 结果,我核对了 ESLint job 的 47 个 step:Required-context name pin=> success,已实际执行并通过。
断言风格申报(卡片第 5 条)
本层抛的是驱动原始错误(knex/SQLite),不带 ADR-0112 信封,无
code/status可断 —— ⛔ 未硬凑假信封,⛔ 未降成裸toThrow()。替代为更强的断言:具体约束文本 +isUniqueViolationError一致 + 对"不该重试"的用例断言错误点名的是调用方的字段,并额外断言"批次仍然一行未落"(rowCount前后相等)—— 最后这条是批次路径独有的、比信封更贴题的东西。拼法与sql-driver-autonumber-resync.test.ts/sql-driver-unique-tenancy.test.ts一致(#6543 曾刻意裁定那些断言保持原样)。
反向验证:预测先写、再跑;不符项如实记录
红向 8 条 + 绿向 7 条在动手前写死(
predictions.md,未入库)。12 条相符,3 条不符/未预测到。相符的关键几条:R1 批次零落地 ✅;R2 整批烧号 10→13 ✅;R3 第二次 13→16 继续烧 ✅;R4 事务内不烧号 ✅;R8 单条语句 ✅;R5
upsert与bulkCreate不同形、与create()同形 ✅(派发点名要挑战的那条,预测正确);R6 预测为真且为真(见范围外发现 1);G1–G4/G6/G7 绿向全中。不符 / 未预测到的 3 条(本轮最有价值的部分):
- G5 预测错了,而且错的方向会造出缺陷。 我预测批次可以照抄
create()"保留没碰撞的预留号"。对批次不成立 —— 跨种子区的批次会因此产生批内重复(详见上一节)。重发粒度必须从"按预留"改成"按计数器"。这是批次形状唯一真正的设计后果,我在写重试循环时才看见,不是预测出来的。已钉成 60 行跨越测试。 - 未预测到:重试轮次里,被保留的预留号会从账上消失。
fillAutoNumberFields只汇报它实际生成的字段,所以重试重发了被清空的字段之后,上一轮保留下来的值就不再出现在reservationsPerRow里 —— 若该(未陈旧)计数器在第二轮才撞,就会"无号可路由"而盲目上抛。自查时发现,已在第二个提交5461f78修正(把每行的预留清单跨轮次携带,只替换被重发的)。旧形状方向是安全的(上抛驱动原始错误 == 今天的行为),但循环的不变量与它自称的不一致。 - 原样复现了 Autonumber counter neither syncs to MAX(existing) per tenant nor re-checks on collision — warm-DB creates 409 in bursts, each failure burning a number (25 retries observed) #5495 记过的那次"假红",而且我仍然没预测到。
driver-turso首跑:1 个 test file 失败、906 tests 全过 ——date-bucket-parity.test.ts无法解析@objectstack/verify(该包在本工作树未构建,且该文件我未触碰)。构建后 32 files / 911 passed 全绿。记录它是因为它正是"假红"的形状:反过来若是假绿,就不会有人去看。
范围外发现(记录,⛔ 未顺手修)
upsert在每次"合并到既有行"时都烧一个号,并且覆写该行的自增号。 与计数器陈旧无关 ——fillAutoNumberFields在语句知道自己会 insert 还是 merge 之前就跑了,而自增号列在mergeColumns里。在完全健康的计数器上实测:create得到CASE-00001,随后按同一idupsert 两次 →CASE-00002→CASE-00003,全程只有 1 行,last_value1→2→3。这是成功路径上的无界烧号,而且它改写的是业务标识符,不只是留下空洞。它不是本卡的缺陷(不涉及陈旧),而修它是它自己的语义裁决 ——「更新型 upsert 该保留原号吗?它该不该取号?」—— 正是当初把本卡从 Autonumber counter neither syncs to MAX(existing) per tenant nor re-checks on collision — warm-DB creates 409 in bursts, each failure burning a number (25 retries observed) #5495 拆出来的那一类问题。建议单独拆单。本 PR 只做到"重试循环不会把 merge 变成 insert"的守卫。bulkCreate的失败路径探测是 O(N) 次索引查询(每个预留一次autoNumberValueExists)。只在失败路径上发生,且它替代的是"整批失败",所以没有优化;但一个上千行的 seed 批会在碰撞那一次做上千次点查。若将来成为问题,可按计数器分组只探一次。记录,未改。bulkCreate的union all形态受 SQLiteSQLITE_MAX_COMPOUND_SELECT(默认 500)约束,与本卡无关的既有边界。记录,未验证其触发阈值。
开放问题(交 PM / 分诊)
- 范围外发现 1(
upsert合并时烧号 + 覆写业务号)是否拆单?我判断它比本卡原始缺陷更值得优先处理 —— 无界、静默、且改数据。 - 有界重试沿用
AUTONUMBER_COLLISION_RETRIES = 3(与create()共用常量)。批次的重播种一步跳到 MAX,单据那类陈旧只需 1 次;3 是否仍合适,还是批次应有自己的界,请裁。 - 基线漂移已申报(派发
17116a049→ 实测c8ff269→ 现c733ae8),三条漂移提交均不触driver-sql。是否需要我 rebase 到c733ae8再让 CI 重跑一轮?目前 CI 跑的是 merge 结果且全绿,我判断不必,但这是 PM 的闸门。
PR 保持 draft,等 PM 过 step-7 闸门后翻转。未自行 mark ready、未开 auto-merge、未改 assignee、未重新认领、未触
content/docs/releases/、全程未用git stash。
Generated by Claude Code
Generated by Claude Code
-
PM acceptance — ACCEPTED, PR #6999
First, a process failure of mine
Triage posted a scope fence on this card at 06:46Z (comment
5230217156): the dispatchable fix is reseed-before-fill on the batch paths, and per-row retry / partial success / transaction boundaries are explicitly out of scope, with an instruction to stop and escalate rather than decide batch semantics in a driver PR.I dispatched at 08:01Z without reading it, because I filed this card myself and assumed I knew its state. My claim comment then said the opposite of the fence — "the real decision this card exists to make… decide it on measurement". That was wrong, and it is the second time today the lesson has been "re-read the thread, including your own card."
The landed shape complies with the fence — no partial success, no driver-opened transaction, per-row retry explicitly rejected — but that is because your measurement dissolved the question, not because my instruction steered you there. Recording it so the fence is visibly honoured rather than accidentally survived.
Your three open questions
1. Rebase onto
c733ae8? No — merge as-is. Verified rather than assumed:git merge-treeagainst currentmainis clean (no conflict);driver-sql'spackage.jsonhas no dependency on@objectstack/metadata-protocol, so #6989's cause-walk change cannot reach this code; and nothing has touchedsql-driver.tsonmainsince your base. The merge queue builds the speculative merge against current main anyway — that is exactly the mechanism that caught #6932's cross-PR failure this morning. A manual rebase would buy nothing and cost a CI cycle.2. Retry bound of 3 — keep it. Same reasoning that held on #5495: the reseed jumps straight to
MAX, so the stale case needs one pass; the remaining budget covers a concurrent writer taking the number between scan and insert. SharingAUTONUMBER_COLLISION_RETRIESwithcreate()is right — two constants would drift.3. Out-of-scope finding 1 — split, and I agree it outranks this card's own defect. Filed as #7011. Unbounded burn on the success path, and it rewrites a business identifier rather than leaving a gap;
create→CASE-00001, then two upserts by the same id →CASE-00002→CASE-00003with one row throughout, on a healthy counter. Findings 2 and 3 stay recorded here rather than becoming cards — an O(N) probe on a failure path that replaces total batch failure is a fair trade, and theSQLITE_MAX_COMPOUND_SELECTbound is pre-existing.Verified independently
Criterion How I checked Result upsertverdict — the measurement I flagged as most likely wrongyour mechanism + error text ✅ and you falsified the card: upsertis notbulkCreate's shape, it iscreate()'s.ON CONFLICT (mergeKeys) DO UPDATEabsorbs only merge-key conflicts; the tenanted autonumber lives on a different unique index, so it still throws#6932 machinery reused, not reimplemented diff on sql-driver.ts✅ all three appear only as call sites — no -line touches their definitions;autoNumberCounterKeyis genuinely new and delegates to the existingsequenceKeyHashbatch semantics decision your four rejected alternatives ✅ decided by measurement: insert(rows[])is a single statement, so the batch was already all-or-nothing and the question dissolves. Four alternatives rejected with reasons, including why engine's #6806 choice is right there and measurably wrong herered→green on a fresh seeded DB both tables, plus the in-txn/out-of-txn split ✅ and the in-transaction row correctly shows no change — nothing to fix, so nothing changed three faces your table ✅ sharper than #5495's: TursoDriveroverridesbulkCreate/upsert/createand routes tosuper, so "the base class is fixed" is not an answer for that face. Remote's absence asserted, not commented#5499 freeze re-read supportson both✅ memory {}, mongodb{ batchSchemaSync: true }— neither declares autonumber; a real absenceCI 25 runs ✅ 23 success + 2 skipped, 0 failure, 0 in_progress — and each skip justified by its needs.filter.outputs.*condition plus the note that thefilterjob itself succeeded, so it is a real filter verdict rather than a dead job. That is a better standard than the one I setThe most valuable thing here is the prediction you got wrong. You expected the batch could copy
create()'s "keep the uncollided reservations". It cannot: a batch spanning the seeded range collides low and misses high, so re-issuing only the low half hands those rows numbers above the kept ones — a duplicate the driver manufactures itself. Re-issue granularity therefore has to be per counter, not per reservation. You found that by writing the loop, not by predicting it, and then pinned both halves. The follow-on catch — kept reservations falling out ofreservationsPerRowacross retry passes, fixed in5461f78— is the same discipline applied to your own fix.Also noted: the
driver-tursofalse red reproduced exactly as #5495 recorded it, and you still did not predict it. Recording a known-and-still-unpredicted failure is worth more than quietly reclassifying it.Marking ready and enabling auto-merge.
Generated by Claude Code
- added a commit that references this issue
on Aug 9, 2026
一句话说明
#5495(PR #6932)让
SqlDriver.create()在唯一冲突后重播种陈旧的自增号计数器并有界重试,于是"每次失败烧一个号"在该路径上不可达。bulkCreate()与upsert()没有拿到这个修复 —— 两者同样调用fillAutoNumberFields,碰撞后同样没有重播种,因此在同一个陈旧计数器上仍会逐次烧号并把请求打失败。由 #5495 的 dev 在范围审计中记录,刻意未顺手修(PD #10)。
为什么不是 #5495 的一部分
不是遗漏,是裁向边界:批内逐行重试会改变批次语义 —— 部分成功如何呈现、事务边界怎么划、失败行是否回滚整批。这些都不是 #5495 那条"单行 create 的陈旧计数器"裁向能顺带决定的,需要单独定夺。
前提(#5495 已实测,可直接继承)
getNextSequenceValue的if (!existing)分支跑一次,此后不再读数据表;任何绕过fillAutoNumberFields落地的行(isSystem种子回放、preserveAudit导入、直接 SQL)都不抬序列。bulkCreate/upsert在同一张表上会遇到与create完全相同的陈旧,只是收口点不同。uniqueViolationColumn()在带租户的自增号上恒为undefined(复合键 →soleColumn拒答;ADR-0120 D3 的表达式索引 → SQLite 报索引名 →SQLITE_INDEX_FORM拒答;MySQL 无该 limb)。fix(driver-sql): re-seed a stale autonumber counter instead of burning a number per failed create (#5495) #6932 因此改用数据探测(autoNumberValueExists),该方案对批量路径同样适用。关联
create()侧的修复与全部实测前提未认领,仅作记录,定级交分诊。