Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions cmd/backscroll/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2438,3 +2438,72 @@ func TestSyncReportsDeletedTemplateCount(t *testing.T) {
t.Errorf("expected 0 templates after sync (stuck template should be deleted), got %d", countAfter)
}
}

// TestRecoverDryRunWithWALSidecars verifies that recover --dry-run succeeds
// when the active database has WAL-mode sidecars (issue #51).
// This is a regression test for the chicken-and-egg bug where startup
// index_prepare creates the -shm sidecar, then recovery rejects it.
func TestRecoverDryRunWithWALSidecars(t *testing.T) {
activePath, _ := testEnv(t)

// Create database in WAL mode with persistent sidecars
activeDB, err := storage.Open(activePath)
if err != nil {
t.Fatalf("create active database: %v", err)
}

// Verify database is in WAL mode
var journalMode string
if err := activeDB.DB().QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
t.Fatalf("query journal mode: %v", err)
}
if journalMode != "wal" {
t.Fatalf("database not in WAL mode, got %s", journalMode)
}
activeDB.Close()

// Manually create WAL sidecars that persist
// Create empty -wal file (zero bytes means no pending changes)
walPath := activePath + "-wal"
if f, err := os.Create(walPath); err != nil {
t.Fatalf("create -wal sidecar: %v", err)
} else {
f.Close()
}

// Create -shm file (SQLite will create this when opening, but we create it manually
// to simulate a database that was previously opened)
shamPath := activePath + "-shm"
if f, err := os.Create(shamPath); err != nil {
t.Fatalf("create -shm sidecar: %v", err)
} else {
f.Close()
}

if !fileExists(shamPath) {
t.Fatalf("-shm sidecar was not created")
}
if !fileExists(walPath) {
t.Fatalf("-wal sidecar was not created")
}

// Now run recover --from <active-db> --dry-run
// This simulates issue #51: database has persistent WAL sidecars,
// startup index_prepare opens the DB (creating more sidecars),
// then recovery tries to check the same database and should NOT fail on the sidecar
// when the -wal file is zero bytes (no pending changes).
stdout, stderr, err := runCmd("recover", "--from", activePath, "--dry-run")
if err != nil {
t.Fatalf("recover --dry-run failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
}

// Verify the dry-run succeeded
if !strings.Contains(stdout, "recovery dry run") {
t.Errorf("recover --dry-run output missing 'recovery dry run': %s", stdout)
}
}

func fileExists(path string) bool {
_, err := os.Lstat(path)
return err == nil
}
10 changes: 10 additions & 0 deletions internal/recovery/recovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,11 @@ func restoreBackup(activePath, backupPath string, backupSidecars []string, plann
}

func ensureSourceSidecarsSafe(dbPath string) error {
// Check WAL file status first to determine if -shm is safe
walPath := dbPath + "-wal"
walInfo, walErr := os.Lstat(walPath)
walIsSafe := walErr != nil || (walInfo != nil && walInfo.Mode().IsRegular() && walInfo.Size() == 0)

for _, suffix := range sqliteSidecarSuffixes() {
path := dbPath + suffix
info, err := os.Lstat(path)
Expand All @@ -944,6 +949,11 @@ func ensureSourceSidecarsSafe(dbPath string) error {
}
return fmt.Errorf("%w: unexpected SQLite sidecar namespace entry %s", storage.ErrImmutableReadOnlyWALUnsafe, path)
}
// For -shm and -journal files, tolerate them if the WAL is safe (zero bytes or absent)
// This matches the safety property: no active writer modifies the database if WAL has no pending changes
if suffix == "-shm" && walIsSafe {
continue
}
return fmt.Errorf("unexpected SQLite sidecar namespace entry %s", path)
} else if !os.IsNotExist(err) {
return fmt.Errorf("lstat SQLite sidecar %s: %w", path, err)
Expand Down
114 changes: 110 additions & 4 deletions internal/recovery/recovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1409,19 +1409,27 @@ func TestRecoverSQLiteSidecarPolicy(t *testing.T) {
}
})

