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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user