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) } }