package main import ( "TsDB2/DB" "fmt" "os" "time" ) type Record struct { DB.BaseModel Name string `vrtx:"maxlen:32"` } const ( dbPath = "lock_table_test.db" poolPath = "lock_test2.db" ) // failed 累计失败项。demo 用退出码说话: 只打印一句 "⚠️ 未检测到阻塞" 然后正常退出, // 等于把"没验到"当成"验过了" —— 这正是老版本测试2/3 的样子。 var failed int func check(ok bool, format string, args ...any) { if ok { fmt.Printf(" ✅ "+format+"\n", args...) return } failed++ fmt.Printf(" ❌ "+format+"\n", args...) } func main() { fmt.Println("==============================================") fmt.Println(" 锁表机制测试") fmt.Print("==============================================\n\n") os.Remove(dbPath) db, err := DB.Open(dbPath) if err != nil { panic(err) } tbl, err := DB.UseTable[Record](db, "test") if err != nil { panic(err) } testManualLock(tbl, db) testAutoCommitOnThreshold(tbl, db) testBufferPoolFull() testReopenDurability(tbl, db) } // testManualLock 钉住 README 注意事项 7 的语义: LockWrites 挡住的是【页落盘】, // 不是 Insert 本身 —— 锁定期插入被阻塞但不报错, 解锁后一条不少地进去。 // // 老版本这里断言的是"锁定期插入应当被拒绝并返回错误"。那是错的: 引擎里根本没有 // "写入被拒绝"这种错误码, BufferPool.WritePage 走的是阻塞等待 (waitForWriteLock)。 // 于是插入在解锁后正常返回 err == nil, 断言"应为错误"必然成立不了 —— demo 一直是红的, // 而 README 又写着"全部通过"。 func testManualLock(tbl *DB.Table[Record], db *DB.DB) { fmt.Println("测试1: 手动 LockWrites/UnlockWrites") before, err := tbl.Count() if err != nil { check(false, "前置查询行数: %v", err) return } db.LockWrites() fmt.Println(" ✅ 已锁定写入") type result struct { err error elapsed time.Duration } rec := &Record{Name: "Emp0000001"} done := make(chan result, 1) go func() { start := time.Now() insertErr := tbl.Insert(rec) done <- result{err: insertErr, elapsed: time.Since(start)} }() // 用"有没有结果"判断阻塞, 而不是用耗时阈值 —— 阈值只能证明"慢", 证明不了"被挡住"。 var res result blocked := true select { case res = <-done: blocked = false case <-time.After(100 * time.Millisecond): } check(blocked, "锁定期插入被阻塞在页落盘处") db.UnlockWrites() fmt.Println(" ✅ 已解锁写入") if blocked { // 被挡住的那一支还没收到结果, 等它跑完再验 —— 否则下面查的是"插入之前"的行数。 res = <-done } check(res.err == nil, "锁定期写入不报错 (err=%v, 解锁后 %v 完成)", res.err, res.elapsed) after, err := tbl.Count() check(err == nil && after == before+1, "解锁后真的落盘: %d → %d 行", before, after) if rec.ID != 0 { got, findErr := tbl.Find(rec.ID) check(findErr == nil && got != nil, "能按引擎分配到的 _id=%d 读回", rec.ID) } else { check(false, "插入后结构体没有回填 _id, 无法验证读回") } } // testAutoCommitOnThreshold 钉住真正的写入路径: 脏页阈值在 WritePage 里【同步】触发 // Commit (pool.WritePage 在锁外直接调 onThreshold), 也就是"写到一半顺手提交一次"。 // SetCommitPreset 的每一档都靠它, 200 行配 5 页阈值必然触发很多次。 // // 为什么不用"一边写一边在另一个 goroutine 里 Commit": 引擎是【单写入者模型】 // (见 pool/buffer_pool.go 里关于 capacityWait 死锁的注释), 写入与 Commit 必须由同一个 // 写入者先后调用。跨 goroutine 重叠会踩到一个真问题: CollectDirty 把脏页移出缓存之后、 // 回写磁盘之前, 这一页既不在缓存里也还不在盘上, 并发的读会从盘上读到全零页 —— // 实测稳定 panic (btPID: slice bounds out of range [-4:], 页头 EntryLen 读到 0)。 // 那条路要等引擎补上写入串行化才能测。 func testAutoCommitOnThreshold(tbl *DB.Table[Record], db *DB.DB) { fmt.Println("\n测试2: 写入过程中脏页阈值触发自动提交") db.SetAutoCommitDelay(0) // 关掉防抖计时器, 只留阈值触发, 保证提交是同步发生的 db.SetDirtyThreshold(5) // 5 个脏页提交一次, 下面的行数必然触发多次 before, err := tbl.Count() if err != nil { check(false, "前置查询行数: %v", err) return } const rows = 200 for i := 0; i < rows; i++ { if insertErr := tbl.Insert(&Record{Name: fmt.Sprintf("Auto%06d", i)}); insertErr != nil { check(false, "写入第 %d 行时失败: %v", i+1, insertErr) return } } after, err := tbl.Count() check(err == nil && after == before+rows, "穿插 %d 次阈值提交后一行不丢: %d → %d", rows/5, before, after) } // testBufferPoolFull 钉住 README 注意事项 7 的第三条: 池子满时淘汰干净页而不是死锁。 // // 老版本同样只看耗时 (> 50ms), 既证明不了"淘汰了干净页", 也证明不了"不死锁"—— 真死锁 // 了它只会永远挂住。这里改成两条硬断言: 写入必须在时限内跑完 (超时即失败, 把"挂住" // 变成"报错"), 并且淘汰之后每一行都还得读得回来 (淘汰逻辑不能吃掉数据)。 func testBufferPoolFull() { fmt.Println("\n测试3: BufferPool 满时淘汰干净页 (容量 10 页)") os.Remove(poolPath) db2, err := DB.Open(poolPath) if err != nil { check(false, "打开 %s: %v", poolPath, err) return } defer func() { db2.Close() os.Remove(poolPath) }() db2.SetBufferPoolCapacity(10) // 只允许 10 页 tbl2, err := DB.UseTable[Record](db2, "test") if err != nil { check(false, "建表: %v", err) return } const rows = 200 done := make(chan error, 1) go func() { for i := 1; i <= rows; i++ { if insertErr := tbl2.Insert(&Record{Name: fmt.Sprintf("Emp%07d", i)}); insertErr != nil { done <- insertErr return } } done <- nil }() // 容量 10 页远小于 200 行占的页数, 插入必然要边走边淘汰; 给它一个上限, // 拿不到结果就是"没能淘汰干净页把自己憋死了"。 select { case err := <-done: check(err == nil, "容量 10 页写入 %d 行无错误 (err=%v)", rows, err) case <-time.After(30 * time.Second): check(false, "写入超时未完成: 池子满时没有淘汰干净页 (疑似死锁)") return } n, err := tbl2.Count() check(err == nil && int(n) == rows, "淘汰后行数完整: 期望 %d, 实际 %d (err=%v)", rows, n, err) } // testReopenDurability 关掉再开: 上面的写入走的是 WAL 提交路径, 重开时要按 WAL 恢复 // 回来, 一行都不能少。顺带覆盖"Close 会兜底把脏页落盘"这条。 func testReopenDurability(tbl *DB.Table[Record], db *DB.DB) { fmt.Println("\n测试4: 关闭重开后数据仍在 (WAL 恢复)") before, err := tbl.Count() if err != nil { check(false, "关闭前查行数: %v", err) return } check(db.Close() == nil, "关闭数据库") reopened, err := DB.Open(dbPath) if err != nil { check(false, "重开 %s: %v", dbPath, err) return } defer func() { reopened.Close() os.Remove(dbPath) }() tbl2, err := DB.UseTable[Record](reopened, "test") if err != nil { check(false, "重开表: %v", err) return } after, err := tbl2.Count() check(err == nil && after == before, "重开后行数一致: %d → %d (err=%v)", before, after, err) }