aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 21:09:05 -0400
committerhistoria <[not public]>2026-09-18 21:09:05 -0400
commitff04d26ce90d6b411a04a2fb59f7c24ceefb161f (patch)
tree754bbd991d147f48822bda519586917e5440916c /internal
parent0f812ccc307ea0550511ccced40b65dfb0cdaa91 (diff)
downloadthehouseoficarus-ff04d26ce90d6b411a04a2fb59f7c24ceefb161f.tar.gz
fix: keep uncertain payout append from double-paying on recovery
Diffstat (limited to 'internal')
-rw-r--r--internal/game/casino.go25
-rw-r--r--internal/game/economy.go51
-rw-r--r--internal/game/economy_rollback_regression_test.go89
3 files changed, 156 insertions, 9 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 736add2..f111ed0 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -136,6 +136,22 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool, stakeIDs []stri
return capErr
}
w.p.Credits = creditsAfter
+ // The append failed, but the record may still be IN the journal: a
+ // failed rollback (a truncate, or the sync that would make the
+ // truncation durable) leaves the bytes behind, and appendRetry can
+ // leave bytes from an earlier attempt even when its final error is
+ // definite. Record the attempted record's ID as open-payout evidence
+ // in the SAME save that makes this payout durable, so recovery
+ // recognizes a surviving record as already paid instead of
+ // re-applying it (a double-pay). When no record survived, the stale
+ // ID is pruned at the next startup.
+ attemptID := ""
+ if rec != nil {
+ attemptID = rec.ID
+ }
+ if attemptID != "" {
+ w.p.OpenPayouts = append(w.p.OpenPayouts, attemptID)
+ }
// The character file is the only durable record, so it must also
// carry the resolution evidence for the stakes this settlement
// resolves: a crash before the games' own ResolveStakes call would
@@ -148,6 +164,9 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool, stakeIDs []stri
resolved := w.recordStakeEvidence(stakeIDs)
if serr := w.g.AccountStore.SaveCharacter(w.p); serr != nil {
w.p.Credits = creditsBefore
+ if attemptID != "" {
+ removeOpenPayout(w.p, attemptID)
+ }
if resolved > 0 {
w.p.ResolvedStakes = w.p.ResolvedStakes[:len(w.p.ResolvedStakes)-resolved]
}
@@ -157,7 +176,11 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool, stakeIDs []stri
if refund {
kind = "refund"
}
- fmt.Fprintf(os.Stderr, "economy: %s payout of %d credits for %s could not be journaled; the character file is the only durable record\n", kind, amount, w.p.Name)
+ if errors.Is(err, errJournalAppendUncertain) {
+ fmt.Fprintf(os.Stderr, "economy: %s payout of %d credits for %s could not be journaled and the append could not be rolled back; recorded as open-payout evidence so recovery will not re-apply it\n", kind, amount, w.p.Name)
+ } else {
+ fmt.Fprintf(os.Stderr, "economy: %s payout of %d credits for %s could not be journaled; the character file is the only durable record\n", kind, amount, w.p.Name)
+ }
return nil
}
if capErr != nil {
diff --git a/internal/game/economy.go b/internal/game/economy.go
index 9853671..28e2880 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -47,6 +47,7 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -165,8 +166,20 @@ func newEconomyRecordID() string {
return hex.EncodeToString(b[:])
}
+// errJournalAppendUncertain reports that a journal append failed AND the
+// record's bytes could not be proven removed: the rollback truncate failed,
+// or the sync that would make that truncation durable failed, or the
+// pre-write size was unknown. The record MAY therefore still be present in
+// the journal. Callers must not treat the transaction as definitely absent:
+// the wallet's journal-unavailable payout fallback records the attempted
+// record's ID as open-payout evidence so a surviving record is settled by
+// recovery instead of being re-applied (a double-pay).
+var errJournalAppendUncertain = errors.New("economy journal append may have left the record in the journal")
+
// append writes one durable line. Errors are returned to the caller, which
-// must treat the transaction as failed.
+// must treat the transaction as failed. An error wrapping
+// errJournalAppendUncertain additionally means the record may have survived
+// in the journal.
func (j *economyJournal) append(rec economyRecord) error {
rec.TS = time.Now()
data, err := json.Marshal(rec)
@@ -195,12 +208,20 @@ func (j *economyJournal) append(rec economyRecord) error {
startSize = fi.Size()
}
rollback := func(cause error) error {
- if startSize >= 0 {
- if terr := f.Truncate(startSize); terr != nil {
- fmt.Fprintf(os.Stderr, "economy journal: rollback truncate after %v: %v\n", cause, terr)
- } else {
- _ = f.Sync()
- }
+ if startSize < 0 {
+ // The pre-write size is unknown, so the record cannot be truncated
+ // away with confidence: it may remain in the journal.
+ return fmt.Errorf("%w: %v (pre-write size unknown)", errJournalAppendUncertain, cause)
+ }
+ if terr := f.Truncate(startSize); terr != nil {
+ fmt.Fprintf(os.Stderr, "economy journal: rollback truncate after %v: %v\n", cause, terr)
+ return fmt.Errorf("%w: %v (rollback truncate: %v)", errJournalAppendUncertain, cause, terr)
+ }
+ if serr := f.Sync(); serr != nil {
+ // The truncation is not durable: a crash can restore the record
+ // bytes, so the append outcome is still unknown.
+ fmt.Fprintf(os.Stderr, "economy journal: rollback sync after %v: %v\n", cause, serr)
+ return fmt.Errorf("%w: %v (rollback sync: %v)", errJournalAppendUncertain, cause, serr)
}
return cause
}
@@ -283,10 +304,20 @@ func (j *economyJournal) repairTornTail() error {
// that money correctness depends on.
func (j *economyJournal) appendRetry(rec economyRecord, attempts int) error {
var err error
+ uncertain := false
for i := 0; i < max(attempts, 1); i++ {
if err = j.append(rec); err == nil {
return nil
}
+ if errors.Is(err, errJournalAppendUncertain) {
+ uncertain = true
+ }
+ }
+ // An earlier attempt whose rollback could not be proven may have left the
+ // record bytes behind even when the final attempt's error is definite, so
+ // the aggregate must still report that the record MAY be present.
+ if uncertain && !errors.Is(err, errJournalAppendUncertain) {
+ err = fmt.Errorf("%w: %v", errJournalAppendUncertain, err)
}
return err
}
@@ -324,7 +355,11 @@ func (j *economyJournal) startKind(kind string, refund bool, covers []string, p
j.seq++
rec.Seq = j.seq
if err := j.appendRetry(*rec, 5); err != nil {
- return nil, err
+ // Return the attempted record with the error. It is NOT durable, but
+ // its ID is what a surviving (uncertain) append would carry, so the
+ // wallet's journal-unavailable payout fallback can record it as
+ // open-payout evidence and stop recovery re-applying the record.
+ return rec, err
}
if rec.Tracked {
j.openPayouts[rec.ID] = true
diff --git a/internal/game/economy_rollback_regression_test.go b/internal/game/economy_rollback_regression_test.go
new file mode 100644
index 0000000..bceb351
--- /dev/null
+++ b/internal/game/economy_rollback_regression_test.go
@@ -0,0 +1,89 @@
+package game
+
+// Rollback-failure regressions for the durable money journal. When a journal
+// append fails, the invariant was historically "append failed means no record
+// bytes remain". A failed rollback (the truncate, or the sync that makes it
+// durable) breaks that invariant: the record can survive while the caller
+// believes the append failed. The wallet's journal-unavailable payout fallback
+// must then still record the attempted record's ID as open-payout evidence, or
+// recovery re-applies the surviving payout and pays the player twice.
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestAppendRollbackFailureIsUncertain: /dev/full accepts the open but fails
+// the write with ENOSPC and the rollback truncate with EINVAL, so the append
+// must report that the record MAY have survived.
+func TestAppendRollbackFailureIsUncertain(t *testing.T) {
+ if _, err := os.Stat("/dev/full"); err != nil {
+ t.Skipf("/dev/full is not available: %v", err)
+ }
+ j := newEconomyJournal(t.TempDir())
+ j.path = "/dev/full"
+
+ err := j.append(economyRecord{ID: "uncertain", Kind: "payout", Status: "open"})
+ if !errors.Is(err, errJournalAppendUncertain) {
+ t.Fatalf("append rollback failure = %v, want errJournalAppendUncertain", err)
+ }
+}
+
+// TestFallbackPayoutRecordsEvidenceForSurvivingRecord: a payout whose journal
+// append fails and whose rollback cannot be proven must leave open-payout
+// evidence in the character file. A surviving record is then settled from that
+// evidence at the next startup instead of being re-applied.
+func TestFallbackPayoutRecordsEvidenceForSurvivingRecord(t *testing.T) {
+ g, p := newEvidenceHarness(t)
+
+ // Break the journal append: point it at its own directory, which cannot
+ // be opened for append or truncation. The wallet takes the file-only
+ // fallback.
+ journalPath := g.Economy.path
+ g.Economy.path = filepath.Dir(journalPath)
+ w := casinoWallet{g: g, p: p}
+ if err := w.PayCredits(20); err != nil {
+ t.Fatalf("fallback payout failed: %v", err)
+ }
+ if p.Credits != 110 {
+ t.Fatalf("fallback payout not applied: credits=%d, want 110", p.Credits)
+ }
+ if len(p.OpenPayouts) != 1 {
+ t.Fatalf("fallback payout recorded %d open-payout evidence IDs, want 1: %v",
+ len(p.OpenPayouts), p.OpenPayouts)
+ }
+ id := p.OpenPayouts[0]
+
+ // Simulate the failed append having survived in the journal (the exact
+ // state a failed rollback produces). Recovery must use the file evidence
+ // and settle it, not re-apply it.
+ g.Economy.path = journalPath
+ rec := economyRecord{
+ ID: id, Player: p.Name, PlayerID: p.ID, Kind: "payout", Tracked: true,
+ Credit: 20, Status: "open", CreditsBefore: 90, CreditsAfter: 110,
+ }
+ line, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(journalPath, append(line, '\n'), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ g.recoverEconomy()
+
+ loaded, err := g.AccountStore.LoadCharacter(p.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 110 {
+ t.Fatalf("balance after recovery = %d, want 110 (surviving payout settled from evidence, not re-applied)",
+ loaded.Credits)
+ }
+ if open := g.Economy.mustLoadOpen(t); len(open) != 0 {
+ t.Fatalf("records left open after recovery: %+v", open)
+ }
+}