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
28 changes: 28 additions & 0 deletions docs/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,34 @@ Recovery is remediation, not a missing-input discovery command:
copy as a dry-run recovery input first. Retire backups only after explicitly
accepting the recovered/restored state.

### Immutable connections and recovery source verification

`storage.OpenImmutableReadOnly` opens an existing canonical main database with
SQLite `mode=ro&immutable=1`, preserving the database and its SQLite sidecars.
It rejects a nonempty WAL observed before opening, since committed frames may
still be outside the main file. Its caller supplies a fully checkpointed,
quiescent database in a trusted filesystem namespace for the connection's entire
lifetime.

Recovery adds a distinct source-verification policy: WAL is absent or an empty
regular file; a leftover regular SHM is accepted in either case; every journal
namespace entry is rejected. File-set fingerprinting requires regular files,
including SHM, and checks content hashes and file identity before and after
immutable reads. Consequently WAL/SHM symlinks are rejected by recovery even
when an immutable connection alone could open the main file. A synthetic journal
entry can likewise be left untouched by the standalone opener while recovery
refuses the file set; that observation establishes a namespace-policy difference,
not a real hot-journal stale-read or data-loss reproduction.

Dry-run applies these checks to both inputs. Apply reads the active input under
a SQLite writer reservation and revalidates its main file and preserved backup
around replacement; a distinct stranded input gets the immutable before/after
file-set checks. Fingerprints detect observed changes rather than reserve the
stranded writer. Keep its writers stopped throughout recovery, even when WAL is
empty or absent. The connection-level and recovery-level policies intentionally
remain separate.



V20 preserves nullable native session identity as canonical recovery evidence.
Compatible unknown ownership/project proof can be enriched, while contradictory
Expand Down
9 changes: 6 additions & 3 deletions internal/recovery/recovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ func planRecoveryFromImmutablePaths(ctx context.Context, paths []resolvedPath, r
return plan, diagnostics, nil
}

