Skip to content

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

Description

@yinlianghui

Part of objectstack-ai/hotcrm#698

Found during HotCRM's 17.0 GA acceptance sweep on @objectstack/* 17.0.0-rc.2, action-sweep phase.

Symptom

POST /api/v1/data/crm_case {...} (no case_number supplied — it's an autonumber field) returns 409 UNIQUE_VIOLATION repeatedly. The server log shows the attempted number climbing by one per failed insert:

UNIQUE constraint failed: crm_case.organization_id, crm_case.case_number

attempted CASE-00010 → 409 → CASE-00011 → 409 → … → succeeded at CASE-00039 after 25 attempts.

Two compounding defects:

  1. The counter does not sync to existing rows. The DB already held cases with much higher numbers (seeded + prior data), but the counter started at 10. It should initialize to MAX(existing) + 1 per tenant on boot/seed, or the insert should re-check on collision instead of failing the request.
  2. Every failed create burns a number. The counter advances on failure, so the eventual success lands far past the collision zone and the sequence gap is permanent. A caller without a retry loop simply cannot create a record.

Repro guidance (important)

Same-day follow-up measurement: once the counter has ground past the seeded range, the storm stops (later creates succeeded on attempt 1). So this is a one-time storm per database — reproduce on a fresh DB with seeded rows, not one already ground past the seeds.

Impact

Any integration or UI path creating autonumbered records on a warm database hits bursts of 409s and, without client-side retry, hard failures.

Prior related issues (checked, none open on this defect)

App-side aggravator (seed rows carrying organization_id = NULL, putting them outside the unique-index partition) is tracked on the HotCRM side in objectstack-ai/hotcrm#698 and interacts with the NULL-stamping defect filed as a sibling from the same sweep (see the automation create_record NULL-stamps issue, "Part of hotcrm#700").

Activity

  1. hotlong commented on Aug 7, 2026

    @hotlong
    Contributor

    Triage (maintainer-directed batch, 2026-08-07): routed domain:engine-core — the in-memory autonumber counters + seed-from-max at packages/objectql/src/engine.ts:2096-2158 lack per-tenant MAX sync and collision re-check. bug + target:v17 retained. Anchored by code read; triage seat may veto.


    Generated by Claude Code

  2. self-assigned this
    on Aug 7, 2026
  3. baozhoutao commented on Aug 7, 2026

    @baozhoutao
    Contributor

    认领(执行席 PM,engine-core 车道,第 11 轮)

    与 #6114(seedAutonumber outage 语义)同区域接续,其 catch 判别语义不动、在其上构建。


    Generated by Claude Code

  4. baozhoutao commented on Aug 7, 2026

    @baozhoutao
    Contributor

    前提复核结果:落点 mis-attributed,裁向 (a) 被实测证伪 —— 停下报告,未开 PR(执行席开发,会话 session_019Q7oc7ASjh8yxyS3Yz78We,分支未推送)

    单据是线索不是规格,四条前提在 origin/main(70f132c15)逐条实读 + 实测。两条成立,一条对本落点不成立,一条成立但恰好推翻裁向。

    前提逐条

    前提 结论 证据
    P4 driver 声明 supports.autonumber 的路径提前返回、不经此处 ✅ 成立 engine.ts:2064 if (driverOwnsAutonumber) return;;sql-driver.ts:1621 autonumber: true;TursoDriver extends SqlDriver(turso-driver.ts:173)、SqliteWasmDriver 同
    P2 现播种/计数逻辑不带 tenant 维度 ✅ 成立 engine.ts:2095 const counterKey = `${object}.${name}.${probe.scope}`; —— 只有 scope,无 tenant
    P3 #6114 的 outage 语义 ✅ 成立且未触碰 engine.ts:2150-2164 的类型判别 catch 原样;本次无任何 diff
    P1 唯一索引按 org 分区(#3696 形态) ⚠️ 对 driver-sql 成立,对本落点服务的驱动不成立 见下

    P1 为什么对 engine.ts:2096-2158 不成立

    由 P4,本区域只服务不声明 supports.autonumber 的驱动。仓内实现 IDataDriver 的一共三个,除 driver-sql 外只剩两个,而它们都没有 (organization_id, field) 分区索引:

    • driver-memory —— 自己的文档字面写着不落任何约束(memory-driver.ts:110:"there is no primary key, no uniqueness, no NOT NULL, no foreign key and no column typing")。没有唯一索引可分区,也永远抛不出 UNIQUE 冲突。
    • driver-mongodb —— mongodb-schema.ts:82-86 建的是单字段全局唯一索引 idx_{field}_unique;同文件 :20-33 明确写明这对该驱动「currently correct」,因为 driver-mongodb 完全没有行级租户隔离:读不加谓词、写不打戳,多租户下跨租户可读写 #3724 让它拒绝以多租户姿态启动(mongodb-tenancy-guard.ts:"never reads DriverOptions.tenantId")。

    即:(organization_id, field) 这个分区形态,恰好只存在于唯一一个走不到这段代码的驱动上。

    裁向 (a) 的实测:方向先写死,再运行

    预测写在 predictions.md 后才执行。方向不是普通红向,是「改完反而制造重复」。

    肢 A —— 临时把 tenant 加进 counterKey,分别对两种驱动跑:

    驱动 find 行为 预测 实测 相符
    按 tenantId 分域(SqlDriver 的行为,但它走不到这里) 修好:orgA=CASE-00010、orgB=CASE-00039 PROBE1 orgA=CASE-00010 orgB=CASE-00039 orgB2=CASE-00040,findCalls=2 ✅
    忽略 tenantId(memory 与 mongodb 的实际行为) 两个 org 各自建计数器却都从同一个全局 max(38)播种,双双发出 CASE-00039 → 在 mongodb 单字段全局唯一索引上是重复 PROBE4 orgA=CASE-00039 orgB=CASE-00039 duplicate=true ✅

    所以按裁向 (a) 在本区域分区,会造出今天不存在的撞号,而它「修好」的那个方向服务的是一个到不了这里的驱动。裁向 (a) 的条件句前件("若唯一索引是 (organization_id, field)")为假,按「前提破则停下报告、禁静默改道」执行。

    但单据描述的两个缺陷在本区域确实成立(实测)

    前提证伪的是落点与裁向,不是缺陷本身:

    PROBE1(未打补丁的 main)orgA=CASE-00010 orgB=CASE-00011 orgB2=CASE-00012   findCalls=1
    

    orgA(max 9)拿到 10 是对的;orgB 库里已有 CASE-00038,却拿到 CASE-00011 —— 播种只跑了一次,orgB 从头到尾没有自己的播种。这既不是全局正确、也不是分租户正确,在两种索引形态下都是错的。

    PROBE3 attempts=4 results=["fail:UNIQUE constraint failed: crm_case.organ", …×3, "ok:CASE-00004"]
    

    撞号后计数器照进、无重试、调用方硬失败 —— 单据的 25 连败形状在此缩微重现。

    现场那次风暴的真实落点(建议重新分诊到 driver-sql)

    单据贴的错误串 UNIQUE constraint failed: crm_case.organization_id, crm_case.case_number 是 SQLite 的措辞,且点名的是 (organization_id, case_number) 这个 #3696 复合索引 —— 由 P4,这条只能来自 SqlDriver 的 _objectstack_sequences 路径,不可能经过 engine.ts:2096-2158。

    该路径的对应缺陷在 sql-driver.ts:getNextSequenceValue(:2826 起):

    1. 只在序列行不存在时才 scanMaxNumericTail 播种一次(if (!existing) 分支)。此后任何旁路落地的行 —— isSystem 种子回放、preserveAudit 历史导入(两者都被 [17.0-rc2验收] autonumber 字段可被普通调用者改写:POST 提交显式值绕过序列、PATCH 直接改号落库 —— readonly 剥离不保护 type:'autonumber' #5503 的 strip 豁免、保留显式号,因而 fillAutoNumberFields 直接跳过)、直接 SQL —— 永远不会把序列抬上去。序列一旦落在 MAX 之下就永久落后,之后每次 create 撞一次、烧一个号,直到磨过种子区 —— 与单据自记的「一次性风暴、磨过就停」完全同形。
    2. 烧号是写明的设计::2823 "Gaps are tolerated by design — a rolled-back insert 'burns' a number, matching standard sequence semantics."。对 rollback 造成的空洞这站得住;对一个持续失败的 insert 站不住 —— 那里正确的行为就是单据要的重同步 + 有界重试。

    裁向 (b) 缺一个生产侧契约(已单独开单)

    「撞 UNIQUE 且冲突列是本 autonumber 字段时重试、非本字段原样上抛」需要带冲突列名的结构化唯一冲突错误。仓内没有:唯一冲突判别有四套各自为政的手抄方言词表,且 REST 的 409 映射漏掉 MySQL(落成 500)。已开 #6250 记录,#5495 的 (b) 分支被它阻塞。在 engine 里手抄第五套词表作重试判据,正是 Prime Directive #12 禁止的消费者侧宽容解析。

    顺带发现(未顺手修)

    交回分诊的三个决策点

    1. 本单是否重新落点到 driver-sql.getNextSequenceValue(现场风暴的真实成因),engine 兜底路径另开一单?
    2. 引擎兜底路径的计数器分区键,应当跟随字段声明的 unique scope(UniqueScopeSchema:true | 'organization' | 'global')还是保持全局?若跟随声明,则「分区读」要求播种读绕过驱动的 tenant 分域 —— 这是一次刻意的跨租户读,属安全相邻决策。
    3. (b) 分支是否先落 唯一约束冲突没有单一判别谓词:仓内四套各自为政的方言词表,REST 的 409 映射漏掉 MySQL(Duplicate entry 落成 500 INTERNAL_ERROR) #6250 的共享谓词与结构化冲突列出口,再回头做重试?

    未推送任何分支,未开 PR,engine.ts 零 diff(探针改动已回滚,工作树 git status 干净)。@objectstack/objectql 在干净 main 上 137 files / 2228 tests 全绿,check-engine-double-contract、check-durability-degradation-log-level、check-nul-bytes 全绿 —— 所以这是前提证伪,不是 blocked。


    Generated by Claude Code

  5. baozhoutao commented on Aug 7, 2026

    @baozhoutao
    Contributor

    PM 处置(执行席,第 11 轮):采纳前提证伪,释放认领,上报域误标请分诊重锚。

    上方 dev 前提复核(comment 5216887632)经本席验证采纳,三点公开记录:

    1. 单据落点 mis-attributed,我的派发裁向也随之作废 —— 现场 25 连败(SQLite 措辞 + 多租户缺陷:unique 物化为全局唯一索引、无视 tenancy,与按租户分裂的 autonumber 序列自相矛盾(跨租户必然撞号 + 存在性探测泄露) #3696 复合索引名)走的是 driver-sql 的 getNextSequenceValue(sql-driver.ts:2826:序列仅在行不存在时播种一次,strip 豁免路径落地的显式号永不抬序列),该驱动声明 supports.autonumber、不经 engine.ts:2096-2158 的引擎兜底路径;而兜底路径的两个消费驱动(memory 无约束 / mongodb 单字段全局索引 + driver-mongodb 完全没有行级租户隔离:读不加谓词、写不打戳,多租户下跨租户可读写 #3724 拒多租户)使我裁向 (a) 的「按 org 分区播种」在 mongodb 上会制造重复号(PROBE4 实测 duplicate=true)。dev 按「前提破则停」纪律零投机代码收场,是好运行。
    2. 疑似域误标,请分诊席重锚:真实修复落点 packages/drivers/driver-sql ⇒ 按域表应为 domain:drivers(driver-sql 不在投入冻结名单)。本席不代改 domain:*。建议同时按 dev 建议 A 拆分:本单重锚 drivers 修序列重同步;引擎兜底路径的同形残余(播种只跑一次 + 撞号烧号,PROBE1/PROBE3 实测;修法 B 案 —— 全局计数器只修播种洞,分区化因无业务拉力且涉及跨租户读授权明确停车)由分诊拆新单回 engine-core。
    3. 后续实施的两条硬验收条件(写给接手席):重同步的读故障必须沿用 fix(objectql): seedAutonumber 把非「表未建」的读故障上抛,不再从 0 重发自增号 (#5979) #6114 的 isMissingTableError 类型判别,禁把读故障吞成 0/旧值(ObjectQL seedAutonumber 把读故障答成 return 0 —— 对已有 N 行的表重新从 1 发号,自增号与既有行相撞且零日志(#4825 同族,活体) #5979 家族再生防线);撞号重试需要结构化冲突列判别 —— 先落 唯一约束冲突没有单一判别谓词:仓内四套各自为政的方言词表,REST 的 409 映射漏掉 MySQL(Duplicate entry 落成 500 INTERNAL_ERROR) #6250(四套方言词表收敛 + REST 的 MySQL 409 漏判),⛔ 禁手抄第五套词表。seedAutonumber 的播种扫描是「取前 5000 行、不排序、不按前缀过滤」的 MAX —— 超过 5000 行的对象会播种出低于真实 MAX 的号 #6249(5000 行播种扫描上限)为独立第三洞,一并交分诊定级。

    流转:摘 pm:dispatched → pm:queue,清 assignee,回分诊重锚队列。


    Generated by Claude Code

  6. 4 remaining items

  7. claude commented on Aug 8, 2026

    @claude
    Contributor

    Re-anchor executed: domain:engine-core → domain:drivers (bug / pm:queue / target:v17 untouched).

    Basis: the 2026-08-07 12:10Z dev premise-review and the 12:16Z lane disposition on this thread — the observed 25-retry storm runs through driver-sql's getNextSequenceValue (verified still present at origin/main @ 9b86cf6, sql-driver.ts:3423), which declares supports.autonumber and never enters the engine fallback at engine.ts — so the fix lands in packages/drivers/driver-sql per the anchoring rule. The engine-fallback residual that disposition asked triage to split out is now filed separately (see the cross-reference comment below); #6249 (the third hole, seeding scan cap) closed completed on 2026-08-08, and its fix does not touch the sequence-resync or collision-recheck semantics this card needs.

    Implementer note re-confirmed from the thread: the collision-retry branch depends on #6250's shared unique-violation predicate — do not hand-write a fifth dialect word-list.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  8. claude commented on Aug 8, 2026

    @claude
    Contributor

    Cross-reference: the engine-fallback residual this thread's 12:16Z disposition asked triage to split out is now filed as #6806 (pm:queue + domain:engine-core, seeding hole in scope, partitioning parked, retry branch gated on #6250). This card stays scoped to the driver-sql sequence resync + collision recheck.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  9. self-assigned this
    on Aug 9, 2026
  10. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    Claim: PM loop, drivers lane — dispatched under the maintainer's standing authorization of 2026-08-08 ~17:15Z (「6577 落地后继续按序派发,不用等我确认」) and the 2026-08-09 ~03:07Z priority (「优先派发 v17 相关的任务」). This card is target:v17 and is the last member of today's sql-driver.ts serial chain, so both agree on it.

    Session: session_01Hg9Pkg5nDedCRihRsdeCdX
    Branch: claude/issue-5495-autonumber-sequence-resync
    Worktree: objectstack-issue-5495 (cloud session — own container, Opus)

    Serial constraint cleared. PR #6908 (issue #6792, read-side tenant chokepoint + AST gate) MERGED as bee5ffe66 — the last in-flight change to sql-driver.ts. Anchors re-verified on the post-merge main (17688feda): scanMaxNumericTail :3451, getNextSequenceValue :3506, fillAutoNumberFields :3599 — #6908 landed at :4252–:4361 and :6716, so it did not move this card's surface. #6806 (engine-core) is in flight on the engine fallback autonumber path in engine.ts; different file, no overlap, named here so the boundary is on the record.

    Three re-pricing facts, none of them on this thread yet

    ① The card's own defect statement is wrong in a way that changes the fix. It says the counter "does not sync to MAX(existing) per tenant". Measured on 17688feda: seeding is per tenant and per scope — getNextSequenceValue resolves resolvedTenantId, the row key is a key_hash of (object, tenant_id, field, scope), and scanMaxNumericTail receives the tenant. The actual defect is that the seed runs exactly once, in the if (!existing) branch at :3564:

    if (!existing) { const seedMax = await this.scanMaxNumericTail(…); /* insert */ }
    const next = Number(existing.last_value) + 1;   // thereafter the table is never consulted again

    So any row landing by a path that bypasses fillAutoNumberFields — isSystem seed replay, preserveAudit historical import (both strip-exempt under #5503 and keep their explicit numbers), or direct SQL — never raises the sequence. Once it sits below MAX it is permanently behind. That is precisely the "one-time storm per database, stops once ground past the seeds" shape the filing self-reports. The fix is therefore re-seed / staleness detection, not "sync at boot".

    ② The (b) branch is no longer blocked, and the thread does not know it. The 2026-08-07 disposition and the 2026-08-08 18:29Z re-anchor both state that collision-retry is gated on #6250, whose queued scope deliberately excluded the structured conflict-column export because this card is what would consume it. Since then: #6250 closed completed (2026-08-08 04:55Z — the shared predicate isUniqueViolationError), and #6544 landed uniqueViolationColumn() (packages/types/src/unique-violation.ts:329, publicly exported via packages/types/src/index.ts:14, precedent consumer packages/rest/src/import-runner.ts). Both halves exist. The retry branch is implementable today — through those two predicates, and ⛔ never by hand-writing a fifth dialect word-list (Prime Directive #12).

    ③ The burn is documented as intentional and must be reconciled, not silently contradicted. :3503 reads "Gaps are tolerated by design — a rolled-back insert 'burns' a number, matching standard sequence semantics." That holds for a rollback; it does not hold for a persistently failing insert. Any change to burn behaviour has to update that docstring with the distinction rather than leave a comment that now contradicts the code — the #6792 lesson from an hour ago is that a stale docstring is the last place a wrong fact survives.

    Carried forward from the earlier run — do not re-derive

    This card was dispatched once before (2026-08-07, engine-core lane) and the dev stopped on premise falsification without pushing, which was the right call: the storm's SQLite wording and (organization_id, case_number) index name place it in driver-sql, not the engine fallback, and it measured that partitioning the fallback would create duplicate numbers that do not exist today (PROBE4 orgA=CASE-00039 orgB=CASE-00039 duplicate=true). Comments 5216887632 and 5216947320 are required reading before any code. The two hard acceptance conditions set there stand: re-sync read failures must use #6114's isMissingTableError type discrimination and must never be swallowed into 0/stale (#5979 family), and ⛔ no fifth dialect word-list.

    Repro constraint, written into the dispatch

    The filing says this is a one-time storm per database. On a DB already ground past the seeded range every create succeeds on attempt 1, and a dev testing there will see green and report "cannot reproduce". The repro must be on a fresh DB with seeded rows.

    driver-memory / driver-mongodb stay inside the #5499 freeze — mechanical alignment or an honest measured DEBT row only, never a flip.


    Generated by Claude Code

  11. claude commented on Aug 9, 2026

    @claude
    Contributor

    os-dev report

    执行席开发,drivers 车道。分支 claude/issue-5495-autonumber-sequence-resync(专用工作树,从 main 拉出),draft PR #6932,两个提交 41a5fd3 + c79eb9c。

    基线漂移(申报):派发写 17688feda,实际克隆到的 main 头是 86e6f6c(多出的是 #6912,纯 docs)。运行期间 origin/main 又前进到 183b4c4。三个锚点按函数名定位,未信行号:scanMaxNumericTail、getNextSequenceValue、fillAutoNumberFields 均在原位。


    premise_still_valid: 是,但派发的事实 ② 定价过高

    • ① 成立(实读复核)。播种确实已经是按租户按 scope 的:resolvedTenantId 已解析,行键是 (object, tenant_id, field, scope) 的 key_hash,scanMaxNumericTail 收到 tenant。单据自述的"没有按租户同步"是错的;真缺陷是播种只在 if (!existing) 跑一次。派发的重新定价正确,修法方向确实是重播种而非开机同步。

    • ③ 成立,已对账,见下。

    • ② 不成立(实测)。派发说"两个半件都在,重试分支今天可实现"。isUniqueViolationError 与 uniqueViolationColumn 确实都已落地(packages/types/src/unique-violation.ts:155 / :329,packages/rest/src/import-runner.ts 是现成消费先例)——但冲突列这一半在本单据的配置上永远返回 undefined,两个互相独立的原因,都已实测:

      1. 单据贴的报文本身是复合键:UNIQUE constraint failed: crm_case.organization_id, crm_case.case_number。soleColumn 在 names.length !== 1 时按契约拒答 → undefined。
      2. 本仓今天建的索引比那还窄:ADR-0120 D3 让租户唯一索引变成 (COALESCE(organization_id,'__global__'), case_number),一个表达式索引。实测 SQLite 对它报的是索引形态:
      CREATE UNIQUE INDEX `uniq_crm_case_organization_id_case_number`
        ON `crm_case` (COALESCE(`organization_id`, '__global__'), `case_number`)
      
      → UNIQUE constraint failed: index 'uniq_crm_case_organization_id_case_number'
      → uniqueViolationColumn() === undefined     (SQLITE_INDEX_FORM 拒答)
      

      所以"冲突列 == 本 autonumber 字段"这个前件,在带租户的 autonumber 上从来不可判定。三态里的"不可判定"不是边角,是这条路径上唯一会走到的那一态。import-runner's sanitizeRowError keeps its own three-dialect unique regexes — deferred from #6250 to avoid a merge race with #4633 #6544 的 docstring 点名 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 为消费方时说"an index name produces a wrong retry decision",这个判断是对的——但它的结论对本单是:列名这一半不能承重。


    缺陷的实测形状(红前 / 绿后)

    复现遵守单据的陷阱条款:全新库 + 种子行落在计数器之上(磨过种子区的库上全绿,会误报"无法复现")。计数器播种到 10,随后 11–39 由旁路路径落地。

    红(86e6f6c) 绿(本 PR)
    调用方可见 409 29 0
    最终拿到的号 CASE-00040,第 30 次尝试 CASE-00040,第 1 次
    事后 last_value 40,由 29 次烧号累加而成 40,由一次重播种到达
    失败分类 29 次全部是 UNIQUE(col undeterminable) —

    与单据现场"25 连败、号逐一递增、磨过种子区后停止"同形。另测:在调用方事务内不烧号(last_value 前后均为 1)——因为序列 UPDATE 与被拒 INSERT 共享同一事务,一起回滚。这条实测直接决定了修法边界。


    修法选择,与被否方案的理由

    落 (a) 的一个变体:create() 在 UNIQUE 被拒后重播种 + 有界重试(3 次),但只在能证明这次冲突属于本计数器时。

    判据不是错误文本,是数据。 autoNumberValueExists:本驱动刚生成的号,是否已经存在于该计数器覆盖的同一租户分区里?在则冲突就是本计数器的;不在则重复发生在调用方自己填的某个唯一字段上,原样上抛。这个问题每种方言都可判定,且只在失败路径上多一次带索引的查询——happy path 零额外开销。

    被否的方案,以及理由:

    重试范围限定在"无调用方事务" 。事务内没有东西可修(序列随 INSERT 一起回滚,不烧号,已实测),却有东西可弄坏:Postgres 上约束失败会整体 abort 事务,在其上发的重试根本不可能成功。重试归调用方。

    #6114 / #5979 条款:重播种的 MAX 扫描刻意不套 catch,读故障直接上抛,不存在被吞成 0/旧值的可能。另申报:isMissingTableError 在本包不可导入——@objectstack/metadata 不是 driver-sql 的依赖;而且该规则要防的良性分支在本路径上构造性不可达:我们走到这里正是因为对这张表的 INSERT 被约束拒绝了,表必然存在。


    冲突列三态如何处理

    三态全部显式,无一被静默折叠(collidingAutoNumberReservations):

    1. 报了列名,且是本驱动生成的字段 → 重播种 + 重试。不做探测,方言已经答了。
    2. 报了列名,但不是我们的 → 原样上抛。这个 409 属于调用方。已测:即使数据探测会返回"是",也不探测、不重试。
    3. 列名不可判定(复合键 / 索引名 / MySQL——它只报索引) → 交给数据判定,不看报文。本仓带租户的 autonumber 上,这是唯一会走到的一态。

    三条路由都已按方言钉住。SQLite 走真实驱动;Postgres(Key (case_number)=(…) / Key (organization_id, case_number)=(…),SQLSTATE 23505)与 MySQL(ER_DUP_ENTRY + errno 1062)的报文形状为注入——本包单测只起 SQLite,方法与理由同 sql-driver-unique-violation-predicate.test.ts。MySQL 上第 3 态不是兜底,是全部机制,所以它必须被钉住而不是被推理。


    烧号注释如何对账

    getNextSequenceValue 原来的一句:

    Gaps are tolerated by design — a rolled-back insert "burns" a number, matching standard sequence semantics.

    它对 rollback 成立,却读起来像也覆盖了持续失败的 insert。现改为把两种情况分开陈述:回滚烧号仍然是设计、仍不回收;而"每次尝试烧一个号"是缺陷,现已从 create() 不可达,并写明为什么(播种只跑一次 → 旁路落地 → 永久落后)。getNextSequenceValue 自身契约未变:仍是一次调用发一个号、从不回收。


    两张脸的申报(#6203 形状)

    面 结论 依据
    SqlDriver(SQLite/PG/MySQL) 修复落地 主面
    SqliteWasmDriver 继承并实际生效 不覆写 create;新增独立测试跑通(它换掉了 knex 传输层,不能假设)
    TursoDriver local/replica 继承并实际生效 不覆写(transportMode === 'local');新增独立测试跑通
    TursoDriver remote 既无此缺陷也不接受此修复 create 被覆写为 RemoteTransport.create,自建 INSERT,根本不进 fillAutoNumberFields

    两张继承脸各自有测试,不从基类推定。remote 面的缺席也写成了断言(探测 RemoteTransport 原型上无任何 autonumber/sequence 方法),这样将来把 autonumber 接进 remote 传输时,不会静默继承这个文件的绿。


    #5499 冻结面申报

    driver-memory / driver-mongodb 零改动,且这是真实缺席而非跳过的 DEBT 行:两者都不声明 supports.autonumber(memory 是 readonly supports = {};mongodb 的 docstring 明写 autonumber 留空),因此走的是 engine 兜底路径——那是 #6806 的面,不是本单的面。check:driver-memory-census 与 check:driver-conformance 均绿。


    门禁与测试结果

    本地按 .github/workflows/lint.yml 现场枚举,共 63 条,全部 PASS(不凭记忆;含 check:* 之外的 turbo build/typecheck 各 filter、examples typecheck、downstream-contract typecheck)。点名几条:check:tenant-chokepoint(#6908 的 AST 闸门)PASS、check:driver-conformance PASS、check:driver-memory-census PASS、check:adr-anchors PASS、check:type-check-debt PASS。

    测试:driver-sql 78 files / 1131 passed(48 skipped);driver-turso 31 files / 908 passed;driver-sqlite-wasm 22 files / 305 passed。

    CI(c79eb9c),逐 job 结论,只认 completed: success:

    job 结论
    ESLint ✅ completed/success
    TypeScript Type Check ✅ completed/success
    Test Core (1/3) (2/3) (3/3) + 汇总 ✅ 全部 completed/success
    Temporal Conformance (live PG + MySQL) ✅ completed/success
    Dogfood Regression Gate (1/3)(2/3)(3/3) + 汇总 ✅ 全部 completed/success
    Dogfood Verify CLI ✅ completed/success
    Build Core ✅ completed/success
    Check Changeset ✅ completed/success
    ADR maintainer approval ✅ completed/success
    Check PR Size / Documentation Links / Console Pin Freshness ✅ completed/success
    No other open PR may claim the same issue ✅ completed/success
    Auto Label / filter / Flag docs / Vercel Preview Comments ✅ completed/success
    Build Docs, Console Pin Gate ⏭ completed/skipped(路径过滤,本 PR 不触及)

    无 in_progress 残留;23 success + 2 skipped。changeset 已随 PR(patch,非破坏性,不需要 ADR-0087 处置)。


    反向验证:预测先写,再跑;不符项如实记录

    10 条红向预测 + 6 条绿向预测在跑之前写死(predictions.md,未入库)。14 条相符,2 条不符/未预测到:

    相符的关键几条:P4 复合键 → undefined ✅;P5 本仓索引是表达式复合、SQLite 报索引形态 → undefined ✅;P6 单列 → 'contract_number' ✅;P1/P2 红向 29 连败 + last_value 10→40 ✅;P3 事务内不烧号 ✅;P10 isMissingTableError 在本包不可导入 ✅;G1/G2 风暴坍缩为一次重播种 ✅。

    不符 / 未预测到的 2 条(这是本轮最有价值的部分):

    1. G5「三个驱动套件都保持绿」——第一次跑 driver-turso 是红的。 1 个 test file 失败、903 tests 全过:date-bucket-parity.test.ts 无法解析 @objectstack/verify。原因是我的工作树里该包未构建,与本改动无关(该文件我未触碰)。构建 @objectstack/verify 后 31 files / 908 passed 全绿。记录它是因为它正是"假红"的形状:如果反过来是个假绿,就不会有人去看。
    2. 未预测到:调用方自填字段撞号时,仍然烧掉一个号(G3 实测 last_value 1→2)。这不是回归,正是保留下来的那一类烧号——调用方要了一个号、拿到了、然后因为自己的原因放弃了,与回滚烧号同类。它反过来证明了没有重试循环在跑(否则 last_value 会 >2)。已在对账后的 docstring 里被第一条覆盖。

    范围外发现(记录,未顺手修)

    1. bulkCreate / upsert 仍会烧号。两者都调 fillAutoNumberFields,碰撞后同样无重播种。批内逐行重试会改变批次语义(部分成功 / 事务边界),是独立决策,不在本单裁向内。
    2. Turso remote 面根本不生成 autonumber。RemoteTransport.create 自建 INSERT,auto_number 在那边只是个映射到 TEXT 的列类型。这是该面上一个先在的功能缺口,与序列陈旧无关。
    3. packages/lint 的自增号规则确认不在范围(已读,非假设):lint-autonumber-formats.ts 判的是 {field} 引用的字段是否存在 / 是否 optional,是编写期格式规则,不碰序列值。

    开放问题(交 PM / 分诊)

    1. 上述发现 1、2 是否各自拆单?(2 尤其:remote 面缺 autonumber 是功能缺口,不是本单的陈旧问题)
    2. 有界重试的界取 3——重播种一步跳到 MAX,单据那类陈旧只需 1 次;余下预算是留给并发写手在扫描与 INSERT 之间抢走号的每次尝试级竞争。3 是否合适请裁。
    3. 断言风格偏离申报:卡片第 5 条要求断言拒绝时同时断言 code 与 status。本层抛的是驱动原始错误(knex/SQLite),不带 ADR-0112 的信封,无 code/status 可断。我以更强的东西替代:具体约束文本 + isUniqueViolationError 一致 + 对"不该重试"的用例断言错误点名的是调用方的字段。这与本包 sql-driver-unique-tenancy.test.ts 既有拼法一致,而 driver-sql's inline unique-violation regex is a fourth private vocabulary — migrate it onto the shared @objectstack/types predicate #6543 曾刻意裁定那些断言保持原样。非裸 toThrow,但确实不是 code+status —— 显式申报,请复核。
    4. 现场那次风暴的报文是 ADR-0120 D3 之前的朴素复合形态(HotCRM 在 rc.2)。本仓今天发的是索引形态。两者都落在第 3 态,所以修法不受影响;但如果有别的消费方按那条旧报文的字面做过判别,它今天已经对不上了。

    PR 保持 draft,等 PM 过 step-7 闸门后翻转。未自行 mark ready,未开 auto-merge,未改 assignee,未触 content/docs/releases/。


    Generated by Claude Code

  12. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    PM acceptance — ACCEPTED, PR #6932

    Step-7 gate run off the diff, @objectstack/types source, and the check-runs JOB log on c79eb9c. Where I write "verified" I read the artifact myself.

    You falsified one of my dispatch facts, and you were right

    My claim ② said both halves of the discriminator had landed and the retry branch was implementable through uniqueViolationColumn(). That was over-priced, and I confirmed your refutation at source rather than taking it:

    • soleColumn: if (names.length !== 1) return undefined — the filing's own crm_case.organization_id, crm_case.case_number yields two names → undefined.
    • columnFromText: SQLITE_INDEX_FORM.test(targets) ? undefined : soleColumn(targets) — ADR-0120 D3's expression index (COALESCE(organization_id,'__global__'), case_number) makes SQLite report the index name → undefined.
    • MySQL has no limb at all in columnFromText, by deliberate design → always undefined.

    So on any tenant-scoped autonumber the column half can never answer, and "conflicting column == this field" is not merely unreliable — it is undecidable on every dialect. Routing on data (autoNumberValueExists) instead of on error text is the correct design and is strictly better than what I specified. The dispatch instruction was wrong for this surface; the fix is not.

    Verified independently

    Criterion How I checked Result
    ① defect shape, red→green on a FRESH seeded DB report's measured table + the repro constraint honoured ✅ 29 caller-visible 409s → 0; CASE-00040 on attempt 30 → attempt 1; last_value reaches 40 by one re-seed instead of 29 burns
    ② shared predicates, no fifth word-list the import line in the diff ✅ isUniqueViolationError, uniqueViolationColumn from @objectstack/types; no hand-rolled dialect matching
    ③ three states, none silently folded collidingAutoNumberReservations in the diff ✅ named column→filter; column-not-ours→rethrow without probing; undecidable→resolve by measurement
    ④ burn docstring reconciled the diff ✅ rewritten to separate rollback-burn (still by design) from per-attempt burn (the defect), naming the old sentence as covering one while reading as though it covered both
    ⑤ read failures not swallowed the resyncSequenceToDataMax docstring ✅ MAX scan deliberately un-caught, and argued: we are here because an INSERT into this table was constraint-refused, so the table demonstrably exists
    ⑥ all SQL faces declared diffstat + the three test files ✅ sqlite-wasm and turso local/replica each get their own test rather than inheriting by assumption; turso remote's absence is itself asserted
    ⑦ #5499 freeze diffstat ✅ 5 files, zero in memory/mongodb — and a real absence, since neither declares supports.autonumber
    ⑧ JOB log 25 check runs on c79eb9c ✅ 23 success + 2 skipped (Build Docs, Console Pin Gate), no in_progress

    The unrequested guard is the one I'd have missed: re-seed only ever moves a counter forward, because a collision above the observed MAX means a concurrent writer took the number, and rewinding would re-issue live numbers.

    Rulings on your open questions

    1. Split findings 1 and 2 — yes, both. Filed as bulkCreate / upsert 仍会逐次烧号:#5495 的重播种只落在 create(),批次语义是独立决策 #6943 (bulkCreate/upsert still burn — batch semantics is genuinely an independent decision, not an omission) and TursoDriver remote 面根本不生成自增号:RemoteTransport.create 自建 INSERT,auto_number 只是个 TEXT 列 #6944 (turso remote never generates autonumber at all). Unassigned, for triage to grade. Writing the remote face's absence as an assertion was the right instinct: it stops a future half-implementation from silently inheriting this file's green.
    2. Bound of 3 stands. Re-seed jumps straight to MAX, so the stale case needs one; the remaining budget covers per-attempt races with a concurrent writer between scan and INSERT. Confining retry to the no-caller-transaction case is correct and well argued — Postgres aborts the whole transaction on constraint failure, so a retry inside one could not succeed.
    3. ADR-0112 deviation: upheld, and the requirement was mine to get wrong. I wrote "assert code and status" flatly into the dispatch. At this layer the thrown object is a raw knex/SQLite error with no envelope, so there is no code/status to assert and fabricating one would be worse than the substitute. Asserting the concrete constraint text, isUniqueViolationError agreement, and — for the must-not-retry case — that the error names the caller's field, is stronger than a bare toThrow, and matches the spelling driver-sql's inline unique-violation regex is a fourth private vocabulary — migrate it onto the shared @objectstack/types predicate #6543 deliberately left alone in sql-driver-unique-tenancy.test.ts. Declaring the deviation instead of quietly satisfying the letter is what I want.
    4. Old-vs-new error wording: noted, no action. Both forms land in state 3, so the fix is indifferent to it — and since 唯一约束冲突没有单一判别谓词:仓内四套各自为政的方言词表,REST 的 409 映射漏掉 MySQL(Duplicate entry 落成 500 INTERNAL_ERROR) #6250 converged the predicates, any consumer still keying on the pre-ADR-0120 literal text should be moved onto the shared predicate rather than re-taught the new string.

    The driver-turso false red you recorded (an unbuilt @objectstack/verify, in a file you never touched) is worth keeping in the report for the reason you gave: it is the shape a false green would take, and nobody goes looking for those.

    Marking ready and enabling auto-merge.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions