// Package persistence implements crash-safe multi-file mutations: write-ahead // journal transactions with undo on recovery, used for account/character // lifecycle changes. package persistence // Multi-file journal transactions. A Transaction collects file operations // (replace, delete, move), snapshots before-images, writes a journal entry // BEFORE mutating anything, applies the operations, and removes the journal // on success. If any operation fails, every applied operation is rolled back. // If the process dies mid-commit, the leftover journal is rolled back at the // next Recover call (identities revert to their pre-transaction state; no // half-renamed accounts or orphaned characters survive a crash). // // Transactions do NOT serialize concurrent callers: like single-file saves, // they must be executed by an owner that prevents concurrent mutations of // the same files (the game's owner goroutine). // // On apply failure the applied operations are rolled back and the journal is // removed. If the ROLLBACK itself fails, the journal is deliberately kept: // its before-images are then the only durable record of the pre-transaction // state, and the next Recover retries the rollback. import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" ) // journalRemoveAttempts bounds the retries of a journal unlink: the unlink is // a transaction's commit point, so a transient failure must not be reported // as a committed transaction that the next startup will roll back. const journalRemoveAttempts = 3 type fileOp struct { // Replace / Delete / Move data Path string `json:"path"` // file this record is keyed by (its before-image lives here) Kind string `json:"kind"` // "replace", "delete", "move", "move-target" MoveTo string `json:"move_to,omitempty"` // move: destination New []byte `json:"new,omitempty"` // replace: after-image; nil means delete target NewExists bool `json:"new_exists,omitempty"` NewMode uint32 `json:"new_mode,omitempty"` // Before-image snapshot (for rollback), captured on first touch. Old []byte `json:"old,omitempty"` OldExists bool `json:"old_exists"` OldMode uint32 `json:"old_mode,omitempty"` } const ( kindReplace = "replace" kindDelete = "delete" kindMove = "move" kindMoveTarget = "move-target" ) type journalEntry struct { Ops []fileOp `json:"ops"` } 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 func(string) error } // syncDir fsyncs a directory so an entry created or removed in it survives a // crash. POSIX does not tie a file's own fsync to its directory entry, so the // journal write, its removal, and every applied file are synced in both. func syncDir(dir string) error { d, err := os.Open(dir) if err != nil { return err } defer d.Close() return d.Sync() } // syncJournalDir syncs the transaction's journal directory. func (t *Transaction) syncJournalDir() error { if t.dirSync != nil { return t.dirSync(t.journalDir) } return syncDir(t.journalDir) } // removeJournalDurable unlinks a journal and syncs its directory, so the // removal itself is durable. The removal is the transaction's commit point: an // unlink that a crash forgets resurrects the journal, and Recover then rolls // back an operation the caller was told succeeded. A failing remove is retried; // a persistent failure is reported as such, with the journal left on disk as // the durable record of the transaction (the next startup rolls it back). func (t *Transaction) removeJournalDurable(path string) error { var removeErr error for attempt := 0; attempt < journalRemoveAttempts; attempt++ { removeErr = os.Remove(path) if removeErr == nil || os.IsNotExist(removeErr) { if err := t.syncJournalDir(); 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 } } return fmt.Errorf("journal %s could not be removed after %d attempts and is retained on disk (the transaction is not durable and will be rolled back at the next startup): %w", path, journalRemoveAttempts, removeErr) } func NewTransaction(journalDir string) *Transaction { return &Transaction{ ops: make(map[string]*fileOp), journalDir: journalDir, } } // getOp returns (creating if needed) the operation record for path, capturing // the before-image the first time the path is touched. func (t *Transaction) getOp(path string) (*fileOp, error) { if op, ok := t.ops[path]; ok { return op, nil } mode := uint32(0600) info, err := os.Stat(path) oldExists := err == nil if err != nil && !os.IsNotExist(err) { return nil, err } if oldExists { mode = uint32(info.Mode().Perm()) } var old []byte if oldExists { old, err = os.ReadFile(path) if err != nil { return nil, fmt.Errorf("snapshot %s: %w", path, err) } } op := &fileOp{ Path: path, Old: old, OldExists: oldExists, OldMode: mode, } t.ops[path] = op t.order = append(t.order, path) return op, nil } // Replace writes data over path (creating it if needed). When the path is // also a move destination (move then replace content in one transaction), // the replace augments the move-target record: apply writes the moved file, // rollback restores the move-target before-image. func (t *Transaction) Replace(path string, data []byte, perm os.FileMode) error { op, err := t.getOp(path) if err != nil { return err } if op.Kind == kindMoveTarget { op.New = data op.NewExists = true op.NewMode = uint32(perm) return nil } op.Kind = kindReplace op.New = data op.NewExists = true op.NewMode = uint32(perm) return nil } // Delete removes path. Deleting a missing path is a no-op. func (t *Transaction) Delete(path string) error { op, err := t.getOp(path) if err != nil { return err } op.Kind = kindDelete op.New, op.NewExists = nil, false return nil } // Move relocates a file (rename semantics). If newPath already exists its // before-image is captured in a separate move-target record for rollback. func (t *Transaction) Move(oldPath, newPath string) error { src, err := t.getOp(oldPath) if err != nil { return err } dst, err := t.getOp(newPath) if err != nil { return err } if src.Kind == kindMove && src.MoveTo != newPath { return fmt.Errorf("conflicting move of %s", oldPath) } if dst.Kind != kindMoveTarget && dst.Kind != "" { return fmt.Errorf("conflicting operation on %s", newPath) } // An existing replace/delete on the destination becomes a move-target // (the destination's before-image governs rollback). dst.Kind = kindMoveTarget src.Kind = kindMove src.MoveTo = newPath return nil } func (t *Transaction) MarshalJournal() ([]byte, error) { ops := make([]fileOp, 0, len(t.order)) for _, p := range t.order { ops = append(ops, *t.ops[p]) } return json.Marshal(journalEntry{Ops: ops}) } // Commit writes the journal (durable), applies all operations, and removes the // journal durably, so the commit point itself survives a crash. Any failure // rolls back every already-applied operation and the journal itself, leaving // the filesystem exactly as before the commit. func (t *Transaction) Commit() error { if len(t.order) == 0 { return nil } journal, err := t.MarshalJournal() if err != nil { return fmt.Errorf("marshal journal: %w", err) } if err := os.MkdirAll(t.journalDir, 0700); err != nil { return fmt.Errorf("journal dir: %w", err) } var id [8]byte if _, err := rand.Read(id[:]); err != nil { return fmt.Errorf("journal id: %w", err) } journalPath := filepath.Join(t.journalDir, "txn-"+hex.EncodeToString(id[:])+".json") if err := WriteFile(journalPath, journal, 0600); err != nil { return fmt.Errorf("write journal: %w", err) } if err := t.apply(); err != nil { if rbErr := t.rollback(); rbErr != nil { // The rollback could not restore the before-images: the journal // is now the only durable record of the pre-transaction state. // KEEP it so the next Recover call (or operator repair of the // underlying fault) can retry the rollback instead of silently // losing the before-images forever. See // TestCommitKeepsJournalAfterFailedRollback. return fmt.Errorf("%v; rollback failed (journal kept for recovery: %s): %v", err, journalPath, rbErr) } // The operations were rolled back, so the leftover journal holds // nothing but the pre-transaction before-images; removing it must be // durable for the same reason the commit point is. if rErr := t.removeJournalDurable(journalPath); rErr != nil { err = fmt.Errorf("%v; journal cleanup failed: %v", err, rErr) } return err } // Commit point: the journal is the record of applied operations. After // it is removed the transaction is complete and unrecoverable, so the // removal must be durable like every other step: a crash that forgets it // would replay the before-images over operations reported as committed // (and over later writes to the same files). if err := t.removeJournalDurable(journalPath); err != nil { return err } return nil } func (t *Transaction) apply() error { for _, p := range t.order { op := t.ops[p] switch op.Kind { case kindReplace: if err := WriteFile(op.Path, op.New, os.FileMode(op.NewMode)); err != nil { return fmt.Errorf("write %s: %w", op.Path, err) } case kindDelete: if err := os.Remove(op.Path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("delete %s: %w", op.Path, err) } case kindMove: // Remove an existing destination first so the rename cannot fail // on a non-empty target; the destination's before-image is // captured in its move-target record for rollback. if _, err := os.Stat(op.MoveTo); err == nil { if err := os.Remove(op.MoveTo); err != nil { return fmt.Errorf("move remove destination %s: %w", op.MoveTo, err) } } if err := os.Rename(op.Path, op.MoveTo); err != nil { return fmt.Errorf("move %s: %w", op.Path, err) } case kindMoveTarget: // Content replace after the move delivered the source bytes. if op.NewExists { if err := WriteFile(op.Path, op.New, os.FileMode(op.NewMode)); err != nil { return fmt.Errorf("write %s: %w", op.Path, err) } } } } return nil } func (t *Transaction) rollback() error { var firstErr error record := func(err error) { if firstErr == nil { firstErr = err } } // Restore in reverse order so a move that overwrote a destination // resurrects the destination before the source is restored. for i := len(t.order) - 1; i >= 0; i-- { op := t.ops[t.order[i]] restore := func(path string) error { if op.OldExists { return WriteFile(path, op.Old, os.FileMode(op.OldMode)) } return os.Remove(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: 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 } // Recover rolls back every leftover journal in journalDir. Call it at // startup before any store reads files the journals may reference. func Recover(journalDir string) error { if err := os.MkdirAll(journalDir, 0700); err != nil { return err } entries, err := os.ReadDir(journalDir) if err != nil { return err } for _, e := range entries { if e.IsDir() || !hasSuffix(e.Name(), ".json") { continue } path := filepath.Join(journalDir, e.Name()) data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read journal %s: %w", path, err) } var entry journalEntry if err := json.Unmarshal(data, &entry); err != nil { // An unparsable journal was likely mid-write and never applied // any operation (journals are written atomically). Drop it. if rmErr := os.Remove(path); rmErr != nil { return fmt.Errorf("remove corrupt journal %s: %w", path, rmErr) } continue } t := &Transaction{ops: make(map[string]*fileOp), journalDir: journalDir} for i := range entry.Ops { op := entry.Ops[i] t.ops[op.Path] = &op t.order = append(t.order, op.Path) } if err := t.rollback(); err != nil { return fmt.Errorf("recover %s: %w", path, err) } // The rollback is durable only once its journal removal is: a // resurrected journal would roll back later writes to the same files. if err := t.removeJournalDurable(path); err != nil { return err } } return nil } func hasSuffix(s, suffix string) bool { return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix } // ListJournalFiles is a debugging helper: returns sorted journal file names. func ListJournalFiles(journalDir string) ([]string, error) { entries, err := os.ReadDir(journalDir) if err != nil { return nil, err } var out []string for _, e := range entries { if !e.IsDir() { out = append(out, e.Name()) } } return out, nil }