Browse Source

fix: strengthen IsRecoverable contiguity check and StateAt snapshot correctness

IsRecoverable now verifies three conditions:
- startExclusive >= tailLSN (not recycled)
- endInclusive <= headLSN (within WAL)
- all LSNs in range exist contiguously (no holes)

StateAt now uses base snapshot captured during AdvanceTail:
- returns nil for LSNs before snapshot boundary (unreconstructable)
- correctly includes block state from recycled entries via snapshot

5 new tests: end-beyond-head, missing entries, state after tail
advance, nil before snapshot, block last written before tail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feature/sw-block
pingqiu 16 hours ago
parent
commit
942a0b7da7
  1. 97
      sw-block/prototype/enginev2/p3_test.go
  2. 68
      sw-block/prototype/enginev2/walhistory.go

97
sw-block/prototype/enginev2/p3_test.go

@ -112,6 +112,103 @@ func TestWAL_StateAt_HistoricalCorrectness(t *testing.T) {
}
}
// --- IsRecoverable: strengthened checks ---
func TestWAL_IsRecoverable_FalseWhenEndBeyondHead(t *testing.T) {
w := NewWALHistory()
for i := uint64(1); i <= 50; i++ {
w.Append(WALEntry{LSN: i})
}
// End is 100 but head is only 50.
if w.IsRecoverable(0, 100) {
t.Fatal("should be false when endInclusive > headLSN")
}
}
func TestWAL_IsRecoverable_FalseWhenEntriesMissing(t *testing.T) {
w := NewWALHistory()
// Append non-contiguous: 1, 2, 3, 5, 6 (missing 4).
w.Append(WALEntry{LSN: 1})
w.Append(WALEntry{LSN: 2})
w.Append(WALEntry{LSN: 3})
w.entries = append(w.entries, WALEntry{LSN: 5}) // skip 4
w.entries = append(w.entries, WALEntry{LSN: 6})
w.headLSN = 6
// Range 0→6 has a hole at LSN 4.
if w.IsRecoverable(0, 6) {
t.Fatal("should be false when entries are non-contiguous")
}
// Range 0→3 is contiguous.
if !w.IsRecoverable(0, 3) {
t.Fatal("range 0→3 should be recoverable (contiguous)")
}
}
// --- StateAt: correctness after tail advancement ---
func TestWAL_StateAt_CorrectAfterTailAdvance(t *testing.T) {
w := NewWALHistory()
w.Append(WALEntry{LSN: 1, Block: 1, Value: 100})
w.Append(WALEntry{LSN: 2, Block: 2, Value: 200})
w.Append(WALEntry{LSN: 3, Block: 1, Value: 300}) // overwrites block 1
w.Append(WALEntry{LSN: 4, Block: 3, Value: 400})
w.Append(WALEntry{LSN: 5, Block: 2, Value: 500}) // overwrites block 2
w.AdvanceTail(3) // recycle LSNs 1-3, snapshot captures block1=300, block2=200
// StateAt(5): should include snapshot state + retained entries.
s := w.StateAt(5)
if s[1] != 300 { // from snapshot (LSN 3)
t.Fatalf("block 1=%d, want 300", s[1])
}
if s[2] != 500 { // from retained entry (LSN 5)
t.Fatalf("block 2=%d, want 500", s[2])
}
if s[3] != 400 { // from retained entry (LSN 4)
t.Fatalf("block 3=%d, want 400", s[3])
}
}
func TestWAL_StateAt_ReturnsNilBeforeSnapshot(t *testing.T) {
w := NewWALHistory()
for i := uint64(1); i <= 10; i++ {
w.Append(WALEntry{LSN: i, Block: i, Value: i * 10})
}
w.AdvanceTail(5) // snapshot at LSN 5
// StateAt(3) — before snapshot boundary, entries recycled.
s := w.StateAt(3)
if s != nil {
t.Fatal("StateAt before snapshot should return nil")
}
// StateAt(5) — at snapshot boundary, valid.
s = w.StateAt(5)
if s == nil {
t.Fatal("StateAt at snapshot boundary should work")
}
}
func TestWAL_StateAt_BlockLastWrittenBeforeTail(t *testing.T) {
w := NewWALHistory()
w.Append(WALEntry{LSN: 1, Block: 99, Value: 42}) // block 99 only written once
w.Append(WALEntry{LSN: 2, Block: 1, Value: 100})
w.Append(WALEntry{LSN: 3, Block: 1, Value: 200})
w.AdvanceTail(2) // snapshot captures block99=42, block1=100
// StateAt(3): block 99's last write was LSN 1 (recycled), but
// captured in snapshot. Must still be present.
s := w.StateAt(3)
if s[99] != 42 {
t.Fatalf("block 99=%d, want 42 (from snapshot of recycled entry)", s[99])
}
if s[1] != 200 {
t.Fatalf("block 1=%d, want 200 (from retained entry)", s[1])
}
}
// --- Recoverability proof: "why is catch-up allowed?" ---
func TestRecoverability_Provable_GapWithinRetention(t *testing.T) {

68
sw-block/prototype/enginev2/walhistory.go

@ -19,6 +19,11 @@ type WALHistory struct {
headLSN uint64 // highest LSN written
tailLSN uint64 // oldest retained LSN (exclusive: entries with LSN > tailLSN are kept)
committedLSN uint64 // lineage-safe boundary
// Base snapshot: block→value state at tailLSN. Captured when tail advances.
// Required for correct StateAt() after entries are recycled.
baseSnapshot map[uint64]uint64
baseSnapshotLSN uint64
}
// NewWALHistory creates an empty WAL history.
@ -47,14 +52,26 @@ func (w *WALHistory) Commit(lsn uint64) error {
return nil
}
// AdvanceTail recycles entries at or below lsn. After this, entries with
// LSN <= lsn are no longer available for catch-up recovery.
// AdvanceTail recycles entries at or below lsn. Before recycling,
// captures a base snapshot of block state at the new tail boundary
// so that StateAt() remains correct after entries are gone.
func (w *WALHistory) AdvanceTail(lsn uint64) {
if lsn <= w.tailLSN {
return
}
// Capture base snapshot: replay all entries up to new tail.
if w.baseSnapshot == nil {
w.baseSnapshot = map[uint64]uint64{}
}
for _, e := range w.entries {
if e.LSN > lsn {
break
}
w.baseSnapshot[e.Block] = e.Value
}
w.baseSnapshotLSN = lsn
w.tailLSN = lsn
// Remove recycled entries from storage.
// Remove recycled entries.
kept := w.entries[:0]
for _, e := range w.entries {
if e.LSN > lsn {
@ -101,8 +118,33 @@ func (w *WALHistory) EntriesInRange(startExclusive, endInclusive uint64) ([]WALE
// IsRecoverable checks whether all entries from startExclusive+1 to
// endInclusive are retained in the WAL. This is the executable proof
// of "why catch-up is allowed."
//
// Verifies three conditions:
// 1. startExclusive >= tailLSN (gap start not recycled)
// 2. endInclusive <= headLSN (gap end within WAL)
// 3. All LSNs in (startExclusive, endInclusive] exist contiguously
func (w *WALHistory) IsRecoverable(startExclusive, endInclusive uint64) bool {
return startExclusive >= w.tailLSN
if startExclusive < w.tailLSN {
return false // start is in recycled region
}
if endInclusive > w.headLSN {
return false // end is beyond WAL head
}
// Verify contiguous coverage.
expect := startExclusive + 1
for _, e := range w.entries {
if e.LSN <= startExclusive {
continue
}
if e.LSN > endInclusive {
break
}
if e.LSN != expect {
return false // gap in retained entries
}
expect++
}
return expect > endInclusive // all LSNs covered
}
// MakeHandshakeResult generates a HandshakeResult from the WAL state
@ -132,10 +174,24 @@ func (w *WALHistory) CommittedLSN() uint64 { return w.committedLSN }
// Len returns the number of retained entries.
func (w *WALHistory) Len() int { return len(w.entries) }
// StateAt replays entries up to lsn and returns block→value state.
// Used to verify historical data correctness after recovery.
// StateAt returns block→value state at a given LSN.
//
// If lsn >= baseSnapshotLSN, starts from the base snapshot and replays
// retained entries up to lsn. This is correct even after tail advancement
// because the snapshot captures all block state from recycled entries.
//
// If lsn < baseSnapshotLSN, the required entries have been recycled and
// the state cannot be reconstructed — returns nil.
func (w *WALHistory) StateAt(lsn uint64) map[uint64]uint64 {
if w.baseSnapshotLSN > 0 && lsn < w.baseSnapshotLSN {
return nil // state unreconstructable: entries recycled
}
// Start from base snapshot (if any).
state := map[uint64]uint64{}
for k, v := range w.baseSnapshot {
state[k] = v
}
// Replay retained entries up to lsn.
for _, e := range w.entries {
if e.LSN > lsn {
break

Loading…
Cancel
Save