diff options
| author | historia <[not public]> | 2026-09-20 15:35:58 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-20 15:35:58 -0400 |
| commit | 91b466b6923d3eb1df1658199960fe7aa6db36dc (patch) | |
| tree | e8a5f308530037f9471e19e6f194177c784c36b6 /internal/persistence | |
| parent | bab5dd653039d026eb3d4e46da7aafef7137fe7c (diff) | |
| download | thehouseoficarus-91b466b6923d3eb1df1658199960fe7aa6db36dc.tar.gz | |
feat: new placeholder world
Diffstat (limited to 'internal/persistence')
| -rw-r--r-- | internal/persistence/txn.go | 60 | ||||
| -rw-r--r-- | internal/persistence/txn_directory_durability_test.go | 175 |
2 files changed, 216 insertions, 19 deletions
diff --git a/internal/persistence/txn.go b/internal/persistence/txn.go index 9939204..400d355 100644 --- a/internal/persistence/txn.go +++ b/internal/persistence/txn.go @@ -64,9 +64,9 @@ type Transaction struct { ops map[string]*fileOp order []string journalDir string - // dirSync makes a journal directory entry durable (nil means syncDir). - // It is a field rather than a package-level function so a test can - // assert that the commit point is synced and inject a sync failure. + // dirSync makes directory mutations durable (nil means syncDir). + // Per-transaction so tests can check ordering and inject failures + // without changing a package-global hook. dirSync func(string) error } @@ -82,12 +82,31 @@ func syncDir(dir string) error { return d.Sync() } -// syncJournalDir syncs the transaction's journal directory. -func (t *Transaction) syncJournalDir() error { +func (t *Transaction) syncDirectory(dir string) error { if t.dirSync != nil { - return t.dirSync(t.journalDir) + return t.dirSync(dir) } - return syncDir(t.journalDir) + return syncDir(dir) +} + +// removeFileDurable also syncs an already-absent file's parent: recovery may +// be retrying a deletion whose previous directory sync failed. A missing +// parent needs no sync because this transaction does not create directories. +func (t *Transaction) removeFileDurable(path string) error { + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return err + } + dir := filepath.Dir(path) + if os.IsNotExist(err) { + if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { + return nil + } + } + if err := t.syncDirectory(dir); err != nil { + return fmt.Errorf("sync directory %s after delete: %w", dir, err) + } + return nil } // removeJournalDurable unlinks a journal and syncs its directory, so the @@ -101,7 +120,7 @@ func (t *Transaction) removeJournalDurable(path string) error { for attempt := 0; attempt < journalRemoveAttempts; attempt++ { removeErr = os.Remove(path) if removeErr == nil || os.IsNotExist(removeErr) { - if err := t.syncJournalDir(); err != nil { + if err := t.syncDirectory(t.journalDir); err != nil { return fmt.Errorf("journal %s removed but its directory could not be synced (a crash may resurrect the journal and roll the transaction back): %w", path, err) } return nil @@ -291,7 +310,7 @@ func (t *Transaction) apply() error { return fmt.Errorf("write %s: %w", op.Path, err) } case kindDelete: - if err := os.Remove(op.Path); err != nil && !os.IsNotExist(err) { + if err := t.removeFileDurable(op.Path); err != nil { return fmt.Errorf("delete %s: %w", op.Path, err) } case kindMove: @@ -306,6 +325,17 @@ func (t *Transaction) apply() error { if err := os.Rename(op.Path, op.MoveTo); err != nil { return fmt.Errorf("move %s: %w", op.Path, err) } + // A cross-directory rename changes both directory entries. Make + // both durable before the recovery journal can be retired. + srcDir, dstDir := filepath.Dir(op.Path), filepath.Dir(op.MoveTo) + if err := t.syncDirectory(srcDir); err != nil { + return fmt.Errorf("sync move source directory %s: %w", srcDir, err) + } + if dstDir != srcDir { + if err := t.syncDirectory(dstDir); err != nil { + return fmt.Errorf("sync move destination directory %s: %w", dstDir, err) + } + } case kindMoveTarget: // Content replace after the move delivered the source bytes. if op.NewExists { @@ -333,21 +363,13 @@ func (t *Transaction) rollback() error { if op.OldExists { return WriteFile(path, op.Old, os.FileMode(op.OldMode)) } - return os.Remove(path) + return t.removeFileDurable(path) } switch op.Kind { - case kindMove, kindMoveTarget: - if err := restore(op.Path); err != nil && !os.IsNotExist(err) { - record(fmt.Errorf("restore %s: %w", op.Path, err)) - } - case kindReplace: + case kindMove, kindMoveTarget, kindReplace, kindDelete: if err := restore(op.Path); err != nil { record(fmt.Errorf("restore %s: %w", op.Path, err)) } - case kindDelete: - if err := restore(op.Path); err != nil && !os.IsNotExist(err) { - record(fmt.Errorf("restore %s: %w", op.Path, err)) - } } } return firstErr diff --git a/internal/persistence/txn_directory_durability_test.go b/internal/persistence/txn_directory_durability_test.go new file mode 100644 index 0000000..134580e --- /dev/null +++ b/internal/persistence/txn_directory_durability_test.go @@ -0,0 +1,175 @@ +package persistence + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestTransactionSyncsChangedDirectoriesBeforeCommit(t *testing.T) { + for _, operation := range []string{"delete", "move same directory", "move across directories"} { + t.Run(operation, func(t *testing.T) { + dir := t.TempDir() + journalDir := filepath.Join(dir, ".journal") + srcDir := filepath.Join(dir, "source") + dstDir := srcDir + if operation == "move across directories" { + dstDir = filepath.Join(dir, "destination") + } + src, dst := filepath.Join(srcDir, "a.yaml"), filepath.Join(dstDir, "b.yaml") + writeTestFile(t, src, "source") + writeTestFile(t, dst, "destination") + txn := NewTransaction(journalDir) + var err error + if operation == "delete" { + err = txn.Delete(src) + } else { + err = txn.Move(src, dst) + } + if err != nil { + t.Fatal(err) + } + var synced []string + txn.dirSync = func(d string) error { + entries, err := ListJournalFiles(journalDir) + if err != nil { + t.Fatal(err) + } + if d != journalDir && len(entries) != 1 { + t.Fatal("journal retired before data directory sync") + } + if d == journalDir && len(entries) != 0 { + t.Fatal("commit point did not remove journal") + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Fatalf("sync must follow deletion/rename: %v", err) + } + synced = append(synced, d) + return syncDir(d) + } + if err := txn.Commit(); err != nil { + t.Fatal(err) + } + want := []string{srcDir} + if dstDir != srcDir { + want = append(want, dstDir) + } + want = append(want, journalDir) + if !reflect.DeepEqual(synced, want) { + t.Fatalf("sync order = %v, want %v", synced, want) + } + if operation != "delete" && readTestFile(t, dst) != "source" { + t.Fatal("move did not replace destination") + } + }) + } +} + +func TestTransactionDataSyncFailureRollsBack(t *testing.T) { + for _, failAt := range []string{"delete", "move source", "move destination"} { + t.Run(failAt, func(t *testing.T) { + dir := t.TempDir() + journalDir := filepath.Join(dir, ".journal") + srcDir, dstDir := filepath.Join(dir, "source"), filepath.Join(dir, "destination") + src, dst := filepath.Join(srcDir, "a.yaml"), filepath.Join(dstDir, "b.yaml") + writeTestFile(t, src, "source") + writeTestFile(t, dst, "destination") + txn := NewTransaction(journalDir) + var err error + if failAt == "delete" { + err = txn.Delete(src) + } else { + err = txn.Move(src, dst) + } + if err != nil { + t.Fatal(err) + } + failed := false + fault := errors.New("injected data directory sync failure") + txn.dirSync = func(d string) error { + failDir := srcDir + if failAt == "move destination" { + failDir = dstDir + } + if d == failDir { + failed = true + return fault + } + // Restoring existing before-images uses WriteFile, which + // already syncs their directories before journal removal. + if d == journalDir && (readTestFile(t, src) != "source" || readTestFile(t, dst) != "destination") { + t.Fatal("journal retired before rollback restored files") + } + return syncDir(d) + } + if err := txn.Commit(); !errors.Is(err, fault) || !failed { + t.Fatalf("commit error = %v, fault injected = %t", err, failed) + } + entries, err := ListJournalFiles(journalDir) + if err != nil || len(entries) != 0 { + t.Fatalf("successful rollback should retire journal: %v, %v", entries, err) + } + }) + } +} + +func TestRollbackDeletionSyncFailureRetainsJournalAndRetries(t *testing.T) { + dir := t.TempDir() + journalDir := filepath.Join(dir, ".journal") + createdDir, failDir := filepath.Join(dir, "created"), filepath.Join(dir, "fail") + if err := os.Mkdir(createdDir, 0700); err != nil { + t.Fatal(err) + } + created, deleted := filepath.Join(createdDir, "new.yaml"), filepath.Join(failDir, "old.yaml") + writeTestFile(t, deleted, "before") + txn := NewTransaction(journalDir) + if err := txn.Replace(created, []byte("new"), 0600); err != nil { + t.Fatal(err) + } + if err := txn.Delete(deleted); err != nil { + t.Fatal(err) + } + var synced []string + txn.dirSync = func(d string) error { + synced = append(synced, d) + return errors.New("injected directory sync failure") + } + if err := txn.Commit(); err == nil || !strings.Contains(err.Error(), "rollback failed") { + t.Fatalf("expected apply and rollback sync failures, got %v", err) + } + if !reflect.DeepEqual(synced, []string{failDir, createdDir}) { + t.Fatalf("syncs = %v; missing rollback deletion barrier", synced) + } + if _, err := os.Stat(created); !os.IsNotExist(err) { + t.Fatalf("rollback should have removed created file: %v", err) + } + if entries, err := ListJournalFiles(journalDir); err != nil || len(entries) != 1 { + t.Fatalf("failed rollback must retain journal: %v, %v", entries, err) + } + + // Retry the same rollback with the file already absent: skipping its + // directory sync would incorrectly retire an undurable deletion. + synced = nil + txn.dirSync = func(d string) error { + synced = append(synced, d) + return syncDir(d) + } + if err := txn.rollback(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(synced, []string{createdDir}) { + t.Fatalf("retry syncs = %v, want %s", synced, createdDir) + } + if err := Recover(journalDir); err != nil { + t.Fatal(err) + } + if readTestFile(t, deleted) != "before" { + t.Fatal("recovery did not restore deleted file") + } + if entries, err := ListJournalFiles(journalDir); err != nil || len(entries) != 0 { + t.Fatalf("recovery did not retire journal: %v, %v", entries, err) + } +} |
