From 91b466b6923d3eb1df1658199960fe7aa6db36dc Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sun, 20 Sep 2026 15:35:58 -0400 Subject: feat: new placeholder world --- .../persistence/txn_directory_durability_test.go | 175 +++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 internal/persistence/txn_directory_durability_test.go (limited to 'internal/persistence/txn_directory_durability_test.go') 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) + } +} -- cgit v1.2.3