aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/economy.go')
-rw-r--r--internal/game/economy.go51
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