Skip to content

electrum: answer read-only batch items concurrently - #1175

Merged
reardencode merged 4 commits into
reardencode:masterfrom
bobberb:electrum-batch-concurrency
Oct 24, 2026
Merged

reardencode merged 4 commits into
reardencode:masterfrom
bobberb:electrum-batch-concurrency

Conversation

@bobberb

@bobberb bobberb commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Batch items used to run one after another, so a 100-address get_history page cost the sum of 100 lookups.

  • Read-only items (scripthash history/balance/unspent/mempool, tx get/merkle/id_from_pos, block header(s), fee estimates, outpoint status) go to the blocking pool, up to ElectrumConfig::batch_window per connection (half the node's blocking pool, at least 1: 2 on a 4-thread pool), and are written in request order.
  • Slot i is written before read i + window starts, so a client that stops reading still stops the work and holds at most a window of unwritten replies. Each read reports its own dispatch time.
  • Anything stateful (subscribe, unsubscribe, server.version, broadcast, ...) waits for the reads before it, then runs alone through answer_one. Notifications, non-objects, streaming methods and the 345 cap are unchanged.
  • The single-request path is the same code, split into spawn_dispatch and finish_reply.
  • Memory: an unpaged scripthash join above --heavy-sh-creates (default 10000, 0 off) takes a node-wide turn, so only one heavy join runs at a time across all Electrum and Esplora clients, whatever the batch window or connection count. Lighter joins stay parallel. A waiting heavy join holds a blocking thread.

Numbers, 100 get_history lookups, median of 7, warm cache:

one batch 2 conns 4 conns 8 conns
mainnet node (4 vCPU), small set 191 ms 112 69 52
mainnet node, mixed set (20 with 100 to 735 entries) 669 ms 345 244 236

The mainnet split across connections stands in for in-batch concurrency; it flattens at 4. Local regtest, this branch vs master, one batch line (12 cores, tiny store): small 8.9 ms to 3.4 ms, mixed 32.9 ms to 15.5 ms.

Tested: new suite tests use a debug-only, opt-in trace of spawn/serial/write steps to check the window never goes past 4, that a subscribe drains earlier reads and is seen by a later unsubscribe, order, and that a stalled reader stops reads at slot + 4. Hand-checked mutants (window bound off by one, unbounded, serial, barrier without drain, pop_back order, subscribe or unsubscribe treated as a read) all fail. cargo test -p rbitcoin-electrum and the rbitcoin-test electrum journeys pass, plus fmt, clippy, ast-grep and cargo check --release.

Closes #1174

🤖 Generated with Claude Code

answer_batch ran every item one after another, so a 100-address
get_history page cost the sum of 100 lookups. Read-only items now go to
the blocking pool up to BATCH_CONCURRENCY (4) at a time per connection,
each with the negotiated protocol and a copy of the join cache, and land
in request order. Slot i is written before read i + 4 starts, so a
client that stops reading still stops the work and a connection holds at
most four unwritten replies.

Any other item (subscribe, unsubscribe, server.version, broadcast and the
rest) waits until every earlier slot is written and then runs alone
through answer_one, so later items see what it changed. Notifications,
non-object items, streaming methods and the 345 cap are unchanged. The
single-request path is the same code split into spawn_dispatch and
finish_reply, so metering, the API log and last_sent stay per item.

A debug-only, opt-in thread-local trace records spawn, serial and write
steps so the suite can check the window, the barrier and the order
without timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

APPROVE. I would merge 55d4712c9b78f51aab29fed6a38fbe5131869290 as-is.

Read-only batch items go to the blocking pool, at most four per connection, and their replies are written in request order. Anything else (subscribe, unsubscribe, server.version, broadcast, a streaming method, a non-object) waits until those reads are written, then runs alone. Slot i is written before read i + 4 is spawned. Notifications still produce no slot.

Findings

Blocking: none.

Nit. land always installs that worker's sh_join. A later read that never touches the slot (header, tx, fee, mempool histogram) still holds the Arc cloned at spawn, so landing it puts the pre-window snapshot back and drops a join an earlier item in the same window just built. Results stay correct: a slot is reused only when the scripthash and tip hash match, and a miss recomputes. The comment on land overstates this as sequential cache behavior. A page of get_history calls, which is the change this PR is for, only keeps the last scripthash anyway.

Nit. A batch read's timer starts in Asked::of and stops when the slot is written, so the API log and rbitcoin_electrum_request_seconds_total include window wait and head-of-line delay. Single-request timing is unchanged.

Nit. The trace hooks are cfg(debug_assertions) and the new tests call them directly, so cargo test --release would fail to compile this suite. Debug #[tokio::test] is current-thread, which is why the thread-local trace is stable.

What I checked

The read-only allowlist matches the methods that only read the store, the mempool, the negotiated protocol, and the join cache. Subscribe, unsubscribe, server.version, header and outpoint subscriptions, silent-payments unsubscribe, and broadcast stay on the serial path. Reads get an empty subscription set.

ShJoinSlot is not mutated in place across tasks. Each read gets its own Arc clone, Arc::make_mut copies when the refcount is above one, and the connection keeps its own clone until land.

JSON-RPC behavior holds: request order, skipped notifications, "id": null still answered, non-objects and in-batch streams get -32600, and the empty-batch and 345-item responses are unchanged. Subscription notifications cannot land in the middle of the array. answer_batch runs on the read branch and holds the writer until the closing ]\n.

