Phase 1 default state had two data-loss paths in WAL recovery.
C2: resolveRecoverySegmentID fell back to CURRENT when MANIFEST=0.
Since segment_manager writes CURRENT on every segment create/rotate,
the first recovery in Phase 1 (MANIFEST always 0 without flush) would
start from the active segment, skipping earlier unflushed segments.
C3: Recover called manifest.Save after every recovery, advancing
recoverySegmentID past segments that were still the only durable copy
of their data (no SSTable flush yet). Next restart would filter those
segments out and permanently lose the data.
Per design §3.2 line 280, recovery must not update MANIFEST; per line
604-06, CURRENT must not be used as recovery start. Both fixes are
required together — fixing C3 alone leaves C2's data-loss window open.
Changes:
- wal/recover.go: remove manifest.Save calls on both success and
tail-repair paths; remove CURRENT fallback in resolveRecoverySegmentID.
RecoveryResult.NextSegmentID is now in-memory only (consumed by DB.Open
to seed the new WalWriter, but never persisted to MANIFEST).
- wal/recover_test.go: rewrite TestRecoverUpdatesManifest as
TestRecoverDoesNotUpdateManifest; add TestRecoverPreservesExistingManifest,
TestRecoverIdempotentClean, TestRecoverIdempotentAfterTruncation,
TestRecoverIgnoresCurrentFallback.
- db_test.go: add TestOpenThreeTimesKeepsData (three opens to catch C3's
second-restart data loss; single-segment to avoid unrelated C8 bug
where segment_manager passes byte offset as startSequence).
Verified: each new test fails on pre-fix code and passes after the fix.
Full suite green including -race.
Audit context: docs/audit-3.2.md (with Oracle revisions from bg_ef425776
and bg_2e86d33b; C8 added). Plan: .omo/plans/fix-c2-c3-wal-recovery.md
(Momus + Oracle reviewed v1.2).
229 lines
5.0 KiB
Go
229 lines
5.0 KiB
Go
package go_kv
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestDBOpenClose(t *testing.T) {
|
|
db, err := Open(t.TempDir(), nil)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
if err := db.Put([]byte("k"), []byte("v")); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
if err := db.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDBPutGet(t *testing.T) {
|
|
db := openTestDB(t, t.TempDir())
|
|
|
|
if err := db.Put([]byte("k1"), []byte("v1")); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
got := db.Get([]byte("k1"))
|
|
if !got.Found {
|
|
t.Fatal("Get(k1) not found")
|
|
}
|
|
if string(got.Value) != "v1" {
|
|
t.Fatalf("Get(k1) value = %q, want %q", got.Value, "v1")
|
|
}
|
|
}
|
|
|
|
func TestDBDelete(t *testing.T) {
|
|
db := openTestDB(t, t.TempDir())
|
|
|
|
if err := db.Put([]byte("k"), []byte("v")); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
if err := db.Delete([]byte("k")); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
|
|
got := db.Get([]byte("k"))
|
|
if got.Found {
|
|
t.Fatalf("Get(k) found deleted key with value %q", got.Value)
|
|
}
|
|
}
|
|
|
|
func TestDBRecovery(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db := openTestDB(t, dir)
|
|
|
|
entries := map[string]string{
|
|
"k1": "v1",
|
|
"k2": "v2",
|
|
"k3": "v3",
|
|
}
|
|
for key, value := range entries {
|
|
if err := db.Put([]byte(key), []byte(value)); err != nil {
|
|
t.Fatalf("Put(%q): %v", key, err)
|
|
}
|
|
}
|
|
closeTestDB(t, db)
|
|
|
|
reopened := openTestDB(t, dir)
|
|
for key, want := range entries {
|
|
got := reopened.Get([]byte(key))
|
|
if !got.Found {
|
|
t.Fatalf("Get(%q) not found after recovery", key)
|
|
}
|
|
if string(got.Value) != want {
|
|
t.Fatalf("Get(%q) value = %q, want %q", key, got.Value, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDBRecoveryAfterCorruption(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db := openTestDB(t, dir)
|
|
|
|
if err := db.Put([]byte("good1"), []byte("v1")); err != nil {
|
|
t.Fatalf("Put good1: %v", err)
|
|
}
|
|
if err := db.Put([]byte("good2"), []byte("v2")); err != nil {
|
|
t.Fatalf("Put good2: %v", err)
|
|
}
|
|
closeTestDB(t, db)
|
|
|
|
appendWalTail(t, dir, []byte{0xde, 0xad, 0xbe, 0xef})
|
|
|
|
reopened := openTestDB(t, dir)
|
|
for key, want := range map[string]string{"good1": "v1", "good2": "v2"} {
|
|
got := reopened.Get([]byte(key))
|
|
if !got.Found {
|
|
t.Fatalf("Get(%q) not found after corrupted-tail recovery", key)
|
|
}
|
|
if string(got.Value) != want {
|
|
t.Fatalf("Get(%q) value = %q, want %q", key, got.Value, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDBMultiplePuts(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db := openTestDB(t, dir)
|
|
|
|
for i := range 50 {
|
|
key := fmt.Appendf(nil, "k%d", i)
|
|
value := fmt.Appendf(nil, "v%d", i)
|
|
if err := db.Put(key, value); err != nil {
|
|
t.Fatalf("Put(%q): %v", key, err)
|
|
}
|
|
}
|
|
closeTestDB(t, db)
|
|
|
|
reopened := openTestDB(t, dir)
|
|
for i := range 50 {
|
|
key := fmt.Appendf(nil, "k%d", i)
|
|
want := fmt.Sprintf("v%d", i)
|
|
got := reopened.Get(key)
|
|
if !got.Found {
|
|
t.Fatalf("Get(%q) not found after recovery", key)
|
|
}
|
|
if string(got.Value) != want {
|
|
t.Fatalf("Get(%q) value = %q, want %q", key, got.Value, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Regression guard for C2+C3: data must survive three Open/Close cycles.
|
|
// C3's data-loss bug manifests on the SECOND restart after writes — two
|
|
// opens cannot catch it.
|
|
//
|
|
// Single-segment only: do NOT force rotation, because segment_manager has
|
|
// an unrelated C8 bug (passes byte offset as startSequence) that breaks
|
|
// multi-segment recovery. That bug is tracked separately.
|
|
func TestOpenThreeTimesKeepsData(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
keys := []string{"k1", "k2", "k3", "k4", "k5"}
|
|
|
|
db1, err := Open(dir, nil)
|
|
if err != nil {
|
|
t.Fatalf("Open 1: %v", err)
|
|
}
|
|
for _, k := range keys {
|
|
if err := db1.Put([]byte(k), []byte("v-"+k)); err != nil {
|
|
t.Fatalf("Put %s: %v", k, err)
|
|
}
|
|
}
|
|
if err := db1.Close(); err != nil {
|
|
t.Fatalf("Close 1: %v", err)
|
|
}
|
|
|
|
verify := func(label string, db *DB) {
|
|
t.Helper()
|
|
for _, k := range keys {
|
|
r := db.Get([]byte(k))
|
|
if !r.Found {
|
|
t.Errorf("%s: key %s not found", label, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
db2, err := Open(dir, nil)
|
|
if err != nil {
|
|
t.Fatalf("Open 2: %v", err)
|
|
}
|
|
verify("Open 2", db2)
|
|
if err := db2.Close(); err != nil {
|
|
t.Fatalf("Close 2: %v", err)
|
|
}
|
|
|
|
db3, err := Open(dir, nil)
|
|
if err != nil {
|
|
t.Fatalf("Open 3: %v", err)
|
|
}
|
|
defer db3.Close()
|
|
verify("Open 3", db3)
|
|
}
|
|
|
|
func openTestDB(t *testing.T, dir string) *DB {
|
|
t.Helper()
|
|
|
|
db, err := Open(dir, nil)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if !db.closed.Load() {
|
|
if err := db.Close(); err != nil {
|
|
t.Fatalf("Close cleanup: %v", err)
|
|
}
|
|
}
|
|
})
|
|
return db
|
|
}
|
|
|
|
func closeTestDB(t *testing.T, db *DB) {
|
|
t.Helper()
|
|
if err := db.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
}
|
|
|
|
func appendWalTail(t *testing.T, dir string, tail []byte) {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(dir, "segment-0.wal")
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
|
if err != nil {
|
|
t.Fatalf("OpenFile(%s): %v", path, err)
|
|
}
|
|
defer func() {
|
|
if err := f.Close(); err != nil {
|
|
t.Fatalf("Close corrupted WAL file: %v", err)
|
|
}
|
|
}()
|
|
if _, err := f.Write(tail); err != nil {
|
|
t.Fatalf("Write corrupted WAL tail: %v", err)
|
|
}
|
|
}
|