Selaa lähdekoodia

fix(core): 随机断电压测抓获两个崩溃恢复丢数据 bug — PageRest 补尾 + 恢复/载序

bug#1 (wal_crash_chaos 压测实锤): WAL 整页镜像只存 useSize 有效前缀, 而
DataPage 的槽位目录在页尾 (useSize 之外) —— 恢复零填充把目录抹掉, 页上全部
行经槽位不可达 (TotalRows 与可达行数分叉)。Heap/Blob 满页同理被'单条容量上限'
(16KB-40-13) 截掉真实尾数据。
修复: packEntries 把截断页展开成 FullPage + WALOpPageRest 成对记录
(Data=[覆盖偏移 u32][页尾原文], 偏移显式入链); 恢复端校验'同页前置镜像存在 +
偏移不越界', 读改写覆盖页尾; 孤儿补尾按拒绝部分重放返回错误并保留 WAL。
新增回归: packEntries 展开 (目录保留/容量截断/B+节点跳过/头页跳过)、
重放逐字节往返、孤儿补尾拒绝。

bug#2 (压测顺藤摸出): Open 先 tablePage.Init 后 RecoverWAL —— 恢复重放的
表信息页 (TotalRows/堆链指针/RowSize) 只到磁盘, 内存 Table 对象持恢复前旧
元数据, 内存与磁盘分叉 (压测现场: 盘 56 / 内存 49)。
修复: Open 调整为 RecoverWAL → tablePage.Init, 元数据载入永远基于恢复后
的最新磁盘状态; 抢救模式 (跳过恢复) 行为不变。
新增回归: TestRecoverWALRefreshesTableMeta。
yl32940 3 viikkoa sitten
vanhempi
sitoutus
e2d21ab99a

+ 8 - 2
DB/core/db.go

@@ -223,8 +223,6 @@ func OpenWithOptions(path string, opt OpenOptions) (*DB, error) {
 		tablePage.InitHeader(header.TablePage)
 		saveHeader = true
 	}
