perf(ledger): batch writes behind in-memory reservations

Raises bet throughput from ~166 to ~318 per second, measured. A 20-second
betting window now absorbs roughly 6,400 bets instead of 3,300.

A bet reserves synchronously in memory and writes in the background. The
reservation counts against the balance immediately, so two concurrent
spends of the same funds cannot both succeed while the first sits in the
buffer — twelve goroutines racing for one balance yield exactly one
winner.

The first attempt was slower than no batching at all, because Flush still
called Post per transaction and each kept its own commit. Amortising the
scheduling is worthless; the fsync is the cost. PostMany now writes the
whole batch in one database transaction, and a rejected group falls back
to individual writes to isolate the offender.

Safety rests on co-location: the reservation buffer and the round live in
the same process, so a crash loses both together — the player was not
charged and is not in the round. A round that flushed and then lost its
process is already handled by the reconciler.

Fixes a data race the detector found: MaxDelay was a public mutable field
read by the flush loop, so any operator tuning it live would have raced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-08-06 04:26:13 +00:00
parent c250ed2f80
commit 2eafcbfd68
3 changed files with 797 additions and 0 deletions

314
pkg/ledger/batch.go Normal file
View File

@@ -0,0 +1,314 @@
package ledger
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
)
// Batcher raises write throughput by grouping transactions.
//
// The ceiling on individual bets is PostgreSQL's commit cost — roughly one
// fsync each, measured at ~230/sec on a modest box. Batching amortises that
// fsync across many bets, but naively deferring writes would let a player
// spend the same balance twice while the first spend sits in a buffer.
//
// So a bet takes two steps:
//
// 1. Reserve, synchronously and in memory. The reservation is checked against
// the ledger balance minus everything already reserved, so an overdraft is
// refused immediately and with the same answer the ledger would give.
// 2. Flush, in the background. Reservations are written as one transaction.
//
// The safety argument for step 2 rests on co-location: the reservation buffer
// and the room holding the round live in the same process. If that process
// dies before a flush, the reservations are lost *and* so is the round they
// belonged to — the player was not charged and is not in the round, which is
// consistent. A round that did flush and then lost its process is handled by
// the reconciler, which refunds abandoned rounds.
//
// A round must therefore never settle before its bets have flushed.
// Room.settle enforces that by calling Flush first.
type Batcher struct {
ledger *Ledger
// maxDelay and maxBatch are read by the flush loop while callers may be
// tuning them, so they are atomic rather than plain fields. A public
// mutable field read by a running goroutine is a race waiting for the
// first operator who adjusts it live.
maxDelay atomic.Int64 // nanoseconds
maxBatch atomic.Int64
mu sync.Mutex
pending []pendingTx
reserved map[int64]int64 // account -> millisatoshis reserved but unwritten
waiters []chan error
flushing sync.Mutex // serialises flushes so ordering is preserved
stop chan struct{}
once sync.Once
// negativeOK caches whether an account may go negative. The flag is set
// when the account is created and never changes, so re-reading it per bet
// was a round trip spent re-learning something immutable.
negMu sync.RWMutex
negativeOK map[int64]bool
}
type pendingTx struct {
kind string
roundID *int64
postings []Posting
}
var ErrBatcherClosed = errors.New("ledger: batcher is closed")
func NewBatcher(l *Ledger) *Batcher {
b := &Batcher{
ledger: l,
reserved: make(map[int64]int64),
negativeOK: make(map[int64]bool),
stop: make(chan struct{}),
}
b.SetMaxDelay(200 * time.Millisecond)
b.SetMaxBatch(256)
return b
}
// SetMaxDelay sets how long a reservation may wait before being written. It
// bounds how much work a crash discards, and is safe to change while running.
func (b *Batcher) SetMaxDelay(d time.Duration) {
if d < time.Millisecond {
d = time.Millisecond
}
b.maxDelay.Store(int64(d))
}
// MaxDelay reports the current flush interval.
func (b *Batcher) MaxDelay() time.Duration {
return time.Duration(b.maxDelay.Load())
}
// SetMaxBatch sets how many transactions may queue before an early flush, so a
// burst does not build an unboundedly large database transaction.
func (b *Batcher) SetMaxBatch(n int) {
if n < 1 {
n = 1
}
b.maxBatch.Store(int64(n))
}
// Run flushes on a timer until the context ends.
func (b *Batcher) Run(ctx context.Context) {
interval := b.MaxDelay()
t := time.NewTicker(interval)
defer t.Stop()
for {
// Pick up a changed interval without restarting the loop.
if d := b.MaxDelay(); d != interval {
interval = d
t.Reset(interval)
}
select {
case <-ctx.Done():
// Flush what is held rather than discarding it: a clean shutdown
// should not lose bets that were accepted.
_ = b.Flush(context.WithoutCancel(ctx))
return
case <-b.stop:
_ = b.Flush(context.WithoutCancel(ctx))
return
case <-t.C:
if err := b.Flush(ctx); err != nil {
fmt.Printf("ledger: batch flush failed: %v\n", err)
}
}
}
}
// Close stops the batcher after a final flush.
func (b *Batcher) Close() {
b.once.Do(func() { close(b.stop) })
}
// AvailableBalance is what an account can actually spend: its ledger balance
// less anything reserved but not yet written.
func (b *Batcher) AvailableBalance(ctx context.Context, accountID int64) (int64, error) {
settled, err := b.ledger.Balance(ctx, accountID)
if err != nil {
return 0, err
}
b.mu.Lock()
defer b.mu.Unlock()
return settled + b.reserved[accountID], nil
}
// mayGoNegative reports whether an account is permitted a negative balance,
// caching the answer. The flag is immutable once an account exists.
func (b *Batcher) mayGoNegative(ctx context.Context, accountID int64) (bool, error) {
b.negMu.RLock()
v, ok := b.negativeOK[accountID]
b.negMu.RUnlock()
if ok {
return v, nil
}
var allow bool
if err := b.ledger.pool.QueryRow(ctx,
`SELECT allow_negative FROM accounts WHERE id = $1`, accountID).Scan(&allow); err != nil {
return false, fmt.Errorf("checking account %d: %w", accountID, err)
}
b.negMu.Lock()
b.negativeOK[accountID] = allow
b.negMu.Unlock()
return allow, nil
}
// Post reserves a transaction and returns once it is durably written.
//
// The reservation is taken synchronously, so two concurrent calls cannot both
// spend the same balance. The write is batched, so the caller waits for the
// next flush rather than for its own fsync — which is where the throughput
// comes from.
func (b *Batcher) Post(ctx context.Context, kind string, roundID *int64, postings []Posting) error {
if len(postings) == 0 {
return ErrEmptyTransaction
}
var sum int64
for _, p := range postings {
sum += p.AmountMsat
}
if sum != 0 {
return fmt.Errorf("%w: sum is %d", ErrUnbalanced, sum)
}
// Check every debit against the balance that will actually be available,
// which is the settled balance plus reservations already taken.
for _, p := range postings {
if p.AmountMsat >= 0 {
continue
}
available, err := b.AvailableBalance(ctx, p.AccountID)
if err != nil {
return err
}
// The bridge is allowed to go negative; everything else is not.
allowNegative, err := b.mayGoNegative(ctx, p.AccountID)
if err != nil {
return err
}
if !allowNegative && available+p.AmountMsat < 0 {
return fmt.Errorf("%w: account %d has %d available, needs %d",
ErrInsufficientFunds, p.AccountID, available, -p.AmountMsat)
}
}
done := make(chan error, 1)
b.mu.Lock()
select {
case <-b.stop:
b.mu.Unlock()
return ErrBatcherClosed
default:
}
for _, p := range postings {
b.reserved[p.AccountID] += p.AmountMsat
}
b.pending = append(b.pending, pendingTx{kind: kind, roundID: roundID, postings: postings})
b.waiters = append(b.waiters, done)
full := int64(len(b.pending)) >= b.maxBatch.Load()
b.mu.Unlock()
if full {
go func() {
if err := b.Flush(context.WithoutCancel(ctx)); err != nil {
fmt.Printf("ledger: batch flush failed: %v\n", err)
}
}()
}
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
// Flush writes every pending transaction.
//
// Each is written as its own ledger transaction, preserving the invariant that
// a transaction balances to zero. What is amortised is the round trip and the
// scheduling, not the atomicity: merging unrelated transactions into one would
// make a single bad posting roll back everyone else's bets.
func (b *Batcher) Flush(ctx context.Context) error {
b.flushing.Lock()
defer b.flushing.Unlock()
b.mu.Lock()
if len(b.pending) == 0 {
b.mu.Unlock()
return nil
}
batch := b.pending
waiters := b.waiters
b.pending = nil
b.waiters = nil
b.mu.Unlock()
release := func(tx pendingTx) {
// Release the reservation whether or not the write succeeded: it has
// either become a real posting or it never will, and in both cases
// holding it would understate what the account can spend.
b.mu.Lock()
for _, p := range tx.postings {
b.reserved[p.AccountID] -= p.AmountMsat
if b.reserved[p.AccountID] == 0 {
delete(b.reserved, p.AccountID)
}
}
b.mu.Unlock()
}
groups := make([]Group, len(batch))
for i, tx := range batch {
groups[i] = Group{Kind: tx.kind, RoundID: tx.roundID, Postings: tx.postings}
}
// The fast path: one database transaction for the whole batch, so the
// commit cost is paid once instead of once per bet.
if _, err := b.ledger.PostMany(ctx, groups); err == nil {
for i, tx := range batch {
release(tx)
waiters[i] <- nil
}
return nil
}
// Something in the batch was rejected. Because the batch shares a
// transaction, one bad group rolls back the rest, so retry individually to
// isolate the offender and let everyone else through. This is rare:
// balances are checked before a group is ever queued.
var firstErr error
for i, tx := range batch {
_, err := b.ledger.Post(ctx, tx.kind, tx.roundID, tx.postings)
release(tx)
waiters[i] <- err
if err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// Pending reports how many transactions are waiting, for tests and metrics.
func (b *Batcher) Pending() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.pending)
}

322
pkg/ledger/batch_test.go Normal file
View File

@@ -0,0 +1,322 @@
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)
}
}

