Files
ttstd-infinite-canvas/backend/internal/app/test_database_test.go
AetherNo2332 76e02734f1 test(app): 测试库 - 改用 WAL 文件库,消除共享缓存内存库的表级锁 flake (#562)
Original-Commit: 25931635266ca01843691e87d631b6f2db178e52
test(app): 测试库 - 改用 WAL 文件库,消除共享缓存内存库的表级锁 flake

两处用例夹具用 sqlite 的 `file:…?mode=memory&cache=shared`(表级锁,且不受 _busy_timeout
约束),而它们都会触发后台删除 outbox(go s.drainResourceDeletionJobs),并发时偶发
`database table is locked`(本地并发读写 400 次撞到 70+ 次)。

新增 newSQLiteTestDB(t):文件库 + WAL + busy_timeout=5000,WAL 允许"一写多读"、写冲突退化成
SQLITE_BUSY 并由 busy_timeout 重试;库文件放 os.MkdirTemp(不用 t.TempDir,避免 outbox 仍在写
时清理报 directory not empty)。两处夹具改用它,并新增一条属性用例守住这个选型。
2026-09-21 09:37:19 +08:00

101 lines
3.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// newSQLiteTestDB 打开一个测试用的 SQLite 库。
//
// 默认写法 `file:<id>?mode=memory&cache=shared` 在"后台 worker 并发写 + 测试线程读"时会报
// `database table is locked`:共享缓存模式下 SQLite 用的是**表级锁**(SQLITE_LOCKED),
// 它不受 `_busy_timeout` 约束(那只管 SQLITE_BUSY),只能靠 `sqlite3_unlock_notify` 重试,
// 而当前驱动没有启用。实测并发读写 400 次能撞到 70+ 次,CI 上因此偶发失败
// (TestDeleteGeneratedAssetTaskReferences/cancelled_output 就是这样挂的)。
//
// 因此凡是会触发后台删除 outbox(`go s.drainResourceDeletionJobs`)的用例,统一用
// 文件库 + WAL + busy_timeout:WAL 允许"一写多读"并发,写冲突退化成 SQLITE_BUSY 并由
// busy_timeout 重试。
//
// 库文件刻意放在 `os.MkdirTemp` 而不是 `t.TempDir()` 下:outbox goroutine 可能在用例返回后
// 仍在写库,`t.TempDir()` 的清理遇到这种写入会以 "directory not empty" 直接判失败,
// 而它本身与用例断言无关。
func newSQLiteTestDB(t *testing.T) *gorm.DB {
t.Helper()
directory, err := os.MkdirTemp("", "canvas-test-db-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(directory) })
db, err := gorm.Open(sqlite.Open(filepath.Join(directory, "test.db")+"?_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
return db
}
// TestSQLiteTestDBAllowsConcurrentReadWrite 守的是 newSQLiteTestDB 的选型:并发读写不得报锁。
// 换回 `mode=memory&cache=shared` 会立刻变红(见上面的机制说明)。
func TestSQLiteTestDBAllowsConcurrentReadWrite(t *testing.T) {
db := newSQLiteTestDB(t)
if err := db.Exec("create table lock_probe (id integer primary key, value text)").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("insert into lock_probe (id, value) values (1, 'a')").Error; err != nil {
t.Fatal(err)
}
var mutex sync.Mutex
var failure error
record := func(err error) {
if err == nil {
return
}
mutex.Lock()
defer mutex.Unlock()
if failure == nil {
failure = err
}
}
stop := make(chan struct{})
var workers sync.WaitGroup
workers.Add(2)
go func() {
defer workers.Done()
for {
select {
case <-stop:
return
default:
}
var count int64
record(db.Table("lock_probe").Count(&count).Error)
}
}()
go func() {
defer workers.Done()
defer close(stop)
for index := 0; index < 300; index++ {
record(db.Exec("update lock_probe set value = ? where id = 1", fmt.Sprint(index)).Error)
time.Sleep(time.Millisecond)
}
}()
workers.Wait()
if failure != nil {
if strings.Contains(failure.Error(), "locked") {
t.Fatalf("并发读写命中 SQLite 锁:%v(说明测试库不该回退到共享缓存内存库)", failure)
}
t.Fatalf("并发读写失败:%v", failure)
}
}