aboutsummaryrefslogtreecommitdiff
path: root/internal/persistence/txn.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 15:35:52 -0400
committerhistoria <[not public]>2026-09-15 15:35:52 -0400
commit0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch)
tree2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/persistence/txn.go
parent396c3facb8287b9a34076cae8f3d7b300d366d99 (diff)
downloadthehouseoficarus-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.go335
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
+}