// 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" ) 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 } 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. 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) } if rErr := os.Remove(journalPath); rErr != nil && !os.IsNotExist(rErr) { 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. if err := os.Remove(journalPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("commit completed; remove journal: %w", 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) } if err := os.Remove(path); err != nil { return fmt.Errorf("remove recovered journal %s: %w", path, 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 }