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

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 {