wal_hardening_test.go 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. package core
  2. import (
  3. "encoding/binary"
  4. "errors"
  5. "hash/crc32"
  6. "path/filepath"
  7. "strings"
  8. "testing"
  9. "git.ycl.pub/yl32940/TsDB2/DB/core/page"
  10. "git.ycl.pub/yl32940/TsDB2/DB/core/pool"
  11. )
  12. // =============================================================================
  13. // WAL/恢复路径加固测试
  14. //
  15. // 构造方式: 先正常建库并关闭, 再用裸 File 句柄直接改写头页 / 伪造 WAL 页,
  16. // 模拟"损坏现场"后重新 Open —— 断言必须返回错误而不是 panic / 静默吞掉。
  17. // =============================================================================
  18. // walHardeningDB 建一个最小合法库并正常关闭, 然后以裸文件句柄返回,
  19. // 供测试绕过引擎的 WAL 写入逻辑、直接伪造损坏的 WAL 链。
  20. func walHardeningDB(t *testing.T) (string, *File) {
  21. t.Helper()
  22. path := filepath.Join(t.TempDir(), "wal_hardening.db")
  23. db, err := Open(path)
  24. if err != nil {
  25. t.Fatalf("建库: %v", err)
  26. }
  27. db.SetAutoCommitDelay(0)
  28. if err := db.Close(); err != nil {
  29. t.Fatalf("关闭: %v", err)
  30. }
  31. ff, err := OpenFile(path)
  32. if err != nil {
  33. t.Fatalf("重开裸文件: %v", err)
  34. }
  35. return path, ff
  36. }
  37. // encodeWALEntry 按写入端格式 (见 wal.go writeWALPages) 编码一条 WAL 条目:
  38. // [TargetPageID(4B)][DataLen(4B)][Checksum(4B)][OpType(1B)][Data]。
  39. // dataLenOverride 非 0 时改写 DataLen 字段 (模拟损坏), Checksum 仍按页内
  40. // 实际字节计算 —— 让越界检查成为唯一防线, 而不是靠 CRC 失败兜住。
  41. func encodeWALEntry(targetPID uint32, opType byte, payload []byte, dataLenOverride uint32) []byte {
  42. e := make([]byte, page.WALEntryMeta+len(payload))
  43. binary.LittleEndian.PutUint32(e[0:], targetPID)
  44. dataLen := uint32(len(payload))
  45. if dataLenOverride != 0 {
  46. dataLen = dataLenOverride
  47. }
  48. binary.LittleEndian.PutUint32(e[4:], dataLen)
  49. cs := crc32.NewIEEE()
  50. _, _ = cs.Write([]byte{opType})
  51. _, _ = cs.Write(payload)
  52. binary.LittleEndian.PutUint32(e[8:], cs.Sum32())
  53. e[12] = opType
  54. copy(e[page.WALEntryMeta:], payload)
  55. return e
  56. }
  57. // buildWALFirstPage 构造一个已提交的 WAL 首页 (State=Committed), 条目区塞入
  58. // 给定的编码后条目, 链指针指向 nextPID。
  59. func buildWALFirstPage(t *testing.T, pid, nextPID uint32, totalEntries uint32, entries ...[]byte) []byte {
  60. t.Helper()
  61. buf := make([]byte, PageSize)
  62. wh := page.WalHdrOf(buf)
  63. wh.Init(pid, 0)
  64. wh.TxnID = 99
  65. wh.TotalEntries = totalEntries
  66. wh.State = WALStateCommitted
  67. wh.EntryCount = uint16(len(entries))
  68. wh.NextPage = nextPID
  69. off := page.WALPageHdrSize
  70. for _, e := range entries {
  71. copy(buf[off:], e)
  72. off += uint32(len(e))
  73. }
  74. return buf
  75. }
  76. // publishWALChain 把伪造的 WAL 首页写盘, 并把头页链指针指过去。
  77. // 页号取头页 NextFreePage —— 空闲链/游标上的页, 不与已活的页 (头页/SchemaExt/
  78. // TablePage) 相撞; 已提交链出错返回时该页不会被回收, 不影响后续断言。
  79. func publishWALChain(t *testing.T, ff *File, firstBuf []byte) uint32 {
  80. t.Helper()
  81. hdr, err := ff.ReadHeader()
  82. if err != nil {
  83. t.Fatalf("读头页: %v", err)
  84. }
  85. pid := hdr.NextFreePage
  86. if err := ff.WritePageDirect(pid, firstBuf); err != nil {
  87. t.Fatalf("写 WAL 首页 %d: %v", pid, err)
  88. }
  89. hdr.FirstWALPage = pid
  90. hdr.LastWALPage = pid
  91. if err := ff.WriteHeader(hdr); err != nil {
  92. t.Fatalf("写头页: %v", err)
  93. }
  94. return pid
  95. }
  96. // nextFreePage 读头页取 NextFreePage (空闲页分配游标) —— 伪造 WAL 页从这里取号,
  97. // 不与已活的页 (头页 / SchemaExt / TablePage) 相撞。
  98. func nextFreePage(t *testing.T, ff *File) uint32 {
  99. t.Helper()
  100. hdr, err := ff.ReadHeader()
  101. if err != nil {
  102. t.Fatalf("读头页: %v", err)
  103. }
  104. return hdr.NextFreePage
  105. }
  106. // TestRecoverWALRejectsOversizedDataLen WAL 条目 dataLen=0xFFFFFFFF 时, 旧实现的
  107. // uint32 越界检查被加法回绕绕过 (40+13+0xFFFFFFFF 回绕成 52 < PageSize), 下一行
  108. // 切片直接 panic。修复后必须返回错误; 已提交链原样保留, 拒绝部分重放 / 回收。
  109. func TestRecoverWALRejectsOversizedDataLen(t *testing.T) {
  110. path, ff := walHardeningDB(t)
  111. walPID := nextFreePage(t, ff)
  112. entry := encodeWALEntry(5, WALOpFullPage, []byte("partial-page-image"), 0xFFFFFFFF)
  113. first := buildWALFirstPage(t, walPID, 0, 1, entry)
  114. publishWALChain(t, ff, first)
  115. _ = ff.Close()
  116. db, err := Open(path)
  117. if err == nil {
  118. _ = db.Close()
  119. t.Fatalf("dataLen=0xFFFFFFFF 的已提交 WAL 链必须让 Open 返回错误, 却成功了")
  120. }
  121. t.Logf("正确拒绝: %v", err)
  122. // 保守处理: 错误返回后 WAL 链必须原样保留 (不回收), 重开仍报错
  123. ff2, err := OpenFile(path)
  124. if err != nil {
  125. t.Fatalf("重开裸文件: %v", err)
  126. }
  127. hdr, err := ff2.ReadHeader()
  128. _ = ff2.Close()
  129. if err != nil {
  130. t.Fatalf("读头页: %v", err)
  131. }
  132. if hdr.FirstWALPage != walPID {
  133. t.Fatalf("WAL 链应原样保留: FirstWALPage=%d, 想要 %d", hdr.FirstWALPage, walPID)
  134. }
  135. }
  136. // TestRecoverWALCommittedChainReadFailure 已提交 WAL 链中途页读失败 (链指针指向
  137. // 文件之外的页号) 必须返回错误 —— 旧实现只 break, 残缺事务被当完整事务重放。
  138. func TestRecoverWALCommittedChainReadFailure(t *testing.T) {
  139. path, ff := walHardeningDB(t)
  140. walPID := nextFreePage(t, ff)
  141. entry := encodeWALEntry(5, WALOpFullPage, []byte("page-five-image"), 0)
  142. // 首页声称有 2 条 / 还有下一页 0x100000, 但文件远没长到那里 —— 读下一页必失败
  143. first := buildWALFirstPage(t, walPID, 0x100000, 2, entry)
  144. publishWALChain(t, ff, first)
  145. _ = ff.Close()
  146. db, err := Open(path)
  147. if err == nil {
  148. _ = db.Close()
  149. t.Fatalf("已提交链中途读失败必须让 Open 返回错误, 却成功了")
  150. }
  151. t.Logf("正确拒绝: %v", err)
  152. }
  153. // TestRecoverWALCommittedChainBadPageType 已提交 WAL 链中途混入非 WAL 页
  154. // (页类型非法) 必须返回错误 —— 与读失败同属"链中途断裂", 不能静默部分重放。
  155. func TestRecoverWALCommittedChainBadPageType(t *testing.T) {
  156. path, ff := walHardeningDB(t)
  157. walPID := nextFreePage(t, ff)
  158. badPID := walPID + 1
  159. entry := encodeWALEntry(5, WALOpFullPage, []byte("page-five-image"), 0)
  160. first := buildWALFirstPage(t, walPID, badPID, 2, entry)
  161. publishWALChain(t, ff, first)
  162. // 链的第二页: 全零页 (Type=0, 不是 WALIndex=7)
  163. if err := ff.WritePageDirect(badPID, make([]byte, PageSize)); err != nil {
  164. t.Fatalf("写坏页: %v", err)
  165. }
  166. _ = ff.Close()
  167. db, err := Open(path)
  168. if err == nil {
  169. _ = db.Close()
  170. t.Fatalf("已提交链中途页类型非法必须让 Open 返回错误, 却成功了")
  171. }
  172. t.Logf("正确拒绝: %v", err)
  173. }
  174. // TestFlushWriteFailureKeepsPagesDirty Flush 写盘失败: 失败页必须保持 dirty、
  175. // 成功的页转干净, 且第一个错误要返回 —— 旧实现 _ = 吞掉错误还无条件全标干净,
  176. // 写失败的页从此被当"已持久化", 淘汰即静默丢数据。
  177. func TestFlushWriteFailureKeepsPagesDirty(t *testing.T) {
  178. writeErr := errors.New("disk full")
  179. fail := true
  180. bp := pool.NewBufferPool(8,
  181. func(uint32, []byte) error { return nil },
  182. func(pageID uint32, _ []byte) error {
  183. if fail && pageID == 2 {
  184. return writeErr
  185. }
  186. return nil
  187. })
  188. _ = bp.WritePage(1, []byte{0x11})
  189. _ = bp.WritePage(2, []byte{0x22})
  190. if err := bp.Flush(); !errors.Is(err, writeErr) {
  191. t.Fatalf("Flush 必须返回第一个写盘错误, 实际 %v", err)
  192. }
  193. if bp.DirtyCount() != 1 {
  194. t.Fatalf("失败页必须保持 dirty, 期望脏页 1, 实际 %d", bp.DirtyCount())
  195. }
  196. // 磁盘恢复后重试: 之前失败的页这次必须落盘并转干净
  197. fail = false
  198. if err := bp.Flush(); err != nil {
  199. t.Fatalf("恢复后重试 Flush: %v", err)
  200. }
  201. if bp.DirtyCount() != 0 {
  202. t.Fatalf("重试成功后不应再有脏页, 实际 %d", bp.DirtyCount())
  203. }
  204. }
  205. // TestBeginTransactionRejectsNestedTxn 已有活跃事务时再 BeginTransaction 必须
  206. // panic: 旧实现直接覆盖 currentTxn, 前一笔事务的 Rollback 会丢弃错误的页集合。
  207. func TestBeginTransactionRejectsNestedTxn(t *testing.T) {
  208. db := fixOpenDB(t)
  209. tx := db.BeginTransaction()
  210. func() {
  211. defer func() {
  212. r := recover()
  213. if r == nil {
  214. t.Fatalf("嵌套 BeginTransaction 必须 panic, 却没有")
  215. }
  216. msg, ok := r.(string)
  217. if !ok || !strings.Contains(msg, "活跃事务") {
  218. t.Fatalf("panic 信息应说明已有活跃事务, 实际 %v", r)
  219. }
  220. t.Logf("正确拒绝: %v", r)
  221. }()
  222. _ = db.BeginTransaction() // 必须 panic
  223. }()
  224. if err := tx.Rollback(); err != nil {
  225. t.Fatalf("rollback: %v", err)
  226. }
  227. }