package ledger_test import ( "context" "errors" "sync" "sync/atomic" "testing" "time" "github.com/drjones/quantum-arcade/pkg/ledger" ) // Batching is only worth having if it cannot lose or create money. These pin // that down before any throughput claim is made. func newBatcher(t *testing.T) (*ledger.Batcher, *ledger.Ledger, context.Context) { t.Helper() l := ledger.New(testPool(t)) b := ledger.NewBatcher(l) b.SetMaxDelay(50 * time.Millisecond) ctx, cancel := context.WithCancel(context.Background()) go b.Run(ctx) t.Cleanup(func() { b.Close() cancel() }) return b, l, context.Background() } func TestBatchedPostIsDurable(t *testing.T) { b, l, ctx := newBatcher(t) from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from")) to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to")) if _, err := l.Deposit(ctx, from, 100_000); err != nil { t.Fatal(err) } if err := b.Post(ctx, "transfer", nil, []ledger.Posting{ {AccountID: from, AmountMsat: -10_000}, {AccountID: to, AmountMsat: 10_000}, }); err != nil { t.Fatal(err) } // Post returns only once written, so the ledger must already show it. if bal, _ := l.Balance(ctx, to); bal != 10_000 { t.Fatalf("recipient balance = %d after Post returned, want 10000", bal) } } // The property that makes deferred writes safe: a reservation must count // against the balance immediately, or the same funds could be spent twice // while the first spend sits in the buffer. func TestReservationsPreventDoubleSpend(t *testing.T) { b, l, ctx := newBatcher(t) from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from")) to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to")) if _, err := l.Deposit(ctx, from, 10_000); err != nil { t.Fatal(err) } const workers = 12 var wg sync.WaitGroup var ok atomic.Int64 for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() // Each tries to spend the entire balance. if err := b.Post(ctx, "transfer", nil, []ledger.Posting{ {AccountID: from, AmountMsat: -10_000}, {AccountID: to, AmountMsat: 10_000}, }); err == nil { ok.Add(1) } }() } wg.Wait() if ok.Load() != 1 { t.Fatalf("%d of %d concurrent spends of the same balance succeeded, want 1", ok.Load(), workers) } if bal, _ := l.Balance(ctx, from); bal != 0 { t.Fatalf("source balance = %d, want 0", bal) } if bal, _ := l.Balance(ctx, to); bal != 10_000 { t.Fatalf("recipient balance = %d, want exactly one transfer of 10000", bal) } } func TestAvailableBalanceReflectsReservations(t *testing.T) { b, l, ctx := newBatcher(t) b.SetMaxDelay(5 * time.Second) // hold the flush so the reservation is visible from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from")) to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to")) if _, err := l.Deposit(ctx, from, 50_000); err != nil { t.Fatal(err) } go func() { _ = b.Post(ctx, "transfer", nil, []ledger.Posting{ {AccountID: from, AmountMsat: -20_000}, {AccountID: to, AmountMsat: 20_000}, }) }() // Wait for the reservation to be taken but not yet written. deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { avail, err := b.AvailableBalance(ctx, from) if err != nil { t.Fatal(err) } if avail == 30_000 { return // reserved amount is subtracted, as it must be } time.Sleep(10 * time.Millisecond) } avail, _ := b.AvailableBalance(ctx, from) t.Fatalf("available balance = %d while 20000 is reserved, want 30000", avail) } func TestOverdraftRefusedBeforeReserving(t *testing.T) { b, l, ctx := newBatcher(t) from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from")) to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to")) if _, err := l.Deposit(ctx, from, 1_000); err != nil { t.Fatal(err) } err := b.Post(ctx, "transfer", nil, []ledger.Posting{ {AccountID: from, AmountMsat: -5_000}, {AccountID: to, AmountMsat: 5_000}, }) if !errors.Is(err, ledger.ErrInsufficientFunds) { t.Fatalf("got %v, want ErrInsufficientFunds", err) } // And a subsequent affordable spend must still work, proving the refused // attempt left no reservation behind. if err := b.Post(ctx, "transfer", nil, []ledger.Posting{ {AccountID: from, AmountMsat: -1_000}, {AccountID: to, AmountMsat: 1_000}, }); err != nil { t.Fatalf("an affordable spend after a refused one failed: %v", err) } } func TestUnbalancedBatchIsRefused(t *testing.T) { b, l, ctx := newBatcher(t) a, _ := l.EnsurePlayer(ctx, uniqueKey(t, "a")) c, _ := l.EnsurePlayer(ctx, uniqueKey(t, "c")) if err := b.Post(ctx, "bad", nil, []ledger.Posting{ {AccountID: a, AmountMsat: -100}, {AccountID: c, AmountMsat: 50}, }); !errors.Is(err, ledger.ErrUnbalanced) { t.Fatalf("got %v, want ErrUnbalanced", err) } } // Value must be conserved across a large batched workload. func TestBatchedWorkloadConservesValue(t *testing.T) { b, l, ctx := newBatcher(t) const players = 10 ids := make([]int64, players) for i := range ids { id, _ := l.EnsurePlayer(ctx, uniqueKey(t, string(rune('a'+i)))) if _, err := l.Deposit(ctx, id, 100_000); err != nil { t.Fatal(err) } ids[i] = id } sumOwn := func() int64 { var total int64 for _, id := range ids { bal, err := l.Balance(ctx, id) if err != nil { t.Fatal(err) } total += bal } return total } before := sumOwn() var wg sync.WaitGroup for i := 0; i < players; i++ { wg.Add(1) go func(i int) { defer wg.Done() for j := 0; j < 20; j++ { from := ids[i] to := ids[(i+1)%players] _ = b.Post(ctx, "transfer", nil, []ledger.Posting{ {AccountID: from, AmountMsat: -500}, {AccountID: to, AmountMsat: 500}, }) } }(i) } wg.Wait() if err := b.Flush(ctx); err != nil { t.Fatal(err) } if after := sumOwn(); after != before { t.Fatalf("batched workload changed total value: %d -> %d", before, after) } for _, id := range ids { if bal, _ := l.Balance(ctx, id); bal < 0 { t.Fatalf("account %d went negative under batching: %d", id, bal) } } } // Flush must drain everything, so a caller can guarantee durability before // settling a round. func TestFlushDrainsEverything(t *testing.T) { b, l, ctx := newBatcher(t) b.SetMaxDelay(time.Hour) // only an explicit Flush will write from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from")) to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to")) if _, err := l.Deposit(ctx, from, 100_000); err != nil { t.Fatal(err) } for i := 0; i < 5; i++ { go func() { _ = b.Post(ctx, "transfer", nil, []ledger.Posting{ {AccountID: from, AmountMsat: -1_000}, {AccountID: to, AmountMsat: 1_000}, }) }() } // Wait for them to be queued. deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) && b.Pending() < 5 { time.Sleep(10 * time.Millisecond) } if err := b.Flush(ctx); err != nil { t.Fatal(err) } if p := b.Pending(); p != 0 { t.Fatalf("%d transactions still pending after Flush", p) } if bal, _ := l.Balance(ctx, to); bal != 5_000 { t.Fatalf("recipient balance = %d after flush, want 5000", bal) } } // The throughput claim, measured rather than asserted. func TestBatchedThroughputBeatsDirect(t *testing.T) { if testing.Short() { t.Skip("throughput measurement") } b, l, ctx := newBatcher(t) // A longer window collects larger batches, which is where the amortisation // comes from. 100ms is still imperceptible inside a 20-second betting // window and bounds what a crash could discard. b.SetMaxDelay(100 * time.Millisecond) house, _ := l.EnsurePlayer(ctx, uniqueKey(t, "house")) const players = 32 ids := make([]int64, players) for i := range ids { id, _ := l.EnsurePlayer(ctx, uniqueKey(t, "p"+string(rune('a'+i%26))+string(rune('0'+i/26)))) if _, err := l.Deposit(ctx, id, 10_000_000); err != nil { t.Fatal(err) } ids[i] = id } run := func(post func(from int64) error) float64 { var wg sync.WaitGroup var ok atomic.Int64 start := time.Now() for _, id := range ids { wg.Add(1) go func(id int64) { defer wg.Done() for i := 0; i < 15; i++ { if err := post(id); err == nil { ok.Add(1) } } }(id) } wg.Wait() return float64(ok.Load()) / time.Since(start).Seconds() } direct := run(func(from int64) error { _, err := l.Post(ctx, "bet", nil, []ledger.Posting{ {AccountID: from, AmountMsat: -100}, {AccountID: house, AmountMsat: 100}, }) return err }) batched := run(func(from int64) error { return b.Post(ctx, "bet", nil, []ledger.Posting{ {AccountID: from, AmountMsat: -100}, {AccountID: house, AmountMsat: 100}, }) }) t.Logf("direct: %.0f bets/sec", direct) t.Logf("batched: %.0f bets/sec (%.1fx)", batched, batched/direct) t.Logf(" -> a 20s betting window absorbs about %.0f batched bets", batched*20) if batched <= direct { t.Fatalf("batching did not improve throughput: %.0f vs %.0f", batched, direct) } }