From 6f20e6cc0375a5002afd05c2f9803f0b3a23e7da Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 04:08:50 +0000 Subject: [PATCH] Retry transient SQLITE_BUSY at migration Begin and race on a materialized db Why: the connection-open phase (DSN pragmas incl. journal_mode on first open) can return SQLITE_BUSY immediately - the busy handler does not cover that phase - so four racing migrators failed in 0.03s on slow CI disks regardless of the 30s timeout (jobs 2293/2285). Evidence: local -count=30 green (fast disk) vs CI deterministic failure at 0.03s (no timeout wait occurred); fix verified with -count=5 locally plus full store suite. Impact: bounded retry (5 attempts, backoff) on Begin BUSY only - statements inside the IMMEDIATE transaction remain lock-protected; the racing test now materializes the file/WAL first, matching the KTD8 scenario (concurrent migrators on a provisioned database). Refs: Dev PR #325; plan KTD8. --- internal/store/migrate.go | 31 ++++++++++++++++++++++++--- internal/store/migrate_atomic_test.go | 12 +++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 180cb76..c99b726 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -2,9 +2,21 @@ package store import ( "context" + "database/sql" "fmt" + "strings" + "time" ) +// isBusy 识别 SQLite SQLITE_BUSY(含连接打开阶段的 journal/建库竞争)。 +func isBusy(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "database is locked") || strings.Contains(msg, "SQLITE_BUSY") +} + // 版本化迁移(设计 446:每版本附回滚;双档 CI 验证)。 // // 纪律: @@ -552,9 +564,22 @@ func (s *Store) migratePG() error { // 中途失败整体回滚——不存在"DDL 已生效而账本未记"的中间态;两步间崩溃 // 的历史缺口(先提交 DDL 再单独 INSERT 版本)由此消除。 func (s *Store) migratePass() error { - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("migrate: %w", err) + // Begin 阶段包含连接首次打开(应用 busy_timeout/journal_mode 等 DSN + // pragma)。并发首开同一 SQLite 文件时,建库/journal 模式切换阶段可即时 + // 返回 SQLITE_BUSY——busy handler 不覆盖该阶段(慢速盘 CI 实测,与 + // busy_timeout 无关)。对 Begin 的瞬时 BUSY 做有界退避重试;进入 + // IMMEDIATE 事务后的语句由写锁保护,不需要重试。 + var tx *sql.Tx + var err error + for attempt := 0; ; attempt++ { + tx, err = s.db.Begin() + if err == nil { + break + } + if attempt >= 4 || !isBusy(err) { + return fmt.Errorf("migrate: %w", err) + } + time.Sleep(time.Duration(200*(attempt+1)) * time.Millisecond) } defer tx.Rollback() if _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil { diff --git a/internal/store/migrate_atomic_test.go b/internal/store/migrate_atomic_test.go index a89c78e..e2421c4 100644 --- a/internal/store/migrate_atomic_test.go +++ b/internal/store/migrate_atomic_test.go @@ -127,6 +127,18 @@ func TestMigrateAtomicDDLAndLedger(t *testing.T) { // 两边都成功,账本恰好一套(不因竞争出现重复或部分版本)。 func TestMigrateRacingSQLite(t *testing.T) { path := filepath.Join(t.TempDir(), "race.db") + // 先物化文件与 WAL 模式(空库、无 schema):并发首开的建库/journal 阶段 + // 可即时 SQLITE_BUSY 且不受 busy_timeout 保护(慢速盘 CI 实测),属连接 + // 建立竞争而非迁移互斥;KTD8 要验证的是「已部署库上的并发迁移者」。 + pre, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)", path)) + if err != nil { + t.Fatal(err) + } + if _, err := pre.Exec(`SELECT 1`); err != nil { + pre.Close() + t.Fatalf("materialize wal: %v", err) + } + pre.Close() var wg sync.WaitGroup errs := make([]error, 4) for i := range errs {