fix(driver-sql): re-seed a stale autonumber counter instead of burning a number per failed create (#5495) - #6932
Conversation
…g a number per failed create (#5495) The counter bootstraps from the data-table MAX exactly once, in `getNextSequenceValue`'s `if (!existing)` branch; afterwards the data table is never consulted again. Any row landing by a path that bypasses `fillAutoNumberFields` — an `isSystem` seed replay, a `preserveAudit` import, or direct SQL — never raises the sequence, so once it sits below MAX it is permanently behind and every create collides, burns a number and fails the request until it has ground past the seeded range one 409 at a time. Measured on main @ 86e6f6c with the counter seeded at 10 and rows 11-39 landed by a bypass path: 29 caller-visible 409s before a create succeeded at CASE-00040 on attempt 30. Now: CASE-00040 on the caller's first attempt. `create()` re-seeds from MAX and retries (bounded) only when it can prove the collision was that counter's. The proof cannot be the conflicting column: `uniqueViolationColumn()` refuses composites, and ADR-0120 D3 makes the tenanted index an expression composite on which SQLite names only the index — so on this path the column is never determinable. All three of that export's states are handled explicitly; the indeterminate one is decided from the data (does the generated value already exist in this tenant partition?), never by a hand-written dialect word-list. Retry is confined to the no-caller-transaction case: inside a caller's transaction the sequence UPDATE rolls back with the INSERT, so nothing is burned, and on Postgres the transaction is aborted anyway. The "gaps are tolerated by design" docstring is reconciled with the change rather than left to contradict it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015XLbsWE5G58ybd1Leq6bNg
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…nd MySQL shapes (#5495) The SQLite tests cover the path end to end, but SQLite is the one dialect where the decision is easy. Postgres names a column in its DETAIL line (state 1/2) or a composite (state 3); MySQL names only an index and so can only ever reach state 3 — which means on MySQL the data probe is not a fallback, it is the whole mechanism. This package's unit suite boots SQLite only, so the shapes are injected rather than driven through live servers — same method and same reason as sql-driver-unique-violation-predicate.test.ts. The state-2 case asserts the part that matters most: a named column that is not ours ends the matter WITHOUT probing, even when the probe would have said yes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015XLbsWE5G58ybd1Leq6bNg
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31297272050 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Queue build 31297272050 red — diagnosed as triage checklist item 3 (semantic conflict with a co-queued PR), not a regression here and not flaky. Recording it so nobody re-derives it. The failure. The received side (7 kinds) is the oclif allowlist and is correct. The expected side is derived, and it has been polluted with seven non-driver tokens. Why that happens. The pin deliberately scrapes every lowercase quoted literal out of Attribution. PR #6910 ( Checked and ruled out before concluding that:
Action taken: none to this branch, and no repeated requeue. #6910 has since left the queue on its own, and this PR is queued again at base Generated by Claude Code |
Fixes #5495.
The defect, measured
getNextSequenceValuebootstraps a counter from the data-tableMAXexactly once, in itsif (!existing)branch. After that the data table is never consulted again:So any row landing by a path that bypasses
fillAutoNumberFields— anisSystemseed replay, apreserveAudithistorical import (both strip-exempt under #5503 and keeping their explicit numbers), or direct SQL — never raises the sequence. Once the counter sits belowMAXit is permanently behind, and every create collides, burns a number and fails the request until it has ground past the seeded range one 409 at a time.Reproduced on
main@86e6f6c, on a fresh DB with seeded rows above the counter (the filing's own repro constraint — on a database already ground past the seeds every create succeeds on attempt 1 and the defect is invisible):CASE-00040, on attempt 30CASE-00040, on attempt 1last_valueafterwardsThe filing's field report was 25 consecutive 409s climbing one per failure, succeeding at
CASE-00039— the same shape.The card's own defect statement was wrong in a way that changes the fix
It says the counter "does not sync to
MAX(existing)per tenant". Seeding is already per tenant and per scope:resolvedTenantIdis resolved, the row key is akey_hashof(object, tenant_id, field, scope), andscanMaxNumericTailreceives the tenant. The defect is that the seed runs once. The fix is therefore re-seed / staleness detection, not "sync at boot".Why the retry cannot be decided from the conflicting column
The obvious predicate — "retry when the conflicting column is this autonumber field" — needs
uniqueViolationColumn()(#6544) to name a column. On a tenanted autonumber it never does, for two independent reasons, both measured, both pinned by a test:UNIQUE constraint failed: crm_case.organization_id, crm_case.case_number— and that export refuses composites by contract (soleColumnreturnsundefinedwhennames.length !== 1);(COALESCE(organization_id, '__global__'), case_number), an expression index, on which SQLite reportsUNIQUE constraint failed: index 'uniq_crm_case_organization_id_case_number'and names no column at all.So the "column not determinable" limb is not an edge case on this path — it is the only limb that ever runs for a tenanted autonumber.
Three states, none of them collapsed
Collapsing any two of
uniqueViolationColumn()'s states silently is how a real 409 gets eaten, so all three are handled explicitly incollidingAutoNumberReservations:autoNumberValueExistsasks whether the value just generated is already present in the same tenant partition the counter covers. Present → the collision was this counter's. Absent → rethrow.One indexed lookup, on the failure path only. The happy path is unchanged.
No fifth dialect word-list: the judgement is
isUniqueViolationError+uniqueViolationColumnfrom@objectstack/types(Prime Directive #12; #5841 precedent). The re-seed'sMAXscan is deliberately not wrapped in acatch, so a read failure propagates rather than being folded into0or a stale value (#6114's rule, #5979's family).isMissingTableErroris not reachable from this package —@objectstack/metadatais not a dependency ofdriver-sql— and the benign case it exists for cannot arise here anyway: we are on this path because an INSERT into this very table was refused by a constraint, so the table demonstrably exists.Scope of the retry
Confined to the no-caller-transaction case. Inside a caller's transaction the sequence
UPDATEshares that transaction and rolls back with the refusedINSERT, so no number is burned (measured:last_valueunchanged), and on Postgres a constraint failure aborts the transaction outright, so a retry issued on it could not succeed. The caller owns that retry.Burn-comment reconciliation
getNextSequenceValue's "Gaps are tolerated by design — a rolled-back insert 'burns' a number" held for a rollback and read as though it also covered a persistently failing insert, which was the defect. The docstring now states both cases separately: the rollback burn is still by design and unreclaimed; the per-attempt burn is no longer reachable fromcreate().Both faces
TursoDriver(local/replica) andSqliteWasmDriverinheritcreate()and the fix — each pinned by its own test rather than assumed from the base class, which is the #6203 shape (one fix, one face, one query with two answers). Turso's remote transport is unaffected in both directions: it overridescreatetoRemoteTransport.create, which builds its ownINSERTand never entersfillAutoNumberFields, so it has neither the defect nor the fix — asserted rather than commented, so wiring autonumber into that transport later cannot silently inherit this file's green.Out of scope, recorded not fixed
bulkCreate/upsertalso callfillAutoNumberFieldsand still burn on collision. A per-row retry inside a batch changes batch semantics and is a separate decision.driver-memory/driver-mongodb([裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499 freeze) are untouched: they declare nosupports.autonumberand use the engine fallback (Engine fallback autonumber: counter seeds once and never resyncs; collisions burn numbers with no recheck (residual split from #5495) #6806's surface), so there is nothing here to align — a real absence, not a skipped DEBT row.🤖 Generated with Claude Code
https://claude.ai/code/session_015XLbsWE5G58ybd1Leq6bNg
Generated by Claude Code