Skip to content

Electrum/Esplora: run index reads off the runtime workers, make request timeouts fire, page /txs/chain from the cursor - #947

Merged
bkeroack merged 11 commits into
release/0.6from
fix/electrum-esplora-work-bounds
Oct 8, 2026
Merged

bkeroack merged 11 commits into
release/0.6from
fix/electrum-esplora-work-bounds

Conversation

@bkeroack

@bkeroack bkeroack commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Electrum and Esplora do their index work synchronously (RocksDB and flat-file reads), and several places ran that work where it held a runtime worker, or repeated it without need:

  • Electrum request timeout could never fire. handle_connection wrapped process_request in tokio::time::timeout(request_timeout, async { process_request(..) }). The inner future has no await point, so the dispatch ran to completion on its first poll. A long request (a scripthash.subscribe batch over long histories, get_balance, get_first_use) held an API-runtime worker for as long as it took; with --api-threads at its small-box default of 2, two such requests stalled every Electrum connection, Esplora, events gRPC and /metrics.
  • Esplora address routes (/address/:a, /txs, /txs/chain[/:last], /txs/mempool, /utxo and the scripthash forms) did the same inline in the async fn, so TimeoutLayer could not answer before the read ended either.
  • /txs/chain/:last_seen_txid read and resolved the whole history for every page and then searched it for the cursor, so walking an N-transaction history 25 at a time cost O(N²) reads. The confirmed part of /txs?after_txid= (added in Esplora: page /txs with after_txid, 404 past a block's last page, omit empty witness and unknown fee targets #943) shared that path.
  • Status recomputes on the core runtime. Every block made the chain notifier recompute the status of every subscribed scripthash, and every mempool event touching a subscription made the mempool index task recompute those; each recompute reads a history from the index. Both tasks run on the core runtime (tokio::spawn in run), so heavy subscriptions held a core worker on every block. Queued block events (IBD, a reorg) each got a full pass, and each mempool event built the set of every subscription to filter its touched scripthashes.
  • Subscription caps. --electrummaxsubsperconn (1000) and the server-wide --addrindexsubscriptions (10000, shared with Esplora's address SSE streams) are not coherent: ten connections at their own cap fill the registry while 54 of 64 connection slots are free, after which every other client's subscribe is refused. Nothing documented or logged this.

Fix

  • Electrum (electrum-proto/src/server.rs): each request runs under spawn_blocking with the timeout around the join. Past --electrumrequesttimeout the client gets the existing request timed out error and the connection is closed (the handler cannot be interrupted and the connection's subscriptions are inside it). The connection task keeps its --electrummaxconns slot until the work ends, so reconnecting after every timeout cannot run more handlers at once than the connection cap. block_in_place in the tweaks handler runs the closure inline on a blocking-pool thread, so it keeps working; its comment is updated.
  • Esplora (esplora-handlers/src/blocking.rs, handlers/address.rs): every address and scripthash route runs its reads on the blocking pool under a work permit held until the work ends, even after its request timed out. The permits are sized from --esploramaxconns (none when it is 0, as for the request cap), so retries after timeouts cannot pile up running reads.
  • Paged confirmed history (Store::addr_rows_desc, AddressIndex::confirmed_txs_newest_first): the store reads a script's address rows newest first from an ordinal down without resolving them. RocksDB seeks backwards over both address families in total order and checks the 16-byte key prefix on every key; the CoinCache merges its unflushed rows in exactly as the flush will write them; a run never ends between two rows of one transaction. The address index starts at the end of the cursor's block (block-mates sort by txid on either side of the cursor) and stops once the page holds only settled blocks. Output and order are unchanged: height descending, then Txid descending, an unknown cursor [] on /txs/chain/:last and 422 on ?after_txid=. The trait default keeps the whole-history read for other implementations. Esplora: page /txs with after_txid, 404 past a block's last page, omit empty witness and unknown fee targets #943's /txs rules are kept: a cursor is looked up in the confirmed history first (one paged read), and a mempool txid is dropped from the mempool part when the index has a page after it, which for an unconfirmed transaction costs one ordinal lookup.
  • Notifier (node/src/index/address/notifier.rs, mempool.rs): the recomputes and the mempool task's lagged resync run under spawn_blocking; the tasks still take events in order and wait for each recompute, so index mutation and notification keep their order. The chain notifier drains every queued block event before a pass and runs one pass for them (the pass reads the index as it then stands). Mempool events look up only their touched scripthashes (SubscriptionRegistry::subscribed) and skip the thread hop when none is subscribed. The two tasks stay on the core runtime: block connection emits the events they consume there, and rpc/access.rs / emit_chain_event document the notifier as a core-runtime consumer of those events, so moving the consumers was left alone.
  • Subscription limits: no default changes. satd logs at Electrum startup how many connections at the per-connection cap fill the registry when that is fewer than --electrummaxconns, and the value of --addrindexsubscriptions that lets every connection reach its own cap. The Electrum chapter gets a "Subscription limits" section; the config reference and the Esplora chapter are updated for the timeout, permit and paging behaviour.

Tests

All fail without the fix (run on the unfixed code first, then each guard reverted):

  • electrum-proto server::electrumbounds_tests
    • request_past_the_timeout_is_answered_with_an_error_and_the_connection_closed: fails without the fix: yes (no timeout error; the slow answer arrives after 1.5 s).
    • a_slow_request_does_not_hold_up_other_connections (one worker; blocking-socket client on its own thread): fails without the fix: yes (a ping waited 1.300s behind another connection's slow request).
    • a_timed_out_request_keeps_its_connection_slot_until_its_work_ends: fails without the fix: yes; with only the wait-for-the-work loop removed: yes (the second connection is admitted instead of refused at capacity, so no capacity answer arrives).
  • esplora-handlers handlers::address::electrumbounds_tests (a real router over an empty regtest chain and an index whose history read sleeps):
    • a_slow_address_read_does_not_hold_up_other_requests: fails without the fix: yes (/blocks/tip/height waited 1.303s).
    • an_address_read_past_the_request_timeout_is_answered_503_on_time: fails without the fix: yes (200 after the read instead of 503).
    • a_timed_out_read_keeps_its_work_permit_until_it_ends: fails without the fix: yes; with only the permit removed: yes (a second read started while the first still held the only permit).
  • node index::address::lookups::electrumbounds_tests
    • pages_match_the_whole_history_from_every_cursor: every cursor (and four that are not in the history) at limits 1, 2, 7, 25 against the trait's whole-history default, over blocks where several of the script's transactions sort against their block position. With the settled-block guard loosened: fails.
    • a_cursor_outside_the_confirmed_history_is_none.
    • a_deep_page_resolves_only_the_rows_near_it: 3000-transaction history; with the paged override removed: fails (read 3000 rows).
  • node storage::rocksdb_store::electrumbounds_tests: reads_both_families_newest_first_within_the_prefix, runs_stop_after_min_txs_and_continue_below (neighbouring prefixes on both sides; with the never-split rule removed: fails).
  • node storage::coin_cache::electrumbounds_tests::pending_rows_merge_as_the_flush_will_write_them: new, interleaved, extra-vout, removed and put-and-removed pending rows; every run size before the flush equals the walk after it. With the remove filter or the floor filter removed: fails.
  • node index::address::notifier::electrumbounds_tests: a_block_recompute_does_not_run_on_the_runtime_thread, a_mempool_recompute_does_not_run_on_the_runtime_thread (fail without the fix: yes, a 50ms sleep took 800ms), queued_block_events_share_one_recompute (fails without the fix: yes, five passes for five queued events).
  • electrum-proto config::electrumbounds_tests for the registry-fill helper.
  • Existing: electrum-proto, esplora-handlers, node (index::address, storage), node-index lib suites; regtest esplora / electrum; e2e electrum / esplora (incl. Electrum: hash scripthash status in history order #938's electrum_status and Esplora: page /txs with after_txid, 404 past a block's last page, omit empty witness and unknown fee targets #943's esplora_paging).

Notes

  • Not done: recompute only the scripthashes a block touched. A block also changes the status of scripthashes it never touches: a mempool child that pays address A and spends a parent the block confirms goes from height -1 to 0, and the reverse on a disconnect. Narrowing needs the block's touched set plus the mempool children of its transactions, and ChainEvent carries no block. That is a design change; this PR keeps the full pass and moves it off the core workers.
  • Not done: a default change for the subscription caps (STABILITY_POLICY: defaults are part of the contract). Raising --addrindexsubscriptions to electrummaxconns × electrummaxsubsperconn (64,000) costs a few KB of channel per subscribed scripthash and a history read per subscription per block; that trade-off is the operator's or a later release's call.
  • Not done: capping full-history reads. blockchain.scripthash.subscribe, get_balance, get_first_use, Esplora /address/:a stats and /utxo still read the whole history per request (now off the runtime workers, bounded by the connection/permit caps and answered on time). A cap would change answers for large scripts (Blockstream's electrs refuses /utxo past its utxos_limit), which is a behaviour decision.
  • Base is now release/0.6 (Electrum: hash scripthash status in history order #938 and Esplora: page /txs with after_txid, 404 past a block's last page, omit empty witness and unknown fee targets #943 merged); no longer stacked.
  • The Electrum timeout answer keeps its existing shape (code 1, id: null).

Release notes

Electrum and Esplora requests that read a long address history no longer hold the API runtime's worker threads: they run on a blocking thread pool, so one slow request no longer stalls other Electrum connections, Esplora, events gRPC or /metrics. --electrumrequesttimeout now takes effect (a request past it is answered with an error and its connection closed), and Esplora's address routes answer 503 at --esplorarequesttimeout. Paging an address with /txs/chain/:last_seen_txid or ?after_txid= reads only the requested page instead of the whole history, and status notifications for subscribed addresses are computed off the threads that connect blocks, once per batch of queued blocks. satd now logs at startup when fewer Electrum connections than --electrummaxconns can fill the server-wide --addrindexsubscriptions registry; see the Electrum chapter's "Subscription limits".

🤖 Generated with Claude Code

bkeroack and others added 9 commits October 7, 2026 16:38
The status satd announced (the blockchain.scripthash.subscribe answer and
every notification) re-sorted the history by (height, txid), which put
the mempool rows (height 0 and -1) before every confirmed row.
get_history listed confirmed rows first and then mempool rows by Txid's
Ord, with 0 and -1 mixed. The Electrum protocol hashes "tx_hash:height:"
over the history in the order get_history returns it: confirmed by
height and position in the block, then mempool by (-height, tx_hash).
A client checks an announced status by hashing get_history as received,
so for any address with confirmed and unconfirmed history at once the
two never matched, and Electrum's synchronizer disconnects on that.

Build the history in one place, node_index::history, in protocol order,
and use it for the subscribe answer, the notifier, get_history and
get_mempool. The status hashes rows in the order given. Confirmed rows
in one block are ordered by their chain ordinal (Store::get_tx_seq),
looked up only for blocks holding more than one of the script's
transactions. Mempool rows sort height 0 before -1, then by display-hex
txid, as ElectrumX does. A mempool-index row whose transaction has
already left the mempool is left out, as electrs does, instead of being
listed with a guessed height and no fee.

Subscribing now records the answered status in the subscription
registry, so the next event that leaves it unchanged does not push it
again. The registry takes it only while the subscribing connection is
the scripthash's sole receiver and no status is recorded yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ness and unknown fee targets

Four answers differed from upstream Esplora (Blockstream's and
mempool.space's electrs, src/rest.rs):

- /address/:addr/txs and /scripthash/:hash/txs ignored ?after_txid=, so a
  client paging with mempool.space's cursor got the first page on every
  request. They now continue after the cursor: the next 25 confirmed for a
  confirmed cursor (the /txs/chain/:last_seen_txid page), or the mempool
  transactions after it and then the first 25 confirmed for a mempool
  cursor. A cursor in neither list is 422 "after_txid not found", as
  mempool.space answers; a malformed one is 400 and an empty one is the
  first page. A script's mempool transactions are now listed in the order
  the node admitted them (then by txid), the order upstream keeps them in,
  instead of HashSet order, so a mempool cursor is stable between requests.
- /block/:hash/txs/:start_index returned [] at or past the end and served
  any start. It now answers 404 "start index out of range" there and 400
  "start index must be a multiple of 25" for an unaligned start, as
  mempool.space does (blockstream.info answers 400 for both).
- Every legacy input carried "witness": []. Upstream sets witness only
  when the input has one; satd now skips the empty array.
- /fee-estimates reported the relay floor for every target when the node
  had no fee signal (empty mempool, no confirmed-block fee history, as in
  IBD or right after startup). Upstream leaves out each target bitcoind
  cannot estimate and answers {} when there is none. satd now leaves out
  the Confidence::Low targets, which in blend mode are exactly the ones
  that fell back to the floor.

The Esplora manual chapter documented the old answers; it now describes
these.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every Electrum handler is synchronous: it reads the address index,
RocksDB and the flat files inline. The connection task ran it inside
tokio::time::timeout(async { process_request(..) }), a future with no
await point, so the dispatch finished on its first poll and the timeout
could never fire. A long request held an API-runtime worker for as long
as it took, and with --api-threads at its small-box default of two, two
such requests stalled every Electrum connection, Esplora, events gRPC
and /metrics with it.

Each request now runs under spawn_blocking with the timeout around the
join. Past --electrumrequesttimeout the client gets the existing
"request timed out" error and the connection is closed: the handler's
work cannot be interrupted and the connection's subscriptions are
inside it. The connection task keeps its slot until that work ends, so
reconnecting after every timeout cannot run more handlers at once than
--electrummaxconns allows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-confirmed one once

Ordering a script's mempool transactions by admission time fetched every
one of them with Mempool::get, which clones the whole entry and its
transaction, where the earlier code fetched at most the 50 it served. The
order now comes from Mempool::admission_times, which reads the times of the
given txids under one read lock without cloning anything, and only the
transactions on the page are fetched.

Between a block connecting and the mempool dropping its transactions, a
transaction can be in a script's mempool list and in its confirmed history
at once. /txs listed it in both parts, and an after_txid naming it resolved
in the mempool list, so the page restarted the confirmed history at the
cursor itself. It is now listed once, as confirmed, as Blockstream's
electrs does (prepare_history), and a cursor is looked up in the confirmed
history first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Esplora's /txs/chain/:last_seen_txid read and resolved a script's whole
confirmed history (every row's ordinal through the reverse map) for
every page, then searched it for the cursor, so walking an
N-transaction history 25 at a time cost O(N^2) reads.

Store::addr_rows_desc reads a script's address rows newest first from
an ordinal down, without resolving them: RocksDB seeks backwards over
both address families within the key prefix, the CoinCache merges its
unflushed rows in the way the flush will write them, and a run never
ends between two rows of one transaction. The address index uses it
for AddressIndex::confirmed_txs_newest_first, which starts at the end
of the cursor's block (its block-mates sort by txid on either side of
it) and stops once the page holds only settled blocks. The page order
is unchanged: height descending, then txid descending. The trait's
default keeps the whole-history read for other implementations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e runtime

The address routes did their RocksDB and flat-file reads inline in the
async handler. A busy script's history held an API-runtime worker
(shared with Electrum, events gRPC and /metrics) for as long as the
read took, and the request TimeoutLayer could not answer until it
ended, since a future that never yields cannot be dropped early.
/txs/chain/:last_seen_txid also read the whole history for every page.

- Every /address and /scripthash route now runs its reads on the
  blocking pool. The worker stays free and --esplorarequesttimeout
  answers 503 on time. A read cannot be stopped once started, so it
  holds a permit until it ends, even after its request timed out; the
  permits are sized from --esploramaxconns, so retrying after every
  timeout cannot pile up running reads.
- /txs/chain[/:last_seen_txid] and the confirmed part of /txs (including
  ?after_txid=) use AddressIndex::confirmed_txs_newest_first, which
  reads from the cursor down instead of resolving the whole history.
  Answers and order are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eued batch of blocks

Every block event made the chain notifier recompute the status of every
subscribed scripthash, and every mempool event touching a subscription
made the mempool index task recompute those: each recompute reads a
history from the index, synchronous RocksDB work as long as the busiest
subscribed history. Both tasks run on the core runtime, so a set of
heavy subscriptions held a core worker (the one connecting blocks and
serving P2P) for the length of that read on every block.

- The recomputes, and the mempool task's lagged resync, now run under
  spawn_blocking. The tasks still take their events in order and wait
  for each recompute, so index mutation and notification keep their
  order; the core workers only wait.
- The chain notifier takes every block event already queued before a
  pass and runs one pass for them: the pass reads the index as it then
  stands, so it covers them all. During IBD or a reorg with subscribers
  that was one full pass per block.
- A mempool event looks up only the scripthashes it touched in the
  registry (SubscriptionRegistry::subscribed) instead of building the
  set of every subscription, and skips the thread hop when none is
  subscribed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e one fills first

blockchain.scripthash.subscribe is bounded twice: per connection
(--electrummaxsubsperconn, default 1000) and server-wide
(--addrindexsubscriptions, default 10000, shared with Esplora's address
SSE streams). With the defaults ten connections at their own cap fill
the server-wide registry while 54 of the 64 connection slots are still
free, and every other client's subscribe is then refused. Neither the
manual nor the log said so.

satd now logs at startup how many connections at the per-connection cap
fill the registry, when that is fewer than --electrummaxconns, and what
--addrindexsubscriptions would let every connection reach its own cap.
The Electrum chapter gets a "Subscription limits" section with the same
sizing, the manual describes the request timeout's new behaviour, and
the Esplora chapter's high-history note describes the paged /txs/chain
reads and the blocking-pool address routes. No default changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e new /txs rules

The parent now orders mempool txids by admission time without cloning,
lists a transaction in both lists once (as confirmed) and looks a cursor
up in the confirmed history first. Those rules are kept on top of the
paged read: the confirmed lookup asks the index for the page after the
cursor, and a mempool txid counts as confirmed when the index has an
(empty) page after it, which for an unconfirmed transaction is one
ordinal lookup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bkeroack
bkeroack changed the base branch from fix/esplora-paging-json to release/0.6 October 8, 2026 18:11
bkeroack and others added 2 commits October 8, 2026 12:51
Merged with fix/esplora-paging-json's tip (5d445f8) as the base, since this
branch already carried it and release/0.6 now holds its squashed equivalent.
One conflict: the storefault and electrumbounds test-module declarations in
rocksdb_store.rs, both kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The mempool part of /txs is filtered by a per-txid confirmed check, and
the first confirmed page is read afterwards. A transaction that
confirmed between the two could be listed in both parts. The served
mempool slice is now also filtered against the page itself, so it is
listed once, as confirmed; one that confirms after the page read is
still listed, as mempool.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bkeroack
bkeroack merged commit c50f8ab into release/0.6 Oct 8, 2026
36 checks passed
@bkeroack
bkeroack deleted the fix/electrum-esplora-work-bounds branch October 8, 2026 20:22
bkeroack added a commit that referenced this pull request Oct 15, 2026
…st timeouts fire, page /txs/chain from the cursor (#947)

* Electrum: hash scripthash status in history order

The status satd announced (the blockchain.scripthash.subscribe answer and
every notification) re-sorted the history by (height, txid), which put
the mempool rows (height 0 and -1) before every confirmed row.
get_history listed confirmed rows first and then mempool rows by Txid's
Ord, with 0 and -1 mixed. The Electrum protocol hashes "tx_hash:height:"
over the history in the order get_history returns it: confirmed by
height and position in the block, then mempool by (-height, tx_hash).
A client checks an announced status by hashing get_history as received,
so for any address with confirmed and unconfirmed history at once the
two never matched, and Electrum's synchronizer disconnects on that.

Build the history in one place, node_index::history, in protocol order,
and use it for the subscribe answer, the notifier, get_history and
get_mempool. The status hashes rows in the order given. Confirmed rows
in one block are ordered by their chain ordinal (Store::get_tx_seq),
looked up only for blocks holding more than one of the script's
transactions. Mempool rows sort height 0 before -1, then by display-hex
txid, as ElectrumX does. A mempool-index row whose transaction has
already left the mempool is left out, as electrs does, instead of being
listed with a guessed height and no fee.

Subscribing now records the answered status in the subscription
registry, so the next event that leaves it unchanged does not push it
again. The registry takes it only while the subscribing connection is
the scripthash's sole receiver and no status is recorded yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Esplora: page /txs with after_txid, bound block pages, omit empty witness and unknown fee targets

Four answers differed from upstream Esplora (Blockstream's and
mempool.space's electrs, src/rest.rs):

- /address/:addr/txs and /scripthash/:hash/txs ignored ?after_txid=, so a
  client paging with mempool.space's cursor got the first page on every
  request. They now continue after the cursor: the next 25 confirmed for a
  confirmed cursor (the /txs/chain/:last_seen_txid page), or the mempool
  transactions after it and then the first 25 confirmed for a mempool
  cursor. A cursor in neither list is 422 "after_txid not found", as
  mempool.space answers; a malformed one is 400 and an empty one is the
  first page. A script's mempool transactions are now listed in the order
  the node admitted them (then by txid), the order upstream keeps them in,
  instead of HashSet order, so a mempool cursor is stable between requests.
- /block/:hash/txs/:start_index returned [] at or past the end and served
  any start. It now answers 404 "start index out of range" there and 400
  "start index must be a multiple of 25" for an unaligned start, as
  mempool.space does (blockstream.info answers 400 for both).
- Every legacy input carried "witness": []. Upstream sets witness only
  when the input has one; satd now skips the empty array.
- /fee-estimates reported the relay floor for every target when the node
  had no fee signal (empty mempool, no confirmed-block fee history, as in
  IBD or right after startup). Upstream leaves out each target bitcoind
  cannot estimate and answers {} when there is none. satd now leaves out
  the Confidence::Low targets, which in blend mode are exactly the ones
  that fell back to the floor.

The Esplora manual chapter documented the old answers; it now describes
these.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Electrum: run requests on the blocking pool so the request timeout fires

Every Electrum handler is synchronous: it reads the address index,
RocksDB and the flat files inline. The connection task ran it inside
tokio::time::timeout(async { process_request(..) }), a future with no
await point, so the dispatch finished on its first poll and the timeout
could never fire. A long request held an API-runtime worker for as long
as it took, and with --api-threads at its small-box default of two, two
such requests stalled every Electrum connection, Esplora, events gRPC
and /metrics with it.

Each request now runs under spawn_blocking with the timeout around the
join. Past --electrumrequesttimeout the client gets the existing
"request timed out" error and the connection is closed: the handler's
work cannot be interrupted and the connection's subscriptions are
inside it. The connection task keeps its slot until that work ends, so
reconnecting after every timeout cannot run more handlers at once than
--electrummaxconns allows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Esplora /txs: clone only the served mempool transactions; list a just-confirmed one once

Ordering a script's mempool transactions by admission time fetched every
one of them with Mempool::get, which clones the whole entry and its
transaction, where the earlier code fetched at most the 50 it served. The
order now comes from Mempool::admission_times, which reads the times of the
given txids under one read lock without cloning anything, and only the
transactions on the page are fetched.

Between a block connecting and the mempool dropping its transactions, a
transaction can be in a script's mempool list and in its confirmed history
at once. /txs listed it in both parts, and an after_txid naming it resolved
in the mempool list, so the page restarted the confirmed history at the
cursor itself. It is now listed once, as confirmed, as Blockstream's
electrs does (prepare_history), and a cursor is looked up in the confirmed
history first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address index: page a confirmed history newest first from the cursor

Esplora's /txs/chain/:last_seen_txid read and resolved a script's whole
confirmed history (every row's ordinal through the reverse map) for
every page, then searched it for the cursor, so walking an
N-transaction history 25 at a time cost O(N^2) reads.

Store::addr_rows_desc reads a script's address rows newest first from
an ordinal down, without resolving them: RocksDB seeks backwards over
both address families within the key prefix, the CoinCache merges its
unflushed rows in the way the flush will write them, and a run never
ends between two rows of one transaction. The address index uses it
for AddressIndex::confirmed_txs_newest_first, which starts at the end
of the cursor's block (its block-mates sort by txid on either side of
it) and stops once the page holds only settled blocks. The page order
is unchanged: height descending, then txid descending. The trait's
default keeps the whole-history read for other implementations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Esplora: page /txs/chain from the cursor and run address reads off the runtime

The address routes did their RocksDB and flat-file reads inline in the
async handler. A busy script's history held an API-runtime worker
(shared with Electrum, events gRPC and /metrics) for as long as the
read took, and the request TimeoutLayer could not answer until it
ended, since a future that never yields cannot be dropped early.
/txs/chain/:last_seen_txid also read the whole history for every page.

- Every /address and /scripthash route now runs its reads on the
  blocking pool. The worker stays free and --esplorarequesttimeout
  answers 503 on time. A read cannot be stopped once started, so it
  holds a permit until it ends, even after its request timed out; the
  permits are sized from --esploramaxconns, so retrying after every
  timeout cannot pile up running reads.
- /txs/chain[/:last_seen_txid] and the confirmed part of /txs (including
  ?after_txid=) use AddressIndex::confirmed_txs_newest_first, which
  reads from the cursor down instead of resolving the whole history.
  Answers and order are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address index: run status recomputes on the blocking pool, one per queued batch of blocks

Every block event made the chain notifier recompute the status of every
subscribed scripthash, and every mempool event touching a subscription
made the mempool index task recompute those: each recompute reads a
history from the index, synchronous RocksDB work as long as the busiest
subscribed history. Both tasks run on the core runtime, so a set of
heavy subscriptions held a core worker (the one connecting blocks and
serving P2P) for the length of that read on every block.

- The recomputes, and the mempool task's lagged resync, now run under
  spawn_blocking. The tasks still take their events in order and wait
  for each recompute, so index mutation and notification keep their
  order; the core workers only wait.
- The chain notifier takes every block event already queued before a
  pass and runs one pass for them: the pass reads the index as it then
  stands, so it covers them all. During IBD or a reorg with subscribers
  that was one full pass per block.
- A mempool event looks up only the scripthashes it touched in the
  registry (SubscriptionRegistry::subscribed) instead of building the
  set of every subscription, and skips the thread hop when none is
  subscribed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Electrum: document the subscription caps, and log when the server-wide one fills first

blockchain.scripthash.subscribe is bounded twice: per connection
(--electrummaxsubsperconn, default 1000) and server-wide
(--addrindexsubscriptions, default 10000, shared with Esplora's address
SSE streams). With the defaults ten connections at their own cap fill
the server-wide registry while 54 of the 64 connection slots are still
free, and every other client's subscribe is then refused. Neither the
manual nor the log said so.

satd now logs at startup how many connections at the per-connection cap
fill the registry, when that is fewer than --electrummaxconns, and what
--addrindexsubscriptions would let every connection reach its own cap.
The Electrum chapter gets a "Subscription limits" section with the same
sizing, the manual describes the request timeout's new behaviour, and
the Esplora chapter's high-history note describes the paged /txs/chain
reads and the blocking-pool address routes. No default changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Esplora /txs: drop a mempool txid the confirmed page already lists

The mempool part of /txs is filtered by a per-txid confirmed check, and
the first confirmed page is read afterwards. A transaction that
confirmed between the two could be listed in both parts. The served
mempool slice is now also filtered against the page itself, so it is
listed once, as confirmed; one that confirms after the page read is
still listed, as mempool.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c50f8ab)
bkeroack added a commit that referenced this pull request Oct 15, 2026
* chore(release): cut 0.6.1

Rename docs/release-notes/0.6.1-pre.md to 0.6.1.md with the release date
and tag header, and rewrite it shorter: each fix is one to three
sentences with its PR, the detail stays in the linked PRs. The upgrade
notes keep their wording, now say that a trailing comment on an
rpcpassword line is refused too, and gain the Electrum and Esplora
request timeouts that now take effect (#947). The file goes from 36,787
to 14,970 bytes; build-release-notes.sh writes a 15,347-byte body.

Bump the workspace version 0.6.0 -> 0.6.1 (Cargo.lock changes only the
19 workspace crates), the satd-events-client pin on satd-events-proto,
the Go SDK's Version, and the illustrative versions in the appliance
README, the manual's Electrum and installing chapters, and the Warnet
README, examples, Dockerfile comment and render script. The changelog's
[Unreleased] list becomes the 0.6.1 releases-table row, and the
release-notes index gains the 0.6.1 row.

Not touched, as for 0.6.0: the Umbrel and StartOS image pins and package
versions, which name a per-commit image digest that only exists once the
tag has built it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Release notes: rustls 0.23.46 in 0.6.1

The 0.6.1 notes and changelog row name the rustls update from #966.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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