main.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. // Hash 宽键 (字符串) 索引专项测试 — 验证分裂/合并阈值随桶页容量自适应
  2. //
  3. // 背景
  4. // ---------------------------------------------------------------------------
  5. // 分裂阈值原先固定为 400 条/桶。窄键时这没问题: int64 键的条目 16B, 单桶页可容
  6. // 1022 条, 400 条远未写满。但宽键的桶页容量会骤降 —— maxlen:64 的列条目 72B,
  7. // 单页仅容 227 条; 默认 string (256 字节) 只有 61 条。仍按固定 400 分裂, 平均每桶
  8. // 就要摊成 6~7 页溢出链, 等值查找退化成 O(链长), 与哈希索引的卖点相悖。
  9. // 现改为 min(400, bucketCap*3/4), 合并阈值 = 分裂阈值/4 (保持 400/100 的滞后比)。
  10. //
  11. // 本测试的判据
  12. // ---------------------------------------------------------------------------
  13. // 同一张表、同样的 3000 行, 放两个哈希索引, 只让键宽不同, 用「分裂进度」区分行为:
  14. //
  15. // idx_tag maxlen:64 → keyLen=64 → EntryLen=72 → 桶页容量 227 → 阈值 min(400,170)=170
  16. // → 16*170 = 2720 行开始分裂 → 3000 行时必然分裂 2 次 → 桶数 18
  17. // idx_grp int64 → keyLen=8 → EntryLen=16 → 桶页容量 1022 → 阈值 400 (上限生效)
  18. // → 16*400 = 6400 行才分裂 → 3000 行时分裂 0 次 → 桶数 16
  19. //
  20. // 若阈值被改回固定 400, idx_tag 的 splitPointer 会退成 0, 核心断言立即失败。
  21. //
  22. // 元数据读取: 演示程序在 core 包外, 拿不到 hash_handler.go 里的未导出偏移常量,
  23. // 因此这里按同一份布局直接读 .db 文件 (页 → 文件偏移: 4096 + (pid-1)*16384)。
  24. // 读之前必须先 db.Commit(): WAL 提交会把脏页 WritePageDirect 落盘并清空缓存。
  25. package main
  26. import (
  27. "git.ycl.pub/yl32940/TsDB2/DB"
  28. "git.ycl.pub/yl32940/TsDB2/DB/core"
  29. "git.ycl.pub/yl32940/TsDB2/DB/core/page"
  30. "git.ycl.pub/yl32940/TsDB2/DB/core/types"
  31. "encoding/binary"
  32. "fmt"
  33. "os"
  34. "strings"
  35. "time"
  36. )
  37. // WideRec 一张表里塞两个哈希索引, 只让键宽不同:
  38. // - Tag: maxlen:64 → FieldType_String (64B) → hash 索引键 64 字节
  39. // - Grp: int64 → FieldType_Uint64 (8B) → hash 索引键 8 字节
  40. type WideRec struct {
  41. DB.BaseModel
  42. Tag string `vrtx:"index:idx_tag,type:hash,maxlen:64"`
  43. Grp int64 `vrtx:"index:idx_grp,type:hash"`
  44. Pay int64
  45. }
  46. const (
  47. rowCount = 3000 // 插满这么多行后看分裂进度
  48. deleteRows = 2500 // 再删掉这么多行, 看桶合并收缩
  49. grpMod = 50 // Grp = i % 50 → 每组恰好 rowCount/grpMod 行
  50. )
  51. // ---------------------------------------------------------------------------
  52. // 哈希索引元数据页布局 (与 DB/core/index/hash_handler.go 的 hashMeta*Off 一致)
  53. //
  54. // [0,32) page.IndexHead
  55. // [32,36) magic
  56. // [36,40) level
  57. // [40,44) splitPointer
  58. // [44,48) totalEntries
  59. // [48,52) dirStartPID
  60. // [52,56) dirPageCount
  61. // [56,60) keyLen
  62. //
  63. // ---------------------------------------------------------------------------
  64. const (
  65. offMagic = page.IndexHeadSize + 0
  66. offLevel = page.IndexHeadSize + 4
  67. offSplit = page.IndexHeadSize + 8
  68. offTotal = page.IndexHeadSize + 12
  69. offDirStart = page.IndexHeadSize + 16
  70. offDirPages = page.IndexHeadSize + 20
  71. offKeyLen = page.IndexHeadSize + 24
  72. hashPageHdr = 28 // 桶页头: PageHdr(20) + BucketID(4) + Count(4), next 复用 PageHdr.NextPage
  73. hashMagicVal = uint32('T')<<24 | uint32('H')<<16 | uint32('H')<<8 | uint32('3') // "THH3"
  74. pkIDLen = 8
  75. )
  76. // hashMeta 哈希索引元数据页 (只解析测试要断言的字段)
  77. type hashMeta struct {
  78. Magic uint32
  79. Level uint32
  80. SplitPointer uint32
  81. TotalEntries uint32
  82. DirStartPID uint32
  83. DirPageCount uint32
  84. KeyLen uint32
  85. }
  86. // bucketCount 当前可用桶数 = 2^Level + SplitPointer (同 hashMetaData.bucketCount)
  87. func (m hashMeta) bucketCount() uint32 { return (uint32(1) << m.Level) + m.SplitPointer }
  88. // bucketCap 单桶页可容纳条目数 = (页大小 - 桶页头) / (keyLen + 8) (同 hashMetaData.bucketCap)
  89. func (m hashMeta) bucketCap() uint32 {
  90. entryLen := m.KeyLen + pkIDLen
  91. if entryLen == 0 {
  92. return 0
  93. }
  94. return (uint32(page.IndexPageSize) - hashPageHdr) / entryLen
  95. }
  96. // splitThreshold 复刻 hashSplitThreshold: min(400, bucketCap*3/4)
  97. func (m hashMeta) splitThreshold() uint32 {
  98. if t := m.bucketCap() * 3 / 4; t < 400 {
  99. return t
  100. }
  101. return 400
  102. }
  103. // mergeThreshold 复刻 hashMergeThreshold: 分裂阈值 / 4
  104. func (m hashMeta) mergeThreshold() uint32 {
  105. if t := m.splitThreshold() / 4; t >= 1 {
  106. return t
  107. }
  108. return 1
  109. }
  110. // readHashMeta 绕过缓冲池直接读 .db 文件, 解析哈希索引元数据页。
  111. // 页 → 文件偏移的换算见 DB/core/File.go: PageSize4K + (pageID-1)*len(buf)。
  112. func readHashMeta(path string, pid uint32) (hashMeta, error) {
  113. f, err := os.Open(path)
  114. if err != nil {
  115. return hashMeta{}, err
  116. }
  117. defer f.Close()
  118. buf := make([]byte, core.PageSize)
  119. offset := int64(core.PageSize4K) + int64(pid-1)*int64(core.PageSize)
  120. if _, err := f.ReadAt(buf, offset); err != nil {
  121. return hashMeta{}, fmt.Errorf("读页 %d (文件偏移 %d): %w", pid, offset, err)
  122. }
  123. m := hashMeta{
  124. Magic: binary.LittleEndian.Uint32(buf[offMagic:]),
  125. Level: binary.LittleEndian.Uint32(buf[offLevel:]),
  126. SplitPointer: binary.LittleEndian.Uint32(buf[offSplit:]),
  127. TotalEntries: binary.LittleEndian.Uint32(buf[offTotal:]),
  128. DirStartPID: binary.LittleEndian.Uint32(buf[offDirStart:]),
  129. DirPageCount: binary.LittleEndian.Uint32(buf[offDirPages:]),
  130. KeyLen: binary.LittleEndian.Uint32(buf[offKeyLen:]),
  131. }
  132. if m.Magic != hashMagicVal {
  133. return m, fmt.Errorf("页 %d 的 magic=%#x (期望 %#x): 该页不是哈希元数据页, "+
  134. "说明页偏移换算或布局假设有误", pid, m.Magic, hashMagicVal)
  135. }
  136. return m, nil
  137. }
  138. // entryPageOf 从索引元信息取哈希索引的元数据页 ID。
  139. // 注意: CreateIndex 时 EntryPage 为 0, 首次 Insert 才分配, 因此必须在插入之后调用。
  140. func entryPageOf(tbl *core.Table, idxName string) (uint32, error) {
  141. m, ok := tbl.IndexMeta().FindIndex(idxName)
  142. if !ok {
  143. return 0, fmt.Errorf("索引 %q 不存在", idxName)
  144. }
  145. if m.EntryPage == 0 {
  146. return 0, fmt.Errorf("索引 %q 的 EntryPage 仍为 0 (需先插入至少一行触发初始化)", idxName)
  147. }
  148. return m.EntryPage, nil
  149. }
  150. // tagOf 从回表结果里取 Tag 字段并去掉定长编码的尾部 0 填充。
  151. func tagOf(vals []types.FieldValue) string {
  152. for _, f := range vals {
  153. if string(f.FieldName) != "Tag" {
  154. continue
  155. }
  156. if b, ok := f.Value.([]byte); ok {
  157. return strings.TrimRight(string(b), "\x00")
  158. }
  159. }
  160. return ""
  161. }
  162. // ---------------------------------------------------------------------------
  163. // 测试脚手架
  164. // ---------------------------------------------------------------------------
  165. var (
  166. dbPath string
  167. db *DB.DB
  168. passed, failed int
  169. )
  170. func check(msg string, ok bool, detail ...string) {
  171. if ok {
  172. passed++
  173. fmt.Printf(" [PASS] %s\n", msg)
  174. return
  175. }
  176. failed++
  177. d := ""
  178. if len(detail) > 0 {
  179. d = " — " + detail[0]
  180. }
  181. fmt.Printf(" [FAIL] %s%s\n", msg, d)
  182. }
  183. func section(t string) { fmt.Printf("\n========== %s\n", t) }
  184. func summary() {
  185. section("测试总结")
  186. fmt.Printf(" 总计 %d 项, 通过 %d, 失败 %d\n", passed+failed, passed, failed)
  187. if failed == 0 {
  188. fmt.Println(" [OK] 宽键哈希索引工作正常, 分裂阈值确实随桶页容量收敛")
  189. } else {
  190. fmt.Println(" [FAIL] 存在失败项, 见上")
  191. }
  192. }
  193. func cleanup() {
  194. if db != nil {
  195. _ = db.Close()
  196. db = nil
  197. }
  198. // [KEEP-DB] 保留数据库文件供 Analyzer 分析, 屏蔽运行结束后的清理删除
  199. // _ = os.Remove(dbPath)
  200. // _ = os.Remove(dbPath + ".wal")
  201. }
  202. // fatal 前置条件不成立时立即收尾退出 —— 后续断言建立在错误前提上没有意义。
  203. func fatal(msg string, err error) {
  204. failed++
  205. fmt.Printf(" [FAIL] %s: %v\n", msg, err)
  206. summary()
  207. cleanup()
  208. os.Exit(1)
  209. }
  210. func main() {
  211. dbPath = fmt.Sprintf("hash_widekey_%d.db", time.Now().UnixNano())
  212. _ = os.Remove(dbPath)
  213. _ = os.Remove(dbPath + ".wal")
  214. fmt.Println("============================================================")
  215. fmt.Println(" Hash 宽键索引测试 — 分裂阈值随桶页容量自适应")
  216. fmt.Println("============================================================")
  217. d, err := DB.Open(dbPath)
  218. if err != nil {
  219. fatal("打开数据库", err)
  220. }
  221. db = d
  222. db.SetAutoCommitDelay(0)
  223. tbl, err := DB.UseTable[WideRec](db, "wide")
  224. if err != nil {
  225. fatal("UseTable[WideRec]", err)
  226. }
  227. coreTbl, ok := db.CoreDB().FindTable("wide")
  228. if !ok {
  229. fatal("FindTable(wide)", fmt.Errorf("表不存在"))
  230. }
  231. // ============================================================
  232. section(fmt.Sprintf("1. 插入 %d 行 (同表两个哈希索引, 仅键宽不同)", rowCount))
  233. t0 := time.Now()
  234. for i := 1; i <= rowCount; i++ {
  235. if err := tbl.Insert(&WideRec{
  236. Tag: fmt.Sprintf("TAG%05d", i),
  237. Grp: int64(i % grpMod),
  238. Pay: int64(i),
  239. }); err != nil {
  240. check(fmt.Sprintf("Insert 第 %d 行", i), false, err.Error())
  241. break
  242. }
  243. }
  244. if err := db.Commit(); err != nil {
  245. fatal("Commit", err)
  246. }
  247. cnt, _ := tbl.Count()
  248. check(fmt.Sprintf("插入 %d 行, Count=%d, 耗时 %.2fs", rowCount, cnt, time.Since(t0).Seconds()),
  249. cnt == int64(rowCount))
  250. // ============================================================
  251. section("2. 分裂进度: 宽键已分裂, 窄键未分裂")
  252. tagPID, err := entryPageOf(coreTbl, "idx_tag")
  253. if err != nil {
  254. fatal("取 idx_tag EntryPage", err)
  255. }
  256. grpPID, err := entryPageOf(coreTbl, "idx_grp")
  257. if err != nil {
  258. fatal("取 idx_grp EntryPage", err)
  259. }
  260. tagMeta, err := readHashMeta(dbPath, tagPID)
  261. if err != nil {
  262. fatal("读 idx_tag 元数据页", err)
  263. }
  264. grpMeta, err := readHashMeta(dbPath, grpPID)
  265. if err != nil {
  266. fatal("读 idx_grp 元数据页", err)
  267. }
  268. fmt.Printf(" [info] idx_tag EntryPage=%d level=%d split=%d total=%d keyLen=%d 目录页=%d\n",
  269. tagPID, tagMeta.Level, tagMeta.SplitPointer, tagMeta.TotalEntries, tagMeta.KeyLen, tagMeta.DirPageCount)
  270. fmt.Printf(" [info] idx_grp EntryPage=%d level=%d split=%d total=%d keyLen=%d 目录页=%d\n",
  271. grpPID, grpMeta.Level, grpMeta.SplitPointer, grpMeta.TotalEntries, grpMeta.KeyLen, grpMeta.DirPageCount)
  272. // --- 键宽/容量必须与理论推导一致, 否则后面的阈值推论不成立 ---
  273. check(fmt.Sprintf("idx_tag keyLen=%d → EntryLen=%d → 桶页容量=%d (期望 64/72/227)",
  274. tagMeta.KeyLen, tagMeta.KeyLen+pkIDLen, tagMeta.bucketCap()),
  275. tagMeta.KeyLen == 64 && tagMeta.bucketCap() == 227)
  276. check(fmt.Sprintf("idx_grp keyLen=%d → EntryLen=%d → 桶页容量=%d (期望 8/16/1022)",
  277. grpMeta.KeyLen, grpMeta.KeyLen+pkIDLen, grpMeta.bucketCap()),
  278. grpMeta.KeyLen == 8 && grpMeta.bucketCap() == 1022)
  279. check(fmt.Sprintf("idx_tag 分裂阈值=%d (min(400, 227*3/4)=170), 合并阈值=%d",
  280. tagMeta.splitThreshold(), tagMeta.mergeThreshold()),
  281. tagMeta.splitThreshold() == 170 && tagMeta.mergeThreshold() == 42)
  282. check(fmt.Sprintf("idx_grp 分裂阈值=%d (1022*3/4=766 > 400, 上限生效), 合并阈值=%d",
  283. grpMeta.splitThreshold(), grpMeta.mergeThreshold()),
  284. grpMeta.splitThreshold() == 400 && grpMeta.mergeThreshold() == 100)
  285. // --- 核心断言: 同样的行数, 宽键分裂、窄键不分裂 ---
  286. // 宽键: 桶数从 16 起, 阈值 170 → 第 2721 行触发第 1 次分裂 (桶→17, 阈值 2890),
  287. // 第 2891 行触发第 2 次 (桶→18, 阈值 3060), 之后到 3000 行不再分裂。
  288. check(fmt.Sprintf("idx_tag %d 行 → level=%d splitPointer=%d 桶数=%d (期望 4/2/18)",
  289. rowCount, tagMeta.Level, tagMeta.SplitPointer, tagMeta.bucketCount()),
  290. tagMeta.Level == 4 && tagMeta.SplitPointer == 2 && tagMeta.bucketCount() == 18)
  291. // 窄键: 16*400 = 6400 行才分裂, 3000 行远未到, 桶数仍是下限 16。
  292. check(fmt.Sprintf("idx_grp %d 行 → level=%d splitPointer=%d 桶数=%d (期望 4/0/16)",
  293. rowCount, grpMeta.Level, grpMeta.SplitPointer, grpMeta.bucketCount()),
  294. grpMeta.Level == 4 && grpMeta.SplitPointer == 0 && grpMeta.bucketCount() == 16)
  295. check(fmt.Sprintf("两个索引活跃条目数均为 %d", rowCount),
  296. tagMeta.TotalEntries == rowCount && grpMeta.TotalEntries == rowCount)
  297. // 桶目录: 18 个槽位远小于单页 4091 槽, 目录必须还在一页内
  298. check(fmt.Sprintf("idx_tag 桶目录页数=%d (18 个槽位应仍在一页内)", tagMeta.DirPageCount),
  299. tagMeta.DirPageCount == 1)
  300. fmt.Printf("\n [说明] 若阈值仍是固定 400: idx_tag 要 16*400=6400 行才分裂,\n" +
  301. " 本次 3000 行时 splitPointer 会是 0 —— 上面那条断言即为回归护栏\n")
  302. // ============================================================
  303. section("3. 宽键哈希索引正确性 (全量点查 / 分组计数 / 范围扫描)")
  304. miss := 0
  305. for i := 1; i <= rowCount; i++ {
  306. want := fmt.Sprintf("TAG%05d", i)
  307. key, err := coreTbl.BuildIndexKey("idx_tag", []byte(want))
  308. if err != nil {
  309. check("BuildIndexKey", false, err.Error())
  310. break
  311. }
  312. vals, err := coreTbl.SearchByIndex("idx_tag", key)
  313. if err != nil || len(vals) == 0 || tagOf(vals) != want {
  314. miss++
  315. }
  316. }
  317. check(fmt.Sprintf("%d 个 64B 键全部点查命中且内容一致 (丢失 %d)", rowCount, miss), miss == 0)
  318. perGrp := int64(rowCount / grpMod)
  319. grpBad := 0
  320. for g := int64(0); g < grpMod; g++ {
  321. n, err := tbl.Where("Grp", "=", g).Count()
  322. if err != nil || n != perGrp {
  323. grpBad++
  324. }
  325. }
  326. check(fmt.Sprintf("%d 个 Grp 分组的计数全部 = %d", grpMod, perGrp), grpBad == 0)
  327. rangeN, err := tbl.Where("Grp", ">=", int64(10)).Where("Grp", "<=", int64(19)).Count()
  328. check(fmt.Sprintf("Grp 范围 [10,19] Count=%d (期望 %d; 哈希不保序, 靠全桶扫描+键过滤)",
  329. rangeN, 10*perGrp), err == nil && rangeN == 10*perGrp)
  330. // ============================================================
  331. section(fmt.Sprintf("4. 删除 %d 行 → 桶合并收缩", deleteRows))
  332. delFail := 0
  333. for i := uint64(1); i <= uint64(deleteRows); i++ {
  334. if err := tbl.Delete(i); err != nil {
  335. delFail++
  336. }
  337. }
  338. if err := db.Commit(); err != nil {
  339. fatal("Commit", err)
  340. }
  341. left, _ := tbl.Count()
  342. check(fmt.Sprintf("删除 %d 行 (失败 %d), 剩余 Count=%d", deleteRows, delFail, left),
  343. delFail == 0 && left == int64(rowCount-deleteRows))
  344. tagMeta2, err := readHashMeta(dbPath, tagPID)
  345. if err != nil {
  346. fatal("删除后读 idx_tag 元数据页", err)
  347. }
  348. fmt.Printf(" [info] 删除后 idx_tag level=%d split=%d total=%d 桶数=%d\n",
  349. tagMeta2.Level, tagMeta2.SplitPointer, tagMeta2.TotalEntries, tagMeta2.bucketCount())
  350. // 合并阈值 42: 桶数 18 时 T<756 合并一次(→17), 17 时 T<714 再合并一次(→16),
  351. // 此后桶数已到下限 hashMinBucketCount=16, 不再合并。最终 500 行 / 16 桶。
  352. check(fmt.Sprintf("删除后 idx_tag total=%d splitPointer=%d 桶数=%d (期望 %d/0/16)",
  353. tagMeta2.TotalEntries, tagMeta2.SplitPointer, tagMeta2.bucketCount(), rowCount-deleteRows),
  354. tagMeta2.TotalEntries == rowCount-deleteRows &&
  355. tagMeta2.SplitPointer == 0 && tagMeta2.bucketCount() == 16)
  356. // ============================================================
  357. section("5. 关闭重开 (持久化)")
  358. if err := db.Close(); err != nil {
  359. fatal("Close", err)
  360. }
  361. db = nil
  362. // 关库后读盘最确定: 此时不可能有未落盘的脏页
  363. fileMeta, err := readHashMeta(dbPath, tagPID)
  364. if err != nil {
  365. fatal("关库后读 idx_tag 元数据页", err)
  366. }
  367. check(fmt.Sprintf("关库后磁盘上 idx_tag splitPointer=%d total=%d, 与关库前一致",
  368. fileMeta.SplitPointer, fileMeta.TotalEntries),
  369. fileMeta.SplitPointer == tagMeta2.SplitPointer &&
  370. fileMeta.TotalEntries == tagMeta2.TotalEntries)
  371. db2, err := DB.Open(dbPath)
  372. if err != nil {
  373. fatal("重开数据库", err)
  374. }
  375. db = db2
  376. db.SetAutoCommitDelay(0)
  377. tbl2, err := DB.UseTable[WideRec](db, "wide")
  378. if err != nil {
  379. fatal("重开 UseTable[WideRec]", err)
  380. }
  381. coreTbl2, ok := db.CoreDB().FindTable("wide")
  382. if !ok {
  383. fatal("重开 FindTable(wide)", fmt.Errorf("表不存在"))
  384. }
  385. pid2, err := entryPageOf(coreTbl2, "idx_tag")
  386. if err != nil {
  387. fatal("重开取 idx_tag EntryPage", err)
  388. }
  389. check(fmt.Sprintf("重开后 idx_tag EntryPage=%d (关闭前 %d)", pid2, tagPID), pid2 == tagPID)
  390. reopMeta, err := readHashMeta(dbPath, pid2)
  391. if err != nil {
  392. fatal("重开后读 idx_tag 元数据页", err)
  393. }
  394. check(fmt.Sprintf("重开后 level=%d splitPointer=%d total=%d 与关闭前一致",
  395. reopMeta.Level, reopMeta.SplitPointer, reopMeta.TotalEntries),
  396. reopMeta.Level == tagMeta2.Level &&
  397. reopMeta.SplitPointer == tagMeta2.SplitPointer &&
  398. reopMeta.TotalEntries == tagMeta2.TotalEntries)
  399. cnt2, _ := tbl2.Count()
  400. check(fmt.Sprintf("重开后 Count=%d (期望 %d)", cnt2, rowCount-deleteRows),
  401. cnt2 == int64(rowCount-deleteRows))
  402. // 重开后仍要能按宽键点查 (存活的行是 PK 2501..3000)
  403. reopenMiss := 0
  404. for i := deleteRows + 1; i <= rowCount; i++ {
  405. want := fmt.Sprintf("TAG%05d", i)
  406. key, err := coreTbl2.BuildIndexKey("idx_tag", []byte(want))
  407. if err != nil {
  408. reopenMiss++
  409. continue
  410. }
  411. vals, err := coreTbl2.SearchByIndex("idx_tag", key)
  412. if err != nil || tagOf(vals) != want {
  413. reopenMiss++
  414. }
  415. }
  416. check(fmt.Sprintf("重开后 %d 个存活行的宽键点查全部命中 (丢失 %d)", rowCount-deleteRows, reopenMiss),
  417. reopenMiss == 0)
  418. // 已删的行不该还能查到
  419. goneKey, _ := coreTbl2.BuildIndexKey("idx_tag", []byte("TAG00001"))
  420. _, goneErr := coreTbl2.SearchByIndex("idx_tag", goneKey)
  421. check("重开后已删除的 TAG00001 查不到", goneErr != nil)
  422. // ============================================================
  423. summary()
  424. cleanup()
  425. if failed > 0 {
  426. os.Exit(1)
  427. }
  428. }