feat(relay): group-commit batching for plain event inserts (F1)#2146
Closed
tlongwell-block wants to merge 4 commits into
Closed
feat(relay): group-commit batching for plain event inserts (F1)#2146tlongwell-block wants to merge 4 commits into
tlongwell-block wants to merge 4 commits into
Conversation
At high ingest rates each plain event pays a full synchronous COMMIT (WAL flush; storage-quorum ack on Aurora). A single writer task now coalesces concurrent inserts into one transaction so one commit amortizes over the whole batch, while each caller keeps its own per-event result. Design (buzz_db::batch): - Opportunistic, not timed: the worker takes one request then drains whatever else is queued (up to BUZZ_WRITE_BATCH_MAX, default 16). A lone request under light load flushes immediately via the direct path — zero added latency; batches only form while a previous transaction is committing. - ACK-after-commit: a caller's future resolves only after the transaction containing its event durably committed. - Poison isolation, statement time: savepoint per event; a failing statement (FK violation, rejected kind) fails only that event. - Poison isolation, commit time: deferred constraint triggers (0021/0022) fire during COMMIT after savepoints are gone, so one bad row aborts the batch. A server-reported COMMIT error (SQLSTATE present — provable abort) replays every request individually through the unbatched path so the poison fails alone. - Indeterminate commits are not replayed: a transport failure awaiting the COMMIT response may have committed server-side; a blind replay would misreport durable inserts as duplicates and skip their mentions. Callers get DbError::CommitOutcomeUnknown; client retries are idempotent (ON CONFLICT DO NOTHING). - Mentions stay post-commit on the pool, log-and-succeed, per event — exactly matching the unbatched wrapper. - Degradation: a dead worker makes the batcher handle return None and ingest falls through to today's direct path; it never hangs a future. Relay wiring: BUZZ_WRITE_BATCH_MAX env knob (0 disables, state holds Option<EventWriteBatcher>); only the plain persistent-event branch routes through the batcher — kind:9007 channel-create, replaceables, and reactions stay direct. Coalescing counters exported as buzz_write_batch_* Prometheus counters from the existing pool-metrics poller; committed vs. fallback vs. indeterminate are distinguishable by construction. Tests (PG-backed, #[ignore]): mixed batch (good / in-batch duplicate / FK poison / rejected AUTH kind / threaded replies) with per-event result pinning and exact thread counters; deferred-trigger abort → per-event replay; commit-error classification; dead-worker fallback; 64-way concurrent coalescing with committed-before-ACK asserted from a second connection. Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Review blocker from Mari: treating every sqlx::Error::Database as proof of rollback is unsafe — PostgreSQL reports indeterminate commit outcomes as server errors too (08007 transaction_resolution_unknown, 40003 statement_completion_unknown). A false "aborted" would replay a transaction that actually committed, misreport durable inserts as duplicates, and skip their mentions. Classify as provably-aborted only an explicit allowlist: class 23 (integrity constraint violation — deferred triggers such as the 0021/0022 guards raise 23514, deferred FK/unique checks land here too), 40001 serialization_failure, and 40P01 deadlock_detected. Everything else — including 08007/40003, unknown codes, and missing SQLSTATE — defaults to indeterminate (no replay, CommitOutcomeUnknown to callers). The classification test now raises real SQLSTATEs through Postgres and table-tests both sides of the allowlist, specifically rejecting 08007/40003. Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
… dawn/f1-write-batching Brings in migrations 0023/0024. Batch-txn interaction reviewed: the batch holds 0023 push-gate shared locks (per community, insert-time) and 0024 channel-TTL shared locks (per channel, deferred at COMMIT) for all events in the lane until batch commit. Shared-shared admits concurrent lanes and direct inserts; no path takes two exclusive advisory locks, so no deadlock ordering is introduced by multi-key batches. Savepoint rollback of a poisoned event does not release its already-acquired shared push-gate lock (held to txn end) - correctness-neutral, slightly extends the window an exclusive lease activation may wait. Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Mari's repaired-T1a-base gate at bdbf2f0 (release binary, alternated 0/16 restarts, matched accepted counts): batching cuts DB commits/msg ~25% but regresses p50 43-58% and p99 52-74% at 500-1000 QPS. The single global batch lane serializes all channels behind one transaction at a time, which outweighs commit amortization on the repaired base. Ship the machinery opt-in; flip the default only if the lane-serialization cost is fixed and remeasured. Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Collaborator
Author
|
Parking this without merge, per Tyler's call after the performance gate. Outcome summary:
Branch |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Group-commit batching for plain (non-replaceable, non-reaction) event inserts. At high ingest rates each plain event pays a full synchronous COMMIT (WAL flush; storage-quorum ack on Aurora). A single writer task per relay coalesces concurrent inserts into one transaction so one commit amortizes over the batch, while each caller keeps its own per-event result.
Per discussion with Tyler/Mari: one worker per relay + horizontal relay scaling is the deliberate shape — no internal worker pool until measurements force it.
Design (
buzz_db::batch)BUZZ_WRITE_BATCH_MAX, default 0 = off; see Perf validation). A lone request under light load flushes immediately through the direct path — zero added latency at low QPS; batches only form while a previous transaction is committing.DbError::CommitOutcomeUnknown; client retries are idempotent (ON CONFLICT DO NOTHING→duplicate:). Semantics-preserving vs. today: a single-event commit whose response is lost also errors to the client today.was_inserted == true, each caller answered after its own attempt — exactly matching the unbatched wrapper. The batch path uses the existing privateinsert_event_with_thread_metadata_tx(nowpub(crate)), which is mentions-free by construction; the fallback path uses the public wrapper which owns its mentions. Disjoint paths, no double pass.None→ ingest falls through to today's direct path. Worker panic mid-batch drops the oneshots → callers getCommitOutcomeUnknown.Relay wiring
BUZZ_WRITE_BATCH_MAXenv knob;0disables (state holdsOption<EventWriteBatcher>, ingest uses the direct path — pre-batching behavior exactly).buzz_write_batch_*Prometheus counters from the existing pool-metrics poller:commits_total,events_committed_total,single_flushes_total,fallback_events_total,indeterminate_total. Committed vs. fallback vs. indeterminate are distinguishable by construction (batch_*count only successful multi-event commits).Tests
PG-backed (
#[ignore], run with--include-ignoredagainst local PG):mixed_batch_maps_results_per_event— one batch of [good, dup, dup-again, FK-poison, rejected AUTH kind, threaded reply ×2]: per-event result pinning, poison fails alone, in-batch dedup, exact thread counters on the root, durability of every reported insert.deferred_trigger_abort_replays_per_event— pool armed with the migration-0021created_atfloor; a stale event aborts the batch at COMMIT, replay commits the good events and fails the stale one alone.commit_abort_classification— server-reported SQLSTATE → aborted; IO/pool-shaped errors → not proven aborted.dead_worker_returns_none_for_fallback— no DB; dead batcher signals fallback instead of hanging.concurrent_inserts_coalesce_and_all_commit— 64 concurrent inserts through the public API; committed-before-ACK asserted by reading each row from a second connection at the instant its future resolves; counters prove real multi-event commits happened.All 5 green against local PG;
clippy --all-targets -D warningsandfmt --checkclean on buzz-db + buzz-relay; unfilteredcargo test -p buzz-db --liband-p buzz-relay --libpass (two pre-existing failures on clean main unrelated to this change:usage_metrics_lockunder--include-ignoredparallelism,mesh_demo504 — both fail identically with this diff stashed).Perf validation (done — default is OFF)
Mari's gate on the repaired-T1a base (merge tip
bdbf2f0c1, release binary, alternated0/16restarts against the same migrated-0024 PG, 64 synchronous clients, hot permanent channel, matched accepted counts):Batching cuts DB commits/msg ~25% but regresses p50 43–58% and p99 52–74% at 500–1000 QPS: the single global batch lane serializes all channels behind one transaction at a time, which outweighs commit amortization.
BUZZ_WRITE_BATCH_MAXtherefore defaults to0(batching off, exact pre-batching behavior); the machinery ships opt-in. Flipping the default requires fixing the lane-serialization cost and remeasuring.