go-ethereum/nomt/bitbox/wal_test.go
weiihann 859312d1f5 nomt/bitbox: add Phase 4 WAL, sync controller, and crash recovery
Implement crash-safe persistence for the Bitbox hash table:
- wal.go: WAL format with START/CLEAR/UPDATE/END entries, builder/reader
- sync.go: 3-phase sync protocol (BeginSync → WriteWAL → CommitSync)
- recover.go: WAL replay for crash recovery

The WAL records page diffs (not full pages) for compact logging. The
3-phase protocol ensures: WAL fsynced before HT modification, HT fsynced
before WAL truncation, providing at-least-once delivery of page updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 17:16:29 +08:00

217 lines
5 KiB
Go

package bitbox
import (
"path/filepath"
"testing"
"github.com/ethereum/go-ethereum/nomt/core"
"github.com/ethereum/go-ethereum/nomt/merkle"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- WAL Builder/Reader Tests ---
func TestWALEmptyRoundTrip(t *testing.T) {
b := NewWALBuilder()
data := b.Finish(42)
// Should be padded to page boundary.
assert.Equal(t, 0, len(data)%pageSize)
seqn, entries, err := ReadWAL(data)
require.NoError(t, err)
assert.Equal(t, uint32(42), seqn)
assert.Empty(t, entries)
}
func TestWALClearEntryRoundTrip(t *testing.T) {
b := NewWALBuilder()
b.AddClear(123)
b.AddClear(456)
data := b.Finish(1)
seqn, entries, err := ReadWAL(data)
require.NoError(t, err)
assert.Equal(t, uint32(1), seqn)
require.Len(t, entries, 2)
assert.Equal(t, WALEntryClear, entries[0].Kind)
assert.Equal(t, uint64(123), entries[0].ClearBucket)
assert.Equal(t, uint64(456), entries[1].ClearBucket)
}
func TestWALUpdateEntryRoundTrip(t *testing.T) {
var pageID [32]byte
pageID[0] = 0xAB
var diff core.PageDiff
diff.SetChanged(5)
diff.SetChanged(70)
nodes := []core.Node{{0x01}, {0x02}}
b := NewWALBuilder()
b.AddUpdate(pageID, diff, nodes, 0xFF, 99)
data := b.Finish(7)
seqn, entries, err := ReadWAL(data)
require.NoError(t, err)
assert.Equal(t, uint32(7), seqn)
require.Len(t, entries, 1)
e := entries[0]
assert.Equal(t, WALEntryUpdate, e.Kind)
assert.Equal(t, pageID, e.PageID)
assert.True(t, e.Diff.IsChanged(5))
assert.True(t, e.Diff.IsChanged(70))
require.Len(t, e.ChangedNodes, 2)
assert.Equal(t, core.Node{0x01}, e.ChangedNodes[0])
assert.Equal(t, core.Node{0x02}, e.ChangedNodes[1])
assert.Equal(t, uint64(0xFF), e.ElidedChildren)
assert.Equal(t, uint64(99), e.UpdateBucket)
}
func TestWALMixedEntries(t *testing.T) {
b := NewWALBuilder()
b.AddClear(10)
var pid [32]byte
var diff core.PageDiff
diff.SetChanged(0)
b.AddUpdate(pid, diff, []core.Node{{0xAA}}, 0, 20)
b.AddClear(30)
data := b.Finish(100)
_, entries, err := ReadWAL(data)
require.NoError(t, err)
require.Len(t, entries, 3)
assert.Equal(t, WALEntryClear, entries[0].Kind)
assert.Equal(t, WALEntryUpdate, entries[1].Kind)
assert.Equal(t, WALEntryClear, entries[2].Kind)
}
func TestReadWALEmpty(t *testing.T) {
seqn, entries, err := ReadWAL(nil)
require.NoError(t, err)
assert.Equal(t, uint32(0), seqn)
assert.Nil(t, entries)
}
func TestWALFilePersistence(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.wal")
b := NewWALBuilder()
b.AddClear(42)
data := b.Finish(5)
require.NoError(t, WriteWALFile(path, data))
loaded, err := ReadWALFile(path)
require.NoError(t, err)
assert.Equal(t, data, loaded)
require.NoError(t, TruncateWALFile(path))
loaded2, err := ReadWALFile(path)
require.NoError(t, err)
assert.Empty(t, loaded2)
}
// --- Sync Controller Tests ---
func TestFullSyncCycle(t *testing.T) {
dir := t.TempDir()
htPath := filepath.Join(dir, "test.bitbox")
walPath := filepath.Join(dir, "test.wal")
seed := HashSeedFromUint64(1, 2)
db, err := Create(htPath, 1024, seed)
require.NoError(t, err)
defer db.Close()
rootID := core.RootPageID()
page := new(core.RawPage)
page.SetNodeAt(0, core.Node{0xAA})
var diff core.PageDiff
diff.SetChanged(0)
updates := []merkle.UpdatedPage{{
PageID: rootID,
Page: page,
Diff: diff,
}}
require.NoError(t, db.FullSync(walPath, 1, updates))
// Verify page is persisted.
loaded, _, found, err := db.LoadPage(rootID)
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, core.Node{0xAA}, loaded.NodeAt(0))
}
// --- Recovery Tests ---
func TestRecoverFromWAL(t *testing.T) {
dir := t.TempDir()
htPath := filepath.Join(dir, "test.bitbox")
walPath := filepath.Join(dir, "test.wal")
seed := HashSeedFromUint64(1, 2)
// Create DB and write a WAL but don't commit Phase 3.
db, err := Create(htPath, 1024, seed)
require.NoError(t, err)
rootID := core.RootPageID()
page := new(core.RawPage)
page.SetNodeAt(0, core.Node{0xBB})
var diff core.PageDiff
diff.SetChanged(0)
updates := []merkle.UpdatedPage{{
PageID: rootID,
Page: page,
Diff: diff,
}}
// Phase 1 + 2 only (simulate crash before Phase 3).
plan, err := db.BeginSync(walPath, 5, updates)
require.NoError(t, err)
require.NoError(t, db.WriteWAL(walPath, plan))
db.Close()
// Reopen and recover.
db2, err := Open(htPath)
require.NoError(t, err)
defer db2.Close()
seqn, err := db2.Recover(walPath)
require.NoError(t, err)
assert.Equal(t, uint32(5), seqn)
// Verify the page was recovered.
loaded, _, found, err := db2.LoadPage(rootID)
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, core.Node{0xBB}, loaded.NodeAt(0))
}
func TestRecoverNoWAL(t *testing.T) {
dir := t.TempDir()
htPath := filepath.Join(dir, "test.bitbox")
walPath := filepath.Join(dir, "test.wal")
seed := HashSeedFromUint64(1, 2)
db, err := Create(htPath, 1024, seed)
require.NoError(t, err)
defer db.Close()
seqn, err := db.Recover(walPath)
require.NoError(t, err)
assert.Equal(t, uint32(0), seqn, "no recovery needed")
}