t.Run("empty active shm rejected before replacement", func(t *testing.T) {
t.Run("empty active shm tolerated before replacement (issue #51 fix)", func(t *testing.T) {
dir := t.TempDir()
activePath := filepath.Join(dir, "active.db")
fromPath := filepath.Join(dir, "stranded.db")
createRecoveryDB(t, activePath, []storage.IndexedMessage{{Ordinal: 0, Role: "user", Text: "active with stale shm", UUID: "66666666-6666-4666-8666-666666666666", ContentType: "text"}})
createRecoveryDB(t, fromPath, []storage.IndexedMessage{{Ordinal: 0, Role: "assistant", Text: "stranded install", UUID: "77777777-7777-4777-8777-777777777777", ContentType: "text"}})
// Create empty -shm and -wal files (simulating a WAL-mode database with no pending changes)
if err := os.WriteFile(activePath+"-shm", nil, 0o600); err != nil {
t.Fatalf("write empty stale shm: %v", err)
}
if err := os.WriteFile(activePath+"-wal", nil, 0o600); err != nil {
t.Fatalf("write empty wal: %v", err)
}

_, err := Execute(context.Background(), Options{ActivePath: activePath, FromPath: fromPath})
if err == nil || !strings.Contains(err.Error(), "-shm") || !strings.Contains(err.Error(), "sidecar namespace") {
t.Fatalf("Execute error = %v, want empty shm namespace rejection", err)
// With the fix for issue #51, empty -shm is tolerated when -wal is zero bytes (no active writer)
report, err := Execute(context.Background(), Options{ActivePath: activePath, FromPath: fromPath, DryRun: true})
if err != nil {
t.Fatalf("Execute error = %v, want success when -shm has no pending changes", err)
}
if !reflect.DeepEqual(report.InputCounts, []int{1, 1}) || report.FinalCount != 2 {
t.Fatalf("report = %+v, want two inputs and two final rows", report)
}
})

Expand Down Expand Up @@ -1723,6 +1731,104 @@ func TestResolvePathAllowsMissingPathForOpenError(t *testing.T) {
}
}

// TestEnsureSourceSidecarsToleratesZeroByteWAL verifies that ensureSourceSidecarsSafe
// tolerates a zero-byte -wal file (which indicates no pending changes).
func TestEnsureSourceSidecarsToleratesZeroByteWAL(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")

// Create a zero-byte -wal file
walPath := dbPath + "-wal"
if f, err := os.Create(walPath); err != nil {
t.Fatalf("create -wal: %v", err)
} else {
f.Close()
}

// ensureSourceSidecarsSafe should tolerate the zero-byte -wal
if err := ensureSourceSidecarsSafe(dbPath); err != nil {
t.Fatalf("ensureSourceSidecarsSafe with zero-byte -wal: %v", err)
}
}

// TestEnsureSourceSidecarsToleratesSHMWithZeroWAL verifies that ensureSourceSidecarsSafe
// tolerates a -shm file when the -wal is zero bytes (issue #51 fix).
// The safety property is: if WAL has no pending changes, the database is not being actively written to.
func TestEnsureSourceSidecarsToleratesSHMWithZeroWAL(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")

// Create a -shm file (and zero-byte -wal for safety)
shamPath := dbPath + "-shm"
walPath := dbPath + "-wal"

if f, err := os.Create(shamPath); err != nil {
t.Fatalf("create -shm: %v", err)
} else {
f.Close()
}
if f, err := os.Create(walPath); err != nil {
t.Fatalf("create -wal: %v", err)
} else {
f.Close()
}

// After the fix, this should succeed because -wal is zero bytes
if err := ensureSourceSidecarsSafe(dbPath); err != nil {
t.Fatalf("ensureSourceSidecarsSafe with -shm and zero-byte -wal: %v", err)
}
}

// TestEnsureSourceSidecarsRejectsSHMWithoutWAL verifies that ensureSourceSidecarsSafe
// tolerates a -shm file when the -wal is absent (issue #51 fix).
func TestEnsureSourceSidecarsToleratesSHMWithoutWAL(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")

// Create only a -shm file (no -wal)
shamPath := dbPath + "-shm"

if f, err := os.Create(shamPath); err != nil {
t.Fatalf("create -shm: %v", err)
} else {
f.Close()
}

// After the fix, this should succeed because -wal is absent
if err := ensureSourceSidecarsSafe(dbPath); err != nil {
t.Fatalf("ensureSourceSidecarsSafe with -shm and no -wal: %v", err)
}
}

// TestEnsureSourceSidecarsRejectsSHMWithNonZeroWAL verifies that ensureSourceSidecarsSafe
// rejects a -shm file when the -wal has pending changes (is non-zero).
func TestEnsureSourceSidecarsRejectsSHMWithNonZeroWAL(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")

// Create a -shm file and a non-zero -wal file
shamPath := dbPath + "-shm"
walPath := dbPath + "-wal"

if f, err := os.Create(shamPath); err != nil {
t.Fatalf("create -shm: %v", err)
} else {
f.Close()
}
if err := os.WriteFile(walPath, []byte("pending changes"), 0o644); err != nil {
t.Fatalf("create non-zero -wal: %v", err)
}

// This should fail because -wal has pending changes
err := ensureSourceSidecarsSafe(dbPath)
if err == nil {
t.Fatal("ensureSourceSidecarsSafe with -shm and non-zero -wal should fail")
}
if !strings.Contains(err.Error(), "unexpected SQLite sidecar namespace entry") {
t.Fatalf("unexpected error message: %v", err)
}
}

type recoveryInventoryEntry struct {
Kind string
Size int64
Expand Down
Loading