diff options
| author | historia <[not public]> | 2026-09-18 21:09:05 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 21:09:05 -0400 |
| commit | ff04d26ce90d6b411a04a2fb59f7c24ceefb161f (patch) | |
| tree | 754bbd991d147f48822bda519586917e5440916c /internal/game/economy.go | |
| parent | 0f812ccc307ea0550511ccced40b65dfb0cdaa91 (diff) | |
| download | thehouseoficarus-ff04d26ce90d6b411a04a2fb59f7c24ceefb161f.tar.gz | |
fix: keep uncertain payout append from double-paying on recovery
Diffstat (limited to 'internal/game/economy.go')
| -rw-r--r-- | internal/game/economy.go | 51 |
1 files changed, 43 insertions, 8 deletions
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 |