-
-	tablePage.Init(header.TablePage)
 	if saveHeader {
 		err = ff.WriteHeader(header)
 		if err != nil {
@@ -237,6 +235,11 @@ func OpenWithOptions(path string, opt OpenOptions) (*DB, error) {
 	// ==========================
 	// WAL 恢复会写盘 —— 抢救模式 (只读) 下跳过: 待恢复事务保持挂起, 数据页
 	// 仍是盘上原状, 读路径不受影响 (这正是抢救模式要的效果)。
+	//
+	// 必须在 tablePage.Init 之前: 恢复会把已提交事务的表信息页镜像 (TotalRows /
+	// 堆链指针 / RowSize 等) 重放回磁盘, 而 Init 会把这些元数据【载入内存 Table
+	// 对象】。先 Init 再恢复, 内存里就是旧元数据: 崩溃恢复后 TotalRows 对不上、
+	// 堆链指针过期, 后续写入按过期结构分配 = 损坏 (wal_crash_chaos 压测实锤)。
 	if !ff.readOnly {
 		if err := RecoverWAL(ff, header); err != nil {
 			_ = ff.f.Close() // 恢复失败必须释放文件句柄, 否则 Windows 上文件被占用
@@ -244,6 +247,9 @@ func OpenWithOptions(path string, opt OpenOptions) (*DB, error) {
 		}
 	}
 
+	// 恢复完成后再载入表元数据: 上面重放过的表页此刻才是最新状态
+	tablePage.Init(header.TablePage)
+
 	db = &DB{
 		file:            ff,
 		header:          header,

+ 21 - 0
DB/core/index/datapage/datapage.go

@@ -107,6 +107,27 @@ func New(handle page.HandleDb) *DataPage {
 	return &DataPage{handle: handle, pageID: pid, buf: buf, count: 0, dataEnd: HdrSize}
 }
 
+// SlotDirStart 返回槽位目录在页内的起始偏移 (目录 = 页尾 [start, PageSize) 区间)。
+// ok=false 表示目录无效 (与 Load 的判定同口径: 槽数越界或目录侵入行区,
+// Load 会按"无槽位"处理) —— 调用方 (WAL 打包) 无需为无效目录保留页尾。
+//
+// 暴露给 core 的 WAL 层: 整页镜像按 useSize 截断存 WAL 时, 必须保证目录随链
+// 可恢复, 否则重放零填充把目录抹掉, 页上全部行经槽位不可达 (压测实锤的丢数据)。
+func SlotDirStart(buf []byte) (start uint32, ok bool) {
+	if len(buf) < PageSize {
+		return 0, false
+	}
+	sc := uint32(binary.LittleEndian.Uint16(buf[PageSize-slotCountSize:]))
+	if sc > maxSlotCount {
+		return 0, false
+	}
+	start = uint32(PageSize) - slotCountSize - sc*slotEntrySize
+	if start < page.HdrOf(buf).UseSize {
+		return 0, false // 目录侵入行区: 与 Load 一致按无槽位处理
+	}
+	return start, true
+}
+
 // Load 从磁盘加载指定页
 func Load(handle page.HandleDb, pageID uint32) (*DataPage, error) {
 	buf := *dpBufPool.Get().(*[]byte)

+ 88 - 6
DB/core/wal.go

@@ -1,6 +1,7 @@
 package core
 
 import (
+	"git.ycl.pub/yl32940/TsDB2/DB/core/index/datapage"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
 	"encoding/binary"
 	"hash/crc32"
@@ -18,7 +19,10 @@ import (
 // 这样整条 WAL 链本身就是一条合法空闲链, 回收时一次拼接即可, 不必逐页遍历 (见 recycleChain)。
 //
 // WALEntry (见 page.WALEntryMeta): [TargetPageID(4B)] [DataLen(4B)] [Checksum(4B)] [OpType(1B)] [Data(DataLen)]
-//   OpType == WALOpFullPage(0)          → Data 为整页镜像 (DataLen = 页有效长度)
+//   OpType == WALOpFullPage(0)          → Data 为整页镜像有效前缀 (DataLen = 页有效长度)
+//   OpType == WALOpPageRest(4)          → Data = [覆盖偏移 u32][页尾原文], 把截掉的
+//                                          页尾按偏移覆盖回去 —— 槽位目录/被容量上限
+//                                          截掉的尾数据由此恢复, 见 packEntries
 //   OpType == Insert/Delete/Update      → Data 为行级数据
 //   Checksum = CRC32(OpType || Data)
 //
@@ -57,10 +61,19 @@ const (
 // =============================================================================
 
 const (
-	WALOpFullPage = 0 // 整页镜像 (Data = 页内容)
+	WALOpFullPage = 0 // 整页镜像 (Data = 页内容有效前缀, 尾部由 WALOpPageRest 补齐)
 	WALOpInsert   = 1 // 插入行
 	WALOpDelete   = 2 // 删除行
 	WALOpUpdate   = 3 // 更新行
+	// WALOpPageRest: 整页镜像的"补尾"记录。Data = [覆盖偏移 u32][页尾原文], 必须
+	// 跟在同页 WALOpFullPage 之后 (恢复端用"同页已有 base"作存在性校验, 偏移本身
+	// 显式存储在 Data 头部)。
+	//
+	// 为什么需要它: 整页镜像只存有效前缀 (useSize), 而 DataPage 的槽位目录在页尾
+	// (useSize 之外) —— 重放时零填充把目录抹掉, 页上全部行经槽位不可达 (chaos 压测
+	// 实锤: 崩溃恢复后 TotalRows 与实际可达行数分叉)。Heap/Blob 满页同理会被
+	// "单条容量上限"截掉真实数据。补尾记录让截掉的每一字节都可恢复。
+	WALOpPageRest = 4
 )
 
 // RowEntry 行级 WAL 记录
@@ -268,8 +281,10 @@ func (w *WALWriter) writeWALPages() ([]uint32, []byte, error) {
 		return w.file.WritePageDirect(curPID, curBuf)
 	}
 
-	for i := range w.entries {
-		opType, targetPID, payload := w.walEntryPayload(w.entries[i])
+	packed := w.packEntries()
+
+	for i := range packed {
+		opType, targetPID, payload := packed[i].opType, packed[i].pageID, packed[i].payload
 		dataLen := uint32(len(payload))
 		need := page.WALEntryMeta + dataLen
 
@@ -313,8 +328,8 @@ func (w *WALWriter) writeWALPages() ([]uint32, []byte, error) {
 			if curIsFirst {
 				// === 首页: TxnID + TotalEntries + State ===
 				wh.TxnID = w.txnID
-				wh.TotalEntries = uint32(len(w.entries))
-				wh.State = WALStateWritten // State = Written (已写盘,未 fsync)
+				wh.TotalEntries = uint32(len(packed)) // 展开后 (含 PageRest) 的记录总数
+				wh.State = WALStateWritten            // State = Written (已写盘,未 fsync)
 			}
 			// 首页与续页头部等长, 条目区起点一致
 			curOff = page.WALPageHdrSize
@@ -369,10 +384,77 @@ func (w *WALWriter) writeWALPages() ([]uint32, []byte, error) {
 	return pageIDs, firstBuf, nil
 }
 
+// packedWalEntry 打包后的最终 WAL 记录 (writeWALPages 的输入)。
+// 每条 = [13B 元数据 + payload], 与 w.entries 的区别: 整页镜像可能被展开成
+// FullPage + PageRest 两条 (见 packEntries)。
+type packedWalEntry struct {
+	opType  byte
+	pageID  uint32
+	payload []byte
+}
+
+// packEntries 把逻辑 entries 展开成最终记录序列。
+//
+// 整页镜像在「useSize 有效前缀截断」或「单条容量上限 (PageSize-40-13)」时会丢掉
+// 页尾字节, 而其中可能装着 recoverable 的数据:
+//
+//   - DataPage 的槽位目录 (页尾, useSize 之外): 被抹掉后页上全部行不可达;
+//   - Heap/Blob 满页被容量上限截掉的尾数据: 直接丢字节。
+//
+// 对这些页补一条 WALOpPageRest, 把 [max(baseLen, 目录起始), 页尾) 的原文带上。
+// 恢复端按"同页上一条 FullPage 的 payload 长度"定位覆盖偏移, 因此补尾必须紧跟
+// 在对应整页镜像之后 (此处按序展开天然保证)。
+func (w *WALWriter) packEntries() []packedWalEntry {
+	var out []packedWalEntry
+	for i := range w.entries {
+		e := &w.entries[i]
+		opType, targetPID, payload := w.walEntryPayload(*e)
+		out = append(out, packedWalEntry{opType: opType, pageID: targetPID, payload: payload})
+		if e.isRowLevel || targetPID == 0 {
+			continue // 行级记录与头页镜像 (4KB 完整) 没有截断问题
+		}
+		pageLen := uint32(len(e.data))
+		if uint32(len(payload)) >= pageLen {
+			continue // 整页入链, 无截断
+		}
+		var restStart uint32
+		hdr := page.HdrOf(e.data)
+		if hdr.UseSize > uint32(len(payload)) {
+			// 容量上限截掉了 [len(payload), useSize) 的真实数据 (行/堆块/blob 字节),
+			// 必须补到页尾 —— DataPage 的目录也在其中, 一并覆盖。
+			restStart = uint32(len(payload))
+		} else if hdr.Type == page.DataIndex {
+			if ds, ok := datapage.SlotDirStart(e.data); ok && ds > uint32(len(payload)) {
+				// useSize 处截断: [useSize, 目录起始) 是未使用的零区, 重放零填充无害;
+				// 但页尾的槽位目录必须随链可恢复, 否则页上全部行经槽位不可达。
+				restStart = ds
+			} else {
+				continue // 目录已在 base 内或无效 (Load 按无槽位处理), 无需补尾
+			}
+		} else {
+			continue // useSize 之内完整入链, 尾部未使用
+		}
+		if restStart < pageLen {
+			// Data = [覆盖偏移 u32][页尾原文]: 偏移显式入链, 恢复端不依赖"紧跟同页
+			// base 之后"来推导位置 (截断点与目录起始经常不一致)
+			rest := make([]byte, 4+pageLen-restStart)
+			binary.LittleEndian.PutUint32(rest, restStart)
+			copy(rest[4:], e.data[restStart:pageLen])
+			out = append(out, packedWalEntry{
+				opType:  WALOpPageRest,
+				pageID:  targetPID,
+				payload: rest,
+			})
+		}
+	}
+	return out
+}
+
 // walEntryPayload 解析一条 entry 的目标页 / OpType / 载荷, 并把载荷截断到
 // 「一定能放进一张空 WAL 页」的长度 (首页/续页头等长, 只需一个限制)。
 //
 // 这个截断是 writeWALPages 打包逻辑的前提: 开新页后必定能写下当前这条记录。
+// 注意: 被截掉的页尾不丢失 —— packEntries 会为其补一条 WALOpPageRest。
 func (w *WALWriter) walEntryPayload(e walEntry) (byte, uint32, []byte) {
 	opType := byte(WALOpFullPage)
 	targetPID := e.pageID

+ 76 - 0
DB/core/wal_meta_refresh_test.go

@@ -0,0 +1,76 @@
+package core
+
+import (
+	"encoding/binary"
+	"testing"
+	"unsafe"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// TestRecoverWALRefreshesTableMeta 回归: Open 必须先做 WAL 恢复、再载入表元数据。
+//
+// 背景 (wal_crash_chaos 压测实锤): 旧顺序 tablePage.Init → RecoverWAL 会让内存
+// Table 对象持有【恢复前】的表信息页 (TotalRows/堆链指针/RowSize), 恢复重放的
+// 新元数据只到磁盘 —— 内存与磁盘分叉: 行数统计错误, 堆链指针过期后后续写入
+// 按过期结构分配 = 损坏。
+func TestRecoverWALRefreshesTableMeta(t *testing.T) {
+	// 建库: 一张表, 不提交任何行 (TotalRows=0)
+	path := t.TempDir() + "/meta_refresh.db"
+	db, err := Open(path)
+	if err != nil {
+		t.Fatalf("建库: %v", err)
+	}
+	db.SetAutoCommitDelay(0)
+	tbl, err := db.CreateTable("t", []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("c")},
+		{FieldType: types.FieldType_String, FieldName: []byte("p")},
+	})
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	if _, err := tbl.CreateIndex("pk", 2, []byte{0}); err != nil {
+		t.Fatalf("建索引: %v", err)
+	}
+	tablePID := tbl.TablePageID()
+	startOff := tbl.TableStartOffset() // 表信息结构在表页内的偏移 (页头之后)
+	if err := db.Close(); err != nil {
+		t.Fatalf("关闭: %v", err)
+	}
+
+	// 伪造一条已提交 WAL: 把表信息页的 TotalRows 改成 4242 后整页镜像入链
+	ff, err := OpenFile(path)
+	if err != nil {
+		t.Fatalf("裸开: %v", err)
+	}
+	walPID := nextFreePage(t, ff)
+	pageBuf := make([]byte, PageSize)
+	if err := ff.readPageDisk(tablePID, pageBuf); err != nil {
+		t.Fatalf("读表页: %v", err)
+	}
+	// TotalRows 页内偏移 = 表信息结构起始偏移 + 结构内偏移 (与载入路径同口径)
+	trOff := startOff + uint32(unsafe.Offsetof(TableInfo{}.TotalRows))
+	before := binary.LittleEndian.Uint64(pageBuf[trOff:])
+	binary.LittleEndian.PutUint64(pageBuf[trOff:], 4242)
+	// 与真实写端一致: 整页镜像只存有效前缀 (表页 useSize 远小于页容量)
+	useSize := binary.LittleEndian.Uint32(pageBuf[16:])
+	entry := encodeWALEntry(tablePID, WALOpFullPage, pageBuf[:useSize], 0)
+	first := buildWALFirstPage(t, walPID, 0, 1, entry)
+	publishWALChain(t, ff, first)
+	_ = ff.Close()
+
+	// 重开: 恢复必须先于表元数据载入, 引擎读到的 TotalRows 必须是恢复后的 4242
+	db2, err := Open(path)
+	if err != nil {
+		t.Fatalf("重开: %v", err)
+	}
+	defer db2.Close()
+	tbl2, ok := db2.FindTable("t")
+	if !ok {
+		t.Fatal("表丢失")
+	}
+	if got := tbl2.TotalRows(); got != 4242 {
+		t.Fatalf("恢复后的表元数据必须对引擎可见: TotalRows=%d, 想要 4242 (恢复前=%d)", got, before)
+	}
+}

+ 207 - 0
DB/core/wal_page_rest_test.go

@@ -0,0 +1,207 @@
+package core
+
+// =============================================================================
+// WALOpPageRest 补尾记录回归测试
+//
+// 背景 (demo/wal_crash_chaos 随机断电压测实锤): 整页镜像按 useSize 截断存 WAL,
+// 而 DataPage 的槽位目录在页尾 (useSize 之外) —— 崩溃恢复零填充把目录抹掉,
+// 页上全部行经槽位不可达 (TotalRows 与实际可达行数分叉)。Heap/Blob 满页同理
+// 会被"单条容量上限"截掉真实数据。修复 = FullPage + PageRest 成对入链, 恢复端
+// 按显式偏移覆盖页尾。
+// =============================================================================
+
+import (
+	"bytes"
+	"encoding/binary"
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/index/datapage"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+)
+
+// stubHandleDataPage 内存态数据页: NewPage 自增分配, SavePage 存回 map。
+// 用来造一张带真实槽位目录的 DataPage 镜像, 喂给 WALWriter.packEntries。
+func stubHandleDataPage(t *testing.T, rows int) (full []byte, useSize, dirStart uint32) {
+	t.Helper()
+	pages := map[uint32][]byte{}
+	next := uint32(1000)
+	h := page.HandleDb{
+		NewPage: func() uint32 { p := next; next++; return p },
+		SavePage: func(pid uint32, buf []byte) error {
+			b := make([]byte, len(buf))
+			copy(b, buf)
+			pages[pid] = b
+			return nil
+		},
+		ReadPage: func(pid uint32, buf []byte) error {
+			copy(buf, pages[pid])
+			return nil
+		},
+	}
+	dp := datapage.New(h)
+	for i := 0; i < rows; i++ {
+		row := make([]byte, 160) // 行: BE 键 + 填充
+		binary.BigEndian.PutUint64(row[:8], uint64(295+i))
+		if dp.AppendRowSlot(row) == 0 {
+			t.Fatalf("AppendRowSlot 第 %d 行失败", i)
+		}
+	}
+	dp.Save()
+	full = pages[dp.PageID()]
+	useSize = binary.LittleEndian.Uint32(full[16:])
+	ds, ok := datapage.SlotDirStart(full)
+	if !ok {
+		t.Fatalf("造页失败: 槽位目录无效")
+	}
+	return full, useSize, ds
+}
+
+// TestPackEntriesDataPageTail 截断的 DataPage 必须展开成 FullPage+PageRest,
+// 且补尾的显式偏移 = 槽位目录起始, 内容 = 目录区原文。
+func TestPackEntriesDataPageTail(t *testing.T) {
+	full, useSize, ds := stubHandleDataPage(t, 21)
+	if ds <= useSize {
+		t.Fatalf("前置条件: 目录应在 useSize 之外 (useSize=%d ds=%d)", useSize, ds)
+	}
+
+	w := &WALWriter{pageIndex: map[uint32]int{}}
+	w.RecordPage(9, full)
+	packed := w.packEntries()
+	if len(packed) != 2 {
+		t.Fatalf("应展开成 2 条 (FullPage+PageRest), 实际 %d", len(packed))
+	}
+	if packed[0].opType != WALOpFullPage || uint32(len(packed[0].payload)) != useSize {
+		t.Fatalf("base: op=%d len=%d, 想要 FullPage/%d", packed[0].opType, len(packed[0].payload), useSize)
+	}
+	if !bytes.Equal(packed[0].payload, full[:useSize]) {
+		t.Fatalf("base 内容 ≠ 页有效前缀")
+	}
+	rest := packed[1]
+	if rest.opType != WALOpPageRest || rest.pageID != 9 {
+		t.Fatalf("补尾: op=%d page=%d, 想要 PageRest/9", rest.opType, rest.pageID)
+	}
+	off := binary.LittleEndian.Uint32(rest.payload[:4])
+	if off != ds {
+		t.Fatalf("补尾偏移=%d, 想要目录起始 %d", off, ds)
+	}
+	if !bytes.Equal(rest.payload[4:], full[ds:]) {
+		t.Fatalf("补尾内容 ≠ 目录区原文")
+	}
+}
+
+// TestPackEntriesCapAndSkips 容量上限截掉的尾数据必须补 (Heap 满页);
+// 未被截断的非数据页 (B+ 树节点) 与头页不产生补尾。
+func TestPackEntriesCapAndSkips(t *testing.T) {
+	// Heap 满页: useSize=16384 > 单条上限 → base 截到 16331, 补 [16331, 16384)
+	heap := make([]byte, PageSize)
+	page.HdrOf(heap).Type = page.HeapIndex
+	page.HdrOf(heap).UseSize = PageSize
+	w := &WALWriter{pageIndex: map[uint32]int{}}
+	w.RecordPage(8, heap)
+	packed := w.packEntries()
+	if len(packed) != 2 {
+		t.Fatalf("Heap 满页应产生补尾, 实际 %d 条", len(packed))
+	}
+	if uint32(len(packed[0].payload)) != PageSize-page.WALPageHdrSize-page.WALEntryMeta {
+		t.Fatalf("base 应截到容量上限, 实际 %d", len(packed[0].payload))
+	}
+	if off := binary.LittleEndian.Uint32(packed[1].payload[:4]); off != uint32(len(packed[0].payload)) {
+		t.Fatalf("补尾偏移=%d, 想要 %d", off, len(packed[0].payload))
+	}
+	if uint32(len(packed[1].payload)) != 4+(PageSize-16331) {
+		t.Fatalf("补尾长度=%d, 想要 %d", len(packed[1].payload), 4+(PageSize-16331))
+	}
+
+	// B+ 树节点 (useSize 远小于上限): useSize 内完整入链, 无补尾
+	bt := make([]byte, PageSize)
+	page.HdrOf(bt).Type = page.IndexDataIndex
+	page.HdrOf(bt).UseSize = 4442
+	w2 := &WALWriter{pageIndex: map[uint32]int{}}
+	w2.RecordPage(7, bt)
+	if p2 := w2.packEntries(); len(p2) != 1 {
+		t.Fatalf("B+ 节点不应产生补尾, 实际 %d 条", len(p2))
+	}
+
+	// 头页镜像 (4KB): 无补尾
+	hdr := make([]byte, PageSize4K)
+	w3 := &WALWriter{pageIndex: map[uint32]int{}}
+	w3.RecordPage(0, hdr)
+	if p3 := w3.packEntries(); len(p3) != 1 {
+		t.Fatalf("头页镜像不应产生补尾, 实际 %d 条", len(p3))
+	}
+}
+
+// TestRecoverWALPageRestRoundTrip 恢复端: FullPage+PageRest 重放后, 整页字节
+// (含 useSize 之外的槽位目录) 必须与原始镜像逐字节一致 —— 这是压测抓到的
+// 丢数据路径的直接回归。
+func TestRecoverWALPageRestRoundTrip(t *testing.T) {
+	path, ff := walHardeningDB(t)
+	walPID := nextFreePage(t, ff)
+	targetPID := walPID + 5 // 不与 WAL 首页相撞
+
+	full, useSize, ds := stubHandleDataPage(t, 21)
+	base := encodeWALEntry(targetPID, WALOpFullPage, full[:useSize], 0)
+	restPayload := make([]byte, 4+PageSize-ds)
+	binary.LittleEndian.PutUint32(restPayload, ds)
+	copy(restPayload[4:], full[ds:])
+	rest := encodeWALEntry(targetPID, WALOpPageRest, restPayload, 0)
+
+	first := buildWALFirstPage(t, walPID, 0, 2, base, rest)
+	publishWALChain(t, ff, first)
+	_ = ff.Close()
+
+	db, err := Open(path)
+	if err != nil {
+		t.Fatalf("重开 (重放 FullPage+PageRest): %v", err)
+	}
+	_ = db.Close()
+
+	// 直读目标页, 与原始镜像逐字节比对 (重点是 useSize 之外的目录区)
+	ff2, err := OpenFile(path)
+	if err != nil {
+		t.Fatalf("重开裸文件: %v", err)
+	}
+	defer ff2.Close()
+	got := make([]byte, PageSize)
+	if err := ff2.readPageDisk(targetPID, got); err != nil {
+		t.Fatalf("读目标页: %v", err)
+	}
+	if !bytes.Equal(got, full) {
+		t.Fatalf("重放后页 %d 与原始镜像不一致 (useSize=%d, 目录起始=%d)", targetPID, useSize, ds)
+	}
+}
+
+// TestRecoverWALOrphanPageRestRejected 孤儿补尾 (没有同页前置整页镜像) 的已提交链
+// 必须拒绝重放并保留 WAL —— 补尾只有叠在 base 写回的页上才有意义, 单独出现
+// 说明链损坏/被篡改, 部分重放会把零填充页当真实数据。
+func TestRecoverWALOrphanPageRestRejected(t *testing.T) {
+	path, ff := walHardeningDB(t)
+	walPID := nextFreePage(t, ff)
+
+	restPayload := make([]byte, 8)
+	binary.LittleEndian.PutUint32(restPayload, 100)
+	rest := encodeWALEntry(5, WALOpPageRest, restPayload, 0)
+	first := buildWALFirstPage(t, walPID, 0, 1, rest)
+	publishWALChain(t, ff, first)
+	_ = ff.Close()
+
+	db, err := Open(path)
+	if err == nil {
+		_ = db.Close()
+		t.Fatalf("孤儿补尾的已提交链必须让 Open 返回错误, 却成功了")
+	}
+	t.Logf("正确拒绝: %v", err)
+
+	ff2, err := OpenFile(path)
+	if err != nil {
+		t.Fatalf("重开裸文件: %v", err)
+	}
+	hdr, err := ff2.ReadHeader()
+	_ = ff2.Close()
+	if err != nil {
+		t.Fatalf("读头页: %v", err)
+	}
+	if hdr.FirstWALPage != walPID {
+		t.Fatalf("WAL 链应原样保留: FirstWALPage=%d, 想要 %d", hdr.FirstWALPage, walPID)
+	}
+}

+ 39 - 3
DB/core/wal_recovery.go

@@ -45,9 +45,10 @@ func RecoverWAL(ff *File, header *HeaderPage) error {
 	type recoveryEntry struct {
 		pageID   uint32
 		data     []byte // 整页镜像 (旧格式)
+		baseLen  uint32 // 整页镜像入链长度 (补尾记录按它定位覆盖偏移)
 		isRow    bool   // 是否为行级记录 (新格式)
-		opType   byte   // 行级操作类型
-		storeRow []byte // 行级数据
+		opType   byte   // 行级操作类型 / WALOpPageRest
+		storeRow []byte // 行级数据 / 补尾数据
 	}
 	var entries []recoveryEntry
 
@@ -118,7 +119,12 @@ func RecoverWAL(ff *File, header *HeaderPage) error {
 				// 整页镜像: 零填充到整页长度, 保证写回时文件偏移一致
 				data := make([]byte, PageSize)
 				copy(data, payload)
-				entries = append(entries, recoveryEntry{pageID: targetPID, data: data})
+				entries = append(entries, recoveryEntry{pageID: targetPID, data: data, baseLen: dataLen})
+			} else if opType == WALOpPageRest {
+				// 整页镜像的页尾补片: 记录目标页与数据, 重放阶段按 baseLen 定位偏移
+				rest := make([]byte, dataLen)
+				copy(rest, payload)
+				entries = append(entries, recoveryEntry{pageID: targetPID, opType: opType, storeRow: rest})
 			} else {
 				storeRow := make([]byte, dataLen)
 				copy(storeRow, payload)
@@ -164,6 +170,7 @@ func RecoverWAL(ff *File, header *HeaderPage) error {
 		// (与提交路径步骤 4/5 钉死的顺序同一道理)。当前提交端头页恒为最后一条
 		// RecordPage, 本不会先写 —— 恢复侧不依赖这个巧合。
 		var headerImage []byte
+		covered := make(map[uint32]uint32, len(entries)) // pageID → 已写回的字节数 (补尾定位用)
 		for _, e := range entries {
 			if e.isRow {
 				continue
@@ -172,9 +179,38 @@ func RecoverWAL(ff *File, header *HeaderPage) error {
 				headerImage = e.data // RecordPage 已按页去重, 至多一条头页镜像
 				continue
 			}
+			if e.opType == WALOpPageRest {
+				// 页尾补片: Data = [覆盖偏移 u32][页尾原文]。必须跟在同页整页镜像之后
+				// (写端 packEntries 保证); 缺前置镜像 / 偏移越界都说明链被篡改或损坏,
+				// 按拒绝部分重放返回错误。
+				if _, ok := covered[e.pageID]; !ok {
+					return fmt.Errorf("WAL 恢复: 已提交事务含孤儿补尾记录 (页 %d 缺少前置整页镜像), "+
+						"拒绝部分重放 —— WAL 链已原样保留, 请修复后重试", e.pageID)
+				}
+				if len(e.storeRow) < 4 {
+					return fmt.Errorf("WAL 恢复: 已提交事务的补尾记录过短 (页 %d, %d 字节), "+
+						"拒绝部分重放 —— WAL 链已原样保留, 请修复后重试", e.pageID, len(e.storeRow))
+				}
+				off := binary.LittleEndian.Uint32(e.storeRow[:4])
+				rest := e.storeRow[4:]
+				if off+uint32(len(rest)) > PageSize {
+					return fmt.Errorf("WAL 恢复: 已提交事务的补尾记录越界 (页 %d: %d+%d > %d), "+
+						"拒绝部分重放 —— WAL 链已原样保留, 请修复后重试", e.pageID, off, len(rest), PageSize)
+				}
+				restBuf := make([]byte, PageSize)
+				if err := ff.readPageDisk(e.pageID, restBuf); err != nil {
+					return err
+				}
+				copy(restBuf[off:], rest)
+				if err := ff.WritePageDirect(e.pageID, restBuf); err != nil {
+					return err
+				}
+				continue
+			}
 			if err := ff.WritePageDirect(e.pageID, e.data); err != nil {
 				return err
 			}
+			covered[e.pageID] = e.baseLen
 		}
 		if headerImage != nil {
 			if err := recoverHeader(ff, headerImage); err != nil {