Skip to content

fix(driver-sql): pre-create autonumber sequences table to avoid batch/tx deadlock - #2537

Merged
baozhoutao merged 3 commits into
mainfrom
claude/elated-turing-419983
Jul 3, 2026
Merged

baozhoutao merged 3 commits into
mainfrom
claude/elated-turing-419983

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

问题

SQLite 下 POST /api/v1/batch 创建含 auto_number 字段的对象会死锁(连接池 acquire 超时):

Knex: Timeout acquiring a connection. The pool is probably full.

根因

_objectstack_sequences 计数表是在第一次 autonumber 写时懒创建的,走的是裸 this.knex.schema.* —— 向连接池申请第二条连接。而 /batch 端点把整批操作包在一个 ql.transaction(...) 里(rest-server.ts:5637),SQLite 连接池上限硬编码为 1(knex Client_SQLite3.poolDefaults → { min:1, max:1 },better-sqlite3 继承),那唯一的连接已被外层事务独占 → 第二条永远拿不到 → 超时。

受 sequencesTableReady 进程级一次性 flag 守护,所以只有进程启动后冷态的第一次 autonumber 写(若恰好走 batch/事务)才 100% 触发 —— 与报告的「已 seed 库重启后首个 batch 动作中招」完全吻合。

不只 SQLite:Postgres/MySQL 池默认 max=10,单请求不死锁,但冷 flag 窗口内 ≥pool.max 个并发首写会同样池耗尽死锁。

修复

  1. 主修: initObjects 在建表阶段(事务外、独立连接)预建计数表,数据写路径永不触发 DDL。
  2. 兜底: 懒创建路径(external 对象 / 未走 initObjects 的消费者)现在在 SQLite 上把 DDL 跑在调用方的事务连接上,不再申请第二条连接。刻意不在 MySQL 上走调用方事务 —— MySQL 的 DDL 会隐式提交调用方事务,破坏 batch 原子性;那里池宽裕,用独立连接安全建表。

验证

  • 新增回归测试 sql-driver-autonumber-tx.test.ts:预建断言 + batch 事务首写 + 冷态事务内首写(全部 withTimeout 抓死锁)。
  • 反证: stash 掉修复、只留测试跑旧代码 → 3 条全部 timeout(复现报告死锁);打回修复 → 全绿。
  • 全量 driver-sql 套件 237 passed (27 files);DTS typecheck 通过。

@vercel

vercel Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 3, 2026 8:31am

Request Review

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling and removed size/m labels Jul 3, 2026
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/core/plugins.mdx (via @objectstack/driver-sql)
  • content/docs/concepts/implementation-status.mdx (via @objectstack/driver-sql)
  • content/docs/concepts/terminology.mdx (via @objectstack/driver-sql)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-sql)
  • content/docs/guides/driver-configuration.mdx (via @objectstack/driver-sql)
  • content/docs/guides/packages.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectos/index.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectos/lifecycle.mdx (via @objectstack/driver-sql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added the size/m label Jul 3, 2026
@github-actions github-actions Bot added size/l and removed size/m labels Jul 3, 2026
@baozhoutao
baozhoutao merged commit 8d87930 into main Jul 3, 2026
16 checks passed
@baozhoutao
baozhoutao deleted the claude/elated-turing-419983 branch July 3, 2026 08:45
…/tx deadlock

Lazy creation of _objectstack_sequences on the first autonumber INSERT asked the
pool for a second connection. Inside a /api/v1/batch transaction on SQLite
(pool max=1) the only connection is already held, so the acquire blocked until
'Knex: Timeout acquiring a connection'. Postgres/MySQL hit the same pool
exhaustion under concurrent cold first-writes.

- initObjects now pre-creates the counter table outside any data transaction.
- The lazy fallback runs its DDL on the caller's transaction on SQLite only
  (never on MySQL, where DDL would implicitly commit the caller's transaction).
- Regression test reproduces the deadlock (fails on old code, passes now).
Harden the lazy ensureSequencesTable fallback: when the table is created
on the caller's transaction (SQLite in-tx path), the create is
commit-conditional — a rollback would drop it. Cache sequencesTableReady
only when the DDL ran on a durable pooled connection (this.knex); on the
in-tx path leave the flag unset so the next write cheaply re-verifies via
hasTable instead of trusting a stale process-level flag. initObjects
still sets it durably up front, so the hot path is unchanged.
…ection deadlock

Turns the latent 'bare this.knex query while a transaction holds SQLite's
only pooled connection' hang (surfacing as 'Knex: Timeout acquiring a
connection') into an immediate, actionable error at the call site.

- Track open transactions (beginTransaction++/commit+rollback--) with a
  WeakSet so the decrement is idempotent on double close.
- assertBareKnexSafe(op): throws only in the danger combination
  (non-production + SQLite + an open transaction). No-op in production and
  on non-SQLite dialects — zero hot-path cost.
- Wire it into ensureSequencesTable's bare-knex branch, catching the
  'opened a tx but forgot to thread it through as parentTrx' regression.

Adds sql-driver-sqlite-tx-guard.test.ts (11 cases: tx bookkeeping, guard
fires on the deadlock shape, guard stays quiet on legitimate paths, and
the production/non-sqlite/no-tx branch matrix).

This branch was successfully deployed

1 active deployment
Preview — 15bcf2c6 Deployed Jul 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant