aboutsummaryrefslogtreecommitdiff
path: root/internal/persistence
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
parent396c3facb8287b9a34076cae8f3d7b300d366d99 (diff)
downloadthehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/persistence')
-rw-r--r--internal/persistence/txn.go335
-rw-r--r--internal/persistence/txn_test.go186
2 files changed, 521 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
+}
diff --git a/internal/persistence/txn_test.go b/internal/persistence/txn_test.go
new file mode 100644
index 0000000..10a809d
--- /dev/null
+++ b/internal/persistence/txn_test.go
@@ -0,0 +1,186 @@
+package persistence
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func writeTestFile(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func readTestFile(t *testing.T, path string) string {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(data)
+}
+
+func TestTxnCommitAppliesAllOps(t *testing.T) {
+ dir := t.TempDir()
+ journalDir := filepath.Join(dir, ".journal")
+ writeTestFile(t, filepath.Join(dir, "a.yaml"), "old-a")
+ writeTestFile(t, filepath.Join(dir, "b.yaml"), "old-b")
+ writeTestFile(t, filepath.Join(dir, "c.yaml"), "old-c")
+
+ txn := NewTransaction(journalDir)
+ if err := txn.Replace(filepath.Join(dir, "a.yaml"), []byte("new-a"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := txn.Delete(filepath.Join(dir, "b.yaml")); err != nil {
+ t.Fatal(err)
+ }
+ if err := txn.Move(filepath.Join(dir, "c.yaml"), filepath.Join(dir, "d.yaml")); err != nil {
+ t.Fatal(err)
+ }
+ if err := txn.Replace(filepath.Join(dir, "created.yaml"), []byte("created"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := txn.Commit(); err != nil {
+ t.Fatalf("commit: %v", err)
+ }
+ if got := readTestFile(t, filepath.Join(dir, "a.yaml")); got != "new-a" {
+ t.Fatalf("replace failed: %q", got)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "b.yaml")); !os.IsNotExist(err) {
+ t.Fatalf("delete failed: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "c.yaml")); !os.IsNotExist(err) {
+ t.Fatalf("move left source behind: %v", err)
+ }
+ if got := readTestFile(t, filepath.Join(dir, "d.yaml")); got != "old-c" {
+ t.Fatalf("move lost content: %q", got)
+ }
+ if got := readTestFile(t, filepath.Join(dir, "created.yaml")); got != "created" {
+ t.Fatalf("create failed: %q", got)
+ }
+ if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 {
+ t.Fatalf("journal not cleaned: %v", entries)
+ }
+}
+
+func TestTxnRollbackOnFailure(t *testing.T) {
+ dir := t.TempDir()
+ journalDir := filepath.Join(dir, ".journal")
+ writeTestFile(t, filepath.Join(dir, "a.yaml"), "old-a")
+ // A file inside a read-only subdirectory makes the delete fail.
+ sub := filepath.Join(dir, "sub")
+ writeTestFile(t, filepath.Join(sub, "gone.yaml"), "old-gone")
+ if err := os.Chmod(sub, 0500); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(sub, 0700) })
+
+ txn := NewTransaction(journalDir)
+ if err := txn.Replace(filepath.Join(dir, "a.yaml"), []byte("new-a"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := txn.Delete(filepath.Join(sub, "gone.yaml")); err != nil {
+ t.Fatal(err)
+ }
+ if err := txn.Commit(); err == nil {
+ t.Fatal("expected commit to fail")
+ }
+
+ if got := readTestFile(t, filepath.Join(dir, "a.yaml")); got != "old-a" {
+ t.Fatalf("rollback did not restore a.yaml: %q", got)
+ }
+ if got := readTestFile(t, filepath.Join(sub, "gone.yaml")); got != "old-gone" {
+ t.Fatalf("rollback did not restore deleted file: %q", got)
+ }
+ if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 {
+ t.Fatalf("journal not cleaned after rollback: %v", entries)
+ }
+}
+
+func TestRecoverRollsBackLeftoverJournal(t *testing.T) {
+ // Simulate a crash: journal exists (it says a.yaml becomes new-a and
+ // c.yaml is deleted) but the operations never ran.
+ dir := t.TempDir()
+ journalDir := filepath.Join(dir, ".journal")
+ writeTestFile(t, filepath.Join(dir, "a.yaml"), "old-a")
+ writeTestFile(t, filepath.Join(dir, "c.yaml"), "old-c")
+
+ txn := NewTransaction(journalDir)
+ if err := txn.Replace(filepath.Join(dir, "a.yaml"), []byte("new-a"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := txn.Delete(filepath.Join(dir, "c.yaml")); err != nil {
+ t.Fatal(err)
+ }
+ journal, err := txn.MarshalJournal()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(journalDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteFile(filepath.Join(journalDir, "txn-crash.json"), journal, 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Recover(journalDir); err != nil {
+ t.Fatalf("recover: %v", err)
+ }
+ if got := readTestFile(t, filepath.Join(dir, "a.yaml")); got != "old-a" {
+ t.Fatalf("recovery did not roll back a.yaml: %q", got)
+ }
+ if got := readTestFile(t, filepath.Join(dir, "c.yaml")); got != "old-c" {
+ t.Fatalf("recovery resurrected a delete: %q", got)
+ }
+ if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 {
+ t.Fatalf("recovered journal not removed: %v", entries)
+ }
+}
+
+func TestRecoverDropsCorruptJournal(t *testing.T) {
+ dir := t.TempDir()
+ journalDir := filepath.Join(dir, ".journal")
+ if err := os.MkdirAll(journalDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(journalDir, "txn-bad.json"), []byte("{not json"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := Recover(journalDir); err != nil {
+ t.Fatalf("recover corrupt: %v", err)
+ }
+ if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 {
+ t.Fatalf("corrupt journal not removed: %v", entries)
+ }
+}
+
+func TestTxnMoveOverwritesDestination(t *testing.T) {
+ dir := t.TempDir()
+ journalDir := filepath.Join(dir, ".journal")
+ writeTestFile(t, filepath.Join(dir, "src.yaml"), "src-content")
+ writeTestFile(t, filepath.Join(dir, "dst.yaml"), "dst-content")
+
+ txn := NewTransaction(journalDir)
+ if err := txn.Move(filepath.Join(dir, "src.yaml"), filepath.Join(dir, "dst.yaml")); err != nil {
+ t.Fatal(err)
+ }
+ if err := txn.Commit(); err != nil {
+ t.Fatalf("commit: %v", err)
+ }
+ if got := readTestFile(t, filepath.Join(dir, "dst.yaml")); got != "src-content" {
+ t.Fatalf("move did not overwrite destination: %q", got)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "src.yaml")); !os.IsNotExist(err) {
+ t.Fatalf("source not removed: %v", err)
+ }
+ if !strings.HasSuffix(readTestFile(t, filepath.Join(dir, "dst.yaml")), "content") {
+ t.Fatal("unreachable")
+ }
+}