A connection holds at most four blocking tasks and four unwritten replies. A stalled write stops further spawns. If that write fails, the remaining JoinHandles are dropped and those tasks run to completion. The node blocking pool is still max(cores, 4), so one connection can occupy every blocking thread. That matches the operator note. BlockingRegion is a thread-local flag so store IO is allowed on those threads. It is not a permit, and the four reads do not nest it on one thread.

No new unwrap. No dependency, lockfile, or auth changes.

Build and test

On that commit, rbitcoin-electrum only:

  • cargo fmt -p rbitcoin-electrum -- --check: exit 0
  • cargo test -p rbitcoin-electrum --test suite -- --test-threads=4 --quiet: 49 passed
  • the two new tests, 40 more times: passed
  • cargo clippy -p rbitcoin-electrum --all-targets -- -D warnings: exit 0

Skipped: workspace cargo test, cargo test --release, cargo mutants, ast-grep, and node/functional harnesses. The in-crate suite includes the scripthash journey.

Comment thread crates/rbitcoin-electrum/src/server.rs Outdated
flight: InFlight,
) -> Result<(), std::io::Error> {
let (result, work, stamped) = flight.task.await.map_err(dispatch_join_error)?;
conn.sh_join = work.sh_join;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this always installs the worker's slot. A read that never touches sh_join (header, tx, fee, mempool histogram) still holds the Arc cloned at spawn, so landing it restores that pre-window snapshot and drops a join computed earlier in the same window. Results stay correct (sh_join_slot_hit requires scripthash and tip hash, then a miss recomputes). The comment above overstates this as sequential cache behavior. Installing the slot only when this worker replaced it would match that comment. Homogeneous get_history pages only keep the last scripthash either way.

One unpaged scripthash join near a raised --max-sh-creates was measured
at 2.1 GiB peak RSS, and a batch line (or a few connections) could start
several at once. An unpaged join with more than --heavy-sh-creates
creates (conf heavy_sh_creates=, default 10000, 0 off) now takes the
Query's single heavy turn before it walks, so one runs at a time across
every Electrum and Esplora client. Lighter joins and paged history do not
wait.

The create count is the one the --max-sh-creates refusal already reads,
so a refused join never takes the turn. The turn is a guard taken on the
blocking pool and dropped when the join returns, on success or error.
Its holder only reads the store and never waits on a client, so a waiter
in the same connection's batch window cannot deadlock it.

A debug-only, opt-in probe on Query counts joins by kind and makes each
wait for a second of its kind, so the journey checks that heavy joins
never overlap while light ones do, from two connections and from one
batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bobberb

bobberb commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Added a node-wide limit in 1f43db7: an unpaged scripthash join with more than --heavy-sh-creates creates (default 10000, 0 off) now waits until no other heavy join is running, across every Electrum and Esplora client. So a batch or a handful of connections can no longer stack several 2 GiB joins. Lighter joins and paged history stay parallel. The journey checks heavy joins never overlap (two connections and one batch) while light ones do.

@coreyphillips

Copy link
Copy Markdown
Contributor

Two independent reviews, nothing blocking a merge.

worth doing, does not block

  • One batch can occupy every blocking thread on a 4-core node (crates/rbitcoin-electrum/src/server.rs:686). On a host with 4 or fewer cores, one batch connection can fill the node's whole blocking pool, which the issue asked the cap to prevent. BATCH_CONCURRENCY = 4 equals the smallest pool blocking_pool_size() builds, max(cores, 4) (crates/rbitcoin-node/src/cli.rs:571). answer_batch spawns four reads before it lands any. Raspberry Pi class hardware and the 4 vCPU box in the PR's table are in this case. That pool is not only Electrum's. Per docs/concurrency.md:198-201, block decode, compact reconstruct, header jobs and mempool accept all use it. Before this PR, a single wallet held one thread. Now block relay and tx accept wait for an Electrum read to finish, and a read near --max-sh-creates can take a long time. The delay is bounded, because tokio's queue is FIFO and a freed thread goes to the waiting job before read i+4 is spawned. Four connections could already do this before. Still, the issue asked for a cap "so one batch can't take every blocking thread", and the new doc comment says this is not met. One fix is to derive the cap from the pool size, for example leaving at least half the pool free, and pass it in through ElectrumConfig. Confirmed by reading answer_batch and node_tokio_runtime. I did not measure the relay latency.
  • Batch read wall time now includes queueing and earlier slot writes (crates/rbitcoin-electrum/src/server.rs:834). For batch reads, the wall_ms in the API log and the METER totals now count time spent waiting for other items, not only the read itself. Asked::of (line 802) starts t0 when batch_item classifies the item (line 834). That happens before answer_batch waits on land(oldest) for a free window place. finish_reply stops the clock only when the read is landed (line 1070). By then the earlier reads in the window have landed and their slots have been written, and each write may take up to idle (120 s by default). Before this PR, answer_one started t0 after the previous slot was written, and stopped it before its own write. Two examples: - A 1 s read at slot 0 makes reads 1 to 4 each report about 1 s, even if they took 1 ms. - With a wallet that reads slowly, cheap reads report the socket stall. This inflates rbitcoin_electrum_request_seconds_total, the Electrum avg/max fields in tip: perf, and per-request wall_ms. Operators use those to find slow queries. A fix is to time the dispatch inside the blocking closure and return the elapsed time with Dispatched. Confirmed by reading the code path; not measured.
  • land can roll the join cache back to a stale copy (crates/rbitcoin-electrum/src/server.rs:965). land copies every read's sh_join back into conn (line 965). That includes reads that never touch the cache: transaction.get, headers, get_mempool, estimatefee. Those return the copy read_conn took when they were spawned. So in a mixed batch, a later non-scripthash read undoes the slot an earlier get_history filled, and the comment "as if the reads had run one after another" is not accurate. Separately, read_conn clones the Arc (line 948) while conn keeps its copy. On a cache hit, Arc::make_mut in rbitcoin-query/src/scripthash.rs:1816 and :2126 then deep-copies the whole joined slot. The serial path take()s the slot and fills it in place. Answers stay correct because this is only a cache. Copying back only when the slot pointer changed would keep the comment true.

The per-connection batch window was a fixed 4, the smallest blocking pool
the node builds, so one batch could take every blocking thread that block
decode, compact reconstruct, header jobs and mempool accept also need.
The window is now ElectrumConfig::batch_window, half the pool and at
least one (batch_window), set by the node from blocking_pool_size. A
4-thread pool gives 2; 12 gives 6.

A batch read's wall time started when the item was classified and
stopped when it landed, so it counted the window wait and earlier slot
writes. The blocking closure now times the dispatch and returns it, and
that is what the API log and the Electrum meter see. A single request
still counts from when it is taken up, as before.

A read that never uses the join cache no longer puts the pre-window copy
back over a join an earlier read built. A scripthash read now takes the
cache instead of sharing an Arc clone, so a hit fills it in place
without a deep copy; land installs a cache only when the read hands one
back.

Tests that use the debug-only trace or probe are gated the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bobberb bobberb changed the title electrum: answer read-only batch items four at a time electrum: answer read-only batch items concurrently Oct 24, 2026
@bobberb

bobberb commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, all addressed in ccdb57a:

  • Blocking pool: the window is now ElectrumConfig::batch_window, half the node's blocking pool and at least 1 (2 on a 4-thread pool), set by the node from blocking_pool_size.
  • Timing: batch reads are timed inside the blocking closure, so the API log and meter see only their own dispatch. Single requests are timed as before.
  • land: the cache is installed only when a read hands one back, and scripthash reads take the slot instead of sharing a clone, so hits fill it in place. A test checks a get_history after a mixed batch is a cache hit.
  • Release: the new tests and hooks are gated on debug_assertions. cargo test --release -p rbitcoin-electrum still doesn't build on master (20 existing errors, as TESTING.md notes); this PR adds none.

Flaky tests from CI are in #1190.

@bobberb

bobberb commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

@reardencode this one's green and the review points are addressed in ccdb57a. Ready for a look when you have time.

Null omits the flag and leaves the node default. A set value, including 0,
is passed for both Electrum and Esplora joins.
@reardencode
reardencode merged commit 27e22a5 into reardencode:master Oct 24, 2026
24 of 25 checks passed
@bobberb
bobberb deleted the electrum-batch-concurrency branch October 24, 2026 21:23
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.

Electrum: answer read-only batch items concurrently

3 participants