Parallelize Esplora tx_sync confirmed/unconfirmed lookups#22
Merged
martinsaposnic merged 2 commits intoJun 9, 2026
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
Address review nit: use a phase_a_txids HashSet and let-else to flatten the B2 consistency-check loop. Behavior unchanged.
amackillop
approved these changes
Jun 9, 2026
0234d9e
into
lsp-0.2.0_accept-underpaying-htlcs_with_timing_logs
8 of 43 checks passed
martinsaposnic
added a commit
to moneydevkit/ldk-node
that referenced
this pull request
Jun 15, 2026
…ync (#39) Follow-up to moneydevkit/rust-lightning#22 (parallel Esplora tx_sync), whose parallelization is already pulled in via this branch's rust-lightning rev. Per that PR's follow-up note, consuming it also requires raising the LDK wallet-sync timeout off the stock 10s. Against a remote, rate-limited Esplora a per-monitor tx_sync fans out ~60 HTTP calls and realistically takes ~20s even with the parallel (buffer_unordered) client, so 10s guaranteed a TxSyncTimeout -> NAPI panic on any wallet with real channel history. 90s leaves headroom for watch-set growth while still failing fast if Esplora is genuinely dead (a webhook node only lives ~40-60s). Target ~30s once Esplora sits behind a cache/CDN. Co-authored-by: Martin Saposnic <martin@moneydevkit.com>
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.