Skip to content

feat(bdk_electrum_streaming)!: Report sync progress - #31

Draft
evanlinjin wants to merge 1 commit into
mainfrom
feat/progress-updates
Draft

evanlinjin wants to merge 1 commit into
mainfrom
feat/progress-updates

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Description

Lets callers show sync progress. State::poll now returns (Option<Update<K>>, Progress), and State::progress() returns the same snapshot at any time.

Progress contains:

Field Meaning
local_tip / remote_tip Our chain's tip vs the tip the server last announced on this connection (None until it announces one)
chain_synced The local chain is at the server's tip (same height and hash) and the confirmation job finished its pass against it, so every anchor is proven. False while the job is working, and also when the job gave up (a failed or mismatched proof, or inconsistent headers) and is waiting for the server to report something new.
spk_jobs_completed / spk_jobs_pending Completed counts from when there were last no pending jobs, so each burst of work counts up from zero
txs_remaining Distinct transactions requested from the server and not yet received. It grows as histories arrive and name more transactions.
headers_fetched / headers_remaining The confirmation job's current header pass. On a fresh wallet this is most of the sync.
anchors_fetched / anchors_remaining The confirmation job's current anchor pass

Methods:

  • is_synced(): chain_synced, with no spk jobs pending and no transactions remaining.
  • work(): rough (done, remaining) units for a progress bar. Every header, anchor, spk job and transaction counts as one unit. The bar can move backwards when new work arrives. remaining is zero exactly when is_synced() is true, so the bar never shows full early. When nothing is counted but we're not synced (before the server announces its tip, or after the confirmation job gave up), it reports one unit remaining.

Keeping it accurate and cheap

poll builds a Progress after every server message, so building one takes constant time:

  • Header and anchor counts are stored in the ConfirmationStage::FetchBlocks and FetchAnchors variants. They are updated as headers arrive (resolve_blocks) and as the anchor pass runs. When a pass restarts or is abandoned, the stage is replaced and its counts go with it, so they can't go stale.
  • txs_remaining is a counter in ReqCoord of GetTx requests still waiting for a response. ReqCoord already deduplicates requests, so a transaction that several jobs need is counted once.
  • Abandoning a pass on inconsistent headers now goes to a new ConfirmationStage::Abandoned instead of Waiting. That keeps Waiting meaning "finished", which chain_synced relies on.
  • start() clears the remote tip, since the new connection may be to a server that hasn't announced its tip yet.

Runners

run_async and run_blocking take a new progress_tx channel. It gets a message only when Progress changes. Progress is informational, so if the receiver is dropped, the state machine keeps running.

Breaking changes

  • State::poll returns (Option<Update<K>>, Progress).
  • run_async and run_blocking take an extra progress_tx argument.
  • ConfirmationStage has a new Abandoned variant, and its FetchBlocks and FetchAnchors variants have a new remaining field.

Notes for reviewers

  • Header and anchor counts go back to 0/0 once their pass finishes, so the UI never sees a final "N/N anchors". Use is_synced() for the finished state.
  • txs_remaining has no matching fetched count. Nothing keeps a record of what has been fetched: a job hands over its transactions and is removed when it finishes.
  • txs_remaining also counts transactions requested by an spk job that was later cancelled, until the server answers.
  • The FetchBlocks stage now checks its remaining counter to decide whether it can move on, instead of scanning every height each poll. A debug_assert checks that the counter matches a full scan.

Tests

  • New state tests:
    • progress_counts_down_to_synced
    • a_failed_proof_is_not_synced
    • restarting_forgets_the_remote_tip
  • All three also check that work() shows remaining work exactly when not synced.
  • All three fail if the fixes they cover are reverted.
  • cargo test passes: 34 state tests, 13 unit tests and 6 env tests against regtest.
  • cargo clippy --all-targets is clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GU8CAerKAhq93c6H7qerQT

`State::poll` now returns a `Progress` alongside the update, and
`State::progress` returns the same snapshot at any time. It reports:

- `local_tip` vs `remote_tip`, the tip the server last announced on this
  connection.
- `chain_synced`: the local chain is at the remote tip and the confirmation
  job finished its pass against it, so every anchor is proven.
- `spk_jobs_completed` vs `spk_jobs_pending`.
- `txs_remaining`: distinct transactions requested and not yet received.
- `headers_fetched` vs `headers_remaining`, and `anchors_fetched` vs
  `anchors_remaining`, for the confirmation job's current pass.

`Progress::is_synced` combines these, and `Progress::work` returns rough
`(done, remaining)` units for a progress bar, with `remaining == 0` exactly
when synced.

Building a `Progress` is O(1), since `poll` builds one per message. Header
and anchor counts live in the `FetchBlocks`/`FetchAnchors` stages, so a
restarted or abandoned pass takes them with it, and `txs_remaining` is a
counter of in-flight `GetTx` requests in `ReqCoord`. Abandoning a pass on
inconsistent headers now goes to a new `ConfirmationStage::Abandoned`, so
`Waiting` means finished. `start()` forgets the remote tip.

`run_async` and `run_blocking` take a `progress_tx` channel that is sent to
whenever progress changes; a dropped receiver does not stop them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GU8CAerKAhq93c6H7qerQT
@evanlinjin
evanlinjin force-pushed the feat/progress-updates branch from 66dc3b4 to 98d9479 Compare September 16, 2026 12:16
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