Parallelize Esplora tx_sync confirmed/unconfirmed lookups#22
Open
martinsaposnic wants to merge 1 commit into
Open
Conversation
EsploraSyncClient::sync re-confirms every watched transaction and output on each pass, one or more HTTP round-trips each, issued strictly serially. Against a remote Esplora the sync wall-time scales with watched_set * round_trip_latency and exceeds a wallet-sync timeout on any wallet with real channel history. Fan out the per-tx / per-output lookups in get_confirmed_transactions (both the watched_transactions phase and the watched_outputs spend-status phase) and the per-block checks in get_unconfirmed_transactions with a bounded buffer_unordered(4), async client only; the blocking client keeps the serial path. Ordering is preserved (results are still sorted by block height then in-block position before being handed to Confirm), and every consistency check the serial version performed is preserved in a sequential post-processing pass. Concurrency is deliberately low: against a shared, rate-limited Esplora the effective request rate is the fleet aggregate, so a small per-node bound stays under the server's per-client limit while removing the serial latency floor. Adds a self-contained timing test (mock Esplora with injected per-request latency, no bitcoind/electrs): N=40 watched txs at 100ms/lookup sync in ~1.0s parallel vs ~4.1s serial. Confirmation/ordering correctness remains covered by the electrs-backed test_esplora_syncs integration test.
amackillop
reviewed
Jun 2, 2026
| .await; | ||
| for r in results { | ||
| if let Some(confirmed_tx) = r? { | ||
| if !confirmed_txs.iter().any(|ctx| ctx.txid == confirmed_tx.txid) { |
There was a problem hiding this comment.
nit: If watched transactions is a set, is it necessary to guard against inserting the same tx twice here? I see it was there before so probably best to keep it just looks strange at first glance.
amackillop
reviewed
Jun 2, 2026
Comment on lines
+370
to
+395
| // B2: post-process sequentially, preserving every consistency check | ||
| // the sequential version performed, and build the to-fetch list. | ||
| let mut to_fetch: Vec<(Txid, Option<BlockHash>, Option<u32>)> = Vec::new(); | ||
| for status_res in status_results { | ||
| let output_status = match status_res? { | ||
| Some(s) => s, | ||
| None => continue, | ||
| }; | ||
| if let Some(spending_txid) = output_status.txid { | ||
| if let Some(spending_tx_status) = output_status.status { | ||
| if confirmed_txs.iter().any(|ctx| ctx.txid == spending_txid) { | ||
| if spending_tx_status.confirmed { | ||
| continue; | ||
| } else { | ||
| log_trace!(self.logger, "Inconsistency: Detected previously-confirmed Tx {} as unconfirmed", spending_txid); | ||
| return Err(InternalError::Inconsistency); | ||
| } | ||
| } | ||
| to_fetch.push(( | ||
| spending_txid, | ||
| spending_tx_status.block_hash, | ||
| spending_tx_status.block_height, | ||
| )); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Suggested change
| // B2: post-process sequentially, preserving every consistency check | |
| // the sequential version performed, and build the to-fetch list. | |
| let mut to_fetch: Vec<(Txid, Option<BlockHash>, Option<u32>)> = Vec::new(); | |
| for status_res in status_results { | |
| let output_status = match status_res? { | |
| Some(s) => s, | |
| None => continue, | |
| }; | |
| if let Some(spending_txid) = output_status.txid { | |
| if let Some(spending_tx_status) = output_status.status { | |
| if confirmed_txs.iter().any(|ctx| ctx.txid == spending_txid) { | |
| if spending_tx_status.confirmed { | |
| continue; | |
| } else { | |
| log_trace!(self.logger, "Inconsistency: Detected previously-confirmed Tx {} as unconfirmed", spending_txid); | |
| return Err(InternalError::Inconsistency); | |
| } | |
| } | |
| to_fetch.push(( | |
| spending_txid, | |
| spending_tx_status.block_hash, | |
| spending_tx_status.block_height, | |
| )); | |
| } | |
| } | |
| } | |
| // B2: post-process sequentially, preserving every consistency check | |
| // the sequential version performed, and build the to-fetch list. | |
| let phase_a_txids: HashSet<Txid> = | |
| confirmed_txs.iter().map(|ctx| ctx.txid).collect(); | |
| let mut to_fetch: Vec<(Txid, Option<BlockHash>, Option<u32>)> = Vec::new(); | |
| // `transpose` drops outputs with no status while still surfacing any | |
| // lookup error through the `?` below. | |
| for status_res in status_results.into_iter().filter_map(Result::transpose) { | |
| let output_status = status_res?; | |
| let (Some(spending_txid), Some(spending_tx_status)) = | |
| (output_status.txid, output_status.status) | |
| else { | |
| continue; | |
| }; | |
| if phase_a_txids.contains(&spending_txid) { | |
| // Phase A already resolved this spend as confirmed; the server | |
| // flipping it back to unconfirmed is an inconsistency. | |
| if !spending_tx_status.confirmed { | |
| log_trace!(self.logger, "Inconsistency: Detected previously-confirmed Tx {} as unconfirmed", spending_txid); | |
| return Err(InternalError::Inconsistency); | |
| } | |
| continue; | |
| } | |
| to_fetch.push(( | |
| spending_txid, | |
| spending_tx_status.block_hash, | |
| spending_tx_status.block_height, | |
| )); | |
| } |
nit: readability improvement
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.
Problem
EsploraSyncClient::syncre-confirms every watched transaction and output on each pass — one or more HTTP round-trips each — strictly serially. Against a remote Esplora, sync wall-time scales withwatched_set × round_trip_latency, which exceeds the LDK wallet-sync timeout on any wallet with real channel history (measured: a 4-monitor wallet = 13 watched txs + 13 watched outputs → ~21s lightning sync, blowing a 10s timeout →TxSyncTimeout).Change
Fan out the per-item lookups with a bounded
buffer_unordered, async client only (the blocking client keeps the serial path):get_confirmed_transactions— thewatched_transactionsphase and thewatched_outputsspend-status phase (status fan-out → sequential consistency post-processing → dependent-tx fan-out).get_unconfirmed_transactions— the per-blockget_block_statuschecks.const ESPLORA_SYNC_CONCURRENCY = 4. Deliberately low: against a shared, rate-limited Esplora the effective request rate is the fleet aggregate (all nodes hit the endpoint via one ALB IP), so a small per-node bound stays under the server's per-client limit while still removing the strictly-serial latency floor.Correctness (the part that matters for a parallel change)
(block_height, in-block pos)before being handed to theConfirminterface —buffer_unordered's arbitrary completion order is re-sorted.Err(Inconsistency)" check runs in a sequential post-processing pass, against the fully-merged phase-A results — identical to serial.?.sync()is untouched (outside the parallel blocks).Test (before/after, self-contained — no bitcoind/electrs)
tests/parallel_sync_timing.rs: a mock Esplora that injects 100ms latency on the/merkleblock-prooffan-out (404 →Ok(None)), N=40 watched txs.f56f47fe)ceil(40/4) × 100msas expected. Confirmation/ordering correctness remains covered by the electrs-backedtest_esplora_syncsintegration test (runs in CI).Follow-up (not in this PR)
Consuming this requires bumping the rust-lightning rev pin in
ldk-node(separate PR, also raisesLDK_WALLET_SYNC_TIMEOUT_SECS10→30), then a lightning-js release. Should land after the Esplora server-side rate-limit raise, else higher concurrency just trips 429-backoff.