View File

@@ -204,6 +204,167 @@ func isBalanceFloorViolation(err error) bool {
strings.Contains(msg, "balance_nonnegative")
}
// Group is one logical transaction inside a batch.
type Group struct {
Kind string
RoundID *int64
Postings []Posting
}
// PostMany writes several transactions in a single database transaction.
//
// This is what makes batching worth anything. Writing them one at a time costs
// one commit — and one fsync — each, which is the ceiling on how fast bets can
// be taken. Sharing a commit amortises that across the whole batch.
//
// Each group remains its own ledger transaction with its own postings, so the
// zero-sum invariant is unchanged; what is shared is durability, not identity.
//
// The trade-off is atomicity across unrelated bets: if one group fails, the
// whole batch rolls back. Callers handle that by retrying the batch one group
// at a time to isolate the offender, which is rare because balances are
// checked before a group ever enters a batch.
func (l *Ledger) PostMany(ctx context.Context, groups []Group) ([]int64, error) {
if len(groups) == 0 {
return nil, nil
}
// Validate every group before opening a transaction, so a malformed one
// cannot abort work that was otherwise fine.
for i, g := range groups {
if len(g.Postings) == 0 {
return nil, fmt.Errorf("group %d: %w", i, ErrEmptyTransaction)
}
sum := new(big.Int)
for _, p := range g.Postings {
sum.Add(sum, big.NewInt(p.AmountMsat))
}
if sum.Sign() != 0 {
return nil, fmt.Errorf("group %d: %w: sum is %s", i, ErrUnbalanced, sum)
}
}
tx, err := l.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// Lock every account the batch touches, once, in ascending order. Doing
// this per group would take and retake the same locks and reintroduce the
// deadlock risk that ordering exists to prevent.
seen := make(map[int64]struct{})
var allIDs []int64
for _, g := range groups {
for _, p := range g.Postings {
if _, ok := seen[p.AccountID]; !ok {
seen[p.AccountID] = struct{}{}
allIDs = append(allIDs, p.AccountID)
}
}
}
sort.Slice(allIDs, func(i, j int) bool { return allIDs[i] < allIDs[j] })
if _, err := tx.Exec(ctx,
`SELECT id FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE`,
allIDs); err != nil {
return nil, fmt.Errorf("locking accounts: %w", err)
}
// Read every balance once, then track them in memory as the batch is
// applied. Re-reading per group would cost a round trip each and defeat
// the point of sharing the transaction.
balances := make(map[int64]int64, len(allIDs))
rows, err := tx.Query(ctx, `
SELECT a.id,
COALESCE((SELECT p.balance_after FROM postings p
WHERE p.account_id = a.id
ORDER BY p.id DESC LIMIT 1), 0)
FROM accounts a WHERE a.id = ANY($1)`, allIDs)
if err != nil {
return nil, err
}
for rows.Next() {
var id, bal int64
if err := rows.Scan(&id, &bal); err != nil {
rows.Close()
return nil, err
}
balances[id] = bal
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
negativeOK := make(map[int64]bool, len(allIDs))
nrows, err := tx.Query(ctx,
`SELECT id, allow_negative FROM accounts WHERE id = ANY($1)`, allIDs)
if err != nil {
return nil, err
}
for nrows.Next() {
var id int64
var ok bool
if err := nrows.Scan(&id, &ok); err != nil {
nrows.Close()
return nil, err
}
negativeOK[id] = ok
}
nrows.Close()
txIDs := make([]int64, 0, len(groups))
for gi, g := range groups {
var txID int64
if err := tx.QueryRow(ctx,
`INSERT INTO transactions (kind, round_id) VALUES ($1, $2) RETURNING id`,
g.Kind, g.RoundID).Scan(&txID); err != nil {
return nil, err
}
txIDs = append(txIDs, txID)
merged := make(map[int64]int64, len(g.Postings))
var order []int64
for _, p := range g.Postings {
if _, ok := merged[p.AccountID]; !ok {
order = append(order, p.AccountID)
}
merged[p.AccountID] += p.AmountMsat
}
sort.Slice(order, func(i, j int) bool { return order[i] < order[j] })
for _, id := range order {
amount := merged[id]
if amount == 0 {
continue
}
before := balances[id]
after := before + amount
if (amount > 0 && after < before) || (amount < 0 && after > before) {
return nil, fmt.Errorf("group %d: amount %d overflows account %d",
gi, amount, id)
}
if after < 0 && !negativeOK[id] {
return nil, fmt.Errorf("group %d: %w: account %d holds %d, needs %d",
gi, ErrInsufficientFunds, id, before, -amount)
}
if _, err := tx.Exec(ctx,
`INSERT INTO postings
(transaction_id, account_id, amount_msat, balance_before, balance_after)
VALUES ($1,$2,$3,$4,$5)`,
txID, id, amount, before, after); err != nil {
return nil, err
}
balances[id] = after
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return txIDs, nil
}
// Transfer moves funds between two accounts. This is the peer-to-peer path.
func (l *Ledger) Transfer(ctx context.Context, from, to int64, amountMsat int64) (int64, error) {
if amountMsat <= 0 {