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
29 changes: 17 additions & 12 deletions CLAUDE.md

Large diffs are not rendered by default.

41 changes: 12 additions & 29 deletions cmd/backscroll/compat_diagnostics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"

"github.com/pablontiv/backscroll/internal/compat"
"github.com/pablontiv/backscroll/internal/config"
"github.com/pablontiv/backscroll/internal/storage"
)

Expand Down Expand Up @@ -540,7 +538,7 @@ func TestLiveWALStartupUsesCompatibleIndexWithoutRecoveryDiagnostic(t *testing.T
}
}

func TestRecoveryContinuationExecutesInConfiguredSamePathContextWithEmptyWAL(t *testing.T) {
func TestRecoverDryRunBypassesPreparationForAlterBuiltLineage(t *testing.T) {
dbPath := newFixtureIndexDB(t, "v13-development-alter-built.sql")
setIndexPolicyEnv(t, dbPath, t.TempDir())
walPath := dbPath + "-wal"
Expand All @@ -550,40 +548,25 @@ func TestRecoveryContinuationExecutesInConfiguredSamePathContextWithEmptyWAL(t *
before := snapshotSQLiteFiles(t, dbPath)
walBefore, err := os.Stat(walPath)
if err != nil {
t.Fatalf("stat empty WAL before continuation: %v", err)
t.Fatalf("stat empty WAL before dry-run: %v", err)
}

emptyInputs := filepath.Join(t.TempDir(), "empty-inputs")
if err := os.MkdirAll(emptyInputs, 0o755); err != nil {
t.Fatalf("mkdir empty recovery inputs: %v", err)
}
cfg := &config.Config{DatabasePath: dbPath, SessionDirs: []string{emptyInputs}}
startupDiagnostic := continuationFor(compat.Diagnostic{Code: compat.CodeUnsupportedLineage, Summary: "fixture diagnostic"}, dbPath)
startupErr := indexDiagnosticError{diagnostic: startupDiagnostic}

var stdout, stderr bytes.Buffer
root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult {
return startupResult{Config: cfg, Failure: &startupFailure{
Stage: startupStageIndexPrepare,
Cause: startupErr,
Diagnostic: startupDiagnostic,
Recoverable: true,
}}
})
root.SetArgs(startupDiagnostic.Continuation)
if err := root.Execute(); err != nil {
t.Fatalf("execute continuation %v after startup diagnostic %s: %v\nstdout=%q stderr=%q", startupDiagnostic.Continuation, startupDiagnostic.Code, err, stdout.String(), stderr.String())
stdout, stderr, err := runCmd("recover", "--from", dbPath, "--dry-run")
if err != nil {
t.Fatalf("recover dry-run failed: %v\nstdout=%q stderr=%q", err, stdout, stderr)
}
if !strings.Contains(stdout.String(), "recovery dry run") {
t.Fatalf("continuation output = %q, want recovery dry run", stdout.String())
if !strings.Contains(stdout, "recovery dry run") {
t.Fatalf("stdout=%q", stdout)
}
if stderr.Len() != 0 {
t.Fatalf("continuation stderr = %q, want empty", stderr.String())
for _, forbidden := range []string{"migration_failed", "unsupported_lineage", "f6a081b9", "50016 diagnostic"} {
if strings.Contains(stdout+stderr, forbidden) {
t.Fatalf("output retained %q: stdout=%q stderr=%q", forbidden, stdout, stderr)
}
}
assertSQLiteFilesUnchanged(t, dbPath, before)
walAfter, err := os.Stat(walPath)
if err != nil {
t.Fatalf("stat empty WAL after continuation: %v", err)
t.Fatalf("stat WAL after dry-run: %v", err)
}
if walAfter.Size() != 0 || walAfter.Mode() != walBefore.Mode() || !walAfter.ModTime().Equal(walBefore.ModTime()) {
t.Fatalf("empty WAL metadata changed: before=%+v after=%+v", walBefore, walAfter)
Expand Down
53 changes: 39 additions & 14 deletions cmd/backscroll/index_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,38 +143,63 @@ func TestPrepareIndexDataReadReturnsReadOnlyConnection(t *testing.T) {
}

func TestPrepareIndexDataReadDoesNotApplyPendingMigration(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "index.db")
writer, err := storage.Open(dbPath)
if err != nil {
t.Fatal(err)
}
if _, err := writer.DB().Exec(`DELETE FROM schema_migrations WHERE version = 13`); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
dbPath := newFixtureIndexDB(t, "v13.sql")

db, diag, err := prepareIndex(context.Background(), &config.Config{DatabasePath: dbPath}, indexDataRead)
if db != nil {
_ = db.Close()
t.Fatal("read preparation returned DB requiring migration")
}
if err == nil && diag == nil {
if err != nil {
t.Fatalf("read preparation returned unexpected error: %v", err)
}
if diag == nil {
t.Fatal("read preparation accepted pending migration")
}
if diag.Code != compat.CodeIndexStale {
t.Fatalf("read preparation diagnostic code=%q, want %q", diag.Code, compat.CodeIndexStale)
}
if !strings.Contains(diag.Summary, "migration") || !strings.Contains(diag.Summary, "step") {
t.Fatalf("read preparation diagnostic summary=%q, want pending migration-step summary", diag.Summary)
}

inspect, err := storage.OpenReadOnly(dbPath)
if err != nil {
t.Fatal(err)
}
defer inspect.Close()
var count int
if err := inspect.DB().QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 13`).Scan(&count); err != nil {
if err := inspect.DB().QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 14`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("read path applied migration 13 count=%d", count)
t.Fatalf("read path applied migration 14 count=%d", count)
}
assertIndexedFilesColumnAbsent(t, inspect.DB(), "file_size")
assertIndexedFilesColumnAbsent(t, inspect.DB(), "file_mtime")
}

func assertIndexedFilesColumnAbsent(t *testing.T, db *sql.DB, column string) {
t.Helper()
rows, err := db.Query(`PRAGMA table_info(indexed_files)`)
if err != nil {
t.Fatalf("indexed_files table_info: %v", err)
}
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notNull, pk int
var defaultValue sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notNull, &defaultValue, &pk); err != nil {
t.Fatalf("scan indexed_files column: %v", err)
}
if name == column {
t.Fatalf("read path applied migration 14 column %s", column)
}
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate indexed_files columns: %v", err)
}
}

Expand Down
8 changes: 3 additions & 5 deletions cmd/backscroll/recover.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"errors"
"fmt"
"io"
"strings"
Expand Down Expand Up @@ -33,12 +32,11 @@ func newRecoverCmd(stdout, stderr io.Writer) *cobra.Command {
},
RunE: func(cmd *cobra.Command, args []string) error {
startup := startupResultFrom(cmd)
startupFailure := optionalStartupFailureError(startup.startupFailure())
cfg := startup.Config
if cfg == nil {
loaded, err := config.Load()
if err != nil {
return errors.Join(startupFailure, fmt.Errorf("load config for recovery: %w", err))
return fmt.Errorf("load config for recovery: %w", err)
}
cfg = loaded
}
Expand All @@ -51,7 +49,7 @@ func newRecoverCmd(stdout, stderr io.Writer) *cobra.Command {
if backupPath, ok := recovery.RestorableBackupPath(err); ok {
_, _ = fmt.Fprintf(stderr, "manual recovery backup path: %s\n", backupPath)
}
return errors.Join(startupFailure, fmt.Errorf("recovery failed: %w", err))
return fmt.Errorf("recovery failed: %w", err)
}
if !dryRun {
if err := recoverPostInstallSync(cfg, stderr); err != nil {
Expand All @@ -63,7 +61,7 @@ func newRecoverCmd(stdout, stderr io.Writer) *cobra.Command {
if report.BackupPath != "" {
_, _ = fmt.Fprintf(stderr, "manual recovery backup path: %s\n", report.BackupPath)
}
return errors.Join(startupFailure, fmt.Errorf("post-recovery sync: %w", err))
return fmt.Errorf("post-recovery sync: %w", err)
}
}
printRecoveryReport(stdout, report, dryRun)
Expand Down
46 changes: 6 additions & 40 deletions cmd/backscroll/recover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,7 @@ func TestRecoverDryRunSkipsPostInstallSync(t *testing.T) {
}
}

func TestRecoverPostInstallSyncFailurePreservesStartupCause(t *testing.T) {
startupErr := errors.New("injected startup failure")
func TestRecoverPostInstallSyncFailurePreservesSyncCause(t *testing.T) {
syncErr := errors.New("injected post-sync failure")
cfg := &config.Config{DatabasePath: filepath.Join(t.TempDir(), "active.db")}
installedPath := cfg.DatabasePath + ".installed"
Expand All @@ -188,21 +187,17 @@ func TestRecoverPostInstallSyncFailurePreservesStartupCause(t *testing.T) {

var stdout, stderr bytes.Buffer
root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult {
return startupResult{Config: cfg, Failure: &startupFailure{
Stage: startupStageStartupSync,
Cause: startupErr,
Diagnostic: continuationFor(compat.Diagnostic{Code: compat.CodeIndexStale, Summary: startupErr.Error()}, cfg.DatabasePath),
Recoverable: true,
}}
return startupResult{Config: cfg}
})
root.SetArgs([]string{"recover", "--from", "stranded.db"})
err := root.Execute()
if !errors.Is(err, startupErr) {
t.Fatalf("error=%v does not preserve startup failure", err)
}
if !errors.Is(err, syncErr) {
t.Fatalf("error=%v does not preserve post-sync failure", err)
}
var failure *startupFailure
if errors.As(err, &failure) {
t.Fatalf("error=%v unexpectedly matches startupFailure target %#v", err, failure)
}
if stdout.Len() != 0 {
t.Fatalf("report printed before failed post-sync: %q", stdout.String())
}
Expand All @@ -216,35 +211,6 @@ func TestRecoverPostInstallSyncFailurePreservesStartupCause(t *testing.T) {
}
}

func TestRecoverSuccessfulContinuationRemediatesStartupFailure(t *testing.T) {
startupErr := errors.New("injected startup failure")
cfg := &config.Config{DatabasePath: filepath.Join(t.TempDir(), "active.db")}

originalExecute := recoverExecute
recoverExecute = func(context.Context, recovery.Options) (recovery.Report, error) {
return recovery.Report{ActivePath: cfg.DatabasePath}, nil
}
t.Cleanup(func() { recoverExecute = originalExecute })

originalPostInstallSync := recoverPostInstallSync
recoverPostInstallSync = func(*config.Config, io.Writer) error { return nil }
t.Cleanup(func() { recoverPostInstallSync = originalPostInstallSync })

var stdout, stderr bytes.Buffer
root := buildRootCmdWithStartup(&stdout, &stderr, func(context.Context, io.Writer, startupCommandClass) startupResult {
return startupResult{Config: cfg, Failure: &startupFailure{
Stage: startupStageStartupSync,
Cause: startupErr,
Diagnostic: continuationFor(compat.Diagnostic{Code: compat.CodeIndexStale, Summary: startupErr.Error()}, cfg.DatabasePath),
Recoverable: true,
}}
})
root.SetArgs([]string{"recover", "--from", "stranded.db"})
if err := root.Execute(); err != nil {
t.Fatalf("recover returned startup failure after successful remediation: %v", err)
}
}

func TestRecoverDryRunMatchesUnionApplyPlanWithoutWrites(t *testing.T) {
dir := t.TempDir()
home := filepath.Join(dir, "home")
Expand Down
13 changes: 9 additions & 4 deletions cmd/backscroll/startup_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const (
startupSnapshotRead startupCommandClass = "snapshot-read"
startupMetadataRead startupCommandClass = "metadata-read"
startupMutation startupCommandClass = "mutation"
startupRemediation startupCommandClass = "remediation"
startupClassKey = "backscroll.io/startup-class"
)

Expand All @@ -17,25 +18,29 @@ func startupCommandClassFor(cmd *cobra.Command) (startupCommandClass, bool) {
}
class := startupCommandClass(cmd.Annotations[startupClassKey])
switch class {
case startupSnapshotRead, startupMetadataRead, startupMutation:
case startupSnapshotRead, startupMetadataRead, startupMutation, startupRemediation:
return class, true
default:
return startupMutation, false
}
}

func startupClassRetainsLease(class startupCommandClass) bool {
return class == startupMutation || class == startupRemediation
}

func registerStartupCommand(root *cobra.Command, class startupCommandClass, cmd *cobra.Command) {
if cmd.Annotations == nil {
cmd.Annotations = make(map[string]string)
}
cmd.Annotations[startupClassKey] = string(class)
if class == startupMutation {
cmd.RunE = wrapMutationRunE(cmd.RunE)
if startupClassRetainsLease(class) {
cmd.RunE = wrapLeaseRetainingRunE(cmd.RunE)
}
root.AddCommand(cmd)
}

func wrapMutationRunE(runE func(*cobra.Command, []string) error) func(*cobra.Command, []string) (retErr error) {
func wrapLeaseRetainingRunE(runE func(*cobra.Command, []string) error) func(*cobra.Command, []string) (retErr error) {
return func(cmd *cobra.Command, args []string) (retErr error) {
defer func() { retErr = startupResultFrom(cmd).release(retErr) }()
return runE(cmd, args)
Expand Down
Loading
Loading