package core import ( "testing" "TsDB2/DB/core/page" "TsDB2/DB/core/types" ) // ============================================================================ // 端到端页回收回归: DROP INDEX 必须把整棵二级索引树还给空闲链。 // // 缺陷 (千万行审计发现): btree.CollectAllPageIDs 下探内部节点时统一按主键布局 // 读「偏移 8..12」的子页号, 而二级 B+ 树的子页号在条目**末 4 字节**。结果 BFS 在 // 二级树上永远读到 0, 整棵子树只回收了根页, 其余成为永久孤儿页。 // // 这条缺陷同时污染三个调用方 (它们共用 CollectAllPageIDs): // - Deleter.releaseIndexPages → DROP INDEX 与批量删除后的旧树重建; // - TablePage.DropTable → DROP TABLE 回收 B+ 树索引页。 // // 观测的是 header.NextFreePage (与 CollectAllPageIDs 无关的独立量), 不是函数自己的 // 返回值 —— 否则「漏收」与「漏归还」会互相印证, 用例反而通过。 // // 判据: 反复 DropIndex + CreateIndex。旧树页若被完整归还, 下一轮重建会复用它们, // 文件不再增长; 只还根页时, 每轮都要新分配整棵树 → NextFreePage 逐轮上涨 // (正是 bench_10m 里「文件只涨不缩」的直接来源)。 // ============================================================================ func TestDropIndexReclaimsWholeSecondaryTree(t *testing.T) { const ( rows = 12000 // 二级叶容量 ≈ 1022 条 (entryLen=16) → 12 叶 + 1 根 rounds = 8 ) db := fixOpenDB(t) tbl, err := db.CreateTable("LEAK", []types.FieldInfo{ {FieldType: types.FieldType_Uint64, FieldName: []byte("ID")}, {FieldType: types.FieldType_Uint64, FieldName: []byte("Tag")}, {FieldType: types.FieldType_Uint64, FieldName: []byte("Score")}, }) if err != nil { t.Fatalf("create table: %v", err) } if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil { t.Fatalf("create pk: %v", err) } for i := uint64(1); i <= rows; i++ { if _, err := tbl.InsertRow([]types.FieldValue{ {FieldName: []byte("ID"), Value: i}, {FieldName: []byte("Tag"), Value: i % 97}, {FieldName: []byte("Score"), Value: i * 13}, }); err != nil { t.Fatalf("insert %d: %v", i, err) } } // sec 覆盖 Tag 列, 建索引时回填存量行 → 立刻是一棵 12 叶 + 1 根的多级二级树。 if _, err := tbl.CreateIndex("sec", page.IndexTypeBTree, []byte{1}); err != nil { t.Fatalf("create sec: %v", err) } if err := db.Commit(); err != nil { t.Fatalf("commit: %v", err) } var base uint32 for r := 1; r <= rounds; r++ { start := db.header.NextFreePage if err := tbl.DropIndex("sec"); err != nil { t.Fatalf("第 %d 轮 DropIndex: %v", r, err) } if _, err := tbl.CreateIndex("sec", page.IndexTypeBTree, []byte{1}); err != nil { t.Fatalf("第 %d 轮 CreateIndex: %v", r, err) } if err := db.Commit(); err != nil { t.Fatalf("第 %d 轮 Commit: %v", r, err) } if r == 1 { base = db.header.NextFreePage } growth := db.header.NextFreePage - base t.Logf("第 %d 轮 Drop/Create: NextFreePage=%d (本轮 +%d, 较第 1 轮 +%d)", r, db.header.NextFreePage, db.header.NextFreePage-start, growth) // 修复前: 每轮漏收 12 叶页 → 稳态每轮至少 +12 (旧树只还了根页)。 // 修复后: 旧树在上一轮 Commit 已还链, 本轮重建全部复用 → 只剩 commit 的常数抖动。 // 阈值取 3/轮 (远小于 12), 前几轮的空闲链余量可能暂时吸收少量泄漏, 故看累计值。 if r > 1 && growth > uint32(3*(r-1)) { t.Fatalf("二级索引 Drop/Create 第 %d 轮后 NextFreePage 累计多涨 %d 页: "+ "旧树页未被完整归还 (空闲链收不到, 文件只涨不缩)", r, growth) } } }