Skip to content

feat(spam-stream): per-signer concurrent sends#590

Open
jelias2 wants to merge 4 commits into
flashbots:mainfrom
jelias2:jelias/spam-stream-concurrent-sends
Open

feat(spam-stream): per-signer concurrent sends#590
jelias2 wants to merge 4 commits into
flashbots:mainfrom
jelias2:jelias/spam-stream-concurrent-sends

Conversation

@jelias2

@jelias2 jelias2 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

spam-stream's drive_stream currently sends seriallysend_one(...).await per spec — so throughput is capped at roughly one RPC round-trip per tx (~20/s). When the stream supplies specs faster than that (e.g. relaying interop executing messages), a send queue builds and end-to-end latency balloons.

This makes the send path concurrent while keeping nonce handling correct.

Approach: per-signer worker pool

Spawn one worker per pool signer. Each incoming spec is routed (round-robin by idx, matching make_strict_call's idx % signers.len() selection) to the worker that owns its signer, and workers send concurrently.

Why per-signer rather than a semaphore over all sends: a bounced/rejected send must reuse its nonce (otherwise a gap stalls every later tx from that signer). That reclaim is only correct if a given signer's sends are serialized. Pinning each signer to one worker gives exactly that:

  • Per-signer serial → nonce assignment + reclaim-on-rejection are race-free with no shared nonce map and no locks (each worker owns its signer's nonce locally).
  • Cross-signer concurrent → throughput scales with pool size.
  • Workers build/sign against a shared Arc<TestScenario> using a worker-local nonce (bypassing prepare_tx_request's &mut), with the gas price shared via an atomic refreshed once per interval.

Compatibility

  • Concurrency == pool size. --pool-size 1 reproduces the original serial behavior exactly.
  • stdin spec format and stdout StreamEvent schema are unchanged (the Go-side/reactive consumer correlates by idx, so out-of-order emits are fine).
  • No public API changes; the change is contained to spam_stream.rs.

Validation

Relaying interop executing messages at a sustained rate above the serial ceiling, with a 32-signer pool: cross-chain inclusion latency dropped from p50 80s (queue-bound) to p50 4s, with send→receipt p50 ~1.5s and 0 failures. At --pool-size 1 behavior is unchanged.

Draft: opening for review/CI. Local cargo build --release is clean; cargo fmt applied.

drive_stream sent serially (send_one().await per spec), capping throughput at
~one RPC round-trip per tx (~20/s). Replace it with a per-signer worker pool:
spawn one worker per pool signer, route each spec to the worker that owns its
signer (round-robin by idx, matching make_strict_call's signer selection), and
let workers send concurrently.

Each signer's sends stay serial within its worker, so nonce assignment and
reclaim-on-rejection (reuse the nonce, no gap) remain correct with no shared
nonce state or locks. Concurrency == pool size; a pool of 1 reproduces the
original serial behavior. Workers build/sign against a shared Arc<TestScenario>
with a worker-local nonce (bypassing prepare_tx_request's &mut), and the gas
price is shared via an atomic refreshed once per interval.

Validated relaying interop executing messages: at a sustained rate above the
serial ceiling, cross-chain inclusion latency dropped from p50 80s (queue-bound)
to p50 4s with a 32-signer pool.
@jelias2 jelias2 marked this pull request as ready for review June 10, 2026 14:38
@jelias2 jelias2 requested a review from zeroXbrock as a code owner June 10, 2026 14:38
jelias2 added 3 commits June 10, 2026 10:47
…igner concurrency

Extract the two correctness invariants of the per-signer worker model into
pure helpers and unit-test them (no RPC needed):
- worker_index(idx, n): round-robin routing; assert idx and idx+n map to the
  same worker (so the same signer) and that it matches make_strict_call's
  idx % signers.len() pick — the property that keeps each signer serial.
- next_nonce(nonce, submitted): advance on accept, reuse on rejection (no gap).
Also assert pool signers are distinct (per-signer workers depend on it).

Update docs/stream-mode.md: data-flow now shows the per-signer worker pool
(concurrency == --pool-size, pool of 1 = serial) and the reuse table reflects
workers building directly with a worker-local nonce instead of prepare_tx_request.
…urrency knob

The per-signer concurrent-sends change resolves the last open question
(concurrency bounded by pool size). Drop the resolved questions, keep the one
real follow-up (Spammer-trait reuse) under Follow-ups, and note in the CLI
table that --pool-size sets send concurrency (no new flag).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant