Retry transient SQLITE_BUSY at migration Begin and race on a materialized db #139

Merged
weichen merged 1 commits from feature/collab-storage-pg-tier-fix2 into main 2026-10-11 04:09:01 +00:00
2 changed files with 40 additions and 3 deletions
+28 -3
View File
@@ -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 {
+12
View File
@@ -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 {