Repository navigation
electrum: answer read-only batch items concurrently - #1175
Conversation
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>
There was a problem hiding this comment.
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 0cargo 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.
| flight: InFlight, | ||
| ) -> Result<(), std::io::Error> { | ||
| let (result, work, stamped) = flight.task.await.map_err(dispatch_join_error)?; | ||
| conn.sh_join = work.sh_join; |
There was a problem hiding this comment.
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>
|
Added a node-wide limit in 1f43db7: an unpaged scripthash join with more than |
|
Two independent reviews, nothing blocking a merge. worth doing, does not block
|
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>
|
Thanks, all addressed in ccdb57a:
Flaky tests from CI are in #1190. |
|
@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.
Batch items used to run one after another, so a 100-address
get_historypage cost the sum of 100 lookups.ElectrumConfig::batch_windowper connection (half the node's blocking pool, at least 1: 2 on a 4-thread pool), and are written in request order.answer_one. Notifications, non-objects, streaming methods and the 345 cap are unchanged.spawn_dispatchandfinish_reply.--heavy-sh-creates(default 10000,0off) 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_historylookups, median of 7, warm cache: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-electrumand the rbitcoin-test electrum journeys pass, plus fmt, clippy, ast-grep andcargo check --release.Closes #1174
🤖 Generated with Claude Code