// readImmutableRecoveryInput layers recovery's sidecar namespace and before/after
// file-set content/identity checks over storage's immutable connection. Callers
// keep stranded writers quiescent; apply reserves the active input separately.
func readImmutableRecoveryInput(ctx context.Context, path string, dryRun bool, deps recoveryOperationDeps) (compat.RecoveryInput, error) {
if err := deps.ensureSidecars(path); err != nil {
return compat.RecoveryInput{}, recoveryOpenError(path, err, dryRun)
Expand Down Expand Up @@ -934,7 +937,7 @@ func restoreBackup(activePath, backupPath string, backupSidecars []string, plann
}

func ensureSourceSidecarsSafe(dbPath string) error {
// Check WAL file status first to determine if -shm is safe
// An absent or empty regular WAL makes a leftover SHM eligible for inspection.
walPath := dbPath + "-wal"
walInfo, walErr := os.Lstat(walPath)
walIsSafe := walErr != nil || (walInfo != nil && walInfo.Mode().IsRegular() && walInfo.Size() == 0)
Expand All @@ -949,8 +952,8 @@ 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
// SHM is eligible with an absent/empty WAL. File-set fingerprinting
// separately requires regular files and stable content/identity.
if suffix == "-shm" && walIsSafe {
continue
}
Expand Down
8 changes: 4 additions & 4 deletions internal/recovery/recovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1399,7 +1399,7 @@ func TestRecoverSQLiteSidecarPolicy(t *testing.T) {
fromPath := filepath.Join(dir, "stranded.db")
createRecoveryDB(t, activePath, []storage.IndexedMessage{{Ordinal: 0, Role: "user", Text: "active", UUID: "44444444-4444-4444-8444-444444444444", ContentType: "text"}})
createRecoveryDB(t, fromPath, []storage.IndexedMessage{{Ordinal: 0, Role: "assistant", Text: "stranded", UUID: "55555555-5555-4555-8555-555555555555", ContentType: "text"}})
if err := os.WriteFile(activePath+"-journal", []byte("hot journal sentinel"), 0o600); err != nil {
if err := os.WriteFile(activePath+"-journal", []byte("synthetic journal namespace sentinel"), 0o600); err != nil {
t.Fatalf("write rollback journal: %v", err)
}

Expand All @@ -1423,7 +1423,7 @@ func TestRecoverSQLiteSidecarPolicy(t *testing.T) {
t.Fatalf("write empty wal: %v", err)
}

// With the fix for issue #51, empty -shm is tolerated when -wal is zero bytes (no active writer)
// Empty WAL makes leftover regular SHM eligible; recovery also fingerprints the file set.
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)
Expand Down Expand Up @@ -1753,7 +1753,7 @@ func TestEnsureSourceSidecarsToleratesZeroByteWAL(t *testing.T) {

// 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.
// Quiescent inputs remain a prerequisite; an empty WAL alone is not a writer reservation.
func TestEnsureSourceSidecarsToleratesSHMWithZeroWAL(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
Expand All @@ -1779,7 +1779,7 @@ func TestEnsureSourceSidecarsToleratesSHMWithZeroWAL(t *testing.T) {
}
}

// TestEnsureSourceSidecarsRejectsSHMWithoutWAL verifies that ensureSourceSidecarsSafe
// TestEnsureSourceSidecarsToleratesSHMWithoutWAL verifies that ensureSourceSidecarsSafe
// tolerates a -shm file when the -wal is absent (issue #51 fix).
func TestEnsureSourceSidecarsToleratesSHMWithoutWAL(t *testing.T) {
dir := t.TempDir()
Expand Down
13 changes: 9 additions & 4 deletions internal/storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,10 +394,15 @@ func openReadOnlyContext(ctx context.Context, path string) (*Database, error) {
return &Database{db: db, path: canonicalPath}, nil
}

// OpenImmutableReadOnly opens an existing SQLite database without creating or
// touching SQLite sidecar files. It refuses non-empty WAL files because an
// immutable view can miss committed frames that are not checkpointed into the
// main database file.
// OpenImmutableReadOnly opens an existing canonical SQLite main database with
// mode=ro and immutable=1, preserving its bytes and SQLite sidecars. It refuses a
// non-empty WAL observed before opening because committed frames may not yet be
// checkpointed into the main file.
//
// Callers must supply a fully checkpointed, quiescent database in a trusted
// filesystem namespace for the connection's lifetime. Recovery additionally
// validates sidecar namespace entries and fingerprints file-set content and
// identity before and after reading; those are separate recovery guarantees.
func OpenImmutableReadOnly(path string) (*Database, error) {
return openImmutableReadOnlyContext(context.Background(), path)
}
Expand Down
43 changes: 43 additions & 0 deletions internal/storage/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,49 @@ func TestOpenImmutableReadOnlyDoesNotCreateSidecars(t *testing.T) {
assertStorageSidecarMissing(t, dbPath+"-shm")
}

func TestOpenImmutableReadOnlyPreservesExistingSidecars(t *testing.T) {
for _, tt := range []struct {
name string
sidecars map[string][]byte
}{
{name: "regular SHM", sidecars: map[string][]byte{"-shm": []byte("leftover SHM")}},
{name: "empty WAL with SHM", sidecars: map[string][]byte{"-wal": nil, "-shm": []byte("leftover SHM")}},
{name: "synthetic journal namespace", sidecars: map[string][]byte{"-journal": []byte("synthetic namespace entry, not a hot journal")}},
} {
t.Run(tt.name, func(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "immutable_sidecars.db")
writer := openTestDBWithLiveWAL(t, dbPath)
if err := writer.Close(); err != nil {
t.Fatalf("close checkpointed fixture: %v", err)
}
for suffix, data := range tt.sidecars {
if err := os.WriteFile(dbPath+suffix, data, 0o600); err != nil {
t.Fatalf("write sidecar %s: %v", suffix, err)
}
}
before := snapshotRecoveryDB(t, dbPath)

immutable, err := OpenImmutableReadOnly(dbPath)
if err != nil {
t.Fatalf("OpenImmutableReadOnly checkpointed fixture: %v", err)
}
var count int
err = immutable.DB().QueryRow("SELECT COUNT(*) FROM search_items WHERE text = 'live WAL committed row'").Scan(&count)
closeErr := immutable.Close()
if err != nil {
t.Fatalf("read checkpointed row: %v", err)
}
if closeErr != nil {
t.Fatalf("close immutable fixture: %v", closeErr)
}
if count != 1 {
t.Fatalf("checkpointed row count = %d, want 1", count)
}
assertRecoveryDBSnapshot(t, dbPath, before)
})
}
}

// TestOpenReadOnlyNonExistent verifies that opening a non-existent database fails.
func TestOpenReadOnlyNonExistent(t *testing.T) {
_, err := OpenReadOnly("/nonexistent/path/db.sqlite")
Expand Down
Loading