diff options
| author | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
| commit | 0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch) | |
| tree | 2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/persistence/txn.go | |
| parent | 396c3facb8287b9a34076cae8f3d7b300d366d99 (diff) | |
| download | thehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz | |
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/persistence/txn.go')
| -rw-r--r-- | internal/persistence/txn.go | 335 |
1 files changed, 335 insertions, 0 deletions
diff --git a/internal/persistence/txn.go b/internal/persistence/txn.go new file mode 100644 index 0000000..7d86cc8 --- /dev/null +++ b/internal/persistence/txn.go @@ -0,0 +1,335 @@ +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). + +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 { + err = fmt.Errorf("%v; rollback failed: %v", err, 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 +} |
