From 795e110307e7a4b53b9cae679823c72c64b11c5b Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Tue, 2 Jun 2026 08:41:58 +0200 Subject: [PATCH 01/32] Refactor bitswap client --- .sisyphus/REVIEW.md | 502 ++++++++ Cargo.lock | 3 +- prdoc/pr_12052.prdoc | 33 + substrate/client/network/Cargo.toml | 10 +- substrate/client/network/build.rs | 23 - .../client/network/src/bitswap/client.rs | 906 -------------- .../client/network/src/bitswap/handle.rs | 274 +++++ substrate/client/network/src/bitswap/mod.rs | 609 +--------- .../client/network/src/bitswap/schema.rs | 24 - .../client/network/src/bitswap/service.rs | 1071 +++++++++++++++++ substrate/client/network/src/config.rs | 10 +- substrate/client/network/src/lib.rs | 8 +- .../client/network/src/litep2p/bitswap.rs | 703 ----------- .../network/src/litep2p/bitswap_metrics.rs | 219 ---- substrate/client/network/src/litep2p/mod.rs | 261 ++-- .../client/network/src/litep2p/service.rs | 94 +- .../network/src/schema/bitswap.v1.2.0.proto | 43 - substrate/client/network/src/service.rs | 19 +- .../client/network/src/service/traits.rs | 33 +- substrate/client/service/src/builder.rs | 40 +- 20 files changed, 2083 insertions(+), 2802 deletions(-) create mode 100644 .sisyphus/REVIEW.md create mode 100644 prdoc/pr_12052.prdoc delete mode 100644 substrate/client/network/build.rs delete mode 100644 substrate/client/network/src/bitswap/client.rs create mode 100644 substrate/client/network/src/bitswap/handle.rs delete mode 100644 substrate/client/network/src/bitswap/schema.rs create mode 100644 substrate/client/network/src/bitswap/service.rs delete mode 100644 substrate/client/network/src/litep2p/bitswap.rs delete mode 100644 substrate/client/network/src/litep2p/bitswap_metrics.rs delete mode 100644 substrate/client/network/src/schema/bitswap.v1.2.0.proto diff --git a/.sisyphus/REVIEW.md b/.sisyphus/REVIEW.md new file mode 100644 index 000000000000..e20b58fa8fae --- /dev/null +++ b/.sisyphus/REVIEW.md @@ -0,0 +1,502 @@ +# Bitswap API Refactor — Review Walkthrough + +This walkthrough takes you through the staged changes in **reading order** (build understanding fastest), not file order. Each section names the files, the key lines, and what to look for when reviewing. + +**TL;DR diff stats**: 14 modified files, 4 deleted files, 3 new files. Net: **+1340 / -2369** lines (≈ -1000 lines overall, even after the new state machine). + +**Tests**: `cargo test -p sc-network bitswap::` — **18/18 pass.** +**Build**: `SKIP_WASM_BUILD=1 cargo check -p sc-network -p sc-service --all-targets` — clean. `cargo check -p staging-node-cli` — clean. + +--- + +## How to read this + +Each step calls out one file or one cluster, with: +- **Why this matters** — 1-2 sentence summary of the architectural intent. +- **Where to look** — file path + key line range. +- **What to verify** — what the reviewer should specifically check. + +Skim the diff stats first, then walk through in this order. Total reading time ≈ 25-40 min. + +--- + +## Step 0 — Context and scope + +Before opening any file, two pieces of context anchor the review: + +- The full design is in [Resolved Design](file:///home/sebastian/Documents/aidump/aidump/Bitswap%20API%20redesign/Resolved%20Design.md). The 10 decisions in §9 of that doc are the contract this PR delivers. +- This PR is **substrate-only**. Cumulus `storage-chain-sync` (the only consumer of the old API) is migrated in a follow-up. Master will not compile for `cumulus-client-storage-chain-sync` between this PR's merge and that follow-up. The breakage is intentional and called out in the [prdoc](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/prdoc/pr_12052.prdoc) and the doc. + +Open [prdoc/pr_12052.prdoc](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/prdoc/pr_12052.prdoc) and read it once. It's 33 lines and sets the framing for everything that follows. + +--- + +## Step 1 — The new public API surface + +**Why this matters**: this is the only thing downstream callers ever touch. If you only review one file, review this one. + +**File**: [substrate/client/network/src/bitswap/handle.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs) (new, 236 lines) + +**What to verify**: + +1. The types and shapes match the [Resolved Design §2](file:///home/sebastian/Documents/aidump/aidump/Bitswap%20API%20redesign/Resolved%20Design.md): + - `BitswapHandle` is `Clone`, holds a single `mpsc::Sender`. + - The single public method is [`request_stream`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs#L121-L162). No `request`. + - Return shape is `Result>, BitswapError>` — outer admission errors, inner per-item errors (decision Q3). + - `FetchOutcome` has only two variants — `Block(Vec)` and `Missing`. All operational causes for missing collapse into one variant (decision Q5/Q8). + - `BitswapError` has 5 variants: `Unavailable`, `ServiceClosed`, `InvalidCid { cid }`, `Overloaded`, `TooManyCids { requested, max }`. `InvalidCid` carries only the `Cid` (decision Q8). + - `BitswapServiceConfig` has only `request_timeout: Duration`, default 30s (decision Q9). + +2. Admission logic in [`request_stream`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs#L137-L162): + - Empty wantlist → immediately-closed receiver, not an error. + - `cids.len() > MAX_CIDS_PER_REQUEST` (=16) → `TooManyCids`. + - First unsupported CID → `InvalidCid`. + - Sink capacity is `cids.len() + 1` — the `+1` reserves a slot for a possible terminal `Err(ServiceClosed)`. Comment on [line 23-24](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs#L23-L24) of that area explains this invariant. + - `cmd_tx.try_send` maps Full → `Overloaded`, Closed → `ServiceClosed`. + +3. [`BitswapCommand`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs#L178-L191) is `pub(crate)` only — the actor's internal message type. + +4. [`PeerEvent`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs#L193-L216) is `pub` (the litep2p backend publishes into it). Three variants: `Snapshot { peers }`, `Connected { peer }`, `Disconnected { peer }`. The `Snapshot` variant is defined for forward-compat (currently never sent — see Step 4 for why this is fine). + +5. [`BitswapWiring`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs#L218-L237) carries everything needed to install Bitswap into the litep2p backend: `litep2p_config` (consumed by `with_libp2p_bitswap`), `user_handle` (stored on `Litep2pNetworkService`), `peer_event_tx` (the main-loop publishes into this). + +**Red flags to look for**: +- Any `pub` item without a docstring → there shouldn't be any. +- Admission that pushes an error onto the sink instead of returning it — admission errors are synchronous Returns; only `ServiceClosed` and `Overloaded` can flow through the sink (and only the actor inserts them). + +--- + +## Step 2 — The trait surgery in `NetworkService` / `NetworkBackend` + +**Why this matters**: this is the user-visible surface change. `NetworkService` gains `bitswap_handle()`. `NetworkBackend` loses `BitswapConfig`/`bitswap_server`. The mechanism is a new supertrait `BitswapProvider`. This pattern is non-obvious — make sure you understand it before reading the impl sites. + +**File**: [substrate/client/network/src/service/traits.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service/traits.rs) + +**What to verify**: + +1. [`BitswapProvider` trait](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service/traits.rs#L56-L67) has a default `bitswap_handle() -> None` body. Backends that don't support Bitswap (libp2p) can use the default, backends that do (litep2p) override. + +2. [`impl BitswapProvider for Arc`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service/traits.rs#L69-L76) blanket. This is required because `NetworkBackend::NetworkService` is wrapped in `Arc` (e.g. `Arc>`). Every other subtrait in this file has the same `Arc` blanket impl — search for `^impl .* for Arc` and you'll see 8 of them. + +3. [`NetworkService` supertrait](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service/traits.rs#L79-L92) gains `+ BitswapProvider`. The blanket impl on `T` (line 94-107) also gains it. **This is the key insight**: because `NetworkService` is a marker-trait with a blanket impl over its subtraits, every concrete type that implements all 7 (now 8) subtraits automatically gets `NetworkService`. Adding the new method means adding a new subtrait, not adding a method to the marker. + +4. [`NetworkBackend` trait](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service/traits.rs#L131-L181): `type BitswapConfig` and `fn bitswap_server` are GONE. Compare with the diff hunk showing lines 148-149 and 162-167 removed. + +**Red flags**: +- The `?Sized` bound on [`impl BitswapProvider for Arc`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service/traits.rs#L69-L72) — required because `Arc` is constructed elsewhere (Litep2pNetworkBackend returns `Arc` from `network_service()`). Without `?Sized` the blanket wouldn't apply to `Arc`. +- Should the default `None` body be in the trait at all? Yes — the libp2p backend benefits from it (Step 3). Removing the default would force every NetworkService implementer (including third-party ones, if any) to add a stub impl. + +--- + +## Step 3 — The two `BitswapProvider` impls (libp2p stub, litep2p real) + +**Why this matters**: confirms the trait machinery works for both backends. + +**File 1**: [substrate/client/network/src/service.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service.rs) (libp2p `NetworkWorker`) + +Look for the line `impl crate::service::traits::BitswapProvider for NetworkService` near the end of the existing 7-trait-impl block (after `NetworkRequest`). The impl body is **empty** — it uses the default `None`. That's the entire change on the libp2p side. + +Also verify the diff REMOVES `type BitswapConfig = RequestResponseConfig` and the `fn bitswap_server` impl in the `impl NetworkBackend for NetworkWorker` block. + +**File 2**: [substrate/client/network/src/litep2p/service.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/litep2p/service.rs) + +Two things to verify: + +1. The struct field [`bitswap_handle: Option`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/litep2p/service.rs#L221-L222) replaces the old `bitswap_cmd_tx: Option>`. The constructor takes it as a parameter (line 237). + +2. The `BitswapProvider` impl at the end of the file delegates to the inherent method: + ```rust + impl crate::service::traits::BitswapProvider for Litep2pNetworkService { + fn bitswap_handle(&self) -> Option { + self.bitswap_handle() + } + } + ``` + The inherent method (around line 251) just `.clone()`s the field. + +3. The whole `route_bitswap_request` method (was lines 253-324) is **gone**. + +4. The bitswap protocol-name special-case in `start_request` (was around line 648, `if protocol.as_ref() == crate::bitswap::PROTOCOL_NAME`) is **gone**. + +**Red flags**: +- The libp2p impl is empty — make sure that's not because I forgot something. Default body returns `None`, which is correct semantics for libp2p (Bitswap not supported there). +- `Litep2pNetworkService` no longer imports `prost::Message` or `ProtoBitswapWantType` — confirm the removed imports in the diff. + +--- + +## Step 4 — The state machine: BitswapService actor + +**Why this matters**: this is the heart of the implementation. The actor's correctness is what makes the API safe. + +**File**: [substrate/client/network/src/bitswap/service.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs) (new, 1071 lines including tests) + +Recommended reading order WITHIN this file: + +### 4a. Internal constants ([lines 60-69](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L60-L69)) + +```rust +const MAX_OUTSTANDING_CIDS: usize = 1024; +const MAX_WAITERS_PER_CID: usize = 64; +const MAX_CONCURRENT_INBOUND_LOOKUPS: usize = 8; +const CMD_CHANNEL_CAPACITY: usize = 256; +const PEER_EVENT_CHANNEL_CAPACITY: usize = 64; +const LOOKUP_CHANNEL_CAPACITY: usize = 64; +const PER_PEER_TIMEOUT: Duration = Duration::from_secs(5); +const PEER_FANOUT_CAP: usize = 1; +const PEER_TIMEOUT_SWEEP_INTERVAL: Duration = Duration::from_secs(1); +``` + +These match [Resolved Design §5](file:///home/sebastian/Documents/aidump/aidump/Bitswap%20API%20redesign/Resolved%20Design.md). Verify the values are as the design says. + +### 4b. `BitswapTransport` trait ([lines 35-54](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L35-L54)) + +This is the test seam. `LitepBitswapHandle` is wrapped behind this trait so unit tests can inject mock events. The production impl is a 3-method passthrough. + +**What to verify**: only 3 methods (`next_event`, `send_request`, `send_response`). No leaky abstractions. Production impl is trivial. + +### 4c. Data model ([lines 71-98](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L71-L98)) + +```rust +struct CidState { + tried_peers: HashSet, + in_flight_peers: HashMap, + waiters: SmallVec<[WaiterId; 2]>, +} + +struct Waiter { + cids_remaining: HashSet, + sink: mpsc::Sender, + delay_key: delay_queue::Key, +} +``` + +**This is the most important diff in the PR.** Confirm: +- `CidState` does **not** carry a `deadline` field. Per-waiter deadlines, not per-CID (decision Q1). +- `CidState.waiters` is a SmallVec — most CIDs have 1-2 waiters; allocation-free in the common case. +- `Waiter.cids_remaining` is a `HashSet` — we decrement it as each CID resolves; the waiter completes when it's empty. +- `Waiter.delay_key` is the `DelayQueue::Key` returned at insert time, used to cancel-on-early-completion (function [`drop_waiter`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L364-L376)). + +### 4d. `BitswapService` struct ([lines 100-115](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L100-L115)) + +Six "wire" fields (cmd_rx, peer_event_rx, lookup_{tx,rx}, lookup_semaphore, waiter_deadlines) and three "state" fields (connected_peers, wants, waiters). Plus `handle`, `client`, `config`. + +**What to verify**: +- `handle: Box` — the test seam. +- `wants: HashMap` — deduplicated across waiters (key insight). +- `waiters: SlotMap` — slotmap because we need stable keys across removals. +- `waiter_deadlines: DelayQueue` — fires when a waiter's deadline elapses; produces a `WaiterId` we look up in `waiters`. + +### 4e. `pub fn start` ([lines 122-154](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L122-L154)) + +The entry point called by `build_network`. Returns `(future, BitswapWiring)`. The future MUST be spawned; the wiring MUST be passed into the litep2p backend. + +**What to verify**: the three channels (cmd, peer_event, lookup) are constructed here with their capacity constants. The `LitepConfig::new()` call is where the litep2p protocol config + handle pair come from. + +### 4f. The `run()` loop ([lines 159-204](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L159-L204)) + +**Six select arms** (one more than the Resolved Design — peer-timeout sweep was added as a separate arm rather than opportunistic checks, for clarity): + +1. Inbound BitswapEvent (Request / Response). +2. User commands (RequestStream). +3. Peer events (Snapshot / Connected / Disconnected). +4. Waiter deadline expiry — gated on `!self.waiter_deadlines.is_empty()` (DelayQueue panics if you poll it empty). +5. Completed blocking lookup → forward via `handle.send_response`. +6. Per-peer timeout ticker. + +**What to verify**: +- No `.await` on storage inside any arm body. Confirm by reading each arm. +- No `.await` on user sinks. The actor uses `try_send` everywhere. Search for `sink.try_send` (about 4 hits). +- No `.await` on the litep2p `handle.send_request` / `send_response` that could conceivably block for an unbounded time. (litep2p's internal channel is 4096 entries deep per the registry source — not infinite, but in practice never blocks. If this becomes a concern, the fix is routing outbound through an internal sender task.) +- `None` from any channel → `shutdown_waiters()` is called → all waiters get `Err(ServiceClosed)` and the loop returns. + +### 4g. `on_request_stream` ([lines 206-244](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L206-L244)) + +Admission-time validation in the actor. Handle.rs already did the cheap checks; this is the second-line defense against races. + +**What to verify**: +- `new_cid_count` filter only counts CIDs not already in `wants` — overlapping waiters don't double-charge the budget. +- `MAX_WAITERS_PER_CID` cap. +- `SlotMap::insert_with_key` is used so the `Waiter`'s `delay_key` can be inserted into `DelayQueue` with the slot's actual key — no placeholder dance needed. +- After insertion, every CID gets `top_up_in_flight` called. + +### 4h. Peer fanout: `top_up_in_flight` ([lines 246-265](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L246-L265)) + +**This is where CAP=1 lives.** + +**What to verify**: +- Returns early if `in_flight_peers.len() >= PEER_FANOUT_CAP` (= 1 in v1). +- Picks the first peer in `connected_peers \ tried_peers \ in_flight_peers`. +- Records the per-peer deadline as `Instant::now() + PER_PEER_TIMEOUT` (= 5s). +- Calls `self.handle.send_request(peer, vec![(cid, WantType::Block)]).await` — sends only one CID per request (no batching across waiters). This is intentional; batching is a future optimization that doesn't change correctness. + +### 4i. Hash verification: `on_inbound_response` + `recompute_cid` ([lines 277-321 and 478-487](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L277-L321)) + +**This is the security-critical part.** + +For each inbound `Block`: +1. Move the peer to `tried_peers` for the CID it responded for. +2. Recompute the CID from `(prefix.codec, prefix.mh_type, hash(prefix.mh_type, data))` via [`recompute_cid`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L478-L487). +3. Look up the recomputed CID in `wants`. If absent → dropped silently. +4. If present → call `deliver_block`. + +**What to verify**: +- The recomputed CID — NOT the claimed CID — is what's looked up in `wants`. A peer cannot make us accept arbitrary bytes by lying about the CID in the response. +- [`hash_for_multihash_code`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L489-L496) supports only the 3 blessed multihash codes (Blake2b-256, SHA2-256, Keccak-256). Unsupported codes → `None` → recompute fails → block dropped. +- Presence responses (`Have`/`DontHave`) move the peer to `tried_peers` and trigger top-up. They do NOT directly cause `Missing` for the waiter — that happens at deadline time when no peer ever delivered the block. + +### 4j. Multi-waiter delivery: `deliver_block` ([lines 332-352](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L332-L352)) + +**What to verify**: +- The bytes are wrapped in `Arc>` internally to avoid cloning across multiple waiters interested in the same CID. The clone at delivery time is unavoidable (mpsc::Sender takes ownership), but it's at most one Vec clone per waiter, not the whole-payload-per-waiter that a naive impl would do. +- `try_send` on the sink — a slow/closed waiter triggers `drop_waiter` for that one waiter, NOT for the others. +- After the loop, if no waiter remains, the CID's CidState is removed (via `drop_waiter`'s GC logic). + +### 4k. Cancellation: `drop_waiter` ([lines 364-376](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L364-L376)) + +**What to verify**: +- Removes the waiter from `waiters` (SlotMap). +- Removes it from `waiter_deadlines` (so the DelayQueue doesn't fire later). +- For each `cid` in the waiter's `cids_remaining`, removes the waiter from `CidState.waiters`. If the CidState has no waiters and no in-flight peers left → it's GC'd from `wants`. + +### 4l. Deadline expiry: `on_waiter_expired` ([lines 378-395](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L378-L395)) + +Each remaining CID gets `Ok((cid, Missing))` sent via `try_send`. The waiter is then removed from every `CidState.waiters`. Same GC as `drop_waiter`. No `Err(...)` is emitted — Missing is the right outcome, not an error. + +### 4m. Peer events: `on_peer_*` ([lines 397-432](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L397-L432)) + +**What to verify**: +- `Connected`: insert into `connected_peers`, then top up every CID in `wants` (since a previously-blocked CID might now have a peer to try). +- `Disconnected`: remove from `connected_peers`. Also remove the peer from `in_flight_peers` of every CID. Then top up those CIDs (failover). +- `Snapshot`: replaces `connected_peers` wholesale, then top up. **Currently never sent** — the actor is constructed before the litep2p backend's `run()` starts, so there are no pre-existing peers to snapshot. The variant exists for forward-compat. + +### 4n. Per-peer timeout sweep ([lines 434-457](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L434-L457)) + +Fires every 1s. Walks `wants` and removes `in_flight_peers` entries whose `Instant` deadline has passed. Each timed-out peer is moved to `tried_peers`, then top-up is called. + +**What to verify**: +- The retain-based pattern is idiomatic. +- After collecting timed-out `(cid, peer)` pairs, the peers are added to `tried_peers` and top-up is called per CID. The split-borrow pattern is necessary because the `retain` closure can't call `self.top_up_in_flight`. + +### 4o. Inbound serving: `on_inbound_request` ([lines 459-475](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L459-L475)) + +**This addresses the [[Bitswap Service Blocking Follow Up]] concern.** + +**What to verify**: +- `lookup_semaphore.try_acquire_owned()` bounds inbound serving concurrency at 8. If saturated → drop the request silently. Old peer eventually times out — no fabricated DontHave. +- `tokio::task::spawn_blocking` for the actual DB read. The semaphore permit is dropped when the spawned task completes. +- Completion arrives on the loop's arm 5 (`lookup_rx`) which then forwards via `handle.send_response`. This is the only place the actor's main loop interacts with disk IO. + +### 4p. Tests ([lines 562-1071](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L562-L1071)) + +16 tests, one per scenario from [Resolved Design §7.1](file:///home/sebastian/Documents/aidump/aidump/Bitswap%20API%20redesign/Resolved%20Design.md). The `TestRig` harness ([lines 596-635](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L596-L635)) gives each test a `MockTransport` for injecting BitswapEvents and observing outbound traffic. + +**What to verify**: +- Most tests use `#[tokio::test(start_paused = true)]` + `tokio::time::advance()` to deterministically test deadlines. No real wall-clock waits. +- `corrupted_block_rejected_then_missing_at_deadline` — hash verification works: peer sends bytes that don't hash to the claimed CID, block is silently dropped, eventually Missing. +- `inbound_request_with_known_block_serves_it` — full inbound path against a real `substrate-test-runtime-client` with an indexed transaction. +- `service_shutdown_emits_service_closed` — dropping the inbound channel causes the actor to call `shutdown_waiters`, which emits `Err(ServiceClosed)` on each waiter's sink. + +**Run them yourself**: +```bash +cd /home/sebastian/work/tries/2026-05-13-bitswap-API-improvement +cargo test -p sc-network bitswap:: +``` +Expected: `18 passed; 0 failed`. + +--- + +## Step 5 — The bitswap module trimmed down + +**Why this matters**: confirms what survives from the old module, and that nothing relies on the deleted protobuf schema. + +**File**: [substrate/client/network/src/bitswap/mod.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/mod.rs) + +The file went from 665 → ~110 lines. What survives: + +- License + module doc. +- `mod handle; mod service;` + re-exports of the public types. +- `MAX_WANTED_BLOCKS` (16), `RAW_CODEC` (0x55), the three multihash constants. +- `is_cid_supported` + `is_supported_multihash_code` — the validation helpers. +- The `Prefix` struct + `From<&Cid>` impl. **No more `to_bytes`** — the actor doesn't construct protobuf prefixes, litep2p does that internally. +- 2 unit tests for `is_cid_supported`. + +What's GONE: +- `BitswapRequestHandler` (the libp2p request-response handler) — 152 lines, deleted. +- `RequestHandlerError` enum. +- The 7 libp2p-specific tests (`undecodable_message`, `empty_want_list`, `too_long_want_list`, `transaction_not_found`, `transaction_found`, `transaction_not_found_sends_dont_have_when_requested`, `transaction_found_sends_have_for_want_have`). +- `mod schema` and the protobuf re-export `BitswapProtoMessage`. +- The `decode_prefix` function and `PrefixDecodeError` type. +- `mod client` + the entire `pub use client::*` re-export block. + +--- + +## Step 6 — Deleted files (the litep2p shim, the libp2p path, the protobuf schema) + +**Why this matters**: these are the deletions that make the new design coherent. None of them should have hidden references elsewhere. + +| Deleted file | Reason | Verify by | +|---|---|---| +| [substrate/client/network/src/bitswap/client.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/client.rs) (906 lines) | The old `request_bitswap_blocks` / `request_bitswap_blocks_unverified` helper — the "req-resp mockery" the issue calls out. | `rg request_bitswap_blocks` → only mentions in docs. | +| [substrate/client/network/src/litep2p/bitswap.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/litep2p/bitswap.rs) (653 lines) | The old `BitswapService` + `PendingBatch` shim that re-encoded protobuf bytes. | `rg PendingBatch\|BitswapOutboundCmd` → no hits. | +| [substrate/client/network/src/bitswap/schema.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/schema.rs) | Just `include!`-ed the prost-generated protobuf bindings. No longer needed (litep2p owns the wire format). | `rg BitswapProtoMessage` → no hits. | +| [substrate/client/network/src/schema/bitswap.v1.2.0.proto](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/schema/bitswap.v1.2.0.proto) | The protobuf source file. | `rg \\.proto` (in sc-network) → no hits. | +| [substrate/client/network/build.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/build.rs) | Compiled the protobuf. | (file gone; cargo skips build step entirely.) | + +**What to verify**: no compilation references to any of these from elsewhere. The two `prost` / `prost-build` dependencies are removed from Cargo.toml (next step). + +--- + +## Step 7 — Cargo.toml dependency churn + +**File**: [substrate/client/network/Cargo.toml](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/Cargo.toml) + +Adds: +- `slotmap = { workspace = true }` — for stable waiter IDs. +- `tokio-util = { features = ["time"], workspace = true }` — for `DelayQueue`. +- `tokio` features: added `rt` (needed by `tokio::task::spawn_blocking`). +- In dev-deps: `tokio` features added `test-util` (for `start_paused = true` + `tokio::time::advance`). + +Removes: +- `prost = { workspace = true }` — no longer parsing/constructing Bitswap protobuf in this crate. +- `[build-dependencies] prost-build = ...` — entire section gone with build.rs. + +**What to verify**: all four added items are workspace-managed (no version pins introduced here). `tokio-util` is now in both `[dependencies]` (with `time` feature) and `[dev-dependencies]` (with `compat` feature) — cargo merges features, this is correct. + +--- + +## Step 8 — Wiring: `Litep2pNetworkBackend` and `build_network` + +**Why this matters**: connects the new service to the actual node startup path. + +### 8a. `IpfsConfig` lost its generics + +**File**: [substrate/client/network/src/config.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/config.rs) + +Old: `pub struct IpfsConfig> { bitswap_config: N::BitswapConfig, block_provider, bootnodes }`. + +New: `pub struct IpfsConfig { bitswap_wiring: Option, block_provider, bootnodes }`. + +Two things to verify: +1. The generics dropped because no remaining field is generic over `(Block, H, N)`. +2. `Params::ipfs_config: Option` (no generics) — this means the 5 test/bench harnesses that set `ipfs_config: None` continue to compile unchanged (verified during implementation by running `cargo check` workspace-wide). + +### 8b. `Litep2pNetworkBackend::new` + +**File**: [substrate/client/network/src/litep2p/mod.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/litep2p/mod.rs) + +The bitswap-init block (was lines 511-529 in the old code) was replaced. New behavior: + +```rust +if let Some(ipfs) = params.ipfs_config { + let wiring = ipfs.bitswap_wiring.expect("...; qed"); + config_builder = config_builder.with_libp2p_bitswap(wiring.litep2p_config); + bitswap_user_handle = Some(wiring.user_handle); + bitswap_peer_event_tx = Some(wiring.peer_event_tx); + // ... DHT setup (unchanged) ... +} +``` + +The `.expect("...; qed")` is the invariant: if `ipfs_config` is `Some`, `bitswap_wiring` must also be `Some`. This invariant is upheld by `build_network` (step 8c). + +Two new fields on `Litep2pNetworkBackend`: +- `bitswap_peer_event_tx: Option>` +- `bitswap_peer_conn_count: HashMap` — see step 8d below. + +### 8c. `build_network` (the libp2p+ipfs_server guard) + +**File**: [substrate/client/service/src/builder.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/service/src/builder.rs) + +The old `Net::bitswap_server(client.clone())` call (was line 1246) is replaced by: + +1. A runtime guard: `if libp2p backend && ipfs_server → return Err(...)`. The error message says "Bitswap requires the litep2p network backend; set --network-backend litep2p or disable --ipfs-server". +2. A call to `sc_network::bitswap::start::(client.clone(), BitswapServiceConfig::default())`. +3. Spawn the returned future as `"bitswap-service"`. +4. Construct `IpfsConfig { bitswap_wiring: Some(bitswap_wiring), ... }`. + +**What to verify**: +- The guard fires BEFORE `bitswap::start` is called → no wasted setup on the error path. +- The match expression uses `matches!(..., NetworkBackendType::Libp2p)` — easy to read. +- The error type is `Error::Other(...)` which is the existing pattern for build-time configuration errors in this file. + +### 8d. Peer-event broadcast from the litep2p main loop + +**File**: [substrate/client/network/src/litep2p/mod.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/litep2p/mod.rs) (the `run()` method, around the `ConnectionEstablished` / `ConnectionClosed` arms) + +**What to verify**: +- The bitswap publish happens **BEFORE** the `let Some(metrics) = &self.metrics else { continue }` guard. This is intentional — the main loop's `self.peers` map is only populated when metrics are enabled, so I cannot rely on it for bitswap peer dedup. The new `bitswap_peer_conn_count` map is populated unconditionally (when bitswap is enabled). +- The dedup logic: increment on every `ConnectionEstablished`, publish `Connected` only on 0→1 transition. Decrement on every `ConnectionClosed`, publish `Disconnected` only on 1→0 transition. This handles peers with multiple concurrent connections correctly. +- `tx.try_send(...)` is used (never blocks the main loop). If the actor's peer_event_rx is full, the event is silently dropped — acceptable because the next ConnectionEstablished/Closed will re-converge state. + +--- + +## Step 9 — Re-exports + +**File**: [substrate/client/network/src/lib.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/lib.rs) + +The `pub use service::traits::{...}` block gains `BitswapProvider`. That's the only change. + +`BitswapHandle`, `BitswapError`, `FetchOutcome`, etc are re-exported via `crate::bitswap::*` — `bitswap` was already a `pub` module, no change needed. + +**What to verify**: callers can write either `use sc_network::bitswap::BitswapHandle;` or `use sc_network::BitswapProvider;` (the trait — needed to call `.bitswap_handle()` on a NetworkService). + +--- + +## Step 10 — The prdoc + +**File**: [prdoc/pr_12052.prdoc](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/prdoc/pr_12052.prdoc) + +33 lines. Sets reviewer expectation: + +- `sc-network`: major bump (API surface changed — `bitswap_server` trait method gone, `BitswapConfig` associated type gone). +- `sc-service`: minor bump (just one site touched, behavior preserved when ipfs_server=false). +- Calls out the substrate-only scope and the temporary cumulus breakage. + +--- + +## Final QA you should run yourself + +```bash +cd /home/sebastian/work/tries/2026-05-13-bitswap-API-improvement + +# 1. Build sc-network + sc-service. +SKIP_WASM_BUILD=1 cargo check -p sc-network -p sc-service --all-targets + +# 2. Build the node binary (this is what verifies end-to-end wiring). +SKIP_WASM_BUILD=1 cargo check -p staging-node-cli --lib + +# 3. Run the bitswap test suite. +cargo test -p sc-network bitswap:: + +# 4. (Optional) Confirm the format pass was applied. +cargo fmt -p sc-network -p sc-service --check +``` + +Expected: +- All checks pass. +- 18/18 bitswap tests pass. +- `cargo fmt --check` is clean. + +--- + +## What's deliberately NOT covered by this PR + +(So you don't ask about them.) + +- Cumulus consumer migration — separate follow-up PR. +- A zombienet end-to-end integration test that drives Bitswap between two nodes — the Resolved Design §7.2 lists it as future work. Unit tests cover the actor's logic; the real two-node test requires the cumulus migration to give us an actual production consumer. +- A clippy pass — let CI run that. Local clippy on `-p sc-network` is clean against the touched code (the warnings that remain are pre-existing in untouched files). +- Real bitswap sessions, HAVE preflight, per-handle quotas, per-call deadline overrides, higher peer-fanout CAP — listed as known follow-ups in Resolved Design §10. + +--- + +## Total time to review + +If you trust the test suite (and you should — 16 new scenario tests cover the decision matrix), the minimum useful review is: + +1. **Skim Step 0 + Step 1** (5 min): confirms the public API matches the design contract. +2. **Read Step 2** (5 min): confirms the trait surgery is sound. +3. **Read Step 4a → 4o** (15-20 min): confirms the actor's correctness invariants. The 4i (hash verification) and 4o (inbound offload) sections deserve special attention. +4. **Skim Step 8** (5 min): confirms the wiring is intentional. +5. **Run the QA commands above** (10-30 min depending on cold/warm cargo cache). + +≈ 45 min for a high-confidence review. Up to 90 min if you want to read every test individually. diff --git a/Cargo.lock b/Cargo.lock index dda0d4d5356a..a4cbcf70e5d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21478,8 +21478,6 @@ dependencies = [ "parking_lot 0.12.3", "partial_sort", "pin-project", - "prost 0.12.6", - "prost-build 0.13.2", "rand 0.8.5", "rustls 0.23.31", "sc-block-builder", @@ -21490,6 +21488,7 @@ dependencies = [ "schnellru", "serde", "serde_json", + "slotmap", "smallvec", "sp-arithmetic 23.0.0", "sp-blockchain 28.0.0", diff --git a/prdoc/pr_12052.prdoc b/prdoc/pr_12052.prdoc new file mode 100644 index 000000000000..6f28884972fe --- /dev/null +++ b/prdoc/pr_12052.prdoc @@ -0,0 +1,33 @@ +title: "Network: refactor Bitswap client API to a long-lived service" + +doc: + - audience: Node Dev + description: | + Replaces the request-response-shaped `request_bitswap_blocks` helper introduced in + PR #12038 with a long-lived Bitswap service exposed via + `NetworkService::bitswap_handle()`. The handle's only method is + `request_stream(cids) -> Receiver>`, + which streams per-CID outcomes as they resolve. + + The service runs an internal actor that owns peer selection, per-peer timeouts (5s), + per-waiter deadlines (configurable, default 30s), hash verification on every received + block, and bounded admission. Inbound serving is off-loaded to a bounded + `spawn_blocking` worker pool so storage reads never block outbound traffic or + response correlation. + + The libp2p Bitswap implementation is removed entirely. Enabling `--ipfs-server` + together with `--network-backend libp2p` now fails at startup with a clear error. + + The unverified Bitswap client codepath is removed as obsolete. + + Closes https://github.com/paritytech/polkadot-sdk/issues/12052. + + Note: this PR is substrate-only. The cumulus storage-chain-sync consumer of the old + helper is migrated in a separate follow-up PR; until that follow-up lands, + `cumulus-client-storage-chain-sync` will not compile against master. + +crates: + - name: sc-network + bump: major + - name: sc-service + bump: minor diff --git a/substrate/client/network/Cargo.toml b/substrate/client/network/Cargo.toml index 7ae8682a268e..cc2470d863e7 100644 --- a/substrate/client/network/Cargo.toml +++ b/substrate/client/network/Cargo.toml @@ -46,7 +46,6 @@ parking_lot = { workspace = true, default-features = true } partial_sort = { workspace = true } pin-project = { workspace = true } prometheus-endpoint = { workspace = true, default-features = true } -prost = { workspace = true } rand = { workspace = true, default-features = true } rustls = { workspace = true } sc-client-api = { workspace = true, default-features = true } @@ -56,6 +55,7 @@ sc-utils = { workspace = true, default-features = true } schnellru = { workspace = true } serde = { features = ["derive"], workspace = true, default-features = true } serde_json = { workspace = true, default-features = true } +slotmap = { workspace = true } smallvec = { workspace = true, default-features = true } sp-arithmetic = { workspace = true, default-features = true } sp-blockchain = { workspace = true, default-features = true } @@ -63,8 +63,9 @@ sp-core = { workspace = true, default-features = true } sp-crypto-hashing = { workspace = true, default-features = true } sp-runtime = { workspace = true, default-features = true } thiserror = { workspace = true } -tokio = { features = ["macros", "sync"], workspace = true, default-features = true } +tokio = { features = ["macros", "rt", "sync"], workspace = true, default-features = true } tokio-stream = { workspace = true } +tokio-util = { features = ["time"], workspace = true } unsigned-varint = { features = ["asynchronous_codec", "futures"], workspace = true } void = { workspace = true } wasm-timer = { workspace = true } @@ -80,13 +81,10 @@ sp-tracing = { workspace = true, default-features = true } substrate-test-runtime = { workspace = true } substrate-test-runtime-client = { workspace = true } tempfile = { workspace = true } -tokio = { features = ["macros", "rt-multi-thread"], workspace = true, default-features = true } +tokio = { features = ["macros", "rt-multi-thread", "test-util"], workspace = true, default-features = true } tokio-util = { features = ["compat"], workspace = true } criterion = { workspace = true, default-features = true, features = ["async_tokio"] } -[build-dependencies] -prost-build = { workspace = true } - [features] default = [] diff --git a/substrate/client/network/build.rs b/substrate/client/network/build.rs deleted file mode 100644 index 2495a9c25653..000000000000 --- a/substrate/client/network/build.rs +++ /dev/null @@ -1,23 +0,0 @@ -// This file is part of Substrate. - -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. - -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -const PROTOS: &[&str] = &["src/schema/bitswap.v1.2.0.proto"]; - -fn main() { - prost_build::compile_protos(PROTOS, &["src/schema"]).unwrap(); -} diff --git a/substrate/client/network/src/bitswap/client.rs b/substrate/client/network/src/bitswap/client.rs deleted file mode 100644 index 60e5240dae76..000000000000 --- a/substrate/client/network/src/bitswap/client.rs +++ /dev/null @@ -1,906 +0,0 @@ -// Copyright (C) Parity Technologies (UK) Ltd. -// This file is part of Substrate. -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -// Substrate is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. - -// Substrate is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -// You should have received a copy of the GNU General Public License -// along with Substrate. If not, see . - -use crate::{IfDisconnected, NetworkRequest, ProtocolName}; - -use cid::{multihash::Multihash as CidMultihash, Cid, Version as CidVersion}; -use log::{debug, trace, warn}; -use prost::Message; -use sc_network_types::PeerId; -use std::collections::{HashMap, HashSet}; - -use super::{ - is_cid_supported, - schema::bitswap::{ - message::{ - wantlist::{Entry, WantType as ProtoWantType}, - BlockPresence, BlockPresenceType, Wantlist, - }, - Message as BitswapMessage, - }, - Prefix, LOG_TARGET, MAX_WANTED_BLOCKS, PROTOCOL_NAME, -}; - -/// Const from -/// Multihash code for BLAKE2b-256. -pub const BLAKE2B_256_MULTIHASH_CODE: u64 = 0xb220; -/// Multihash code for SHA2-256. -pub const SHA2_256_MULTIHASH_CODE: u64 = 0x12; -/// Multihash code for Keccak-256. -pub const KECCAK_256_MULTIHASH_CODE: u64 = 0x1b; - -/// Per-CID outcome from a Bitswap block request. -/// -/// The public contract is intentionally narrow: either the peer delivered the bytes for the CID -/// or it did not. A peer signalling `DONT_HAVE` and a peer staying silent for a CID are both -/// surfaced as [`FetchOutcome::Missing`]; callers needing a different policy must implement it -/// over [`FetchOutcome`]. -#[derive(Debug)] -pub enum FetchOutcome { - /// Peer returned bytes for the requested CID. - Block(Vec), - /// Peer did not deliver bytes for this CID. - /// - /// Covers the peer explicitly answering `DONT_HAVE`, the peer answering `HAVE` without bytes, - /// and the peer not acknowledging the CID at all. From the caller's perspective these are - /// equivalent: no block was delivered. - Missing, -} - -/// Multihash type with a 64-byte digest capacity. -type Multihash = CidMultihash<64>; - -/// Validate the wantlist length is within bounds. -fn validate_wantlist_size(len: usize) -> Result<(), BitswapError> { - if len == 0 { - return Err(BitswapError::DecodeError("empty wantlist".into())); - } - if len > MAX_WANTED_BLOCKS { - return Err(BitswapError::DecodeError(format!( - "wantlist too large: {len} > {MAX_WANTED_BLOCKS}", - ))); - } - Ok(()) -} - -/// Validate CIDs: enforce length, CID support, and CID uniqueness. -fn validate_cids(cids: &[Cid]) -> Result<(), BitswapError> { - validate_wantlist_size(cids.len())?; - - let mut seen: HashSet = HashSet::with_capacity(cids.len()); - for cid in cids { - if !is_cid_supported(cid) { - return Err(BitswapError::UnsupportedHashing { multihash_code: cid.hash().code() }); - } - if !seen.insert(*cid) { - return Err(BitswapError::DecodeError(format!("duplicate CID in wantlist: {cid}"))); - } - } - - Ok(()) -} - -/// Send one `WANT-BLOCK` request for `cids` to `peer` and classify the response. -/// -/// Returned blocks are verified by recomputing the CID from the response prefix and bytes. -/// Blocks whose recomputed CID was not requested are ignored. -/// -/// Errors if `cids` is empty, larger than [`MAX_WANTED_BLOCKS`], contains an unsupported CID, -/// or contains a duplicate CID. -/// -/// Note: This is a temporary API that shall be superseeded by a better abstraction such as -/// -pub async fn request_bitswap_blocks( - network: &N, - peer: PeerId, - cids: &[Cid], -) -> Result, BitswapError> -where - N: NetworkRequest + ?Sized, -{ - validate_cids(cids)?; - - let wanted: HashSet = cids.iter().copied().collect(); - let response = send_request(network, peer, cids).await?; - Ok(classify_response(response, &wanted, peer)) -} - -/// Like [`request_bitswap_blocks`], but does not recompute or verify the hash of received bytes. -/// -/// Use this when the requester must fetch by CID-shaped identifiers before it can verify the -/// returned bytes through an external authority. The response is matched by request order and -/// CID prefix only; integrity verification is delegated to the caller. -/// -/// Note: This is a temporary API that shall be superseeded by a better abstraction such as -/// -pub async fn request_bitswap_blocks_unverified( - network: &N, - peer: PeerId, - cids: &[Cid], -) -> Result, BitswapError> -where - N: NetworkRequest + ?Sized, -{ - validate_cids(cids)?; - - let response = send_request(network, peer, cids).await?; - Ok(classify_response_unverified(response, cids, peer)) -} - -/// Dispatch a bitswap WANT request to `peer` and decode the response. -async fn send_request( - network: &N, - peer: PeerId, - cids: &[Cid], -) -> Result -where - N: NetworkRequest + ?Sized, -{ - let entries: Vec = cids - .iter() - .copied() - .map(|cid| Entry { - block: cid.to_bytes(), - want_type: ProtoWantType::Block as i32, - send_dont_have: true, - ..Default::default() - }) - .collect(); - let request = - BitswapMessage { wantlist: Some(Wantlist { entries, full: false }), ..Default::default() }; - - trace!( - target: LOG_TARGET, - "client: sending Bitswap wantlist for {} CIDs to {peer}, protocol {PROTOCOL_NAME}", - cids.len(), - ); - - let payload = match network - .request( - peer, - ProtocolName::from(PROTOCOL_NAME), - request.encode_to_vec(), - None, - IfDisconnected::TryConnect, - ) - .await - { - Ok((payload, _)) => payload, - Err(err) => { - debug!(target: LOG_TARGET, "client: batch request to {peer} rejected by network: {err:?}"); - return Err(BitswapError::RequestFailed(err.to_string())); - }, - }; - - BitswapMessage::decode(&payload[..]).map_err(|err| { - debug!(target: LOG_TARGET, "client: failed to decode batch response from {peer}: {err}"); - BitswapError::DecodeError(err.to_string()) - }) -} - -/// Classify the response by verifying each block's CID against the wanted set. -/// -/// Every wanted CID is recorded exactly once: as [`FetchOutcome::Block`] if the peer delivered -/// bytes whose recomputed CID is in `wanted`, otherwise as [`FetchOutcome::Missing`]. Presence -/// frames (`HAVE` / `DONT_HAVE`) are logged for diagnostics but do not change the outcome. -fn classify_response( - response: BitswapMessage, - wanted: &HashSet, - peer: PeerId, -) -> HashMap { - let mut result: HashMap = HashMap::with_capacity(wanted.len()); - - for block in response.payload { - let Ok(cid) = cid_from_block_prefix(&block.prefix, &block.data).inspect_err(|err| { - debug!(target: LOG_TARGET, "client: malformed block prefix from {peer}: {err:?}"); - }) else { - continue; - }; - if !wanted.contains(&cid) { - debug!(target: LOG_TARGET, "client: {peer} returned unsolicited block for CID {cid}"); - continue; - } - debug!(target: LOG_TARGET, "client: {peer} returned {} bytes for CID {cid}", block.data.len()); - result.insert(cid, FetchOutcome::Block(block.data)); - } - - log_presences(response.block_presences, wanted, peer); - - for cid in wanted { - result.entry(*cid).or_insert(FetchOutcome::Missing); - } - - result -} - -/// Classify an unverified response via order-based correlation. -/// -/// Every wanted CID is recorded exactly once: as [`FetchOutcome::Block`] if the peer delivered -/// bytes whose declared prefix matches a requested CID at the corresponding position in the -/// wantlist, otherwise as [`FetchOutcome::Missing`]. -fn classify_response_unverified( - response: BitswapMessage, - cids: &[Cid], - peer: PeerId, -) -> HashMap { - let mut result: HashMap = HashMap::with_capacity(cids.len()); - let wanted_set: HashSet = cids.iter().copied().collect(); - let mut dont_have_cids: HashSet = HashSet::with_capacity(cids.len()); - - for presence in response.block_presences { - let Ok(cid) = Cid::read_bytes(presence.cid.as_slice()).inspect_err(|err| { - debug!(target: LOG_TARGET, "client: malformed presence CID from {peer}: {err}"); - }) else { - continue; - }; - if !wanted_set.contains(&cid) { - debug!(target: LOG_TARGET, "client: {peer} returned unsolicited presence for CID {cid}"); - continue; - } - if presence.r#type == BlockPresenceType::DontHave as i32 { - debug!(target: LOG_TARGET, "client: {peer} DONT_HAVE for CID {cid}"); - dont_have_cids.insert(cid); - } else if presence.r#type == BlockPresenceType::Have as i32 { - debug!(target: LOG_TARGET, "client: {peer} HAVE for CID {cid}"); - } else { - warn!( - target: LOG_TARGET, - "client: {peer} unexpected presence type {} for CID {cid}", - presence.r#type, - ); - } - } - - // Unverified payloads cannot be matched by recomputing their CID from bytes, so attribute - // each block to the next requested CID (skipping any the peer already said it doesn't have) - // whose CID metadata matches the payload prefix. - let mut expected_payload_order = - cids.iter().copied().filter(|cid| !dont_have_cids.contains(cid)); - - for block in response.payload { - let Some(expected_cid) = expected_payload_order.next() else { - debug!(target: LOG_TARGET, "client: {peer} returned more payload blocks than expected; dropping extras"); - break; - }; - let Ok(prefix) = decode_prefix(&block.prefix).inspect_err(|err| { - debug!(target: LOG_TARGET, "client: malformed block prefix from {peer}: {err:?}"); - }) else { - break; - }; - if !prefix_matches_cid(&prefix, &expected_cid) { - debug!( - target: LOG_TARGET, - "client: {peer} returned block with prefix {:?} but expected CID {expected_cid}; \ - stopping payload attribution", - prefix, - ); - break; - } - debug!( - target: LOG_TARGET, - "client: {peer} returned {} unverified bytes for CID {expected_cid}", - block.data.len(), - ); - result.insert(expected_cid, FetchOutcome::Block(block.data.clone())); - } - - for cid in cids { - result.entry(*cid).or_insert(FetchOutcome::Missing); - } - - result -} - -/// Log per-CID presence frames for diagnostics. Presence does not influence the public outcome. -fn log_presences(presences: Vec, wanted: &HashSet, peer: PeerId) { - for presence in presences { - let Ok(cid) = Cid::read_bytes(presence.cid.as_slice()).inspect_err(|err| { - debug!(target: LOG_TARGET, "client: malformed presence CID from {peer}: {err}"); - }) else { - continue; - }; - if !wanted.contains(&cid) { - debug!(target: LOG_TARGET, "client: {peer} returned unsolicited presence for CID {cid}"); - continue; - } - if presence.r#type == BlockPresenceType::DontHave as i32 { - debug!(target: LOG_TARGET, "client: {peer} DONT_HAVE for CID {cid}"); - } else if presence.r#type == BlockPresenceType::Have as i32 { - debug!(target: LOG_TARGET, "client: {peer} HAVE for CID {cid}"); - } else { - debug!( - target: LOG_TARGET, - "client: {peer} unexpected presence type {} for CID {cid}", - presence.r#type, - ); - } - } -} - -/// Check that a decoded prefix matches a CID's version, codec, and multihash metadata. -fn prefix_matches_cid(prefix: &Prefix, cid: &Cid) -> bool { - prefix.version == cid.version() && - prefix.codec == cid.codec() && - prefix.mh_type == cid.hash().code() && - prefix.mh_len == cid.hash().size() -} - -/// Reconstruct a CID from a block's prefix bytes and payload data. -fn cid_from_block_prefix(prefix: &[u8], data: &[u8]) -> Result { - let prefix = decode_prefix(prefix)?; - if prefix.version != CidVersion::V1 { - return Err(BitswapError::UnsupportedCidVersion { version: prefix.version.into() }); - } - - let hash = hash_for_multihash_code(prefix.mh_type, data) - .ok_or(BitswapError::UnsupportedHashing { multihash_code: prefix.mh_type })?; - let multihash = Multihash::wrap(prefix.mh_type, &hash) - .map_err(|err| BitswapError::DecodeError(err.to_string()))?; - Ok(Cid::new_v1(prefix.codec, multihash)) -} - -/// Compute a 32-byte hash for the given multihash code. -fn hash_for_multihash_code(multihash_code: u64, data: &[u8]) -> Option<[u8; 32]> { - match multihash_code { - BLAKE2B_256_MULTIHASH_CODE => Some(sp_crypto_hashing::blake2_256(data)), - SHA2_256_MULTIHASH_CODE => Some(sp_crypto_hashing::sha2_256(data)), - KECCAK_256_MULTIHASH_CODE => Some(sp_crypto_hashing::keccak_256(data)), - _ => None, - } -} - -/// Decode varint-encoded CID prefix bytes. -fn decode_prefix(mut bytes: &[u8]) -> Result { - let mut read_varint = || -> Result { - let (v, rest) = unsigned_varint::decode::u64(bytes) - .map_err(|err| BitswapError::DecodeError(err.to_string()))?; - bytes = rest; - Ok(v) - }; - - let version = read_varint()?; - let codec = read_varint()?; - let mh_type = read_varint()?; - let mh_len = read_varint()?; - - if !bytes.is_empty() { - return Err(BitswapError::DecodeError("bitswap block prefix had trailing bytes".into())); - } - - let version = CidVersion::try_from(version) - .map_err(|_| BitswapError::UnsupportedCidVersion { version })?; - let mh_len = u8::try_from(mh_len).map_err(|_| { - BitswapError::DecodeError(format!("multihash length {mh_len} does not fit into u8")) - })?; - - Ok(Prefix { version, codec, mh_type, mh_len }) -} - -/// Bitswap client errors. -#[derive(Debug)] -pub enum BitswapError { - /// Failed to decode or validate a bitswap payload. - DecodeError(String), - /// Request/response exchange failed. - RequestFailed(String), - /// Block prefix declared an unsupported multihash code. - UnsupportedHashing { - /// The unrecognised IPFS multihash code. - multihash_code: u64, - }, - /// CID version is unsupported for this bitswap client. - UnsupportedCidVersion { - /// The unsupported CID version number. - version: u64, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{OutboundFailure, RequestFailure}; - use futures::channel::oneshot; - use sc_network_types::PeerId; - use std::{collections::VecDeque, sync::Mutex}; - - use super::super::{ - is_supported_multihash_code, - schema::bitswap::message::{Block as MessageBlock, BlockPresence, BlockPresenceType}, - RAW_CODEC, - }; - - /// Build a raw-codec CID from a 32-byte digest and supported multihash code. - fn raw_cid_from_digest(multihash_code: u64, digest: [u8; 32]) -> Result { - if !is_supported_multihash_code(multihash_code) { - return Err(BitswapError::UnsupportedHashing { multihash_code }); - } - let multihash = CidMultihash::wrap(multihash_code, &digest) - .map_err(|e| BitswapError::DecodeError(e.to_string()))?; - Ok(Cid::new_v1(RAW_CODEC, multihash)) - } - - struct StubSender { - responses: Mutex, RequestFailure>>>, - requests: Mutex>>, - } - - impl StubSender { - fn new(responses: impl IntoIterator, RequestFailure>>) -> Self { - Self { - responses: Mutex::new(responses.into_iter().collect()), - requests: Mutex::new(Vec::new()), - } - } - - fn pop_request(&self) -> BitswapMessage { - let bytes = self.requests.lock().unwrap().pop().expect("request should be recorded"); - BitswapMessage::decode(bytes.as_slice()).expect("request should decode") - } - } - - #[async_trait::async_trait] - impl NetworkRequest for StubSender { - async fn request( - &self, - _target: PeerId, - _protocol: ProtocolName, - request: Vec, - _fallback_request: Option<(Vec, ProtocolName)>, - _connect: IfDisconnected, - ) -> Result<(Vec, ProtocolName), RequestFailure> { - self.requests.lock().unwrap().push(request); - self.responses - .lock() - .unwrap() - .pop_front() - .expect("StubSender: no canned response queued") - .map(|bytes| (bytes, ProtocolName::from(PROTOCOL_NAME))) - } - - fn start_request( - &self, - _peer: PeerId, - _protocol: ProtocolName, - payload: Vec, - _fallback_request: Option<(Vec, ProtocolName)>, - tx: oneshot::Sender, ProtocolName), RequestFailure>>, - _connect: IfDisconnected, - ) { - self.requests.lock().unwrap().push(payload); - let resp = self - .responses - .lock() - .unwrap() - .pop_front() - .expect("StubSender: no canned response queued"); - let _ = tx.send(resp.map(|bytes| (bytes, ProtocolName::from(PROTOCOL_NAME)))); - } - } - - fn prefix_for(multihash_code: u64) -> Vec { - Prefix { version: CidVersion::V1, codec: RAW_CODEC, mh_type: multihash_code, mh_len: 32 } - .to_bytes() - } - - fn cid_for_data(multihash_code: u64, data: &[u8]) -> Cid { - raw_cid_from_digest(multihash_code, hash_for_multihash_code(multihash_code, data).unwrap()) - .unwrap() - } - - fn cid_for_digest(multihash_code: u64, digest: [u8; 32]) -> Cid { - raw_cid_from_digest(multihash_code, digest).unwrap() - } - - fn encode_response(blocks: &[(u64, Vec)], presences: &[(Cid, i32)]) -> Vec { - let payload = blocks - .iter() - .map(|(multihash_code, data)| MessageBlock { - prefix: prefix_for(*multihash_code), - data: data.clone(), - }) - .collect(); - let block_presences = presences - .iter() - .map(|(cid, ptype)| BlockPresence { cid: cid.to_bytes(), r#type: *ptype }) - .collect(); - BitswapMessage { payload, block_presences, ..Default::default() }.encode_to_vec() - } - - #[tokio::test] - async fn request_bitswap_blocks_returns_blocks_for_all_wanted() { - let data_a = b"hash-a-payload".to_vec(); - let data_b = b"hash-b-payload".to_vec(); - let data_c = b"hash-c-payload".to_vec(); - let cid_a = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data_a); - let cid_b = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data_b); - let cid_c = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data_c); - - let response = encode_response( - &[ - (BLAKE2B_256_MULTIHASH_CODE, data_a.clone()), - (BLAKE2B_256_MULTIHASH_CODE, data_b.clone()), - (BLAKE2B_256_MULTIHASH_CODE, data_c.clone()), - ], - &[], - ); - let stub = StubSender::new([Ok(response)]); - - let result = request_bitswap_blocks(&stub, PeerId::random(), &[cid_a, cid_b, cid_c]) - .await - .expect("request_bitswap_blocks should succeed"); - - assert_eq!(result.len(), 3); - assert!(matches!(result.get(&cid_a), Some(FetchOutcome::Block(d)) if *d == data_a)); - assert!(matches!(result.get(&cid_b), Some(FetchOutcome::Block(d)) if *d == data_b)); - assert!(matches!(result.get(&cid_c), Some(FetchOutcome::Block(d)) if *d == data_c)); - } - - #[tokio::test] - async fn request_bitswap_blocks_dont_have_is_surfaced_as_missing() { - let data_a = b"a".to_vec(); - let data_b = b"b".to_vec(); - let cid_a = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data_a); - let cid_b = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data_b); - let cid_c = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, b"c-not-served"); - - let response = encode_response( - &[ - (BLAKE2B_256_MULTIHASH_CODE, data_a.clone()), - (BLAKE2B_256_MULTIHASH_CODE, data_b.clone()), - ], - &[(cid_c, BlockPresenceType::DontHave as i32)], - ); - let stub = StubSender::new([Ok(response)]); - - let result = request_bitswap_blocks(&stub, PeerId::random(), &[cid_a, cid_b, cid_c]) - .await - .unwrap(); - - assert_eq!(result.len(), 3); - assert!(matches!(result.get(&cid_a), Some(FetchOutcome::Block(_)))); - assert!(matches!(result.get(&cid_b), Some(FetchOutcome::Block(_)))); - assert!(matches!(result.get(&cid_c), Some(FetchOutcome::Missing))); - } - - #[tokio::test] - async fn request_bitswap_blocks_corrupted_data_dropped_as_unsolicited() { - let real_data = b"real-payload".to_vec(); - let wanted_cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &real_data); - let corrupted_data = b"i-am-not-the-real-payload".to_vec(); - let response = encode_response(&[(BLAKE2B_256_MULTIHASH_CODE, corrupted_data)], &[]); - let stub = StubSender::new([Ok(response)]); - - let result = request_bitswap_blocks(&stub, PeerId::random(), &[wanted_cid]).await.unwrap(); - - assert_eq!(result.len(), 1); - assert!(matches!(result.get(&wanted_cid), Some(FetchOutcome::Missing))); - } - - #[tokio::test] - async fn request_bitswap_blocks_encodes_only_want_block_entries() { - let cid_a = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [1u8; 32]); - let cid_b = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [2u8; 32]); - let stub = StubSender::new([Ok(BitswapMessage::default().encode_to_vec())]); - - let result = request_bitswap_blocks(&stub, PeerId::random(), &[cid_a, cid_b]) - .await - .expect("block-only request must encode"); - - assert!(matches!(result.get(&cid_a), Some(FetchOutcome::Missing))); - assert!(matches!(result.get(&cid_b), Some(FetchOutcome::Missing))); - - let request = stub.pop_request(); - let entries = request.wantlist.expect("wantlist should be present").entries; - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].want_type, ProtoWantType::Block as i32); - assert_eq!(entries[1].want_type, ProtoWantType::Block as i32); - } - - #[tokio::test] - async fn request_bitswap_blocks_have_presence_alone_is_missing() { - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [3u8; 32]); - let response = encode_response(&[], &[(cid, BlockPresenceType::Have as i32)]); - let stub = StubSender::new([Ok(response)]); - - let result = request_bitswap_blocks(&stub, PeerId::random(), &[cid]) - .await - .expect("HAVE-only response should classify successfully"); - - assert_eq!(result.len(), 1); - assert!(matches!(result.get(&cid), Some(FetchOutcome::Missing))); - } - - #[tokio::test] - async fn request_bitswap_blocks_unverified_accepts_bytes_without_hash_recompute() { - let data = b"sha2-digest-but-blake2b-request-prefix".to_vec(); - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, sp_crypto_hashing::sha2_256(&data)); - let response = encode_response(&[(BLAKE2B_256_MULTIHASH_CODE, data.clone())], &[]); - let stub = StubSender::new([Ok(response)]); - - let result = request_bitswap_blocks_unverified(&stub, PeerId::random(), &[cid]) - .await - .expect("unverified fetch should not recompute hashes"); - - assert_eq!(result.len(), 1); - assert!(matches!(result.get(&cid), Some(FetchOutcome::Block(d)) if *d == data)); - } - - #[tokio::test] - async fn request_bitswap_blocks_unverified_dont_have_returned_as_missing() { - let cid = cid_for_digest( - BLAKE2B_256_MULTIHASH_CODE, - sp_crypto_hashing::sha2_256(b"pruned-unverified-payload"), - ); - let response = encode_response(&[], &[(cid, BlockPresenceType::DontHave as i32)]); - let stub = StubSender::new([Ok(response)]); - - let result = request_bitswap_blocks_unverified(&stub, PeerId::random(), &[cid]) - .await - .expect("unverified DONT_HAVE should classify successfully"); - - assert_eq!(result.len(), 1); - assert!(matches!(result.get(&cid), Some(FetchOutcome::Missing))); - } - - #[tokio::test] - async fn request_bitswap_blocks_unverified_empty_wants_errors() { - let stub = StubSender::new(std::iter::empty()); - - let err = request_bitswap_blocks_unverified(&stub, PeerId::random(), &[]) - .await - .expect_err("empty wantlist must error"); - assert!(matches!(err, BitswapError::DecodeError(msg) if msg == "empty wantlist")); - } - - #[tokio::test] - async fn request_bitswap_blocks_duplicate_cids_error() { - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [9u8; 32]); - let stub = StubSender::new(std::iter::empty()); - - let err = request_bitswap_blocks(&stub, PeerId::random(), &[cid, cid]) - .await - .expect_err("two wants for the same CID are ambiguous"); - assert!(matches!(err, BitswapError::DecodeError(msg) if msg.starts_with("duplicate CID"))); - } - - #[tokio::test] - async fn request_bitswap_blocks_unverified_multi_want_all_served_in_request_order() { - let data_a = b"first-unverified-payload".to_vec(); - let data_b = b"second-unverified-payload".to_vec(); - let data_c = b"third-unverified-payload".to_vec(); - let cid_a = - cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, sp_crypto_hashing::sha2_256(&data_a)); - let cid_b = - cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, sp_crypto_hashing::keccak_256(&data_b)); - let cid_c = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data_c); - - let response = encode_response( - &[ - (BLAKE2B_256_MULTIHASH_CODE, data_a.clone()), - (BLAKE2B_256_MULTIHASH_CODE, data_b.clone()), - (BLAKE2B_256_MULTIHASH_CODE, data_c.clone()), - ], - &[], - ); - let stub = StubSender::new([Ok(response)]); - - let result = - request_bitswap_blocks_unverified(&stub, PeerId::random(), &[cid_a, cid_b, cid_c]) - .await - .expect("multi-want unverified must succeed via positional correlation"); - - assert_eq!(result.len(), 3); - assert!(matches!(result.get(&cid_a), Some(FetchOutcome::Block(d)) if *d == data_a)); - assert!(matches!(result.get(&cid_b), Some(FetchOutcome::Block(d)) if *d == data_b)); - assert!(matches!(result.get(&cid_c), Some(FetchOutcome::Block(d)) if *d == data_c)); - } - - #[tokio::test] - async fn request_bitswap_blocks_unverified_dont_have_skips_position_in_payload_order() { - let data = b"second-payload-after-dont-have".to_vec(); - let dont_have_cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [4u8; 32]); - let block_cid = - cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, sp_crypto_hashing::sha2_256(&data)); - let response = encode_response( - &[(BLAKE2B_256_MULTIHASH_CODE, data.clone())], - &[(dont_have_cid, BlockPresenceType::DontHave as i32)], - ); - let stub = StubSender::new([Ok(response)]); - - let result = - request_bitswap_blocks_unverified(&stub, PeerId::random(), &[dont_have_cid, block_cid]) - .await - .expect("unverified mixed presence/payload should classify successfully"); - - assert_eq!(result.len(), 2); - assert!(matches!(result.get(&dont_have_cid), Some(FetchOutcome::Missing))); - assert!(matches!(result.get(&block_cid), Some(FetchOutcome::Block(d)) if *d == data)); - } - - #[tokio::test] - async fn request_bitswap_blocks_dispatches_per_entry_multihash() { - let data_b2 = b"blake2b-payload".to_vec(); - let data_sha = b"sha2-256-payload".to_vec(); - let data_kec = b"keccak-256-payload".to_vec(); - let cid_b2 = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data_b2); - let cid_sha = cid_for_data(SHA2_256_MULTIHASH_CODE, &data_sha); - let cid_kec = cid_for_data(KECCAK_256_MULTIHASH_CODE, &data_kec); - - let response = encode_response( - &[ - (BLAKE2B_256_MULTIHASH_CODE, data_b2.clone()), - (SHA2_256_MULTIHASH_CODE, data_sha.clone()), - (KECCAK_256_MULTIHASH_CODE, data_kec.clone()), - ], - &[], - ); - let stub = StubSender::new([Ok(response)]); - - let result = request_bitswap_blocks(&stub, PeerId::random(), &[cid_b2, cid_sha, cid_kec]) - .await - .unwrap(); - - assert_eq!(result.len(), 3); - assert!(matches!(result.get(&cid_b2), Some(FetchOutcome::Block(d)) if *d == data_b2)); - assert!(matches!(result.get(&cid_sha), Some(FetchOutcome::Block(d)) if *d == data_sha)); - assert!(matches!(result.get(&cid_kec), Some(FetchOutcome::Block(d)) if *d == data_kec)); - } - - #[tokio::test] - async fn request_bitswap_blocks_over_cap_errors() { - let wants: Vec<_> = (0..(MAX_WANTED_BLOCKS + 1) as u8) - .map(|i| { - let mut h = [0u8; 32]; - h[0] = i; - cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, h) - }) - .collect(); - let stub = StubSender::new(std::iter::empty()); - - let err = request_bitswap_blocks(&stub, PeerId::random(), &wants) - .await - .expect_err("over-cap wantlist must error"); - assert!(matches!(err, BitswapError::DecodeError(_))); - } - - #[tokio::test] - async fn request_bitswap_blocks_at_exactly_max_wanted_blocks_succeeds() { - let mut wants = Vec::with_capacity(MAX_WANTED_BLOCKS); - let mut blocks = Vec::with_capacity(MAX_WANTED_BLOCKS); - for i in 0..MAX_WANTED_BLOCKS { - let data = format!("payload-{i}").into_bytes(); - wants.push(cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data)); - blocks.push((BLAKE2B_256_MULTIHASH_CODE, data)); - } - - let response = encode_response(&blocks, &[]); - let stub = StubSender::new([Ok(response)]); - - let result = request_bitswap_blocks(&stub, PeerId::random(), &wants) - .await - .expect("exactly MAX_WANTED_BLOCKS must succeed"); - - assert_eq!(result.len(), MAX_WANTED_BLOCKS); - for cid in &wants { - assert!(matches!(result.get(cid), Some(FetchOutcome::Block(_)))); - } - } - - #[tokio::test] - async fn request_bitswap_blocks_block_beats_presence_for_same_cid() { - let data = b"both-block-and-presence".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - let response = encode_response( - &[(BLAKE2B_256_MULTIHASH_CODE, data.clone())], - &[(cid, BlockPresenceType::DontHave as i32)], - ); - let stub = StubSender::new([Ok(response)]); - - let result = request_bitswap_blocks(&stub, PeerId::random(), &[cid]).await.unwrap(); - - assert_eq!(result.len(), 1); - assert!(matches!(result.get(&cid), Some(FetchOutcome::Block(d)) if *d == data)); - } - - #[tokio::test] - async fn request_bitswap_blocks_response_decode_failure() { - let stub = StubSender::new([Ok(vec![0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff])]); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, b"any"); - - let err = request_bitswap_blocks(&stub, PeerId::random(), &[cid]) - .await - .expect_err("malformed response bytes must surface as DecodeError"); - assert!(matches!(err, BitswapError::DecodeError(_))); - } - - #[tokio::test] - async fn request_bitswap_blocks_request_failure_propagates() { - struct FailingSender; - #[async_trait::async_trait] - impl NetworkRequest for FailingSender { - async fn request( - &self, - _target: PeerId, - _protocol: ProtocolName, - _request: Vec, - _fallback_request: Option<(Vec, ProtocolName)>, - _connect: IfDisconnected, - ) -> Result<(Vec, ProtocolName), RequestFailure> { - Err(RequestFailure::Network(OutboundFailure::ConnectionClosed)) - } - - fn start_request( - &self, - _peer: PeerId, - _protocol: ProtocolName, - _payload: Vec, - _fallback_request: Option<(Vec, ProtocolName)>, - tx: oneshot::Sender, ProtocolName), RequestFailure>>, - _connect: IfDisconnected, - ) { - drop(tx); - } - } - - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, b"any"); - let err = request_bitswap_blocks(&FailingSender, PeerId::random(), &[cid]) - .await - .expect_err("request failure must surface as RequestFailed"); - assert!(matches!(err, BitswapError::RequestFailed(_))); - } - - #[tokio::test] - async fn request_bitswap_blocks_unsupported_multihash_in_block_dropped() { - let wanted_data = b"wanted".to_vec(); - let wanted_cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &wanted_data); - const UNSUPPORTED_MH_CODE: u64 = 0x99; - let bad_prefix = Prefix { - version: CidVersion::V1, - codec: RAW_CODEC, - mh_type: UNSUPPORTED_MH_CODE, - mh_len: 32, - } - .to_bytes(); - - let mut payload_msg = BitswapMessage::default(); - payload_msg.payload = - vec![MessageBlock { prefix: bad_prefix, data: b"some-bytes".to_vec() }]; - let response = payload_msg.encode_to_vec(); - let stub = StubSender::new([Ok(response)]); - - let result = request_bitswap_blocks(&stub, PeerId::random(), &[wanted_cid]).await.unwrap(); - - assert_eq!(result.len(), 1); - assert!(matches!(result.get(&wanted_cid), Some(FetchOutcome::Missing))); - } - - #[test] - fn cid_from_block_prefix_rejects_cid_v0_as_unsupported() { - let prefix = Prefix { - version: CidVersion::V0, - codec: RAW_CODEC, - mh_type: BLAKE2B_256_MULTIHASH_CODE, - mh_len: 32, - } - .to_bytes(); - - let err = - cid_from_block_prefix(&prefix, b"payload").expect_err("CIDv0 must be unsupported"); - assert!(matches!(err, BitswapError::UnsupportedCidVersion { version: 0 })); - } -} diff --git a/substrate/client/network/src/bitswap/handle.rs b/substrate/client/network/src/bitswap/handle.rs new file mode 100644 index 000000000000..ee7e83eac6f8 --- /dev/null +++ b/substrate/client/network/src/bitswap/handle.rs @@ -0,0 +1,274 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// This file is part of Substrate. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// Substrate is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Substrate is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Substrate. If not, see . + +//! Public user-facing handle for the Bitswap service. +//! +//! The handle is returned by [`crate::service::traits::BitswapProvider::bitswap_handle`] when +//! the node is configured with `--ipfs-server` and uses the litep2p network backend. +//! +//! Cheap to clone. Submit work via [`BitswapHandle::request_stream`], drain the receiver to +//! get per-CID outcomes as they resolve. + +use super::{is_cid_supported, Cid, MAX_WANTED_BLOCKS}; + +use async_trait::async_trait; +use std::{sync::Arc, time::Duration}; +use tokio::sync::mpsc; + +/// Outcome of a single Bitswap fetch for one CID. +/// +/// Operational causes for `Missing` (timeout, no peers, all DONT_HAVE, verification failure) +/// are collapsed into one variant on purpose — they are not actionable from the caller's +/// perspective. Diagnostic distinction is available via tracing/metrics on the service side. +#[derive(Debug)] +pub enum FetchOutcome { + /// Hash-verified bytes for the requested CID. + Block(Vec), + /// The block was not retrieved before the request deadline expired. Could mean any of: + /// no peers were available, every peer replied DONT_HAVE, every peer timed out, or every + /// candidate block failed CID verification. + Missing, +} + +/// Service-level Bitswap errors. +/// +/// `BitswapError` is returned **synchronously** from [`BitswapHandle::request_stream`] for +/// admission-time failures. It also appears at most once **inside** the returned stream +/// (as the `Err` variant of a stream item) to signal `ServiceClosed` mid-stream. All +/// per-CID failure modes collapse into [`FetchOutcome::Missing`] instead of producing an +/// error. +#[derive(Debug, thiserror::Error)] +pub enum BitswapError { + /// `ipfs_server` is not enabled, or the network backend does not support Bitswap. + #[error("Bitswap is not available on this node")] + Unavailable, + /// The Bitswap service task has shut down. + #[error("Bitswap service is closed")] + ServiceClosed, + /// A CID in the wantlist is unsupported (bad version, bad multihash code, or bad digest + /// size). + #[error("invalid CID for Bitswap: {cid}")] + InvalidCid { + /// The offending CID. + cid: Cid, + }, + /// The service has too many in-flight wants. + #[error("Bitswap service is overloaded")] + Overloaded, + /// Per-call CID count exceeds [`MAX_CIDS_PER_REQUEST`]. + #[error("too many CIDs in request: {requested} > {max}")] + TooManyCids { + /// CIDs requested in the failing call. + requested: usize, + /// Service-level maximum. + max: usize, + }, +} + +/// Maximum number of CIDs accepted in a single [`BitswapHandle::request_stream`] call. +/// +/// Matches the Bitswap v1.2.0 wantlist-entry cap that the rest of the codebase already +/// enforces. +pub const MAX_CIDS_PER_REQUEST: usize = MAX_WANTED_BLOCKS; + +/// Configuration applied at service construction time. +#[derive(Debug, Clone)] +pub struct BitswapServiceConfig { + /// Per-waiter deadline. Each call to [`BitswapHandle::request_stream`] inherits this + /// value; the receiver will yield `Missing` for any CID still unresolved at the + /// deadline and then close. + pub request_timeout: Duration, +} + +impl Default for BitswapServiceConfig { + fn default() -> Self { + Self { request_timeout: Duration::from_secs(30) } + } +} + +/// Item carried on the receiver returned by [`BitswapHandle::request_stream`]. +pub type FetchItem = Result<(Cid, FetchOutcome), BitswapError>; + +/// User-facing handle to the Bitswap service. +/// +/// Cheap to clone. Created at network construction time and stored on `NetworkService`; +/// retrieve via `NetworkService::bitswap_handle()`. +#[derive(Debug, Clone)] +pub struct BitswapHandle { + cmd_tx: mpsc::Sender, +} + +impl BitswapHandle { + /// Construct a new handle around an existing command sender. Used internally by + /// [`crate::bitswap::start`]. + pub(crate) fn new(cmd_tx: mpsc::Sender) -> Self { + Self { cmd_tx } + } + + /// Submit a wantlist. Returns a receiver that yields one item per requested CID, in + /// the order they resolve. + /// + /// Each item is: + /// - `Ok((cid, FetchOutcome::Block(bytes)))` when a peer delivered hash-verified bytes. + /// - `Ok((cid, FetchOutcome::Missing))` when the per-waiter deadline expired without a block. + /// - `Err(BitswapError::ServiceClosed)` once, if the service task shuts down mid-stream. + /// + /// The stream closes when either every CID has produced an outcome, or + /// `ServiceClosed` has been emitted. Callers that need to know whether all CIDs were + /// covered should track the requested set against the items received before the + /// stream closed. + /// + /// Returns a synchronous `BitswapError` for admission-time failures (`Unavailable`, + /// `ServiceClosed`, `InvalidCid`, `Overloaded`, `TooManyCids`). + /// + /// An empty `cids` slice returns an immediately-closed receiver, not an error. + pub async fn request_stream( + &self, + cids: Vec, + ) -> Result, BitswapError> { + if cids.is_empty() { + let (_tx, rx) = mpsc::channel(1); + return Ok(rx); + } + + if cids.len() > MAX_CIDS_PER_REQUEST { + return Err(BitswapError::TooManyCids { + requested: cids.len(), + max: MAX_CIDS_PER_REQUEST, + }); + } + + for cid in &cids { + if !is_cid_supported(cid) { + return Err(BitswapError::InvalidCid { cid: *cid }); + } + } + + // `cids.len() + 1` reserves one slot for a possible terminal `Err(ServiceClosed)`, + // so the actor's `try_send` for outcomes never fails for well-behaved callers. + let (sink, rx) = mpsc::channel(cids.len() + 1); + + self.cmd_tx.try_send(BitswapCommand::RequestStream { cids, sink }).map_err( + |e| match e { + mpsc::error::TrySendError::Full(_) => BitswapError::Overloaded, + mpsc::error::TrySendError::Closed(_) => BitswapError::ServiceClosed, + }, + )?; + + Ok(rx) + } +} + +/// Object-safe surface over [`BitswapHandle::request_stream`]. +/// +/// Hold an `Arc` to abstract over the bitswap client for testing or +/// for late-bound wiring. The trait carries no methods beyond `request_stream`; consumers +/// that need other [`BitswapHandle`] functionality should keep the concrete type. +#[async_trait] +pub trait BitswapRequest: Send + Sync { + /// Submit a wantlist. See [`BitswapHandle::request_stream`] for full semantics. + async fn request_stream( + &self, + cids: Vec, + ) -> Result, BitswapError>; +} + +#[async_trait] +impl BitswapRequest for BitswapHandle { + async fn request_stream( + &self, + cids: Vec, + ) -> Result, BitswapError> { + BitswapHandle::request_stream(self, cids).await + } +} + +#[async_trait] +impl BitswapRequest for Arc +where + T: BitswapRequest + ?Sized, +{ + async fn request_stream( + &self, + cids: Vec, + ) -> Result, BitswapError> { + T::request_stream(self, cids).await + } +} + +/// Internal command sent from a [`BitswapHandle`] to the service actor. +#[derive(Debug)] +pub(crate) enum BitswapCommand { + /// Submit a streaming request. The actor inserts a `Waiter` keyed by these `cids`, with + /// the configured deadline, and writes per-CID outcomes into `sink` as they resolve. + RequestStream { + /// Wantlist (already validated for emptiness, cap, and CID support at admission). + cids: Vec, + /// Sink for `Ok((cid, outcome))` items and the optional final `Err(ServiceClosed)`. + sink: mpsc::Sender, + }, +} + +/// Peer connect/disconnect events published from the litep2p backend's main loop into the +/// Bitswap service actor. +/// +/// `Snapshot` is delivered exactly once, as the first event after the actor subscribes, so +/// the actor learns about already-established connections that pre-date its startup. The +/// actor MUST handle `Snapshot` before treating `connected_peers` as authoritative. +#[derive(Debug, Clone)] +pub enum PeerEvent { + /// Initial snapshot of currently-connected peers. + Snapshot { + /// Already-connected peers at subscription time. + peers: Vec, + }, + /// A new peer connected. + Connected { + /// Peer ID. + peer: litep2p::PeerId, + }, + /// A previously-connected peer disconnected. + Disconnected { + /// Peer ID. + peer: litep2p::PeerId, + }, +} + +/// Wiring produced by [`crate::bitswap::start`] and consumed by the litep2p network +/// backend at construction time. +/// +/// The backend: +/// - feeds [`Self::litep2p_config`] into `Litep2pConfigBuilder::with_libp2p_bitswap`, +/// - stores [`Self::user_handle`] on `Litep2pNetworkService` for the `bitswap_handle()` accessor, +/// - publishes `PeerEvent`s into [`Self::peer_event_tx`] from its main loop. +pub struct BitswapWiring { + /// Litep2p protocol config; consumed by `with_libp2p_bitswap`. + pub litep2p_config: litep2p::protocol::libp2p::bitswap::Config, + /// Public, cloneable user-facing handle. + pub user_handle: BitswapHandle, + /// Sender into which the backend's main loop publishes peer events. The actor holds the + /// receiver internally. + pub peer_event_tx: mpsc::Sender, +} + +impl std::fmt::Debug for BitswapWiring { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BitswapWiring") + .field("user_handle", &self.user_handle) + .finish_non_exhaustive() + } +} diff --git a/substrate/client/network/src/bitswap/mod.rs b/substrate/client/network/src/bitswap/mod.rs index 3d37fc568e45..6b3482dec46f 100644 --- a/substrate/client/network/src/bitswap/mod.rs +++ b/substrate/client/network/src/bitswap/mod.rs @@ -15,64 +15,47 @@ // You should have received a copy of the GNU General Public License // along with Substrate. If not, see . -//! Bitswap server for Substrate. +//! Bitswap support for Substrate. //! -//! Supports querying indexed transactions by hash over the standard bitswap protocol (v1.2.0). -//! CIDs must reference a supported 256-bit transaction hash. - -use crate::{ - request_responses::{IncomingRequest, OutgoingResponse, ProtocolConfig}, - types::ProtocolName, - MAX_RESPONSE_SIZE, -}; +//! Implements both the server side (serving indexed-transaction blocks to peers that send +//! Bitswap v1.2.0 wantlists) and the client side (a long-lived service plus user-facing +//! [`BitswapHandle`] for fetching CIDs from the network). +//! +//! Only available on the litep2p network backend. The user-facing handle is reached via +//! `NetworkService::bitswap_handle()` and is `Some(_)` when the node was started with +//! `--ipfs-server` and a litep2p backend, `None` otherwise. -use cid::{Error as CidError, Version as CidVersion}; -use futures::StreamExt; -use log::{debug, error, trace}; -use prost::Message; -use sc_client_api::BlockBackend; -use sc_network_types::PeerId; -use schema::bitswap::{ - message::{wantlist::WantType, Block as MessageBlock, BlockPresence, BlockPresenceType}, - Message as BitswapMessage, -}; -use sp_core::H256; -use sp_runtime::traits::Block as BlockT; -use std::{io, sync::Arc, time::Duration}; -use unsigned_varint::encode as varint_encode; +use cid::Version as CidVersion; -/// Bitswap client. -mod client; -/// Bitswap protobuf schema, generated from the protocol definitions. -pub mod schema; +mod handle; +mod service; pub use cid::Cid; - -pub use client::{ - request_bitswap_blocks, request_bitswap_blocks_unverified, BitswapError, FetchOutcome, - BLAKE2B_256_MULTIHASH_CODE, KECCAK_256_MULTIHASH_CODE, SHA2_256_MULTIHASH_CODE, +pub(crate) use handle::BitswapCommand; +pub use handle::{ + BitswapError, BitswapHandle, BitswapRequest, BitswapServiceConfig, BitswapWiring, FetchItem, + FetchOutcome, PeerEvent, MAX_CIDS_PER_REQUEST, }; - -pub(crate) use schema::bitswap::Message as BitswapProtoMessage; +pub use service::start; pub(crate) const LOG_TARGET: &str = "sub-libp2p::bitswap"; -// Use the network-wide response cap for Bitswap messages. -const MAX_PACKET_SIZE: u64 = MAX_RESPONSE_SIZE; - -/// Max number of queued responses before denying requests. -const MAX_REQUEST_QUEUE: usize = 20; - -/// Max number of blocks per wantlist. +/// Max number of blocks per wantlist, enforced both on the wire and at admission. pub const MAX_WANTED_BLOCKS: usize = 16; -/// Bitswap protocol name. -pub(crate) const PROTOCOL_NAME: &str = "/ipfs/bitswap/1.2.0"; - /// IPFS raw multicodec used for indexed transaction payload bytes. pub const RAW_CODEC: u64 = 0x55; -/// Check if a CID is supported by the bitswap protocol — CIDv1, 32-byte digest, with a +/// Multihash code for BLAKE2b-256, per the multicodec table. +pub const BLAKE2B_256_MULTIHASH_CODE: u64 = 0xb220; + +/// Multihash code for SHA2-256, per the multicodec table. +pub const SHA2_256_MULTIHASH_CODE: u64 = 0x12; + +/// Multihash code for Keccak-256, per the multicodec table. +pub const KECCAK_256_MULTIHASH_CODE: u64 = 0x1b; + +/// Check if a CID is supported by the Bitswap protocol: CIDv1, 32-byte digest, with a /// supported multihash code (Blake2b-256, SHA2-256, or Keccak-256). pub fn is_cid_supported(cid: &Cid) -> bool { cid.version() != CidVersion::V0 && @@ -80,21 +63,15 @@ pub fn is_cid_supported(cid: &Cid) -> bool { is_supported_multihash_code(cid.hash().code()) } -/// Return `true` if `code` is a supported multihash code. pub(crate) fn is_supported_multihash_code(code: u64) -> bool { matches!(code, BLAKE2B_256_MULTIHASH_CODE | SHA2_256_MULTIHASH_CODE | KECCAK_256_MULTIHASH_CODE) } -/// CID metadata without the actual content bytes. #[derive(PartialEq, Eq, Clone, Debug)] -pub struct Prefix { - /// The version of CID. +pub(crate) struct Prefix { pub version: CidVersion, - /// The codec of CID. pub codec: u64, - /// The multihash type of CID. pub mh_type: u64, - /// The multihash length of CID. pub mh_len: u8, } @@ -109,539 +86,9 @@ impl From<&Cid> for Prefix { } } -impl Prefix { - /// Convert the prefix to encoded bytes. - pub fn to_bytes(&self) -> Vec { - let mut res = Vec::with_capacity(4); - let mut buf = varint_encode::u64_buffer(); - let version = varint_encode::u64(self.version.into(), &mut buf); - res.extend_from_slice(version); - let mut buf = varint_encode::u64_buffer(); - let codec = varint_encode::u64(self.codec, &mut buf); - res.extend_from_slice(codec); - let mut buf = varint_encode::u64_buffer(); - let mh_type = varint_encode::u64(self.mh_type, &mut buf); - res.extend_from_slice(mh_type); - let mut buf = varint_encode::u64_buffer(); - let mh_len = varint_encode::u64(self.mh_len as u64, &mut buf); - res.extend_from_slice(mh_len); - res - } -} - -/// Bitswap request handler. -pub(crate) struct BitswapRequestHandler { - client: Arc + Send + Sync>, - request_receiver: async_channel::Receiver, -} - -impl BitswapRequestHandler { - /// Create a new [`BitswapRequestHandler`]. - pub(crate) fn new(client: Arc + Send + Sync>) -> (Self, ProtocolConfig) { - let (tx, request_receiver) = async_channel::bounded(MAX_REQUEST_QUEUE); - - let config = ProtocolConfig { - name: ProtocolName::from(PROTOCOL_NAME), - fallback_names: vec![], - max_request_size: MAX_PACKET_SIZE, - max_response_size: MAX_PACKET_SIZE, - request_timeout: Duration::from_secs(15), - inbound_queue: Some(tx), - }; - - (Self { client, request_receiver }, config) - } - - /// Run [`BitswapRequestHandler`]. - pub(crate) async fn run(mut self) { - while let Some(request) = self.request_receiver.next().await { - let IncomingRequest { peer, payload, pending_response } = request; - - match self.handle_message(&peer, &payload) { - Ok(response) => { - let response = OutgoingResponse { - result: Ok(response), - reputation_changes: Vec::new(), - sent_feedback: None, - }; - - match pending_response.send(response) { - Ok(()) => { - trace!(target: LOG_TARGET, "Handled bitswap request from {peer}.",) - }, - Err(_) => debug!( - target: LOG_TARGET, - "Failed to handle bitswap request from {peer}: {}", - RequestHandlerError::SendResponse, - ), - } - }, - Err(err) => { - error!(target: LOG_TARGET, "Failed to process request from {peer}: {err}"); - - // TODO: adjust reputation? - - let response = OutgoingResponse { - result: Err(()), - reputation_changes: vec![], - sent_feedback: None, - }; - - if pending_response.send(response).is_err() { - debug!( - target: LOG_TARGET, - "Failed to handle bitswap request from {peer}: {}", - RequestHandlerError::SendResponse, - ); - } - }, - } - } - } - - /// Handle received Bitswap request - fn handle_message( - &mut self, - peer: &PeerId, - payload: &[u8], - ) -> Result, RequestHandlerError> { - let request = schema::bitswap::Message::decode(payload)?; - - trace!(target: LOG_TARGET, "Received request: {:?} from {}", request, peer); - - let mut response = BitswapMessage::default(); - - let wantlist = match request.wantlist { - Some(wantlist) => wantlist, - None => { - debug!(target: LOG_TARGET, "Unexpected bitswap message from {}", peer); - return Err(RequestHandlerError::InvalidWantList); - }, - }; - - if wantlist.entries.len() > MAX_WANTED_BLOCKS { - trace!(target: LOG_TARGET, "Ignored request: too many entries"); - return Err(RequestHandlerError::TooManyEntries); - } - - for entry in wantlist.entries { - let cid = match Cid::read_bytes(entry.block.as_slice()) { - Ok(cid) => cid, - Err(e) => { - trace!(target: LOG_TARGET, "Bad CID {:?}: {:?}", entry.block, e); - continue; - }, - }; - - if !is_cid_supported(&cid) { - trace!(target: LOG_TARGET, "Ignoring unsupported CID {}: {}", peer, cid); - continue; - } - - let mut hash = H256::default(); - hash.as_mut().copy_from_slice(&cid.hash().digest()[0..32]); - let transaction = match self.client.indexed_transaction(hash) { - Ok(ex) => ex, - Err(e) => { - error!(target: LOG_TARGET, "Error retrieving transaction {}: {}", hash, e); - None - }, - }; - - match transaction { - Some(transaction) => { - trace!(target: LOG_TARGET, "Found CID {:?}, hash {:?}", cid, hash); - - if entry.want_type == WantType::Block as i32 { - let prefix: Prefix = (&cid).into(); - response - .payload - .push(MessageBlock { prefix: prefix.to_bytes(), data: transaction }); - } else { - response.block_presences.push(BlockPresence { - r#type: BlockPresenceType::Have as i32, - cid: cid.to_bytes(), - }); - } - }, - None => { - trace!(target: LOG_TARGET, "Missing CID {:?}, hash {:?}", cid, hash); - - if entry.send_dont_have { - response.block_presences.push(BlockPresence { - r#type: BlockPresenceType::DontHave as i32, - cid: cid.to_bytes(), - }); - } - }, - } - } - - Ok(response.encode_to_vec()) - } -} - -/// Bitswap protocol error. -#[derive(Debug, thiserror::Error)] -enum RequestHandlerError { - /// Protobuf decoding error. - #[error("Failed to decode request: {0}.")] - DecodeProto(#[from] prost::DecodeError), - - /// Protobuf encoding error. - #[error("Failed to encode response: {0}.")] - EncodeProto(#[from] prost::EncodeError), - - /// Client backend error. - #[error(transparent)] - Client(#[from] sp_blockchain::Error), - - /// Error parsing CID - #[error(transparent)] - BadCid(#[from] CidError), - - /// Packet read error. - #[error(transparent)] - Read(#[from] io::Error), - - /// Error sending response. - #[error("Failed to send response.")] - SendResponse, - - /// Message doesn't have a WANT list. - #[error("Invalid WANT list.")] - InvalidWantList, - - /// Too many blocks requested. - #[error("Too many block entries in the request.")] - TooManyEntries, -} - #[cfg(test)] mod tests { use super::*; - use futures::channel::oneshot; - use litep2p::types::multihash::Code as LiteP2pCode; - use sc_block_builder::BlockBuilderBuilder; - use schema::bitswap::{ - message::{wantlist::Entry, Wantlist}, - Message as BitswapMessage, - }; - use sp_consensus::BlockOrigin; - use sp_runtime::codec::Encode; - use substrate_test_runtime::ExtrinsicBuilder; - use substrate_test_runtime_client::{self, prelude::*, TestClientBuilder}; - - #[tokio::test] - async fn undecodable_message() { - let client = substrate_test_runtime_client::new(); - let (bitswap, config) = BitswapRequestHandler::new(Arc::new(client)); - - tokio::spawn(async move { bitswap.run().await }); - - let (tx, rx) = oneshot::channel(); - config - .inbound_queue - .unwrap() - .send(IncomingRequest { - peer: PeerId::random(), - payload: vec![0x13, 0x37, 0x13, 0x38], - pending_response: tx, - }) - .await - .unwrap(); - - if let Ok(OutgoingResponse { result, reputation_changes, sent_feedback }) = rx.await { - assert_eq!(result, Err(())); - assert_eq!(reputation_changes, Vec::new()); - assert!(sent_feedback.is_none()); - } else { - panic!("invalid event received"); - } - } - - #[tokio::test] - async fn empty_want_list() { - let client = substrate_test_runtime_client::new(); - let (bitswap, mut config) = BitswapRequestHandler::new(Arc::new(client)); - - tokio::spawn(async move { bitswap.run().await }); - - let (tx, rx) = oneshot::channel(); - config - .inbound_queue - .as_mut() - .unwrap() - .send(IncomingRequest { - peer: PeerId::random(), - payload: BitswapMessage { wantlist: None, ..Default::default() }.encode_to_vec(), - pending_response: tx, - }) - .await - .unwrap(); - - if let Ok(OutgoingResponse { result, reputation_changes, sent_feedback }) = rx.await { - assert_eq!(result, Err(())); - assert_eq!(reputation_changes, Vec::new()); - assert!(sent_feedback.is_none()); - } else { - panic!("invalid event received"); - } - - // Empty WANT list should not cause an error - let (tx, rx) = oneshot::channel(); - config - .inbound_queue - .unwrap() - .send(IncomingRequest { - peer: PeerId::random(), - payload: BitswapMessage { - wantlist: Some(Default::default()), - ..Default::default() - } - .encode_to_vec(), - pending_response: tx, - }) - .await - .unwrap(); - - if let Ok(OutgoingResponse { result, reputation_changes, sent_feedback }) = rx.await { - assert_eq!(result, Ok(BitswapMessage::default().encode_to_vec())); - assert_eq!(reputation_changes, Vec::new()); - assert!(sent_feedback.is_none()); - } else { - panic!("invalid event received"); - } - } - - #[tokio::test] - async fn too_long_want_list() { - let client = substrate_test_runtime_client::new(); - let (bitswap, config) = BitswapRequestHandler::new(Arc::new(client)); - - tokio::spawn(async move { bitswap.run().await }); - - let (tx, rx) = oneshot::channel(); - config - .inbound_queue - .unwrap() - .send(IncomingRequest { - peer: PeerId::random(), - payload: BitswapMessage { - wantlist: Some(Wantlist { - entries: (0..MAX_WANTED_BLOCKS + 1) - .map(|_| Entry::default()) - .collect::>(), - full: false, - }), - ..Default::default() - } - .encode_to_vec(), - pending_response: tx, - }) - .await - .unwrap(); - - if let Ok(OutgoingResponse { result, reputation_changes, sent_feedback }) = rx.await { - assert_eq!(result, Err(())); - assert_eq!(reputation_changes, Vec::new()); - assert!(sent_feedback.is_none()); - } else { - panic!("invalid event received"); - } - } - - #[tokio::test] - async fn transaction_not_found() { - let client = TestClientBuilder::with_tx_storage(u32::MAX).build(); - - let (bitswap, config) = BitswapRequestHandler::new(Arc::new(client)); - tokio::spawn(async move { bitswap.run().await }); - - let (tx, rx) = oneshot::channel(); - config - .inbound_queue - .unwrap() - .send(IncomingRequest { - peer: PeerId::random(), - payload: BitswapMessage { - wantlist: Some(Wantlist { - entries: vec![Entry { - block: cid::Cid::new_v1( - 0x70, - cid::multihash::Multihash::wrap( - u64::from(LiteP2pCode::Blake2b256), - &[0u8; 32], - ) - .unwrap(), - ) - .to_bytes(), - ..Default::default() - }], - full: false, - }), - ..Default::default() - } - .encode_to_vec(), - pending_response: tx, - }) - .await - .unwrap(); - - if let Ok(OutgoingResponse { result, reputation_changes, sent_feedback }) = rx.await { - assert_eq!(result, Ok(vec![])); - assert_eq!(reputation_changes, Vec::new()); - assert!(sent_feedback.is_none()); - } else { - panic!("invalid event received"); - } - } - - #[tokio::test] - async fn transaction_found() { - let client = TestClientBuilder::with_tx_storage(u32::MAX).build(); - let mut block_builder = BlockBuilderBuilder::new(&client) - .on_parent_block(client.chain_info().genesis_hash) - .with_parent_block_number(0) - .build() - .unwrap(); - - // encoded extrinsic: [161, .. , 2, 6, 16, 19, 55, 19, 56] - let ext = ExtrinsicBuilder::new_indexed_call(vec![0x13, 0x37, 0x13, 0x38]).build(); - let pattern_index = ext.encoded_size() - 4; - - block_builder.push(ext.clone()).unwrap(); - let block = block_builder.build().unwrap().block; - - client.import(BlockOrigin::File, block).await.unwrap(); - - let (bitswap, config) = BitswapRequestHandler::new(Arc::new(client)); - - tokio::spawn(async move { bitswap.run().await }); - - let (tx, rx) = oneshot::channel(); - config - .inbound_queue - .unwrap() - .send(IncomingRequest { - peer: PeerId::random(), - payload: BitswapMessage { - wantlist: Some(Wantlist { - entries: vec![Entry { - block: cid::Cid::new_v1( - 0x70, - cid::multihash::Multihash::wrap( - u64::from(LiteP2pCode::Blake2b256), - &sp_crypto_hashing::blake2_256(&ext.encode()[pattern_index..]), - ) - .unwrap(), - ) - .to_bytes(), - ..Default::default() - }], - full: false, - }), - ..Default::default() - } - .encode_to_vec(), - pending_response: tx, - }) - .await - .unwrap(); - - if let Ok(OutgoingResponse { result, reputation_changes, sent_feedback }) = rx.await { - assert_eq!(reputation_changes, Vec::new()); - assert!(sent_feedback.is_none()); - - let response = - schema::bitswap::Message::decode(&result.expect("fetch to succeed")[..]).unwrap(); - assert_eq!(response.payload[0].data, vec![0x13, 0x37, 0x13, 0x38]); - } else { - panic!("invalid event received"); - } - } - - #[tokio::test] - async fn transaction_not_found_sends_dont_have_when_requested() { - let client = TestClientBuilder::with_tx_storage(u32::MAX).build(); - let (mut bitswap, _config) = BitswapRequestHandler::new(Arc::new(client)); - let cid = cid::Cid::new_v1( - 0x70, - cid::multihash::Multihash::wrap(u64::from(LiteP2pCode::Blake2b256), &[0u8; 32]) - .unwrap(), - ); - let request = BitswapMessage { - wantlist: Some(Wantlist { - entries: vec![Entry { - block: cid.to_bytes(), - send_dont_have: true, - ..Default::default() - }], - full: false, - }), - ..Default::default() - } - .encode_to_vec(); - - let response = BitswapMessage::decode( - bitswap.handle_message(&PeerId::random(), &request).unwrap().as_slice(), - ) - .unwrap(); - - assert!(response.payload.is_empty()); - assert_eq!(response.block_presences.len(), 1); - assert_eq!(response.block_presences[0].cid, cid.to_bytes()); - assert_eq!(response.block_presences[0].r#type, BlockPresenceType::DontHave as i32); - } - - #[tokio::test] - async fn transaction_found_sends_have_for_want_have() { - let client = TestClientBuilder::with_tx_storage(u32::MAX).build(); - let mut block_builder = BlockBuilderBuilder::new(&client) - .on_parent_block(client.chain_info().genesis_hash) - .with_parent_block_number(0) - .build() - .unwrap(); - - let ext = ExtrinsicBuilder::new_indexed_call(vec![0x13, 0x37, 0x13, 0x38]).build(); - let pattern_index = ext.encoded_size() - 4; - let cid = cid::Cid::new_v1( - 0x70, - cid::multihash::Multihash::wrap( - u64::from(LiteP2pCode::Blake2b256), - &sp_crypto_hashing::blake2_256(&ext.encode()[pattern_index..]), - ) - .unwrap(), - ); - - block_builder.push(ext).unwrap(); - let block = block_builder.build().unwrap().block; - client.import(BlockOrigin::File, block).await.unwrap(); - - let (mut bitswap, _config) = BitswapRequestHandler::new(Arc::new(client)); - let request = BitswapMessage { - wantlist: Some(Wantlist { - entries: vec![Entry { - block: cid.to_bytes(), - want_type: WantType::Have as i32, - ..Default::default() - }], - full: false, - }), - ..Default::default() - } - .encode_to_vec(); - - let response = BitswapMessage::decode( - bitswap.handle_message(&PeerId::random(), &request).unwrap().as_slice(), - ) - .unwrap(); - - assert!(response.payload.is_empty()); - assert_eq!(response.block_presences.len(), 1); - assert_eq!(response.block_presences[0].cid, cid.to_bytes()); - assert_eq!(response.block_presences[0].r#type, BlockPresenceType::Have as i32); - } #[test] fn is_cid_supported_accepts_all_three_supported_hashings() { diff --git a/substrate/client/network/src/bitswap/schema.rs b/substrate/client/network/src/bitswap/schema.rs deleted file mode 100644 index 07a770540f1a..000000000000 --- a/substrate/client/network/src/bitswap/schema.rs +++ /dev/null @@ -1,24 +0,0 @@ -// This file is part of Substrate. - -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. - -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -//! Include sources generated from protobuf definitions. - -#[allow(missing_docs)] -pub mod bitswap { - include!(concat!(env!("OUT_DIR"), "/bitswap.message.rs")); -} diff --git a/substrate/client/network/src/bitswap/service.rs b/substrate/client/network/src/bitswap/service.rs new file mode 100644 index 000000000000..cfebcda53773 --- /dev/null +++ b/substrate/client/network/src/bitswap/service.rs @@ -0,0 +1,1071 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// This file is part of Substrate. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// Substrate is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Substrate is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Substrate. If not, see . + +//! Bitswap service actor. +//! +//! Owns the litep2p [`litep2p::protocol::libp2p::bitswap::BitswapHandle`] and drives both +//! the inbound (serve indexed-transaction blocks to peers) and outbound (fetch CIDs from +//! peers on behalf of [`BitswapHandle`] callers) Bitswap flows. +//! +//! The actor runs a single [`tokio::select!`] loop with six arms; see [`BitswapService::run`]. + +use super::{ + is_cid_supported, BitswapCommand, BitswapHandle, BitswapServiceConfig, BitswapWiring, Cid, + FetchItem, FetchOutcome, PeerEvent, Prefix, BLAKE2B_256_MULTIHASH_CODE, + KECCAK_256_MULTIHASH_CODE, LOG_TARGET, MAX_WANTED_BLOCKS, SHA2_256_MULTIHASH_CODE, +}; +use crate::bitswap::handle::BitswapError; + +use async_trait::async_trait; +use cid::multihash::Multihash as CidMultihash; +use futures::StreamExt; +use litep2p::protocol::libp2p::bitswap::{ + BitswapEvent, BitswapHandle as LitepBitswapHandle, BlockPresenceType, Config as LitepConfig, + ResponseType, WantType, +}; +use sc_client_api::BlockBackend; +use slotmap::{new_key_type, SlotMap}; +use smallvec::SmallVec; +use sp_core::H256; +use sp_runtime::traits::Block as BlockT; +use std::{ + collections::{HashMap, HashSet}, + future::Future, + pin::Pin, + sync::Arc, + time::Duration, +}; +use tokio::{ + sync::{mpsc, Semaphore}, + time::Instant, +}; +use tokio_util::time::{delay_queue, DelayQueue}; + +#[async_trait] +pub(crate) trait BitswapTransport: Send { + async fn next_event(&mut self) -> Option; + async fn send_request(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>); + async fn send_response(&self, peer: litep2p::PeerId, responses: Vec); +} + +#[async_trait] +impl BitswapTransport for LitepBitswapHandle { + async fn next_event(&mut self) -> Option { + StreamExt::next(self).await + } + + async fn send_request(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { + LitepBitswapHandle::send_request(self, peer, cids).await + } + + async fn send_response(&self, peer: litep2p::PeerId, responses: Vec) { + LitepBitswapHandle::send_response(self, peer, responses).await + } +} + +const MAX_OUTSTANDING_CIDS: usize = 1024; +const MAX_WAITERS_PER_CID: usize = 64; +const MAX_CONCURRENT_INBOUND_LOOKUPS: usize = 8; +const CMD_CHANNEL_CAPACITY: usize = 256; +const PEER_EVENT_CHANNEL_CAPACITY: usize = 64; +const LOOKUP_CHANNEL_CAPACITY: usize = 64; +const PER_PEER_TIMEOUT: Duration = Duration::from_secs(5); +const PEER_FANOUT_CAP: usize = 1; +const PEER_TIMEOUT_SWEEP_INTERVAL: Duration = Duration::from_secs(1); + +new_key_type! { struct WaiterId; } + +// Per-CID network scheduling state, deduplicated across overlapping waiters. +struct CidState { + tried_peers: HashSet, + in_flight_peers: HashMap, + waiters: SmallVec<[WaiterId; 2]>, +} + +impl CidState { + fn new() -> Self { + Self { + tried_peers: HashSet::new(), + in_flight_peers: HashMap::new(), + waiters: SmallVec::new(), + } + } + + fn has_waiters(&self) -> bool { + !self.waiters.is_empty() + } + + fn is_idle(&self) -> bool { + self.waiters.is_empty() && self.in_flight_peers.is_empty() + } +} + +struct Waiter { + cids_remaining: HashSet, + sink: mpsc::Sender, + delay_key: delay_queue::Key, +} + +pub(crate) struct BitswapService { + handle: Box, + client: Arc + Send + Sync>, + config: BitswapServiceConfig, + + cmd_rx: mpsc::Receiver, + peer_event_rx: mpsc::Receiver, + lookup_tx: mpsc::Sender<(litep2p::PeerId, Vec)>, + lookup_rx: mpsc::Receiver<(litep2p::PeerId, Vec)>, + lookup_semaphore: Arc, + + waiter_deadlines: DelayQueue, + connected_peers: HashSet, + wants: HashMap, + waiters: SlotMap, +} + +/// Build, wire and return the Bitswap service. +/// +/// The returned future MUST be spawned on the runtime; the [`BitswapWiring`] MUST be passed +/// to the litep2p network backend at construction (it carries the litep2p protocol config +/// and the user-facing handle). +pub fn start( + client: Arc + Send + Sync>, + config: BitswapServiceConfig, +) -> (Pin + Send>>, BitswapWiring) { + let (litep2p_config, litep2p_handle) = LitepConfig::new(); + let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); + let (peer_event_tx, peer_event_rx) = mpsc::channel(PEER_EVENT_CHANNEL_CAPACITY); + let (lookup_tx, lookup_rx) = mpsc::channel(LOOKUP_CHANNEL_CAPACITY); + + let user_handle = BitswapHandle::new(cmd_tx); + + let service = BitswapService { + handle: Box::new(litep2p_handle), + client, + config, + cmd_rx, + peer_event_rx, + lookup_tx, + lookup_rx, + lookup_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_INBOUND_LOOKUPS)), + waiter_deadlines: DelayQueue::new(), + connected_peers: HashSet::new(), + wants: HashMap::new(), + waiters: SlotMap::with_key(), + }; + + let future = Box::pin(async move { service.run().await }); + let wiring = BitswapWiring { litep2p_config, user_handle: user_handle.clone(), peer_event_tx }; + + (future, wiring) +} + +impl BitswapService { + async fn run(mut self) { + log::debug!(target: LOG_TARGET, "BitswapService starting"); + let mut peer_timeout_ticker = tokio::time::interval(PEER_TIMEOUT_SWEEP_INTERVAL); + peer_timeout_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + peer_timeout_ticker.tick().await; + + loop { + tokio::select! { + event = self.handle.next_event() => match event { + Some(BitswapEvent::Request { peer, cids }) => + self.on_inbound_request(peer, cids), + Some(BitswapEvent::Response { peer, responses }) => + self.on_inbound_response(peer, responses).await, + None => { + log::debug!(target: LOG_TARGET, "litep2p bitswap stream ended; shutting down"); + self.shutdown_waiters(); + return; + }, + }, + + cmd = self.cmd_rx.recv() => match cmd { + Some(BitswapCommand::RequestStream { cids, sink }) => + self.on_request_stream(cids, sink).await, + None => { + log::debug!(target: LOG_TARGET, "command channel closed; shutting down"); + self.shutdown_waiters(); + return; + }, + }, + + peer_ev = self.peer_event_rx.recv() => match peer_ev { + Some(PeerEvent::Snapshot { peers }) => self.on_peer_snapshot(peers).await, + Some(PeerEvent::Connected { peer }) => self.on_peer_connected(peer).await, + Some(PeerEvent::Disconnected { peer }) => self.on_peer_disconnected(peer).await, + None => { + log::debug!(target: LOG_TARGET, "peer event channel closed; shutting down"); + self.shutdown_waiters(); + return; + }, + }, + + maybe_expired = self.waiter_deadlines.next(), if !self.waiter_deadlines.is_empty() => { + if let Some(expired) = maybe_expired { + self.on_waiter_expired(expired.into_inner()); + } + }, + + Some((peer, responses)) = self.lookup_rx.recv() => { + self.handle.send_response(peer, responses).await; + }, + + _ = peer_timeout_ticker.tick() => { + self.sweep_per_peer_timeouts().await; + }, + } + } + } + + async fn on_request_stream(&mut self, cids: Vec, sink: mpsc::Sender) { + // New CIDs are CIDs not already in `wants`. We re-check the overall outstanding-CIDs + // budget against the *new* additions, otherwise overlapping waiters would be charged + // twice for the same CID. + let new_cid_count = cids.iter().filter(|cid| !self.wants.contains_key(cid)).count(); + if self.wants.len() + new_cid_count > MAX_OUTSTANDING_CIDS { + let _ = sink.try_send(Err(BitswapError::Overloaded)); + return; + } + + for cid in &cids { + let waiters_for_cid = self.wants.get(cid).map_or(0, |cs| cs.waiters.len()); + if waiters_for_cid >= MAX_WAITERS_PER_CID { + let _ = sink.try_send(Err(BitswapError::Overloaded)); + return; + } + } + + let deadline = Instant::now() + self.config.request_timeout; + let cids_remaining: HashSet = cids.iter().copied().collect(); + + let waiter_id = self.waiters.insert_with_key(|id| Waiter { + cids_remaining, + sink, + delay_key: self.waiter_deadlines.insert_at(id, deadline), + }); + + for cid in &cids { + let cid_state = self.wants.entry(*cid).or_insert_with(CidState::new); + cid_state.waiters.push(waiter_id); + } + + for cid in cids { + self.top_up_in_flight(cid).await; + } + } + + async fn top_up_in_flight(&mut self, cid: Cid) { + let Some(cid_state) = self.wants.get_mut(&cid) else { return }; + if !cid_state.has_waiters() { + return; + } + if cid_state.in_flight_peers.len() >= PEER_FANOUT_CAP { + return; + } + + let Some(peer) = self + .connected_peers + .iter() + .find(|p| { + !cid_state.tried_peers.contains(p) && !cid_state.in_flight_peers.contains_key(p) + }) + .copied() + else { + return; + }; + + cid_state.in_flight_peers.insert(peer, Instant::now() + PER_PEER_TIMEOUT); + + log::trace!(target: LOG_TARGET, "WANT-BLOCK {cid} -> {peer:?}"); + self.handle.send_request(peer, vec![(cid, WantType::Block)]).await; + } + + async fn on_inbound_response(&mut self, peer: litep2p::PeerId, responses: Vec) { + let mut cids_to_top_up: HashSet = HashSet::new(); + + for response in responses { + match response { + ResponseType::Block { cid: claimed_cid, block } => { + self.mark_peer_done_for_cid(peer, claimed_cid); + + let recomputed = match recompute_cid(&claimed_cid, &block) { + Ok(c) => c, + Err(e) => { + log::debug!( + target: LOG_TARGET, + "{peer:?} sent block for {claimed_cid} that failed prefix decode: {e}", + ); + cids_to_top_up.insert(claimed_cid); + continue; + }, + }; + + if !self.wants.contains_key(&recomputed) { + log::debug!( + target: LOG_TARGET, + "{peer:?} sent unsolicited or unwanted block for {recomputed}", + ); + continue; + } + + self.deliver_block(recomputed, block); + }, + ResponseType::Presence { cid, presence } => { + self.mark_peer_done_for_cid(peer, cid); + match presence { + BlockPresenceType::DontHave => { + log::trace!(target: LOG_TARGET, "{peer:?} DONT_HAVE {cid}"); + cids_to_top_up.insert(cid); + }, + BlockPresenceType::Have => { + log::trace!(target: LOG_TARGET, "{peer:?} HAVE {cid}"); + cids_to_top_up.insert(cid); + }, + } + }, + } + } + + for cid in cids_to_top_up { + if self.wants.contains_key(&cid) { + self.top_up_in_flight(cid).await; + } + } + } + + fn mark_peer_done_for_cid(&mut self, peer: litep2p::PeerId, cid: Cid) { + if let Some(cid_state) = self.wants.get_mut(&cid) { + cid_state.in_flight_peers.remove(&peer); + cid_state.tried_peers.insert(peer); + } + } + + fn deliver_block(&mut self, cid: Cid, bytes: Vec) { + let Some(cid_state) = self.wants.remove(&cid) else { return }; + let bytes = Arc::new(bytes); + + for waiter_id in cid_state.waiters { + let Some(waiter) = self.waiters.get_mut(waiter_id) else { continue }; + if !waiter.cids_remaining.remove(&cid) { + continue; + } + let payload = Vec::clone(&bytes); + if waiter.sink.try_send(Ok((cid, FetchOutcome::Block(payload)))).is_err() { + log::trace!(target: LOG_TARGET, "waiter sink full/closed for {cid}"); + self.drop_waiter(waiter_id); + continue; + } + if waiter.cids_remaining.is_empty() { + self.drop_waiter(waiter_id); + } + } + } + + fn drop_waiter(&mut self, id: WaiterId) { + let Some(waiter) = self.waiters.remove(id) else { return }; + self.waiter_deadlines.remove(&waiter.delay_key); + + for cid in &waiter.cids_remaining { + if let Some(cid_state) = self.wants.get_mut(cid) { + cid_state.waiters.retain(|w| *w != id); + if cid_state.is_idle() { + self.wants.remove(cid); + } + } + } + } + + fn on_waiter_expired(&mut self, id: WaiterId) { + let Some(mut waiter) = self.waiters.remove(id) else { return }; + let remaining: Vec = waiter.cids_remaining.drain().collect(); + for cid in &remaining { + let _ = waiter.sink.try_send(Ok((*cid, FetchOutcome::Missing))); + if let Some(cid_state) = self.wants.get_mut(cid) { + cid_state.waiters.retain(|w| *w != id); + if cid_state.is_idle() { + self.wants.remove(cid); + } + } + } + log::trace!( + target: LOG_TARGET, + "waiter {id:?} expired; emitted Missing for {} CIDs", + remaining.len(), + ); + } + + async fn on_peer_snapshot(&mut self, peers: Vec) { + self.connected_peers = peers.into_iter().collect(); + log::debug!( + target: LOG_TARGET, + "snapshot: {} connected peers at startup", + self.connected_peers.len(), + ); + let cids: Vec = self.wants.keys().copied().collect(); + for cid in cids { + self.top_up_in_flight(cid).await; + } + } + + async fn on_peer_connected(&mut self, peer: litep2p::PeerId) { + self.connected_peers.insert(peer); + let cids: Vec = self.wants.keys().copied().collect(); + for cid in cids { + self.top_up_in_flight(cid).await; + } + } + + async fn on_peer_disconnected(&mut self, peer: litep2p::PeerId) { + self.connected_peers.remove(&peer); + let cids_to_top_up: Vec = self + .wants + .iter_mut() + .filter_map(|(cid, cs)| cs.in_flight_peers.remove(&peer).map(|_| *cid)) + .collect(); + for cid in cids_to_top_up { + self.top_up_in_flight(cid).await; + } + } + + async fn sweep_per_peer_timeouts(&mut self) { + let now = Instant::now(); + let mut timed_out: Vec<(Cid, litep2p::PeerId)> = Vec::new(); + for (cid, cid_state) in self.wants.iter_mut() { + cid_state.in_flight_peers.retain(|peer, deadline| { + if *deadline <= now { + timed_out.push((*cid, *peer)); + false + } else { + true + } + }); + } + for (cid, peer) in &timed_out { + if let Some(cid_state) = self.wants.get_mut(cid) { + cid_state.tried_peers.insert(*peer); + } + } + for (cid, _) in timed_out { + if self.wants.contains_key(&cid) { + self.top_up_in_flight(cid).await; + } + } + } + + fn on_inbound_request(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { + if cids.len() > MAX_WANTED_BLOCKS { + log::trace!( + target: LOG_TARGET, + "ignored inbound wantlist from {peer:?} with {} entries (cap {MAX_WANTED_BLOCKS})", + cids.len(), + ); + return; + } + let Ok(permit) = self.lookup_semaphore.clone().try_acquire_owned() else { + log::trace!( + target: LOG_TARGET, + "inbound serving pool saturated; dropping wantlist from {peer:?}", + ); + return; + }; + let client = self.client.clone(); + let lookup_tx = self.lookup_tx.clone(); + tokio::task::spawn_blocking(move || { + let _permit = permit; + let responses = serve_inbound(&*client, cids); + let _ = lookup_tx.try_send((peer, responses)); + }); + } + + fn shutdown_waiters(&mut self) { + let ids: Vec = self.waiters.keys().collect(); + for id in ids { + if let Some(waiter) = self.waiters.remove(id) { + let _ = waiter.sink.try_send(Err(BitswapError::ServiceClosed)); + self.waiter_deadlines.remove(&waiter.delay_key); + } + } + self.wants.clear(); + } +} + +fn recompute_cid(reference_cid: &Cid, data: &[u8]) -> Result { + let prefix: Prefix = reference_cid.into(); + let digest = hash_for_multihash_code(prefix.mh_type, data) + .ok_or_else(|| format!("unsupported multihash code {}", prefix.mh_type))?; + let mh = CidMultihash::<64>::wrap(prefix.mh_type, &digest) + .map_err(|e| format!("multihash wrap failed: {e}"))?; + Ok(Cid::new_v1(prefix.codec, mh)) +} + +fn hash_for_multihash_code(multihash_code: u64, data: &[u8]) -> Option<[u8; 32]> { + match multihash_code { + BLAKE2B_256_MULTIHASH_CODE => Some(sp_crypto_hashing::blake2_256(data)), + SHA2_256_MULTIHASH_CODE => Some(sp_crypto_hashing::sha2_256(data)), + KECCAK_256_MULTIHASH_CODE => Some(sp_crypto_hashing::keccak_256(data)), + _ => None, + } +} + +fn serve_inbound( + client: &(dyn BlockBackend + Send + Sync), + cids: Vec<(Cid, WantType)>, +) -> Vec { + cids.into_iter() + .filter(|(cid, _)| is_cid_supported(cid)) + .map(|(cid, want_type)| { + let hash = H256::from_slice(&cid.hash().digest()[0..32]); + let transaction = match client.indexed_transaction(hash) { + Ok(t) => t, + Err(e) => { + log::error!(target: LOG_TARGET, "indexed_transaction({hash}) failed: {e}"); + None + }, + }; + match (transaction, want_type) { + (Some(transaction), WantType::Block) => { + ResponseType::Block { cid, block: transaction } + }, + (Some(_), _) => ResponseType::Presence { cid, presence: BlockPresenceType::Have }, + (None, _) => ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitswap::RAW_CODEC; + use sc_block_builder::BlockBuilderBuilder; + use sp_consensus::BlockOrigin; + use sp_runtime::codec::Encode; + use substrate_test_runtime::ExtrinsicBuilder; + use substrate_test_runtime_client::{prelude::*, TestClientBuilder}; + use tokio::{ + sync::Mutex as AsyncMutex, + time::{sleep, timeout}, + }; + + struct MockTransport { + inbound: AsyncMutex>, + outbound_req_tx: mpsc::Sender<(litep2p::PeerId, Vec<(Cid, WantType)>)>, + outbound_resp_tx: mpsc::Sender<(litep2p::PeerId, Vec)>, + } + + #[async_trait] + impl BitswapTransport for MockTransport { + async fn next_event(&mut self) -> Option { + self.inbound.get_mut().recv().await + } + + async fn send_request(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { + let _ = self.outbound_req_tx.send((peer, cids)).await; + } + + async fn send_response(&self, peer: litep2p::PeerId, responses: Vec) { + let _ = self.outbound_resp_tx.send((peer, responses)).await; + } + } + + struct TestRig { + user_handle: BitswapHandle, + peer_event_tx: mpsc::Sender, + inbound_tx: mpsc::Sender, + outbound_req_rx: mpsc::Receiver<(litep2p::PeerId, Vec<(Cid, WantType)>)>, + outbound_resp_rx: mpsc::Receiver<(litep2p::PeerId, Vec)>, + _handle: tokio::task::JoinHandle<()>, + } + + fn build_rig_with( + client: Arc + Send + Sync>, + config: BitswapServiceConfig, + ) -> TestRig { + let (inbound_tx, inbound_rx) = mpsc::channel(64); + let (outbound_req_tx, outbound_req_rx) = mpsc::channel(64); + let (outbound_resp_tx, outbound_resp_rx) = mpsc::channel(64); + let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); + let (peer_event_tx, peer_event_rx) = mpsc::channel(PEER_EVENT_CHANNEL_CAPACITY); + let (lookup_tx, lookup_rx) = mpsc::channel(LOOKUP_CHANNEL_CAPACITY); + + let transport = MockTransport { + inbound: AsyncMutex::new(inbound_rx), + outbound_req_tx, + outbound_resp_tx, + }; + + let service: BitswapService = BitswapService { + handle: Box::new(transport), + client, + config, + cmd_rx, + peer_event_rx, + lookup_tx, + lookup_rx, + lookup_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_INBOUND_LOOKUPS)), + waiter_deadlines: DelayQueue::new(), + connected_peers: HashSet::new(), + wants: HashMap::new(), + waiters: SlotMap::with_key(), + }; + + let user_handle = BitswapHandle::new(cmd_tx); + let _handle = tokio::spawn(async move { service.run().await }); + + TestRig { + user_handle, + peer_event_tx, + inbound_tx, + outbound_req_rx, + outbound_resp_rx, + _handle, + } + } + + fn empty_rig() -> TestRig { + let client = Arc::new(substrate_test_runtime_client::new()); + build_rig_with(client, BitswapServiceConfig { request_timeout: Duration::from_secs(30) }) + } + + fn short_deadline_rig(deadline: Duration) -> TestRig { + let client = Arc::new(substrate_test_runtime_client::new()); + build_rig_with(client, BitswapServiceConfig { request_timeout: deadline }) + } + + fn cid_for_data(mh_code: u64, data: &[u8]) -> Cid { + let digest = hash_for_multihash_code(mh_code, data).expect("supported"); + let mh = CidMultihash::<64>::wrap(mh_code, &digest).unwrap(); + Cid::new_v1(RAW_CODEC, mh) + } + + fn cid_for_digest(mh_code: u64, digest: [u8; 32]) -> Cid { + let mh = CidMultihash::<64>::wrap(mh_code, &digest).unwrap(); + Cid::new_v1(RAW_CODEC, mh) + } + + async fn drain_next(rx: &mut mpsc::Receiver) -> Option { + timeout(Duration::from_secs(2), rx.recv()).await.ok().flatten() + } + + #[tokio::test(start_paused = true)] + async fn single_cid_single_peer_block_response() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let data = b"payload-a".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + + let (out_peer, out_cids) = + drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); + assert_eq!(out_peer, peer); + assert_eq!(out_cids, vec![(cid, WantType::Block)]); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + let item = drain_next(&mut rx).await.expect("item"); + match item { + Ok((got_cid, FetchOutcome::Block(bytes))) => { + assert_eq!(got_cid, cid); + assert_eq!(bytes, data); + }, + other => panic!("expected Block, got {other:?}"), + } + } + + #[tokio::test(start_paused = true)] + async fn dont_have_then_missing_at_deadline() { + let mut rig = short_deadline_rig(Duration::from_millis(50)); + let peer = litep2p::PeerId::random(); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [7u8; 32]); + + rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Presence { + cid, + presence: BlockPresenceType::DontHave, + }], + }) + .await + .unwrap(); + + tokio::time::advance(Duration::from_millis(60)).await; + + let item = drain_next(&mut rx).await.expect("item"); + assert!(matches!(item, Ok((_, FetchOutcome::Missing)))); + } + + #[tokio::test(start_paused = true)] + async fn per_peer_timeout_followed_by_deadline_missing() { + let mut rig = short_deadline_rig(Duration::from_millis(100)); + let peer = litep2p::PeerId::random(); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [42u8; 32]); + + rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); + + tokio::time::advance(Duration::from_millis(120)).await; + + let item = drain_next(&mut rx).await.expect("item"); + assert!(matches!(item, Ok((_, FetchOutcome::Missing)))); + } + + #[tokio::test(start_paused = true)] + async fn two_peers_first_dont_have_second_block() { + let mut rig = empty_rig(); + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let data = b"after-failover".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.peer_event_tx.send(PeerEvent::Connected { peer: peer_a }).await.unwrap(); + rig.peer_event_tx.send(PeerEvent::Connected { peer: peer_b }).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + + let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer: first_peer, + responses: vec![ResponseType::Presence { + cid, + presence: BlockPresenceType::DontHave, + }], + }) + .await + .unwrap(); + + let (second_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("second WANT"); + assert_ne!(first_peer, second_peer); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer: second_peer, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + let item = drain_next(&mut rx).await.expect("item"); + match item { + Ok((got, FetchOutcome::Block(bytes))) => { + assert_eq!(got, cid); + assert_eq!(bytes, data); + }, + other => panic!("expected Block, got {other:?}"), + } + } + + #[tokio::test(start_paused = true)] + async fn zero_peers_at_admission_missing_at_deadline() { + let mut rig = short_deadline_rig(Duration::from_millis(50)); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [1u8; 32]); + + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + + assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); + + tokio::time::advance(Duration::from_millis(60)).await; + + let item = drain_next(&mut rx).await.expect("item"); + assert!(matches!(item, Ok((_, FetchOutcome::Missing)))); + } + + #[tokio::test(start_paused = true)] + async fn two_waiters_overlapping_cid_both_get_block() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let data = b"shared".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + + let mut rx_a = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rx_b = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + let item_a = drain_next(&mut rx_a).await.expect("a"); + let item_b = drain_next(&mut rx_b).await.expect("b"); + assert!(matches!(&item_a, Ok((c, FetchOutcome::Block(b))) if *c == cid && *b == data)); + assert!(matches!(&item_b, Ok((c, FetchOutcome::Block(b))) if *c == cid && *b == data)); + } + + #[tokio::test(start_paused = true)] + async fn waiter_drop_does_not_break_other_waiter() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let data = b"survivor".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + + let rx_a = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rx_b = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + + drop(rx_a); + sleep(Duration::from_millis(1)).await; + + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + let item_b = drain_next(&mut rx_b).await.expect("b"); + assert!(matches!(item_b, Ok((c, FetchOutcome::Block(b))) if c == cid && b == data)); + } + + #[tokio::test(start_paused = true)] + async fn waiter_deadline_emits_missing_for_unresolved() { + let mut rig = short_deadline_rig(Duration::from_millis(40)); + let peer = litep2p::PeerId::random(); + let cid_a = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [1u8; 32]); + let cid_b = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [2u8; 32]); + + rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid_a, cid_b]).await.unwrap(); + + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound a or b"); + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound the other"); + + tokio::time::advance(Duration::from_millis(60)).await; + + let mut seen = HashSet::new(); + for _ in 0..2 { + let item = drain_next(&mut rx).await.expect("missing item"); + match item { + Ok((c, FetchOutcome::Missing)) => { + seen.insert(c); + }, + other => panic!("expected Missing, got {other:?}"), + } + } + assert!(seen.contains(&cid_a)); + assert!(seen.contains(&cid_b)); + } + + #[tokio::test(start_paused = true)] + async fn inbound_request_with_known_block_serves_it() { + let client = TestClientBuilder::with_tx_storage(u32::MAX).build(); + let mut block_builder = BlockBuilderBuilder::new(&client) + .on_parent_block(client.chain_info().genesis_hash) + .with_parent_block_number(0) + .build() + .unwrap(); + + let ext = ExtrinsicBuilder::new_indexed_call(vec![0x42, 0x42, 0x42, 0x42]).build(); + let pattern_index = ext.encoded_size() - 4; + let data_hash = sp_crypto_hashing::blake2_256(&ext.encode()[pattern_index..]); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, data_hash); + + block_builder.push(ext.clone()).unwrap(); + let block = block_builder.build().unwrap().block; + client.import(BlockOrigin::File, block).await.unwrap(); + + let mut rig = build_rig_with( + Arc::new(client), + BitswapServiceConfig { request_timeout: Duration::from_secs(30) }, + ); + + let peer = litep2p::PeerId::random(); + rig.inbound_tx + .send(BitswapEvent::Request { peer, cids: vec![(cid, WantType::Block)] }) + .await + .unwrap(); + + let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("response"); + assert_eq!(resp_peer, peer); + assert_eq!(responses.len(), 1); + match &responses[0] { + ResponseType::Block { cid: got_cid, block } => { + assert_eq!(*got_cid, cid); + assert_eq!(*block, vec![0x42, 0x42, 0x42, 0x42]); + }, + other => panic!("expected Block, got {other:?}"), + } + } + + #[tokio::test(start_paused = true)] + async fn corrupted_block_rejected_then_missing_at_deadline() { + let mut rig = short_deadline_rig(Duration::from_millis(50)); + let peer = litep2p::PeerId::random(); + let real = b"the-real-payload".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &real); + + rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Block { + cid, + block: b"NOT-the-real-payload".to_vec(), + }], + }) + .await + .unwrap(); + + tokio::time::advance(Duration::from_millis(60)).await; + + let item = drain_next(&mut rx).await.expect("item"); + assert!(matches!(item, Ok((_, FetchOutcome::Missing)))); + } + + #[tokio::test] + async fn admission_too_many_cids_rejected() { + let rig = empty_rig(); + let cids: Vec = (0..=MAX_WANTED_BLOCKS as u8) + .map(|i| { + let mut d = [0u8; 32]; + d[0] = i; + cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, d) + }) + .collect(); + + let err = rig.user_handle.request_stream(cids).await.err().expect("err"); + match err { + BitswapError::TooManyCids { requested, max } => { + assert_eq!(requested, MAX_WANTED_BLOCKS + 1); + assert_eq!(max, MAX_WANTED_BLOCKS); + }, + other => panic!("expected TooManyCids, got {other:?}"), + } + } + + #[tokio::test] + async fn admission_invalid_cid_rejected() { + let rig = empty_rig(); + let bad = Cid::new_v1( + RAW_CODEC, + CidMultihash::<64>::wrap(0x99 /* unsupported */, &[0u8; 32]).unwrap(), + ); + + let err = rig.user_handle.request_stream(vec![bad]).await.err().expect("err"); + assert!(matches!(err, BitswapError::InvalidCid { .. })); + } + + #[tokio::test] + async fn admission_empty_returns_closed_receiver() { + let rig = empty_rig(); + let mut rx = rig.user_handle.request_stream(vec![]).await.unwrap(); + assert!(rx.recv().await.is_none()); + } + + #[tokio::test(start_paused = true)] + async fn service_shutdown_emits_service_closed() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xee; 32]); + + rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await; + + drop(rig.inbound_tx); + + let item = drain_next(&mut rx).await.expect("expect Err"); + assert!(matches!(item, Err(BitswapError::ServiceClosed))); + } + + #[tokio::test(start_paused = true)] + async fn peer_disconnect_triggers_failover() { + let mut rig = empty_rig(); + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let data = b"after-disconnect".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.peer_event_tx.send(PeerEvent::Connected { peer: peer_a }).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + assert_eq!(first_peer, peer_a); + + rig.peer_event_tx.send(PeerEvent::Connected { peer: peer_b }).await.unwrap(); + rig.peer_event_tx.send(PeerEvent::Disconnected { peer: peer_a }).await.unwrap(); + + let (second_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); + assert_eq!(second_peer, peer_b); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer: peer_b, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + let item = drain_next(&mut rx).await.expect("item"); + assert!(matches!(item, Ok((c, FetchOutcome::Block(b))) if c == cid && b == data)); + } + + #[tokio::test(start_paused = true)] + async fn late_response_after_waiter_gone_is_dropped() { + let mut rig = short_deadline_rig(Duration::from_millis(20)); + let peer = litep2p::PeerId::random(); + let data = b"too-late".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); + + tokio::time::advance(Duration::from_millis(40)).await; + let _missing = drain_next(&mut rx).await.expect("missing"); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + sleep(Duration::from_millis(1)).await; + assert!(rx.recv().await.is_none()); + } +} diff --git a/substrate/client/network/src/config.rs b/substrate/client/network/src/config.rs index 53ed03a51d36..14bcd8b6c1ce 100644 --- a/substrate/client/network/src/config.rs +++ b/substrate/client/network/src/config.rs @@ -766,9 +766,11 @@ impl NetworkConfiguration { } /// IPFS server configuration. -pub struct IpfsConfig> { - /// Network-backend-specific Bitswap configuration. - pub bitswap_config: N::BitswapConfig, +pub struct IpfsConfig { + /// Bitswap wiring produced by [`crate::bitswap::start`]. Carries the litep2p protocol + /// config, the user-facing handle, and the peer-event sender. Consumed by the litep2p + /// backend at construction. `None` is unsupported when `ipfs_server = true`. + pub bitswap_wiring: Option, /// Indexed transactions provider. pub block_provider: Box, /// IPFS bootstrap nodes. @@ -803,7 +805,7 @@ pub struct Params> { pub block_announce_config: N::NotificationProtocolConfig, /// Bitswap configuration, if the server has been enabled. - pub ipfs_config: Option>, + pub ipfs_config: Option, /// Notification metrics. pub notification_metrics: NotificationMetrics, diff --git a/substrate/client/network/src/lib.rs b/substrate/client/network/src/lib.rs index dcf97fd14135..cec16c341fc4 100644 --- a/substrate/client/network/src/lib.rs +++ b/substrate/client/network/src/lib.rs @@ -284,10 +284,10 @@ pub use service::{ metrics::NotificationMetrics, signature::Signature, traits::{ - KademliaKey, MessageSink, NetworkBackend, NetworkBlock, NetworkDHTProvider, - NetworkEventStream, NetworkPeers, NetworkRequest, NetworkSigner, NetworkStateInfo, - NetworkStatus, NetworkStatusProvider, NetworkSyncForkRequest, NotificationConfig, - NotificationSender as NotificationSenderT, NotificationSenderError, + BitswapProvider, KademliaKey, MessageSink, NetworkBackend, NetworkBlock, + NetworkDHTProvider, NetworkEventStream, NetworkPeers, NetworkRequest, NetworkSigner, + NetworkStateInfo, NetworkStatus, NetworkStatusProvider, NetworkSyncForkRequest, + NotificationConfig, NotificationSender as NotificationSenderT, NotificationSenderError, NotificationSenderReady, NotificationService, }, DecodingError, Keypair, NetworkService, NetworkWorker, NotificationSender, OutboundFailure, diff --git a/substrate/client/network/src/litep2p/bitswap.rs b/substrate/client/network/src/litep2p/bitswap.rs deleted file mode 100644 index 7cc0a95b41ce..000000000000 --- a/substrate/client/network/src/litep2p/bitswap.rs +++ /dev/null @@ -1,703 +0,0 @@ -// This file is part of Substrate. - -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. - -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -//! Bidirectional bitswap shim for litep2p. -//! -//! Wraps litep2p's native [`BitswapHandle`] to provide both server-side (inbound WANT handling) -//! and client-side (outbound WANT dispatch + response correlation) functionality. - -use crate::{ - bitswap::{ - is_cid_supported, - schema::bitswap::message::{ - Block as MessageBlock, BlockPresence, BlockPresenceType as ProtoPresenceType, - }, - BitswapProtoMessage, Cid, Prefix, LOG_TARGET, MAX_WANTED_BLOCKS, PROTOCOL_NAME, - }, - litep2p::bitswap_metrics::{errors, outcomes, BitswapMetrics}, - request_responses::RequestFailure, - OutboundFailure, ProtocolName, MAX_RESPONSE_SIZE, -}; -use futures::{channel::oneshot, StreamExt}; -use litep2p::protocol::libp2p::bitswap::{ - BitswapEvent, BitswapHandle, BlockPresenceType, Config, ResponseType, WantType, -}; -use prometheus_endpoint::Registry; -use prost::Message as ProstMessage; -use sc_client_api::BlockBackend; -use sp_core::H256; -use sp_runtime::traits::Block as BlockT; -use std::{ - collections::HashMap, - future::Future, - pin::Pin, - sync::Arc, - time::{Duration, Instant}, -}; -use tokio::sync::mpsc; - -/// Command channel capacity. -const CMD_CHANNEL_CAPACITY: usize = 256; -/// Timeout for pending bitswap requests. -const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); -/// Interval for reaping expired pending batches. -const EXPIRY_TICK_INTERVAL: Duration = Duration::from_secs(10); - -pub(crate) type ResponseSender = oneshot::Sender, ProtocolName), RequestFailure>>; - -/// Outbound bitswap command sent from [`super::service::Litep2pNetworkService`]. -pub(crate) struct BitswapOutboundCmd { - pub(crate) peer: litep2p::PeerId, - pub(crate) wants: Vec<(Cid, WantType)>, - pub(crate) response_tx: ResponseSender, -} - -/// Pending outbound WANT batch. -struct PendingBatch { - cids: Vec, - responses: HashMap, - response_bytes: usize, - response_tx: Option, - inserted: Instant, -} - -impl PendingBatch { - fn new(cids: Vec, response_tx: ResponseSender, inserted: Instant) -> Self { - Self { - cids, - responses: HashMap::new(), - response_bytes: 0, - response_tx: Some(response_tx), - inserted, - } - } - - fn record_responses(&mut self, responses: &HashMap) { - for cid in &self.cids { - if self.responses.contains_key(cid) { - continue; - } - let Some(resp) = responses.get(cid) else { continue }; - self.response_bytes = self.response_bytes.saturating_add(response_retained_bytes(resp)); - self.responses.insert(*cid, resp.clone()); - } - } - - fn is_complete(&self) -> bool { - self.cids.len() == self.responses.len() - } - - fn is_over_limit(&self, max_response_bytes: usize) -> bool { - self.response_bytes > max_response_bytes - } - - fn is_expired(&self, timeout: Duration, now: Instant) -> bool { - now.saturating_duration_since(self.inserted) >= timeout - } - - fn send_success(&mut self) { - let responses: Vec = - self.cids.iter().filter_map(|cid| self.responses.get(cid).cloned()).collect(); - let encoded = encode_responses_as_bitswap_message(&responses); - if let Some(response_tx) = self.response_tx.take() { - let _ = response_tx.send(Ok((encoded, ProtocolName::from(PROTOCOL_NAME)))); - } - } - - fn send_failure(&mut self, failure: RequestFailure) { - if let Some(response_tx) = self.response_tx.take() { - let _ = response_tx.send(Err(failure)); - } - } -} - -/// Litep2p-specific bitswap configuration returned by [`BitswapService::new`]. -/// -/// Carries the native litep2p [`Config`] and the sender half of the command -/// channel so that [`super::service::Litep2pNetworkService`] can forward -/// client-side bitswap requests. -pub struct BitswapConfig { - pub(crate) litep2p_config: Config, - pub(crate) cmd_tx: mpsc::Sender, -} - -/// Pending outbound WANT batches, indexed by peer. -#[derive(Default)] -struct PendingBatches { - by_peer: HashMap>, -} - -impl PendingBatches { - fn insert(&mut self, peer: litep2p::PeerId, batch: PendingBatch) { - self.by_peer.entry(peer).or_default().push(batch); - } - - fn handle_response(&mut self, peer: litep2p::PeerId, responses: Vec) { - self.handle_response_with_limit(peer, responses, MAX_RESPONSE_SIZE as usize); - } - - fn handle_response_with_limit( - &mut self, - peer: litep2p::PeerId, - responses: Vec, - max_response_bytes: usize, - ) { - log::debug!( - target: LOG_TARGET, - "bitswap: received response from {peer:?} with {} entries", - responses.len() - ); - - let Some(peer_batches) = self.by_peer.get_mut(&peer) else { return }; - let best = select_best_response_per_cid(responses); - - peer_batches.retain_mut(|batch| { - batch.record_responses(&best); - - if batch.is_over_limit(max_response_bytes) { - log::warn!( - target: LOG_TARGET, - "bitswap: response from {peer:?} exceeded pending batch byte limit: {} > {}", - batch.response_bytes, - max_response_bytes, - ); - batch.send_failure(RequestFailure::Network(OutboundFailure::ConnectionClosed)); - false - } else if batch.is_complete() { - batch.send_success(); - false - } else { - true - } - }); - - if peer_batches.is_empty() { - self.by_peer.remove(&peer); - } - } - - fn expire(&mut self, timeout: Duration, now: Instant) { - self.by_peer.retain(|peer, peer_batches| { - peer_batches.retain_mut(|batch| { - if batch.is_expired(timeout, now) { - log::debug!( - target: LOG_TARGET, - "bitswap: expired pending batch for {} CIDs from {:?}", - batch.cids.len(), - peer, - ); - batch.send_failure(RequestFailure::Network(OutboundFailure::Timeout)); - false - } else { - true - } - }); - - !peer_batches.is_empty() - }); - } - - #[cfg(test)] - fn is_empty(&self) -> bool { - self.by_peer.is_empty() - } - - #[cfg(test)] - fn len(&self) -> usize { - self.by_peer.len() - } - - #[cfg(test)] - fn contains_key(&self, peer: &litep2p::PeerId) -> bool { - self.by_peer.contains_key(peer) - } -} - -/// Bidirectional bitswap service for litep2p. -pub(crate) struct BitswapService { - handle: BitswapHandle, - client: Arc + Send + Sync>, - cmd_rx: mpsc::Receiver, - pending: PendingBatches, - metrics: BitswapMetrics, -} - -impl BitswapService { - /// Create a new bidirectional bitswap service. - /// - /// Returns the boxed task future (to be spawned on the executor) and the - /// [`BitswapConfig`] to be passed into the litep2p config builder. - /// - /// If `metrics_registry` is `Some`, Prometheus metrics are registered with - /// it. A registration failure is logged and falls back to disabled metrics - /// (the service still runs). - pub(crate) fn new( - client: Arc + Send + Sync>, - metrics_registry: Option<&Registry>, - ) -> (Pin + Send>>, BitswapConfig) { - let metrics = BitswapMetrics::new(metrics_registry).unwrap_or_else(|err| { - log::debug!(target: LOG_TARGET, "failed to register bitswap metrics: {err}"); - BitswapMetrics::new(None).expect("registering with None registry never fails; qed") - }); - let (litep2p_config, handle) = Config::new(); - let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); - let service = Self { handle, client, cmd_rx, pending: PendingBatches::default(), metrics }; - let future = Box::pin(async move { service.run().await }); - let config = BitswapConfig { litep2p_config, cmd_tx }; - (future, config) - } - - /// Run the bitswap event loop. - async fn run(mut self) { - log::debug!(target: LOG_TARGET, "starting bidirectional bitswap service"); - let mut expiry_ticker = tokio::time::interval(EXPIRY_TICK_INTERVAL); - expiry_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - expiry_ticker.tick().await; - - loop { - tokio::select! { - event = self.handle.next() => match event { - Some(BitswapEvent::Request { peer, cids }) => - self.handle_inbound_request(peer, cids).await, - Some(BitswapEvent::Response { peer, responses }) => - self.pending.handle_response(peer, responses), - None => { - log::debug!(target: LOG_TARGET, "bitswap handle stream ended"); - return; - }, - }, - cmd = self.cmd_rx.recv() => match cmd { - Some(BitswapOutboundCmd { peer, wants, response_tx }) => - self.handle_outbound_cmd(peer, wants, response_tx).await, - None => { - log::debug!(target: LOG_TARGET, "bitswap cmd channel closed"); - return; - }, - }, - _ = expiry_ticker.tick() => { - self.pending.expire(REQUEST_TIMEOUT, Instant::now()); - }, - } - } - } - - /// Handle an inbound bitswap WANT request from `peer`. - async fn handle_inbound_request(&mut self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { - let started = Instant::now(); - let want_count = cids.len(); - if inbound_wantlist_exceeds_limit(want_count) { - self.metrics.record_error(errors::TOO_MANY_ENTRIES); - log::trace!(target: LOG_TARGET, "bitswap: ignored inbound request with {want_count} entries"); - return; - } - - log::debug!(target: LOG_TARGET, "bitswap: handle inbound request from {peer:?} for {cids:?}"); - - let metrics = &self.metrics; - let response: Vec = cids - .into_iter() - .filter(|(cid, _)| { - let supported = is_cid_supported(cid); - if !supported { - metrics.record_entry(outcomes::UNSUPPORTED_CID); - } - supported - }) - .map(|(cid, want_type)| { - let hash = H256::from_slice(&cid.hash().digest()[0..32]); - let transaction = match self.client.indexed_transaction(hash) { - Ok(ex) => ex, - Err(error) => { - metrics.record_error(errors::CLIENT); - log::error!(target: LOG_TARGET, "error retrieving transaction {hash}: {error}"); - None - }, - }; - let response = match transaction { - Some(transaction) => match want_type { - WantType::Block => ResponseType::Block { cid, block: transaction }, - _ => ResponseType::Presence { cid, presence: BlockPresenceType::Have }, - }, - None => ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }, - }; - metrics.record_response(&response); - response - }) - .collect(); - - // note: we assume the duplicate encode (litep2p re-serialises internally inside - // `send_response`) is cheap. - let response_bytes = encode_responses_as_bitswap_message(&response).len(); - self.metrics.add_response_bytes(response_bytes as u64); - - self.handle.send_response(peer, response).await; - self.metrics.record_duration(started.elapsed()); - } - - /// Handle an outbound bitswap command from the network service. - async fn handle_outbound_cmd( - &mut self, - peer: litep2p::PeerId, - wants: Vec<(Cid, WantType)>, - response_tx: ResponseSender, - ) { - log::debug!( - target: LOG_TARGET, - "bitswap: outbound WANT for {} CIDs to {peer:?}", - wants.len(), - ); - let cids: Vec<_> = wants.iter().map(|(cid, _)| *cid).collect(); - self.pending.insert(peer, PendingBatch::new(cids, response_tx, Instant::now())); - self.handle.send_request(peer, wants).await; - } -} - -fn inbound_wantlist_exceeds_limit(len: usize) -> bool { - len > MAX_WANTED_BLOCKS -} - -/// Collapse a response list into at most one entry per CID, preferring `Block` -/// over `Presence` when both arrive for the same CID. -fn select_best_response_per_cid(responses: Vec) -> HashMap { - let mut best: HashMap = HashMap::new(); - for resp in responses { - let cid = match &resp { - ResponseType::Block { cid, .. } => *cid, - ResponseType::Presence { cid, .. } => *cid, - }; - match best.entry(cid) { - std::collections::hash_map::Entry::Vacant(e) => { - e.insert(resp); - }, - std::collections::hash_map::Entry::Occupied(mut e) => { - if matches!(resp, ResponseType::Block { .. }) && - matches!(*e.get(), ResponseType::Presence { .. }) - { - e.insert(resp); - } - }, - } - } - best -} - -/// Return the byte size of a response that counts toward the pending batch cap. -fn response_retained_bytes(response: &ResponseType) -> usize { - match response { - ResponseType::Block { block, .. } => block.len(), - ResponseType::Presence { .. } => 0, - } -} - -/// Encode litep2p [`ResponseType`] values into a [`BitswapProtoMessage`] byte vector. -fn encode_responses_as_bitswap_message(responses: &[ResponseType]) -> Vec { - let mut msg = BitswapProtoMessage::default(); - - for resp in responses { - match resp { - ResponseType::Block { cid, block } => { - let prefix: Prefix = cid.into(); - msg.payload - .push(MessageBlock { prefix: prefix.to_bytes(), data: block.clone() }); - }, - ResponseType::Presence { cid, presence } => { - msg.block_presences.push(BlockPresence { - cid: cid.to_bytes(), - r#type: match presence { - BlockPresenceType::Have => ProtoPresenceType::Have as i32, - BlockPresenceType::DontHave => ProtoPresenceType::DontHave as i32, - }, - }); - }, - } - } - - msg.encode_to_vec() -} - -#[cfg(test)] -mod tests { - use super::*; - use cid::multihash::Multihash as CidMultihash; - use prometheus_endpoint::Registry; - use substrate_test_runtime_client; - - fn make_peer() -> litep2p::PeerId { - litep2p::PeerId::random() - } - - fn make_cid(byte: u8) -> Cid { - let digest = [byte; 32]; - let mh = CidMultihash::<64>::wrap(0xb220, &digest).unwrap(); - Cid::new_v1(0x55, mh) - } - - #[test] - fn inbound_wantlist_limit_rejects_only_over_cap_requests() { - assert!(!inbound_wantlist_exceeds_limit(MAX_WANTED_BLOCKS)); - assert!(inbound_wantlist_exceeds_limit(MAX_WANTED_BLOCKS + 1)); - } - - #[test] - fn bitswap_service_constructs_without_registry() { - let client = Arc::new(substrate_test_runtime_client::new()); - let (_future, _config) = BitswapService::new(client, None); - } - - #[test] - fn bitswap_service_constructs_with_registry() { - let registry = Registry::new(); - let client = Arc::new(substrate_test_runtime_client::new()); - let (_future, _config) = BitswapService::new(client, Some(®istry)); - - // Sanity check: registering the same metric names a second time on the same - // registry must fail — proves the first registration actually went through. - let second = crate::litep2p::bitswap_metrics::BitswapMetrics::new(Some(®istry)); - assert!(second.is_err(), "double registration should fail"); - } - - fn pending_batch( - cids: Vec, - response_tx: ResponseSender, - inserted: Instant, - ) -> PendingBatch { - PendingBatch::new(cids, response_tx, inserted) - } - - #[test] - fn encode_responses_are_decodable() { - let block_cid = make_cid(1); - let presence_cid = make_cid(2); - let data = b"block-data-payload".to_vec(); - let responses = vec![ - ResponseType::Block { cid: block_cid, block: data.clone() }, - ResponseType::Presence { cid: presence_cid, presence: BlockPresenceType::DontHave }, - ]; - - let bytes = encode_responses_as_bitswap_message(&responses); - let msg = BitswapProtoMessage::decode(bytes.as_slice()).unwrap(); - - assert_eq!(msg.payload.len(), 1); - assert_eq!(msg.payload[0].data, data); - assert_eq!(msg.block_presences.len(), 1); - assert_eq!(msg.block_presences[0].r#type, ProtoPresenceType::DontHave as i32); - } - - #[test] - fn select_best_prefers_block_over_presence() { - let cid = make_cid(3); - let data = b"data".to_vec(); - let responses = vec![ - ResponseType::Presence { cid, presence: BlockPresenceType::Have }, - ResponseType::Block { cid, block: data.clone() }, - ]; - let best = select_best_response_per_cid(responses); - assert_eq!(best.len(), 1); - match best.into_iter().next().unwrap().1 { - ResponseType::Block { block, .. } => assert_eq!(block, data), - _ => panic!("expected Block to win"), - } - } - - #[test] - fn select_best_prefers_block_over_presence_regardless_of_order() { - let cid = make_cid(4); - let data = b"data-reversed".to_vec(); - let responses = vec![ - ResponseType::Block { cid, block: data.clone() }, - ResponseType::Presence { cid, presence: BlockPresenceType::Have }, - ]; - let best = select_best_response_per_cid(responses); - assert_eq!(best.len(), 1); - match best.into_iter().next().unwrap().1 { - ResponseType::Block { block, .. } => assert_eq!(block, data), - _ => panic!("expected Block to win"), - } - } - - #[test] - fn select_best_keeps_distinct_cids() { - let cid_a = make_cid(5); - let cid_b = make_cid(6); - let responses = vec![ - ResponseType::Block { cid: cid_a, block: b"a".to_vec() }, - ResponseType::Presence { cid: cid_b, presence: BlockPresenceType::DontHave }, - ]; - let best = select_best_response_per_cid(responses); - assert_eq!(best.len(), 2); - assert!(best.contains_key(&cid_a)); - assert!(best.contains_key(&cid_b)); - } - - #[tokio::test] - async fn pending_batch_single_request_resolves() { - let peer = make_peer(); - let cid = make_cid(7); - let data = b"resolved-data".to_vec(); - - let (tx, rx) = oneshot::channel(); - let mut pending = PendingBatches::default(); - pending.insert(peer, pending_batch(vec![cid], tx, Instant::now())); - - pending.handle_response(peer, vec![ResponseType::Block { cid, block: data.clone() }]); - - let (payload, _) = rx.await.unwrap().unwrap(); - let msg = BitswapProtoMessage::decode(payload.as_slice()).unwrap(); - assert_eq!(msg.payload.len(), 1); - assert_eq!(msg.payload[0].data, data); - assert!(pending.is_empty()); - } - - #[tokio::test] - async fn pending_batch_duplicate_requests_both_resolve() { - let peer = make_peer(); - let cid = make_cid(8); - let data = b"shared-blob".to_vec(); - - let (tx_a, rx_a) = oneshot::channel(); - let (tx_b, rx_b) = oneshot::channel(); - let mut pending = PendingBatches::default(); - pending.insert(peer, pending_batch(vec![cid], tx_a, Instant::now())); - pending.insert(peer, pending_batch(vec![cid], tx_b, Instant::now())); - - pending.handle_response(peer, vec![ResponseType::Block { cid, block: data.clone() }]); - - let a = rx_a.await.unwrap().unwrap(); - let b = rx_b.await.unwrap().unwrap(); - let msg_a = BitswapProtoMessage::decode(a.0.as_slice()).unwrap(); - let msg_b = BitswapProtoMessage::decode(b.0.as_slice()).unwrap(); - assert_eq!(msg_a.payload[0].data, data); - assert_eq!(msg_b.payload[0].data, data); - assert!(pending.is_empty()); - } - - #[tokio::test] - async fn pending_batch_multi_want_waits_for_all_cids() { - let peer = make_peer(); - let cid_a = make_cid(11); - let cid_b = make_cid(12); - let data_a = b"first".to_vec(); - let data_b = b"second".to_vec(); - - let (tx, rx) = oneshot::channel(); - let mut pending = PendingBatches::default(); - pending.insert(peer, pending_batch(vec![cid_a, cid_b], tx, Instant::now())); - - pending - .handle_response(peer, vec![ResponseType::Block { cid: cid_a, block: data_a.clone() }]); - assert_eq!(pending.len(), 1); - - pending - .handle_response(peer, vec![ResponseType::Block { cid: cid_b, block: data_b.clone() }]); - - let (payload, _) = rx.await.unwrap().unwrap(); - let msg = BitswapProtoMessage::decode(payload.as_slice()).unwrap(); - assert_eq!(msg.payload.len(), 2); - assert_eq!(msg.payload[0].data, data_a); - assert_eq!(msg.payload[1].data, data_b); - assert!(pending.is_empty()); - } - - #[tokio::test] - async fn pending_batch_fails_when_partial_responses_exceed_byte_limit() { - let peer = make_peer(); - let cid_a = make_cid(13); - let cid_b = make_cid(14); - - let (tx, rx) = oneshot::channel(); - let mut pending = PendingBatches::default(); - pending.insert(peer, pending_batch(vec![cid_a, cid_b], tx, Instant::now())); - - pending.handle_response_with_limit( - peer, - vec![ResponseType::Block { cid: cid_a, block: vec![0u8; 8] }], - 4, - ); - - let result = rx.await.unwrap(); - assert!(matches!(result, Err(RequestFailure::Network(OutboundFailure::ConnectionClosed)))); - assert!(pending.is_empty()); - } - - #[tokio::test] - async fn pending_batch_expiry_sends_failure() { - let peer = make_peer(); - let cid = make_cid(9); - - let (tx_stale, rx_stale) = oneshot::channel(); - let (tx_fresh, rx_fresh) = oneshot::channel(); - let past = Instant::now() - Duration::from_secs(60); - let fresh_time = Instant::now(); - - let mut pending = PendingBatches::default(); - pending.insert(peer, pending_batch(vec![cid], tx_stale, past)); - pending.insert(peer, pending_batch(vec![cid], tx_fresh, fresh_time)); - - pending.expire(Duration::from_secs(30), Instant::now()); - - let stale_result = rx_stale.await.unwrap(); - assert!(matches!(stale_result, Err(RequestFailure::Network(OutboundFailure::Timeout)))); - assert_eq!(pending.len(), 1); - drop(rx_fresh); - } - - #[tokio::test] - async fn pending_batch_mismatched_peer_does_not_resolve() { - let peer_a = make_peer(); - let peer_b = make_peer(); - let cid = make_cid(10); - - let (tx, mut rx) = oneshot::channel(); - let mut pending = PendingBatches::default(); - pending.insert(peer_a, pending_batch(vec![cid], tx, Instant::now())); - - pending.handle_response(peer_b, vec![ResponseType::Block { cid, block: b"data".to_vec() }]); - - assert_eq!(pending.len(), 1); - assert!(rx.try_recv().unwrap().is_none()); - } - - #[tokio::test] - async fn pending_batch_response_from_one_peer_does_not_affect_other_peer() { - let peer_a = make_peer(); - let peer_b = make_peer(); - let cid_a = make_cid(20); - let cid_b = make_cid(21); - let data_b = b"peer-b-data".to_vec(); - - let (tx_a, mut rx_a) = oneshot::channel(); - let (tx_b, rx_b) = oneshot::channel(); - let mut pending = PendingBatches::default(); - pending.insert(peer_a, pending_batch(vec![cid_a], tx_a, Instant::now())); - pending.insert(peer_b, pending_batch(vec![cid_b], tx_b, Instant::now())); - - pending.handle_response( - peer_b, - vec![ResponseType::Block { cid: cid_b, block: data_b.clone() }], - ); - - let (payload, _) = rx_b.await.unwrap().unwrap(); - let msg = BitswapProtoMessage::decode(payload.as_slice()).unwrap(); - assert_eq!(msg.payload.len(), 1); - assert_eq!(msg.payload[0].data, data_b); - - assert!(rx_a.try_recv().unwrap().is_none()); - assert_eq!(pending.len(), 1); - assert!(pending.contains_key(&peer_a)); - } -} diff --git a/substrate/client/network/src/litep2p/bitswap_metrics.rs b/substrate/client/network/src/litep2p/bitswap_metrics.rs deleted file mode 100644 index cc5c26274cac..000000000000 --- a/substrate/client/network/src/litep2p/bitswap_metrics.rs +++ /dev/null @@ -1,219 +0,0 @@ -// This file is part of Substrate. - -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. - -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -//! Prometheus metrics for the litep2p bitswap server. - -use litep2p::protocol::libp2p::bitswap::{BlockPresenceType, ResponseType}; -use prometheus_endpoint::{ - exponential_buckets, register, Counter, CounterVec, Histogram, HistogramOpts, Opts, - PrometheusError, Registry, U64, -}; -use std::time::Duration; - -/// `outcome` label values for `substrate_sub_libp2p_bitswap_entries_total`. -pub mod outcomes { - pub const BLOCK_SERVED: &str = "block_served"; - pub const HAVE: &str = "have"; - pub const DONT_HAVE: &str = "dont_have"; - pub const UNSUPPORTED_CID: &str = "unsupported_cid"; -} - -/// `reason` label values for `substrate_sub_libp2p_bitswap_request_errors_total`. -pub mod errors { - pub const TOO_MANY_ENTRIES: &str = "too_many_entries"; - pub const CLIENT: &str = "client"; -} - -struct Inner { - entries_total: CounterVec, - request_errors_total: CounterVec, - inbound_request_duration_seconds: Histogram, - response_bytes_total: Counter, -} - -impl Inner { - fn register(registry: &Registry) -> Result { - Ok(Self { - entries_total: register( - CounterVec::new( - Opts::new( - "substrate_sub_libp2p_bitswap_entries_total", - "Total number of bitswap wantlist entries processed, by outcome", - ), - &["outcome"], - )?, - registry, - )?, - request_errors_total: register( - CounterVec::new( - Opts::new( - "substrate_sub_libp2p_bitswap_request_errors_total", - "Total number of bitswap inbound requests rejected, by reason", - ), - &["reason"], - )?, - registry, - )?, - inbound_request_duration_seconds: register( - Histogram::with_opts(HistogramOpts { - common_opts: Opts::new( - "substrate_sub_libp2p_bitswap_inbound_request_duration_seconds", - "Duration of handling an inbound bitswap wantlist, in seconds", - ), - buckets: exponential_buckets(0.001, 2.0, 16) - .expect("parameters are always valid values; qed"), - })?, - registry, - )?, - response_bytes_total: register( - Counter::new( - "substrate_sub_libp2p_bitswap_response_bytes_total", - "Total bytes sent in bitswap responses to inbound wantlists", - )?, - registry, - )?, - }) - } -} - -/// Helper wrapper around the bitswap server metrics. -/// -/// When constructed without a `Registry`, all recording methods become no-ops. -pub struct BitswapMetrics { - inner: Option, -} - -impl BitswapMetrics { - /// Register the metrics with the given Prometheus registry, if any. - pub fn new(registry: Option<&Registry>) -> Result { - Ok(Self { inner: registry.map(Inner::register).transpose()? }) - } - - /// Record one wantlist entry processed with the given outcome. - pub fn record_entry(&self, outcome: &str) { - if let Some(inner) = &self.inner { - inner.entries_total.with_label_values(&[outcome]).inc(); - } - } - - /// Record one outbound response variant under the matching outcome label. - pub fn record_response(&self, response: &ResponseType) { - let outcome = match response { - ResponseType::Block { .. } => outcomes::BLOCK_SERVED, - ResponseType::Presence { presence: BlockPresenceType::Have, .. } => outcomes::HAVE, - ResponseType::Presence { presence: BlockPresenceType::DontHave, .. } => { - outcomes::DONT_HAVE - }, - }; - self.record_entry(outcome); - } - - /// Record one request-level error with the given reason. - pub fn record_error(&self, reason: &str) { - if let Some(inner) = &self.inner { - inner.request_errors_total.with_label_values(&[reason]).inc(); - } - } - - /// Observe the duration of an inbound wantlist handling. - pub fn record_duration(&self, duration: Duration) { - if let Some(inner) = &self.inner { - inner.inbound_request_duration_seconds.observe(duration.as_secs_f64()); - } - } - - /// Add to the running total of bitswap response bytes sent. - pub fn add_response_bytes(&self, bytes: u64) { - if let Some(inner) = &self.inner { - inner.response_bytes_total.inc_by(bytes); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cid::{multihash::Multihash as CidMultihash, Cid}; - - fn make_cid() -> Cid { - let mh = CidMultihash::<64>::wrap(0xb220, &[0u8; 32]).unwrap(); - Cid::new_v1(0x55, mh) - } - - #[test] - fn disabled_metrics_are_no_ops() { - let metrics = BitswapMetrics::new(None).unwrap(); - metrics.record_entry(outcomes::BLOCK_SERVED); - metrics.record_error(errors::CLIENT); - metrics.record_duration(Duration::from_millis(1)); - metrics.add_response_bytes(42); - } - - #[test] - fn record_response_maps_variants_to_outcomes() { - let registry = Registry::new(); - let metrics = BitswapMetrics::new(Some(®istry)).unwrap(); - let cid = make_cid(); - - metrics.record_response(&ResponseType::Block { cid, block: vec![1, 2, 3] }); - metrics.record_response(&ResponseType::Block { cid, block: vec![4] }); - metrics.record_response(&ResponseType::Presence { cid, presence: BlockPresenceType::Have }); - metrics.record_response(&ResponseType::Presence { - cid, - presence: BlockPresenceType::DontHave, - }); - metrics.record_response(&ResponseType::Presence { - cid, - presence: BlockPresenceType::DontHave, - }); - metrics.record_response(&ResponseType::Presence { - cid, - presence: BlockPresenceType::DontHave, - }); - - let inner = metrics.inner.as_ref().expect("inner should be present"); - assert_eq!(inner.entries_total.with_label_values(&[outcomes::BLOCK_SERVED]).get(), 2); - assert_eq!(inner.entries_total.with_label_values(&[outcomes::HAVE]).get(), 1); - assert_eq!(inner.entries_total.with_label_values(&[outcomes::DONT_HAVE]).get(), 3); - assert_eq!(inner.entries_total.with_label_values(&[outcomes::UNSUPPORTED_CID]).get(), 0); - } - - #[test] - fn enabled_metrics_register_and_increment() { - let registry = Registry::new(); - let metrics = BitswapMetrics::new(Some(®istry)).unwrap(); - - metrics.record_entry(outcomes::BLOCK_SERVED); - metrics.record_entry(outcomes::BLOCK_SERVED); - metrics.record_entry(outcomes::HAVE); - metrics.record_error(errors::TOO_MANY_ENTRIES); - metrics.record_duration(Duration::from_millis(5)); - metrics.add_response_bytes(1024); - - let inner = metrics.inner.as_ref().expect("inner should be present when registry given"); - assert_eq!(inner.entries_total.with_label_values(&[outcomes::BLOCK_SERVED]).get(), 2); - assert_eq!(inner.entries_total.with_label_values(&[outcomes::HAVE]).get(), 1); - assert_eq!(inner.entries_total.with_label_values(&[outcomes::DONT_HAVE]).get(), 0); - assert_eq!( - inner.request_errors_total.with_label_values(&[errors::TOO_MANY_ENTRIES]).get(), - 1 - ); - assert_eq!(inner.response_bytes_total.get(), 1024); - assert_eq!(inner.inbound_request_duration_seconds.get_sample_count(), 1); - } -} diff --git a/substrate/client/network/src/litep2p/mod.rs b/substrate/client/network/src/litep2p/mod.rs index 4923e83c6148..1ae0dc4acc6c 100644 --- a/substrate/client/network/src/litep2p/mod.rs +++ b/substrate/client/network/src/litep2p/mod.rs @@ -26,7 +26,6 @@ use crate::{ error::Error, event::{DhtEvent, Event}, litep2p::{ - bitswap::BitswapService, discovery::{Discovery, DiscoveryEvent}, ipfs_dht::IpfsDht, peerstore::Peerstore, @@ -48,7 +47,7 @@ use crate::{ NetworkStatus, NotificationService, ProtocolName, }; -use codec::{Decode, Encode}; +use codec::Encode; use futures::StreamExt; use litep2p::{ config::ConfigBuilder, @@ -61,9 +60,7 @@ use litep2p::{ }, transport::{ tcp::config::Config as TcpTransportConfig, - webrtc::{config::Config as WebRtcTransportConfig, DtlsCertificate}, - websocket::config::Config as WebSocketTransportConfig, - ConnectionLimitsConfig, Endpoint, + websocket::config::Config as WebSocketTransportConfig, ConnectionLimitsConfig, Endpoint, }, types::{ multiaddr::{Multiaddr, Protocol}, @@ -74,7 +71,6 @@ use litep2p::{ use prometheus_endpoint::Registry; use sc_network_types::kad::{Key as RecordKey, PeerRecord, Record as P2PRecord}; -use sc_client_api::BlockBackend; use sc_network_common::{role::Roles, ExHashT}; use sc_network_types::PeerId; use sc_utils::mpsc::{tracing_unbounded, TracingUnboundedReceiver}; @@ -94,17 +90,12 @@ use std::{ time::{Duration, Instant}, }; -mod bitswap; -mod bitswap_metrics; mod discovery; mod ipfs_dht; mod peerstore; mod service; mod shim; -/// File name for the persisted WebRTC DTLS certificate. -pub const NODE_KEY_WEBRTC_FILE: &str = "webrtc_certificate"; - /// Litep2p bandwidth sink. struct Litep2pBandwidthSink { sink: litep2p::BandwidthSink, @@ -200,6 +191,18 @@ pub struct Litep2pNetworkBackend { /// Prometheus metrics. metrics: Option, + + /// Sender for Bitswap peer events. `Some` when `--ipfs-server` is enabled. The main + /// loop publishes [`crate::bitswap::PeerEvent::Connected`] / + /// [`crate::bitswap::PeerEvent::Disconnected`] into this channel from its + /// `ConnectionEstablished` / `ConnectionClosed` arms; the receiver lives inside the + /// Bitswap service actor. + bitswap_peer_event_tx: Option>, + + /// Per-peer connection count, used to dedup Bitswap peer events. Empty when + /// `bitswap_peer_event_tx` is `None`. We track this independently of + /// [`Self::peers`] because that map is only populated when metrics are enabled. + bitswap_peer_conn_count: HashMap, } impl Litep2pNetworkBackend { @@ -281,106 +284,65 @@ impl Litep2pNetworkBackend { }; let config_builder = ConfigBuilder::new(); - let listen_addr_len = config.network_config.listen_addresses.len(); - let mut tcp_addresses = Vec::with_capacity(listen_addr_len); - let mut websocket_addresses = Vec::with_capacity(listen_addr_len); - let mut webrtc_addresses = Vec::with_capacity(listen_addr_len); - - for addr in &config.network_config.listen_addresses { - use sc_network_types::multiaddr::Protocol; - - let mut iter = addr.iter(); + let (tcp, websocket): (Vec>, Vec>) = config + .network_config + .listen_addresses + .iter() + .filter_map(|address| { + use sc_network_types::multiaddr::Protocol; - let ip_version = iter.next(); - let Some(Protocol::Ip4(_) | Protocol::Ip6(_)) = ip_version else { - log::error!( - target: LOG_TARGET, - "unknown protocol {ip_version:?}, ignoring {addr:?}", - ); - continue; - }; + let mut iter = address.iter(); - let transport_layer = iter.next(); - let protocol_type = iter.next(); + match iter.next() { + Some(Protocol::Ip4(_) | Protocol::Ip6(_)) => {}, + protocol => { + log::error!( + target: LOG_TARGET, + "unknown protocol {protocol:?}, ignoring {address:?}", + ); - match (&transport_layer, &protocol_type) { - // Plain TCP address. - (Some(Protocol::Tcp(_)), Some(Protocol::P2p(_)) | None) => { - tcp_addresses.push(addr.clone()); - }, + return None; + }, + } - // Websocket address. - (Some(Protocol::Tcp(_)), Some(Protocol::Ws(_) | Protocol::Wss(_))) => { - websocket_addresses.push(addr.clone()); - }, - // WebRTCDirecet address. - (Some(Protocol::Udp(_)), Some(Protocol::WebRTCDirect)) => { - // Ignore WebRTC addresses unless the experimental feature is enabled. - if !config.network_config.experimental_webrtc { - log::warn!( + match iter.next() { + Some(Protocol::Tcp(_)) => match iter.next() { + Some(Protocol::Ws(_) | Protocol::Wss(_)) => { + Some((None, Some(address.clone()))) + }, + Some(Protocol::P2p(_)) | None => Some((Some(address.clone()), None)), + protocol => { + log::error!( + target: LOG_TARGET, + "unknown protocol {protocol:?}, ignoring {address:?}", + ); + None + }, + }, + protocol => { + log::error!( target: LOG_TARGET, - "WebRTC address provided but --experimental-webrtc flag not enabled, ignoring {addr:?}" + "unknown protocol {protocol:?}, ignoring {address:?}", ); - continue; - } - webrtc_addresses.push(addr.clone()); - }, - _ => { - log::error!( - target: LOG_TARGET, - "unknown transport layer {transport_layer:?} and protocol type {protocol_type:?}, ignoring {addr:?}", - ); - }, - }; - } + None + }, + } + }) + .unzip(); - let mut config_builder = config_builder + config_builder .with_websocket(WebSocketTransportConfig { - listen_addresses: websocket_addresses.into_iter().map(Into::into).collect(), + listen_addresses: websocket.into_iter().flatten().map(Into::into).collect(), yamux_config: litep2p::yamux::Config::default(), nodelay: true, ..Default::default() }) .with_tcp(TcpTransportConfig { - listen_addresses: tcp_addresses.into_iter().map(Into::into).collect(), + listen_addresses: tcp.into_iter().flatten().map(Into::into).collect(), yamux_config: litep2p::yamux::Config::default(), nodelay: true, ..Default::default() - }); - - if !webrtc_addresses.is_empty() { - config_builder = config_builder.with_webrtc({ - // If WebRTC has been specified within the listen address and there - // is an on-disk config dir, attempt to use an already existing - // DTLS certificate, or generate a fresh one. - // Otherwise, fall back to an ephemeral certificate. - let certificate = match &config.network_config.net_config_path { - Some(dir) => { - read_or_generate_webrtc_certificate(&dir.join(NODE_KEY_WEBRTC_FILE)) - }, - None => { - log::warn!( - target: LOG_TARGET, - "WebRtc enabled but no networking path specified, using an ephemeral certificate" - ); - None - }, - }; - - WebRtcTransportConfig { - listen_addresses: webrtc_addresses.into_iter().map(Into::into).collect(), - certificate, - ..Default::default() - } - }); - } else if config.network_config.experimental_webrtc { - log::warn!( - target: LOG_TARGET, - "WebRtc enabled but no listen address specified" - ); - } - - config_builder + }) } } @@ -390,20 +352,11 @@ impl NetworkBackend for Litep2pNetworkBac type RequestResponseProtocolConfig = RequestResponseConfig; type NetworkService = Arc; type PeerStore = Peerstore; - type BitswapConfig = bitswap::BitswapConfig; fn new(mut params: Params) -> Result where Self: Sized, { - // Install the ring CryptoProvider for rustls before any TLS connections are made. - if let Err(err) = rustls::crypto::ring::default_provider().install_default() { - log::warn!( - target: LOG_TARGET, - "failed to install ring CryptoProvider for rustls, another provider might be installed: {err:?}", - ); - } - let (keypair, local_peer_id) = Self::get_keypair(¶ms.network_config.network_config.node_key)?; let (cmd_tx, cmd_rx) = tracing_unbounded("mpsc_network_worker", 100_000); @@ -541,9 +494,6 @@ impl NetworkBackend for Litep2pNetworkBac Some(Protocol::Ws(_) | Protocol::Wss(_) | Protocol::Tcp(_)) => { address.with(Protocol::P2p(peer.into())) }, - Some(Protocol::WebRTCDirect | Protocol::Certhash(_)) => { - address.with(Protocol::P2p(peer.into())) - }, Some(Protocol::P2p(_)) => address, _ => return acc, }; @@ -568,15 +518,22 @@ impl NetworkBackend for Litep2pNetworkBac Arc::clone(&peer_store_handle), ); - let bitswap_cmd_tx = params.ipfs_config.as_ref().map(|c| c.bitswap_config.cmd_tx.clone()); + let mut bitswap_user_handle: Option = None; + let mut bitswap_peer_event_tx: Option< + tokio::sync::mpsc::Sender, + > = None; - // enable Bitswap & IPFS DHT - if let Some(config) = params.ipfs_config { - config_builder = - config_builder.with_libp2p_bitswap(config.bitswap_config.litep2p_config); + if let Some(ipfs) = params.ipfs_config { + let wiring = ipfs.bitswap_wiring.expect( + "ipfs_config.bitswap_wiring must be Some when ipfs_server is enabled; \ + build_network constructs it via sc_network::bitswap::start; qed", + ); + config_builder = config_builder.with_libp2p_bitswap(wiring.litep2p_config); + bitswap_user_handle = Some(wiring.user_handle); + bitswap_peer_event_tx = Some(wiring.peer_event_tx); - if !config.bootnodes.is_empty() { - let (ipfs_dht, kad_config) = IpfsDht::new(config.bootnodes, config.block_provider); + if !ipfs.bootnodes.is_empty() { + let (ipfs_dht, kad_config) = IpfsDht::new(ipfs.bootnodes, ipfs.block_provider); config_builder = config_builder.with_libp2p_kademlia(kad_config); executor.run(Box::pin(ipfs_dht.run())); } else { @@ -635,7 +592,7 @@ impl NetworkBackend for Litep2pNetworkBac request_response_senders, Arc::clone(&listen_addresses), public_addresses, - bitswap_cmd_tx, + bitswap_user_handle, )); // register rest of the metrics now that `Litep2p` has been created @@ -660,6 +617,8 @@ impl NetworkBackend for Litep2pNetworkBac event_streams: out_events::OutChannels::new(None)?, peers: HashMap::new(), litep2p, + bitswap_peer_event_tx, + bitswap_peer_conn_count: HashMap::new(), }) } @@ -678,14 +637,6 @@ impl NetworkBackend for Litep2pNetworkBac NotificationMetrics::new(registry) } - /// Create Bitswap server. - fn bitswap_server( - client: Arc + Send + Sync>, - metrics_registry: Option, - ) -> (Pin + Send>>, Self::BitswapConfig) { - BitswapService::new(client, metrics_registry.as_ref()) - } - /// Create notification protocol configuration for `protocol`. fn notification_config( protocol_name: ProtocolName, @@ -1237,6 +1188,14 @@ impl NetworkBackend for Litep2pNetworkBac }, event = self.litep2p.next_event() => match event { Some(Litep2pEvent::ConnectionEstablished { peer, endpoint }) => { + if let Some(tx) = self.bitswap_peer_event_tx.as_ref() { + let count = self.bitswap_peer_conn_count.entry(peer).or_insert(0); + *count += 1; + if *count == 1 { + let _ = tx.try_send(crate::bitswap::PeerEvent::Connected { peer }); + } + } + let Some(metrics) = &self.metrics else { continue; }; @@ -1271,6 +1230,16 @@ impl NetworkBackend for Litep2pNetworkBac } } Some(Litep2pEvent::ConnectionClosed { peer, connection_id }) => { + if let Some(tx) = self.bitswap_peer_event_tx.as_ref() { + if let Some(count) = self.bitswap_peer_conn_count.get_mut(&peer) { + *count = count.saturating_sub(1); + if *count == 0 { + self.bitswap_peer_conn_count.remove(&peer); + let _ = tx.try_send(crate::bitswap::PeerEvent::Disconnected { peer }); + } + } + } + let Some(metrics) = &self.metrics else { continue; }; @@ -1352,49 +1321,3 @@ impl NetworkBackend for Litep2pNetworkBac } } } - -/// Load the encoded WebRTC DTLS certificate from `file`, or generate a new one, -/// persist it (0600), and return it. -/// -/// Returns `None` if any error occurred while reading, writing, or generating the certificate. -fn read_or_generate_webrtc_certificate(file: &std::path::Path) -> Option { - match inner_read_or_generate_webrtc_certificate(file) { - Ok(maybe_certificate) => maybe_certificate, - Err(err) => { - log::warn!(target: LOG_TARGET, "{err}"); - None - }, - } -} - -/// Inner helper that wraps errors with context, the caller logs them. -fn inner_read_or_generate_webrtc_certificate( - file: &std::path::Path, -) -> Result, String> { - match std::fs::read(file) { - Ok(bytes) => { - log::info!(target: LOG_TARGET, "WebRTC certificate found at {file:?}, using existing one"); - let (certificate, private_key) = Decode::decode(&mut bytes.as_slice()) - .map_err(|err| format!("Failed to decode WebRTC certificate: {err:?}"))?; - DtlsCertificate::load(certificate, private_key) - .map_err(|err| format!("Failed to load WebRTC certificate: {err:?}")) - .map(Some) - }, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - log::info!(target: LOG_TARGET, "No WebRTC certificate found at {file:?}, generating a new one"); - file.parent() - .map_or(Ok(()), fs::create_dir_all) - .map_err(|err| format!("Failed to create WebRTC certificate directory: {err:?}"))?; - let certificate = DtlsCertificate::new() - .map_err(|err| format!("Failed to generate WebRTC certificate: {err:?}"))?; - let certificate_bytes = certificate.as_parts().encode(); - crate::config::write_secret_file(file, &certificate_bytes).map_err(|err| { - format!("Failed to persist WebRTC certificate to {file:?}: {err:?}") - })?; - Ok(Some(certificate)) - }, - Err(err) => Err(format!( - "Failed to read WebRTC certificate at {file:?}: {err:?}, using an ephemeral one" - )), - } -} diff --git a/substrate/client/network/src/litep2p/service.rs b/substrate/client/network/src/litep2p/service.rs index cb5ecfb84e4b..e20a6609c5b8 100644 --- a/substrate/client/network/src/litep2p/service.rs +++ b/substrate/client/network/src/litep2p/service.rs @@ -19,7 +19,6 @@ //! `NetworkService` implementation for `litep2p`. use crate::{ - bitswap::schema::bitswap::message::wantlist::WantType as ProtoBitswapWantType, config::MultiaddrWithPeerId, litep2p::shim::{ notification::{config::ProtocolControlHandle, peerset::PeersetCommand}, @@ -38,7 +37,6 @@ use futures::{channel::oneshot, stream::BoxStream}; use libp2p::identity::SigningError; use litep2p::{ addresses::PublicAddresses, crypto::ed25519::Keypair, - protocol::libp2p::bitswap::WantType as LitepBitswapWantType, types::multiaddr::Multiaddr as LiteP2pMultiaddr, }; use parking_lot::RwLock; @@ -218,8 +216,8 @@ pub struct Litep2pNetworkService { /// External addresses. external_addresses: PublicAddresses, - /// Sender for outbound bitswap requests; `None` if IPFS/bitswap is not configured. - bitswap_cmd_tx: Option>, + /// User-facing Bitswap handle; `Some` when `--ipfs-server` is enabled, `None` otherwise. + bitswap_handle: Option, } impl Litep2pNetworkService { @@ -234,7 +232,7 @@ impl Litep2pNetworkService { request_response_protocols: HashMap>, listen_addresses: Arc>>, external_addresses: PublicAddresses, - bitswap_cmd_tx: Option>, + bitswap_handle: Option, ) -> Self { Self { local_peer_id, @@ -246,81 +244,12 @@ impl Litep2pNetworkService { request_response_protocols, listen_addresses, external_addresses, - bitswap_cmd_tx, + bitswap_handle, } } - /// Route an outbound request whose protocol name matches the bitswap protocol. - /// - /// Native bitswap is not registered as a generic request-response protocol, so this bridges - /// the `NetworkRequest` payload to the litep2p bitswap service. - fn route_bitswap_request( - &self, - peer: PeerId, - request_payload: Vec, - sender: oneshot::Sender, ProtocolName), RequestFailure>>, - ) { - use prost::Message as _; - - let Some(cmd_tx) = self.bitswap_cmd_tx.as_ref() else { - log::warn!( - target: LOG_TARGET, - "bitswap: received outbound request but BitswapService is not configured" - ); - let _ = sender.send(Err(RequestFailure::UnknownProtocol)); - return; - }; - - let msg = match crate::bitswap::BitswapProtoMessage::decode(request_payload.as_slice()) { - Ok(m) => m, - Err(e) => { - log::warn!(target: LOG_TARGET, "bitswap: failed to decode WANT payload: {e}"); - let _ = sender.send(Err(RequestFailure::InvalidRequest)); - return; - }, - }; - - let wantlist = match msg.wantlist { - Some(w) if !w.entries.is_empty() => w, - _ => { - log::warn!(target: LOG_TARGET, "bitswap: WANT message has no wantlist entries"); - let _ = sender.send(Err(RequestFailure::InvalidRequest)); - return; - }, - }; - - let mut cids = Vec::with_capacity(wantlist.entries.len()); - for entry in wantlist.entries { - let cid = match crate::bitswap::Cid::read_bytes(entry.block.as_slice()) { - Ok(c) => c, - Err(e) => { - log::warn!(target: LOG_TARGET, "bitswap: invalid CID in WANT entry: {e}"); - let _ = sender.send(Err(RequestFailure::InvalidRequest)); - return; - }, - }; - let want_type = if entry.want_type == ProtoBitswapWantType::Have as i32 { - LitepBitswapWantType::Have - } else { - LitepBitswapWantType::Block - }; - cids.push((cid, want_type)); - } - - let cmd = super::bitswap::BitswapOutboundCmd { - peer: peer.into(), - wants: cids, - response_tx: sender, - }; - - if let Err(e) = cmd_tx.try_send(cmd) { - log::warn!( - target: LOG_TARGET, - "bitswap cmd channel full or closed; dropping request for {peer:?}: {e}", - ); - let cmd = e.into_inner(); - let _ = cmd.response_tx.send(Err(RequestFailure::UnknownProtocol)); - } + pub(crate) fn bitswap_handle(&self) -> Option { + self.bitswap_handle.clone() } } @@ -645,11 +574,6 @@ impl NetworkRequest for Litep2pNetworkService { sender: oneshot::Sender, ProtocolName), RequestFailure>>, connect: IfDisconnected, ) { - if protocol.as_ref() == crate::bitswap::PROTOCOL_NAME { - self.route_bitswap_request(peer, request, sender); - return; - } - match self.request_response_protocols.get(&protocol) { Some(tx) => { let _ = tx.unbounded_send(OutboundRequest::new( @@ -667,3 +591,9 @@ impl NetworkRequest for Litep2pNetworkService { } } } + +impl crate::service::traits::BitswapProvider for Litep2pNetworkService { + fn bitswap_handle(&self) -> Option { + self.bitswap_handle() + } +} diff --git a/substrate/client/network/src/schema/bitswap.v1.2.0.proto b/substrate/client/network/src/schema/bitswap.v1.2.0.proto deleted file mode 100644 index a4138b516d63..000000000000 --- a/substrate/client/network/src/schema/bitswap.v1.2.0.proto +++ /dev/null @@ -1,43 +0,0 @@ -syntax = "proto3"; - -package bitswap.message; - -message Message { - message Wantlist { - enum WantType { - Block = 0; - Have = 1; - } - - message Entry { - bytes block = 1; // the block cid (cidV0 in bitswap 1.0.0, cidV1 in bitswap 1.1.0) - int32 priority = 2; // the priority (normalized). default to 1 - bool cancel = 3; // whether this revokes an entry - WantType wantType = 4; // Note: defaults to enum 0, ie Block - bool sendDontHave = 5; // Note: defaults to false - } - - repeated Entry entries = 1; // a list of wantlist entries - bool full = 2; // whether this is the full wantlist. default to false - } - - message Block { - bytes prefix = 1; // CID prefix (cid version, multicodec and multihash prefix (type + length) - bytes data = 2; - } - - enum BlockPresenceType { - Have = 0; - DontHave = 1; - } - message BlockPresence { - bytes cid = 1; - BlockPresenceType type = 2; - } - - Wantlist wantlist = 1; - repeated bytes blocks = 2; // used to send Blocks in bitswap 1.0.0 - repeated Block payload = 3; // used to send Blocks in bitswap 1.1.0 - repeated BlockPresence blockPresences = 4; - int32 pendingBytes = 5; -} diff --git a/substrate/client/network/src/service.rs b/substrate/client/network/src/service.rs index a1ee4bfd817e..ecf8db7ad712 100644 --- a/substrate/client/network/src/service.rs +++ b/substrate/client/network/src/service.rs @@ -29,7 +29,6 @@ use crate::{ behaviour::{self, Behaviour, BehaviourOut}, - bitswap::BitswapRequestHandler, config::{ parse_addr, FullNetworkConfiguration, IncomingRequest, MultiaddrWithPeerId, NonDefaultSetConfig, NotificationHandshake, Params, SetConfig, TransportConfig, @@ -78,7 +77,6 @@ use parking_lot::Mutex; use prometheus_endpoint::Registry; use sc_network_types::kad::{Key as KademliaKey, Record}; -use sc_client_api::BlockBackend; use sc_network_common::{ role::{ObservedRole, Roles}, ExHashT, @@ -171,7 +169,6 @@ where type RequestResponseProtocolConfig = RequestResponseConfig; type NetworkService = Arc>; type PeerStore = PeerStore; - type BitswapConfig = RequestResponseConfig; fn new(params: Params) -> Result where @@ -197,15 +194,6 @@ where NotificationMetrics::new(registry) } - fn bitswap_server( - client: Arc + Send + Sync>, - _metrics_registry: Option, - ) -> (Pin + Send>>, Self::BitswapConfig) { - let (handler, protocol_config) = BitswapRequestHandler::new(client.clone()); - - (Box::pin(async move { handler.run().await }), protocol_config) - } - /// Create notification protocol configuration. fn notification_config( protocol_name: ProtocolName, @@ -1240,6 +1228,13 @@ where } } +impl crate::service::traits::BitswapProvider for NetworkService +where + B: BlockT + 'static, + H: ExHashT, +{ +} + /// A `NotificationSender` allows for sending notifications to a peer with a chosen protocol. #[must_use] pub struct NotificationSender { diff --git a/substrate/client/network/src/service/traits.rs b/substrate/client/network/src/service/traits.rs index e0df3c012244..1ff44fa1d02a 100644 --- a/substrate/client/network/src/service/traits.rs +++ b/substrate/client/network/src/service/traits.rs @@ -34,7 +34,6 @@ use crate::{ use futures::{channel::oneshot, Stream}; use prometheus_endpoint::Registry; -use sc_client_api::BlockBackend; use sc_network_common::{role::ObservedRole, ExHashT}; pub use sc_network_types::{ kad::{Key as KademliaKey, Record}, @@ -54,6 +53,27 @@ use std::{ pub use libp2p::identity::SigningError; +/// Access to the user-facing Bitswap handle. +/// +/// Returns `Some` when the node is running on a backend that supports Bitswap (litep2p) AND +/// `--ipfs-server` is enabled, otherwise `None`. Implemented as a separate provider trait so +/// it can be supplied a default `None` impl for backends that do not support Bitswap. +pub trait BitswapProvider { + /// Returns the user-facing Bitswap handle, or `None` if Bitswap is not available. + fn bitswap_handle(&self) -> Option { + None + } +} + +impl BitswapProvider for Arc +where + T: BitswapProvider + ?Sized, +{ + fn bitswap_handle(&self) -> Option { + T::bitswap_handle(self) + } +} + /// Supertrait defining the services provided by [`NetworkBackend`] service handle. pub trait NetworkService: NetworkSigner @@ -63,6 +83,7 @@ pub trait NetworkService: + NetworkEventStream + NetworkStateInfo + NetworkRequest + + BitswapProvider + Send + Sync + 'static @@ -77,6 +98,7 @@ impl NetworkService for T where + NetworkEventStream + NetworkStateInfo + NetworkRequest + + BitswapProvider + Send + Sync + 'static @@ -126,9 +148,6 @@ pub trait NetworkBackend: Send + 'static { /// Type implementing [`PeerStore`]. type PeerStore: PeerStore; - /// Bitswap config. - type BitswapConfig; - /// Create new `NetworkBackend`. fn new(params: Params) -> Result where @@ -143,12 +162,6 @@ pub trait NetworkBackend: Send + 'static { /// Register metrics that are used by the notification protocols. fn register_notification_metrics(registry: Option<&Registry>) -> NotificationMetrics; - /// Create Bitswap server. - fn bitswap_server( - client: Arc + Send + Sync>, - metrics_registry: Option, - ) -> (Pin + Send>>, Self::BitswapConfig); - /// Create notification protocol configuration and an associated `NotificationService` /// for the protocol. fn notification_config( diff --git a/substrate/client/service/src/builder.rs b/substrate/client/service/src/builder.rs index 265091801be3..9d43e20d6b2f 100644 --- a/substrate/client/service/src/builder.rs +++ b/substrate/client/service/src/builder.rs @@ -558,11 +558,7 @@ where spawn_handle.spawn( "txpool-notifications", Some("transaction-pool"), - sc_transaction_pool::notification_future( - client.clone(), - transaction_pool.clone(), - config.transaction_pool.use_all_block_notifications(), - ), + sc_transaction_pool::notification_future(client.clone(), transaction_pool.clone()), ); spawn_handle.spawn( @@ -1234,23 +1230,39 @@ where // install request handlers to `FullNetworkConfiguration` net_config.add_request_response_protocol(light_client_request_protocol_config); - // Initialize IPFS server. - let ipfs_config = net_config.network_config.ipfs_server.then(|| { - let (handler, bitswap_config) = - Net::bitswap_server(client.clone(), metrics_registry.cloned()); - spawn_handle.spawn("bitswap-request-handler", Some("networking"), handler); + // Initialize IPFS server. Bitswap is only supported on the litep2p backend; reject the + // libp2p + `--ipfs-server` combination loudly rather than silently disabling Bitswap. + let ipfs_config = if net_config.network_config.ipfs_server { + if matches!( + net_config.network_config.network_backend, + sc_network::config::NetworkBackendType::Libp2p + ) { + return Err(Error::Other( + "Bitswap requires the litep2p network backend; \ + set --network-backend litep2p or disable --ipfs-server" + .into(), + )); + } + + let (handler, bitswap_wiring) = sc_network::bitswap::start::( + client.clone(), + sc_network::bitswap::BitswapServiceConfig::default(), + ); + spawn_handle.spawn("bitswap-service", Some("networking"), handler); let ipfs_num_blocks = match blocks_pruning { BlocksPruning::KeepAll | BlocksPruning::KeepFinalized => IPFS_MAX_BLOCKS, BlocksPruning::Some(num) => std::cmp::min(num, IPFS_MAX_BLOCKS), }; - IpfsConfig { - bitswap_config, + Some(IpfsConfig { + bitswap_wiring: Some(bitswap_wiring), block_provider: Box::new(IpfsIndexedTransactions::new(client.clone(), ipfs_num_blocks)), bootnodes: net_config.network_config.ipfs_bootnodes.clone(), - } - }); + }) + } else { + None + }; // Create transactions protocol and add it to the list of supported protocols of let (transactions_handler_proto, transactions_config) = From d3814a80b85cfe905d29754dba624b6150361875 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Mon, 8 Jun 2026 10:47:45 +0200 Subject: [PATCH 02/32] sc-storage-chain-sync: adapt to new bitswap BitswapHandle API The bitswap client refactor in the previous commit moved peer selection, per-peer timeouts, retries and hash verification into the bitswap service actor. This commit migrates the only in-tree consumer of the deleted `request_bitswap_blocks` helper. IndexedTransactionFetcher is now a thin adapter over BitswapHandle. It builds per-want CIDs, chunks by MAX_CIDS_PER_REQUEST, submits all chunks concurrently via request_stream, and drains the resulting per-chunk streams. Peer rotation and per-peer timeouts no longer live in this crate. Public surface changes on sc-storage-chain-sync: - removed: BitswapPeerSource, NetworkHandle, SyncingHandle - added: BitswapClient, BitswapHandleSlot The omni-node wiring in polkadot-omni-node-lib is updated accordingly: PartialComponents::other now carries a single BitswapHandleSlot instead of (NetworkHandle, SyncingHandle), and start_node populates it via NetworkService::bitswap_handle() (None when --ipfs-server is disabled or the libp2p backend is in use). The integration tests are rewritten to mock at the BitswapHandle boundary via a MockBitswap implementing BitswapClient. This removes the dependency on prost and the sc-network test-helpers feature for fabricating raw bitswap protobuf messages. --- Cargo.lock | 3 - .../polkadot-omni-node/lib/src/common/spec.rs | 24 +- .../lib/src/common/types.rs | 3 +- .../polkadot-omni-node/lib/src/nodes/aura.rs | 2 +- prdoc/pr_12243.prdoc | 30 +++ .../client/storage-chain-sync/Cargo.toml | 6 +- .../client/storage-chain-sync/src/fetcher.rs | 236 ++++++++---------- .../client/storage-chain-sync/src/lib.rs | 2 +- .../client/storage-chain-sync/tests/it.rs | 132 +++------- 9 files changed, 187 insertions(+), 251 deletions(-) create mode 100644 prdoc/pr_12243.prdoc diff --git a/Cargo.lock b/Cargo.lock index a4cbcf70e5d2..f34966eede0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22160,17 +22160,14 @@ dependencies = [ "async-trait", "cid", "futures", - "futures-timer", "log", "parity-scale-codec", - "prost 0.12.6", "rand 0.8.5", "rstest", "sc-client-api 28.0.0", "sc-client-db", "sc-consensus", "sc-network 0.34.0", - "sc-network-sync", "sp-api 26.0.0", "sp-blockchain 28.0.0", "sp-consensus 0.32.0", diff --git a/cumulus/polkadot-omni-node/lib/src/common/spec.rs b/cumulus/polkadot-omni-node/lib/src/common/spec.rs index 538fa9a48920..cd03d14ead20 100644 --- a/cumulus/polkadot-omni-node/lib/src/common/spec.rs +++ b/cumulus/polkadot-omni-node/lib/src/common/spec.rs @@ -51,8 +51,9 @@ use sc_network::{ }; use sc_service::{Configuration, ImportQueue, PartialComponents, TaskManager}; use sc_statement_store::Store; +use sc_network::bitswap::BitswapRequest; use sc_storage_chain_sync::{ - IndexedTransactionFetcher, NetworkHandle, StorageChainBlockImport, SyncingHandle, + BitswapHandleSlot, IndexedTransactionFetcher, StorageChainBlockImport, }; use sc_sysinfo::HwBench; use sc_telemetry::{TelemetryHandle, TelemetryWorker}; @@ -298,13 +299,9 @@ pub(crate) trait BaseNodeSpec { .build(), ); - let network_handle: NetworkHandle = Arc::new(OnceLock::new()); - let syncing_handle: SyncingHandle = Arc::new(OnceLock::new()); + let bitswap_slot: BitswapHandleSlot = Arc::new(OnceLock::new()); - let fetcher = IndexedTransactionFetcher::new( - Arc::clone(&network_handle), - Arc::clone(&syncing_handle), - ); + let fetcher = IndexedTransactionFetcher::new(Arc::clone(&bitswap_slot)); let storage_chain_block_import = StorageChainBlockImport::new(client.clone(), client.clone(), fetcher); @@ -335,8 +332,7 @@ pub(crate) trait BaseNodeSpec { telemetry, telemetry_worker_handle, block_import_auxiliary_data, - network_handle, - syncing_handle, + bitswap_slot, ), }) } @@ -404,8 +400,7 @@ pub(crate) trait NodeSpec: BaseNodeSpec { mut telemetry, telemetry_worker_handle, block_import_auxiliary_data, - network_handle, - syncing_handle, + bitswap_slot, ) = params.other; let client = params.client.clone(); let backend = params.backend.clone(); @@ -466,10 +461,9 @@ pub(crate) trait NodeSpec: BaseNodeSpec { }) .await?; - let _ = network_handle - .set(network.clone() as Arc); - let _ = syncing_handle.set(sync_service.clone() - as Arc); + if let Some(handle) = sc_network::BitswapProvider::bitswap_handle(&*network) { + let _ = bitswap_slot.set(Arc::new(handle) as Arc); + } let peer_id = relay_chain_network.local_peer_id(); diff --git a/cumulus/polkadot-omni-node/lib/src/common/types.rs b/cumulus/polkadot-omni-node/lib/src/common/types.rs index 62b2e2898407..b90e935fe59f 100644 --- a/cumulus/polkadot-omni-node/lib/src/common/types.rs +++ b/cumulus/polkadot-omni-node/lib/src/common/types.rs @@ -60,7 +60,6 @@ pub type ParachainService = PartialCo Option, Option, BIExtraReturnValue, - sc_storage_chain_sync::NetworkHandle, - sc_storage_chain_sync::SyncingHandle, + sc_storage_chain_sync::BitswapHandleSlot, ), >; diff --git a/cumulus/polkadot-omni-node/lib/src/nodes/aura.rs b/cumulus/polkadot-omni-node/lib/src/nodes/aura.rs index 536c1a995347..a33b05074414 100644 --- a/cumulus/polkadot-omni-node/lib/src/nodes/aura.rs +++ b/cumulus/polkadot-omni-node/lib/src/nodes/aura.rs @@ -252,7 +252,7 @@ where keystore_container, select_chain: _, transaction_pool, - other: (_, mut telemetry, _, _, _, _), + other: (_, mut telemetry, _, _, _), } = Self::new_partial(&config)?; // Since this is a dev node, prevent it from connecting to peers. diff --git a/prdoc/pr_12243.prdoc b/prdoc/pr_12243.prdoc new file mode 100644 index 000000000000..954327d0b45f --- /dev/null +++ b/prdoc/pr_12243.prdoc @@ -0,0 +1,30 @@ +title: "Storage-chain sync: migrate to the new bitswap `BitswapHandle` API" + +doc: + - audience: Node Dev + description: | + Adapts `sc-storage-chain-sync` and the omni-node wiring to the redesigned + bitswap client API introduced in PR #12052. + + The `IndexedTransactionFetcher` no longer rotates across peers, applies its + own per-peer timeouts, or queries `SyncingService::peers_info()`. All of + that has moved into the bitswap service actor. The fetcher is now a thin + adapter that builds CIDs, chunks the wantlist by `MAX_CIDS_PER_REQUEST`, + submits all chunks concurrently via `BitswapHandle::request_stream`, and + drains the resulting per-chunk streams. + + `BitswapPeerSource`, `NetworkHandle` and `SyncingHandle` are removed. + They are replaced by a single `BitswapHandleSlot` and a crate-internal + `BitswapClient` trait so the integration tests can mock at the bitswap + handle boundary without depending on the bitswap protobuf schema. + + The omni-node wiring in `polkadot-omni-node-lib` is updated accordingly: + the partial-components tuple carries one bitswap-handle slot instead of + two handles, and `start_node` populates the slot via + `NetworkService::bitswap_handle()`. + +crates: + - name: sc-storage-chain-sync + bump: major + - name: polkadot-omni-node-lib + bump: patch diff --git a/substrate/client/storage-chain-sync/Cargo.toml b/substrate/client/storage-chain-sync/Cargo.toml index 291186d4082a..a3db9bd7d1de 100644 --- a/substrate/client/storage-chain-sync/Cargo.toml +++ b/substrate/client/storage-chain-sync/Cargo.toml @@ -16,14 +16,12 @@ async-trait = { workspace = true } cid = { workspace = true } codec = { workspace = true, default-features = true } futures = { workspace = true } -futures-timer = { workspace = true } log = { workspace = true, default-features = true } rand = { workspace = true, default-features = true } sc-client-api = { workspace = true, default-features = true } sc-client-db = { workspace = true, default-features = true } sc-consensus = { workspace = true, default-features = true } sc-network = { workspace = true, default-features = true } -sc-network-sync = { workspace = true, default-features = true } sp-api = { workspace = true, default-features = true } sp-blockchain = { workspace = true, default-features = true } sp-consensus = { workspace = true, default-features = true } @@ -34,14 +32,14 @@ sp-state-machine = { workspace = true, default-features = true } sp-transaction-storage-proof = { workspace = true, default-features = true } sp-trie = { workspace = true, default-features = true } thiserror = { workspace = true } +tokio = { workspace = true, default-features = true, features = ["sync"] } [dev-dependencies] -prost = { workspace = true } rstest = { workspace = true } sp-crypto-hashing = { workspace = true, default-features = true } sp-tracing = { workspace = true, default-features = true } sp-version = { workspace = true, default-features = true } -tokio = { workspace = true, default-features = true, features = ["macros", "rt"] } +tokio = { workspace = true, default-features = true, features = ["macros", "rt", "sync"] } tracing = { workspace = true, default-features = true } tracing-log = { workspace = true, default-features = true } tracing-subscriber = { workspace = true, default-features = true } diff --git a/substrate/client/storage-chain-sync/src/fetcher.rs b/substrate/client/storage-chain-sync/src/fetcher.rs index fc4f06cf8c29..a441bc1285cf 100644 --- a/substrate/client/storage-chain-sync/src/fetcher.rs +++ b/substrate/client/storage-chain-sync/src/fetcher.rs @@ -17,88 +17,86 @@ // along with this program. If not, see . //! Bitswap-based fetcher for indexed-transaction blobs. +//! +//! Thin adapter over [`sc_network::bitswap::BitswapHandle`]. Peer selection, +//! per-peer timeouts, retries and hash verification all live in the bitswap actor +//! itself. This fetcher's only jobs are: +//! +//! 1. building the per-want CIDs from the runtime-declared [`RenewWant`]s, +//! 2. chunking by [`sc_network::bitswap::MAX_CIDS_PER_REQUEST`] and submitting all +//! chunks concurrently, +//! 3. draining the per-chunk streams into a `HashMap>`. use crate::RenewWant; -use async_trait::async_trait; use cid::{multihash::Multihash, Cid}; -use futures::channel::oneshot; -use rand::seq::SliceRandom; -use sc_network::{ - bitswap::{request_bitswap_blocks, FetchOutcome, MAX_WANTED_BLOCKS}, - NetworkRequest, PeerId, +use futures::{future, stream::FuturesUnordered, StreamExt}; +use sc_network::bitswap::{ + BitswapError, BitswapRequest, FetchOutcome, MAX_CIDS_PER_REQUEST, }; -use sc_network_sync::SyncingService; use sp_runtime::traits::Block as BlockT; use sp_transaction_storage_proof::ContentHash; use std::{ collections::HashMap, sync::{Arc, OnceLock}, - time::Duration, }; const LOG_TARGET: &str = "storage-chain-fetcher"; -const BITSWAP_PER_PEER_TIMEOUT: Duration = Duration::from_secs(5); -const MAX_PEERS_PER_IMPORT: usize = 8; -/// Source of currently-connected sync peer IDs. -#[async_trait] -pub trait BitswapPeerSource: Send + Sync { - async fn current_peers(&self) -> Result, oneshot::Canceled>; -} +/// Late-bound bitswap handle slot, populated by the omni-node after `build_network`. +/// +/// The slot exists because [`crate::StorageChainBlockImport`] is constructed before +/// `build_network` runs (the block import is consumed when building the import queue, +/// which is in turn consumed by `build_network`). The handle becomes available only +/// once the network service has been built; the `OnceLock` carries it across that +/// boundary without changing the block-import's public API. +pub type BitswapHandleSlot = Arc>>; -#[async_trait] -impl BitswapPeerSource for SyncingService { - async fn current_peers(&self) -> Result, oneshot::Canceled> { - Ok(self - .peers_info() - .await? - .into_iter() - .filter_map(|(peer, info)| info.roles.is_full().then_some(peer)) - .collect()) - } -} - -/// Late-bound network request handle, populated once the network is built. -pub type NetworkHandle = Arc>>; -/// Late-bound peer-source handle, populated once the network is built. -pub type SyncingHandle = Arc>>; - -/// Infrastructure-level fetch failure. +/// Infrastructure-level fetch failure surfaced to [`crate::StorageChainBlockImport`]. #[derive(Debug, thiserror::Error)] pub enum FetchError { - #[error("network handle not yet set; storage-chain blocks cannot be fetched before build_network completes")] - NetworkHandleUnset, - #[error("sync handle not yet set; storage-chain blocks cannot be fetched before build_network completes")] - SyncingHandleUnset, + /// The bitswap handle has not been set yet (called before `build_network` finished) + /// or bitswap is not configured (`--ipfs-server` not enabled, or libp2p backend in use). + #[error("bitswap handle not yet set; storage-chain blocks cannot be fetched before build_network completes")] + BitswapHandleUnset, + /// CID construction failed for the given (hashing, hash) pair. Bug indicator. #[error("failed to construct multihash for CID: {0}")] Multihash(String), + /// The bitswap service rejected the request at admission, or shut down mid-stream. + #[error("bitswap service error: {0}")] + Bitswap(#[from] BitswapError), } /// Fetcher that resolves indexed-transaction hashes via bitswap. +/// +/// Holds the late-bound [`BitswapRequest`] slot. The block-import path holds one +/// of these and calls [`Self::fetch_many`] for each batch of missing renew hashes. +/// +/// Cloning is cheap: the only field is an `Arc`. pub struct IndexedTransactionFetcher { - network: NetworkHandle, - peer_source: SyncingHandle, + bitswap: BitswapHandleSlot, _phantom: std::marker::PhantomData, } impl Clone for IndexedTransactionFetcher { fn clone(&self) -> Self { - Self { - network: self.network.clone(), - peer_source: self.peer_source.clone(), - _phantom: std::marker::PhantomData, - } + Self { bitswap: self.bitswap.clone(), _phantom: std::marker::PhantomData } } } impl IndexedTransactionFetcher { - /// Build a new fetcher backed by the given late-bound handles. - pub fn new(network: NetworkHandle, peer_source: SyncingHandle) -> Self { - Self { network, peer_source, _phantom: std::marker::PhantomData } + /// Build a new fetcher backed by the given late-bound bitswap handle slot. + pub fn new(bitswap: BitswapHandleSlot) -> Self { + Self { bitswap, _phantom: std::marker::PhantomData } } - /// Resolve a batch of indexed-transaction renew wants via bitswap, rotating across up to - /// `MAX_PEERS_PER_IMPORT` peers. Returns only successfully fetched entries. + /// Resolve a batch of indexed-transaction hashes via bitswap. + /// + /// Each want carries the runtime-declared `cid_codec` so the request CID's + /// codec matches what the producing runtime announced. + /// + /// Returns only successfully fetched entries. A short result means the caller + /// (the block import) will surface a `ConsensusError` and the import will be + /// retried later. pub(crate) async fn fetch_many( &self, wants: &[RenewWant], @@ -106,99 +104,71 @@ impl IndexedTransactionFetcher { if wants.is_empty() { return Ok(HashMap::new()); } - let network = self.network.get().ok_or(FetchError::NetworkHandleUnset)?; - let peer_source = self.peer_source.get().ok_or(FetchError::SyncingHandleUnset)?; - - let Ok(mut peers) = peer_source.current_peers().await else { - log::warn!(target: LOG_TARGET, "current_peers() channel cancelled"); - return Ok(HashMap::new()); - }; - if peers.is_empty() { - log::debug!( - target: LOG_TARGET, - "no connected sync peers, cannot fetch via bitswap yet", - ); - return Ok(HashMap::new()); + let handle = self.bitswap.get().ok_or(FetchError::BitswapHandleUnset)?; + + let mut by_cid: HashMap = HashMap::with_capacity(wants.len()); + let mut cids: Vec = Vec::with_capacity(wants.len()); + for want in wants { + let mh = Multihash::<64>::wrap(want.hashing.multihash_code(), &want.hash) + .map_err(|e| FetchError::Multihash(e.to_string()))?; + let cid = Cid::new_v1(want.cid_codec, mh); + by_cid.insert(cid, want.hash); + cids.push(cid); } - // Shuffle peers to not end up with always the same peers. - peers.shuffle(&mut rand::thread_rng()); - - // Build per-want CIDs once; reuse across peers and chunks. - let cids: Vec<(ContentHash, Cid)> = wants - .iter() - .map(|w| { - let mh = Multihash::<64>::wrap(w.hashing.multihash_code(), &w.hash) - .map_err(|e| FetchError::Multihash(e.to_string()))?; - Ok::<_, FetchError>((w.hash, Cid::new_v1(w.cid_codec, mh))) - }) - .collect::>()?; - let mut remaining = cids; - let mut acquired: HashMap> = HashMap::new(); - for peer in peers.into_iter().take(MAX_PEERS_PER_IMPORT) { - if remaining.is_empty() { - break; - } - let from_peer = try_fetch_from_peer(network.as_ref(), peer, &remaining).await; - acquired.extend(from_peer); - remaining.retain(|(hash, _)| !acquired.contains_key(hash)); - } + let chunks: Vec> = cids + .chunks(MAX_CIDS_PER_REQUEST) + .map(<[Cid]>::to_vec) + .collect(); - Ok(acquired) - } -} + let receivers = future::try_join_all( + chunks.into_iter().map(|chunk| handle.request_stream(chunk)), + ) + .await?; -/// Try every chunk of `wants` against a single peer in sequence. Returns whatever blocks the -/// peer actually served. A timeout or per-chunk error aborts the remaining chunks for this peer -/// and lets the caller move on to the next one. -async fn try_fetch_from_peer( - network: &N, - peer: PeerId, - wants: &[(ContentHash, Cid)], -) -> HashMap> { - let mut acquired: HashMap> = HashMap::new(); - for chunk in wants.chunks(MAX_WANTED_BLOCKS) { - let cids: Vec = chunk.iter().map(|(_, cid)| *cid).collect(); - match with_timeout(request_bitswap_blocks(network, peer, &cids), BITSWAP_PER_PEER_TIMEOUT) - .await - { - None => { - log::debug!( - target: LOG_TARGET, - "request_bitswap_blocks to {peer:?}: timeout (chunk size {})", - chunk.len(), - ); - return acquired; - }, - Some(Err(e)) => { - log::debug!(target: LOG_TARGET, "request_bitswap_blocks to {peer:?}: {e:?}"); - return acquired; - }, - Some(Ok(per_cid)) => { - for (hash, cid) in chunk { - if let Some(FetchOutcome::Block(data)) = per_cid.get(cid) { + let mut acquired: HashMap> = HashMap::with_capacity(wants.len()); + let mut streams: FuturesUnordered<_> = receivers + .into_iter() + .map(|mut rx| async move { + let mut out = Vec::new(); + while let Some(item) = rx.recv().await { + out.push(item); + } + out + }) + .collect(); + + while let Some(items) = streams.next().await { + for item in items { + match item { + Ok((cid, FetchOutcome::Block(bytes))) => { + if let Some(hash) = by_cid.get(&cid) { + log::debug!( + target: LOG_TARGET, + "bitswap fetched {} bytes for {hash:?}", + bytes.len(), + ); + acquired.insert(*hash, bytes); + } + }, + Ok((cid, FetchOutcome::Missing)) => { log::debug!( target: LOG_TARGET, - "fetched {} bytes for {:?} from {peer:?}", - data.len(), - hash, + "bitswap returned Missing for {cid}", ); - acquired.insert(*hash, data.clone()); - } + }, + Err(BitswapError::ServiceClosed) => { + log::warn!( + target: LOG_TARGET, + "bitswap service closed mid-stream; returning partial result", + ); + return Ok(acquired); + }, + Err(other) => return Err(FetchError::Bitswap(other)), } - }, + } } - } - acquired -} -async fn with_timeout(fut: F, timeout: Duration) -> Option -where - F: std::future::Future, -{ - use futures::FutureExt; - futures::select! { - v = fut.fuse() => Some(v), - _ = futures_timer::Delay::new(timeout).fuse() => None, + Ok(acquired) } } diff --git a/substrate/client/storage-chain-sync/src/lib.rs b/substrate/client/storage-chain-sync/src/lib.rs index 428972d2183d..cc38a779bfc9 100644 --- a/substrate/client/storage-chain-sync/src/lib.rs +++ b/substrate/client/storage-chain-sync/src/lib.rs @@ -34,7 +34,7 @@ mod fetcher; pub(crate) use fetcher::FetchError; -pub use fetcher::{BitswapPeerSource, IndexedTransactionFetcher, NetworkHandle, SyncingHandle}; +pub use fetcher::{BitswapHandleSlot, IndexedTransactionFetcher}; use codec::Encode; use sc_client_api::{BlockBackend, PrefetchedIndexedTransactions}; diff --git a/substrate/client/storage-chain-sync/tests/it.rs b/substrate/client/storage-chain-sync/tests/it.rs index 565f78b4dd66..1371169e9178 100644 --- a/substrate/client/storage-chain-sync/tests/it.rs +++ b/substrate/client/storage-chain-sync/tests/it.rs @@ -523,23 +523,17 @@ async fn import_gap_sync_without_body_passes_through() { mod mock { use async_trait::async_trait; - use cid::{Cid, Version as CidVersion}; use codec::{Decode, Encode}; - use futures::channel::oneshot; use sc_storage_chain_sync::{ - BitswapPeerSource, IndexedTransactionFetcher, NetworkHandle, StorageChainBlockImport, - SyncingHandle, + BitswapHandleSlot, IndexedTransactionFetcher, StorageChainBlockImport, }; use sc_consensus::{ BlockCheckParams, BlockImport, BlockImportParams, ImportResult, ImportedAux, StateAction, StorageChanges as ConsensusStorageChanges, }; - use sc_network::{ - bitswap::{schema::bitswap as bitswap_schema, RAW_CODEC}, - request_responses::{IfDisconnected, RequestFailure}, - types::ProtocolName, - NetworkRequest, PeerId, + use sc_network::bitswap::{ + BitswapError, BitswapRequest, Cid as BitswapCid, FetchItem, FetchOutcome, RAW_CODEC, }; use sp_api::{ApiError, ConstructRuntimeApi}; use sp_consensus::{BlockOrigin, Error as ConsensusError}; @@ -555,6 +549,7 @@ mod mock { collections::HashMap, sync::{Arc, Mutex, OnceLock}, }; + use tokio::sync::mpsc; pub(super) type TestBlock = generic::Block, OpaqueExtrinsic>; type TestHeader = generic::Header; @@ -811,14 +806,20 @@ mod mock { } } + /// In-memory mock of [`BitswapRequest`] for the integration tests. + /// + /// Stores a `ContentHash -> Vec` map. On `request_stream`, returns a receiver + /// pre-loaded with one [`FetchOutcome::Block`] per known CID and one + /// [`FetchOutcome::Missing`] per unknown CID. Records every observed CID and counts + /// every call for assertions. #[derive(Default)] - pub(super) struct MockNetworkRequest { + pub(super) struct MockBitswap { responses: Mutex>>, call_count: Mutex, - observed_cids: Mutex>, + observed_cids: Mutex>, } - impl MockNetworkRequest { + impl MockBitswap { pub(super) fn insert(&self, hash: ContentHash, data: Vec) { self.responses.lock().unwrap().insert(hash, data); } @@ -827,80 +828,38 @@ mod mock { *self.call_count.lock().unwrap() } - pub(super) fn observed_cids(&self) -> Vec { + pub(super) fn observed_cids(&self) -> Vec { self.observed_cids.lock().unwrap().clone() } } #[async_trait] - impl NetworkRequest for MockNetworkRequest { - async fn request( + impl BitswapRequest for MockBitswap { + async fn request_stream( &self, - _target: PeerId, - _protocol: ProtocolName, - request: Vec, - _fallback_request: Option<(Vec, ProtocolName)>, - _connect: IfDisconnected, - ) -> Result<(Vec, ProtocolName), RequestFailure> { - use prost::Message as _; + cids: Vec, + ) -> Result, BitswapError> { *self.call_count.lock().unwrap() += 1; - let message = bitswap_schema::Message::decode(&*request) - .expect("MockNetworkRequest received malformed bitswap request"); + let (tx, rx) = mpsc::channel(cids.len().max(1)); let responses = self.responses.lock().unwrap(); - let mut payload = Vec::new(); - let mut block_presences = Vec::new(); - for entry in message.wantlist.unwrap_or_default().entries { - let Ok(cid) = Cid::read_bytes(entry.block.as_slice()) else { continue }; - self.observed_cids.lock().unwrap().push(cid); + let mut observed = self.observed_cids.lock().unwrap(); + for cid in cids { + observed.push(cid); let digest: Option = cid.hash().digest().try_into().ok(); - match digest.and_then(|d| responses.get(&d).cloned()) { - Some(data) => payload.push(bitswap_schema::message::Block { - prefix: prefix_mirroring_request(&cid), - data, - }), - None => block_presences.push(bitswap_schema::message::BlockPresence { - cid: entry.block, - r#type: bitswap_schema::message::BlockPresenceType::DontHave as i32, - }), - } + let outcome = match digest.and_then(|d| responses.get(&d).cloned()) { + Some(bytes) => FetchOutcome::Block(bytes), + None => FetchOutcome::Missing, + }; + tx.try_send(Ok((cid, outcome))).expect("channel sized for cids.len()"); } - let response = - bitswap_schema::Message { payload, block_presences, ..Default::default() }; - Ok((response.encode_to_vec(), ProtocolName::from("/ipfs/bitswap/1.2.0"))) + Ok(rx) } - - fn start_request( - &self, - _target: PeerId, - _protocol: ProtocolName, - _request: Vec, - _fallback_request: Option<(Vec, ProtocolName)>, - _tx: oneshot::Sender, ProtocolName), RequestFailure>>, - _connect: IfDisconnected, - ) { - unreachable!("the bitswap client uses async request(), never start_request()") - } - } - - fn prefix_mirroring_request(cid: &Cid) -> Vec { - sc_network::bitswap::Prefix { - version: CidVersion::V1, - codec: cid.codec(), - mh_type: cid.hash().code(), - mh_len: 32, - } - .to_bytes() } - struct MockBitswapPeerSource { - peers: Vec, - } - - #[async_trait] - impl BitswapPeerSource for MockBitswapPeerSource { - async fn current_peers(&self) -> Result, oneshot::Canceled> { - Ok(self.peers.clone()) - } + fn populated_bitswap_slot(mock: Arc) -> BitswapHandleSlot { + let slot: BitswapHandleSlot = Arc::new(OnceLock::new()); + let _ = slot.set(mock as Arc); + slot } #[allow(dead_code)] @@ -1167,23 +1126,18 @@ mod mock { pub(super) wrapper: StorageChainBlockImport, pub(super) api: Arc, pub(super) captured: Arc>>>, - pub(super) network: Arc, + pub(super) network: Arc, } pub(super) fn make_harness() -> Harness { let api = Arc::new(MockApiClient::default()); - let network: Arc = Arc::new(MockNetworkRequest::default()); + let network: Arc = Arc::new(MockBitswap::default()); let inner = TestInner::recording(); let captured = inner.captured.clone(); - let network_handle: NetworkHandle = Arc::new(OnceLock::new()); - let syncing_handle: SyncingHandle = Arc::new(OnceLock::new()); - let _ = network_handle.set(network.clone() as Arc); - let _ = syncing_handle - .set(Arc::new(MockBitswapPeerSource { peers: vec![PeerId::random()] }) - as Arc); - - let fetcher = IndexedTransactionFetcher::::new(network_handle, syncing_handle); + let fetcher = IndexedTransactionFetcher::::new(populated_bitswap_slot( + network.clone(), + )); let wrapper = StorageChainBlockImport::new(inner, api.clone(), fetcher); Harness { wrapper, api, captured, network } @@ -1199,19 +1153,13 @@ mod mock { pub(super) fn make_block_execution_harness(data: Vec) -> BlockExecutionHarness { let content_hash = sp_transaction_storage_proof::HashingAlgorithm::Blake2b256.hash(&data); let api = Arc::new(BlockExecutionClient::new(content_hash, data.clone())); - let network: Arc = Arc::new(MockNetworkRequest::default()); + let network: Arc = Arc::new(MockBitswap::default()); network.insert(content_hash, data); let inner = TestInner::recording(); let captured = inner.captured.clone(); - let network_handle: NetworkHandle = Arc::new(OnceLock::new()); - let syncing_handle: SyncingHandle = Arc::new(OnceLock::new()); - let _ = network_handle.set(network as Arc); - let _ = syncing_handle - .set(Arc::new(MockBitswapPeerSource { peers: vec![PeerId::random()] }) - as Arc); - - let fetcher = IndexedTransactionFetcher::::new(network_handle, syncing_handle); + let fetcher = + IndexedTransactionFetcher::::new(populated_bitswap_slot(network)); let wrapper = StorageChainBlockImport::new(inner, api.clone(), fetcher); BlockExecutionHarness { wrapper, api, captured, content_hash } From 105b3de82f5c96f6c9d055dec7d53f1638ee2cb8 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Tue, 9 Jun 2026 10:25:20 +0200 Subject: [PATCH 03/32] Improve readability --- .../client/network/src/bitswap/service.rs | 335 ++++++++++++------ 1 file changed, 231 insertions(+), 104 deletions(-) diff --git a/substrate/client/network/src/bitswap/service.rs b/substrate/client/network/src/bitswap/service.rs index cfebcda53773..f2a44c1f9a5d 100644 --- a/substrate/client/network/src/bitswap/service.rs +++ b/substrate/client/network/src/bitswap/service.rs @@ -114,26 +114,200 @@ impl CidState { } } +struct WantSet { + inner: HashMap, +} + +impl WantSet { + fn new() -> Self { + Self { inner: HashMap::new() } + } + + fn len(&self) -> usize { + self.inner.len() + } + + fn contains(&self, cid: &Cid) -> bool { + self.inner.contains_key(cid) + } + + fn new_cid_count(&self, cids: &[Cid]) -> usize { + cids.iter().filter(|cid| !self.inner.contains_key(cid)).count() + } + + fn waiter_count(&self, cid: &Cid) -> usize { + self.inner.get(cid).map_or(0, |state| state.waiters.len()) + } + + fn add_waiter(&mut self, cid: Cid, waiter: WaiterId) { + self.inner.entry(cid).or_insert_with(CidState::new).waiters.push(waiter); + } + + fn remove_waiter(&mut self, cid: Cid, waiter: WaiterId) { + if let Some(state) = self.inner.get_mut(&cid) { + state.waiters.retain(|w| *w != waiter); + } + self.remove_if_idle(cid); + } + + fn all_cids(&self) -> Vec { + self.inner.keys().copied().collect() + } + + fn take_waiters_for_delivered_cid(&mut self, cid: Cid) -> Option> { + self.inner.remove(&cid).map(|state| state.waiters) + } + + fn next_peer_to_request( + &mut self, + cid: Cid, + connected_peers: &HashSet, + now: Instant, + ) -> Option { + let state = self.inner.get(&cid)?; + if !state.has_waiters() || state.in_flight_peers.len() >= PEER_FANOUT_CAP { + return None; + } + + let peer = connected_peers + .iter() + .find(|peer| { + !state.tried_peers.contains(peer) && !state.in_flight_peers.contains_key(peer) + }) + .copied()?; + + let state = self.inner.get_mut(&cid).expect("checked above; qed"); + state.in_flight_peers.insert(peer, now + PER_PEER_TIMEOUT); + + Some(peer) + } + + fn mark_peer_done_for_cid(&mut self, peer: litep2p::PeerId, cid: Cid) { + if let Some(state) = self.inner.get_mut(&cid) { + state.in_flight_peers.remove(&peer); + state.tried_peers.insert(peer); + } + self.remove_if_idle(cid); + } + + fn remove_in_flight_peer(&mut self, peer: litep2p::PeerId) -> Vec { + let affected: Vec = self + .inner + .iter_mut() + .filter_map(|(cid, state)| state.in_flight_peers.remove(&peer).map(|_| *cid)) + .collect(); + + self.remove_idle_and_filter_existing(affected) + } + + fn expire_peer_timeouts(&mut self, now: Instant) -> Vec { + let mut timed_out: Vec<(Cid, litep2p::PeerId)> = Vec::new(); + for (cid, state) in self.inner.iter_mut() { + state.in_flight_peers.retain(|peer, deadline| { + if *deadline <= now { + timed_out.push((*cid, *peer)); + false + } else { + true + } + }); + } + + let mut cids = Vec::with_capacity(timed_out.len()); + for (cid, peer) in timed_out { + if let Some(state) = self.inner.get_mut(&cid) { + state.tried_peers.insert(peer); + cids.push(cid); + } + } + + self.remove_idle_and_filter_existing(cids) + } + + fn clear(&mut self) { + self.inner.clear(); + } + + fn remove_idle_and_filter_existing(&mut self, cids: Vec) -> Vec { + for cid in &cids { + self.remove_if_idle(*cid); + } + cids.into_iter().filter(|cid| self.inner.contains_key(cid)).collect() + } + + fn remove_if_idle(&mut self, cid: Cid) { + if self.inner.get(&cid).is_some_and(CidState::is_idle) { + self.inner.remove(&cid); + } + } +} + struct Waiter { cids_remaining: HashSet, sink: mpsc::Sender, delay_key: delay_queue::Key, } +struct InboundLookupResult { + peer: litep2p::PeerId, + responses: Vec, +} + +enum LookupAdmission { + Submitted, + Overloaded, +} + +struct InboundLookupPool { + client: Arc + Send + Sync>, + result_tx: mpsc::Sender, + semaphore: Arc, +} + +impl InboundLookupPool { + fn new( + client: Arc + Send + Sync>, + ) -> (Self, mpsc::Receiver) { + let (result_tx, result_rx) = mpsc::channel(LOOKUP_CHANNEL_CAPACITY); + ( + Self { + client, + result_tx, + semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_INBOUND_LOOKUPS)), + }, + result_rx, + ) + } + + fn submit(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) -> LookupAdmission { + let Ok(permit) = self.semaphore.clone().try_acquire_owned() else { + return LookupAdmission::Overloaded; + }; + + let client = self.client.clone(); + let result_tx = self.result_tx.clone(); + tokio::task::spawn_blocking(move || { + let _permit = permit; + let responses = serve_inbound(&*client, cids); + let _ = result_tx.try_send(InboundLookupResult { peer, responses }); + }); + + LookupAdmission::Submitted + } +} + pub(crate) struct BitswapService { handle: Box, - client: Arc + Send + Sync>, config: BitswapServiceConfig, cmd_rx: mpsc::Receiver, peer_event_rx: mpsc::Receiver, - lookup_tx: mpsc::Sender<(litep2p::PeerId, Vec)>, - lookup_rx: mpsc::Receiver<(litep2p::PeerId, Vec)>, - lookup_semaphore: Arc, + inbound_lookup_pool: InboundLookupPool, + inbound_lookup_rx: mpsc::Receiver, waiter_deadlines: DelayQueue, connected_peers: HashSet, - wants: HashMap, + wants: WantSet, waiters: SlotMap, } @@ -149,22 +323,20 @@ pub fn start( let (litep2p_config, litep2p_handle) = LitepConfig::new(); let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); let (peer_event_tx, peer_event_rx) = mpsc::channel(PEER_EVENT_CHANNEL_CAPACITY); - let (lookup_tx, lookup_rx) = mpsc::channel(LOOKUP_CHANNEL_CAPACITY); + let (inbound_lookup_pool, inbound_lookup_rx) = InboundLookupPool::new(client); let user_handle = BitswapHandle::new(cmd_tx); let service = BitswapService { handle: Box::new(litep2p_handle), - client, config, cmd_rx, peer_event_rx, - lookup_tx, - lookup_rx, - lookup_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_INBOUND_LOOKUPS)), + inbound_lookup_pool, + inbound_lookup_rx, waiter_deadlines: DelayQueue::new(), connected_peers: HashSet::new(), - wants: HashMap::new(), + wants: WantSet::new(), waiters: SlotMap::with_key(), }; @@ -222,8 +394,8 @@ impl BitswapService { } }, - Some((peer, responses)) = self.lookup_rx.recv() => { - self.handle.send_response(peer, responses).await; + Some(result) = self.inbound_lookup_rx.recv() => { + self.handle.send_response(result.peer, result.responses).await; }, _ = peer_timeout_ticker.tick() => { @@ -237,15 +409,14 @@ impl BitswapService { // New CIDs are CIDs not already in `wants`. We re-check the overall outstanding-CIDs // budget against the *new* additions, otherwise overlapping waiters would be charged // twice for the same CID. - let new_cid_count = cids.iter().filter(|cid| !self.wants.contains_key(cid)).count(); + let new_cid_count = self.wants.new_cid_count(&cids); if self.wants.len() + new_cid_count > MAX_OUTSTANDING_CIDS { let _ = sink.try_send(Err(BitswapError::Overloaded)); return; } for cid in &cids { - let waiters_for_cid = self.wants.get(cid).map_or(0, |cs| cs.waiters.len()); - if waiters_for_cid >= MAX_WAITERS_PER_CID { + if self.wants.waiter_count(cid) >= MAX_WAITERS_PER_CID { let _ = sink.try_send(Err(BitswapError::Overloaded)); return; } @@ -261,8 +432,7 @@ impl BitswapService { }); for cid in &cids { - let cid_state = self.wants.entry(*cid).or_insert_with(CidState::new); - cid_state.waiters.push(waiter_id); + self.wants.add_waiter(*cid, waiter_id); } for cid in cids { @@ -271,29 +441,12 @@ impl BitswapService { } async fn top_up_in_flight(&mut self, cid: Cid) { - let Some(cid_state) = self.wants.get_mut(&cid) else { return }; - if !cid_state.has_waiters() { - return; + if let Some(peer) = + self.wants.next_peer_to_request(cid, &self.connected_peers, Instant::now()) + { + log::trace!(target: LOG_TARGET, "WANT-BLOCK {cid} -> {peer:?}"); + self.handle.send_request(peer, vec![(cid, WantType::Block)]).await; } - if cid_state.in_flight_peers.len() >= PEER_FANOUT_CAP { - return; - } - - let Some(peer) = self - .connected_peers - .iter() - .find(|p| { - !cid_state.tried_peers.contains(p) && !cid_state.in_flight_peers.contains_key(p) - }) - .copied() - else { - return; - }; - - cid_state.in_flight_peers.insert(peer, Instant::now() + PER_PEER_TIMEOUT); - - log::trace!(target: LOG_TARGET, "WANT-BLOCK {cid} -> {peer:?}"); - self.handle.send_request(peer, vec![(cid, WantType::Block)]).await; } async fn on_inbound_response(&mut self, peer: litep2p::PeerId, responses: Vec) { @@ -316,7 +469,7 @@ impl BitswapService { }, }; - if !self.wants.contains_key(&recomputed) { + if !self.wants.contains(&recomputed) { log::debug!( target: LOG_TARGET, "{peer:?} sent unsolicited or unwanted block for {recomputed}", @@ -343,24 +496,21 @@ impl BitswapService { } for cid in cids_to_top_up { - if self.wants.contains_key(&cid) { + if self.wants.contains(&cid) { self.top_up_in_flight(cid).await; } } } fn mark_peer_done_for_cid(&mut self, peer: litep2p::PeerId, cid: Cid) { - if let Some(cid_state) = self.wants.get_mut(&cid) { - cid_state.in_flight_peers.remove(&peer); - cid_state.tried_peers.insert(peer); - } + self.wants.mark_peer_done_for_cid(peer, cid); } fn deliver_block(&mut self, cid: Cid, bytes: Vec) { - let Some(cid_state) = self.wants.remove(&cid) else { return }; + let Some(waiter_ids) = self.wants.take_waiters_for_delivered_cid(cid) else { return }; let bytes = Arc::new(bytes); - for waiter_id in cid_state.waiters { + for waiter_id in waiter_ids { let Some(waiter) = self.waiters.get_mut(waiter_id) else { continue }; if !waiter.cids_remaining.remove(&cid) { continue; @@ -382,12 +532,7 @@ impl BitswapService { self.waiter_deadlines.remove(&waiter.delay_key); for cid in &waiter.cids_remaining { - if let Some(cid_state) = self.wants.get_mut(cid) { - cid_state.waiters.retain(|w| *w != id); - if cid_state.is_idle() { - self.wants.remove(cid); - } - } + self.wants.remove_waiter(*cid, id); } } @@ -396,12 +541,7 @@ impl BitswapService { let remaining: Vec = waiter.cids_remaining.drain().collect(); for cid in &remaining { let _ = waiter.sink.try_send(Ok((*cid, FetchOutcome::Missing))); - if let Some(cid_state) = self.wants.get_mut(cid) { - cid_state.waiters.retain(|w| *w != id); - if cid_state.is_idle() { - self.wants.remove(cid); - } - } + self.wants.remove_waiter(*cid, id); } log::trace!( target: LOG_TARGET, @@ -417,7 +557,7 @@ impl BitswapService { "snapshot: {} connected peers at startup", self.connected_peers.len(), ); - let cids: Vec = self.wants.keys().copied().collect(); + let cids = self.wants.all_cids(); for cid in cids { self.top_up_in_flight(cid).await; } @@ -425,7 +565,7 @@ impl BitswapService { async fn on_peer_connected(&mut self, peer: litep2p::PeerId) { self.connected_peers.insert(peer); - let cids: Vec = self.wants.keys().copied().collect(); + let cids = self.wants.all_cids(); for cid in cids { self.top_up_in_flight(cid).await; } @@ -433,38 +573,15 @@ impl BitswapService { async fn on_peer_disconnected(&mut self, peer: litep2p::PeerId) { self.connected_peers.remove(&peer); - let cids_to_top_up: Vec = self - .wants - .iter_mut() - .filter_map(|(cid, cs)| cs.in_flight_peers.remove(&peer).map(|_| *cid)) - .collect(); + let cids_to_top_up = self.wants.remove_in_flight_peer(peer); for cid in cids_to_top_up { self.top_up_in_flight(cid).await; } } async fn sweep_per_peer_timeouts(&mut self) { - let now = Instant::now(); - let mut timed_out: Vec<(Cid, litep2p::PeerId)> = Vec::new(); - for (cid, cid_state) in self.wants.iter_mut() { - cid_state.in_flight_peers.retain(|peer, deadline| { - if *deadline <= now { - timed_out.push((*cid, *peer)); - false - } else { - true - } - }); - } - for (cid, peer) in &timed_out { - if let Some(cid_state) = self.wants.get_mut(cid) { - cid_state.tried_peers.insert(*peer); - } - } - for (cid, _) in timed_out { - if self.wants.contains_key(&cid) { - self.top_up_in_flight(cid).await; - } + for cid in self.wants.expire_peer_timeouts(Instant::now()) { + self.top_up_in_flight(cid).await; } } @@ -477,20 +594,12 @@ impl BitswapService { ); return; } - let Ok(permit) = self.lookup_semaphore.clone().try_acquire_owned() else { + if let LookupAdmission::Overloaded = self.inbound_lookup_pool.submit(peer, cids) { log::trace!( target: LOG_TARGET, "inbound serving pool saturated; dropping wantlist from {peer:?}", ); - return; - }; - let client = self.client.clone(); - let lookup_tx = self.lookup_tx.clone(); - tokio::task::spawn_blocking(move || { - let _permit = permit; - let responses = serve_inbound(&*client, cids); - let _ = lookup_tx.try_send((peer, responses)); - }); + } } fn shutdown_waiters(&mut self) { @@ -602,7 +711,7 @@ mod tests { let (outbound_resp_tx, outbound_resp_rx) = mpsc::channel(64); let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); let (peer_event_tx, peer_event_rx) = mpsc::channel(PEER_EVENT_CHANNEL_CAPACITY); - let (lookup_tx, lookup_rx) = mpsc::channel(LOOKUP_CHANNEL_CAPACITY); + let (inbound_lookup_pool, inbound_lookup_rx) = InboundLookupPool::new(client); let transport = MockTransport { inbound: AsyncMutex::new(inbound_rx), @@ -612,16 +721,14 @@ mod tests { let service: BitswapService = BitswapService { handle: Box::new(transport), - client, config, cmd_rx, peer_event_rx, - lookup_tx, - lookup_rx, - lookup_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_INBOUND_LOOKUPS)), + inbound_lookup_pool, + inbound_lookup_rx, waiter_deadlines: DelayQueue::new(), connected_peers: HashSet::new(), - wants: HashMap::new(), + wants: WantSet::new(), waiters: SlotMap::with_key(), }; @@ -663,6 +770,26 @@ mod tests { timeout(Duration::from_secs(2), rx.recv()).await.ok().flatten() } + #[test] + fn want_set_removes_cid_after_last_waiter_and_peer_complete() { + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xaa; 32]); + let peer = litep2p::PeerId::random(); + let mut waiter_ids = SlotMap::with_key(); + let waiter_id = waiter_ids.insert(()); + let mut wants = WantSet::new(); + + wants.add_waiter(cid, waiter_id); + let selected = + wants.next_peer_to_request(cid, &HashSet::from([peer]), Instant::now()).unwrap(); + assert_eq!(selected, peer); + + wants.remove_waiter(cid, waiter_id); + assert!(wants.contains(&cid)); + + wants.mark_peer_done_for_cid(peer, cid); + assert!(!wants.contains(&cid)); + } + #[tokio::test(start_paused = true)] async fn single_cid_single_peer_block_response() { let mut rig = empty_rig(); From 6587bc9e1eb1e412f44f383faf5e478d44cc40f6 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Wed, 24 Jun 2026 14:34:01 +0200 Subject: [PATCH 04/32] Use sync events --- .../polkadot-omni-node/lib/src/common/spec.rs | 4 +- .../client/network/src/bitswap/handle.rs | 23 ++++------- .../client/network/src/bitswap/service.rs | 20 ++------- substrate/client/network/src/config.rs | 4 +- substrate/client/network/src/litep2p/mod.rs | 36 ---------------- substrate/client/service/src/builder.rs | 41 +++++++++++++++++-- .../client/storage-chain-sync/src/fetcher.rs | 21 ++++------ .../client/storage-chain-sync/tests/it.rs | 8 ++-- 8 files changed, 63 insertions(+), 94 deletions(-) diff --git a/cumulus/polkadot-omni-node/lib/src/common/spec.rs b/cumulus/polkadot-omni-node/lib/src/common/spec.rs index cd03d14ead20..ccf02cf3cdde 100644 --- a/cumulus/polkadot-omni-node/lib/src/common/spec.rs +++ b/cumulus/polkadot-omni-node/lib/src/common/spec.rs @@ -47,11 +47,11 @@ use sc_client_api::Backend; use sc_consensus::DefaultImportQueue; use sc_executor::{HeapAllocStrategy, DEFAULT_HEAP_ALLOC_STRATEGY}; use sc_network::{ - config::FullNetworkConfiguration, NetworkBackend, NetworkBlock, NetworkStateInfo, PeerId, + bitswap::BitswapRequest, config::FullNetworkConfiguration, NetworkBackend, NetworkBlock, + NetworkStateInfo, PeerId, }; use sc_service::{Configuration, ImportQueue, PartialComponents, TaskManager}; use sc_statement_store::Store; -use sc_network::bitswap::BitswapRequest; use sc_storage_chain_sync::{ BitswapHandleSlot, IndexedTransactionFetcher, StorageChainBlockImport, }; diff --git a/substrate/client/network/src/bitswap/handle.rs b/substrate/client/network/src/bitswap/handle.rs index ee7e83eac6f8..9f739df34e35 100644 --- a/substrate/client/network/src/bitswap/handle.rs +++ b/substrate/client/network/src/bitswap/handle.rs @@ -223,19 +223,11 @@ pub(crate) enum BitswapCommand { }, } -/// Peer connect/disconnect events published from the litep2p backend's main loop into the -/// Bitswap service actor. +/// Peer connect/disconnect events published into the Bitswap service actor. /// -/// `Snapshot` is delivered exactly once, as the first event after the actor subscribes, so -/// the actor learns about already-established connections that pre-date its startup. The -/// actor MUST handle `Snapshot` before treating `connected_peers` as authoritative. +/// Production wiring uses `Connected` / `Disconnected` events from the sync peer stream. #[derive(Debug, Clone)] pub enum PeerEvent { - /// Initial snapshot of currently-connected peers. - Snapshot { - /// Already-connected peers at subscription time. - peers: Vec, - }, /// A new peer connected. Connected { /// Peer ID. @@ -248,20 +240,19 @@ pub enum PeerEvent { }, } -/// Wiring produced by [`crate::bitswap::start`] and consumed by the litep2p network -/// backend at construction time. +/// Wiring produced by [`crate::bitswap::start`] and consumed during network construction. /// -/// The backend: +/// Network construction: /// - feeds [`Self::litep2p_config`] into `Litep2pConfigBuilder::with_libp2p_bitswap`, /// - stores [`Self::user_handle`] on `Litep2pNetworkService` for the `bitswap_handle()` accessor, -/// - publishes `PeerEvent`s into [`Self::peer_event_tx`] from its main loop. +/// - publishes sync-vetted `PeerEvent`s into [`Self::peer_event_tx`]. pub struct BitswapWiring { /// Litep2p protocol config; consumed by `with_libp2p_bitswap`. pub litep2p_config: litep2p::protocol::libp2p::bitswap::Config, /// Public, cloneable user-facing handle. pub user_handle: BitswapHandle, - /// Sender into which the backend's main loop publishes peer events. The actor holds the - /// receiver internally. + /// Sender into which network construction publishes peer events. The actor holds the receiver + /// internally. pub peer_event_tx: mpsc::Sender, } diff --git a/substrate/client/network/src/bitswap/service.rs b/substrate/client/network/src/bitswap/service.rs index f2a44c1f9a5d..ec82130eae76 100644 --- a/substrate/client/network/src/bitswap/service.rs +++ b/substrate/client/network/src/bitswap/service.rs @@ -313,9 +313,9 @@ pub(crate) struct BitswapService { /// Build, wire and return the Bitswap service. /// -/// The returned future MUST be spawned on the runtime; the [`BitswapWiring`] MUST be passed -/// to the litep2p network backend at construction (it carries the litep2p protocol config -/// and the user-facing handle). +/// The returned future MUST be spawned on the runtime; the [`BitswapWiring`] carries the +/// litep2p protocol config, user-facing handle, and peer-event sender needed during network +/// construction. pub fn start( client: Arc + Send + Sync>, config: BitswapServiceConfig, @@ -378,7 +378,6 @@ impl BitswapService { }, peer_ev = self.peer_event_rx.recv() => match peer_ev { - Some(PeerEvent::Snapshot { peers }) => self.on_peer_snapshot(peers).await, Some(PeerEvent::Connected { peer }) => self.on_peer_connected(peer).await, Some(PeerEvent::Disconnected { peer }) => self.on_peer_disconnected(peer).await, None => { @@ -550,19 +549,6 @@ impl BitswapService { ); } - async fn on_peer_snapshot(&mut self, peers: Vec) { - self.connected_peers = peers.into_iter().collect(); - log::debug!( - target: LOG_TARGET, - "snapshot: {} connected peers at startup", - self.connected_peers.len(), - ); - let cids = self.wants.all_cids(); - for cid in cids { - self.top_up_in_flight(cid).await; - } - } - async fn on_peer_connected(&mut self, peer: litep2p::PeerId) { self.connected_peers.insert(peer); let cids = self.wants.all_cids(); diff --git a/substrate/client/network/src/config.rs b/substrate/client/network/src/config.rs index 14bcd8b6c1ce..bab6fc3c3983 100644 --- a/substrate/client/network/src/config.rs +++ b/substrate/client/network/src/config.rs @@ -768,8 +768,8 @@ impl NetworkConfiguration { /// IPFS server configuration. pub struct IpfsConfig { /// Bitswap wiring produced by [`crate::bitswap::start`]. Carries the litep2p protocol - /// config, the user-facing handle, and the peer-event sender. Consumed by the litep2p - /// backend at construction. `None` is unsupported when `ipfs_server = true`. + /// config, the user-facing handle, and the peer-event sender. `None` is unsupported when + /// `ipfs_server = true`. pub bitswap_wiring: Option, /// Indexed transactions provider. pub block_provider: Box, diff --git a/substrate/client/network/src/litep2p/mod.rs b/substrate/client/network/src/litep2p/mod.rs index 1ae0dc4acc6c..1932ebbcd2df 100644 --- a/substrate/client/network/src/litep2p/mod.rs +++ b/substrate/client/network/src/litep2p/mod.rs @@ -191,18 +191,6 @@ pub struct Litep2pNetworkBackend { /// Prometheus metrics. metrics: Option, - - /// Sender for Bitswap peer events. `Some` when `--ipfs-server` is enabled. The main - /// loop publishes [`crate::bitswap::PeerEvent::Connected`] / - /// [`crate::bitswap::PeerEvent::Disconnected`] into this channel from its - /// `ConnectionEstablished` / `ConnectionClosed` arms; the receiver lives inside the - /// Bitswap service actor. - bitswap_peer_event_tx: Option>, - - /// Per-peer connection count, used to dedup Bitswap peer events. Empty when - /// `bitswap_peer_event_tx` is `None`. We track this independently of - /// [`Self::peers`] because that map is only populated when metrics are enabled. - bitswap_peer_conn_count: HashMap, } impl Litep2pNetworkBackend { @@ -519,9 +507,6 @@ impl NetworkBackend for Litep2pNetworkBac ); let mut bitswap_user_handle: Option = None; - let mut bitswap_peer_event_tx: Option< - tokio::sync::mpsc::Sender, - > = None; if let Some(ipfs) = params.ipfs_config { let wiring = ipfs.bitswap_wiring.expect( @@ -530,7 +515,6 @@ impl NetworkBackend for Litep2pNetworkBac ); config_builder = config_builder.with_libp2p_bitswap(wiring.litep2p_config); bitswap_user_handle = Some(wiring.user_handle); - bitswap_peer_event_tx = Some(wiring.peer_event_tx); if !ipfs.bootnodes.is_empty() { let (ipfs_dht, kad_config) = IpfsDht::new(ipfs.bootnodes, ipfs.block_provider); @@ -617,8 +601,6 @@ impl NetworkBackend for Litep2pNetworkBac event_streams: out_events::OutChannels::new(None)?, peers: HashMap::new(), litep2p, - bitswap_peer_event_tx, - bitswap_peer_conn_count: HashMap::new(), }) } @@ -1188,14 +1170,6 @@ impl NetworkBackend for Litep2pNetworkBac }, event = self.litep2p.next_event() => match event { Some(Litep2pEvent::ConnectionEstablished { peer, endpoint }) => { - if let Some(tx) = self.bitswap_peer_event_tx.as_ref() { - let count = self.bitswap_peer_conn_count.entry(peer).or_insert(0); - *count += 1; - if *count == 1 { - let _ = tx.try_send(crate::bitswap::PeerEvent::Connected { peer }); - } - } - let Some(metrics) = &self.metrics else { continue; }; @@ -1230,16 +1204,6 @@ impl NetworkBackend for Litep2pNetworkBac } } Some(Litep2pEvent::ConnectionClosed { peer, connection_id }) => { - if let Some(tx) = self.bitswap_peer_event_tx.as_ref() { - if let Some(count) = self.bitswap_peer_conn_count.get_mut(&peer) { - *count = count.saturating_sub(1); - if *count == 0 { - self.bitswap_peer_conn_count.remove(&peer); - let _ = tx.try_send(crate::bitswap::PeerEvent::Disconnected { peer }); - } - } - } - let Some(metrics) = &self.metrics else { continue; }; diff --git a/substrate/client/service/src/builder.rs b/substrate/client/service/src/builder.rs index 9d43e20d6b2f..05a2ebbf86b5 100644 --- a/substrate/client/service/src/builder.rs +++ b/substrate/client/service/src/builder.rs @@ -64,7 +64,7 @@ use sc_network_sync::{ SyncingStrategy, }, warp_request_handler::RequestHandler as WarpSyncRequestHandler, - SyncingService, WarpSyncConfig, + SyncEvent, SyncEventStream, SyncingService, WarpSyncConfig, }; use sc_rpc::{ author::AuthorApiServer, @@ -1170,6 +1170,37 @@ where pub blocks_pruning: BlocksPruning, } +fn spawn_bitswap_sync_peer_events( + spawn_handle: &SpawnTaskHandle, + sync_service: Arc>, + bitswap_peer_event_tx: tokio::sync::mpsc::Sender, +) { + let mut sync_event_stream = sync_service.event_stream("bitswap-sync-peers"); + spawn_handle.spawn("bitswap-sync-peer-events", Some("networking"), async move { + while let Some(event) = sync_event_stream.next().await { + let peer_event = match event { + SyncEvent::PeerConnected(peer) => { + sc_network::bitswap::PeerEvent::Connected { peer: peer.into() } + }, + SyncEvent::PeerDisconnected(peer) => { + sc_network::bitswap::PeerEvent::Disconnected { peer: peer.into() } + }, + }; + + match bitswap_peer_event_tx.try_send(peer_event) { + Ok(()) => {}, + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + log::warn!( + target: "sub-libp2p::bitswap", + "dropping sync peer event because BitswapService is backlogged", + ); + }, + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => break, + } + } + }); +} + /// Build the network service, the network status sinks and an RPC sender, this is a lower-level /// version of [`build_network`] for those needing more control. pub fn build_network_advanced( @@ -1218,6 +1249,7 @@ where } = params; let genesis_hash = client.info().genesis_hash; + let sync_service = Arc::new(sync_service); let light_client_request_protocol_config = { // Allow both outgoing and incoming requests. @@ -1249,6 +1281,11 @@ where sc_network::bitswap::BitswapServiceConfig::default(), ); spawn_handle.spawn("bitswap-service", Some("networking"), handler); + spawn_bitswap_sync_peer_events( + &spawn_handle, + sync_service.clone(), + bitswap_wiring.peer_event_tx.clone(), + ); let ipfs_num_blocks = match blocks_pruning { BlocksPruning::KeepAll | BlocksPruning::KeepFinalized => IPFS_MAX_BLOCKS, @@ -1279,8 +1316,6 @@ where let peer_store = net_config.take_peer_store(); spawn_handle.spawn("peer-store", Some("networking"), peer_store.run()); - let sync_service = Arc::new(sync_service); - let network_params = sc_network::config::Params::::Hash, Net> { role, executor: { diff --git a/substrate/client/storage-chain-sync/src/fetcher.rs b/substrate/client/storage-chain-sync/src/fetcher.rs index a441bc1285cf..3a21909e72a0 100644 --- a/substrate/client/storage-chain-sync/src/fetcher.rs +++ b/substrate/client/storage-chain-sync/src/fetcher.rs @@ -23,16 +23,14 @@ //! itself. This fetcher's only jobs are: //! //! 1. building the per-want CIDs from the runtime-declared [`RenewWant`]s, -//! 2. chunking by [`sc_network::bitswap::MAX_CIDS_PER_REQUEST`] and submitting all -//! chunks concurrently, +//! 2. chunking by [`sc_network::bitswap::MAX_CIDS_PER_REQUEST`] and submitting all chunks +//! concurrently, //! 3. draining the per-chunk streams into a `HashMap>`. use crate::RenewWant; use cid::{multihash::Multihash, Cid}; use futures::{future, stream::FuturesUnordered, StreamExt}; -use sc_network::bitswap::{ - BitswapError, BitswapRequest, FetchOutcome, MAX_CIDS_PER_REQUEST, -}; +use sc_network::bitswap::{BitswapError, BitswapRequest, FetchOutcome, MAX_CIDS_PER_REQUEST}; use sp_runtime::traits::Block as BlockT; use sp_transaction_storage_proof::ContentHash; use std::{ @@ -116,15 +114,12 @@ impl IndexedTransactionFetcher { cids.push(cid); } - let chunks: Vec> = cids - .chunks(MAX_CIDS_PER_REQUEST) - .map(<[Cid]>::to_vec) - .collect(); + let chunks: Vec> = + cids.chunks(MAX_CIDS_PER_REQUEST).map(<[Cid]>::to_vec).collect(); - let receivers = future::try_join_all( - chunks.into_iter().map(|chunk| handle.request_stream(chunk)), - ) - .await?; + let receivers = + future::try_join_all(chunks.into_iter().map(|chunk| handle.request_stream(chunk))) + .await?; let mut acquired: HashMap> = HashMap::with_capacity(wants.len()); let mut streams: FuturesUnordered<_> = receivers diff --git a/substrate/client/storage-chain-sync/tests/it.rs b/substrate/client/storage-chain-sync/tests/it.rs index 1371169e9178..938a1eb527a8 100644 --- a/substrate/client/storage-chain-sync/tests/it.rs +++ b/substrate/client/storage-chain-sync/tests/it.rs @@ -1135,9 +1135,8 @@ mod mock { let inner = TestInner::recording(); let captured = inner.captured.clone(); - let fetcher = IndexedTransactionFetcher::::new(populated_bitswap_slot( - network.clone(), - )); + let fetcher = + IndexedTransactionFetcher::::new(populated_bitswap_slot(network.clone())); let wrapper = StorageChainBlockImport::new(inner, api.clone(), fetcher); Harness { wrapper, api, captured, network } @@ -1158,8 +1157,7 @@ mod mock { let inner = TestInner::recording(); let captured = inner.captured.clone(); - let fetcher = - IndexedTransactionFetcher::::new(populated_bitswap_slot(network)); + let fetcher = IndexedTransactionFetcher::::new(populated_bitswap_slot(network)); let wrapper = StorageChainBlockImport::new(inner, api.clone(), fetcher); BlockExecutionHarness { wrapper, api, captured, content_hash } From 7f77a036d37d3930d8ec88fed014ea452171c28e Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Fri, 3 Jul 2026 18:38:25 +0200 Subject: [PATCH 05/32] Move bitswap service to own crate # Conflicts: # substrate/client/storage-chain-sync/src/fetcher.rs --- Cargo.lock | 33 +++++ Cargo.toml | 2 + cumulus/client/service/Cargo.toml | 1 + cumulus/client/service/src/lib.rs | 1 + cumulus/polkadot-omni-node/lib/Cargo.toml | 1 + .../polkadot-omni-node/lib/src/common/spec.rs | 8 +- .../polkadot-omni-node/lib/src/nodes/aura.rs | 2 +- cumulus/test/service/src/lib.rs | 2 +- polkadot/node/service/src/builder/mod.rs | 2 +- prdoc/pr_12243.prdoc | 6 +- substrate/bin/node/cli/src/service.rs | 2 +- substrate/client/network/bitswap/Cargo.toml | 44 ++++++ .../{src/bitswap => bitswap/src}/handle.rs | 48 +----- .../bitswap/mod.rs => bitswap/src/lib.rs} | 12 +- .../{src/bitswap => bitswap/src}/service.rs | 137 ++++++++++++------ substrate/client/network/src/config.rs | 35 ++++- substrate/client/network/src/lib.rs | 9 +- substrate/client/network/src/litep2p/mod.rs | 10 +- .../client/network/src/litep2p/service.rs | 15 +- substrate/client/network/src/service.rs | 7 - .../client/network/src/service/traits.rs | 23 --- substrate/client/service/Cargo.toml | 1 + substrate/client/service/src/builder.rs | 93 ++++++------ .../client/storage-chain-sync/Cargo.toml | 1 + .../client/storage-chain-sync/src/fetcher.rs | 6 +- .../client/storage-chain-sync/src/lib.rs | 2 +- .../client/storage-chain-sync/tests/it.rs | 6 +- .../frame/revive/dev-node/node/src/service.rs | 2 +- templates/minimal/node/src/service.rs | 2 +- templates/parachain/node/src/service.rs | 2 +- templates/solochain/node/src/service.rs | 2 +- 31 files changed, 291 insertions(+), 226 deletions(-) create mode 100644 substrate/client/network/bitswap/Cargo.toml rename substrate/client/network/{src/bitswap => bitswap/src}/handle.rs (81%) rename substrate/client/network/{src/bitswap/mod.rs => bitswap/src/lib.rs} (88%) rename substrate/client/network/{src/bitswap => bitswap/src}/service.rs (87%) diff --git a/Cargo.lock b/Cargo.lock index f34966eede0c..8777be611e6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4977,6 +4977,7 @@ dependencies = [ "sc-client-api 28.0.0", "sc-consensus", "sc-network 0.34.0", + "sc-network-bitswap", "sc-network-sync", "sc-network-transactions", "sc-rpc", @@ -16938,6 +16939,7 @@ dependencies = [ "sc-hop", "sc-keystore", "sc-network 0.34.0", + "sc-network-bitswap", "sc-network-statement", "sc-network-sync", "sc-offchain", @@ -21562,6 +21564,35 @@ dependencies = [ "zeroize", ] +[[package]] +name = "sc-network-bitswap" +version = "0.1.0" +dependencies = [ + "async-trait", + "cid", + "futures", + "litep2p 0.14.3", + "log", + "multihash-codetable", + "sc-block-builder", + "sc-client-api 28.0.0", + "sc-network-sync", + "sc-network-types 0.10.0", + "slotmap", + "smallvec", + "sp-consensus 0.32.0", + "sp-core 28.0.0", + "sp-crypto-hashing 0.1.0", + "sp-runtime 31.0.1", + "sp-tracing 16.0.0", + "substrate-test-runtime", + "substrate-test-runtime-client", + "thiserror 1.0.65", + "tokio", + "tokio-stream", + "tokio-util", +] + [[package]] name = "sc-network-common" version = "0.33.0" @@ -22035,6 +22066,7 @@ dependencies = [ "sc-informant", "sc-keystore", "sc-network 0.34.0", + "sc-network-bitswap", "sc-network-common 0.33.0", "sc-network-light", "sc-network-sync", @@ -22168,6 +22200,7 @@ dependencies = [ "sc-client-db", "sc-consensus", "sc-network 0.34.0", + "sc-network-bitswap", "sp-api 26.0.0", "sp-blockchain 28.0.0", "sp-consensus 0.32.0", diff --git a/Cargo.toml b/Cargo.toml index 073f0eaf4270..2fd15a54c960 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -292,6 +292,7 @@ members = [ "substrate/client/mixnet", "substrate/client/network", "substrate/client/network-gossip", + "substrate/client/network/bitswap", "substrate/client/network/common", "substrate/client/network/light", "substrate/client/network/statement", @@ -1268,6 +1269,7 @@ sc-informant = { path = "substrate/client/informant", default-features = false } sc-keystore = { path = "substrate/client/keystore", default-features = false } sc-mixnet = { path = "substrate/client/mixnet", default-features = false } sc-network = { path = "substrate/client/network", default-features = false } +sc-network-bitswap = { path = "substrate/client/network/bitswap", default-features = false } sc-network-common = { path = "substrate/client/network/common", default-features = false } sc-network-gossip = { path = "substrate/client/network-gossip", default-features = false } sc-network-light = { path = "substrate/client/network/light", default-features = false } diff --git a/cumulus/client/service/Cargo.toml b/cumulus/client/service/Cargo.toml index 11f965cc93ca..31e5408fccd9 100644 --- a/cumulus/client/service/Cargo.toml +++ b/cumulus/client/service/Cargo.toml @@ -21,6 +21,7 @@ prometheus = { workspace = true } sc-client-api = { workspace = true, default-features = true } sc-consensus = { workspace = true, default-features = true } sc-network = { workspace = true, default-features = true } +sc-network-bitswap = { workspace = true, default-features = true } sc-network-sync = { workspace = true, default-features = true } sc-network-transactions = { workspace = true, default-features = true } sc-rpc = { workspace = true, default-features = true } diff --git a/cumulus/client/service/src/lib.rs b/cumulus/client/service/src/lib.rs index 3f7efee15ea1..e44ad017eb50 100644 --- a/cumulus/client/service/src/lib.rs +++ b/cumulus/client/service/src/lib.rs @@ -323,6 +323,7 @@ pub async fn build_network<'a, Block, Client, RCInterface, IQ, Network>( TracingUnboundedSender>, TransactionsHandlerController, Arc>, + Option, )> where Block: BlockT, diff --git a/cumulus/polkadot-omni-node/lib/Cargo.toml b/cumulus/polkadot-omni-node/lib/Cargo.toml index 9087805dd342..e9a06ace33a9 100644 --- a/cumulus/polkadot-omni-node/lib/Cargo.toml +++ b/cumulus/polkadot-omni-node/lib/Cargo.toml @@ -56,6 +56,7 @@ sc-executor = { workspace = true, default-features = true } sc-hop = { workspace = true, default-features = true } sc-keystore = { workspace = true, default-features = true } sc-network = { workspace = true, default-features = true } +sc-network-bitswap = { workspace = true, default-features = true } sc-network-statement = { workspace = true, default-features = true } sc-network-sync = { workspace = true, default-features = true } sc-offchain = { workspace = true, default-features = true } diff --git a/cumulus/polkadot-omni-node/lib/src/common/spec.rs b/cumulus/polkadot-omni-node/lib/src/common/spec.rs index ccf02cf3cdde..5bd2c06fd213 100644 --- a/cumulus/polkadot-omni-node/lib/src/common/spec.rs +++ b/cumulus/polkadot-omni-node/lib/src/common/spec.rs @@ -47,9 +47,9 @@ use sc_client_api::Backend; use sc_consensus::DefaultImportQueue; use sc_executor::{HeapAllocStrategy, DEFAULT_HEAP_ALLOC_STRATEGY}; use sc_network::{ - bitswap::BitswapRequest, config::FullNetworkConfiguration, NetworkBackend, NetworkBlock, - NetworkStateInfo, PeerId, + config::FullNetworkConfiguration, NetworkBackend, NetworkBlock, NetworkStateInfo, PeerId, }; +use sc_network_bitswap::BitswapRequest; use sc_service::{Configuration, ImportQueue, PartialComponents, TaskManager}; use sc_statement_store::Store; use sc_storage_chain_sync::{ @@ -445,7 +445,7 @@ pub(crate) trait NodeSpec: BaseNodeSpec { (proto, config) }); - let (network, system_rpc_tx, tx_handler_controller, sync_service) = + let (network, system_rpc_tx, tx_handler_controller, sync_service, bitswap_handle) = build_network(BuildNetworkParams { parachain_config: ¶chain_config, net_config, @@ -461,7 +461,7 @@ pub(crate) trait NodeSpec: BaseNodeSpec { }) .await?; - if let Some(handle) = sc_network::BitswapProvider::bitswap_handle(&*network) { + if let Some(handle) = bitswap_handle { let _ = bitswap_slot.set(Arc::new(handle) as Arc); } diff --git a/cumulus/polkadot-omni-node/lib/src/nodes/aura.rs b/cumulus/polkadot-omni-node/lib/src/nodes/aura.rs index a33b05074414..3015da80145a 100644 --- a/cumulus/polkadot-omni-node/lib/src/nodes/aura.rs +++ b/cumulus/polkadot-omni-node/lib/src/nodes/aura.rs @@ -276,7 +276,7 @@ where (proto, *ss_config) }); - let (network, system_rpc_tx, tx_handler_controller, sync_service) = + let (network, system_rpc_tx, tx_handler_controller, sync_service, _bitswap_handle) = sc_service::build_network(sc_service::BuildNetworkParams { config: &config, client: client.clone(), diff --git a/cumulus/test/service/src/lib.rs b/cumulus/test/service/src/lib.rs index 1031f9c93025..8d2f190bcde3 100644 --- a/cumulus/test/service/src/lib.rs +++ b/cumulus/test/service/src/lib.rs @@ -361,7 +361,7 @@ where .map_err(|e| sc_service::Error::Application(Box::new(e) as Box<_>))?; tracing::info!("Parachain id: {:?}", para_id); - let (network, system_rpc_tx, tx_handler_controller, sync_service) = + let (network, system_rpc_tx, tx_handler_controller, sync_service, _bitswap_handle) = build_network(BuildNetworkParams { parachain_config: ¶chain_config, net_config, diff --git a/polkadot/node/service/src/builder/mod.rs b/polkadot/node/service/src/builder/mod.rs index 403f7e342606..855861656045 100644 --- a/polkadot/node/service/src/builder/mod.rs +++ b/polkadot/node/service/src/builder/mod.rs @@ -468,7 +468,7 @@ where }) }; - let (network, system_rpc_tx, tx_handler_controller, sync_service) = + let (network, system_rpc_tx, tx_handler_controller, sync_service, _bitswap_handle) = sc_service::build_network(sc_service::BuildNetworkParams { config: &config, net_config, diff --git a/prdoc/pr_12243.prdoc b/prdoc/pr_12243.prdoc index 954327d0b45f..ff1963f49c81 100644 --- a/prdoc/pr_12243.prdoc +++ b/prdoc/pr_12243.prdoc @@ -20,8 +20,10 @@ doc: The omni-node wiring in `polkadot-omni-node-lib` is updated accordingly: the partial-components tuple carries one bitswap-handle slot instead of - two handles, and `start_node` populates the slot via - `NetworkService::bitswap_handle()`. + two handles, and `start_node` populates the slot from the bitswap handle + returned in the build-network output tuple (see the follow-up PR that + extracts the bitswap crate; `NetworkService::bitswap_handle()` is + removed as part of that move). crates: - name: sc-storage-chain-sync diff --git a/substrate/bin/node/cli/src/service.rs b/substrate/bin/node/cli/src/service.rs index 012234dc5032..f6289853e439 100644 --- a/substrate/bin/node/cli/src/service.rs +++ b/substrate/bin/node/cli/src/service.rs @@ -534,7 +534,7 @@ pub fn new_full_base::Hash>>( Vec::default(), )); - let (network, system_rpc_tx, tx_handler_controller, sync_service) = + let (network, system_rpc_tx, tx_handler_controller, sync_service, _bitswap_handle) = sc_service::build_network(sc_service::BuildNetworkParams { config: &config, net_config, diff --git a/substrate/client/network/bitswap/Cargo.toml b/substrate/client/network/bitswap/Cargo.toml new file mode 100644 index 000000000000..b92ee14674bf --- /dev/null +++ b/substrate/client/network/bitswap/Cargo.toml @@ -0,0 +1,44 @@ +[package] +description = "Substrate Bitswap client/server service" +name = "sc-network-bitswap" +version = "0.1.0" +license = "GPL-3.0-or-later WITH Classpath-exception-2.0" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true +documentation = "https://docs.rs/sc-network-bitswap" + +[lints] +workspace = true + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +async-trait = { workspace = true } +cid = { workspace = true } +futures = { workspace = true } +litep2p = { workspace = true } +log = { workspace = true, default-features = true } +sc-client-api = { workspace = true, default-features = true } +sc-network-sync = { workspace = true, default-features = true } +sc-network-types = { workspace = true, default-features = true } +slotmap = { workspace = true } +smallvec = { workspace = true, default-features = true } +sp-core = { workspace = true, default-features = true } +sp-crypto-hashing = { workspace = true, default-features = true } +sp-runtime = { workspace = true, default-features = true } +thiserror = { workspace = true } +tokio = { features = ["macros", "rt", "sync"], workspace = true, default-features = true } +tokio-util = { features = ["time"], workspace = true } + +[dev-dependencies] +multihash-codetable = { workspace = true } +sc-block-builder = { workspace = true, default-features = true } +sp-consensus = { workspace = true, default-features = true } +sp-tracing = { workspace = true, default-features = true } +substrate-test-runtime = { workspace = true } +substrate-test-runtime-client = { workspace = true } +tokio = { features = ["macros", "rt-multi-thread", "test-util"], workspace = true, default-features = true } +tokio-stream = { workspace = true } diff --git a/substrate/client/network/src/bitswap/handle.rs b/substrate/client/network/bitswap/src/handle.rs similarity index 81% rename from substrate/client/network/src/bitswap/handle.rs rename to substrate/client/network/bitswap/src/handle.rs index 9f739df34e35..0b19bc2838bb 100644 --- a/substrate/client/network/src/bitswap/handle.rs +++ b/substrate/client/network/bitswap/src/handle.rs @@ -17,8 +17,8 @@ //! Public user-facing handle for the Bitswap service. //! -//! The handle is returned by [`crate::service::traits::BitswapProvider::bitswap_handle`] when -//! the node is configured with `--ipfs-server` and uses the litep2p network backend. +//! The handle is returned by [`crate::start`] when the node is configured with +//! `--ipfs-server` and uses the litep2p network backend. //! //! Cheap to clone. Submit work via [`BitswapHandle::request_stream`], drain the receiver to //! get per-CID outcomes as they resolve. @@ -105,8 +105,7 @@ pub type FetchItem = Result<(Cid, FetchOutcome), BitswapError>; /// User-facing handle to the Bitswap service. /// -/// Cheap to clone. Created at network construction time and stored on `NetworkService`; -/// retrieve via `NetworkService::bitswap_handle()`. +/// Cheap to clone. Created at network construction time and returned by [`crate::start`]. #[derive(Debug, Clone)] pub struct BitswapHandle { cmd_tx: mpsc::Sender, @@ -114,7 +113,7 @@ pub struct BitswapHandle { impl BitswapHandle { /// Construct a new handle around an existing command sender. Used internally by - /// [`crate::bitswap::start`]. + /// [`crate::start`]. pub(crate) fn new(cmd_tx: mpsc::Sender) -> Self { Self { cmd_tx } } @@ -223,43 +222,4 @@ pub(crate) enum BitswapCommand { }, } -/// Peer connect/disconnect events published into the Bitswap service actor. -/// -/// Production wiring uses `Connected` / `Disconnected` events from the sync peer stream. -#[derive(Debug, Clone)] -pub enum PeerEvent { - /// A new peer connected. - Connected { - /// Peer ID. - peer: litep2p::PeerId, - }, - /// A previously-connected peer disconnected. - Disconnected { - /// Peer ID. - peer: litep2p::PeerId, - }, -} -/// Wiring produced by [`crate::bitswap::start`] and consumed during network construction. -/// -/// Network construction: -/// - feeds [`Self::litep2p_config`] into `Litep2pConfigBuilder::with_libp2p_bitswap`, -/// - stores [`Self::user_handle`] on `Litep2pNetworkService` for the `bitswap_handle()` accessor, -/// - publishes sync-vetted `PeerEvent`s into [`Self::peer_event_tx`]. -pub struct BitswapWiring { - /// Litep2p protocol config; consumed by `with_libp2p_bitswap`. - pub litep2p_config: litep2p::protocol::libp2p::bitswap::Config, - /// Public, cloneable user-facing handle. - pub user_handle: BitswapHandle, - /// Sender into which network construction publishes peer events. The actor holds the receiver - /// internally. - pub peer_event_tx: mpsc::Sender, -} - -impl std::fmt::Debug for BitswapWiring { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("BitswapWiring") - .field("user_handle", &self.user_handle) - .finish_non_exhaustive() - } -} diff --git a/substrate/client/network/src/bitswap/mod.rs b/substrate/client/network/bitswap/src/lib.rs similarity index 88% rename from substrate/client/network/src/bitswap/mod.rs rename to substrate/client/network/bitswap/src/lib.rs index 6b3482dec46f..37f97a5cac45 100644 --- a/substrate/client/network/src/bitswap/mod.rs +++ b/substrate/client/network/bitswap/src/lib.rs @@ -21,9 +21,11 @@ //! Bitswap v1.2.0 wantlists) and the client side (a long-lived service plus user-facing //! [`BitswapHandle`] for fetching CIDs from the network). //! -//! Only available on the litep2p network backend. The user-facing handle is reached via -//! `NetworkService::bitswap_handle()` and is `Some(_)` when the node was started with -//! `--ipfs-server` and a litep2p backend, `None` otherwise. +//! Only available on the litep2p network backend. The caller mints the litep2p protocol +//! config + transport handle pair via `litep2p::protocol::libp2p::bitswap::Config::new`, +//! hands the transport handle to [`start`], and routes the litep2p config into the +//! backend's IPFS layer. The user-facing handle returned by [`start`] is exposed to +//! consumers via the build-network output of `sc-service`. use cid::Version as CidVersion; @@ -33,8 +35,8 @@ mod service; pub use cid::Cid; pub(crate) use handle::BitswapCommand; pub use handle::{ - BitswapError, BitswapHandle, BitswapRequest, BitswapServiceConfig, BitswapWiring, FetchItem, - FetchOutcome, PeerEvent, MAX_CIDS_PER_REQUEST, + BitswapError, BitswapHandle, BitswapRequest, BitswapServiceConfig, FetchItem, FetchOutcome, + MAX_CIDS_PER_REQUEST, }; pub use service::start; diff --git a/substrate/client/network/src/bitswap/service.rs b/substrate/client/network/bitswap/src/service.rs similarity index 87% rename from substrate/client/network/src/bitswap/service.rs rename to substrate/client/network/bitswap/src/service.rs index ec82130eae76..875f952e2590 100644 --- a/substrate/client/network/src/bitswap/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -22,22 +22,27 @@ //! peers on behalf of [`BitswapHandle`] callers) Bitswap flows. //! //! The actor runs a single [`tokio::select!`] loop with six arms; see [`BitswapService::run`]. +//! +//! Peer connect/disconnect tracking comes from `sc-network-sync`'s [`SyncEventStream`]; the +//! actor subscribes inside [`start`] and consumes [`SyncEvent`] directly. The sync engine +//! replays `PeerConnected` for every currently-connected peer when a new subscriber registers, +//! so the actor sees the up-to-date peer set on startup without any extra wiring. use super::{ - is_cid_supported, BitswapCommand, BitswapHandle, BitswapServiceConfig, BitswapWiring, Cid, - FetchItem, FetchOutcome, PeerEvent, Prefix, BLAKE2B_256_MULTIHASH_CODE, - KECCAK_256_MULTIHASH_CODE, LOG_TARGET, MAX_WANTED_BLOCKS, SHA2_256_MULTIHASH_CODE, + is_cid_supported, BitswapCommand, BitswapHandle, BitswapServiceConfig, Cid, FetchItem, + FetchOutcome, Prefix, BLAKE2B_256_MULTIHASH_CODE, KECCAK_256_MULTIHASH_CODE, LOG_TARGET, + MAX_WANTED_BLOCKS, SHA2_256_MULTIHASH_CODE, }; -use crate::bitswap::handle::BitswapError; +use crate::handle::BitswapError; use async_trait::async_trait; use cid::multihash::Multihash as CidMultihash; -use futures::StreamExt; +use futures::{Stream, StreamExt}; use litep2p::protocol::libp2p::bitswap::{ - BitswapEvent, BitswapHandle as LitepBitswapHandle, BlockPresenceType, Config as LitepConfig, - ResponseType, WantType, + BitswapEvent, BitswapHandle as LitepBitswapHandle, BlockPresenceType, ResponseType, WantType, }; use sc_client_api::BlockBackend; +use sc_network_sync::{SyncEvent, SyncEventStream}; use slotmap::{new_key_type, SlotMap}; use smallvec::SmallVec; use sp_core::H256; @@ -81,7 +86,6 @@ const MAX_OUTSTANDING_CIDS: usize = 1024; const MAX_WAITERS_PER_CID: usize = 64; const MAX_CONCURRENT_INBOUND_LOOKUPS: usize = 8; const CMD_CHANNEL_CAPACITY: usize = 256; -const PEER_EVENT_CHANNEL_CAPACITY: usize = 64; const LOOKUP_CHANNEL_CAPACITY: usize = 64; const PER_PEER_TIMEOUT: Duration = Duration::from_secs(5); const PEER_FANOUT_CAP: usize = 1; @@ -296,42 +300,63 @@ impl InboundLookupPool { } } +/// Peer-id type the actor stores in its connected-peer set and dispatches requests over. +/// We translate from [`sc_network_sync::SyncEvent`]'s `sc_network_types::PeerId` (which the +/// sync engine emits) at the actor boundary. +type ActorPeerId = litep2p::PeerId; + pub(crate) struct BitswapService { handle: Box, config: BitswapServiceConfig, cmd_rx: mpsc::Receiver, - peer_event_rx: mpsc::Receiver, + sync_event_stream: Pin + Send>>, inbound_lookup_pool: InboundLookupPool, inbound_lookup_rx: mpsc::Receiver, waiter_deadlines: DelayQueue, - connected_peers: HashSet, + connected_peers: HashSet, wants: WantSet, waiters: SlotMap, } /// Build, wire and return the Bitswap service. /// -/// The returned future MUST be spawned on the runtime; the [`BitswapWiring`] carries the -/// litep2p protocol config, user-facing handle, and peer-event sender needed during network -/// construction. -pub fn start( +/// The returned future MUST be spawned on the runtime; the returned [`BitswapHandle`] is the +/// user-facing handle exposed to consumers via the build-network output of `sc-service`. +/// +/// `litep2p_handle` is the transport-side handle minted by the caller via +/// `litep2p::protocol::libp2p::bitswap::Config::new`; the corresponding `Config` value +/// produced by the same call must be installed into the litep2p backend's IPFS layer by the +/// caller. Owning that pair-up at the caller keeps `sc-network-bitswap` from instantiating +/// the litep2p protocol config it does not own. +/// +/// `sync` is anything implementing [`SyncEventStream`] (concretely, `&*sync_service` where +/// `sync_service: Arc>` — `Arc` has a blanket [`SyncEventStream`] impl). +/// The actor subscribes inside this function via `sync.event_stream("bitswap")`; the engine +/// replays `SyncEvent::PeerConnected` for every currently-tracked peer the first time it +/// processes the subscription command, so we get an accurate startup snapshot without any +/// extra synchronisation. +pub fn start( client: Arc + Send + Sync>, + sync: &S, + litep2p_handle: LitepBitswapHandle, config: BitswapServiceConfig, -) -> (Pin + Send>>, BitswapWiring) { - let (litep2p_config, litep2p_handle) = LitepConfig::new(); +) -> (Pin + Send>>, BitswapHandle) +where + S: SyncEventStream + ?Sized, +{ let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); - let (peer_event_tx, peer_event_rx) = mpsc::channel(PEER_EVENT_CHANNEL_CAPACITY); let (inbound_lookup_pool, inbound_lookup_rx) = InboundLookupPool::new(client); let user_handle = BitswapHandle::new(cmd_tx); + let sync_event_stream = sync.event_stream("bitswap"); let service = BitswapService { handle: Box::new(litep2p_handle), config, cmd_rx, - peer_event_rx, + sync_event_stream, inbound_lookup_pool, inbound_lookup_rx, waiter_deadlines: DelayQueue::new(), @@ -341,9 +366,8 @@ pub fn start( }; let future = Box::pin(async move { service.run().await }); - let wiring = BitswapWiring { litep2p_config, user_handle: user_handle.clone(), peer_event_tx }; - (future, wiring) + (future, user_handle) } impl BitswapService { @@ -377,11 +401,11 @@ impl BitswapService { }, }, - peer_ev = self.peer_event_rx.recv() => match peer_ev { - Some(PeerEvent::Connected { peer }) => self.on_peer_connected(peer).await, - Some(PeerEvent::Disconnected { peer }) => self.on_peer_disconnected(peer).await, + sync_ev = self.sync_event_stream.next() => match sync_ev { + Some(SyncEvent::PeerConnected(peer)) => self.on_peer_connected(peer.into()).await, + Some(SyncEvent::PeerDisconnected(peer)) => self.on_peer_disconnected(peer.into()).await, None => { - log::debug!(target: LOG_TARGET, "peer event channel closed; shutting down"); + log::debug!(target: LOG_TARGET, "sync event stream ended; shutting down"); self.shutdown_waiters(); return; }, @@ -647,8 +671,10 @@ fn serve_inbound( #[cfg(test)] mod tests { use super::*; - use crate::bitswap::RAW_CODEC; + use crate::RAW_CODEC; use sc_block_builder::BlockBuilderBuilder; + use sc_network_sync::SyncEvent; + use sc_network_types::PeerId as TypesPeerId; use sp_consensus::BlockOrigin; use sp_runtime::codec::Encode; use substrate_test_runtime::ExtrinsicBuilder; @@ -679,9 +705,13 @@ mod tests { } } + /// Test helper: a sender for `SyncEvent`s that closes when dropped. We feed it via a + /// tokio mpsc and adapt to a `Stream` in [`build_rig_with`]. + type SyncEventSender = mpsc::Sender; + struct TestRig { user_handle: BitswapHandle, - peer_event_tx: mpsc::Sender, + sync_event_tx: SyncEventSender, inbound_tx: mpsc::Sender, outbound_req_rx: mpsc::Receiver<(litep2p::PeerId, Vec<(Cid, WantType)>)>, outbound_resp_rx: mpsc::Receiver<(litep2p::PeerId, Vec)>, @@ -696,7 +726,7 @@ mod tests { let (outbound_req_tx, outbound_req_rx) = mpsc::channel(64); let (outbound_resp_tx, outbound_resp_rx) = mpsc::channel(64); let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); - let (peer_event_tx, peer_event_rx) = mpsc::channel(PEER_EVENT_CHANNEL_CAPACITY); + let (sync_event_tx, sync_event_rx) = mpsc::channel::(64); let (inbound_lookup_pool, inbound_lookup_rx) = InboundLookupPool::new(client); let transport = MockTransport { @@ -705,11 +735,14 @@ mod tests { outbound_resp_tx, }; + let sync_event_stream: Pin + Send>> = + Box::pin(tokio_stream::wrappers::ReceiverStream::new(sync_event_rx)); + let service: BitswapService = BitswapService { handle: Box::new(transport), config, cmd_rx, - peer_event_rx, + sync_event_stream, inbound_lookup_pool, inbound_lookup_rx, waiter_deadlines: DelayQueue::new(), @@ -723,7 +756,7 @@ mod tests { TestRig { user_handle, - peer_event_tx, + sync_event_tx, inbound_tx, outbound_req_rx, outbound_resp_rx, @@ -756,6 +789,24 @@ mod tests { timeout(Duration::from_secs(2), rx.recv()).await.ok().flatten() } + /// Synthesise a sync `PeerConnected` for a litep2p peer. Tests use a small helper + /// because [`SyncEvent::PeerConnected`] carries [`sc_network_types::PeerId`], and we + /// want the same peer to appear in outbound bitswap requests as a [`litep2p::PeerId`]. + fn sync_connected(peer: litep2p::PeerId) -> SyncEvent { + // `litep2p::PeerId` <-> `sc_network_types::PeerId` are wire-compatible via bytes. + let bytes = peer.to_bytes(); + let types_peer = TypesPeerId::from_bytes(&bytes) + .expect("litep2p PeerId bytes are valid sc_network_types PeerId bytes; qed"); + SyncEvent::PeerConnected(types_peer) + } + + fn sync_disconnected(peer: litep2p::PeerId) -> SyncEvent { + let bytes = peer.to_bytes(); + let types_peer = TypesPeerId::from_bytes(&bytes) + .expect("litep2p PeerId bytes are valid sc_network_types PeerId bytes; qed"); + SyncEvent::PeerDisconnected(types_peer) + } + #[test] fn want_set_removes_cid_after_last_waiter_and_peer_complete() { let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xaa; 32]); @@ -783,7 +834,7 @@ mod tests { let data = b"payload-a".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let (out_peer, out_cids) = @@ -815,7 +866,7 @@ mod tests { let peer = litep2p::PeerId::random(); let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [7u8; 32]); - rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); @@ -843,7 +894,7 @@ mod tests { let peer = litep2p::PeerId::random(); let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [42u8; 32]); - rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); @@ -861,8 +912,8 @@ mod tests { let data = b"after-failover".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.peer_event_tx.send(PeerEvent::Connected { peer: peer_a }).await.unwrap(); - rig.peer_event_tx.send(PeerEvent::Connected { peer: peer_b }).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer_a)).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer_b)).await.unwrap(); let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); @@ -921,7 +972,7 @@ mod tests { let data = b"shared".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); let mut rx_a = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let mut rx_b = rig.user_handle.request_stream(vec![cid]).await.unwrap(); @@ -949,7 +1000,7 @@ mod tests { let data = b"survivor".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); let rx_a = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let mut rx_b = rig.user_handle.request_stream(vec![cid]).await.unwrap(); @@ -978,7 +1029,7 @@ mod tests { let cid_a = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [1u8; 32]); let cid_b = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [2u8; 32]); - rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); let mut rx = rig.user_handle.request_stream(vec![cid_a, cid_b]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound a or b"); @@ -1048,7 +1099,7 @@ mod tests { let real = b"the-real-payload".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &real); - rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); @@ -1115,7 +1166,7 @@ mod tests { let peer = litep2p::PeerId::random(); let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xee; 32]); - rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await; @@ -1133,13 +1184,13 @@ mod tests { let data = b"after-disconnect".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.peer_event_tx.send(PeerEvent::Connected { peer: peer_a }).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer_a)).await.unwrap(); let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); assert_eq!(first_peer, peer_a); - rig.peer_event_tx.send(PeerEvent::Connected { peer: peer_b }).await.unwrap(); - rig.peer_event_tx.send(PeerEvent::Disconnected { peer: peer_a }).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer_b)).await.unwrap(); + rig.sync_event_tx.send(sync_disconnected(peer_a)).await.unwrap(); let (second_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); assert_eq!(second_peer, peer_b); @@ -1163,7 +1214,7 @@ mod tests { let data = b"too-late".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.peer_event_tx.send(PeerEvent::Connected { peer }).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); diff --git a/substrate/client/network/src/config.rs b/substrate/client/network/src/config.rs index bab6fc3c3983..11005ab8fb8c 100644 --- a/substrate/client/network/src/config.rs +++ b/substrate/client/network/src/config.rs @@ -36,6 +36,14 @@ pub use crate::{ }; pub use sc_network_types::{build_multiaddr, ed25519}; + +/// Litep2p transport-side Bitswap handle. +/// +/// Re-exported here so callers wiring bitswap (typically `sc-service`) need not depend on +/// `litep2p` directly. Pair-minted with [`IpfsConfig`] via [`IpfsConfig::new`]; the handle is +/// then handed to `sc_network_bitswap::start` while the config is consumed by the litep2p +/// backend. +pub use litep2p::protocol::libp2p::bitswap::BitswapHandle as LitepBitswapHandle; use sc_network_types::{ multiaddr::{self, Multiaddr}, PeerId, @@ -767,16 +775,35 @@ impl NetworkConfiguration { /// IPFS server configuration. pub struct IpfsConfig { - /// Bitswap wiring produced by [`crate::bitswap::start`]. Carries the litep2p protocol - /// config, the user-facing handle, and the peer-event sender. `None` is unsupported when - /// `ipfs_server = true`. - pub bitswap_wiring: Option, + /// Litep2p Bitswap protocol config; consumed by `Litep2pConfigBuilder::with_libp2p_bitswap` + /// inside the network backend. Pair-minted with the transport handle returned alongside + /// from [`IpfsConfig::new`]. + pub litep2p_bitswap_config: litep2p::protocol::libp2p::bitswap::Config, /// Indexed transactions provider. pub block_provider: Box, /// IPFS bootstrap nodes. pub bootnodes: Vec, } +impl IpfsConfig { + /// Construct an [`IpfsConfig`] together with the litep2p transport-side Bitswap handle + /// that the bitswap service actor must own. + /// + /// The litep2p `(Config, BitswapHandle)` pair shares one protocol negotiator and must + /// therefore be minted together; returning them from the same call makes that constraint + /// type-enforced. The caller hands the returned [`LitepBitswapHandle`] to + /// `sc_network_bitswap::start`, then passes the resulting `IpfsConfig` to + /// `sc-network` via `Params::ipfs_config`. + pub fn new( + block_provider: Box, + bootnodes: Vec, + ) -> (Self, LitepBitswapHandle) { + let (litep2p_bitswap_config, litep2p_handle) = + litep2p::protocol::libp2p::bitswap::Config::new(); + (Self { litep2p_bitswap_config, block_provider, bootnodes }, litep2p_handle) + } +} + /// Network initialization parameters. pub struct Params> { /// Assigned role for our node (full, light, ...). diff --git a/substrate/client/network/src/lib.rs b/substrate/client/network/src/lib.rs index cec16c341fc4..d029123960bc 100644 --- a/substrate/client/network/src/lib.rs +++ b/substrate/client/network/src/lib.rs @@ -243,7 +243,6 @@ //! More precise usage details are still being worked on and will likely change in the future. mod behaviour; -pub mod bitswap; mod ipfs_block_provider; mod litep2p; mod protocol; @@ -284,10 +283,10 @@ pub use service::{ metrics::NotificationMetrics, signature::Signature, traits::{ - BitswapProvider, KademliaKey, MessageSink, NetworkBackend, NetworkBlock, - NetworkDHTProvider, NetworkEventStream, NetworkPeers, NetworkRequest, NetworkSigner, - NetworkStateInfo, NetworkStatus, NetworkStatusProvider, NetworkSyncForkRequest, - NotificationConfig, NotificationSender as NotificationSenderT, NotificationSenderError, + KademliaKey, MessageSink, NetworkBackend, NetworkBlock, NetworkDHTProvider, + NetworkEventStream, NetworkPeers, NetworkRequest, NetworkSigner, NetworkStateInfo, + NetworkStatus, NetworkStatusProvider, NetworkSyncForkRequest, NotificationConfig, + NotificationSender as NotificationSenderT, NotificationSenderError, NotificationSenderReady, NotificationService, }, DecodingError, Keypair, NetworkService, NetworkWorker, NotificationSender, OutboundFailure, diff --git a/substrate/client/network/src/litep2p/mod.rs b/substrate/client/network/src/litep2p/mod.rs index 1932ebbcd2df..49092ed5b696 100644 --- a/substrate/client/network/src/litep2p/mod.rs +++ b/substrate/client/network/src/litep2p/mod.rs @@ -506,15 +506,8 @@ impl NetworkBackend for Litep2pNetworkBac Arc::clone(&peer_store_handle), ); - let mut bitswap_user_handle: Option = None; - if let Some(ipfs) = params.ipfs_config { - let wiring = ipfs.bitswap_wiring.expect( - "ipfs_config.bitswap_wiring must be Some when ipfs_server is enabled; \ - build_network constructs it via sc_network::bitswap::start; qed", - ); - config_builder = config_builder.with_libp2p_bitswap(wiring.litep2p_config); - bitswap_user_handle = Some(wiring.user_handle); + config_builder = config_builder.with_libp2p_bitswap(ipfs.litep2p_bitswap_config); if !ipfs.bootnodes.is_empty() { let (ipfs_dht, kad_config) = IpfsDht::new(ipfs.bootnodes, ipfs.block_provider); @@ -576,7 +569,6 @@ impl NetworkBackend for Litep2pNetworkBac request_response_senders, Arc::clone(&listen_addresses), public_addresses, - bitswap_user_handle, )); // register rest of the metrics now that `Litep2p` has been created diff --git a/substrate/client/network/src/litep2p/service.rs b/substrate/client/network/src/litep2p/service.rs index e20a6609c5b8..ac587a545c77 100644 --- a/substrate/client/network/src/litep2p/service.rs +++ b/substrate/client/network/src/litep2p/service.rs @@ -215,9 +215,6 @@ pub struct Litep2pNetworkService { /// External addresses. external_addresses: PublicAddresses, - - /// User-facing Bitswap handle; `Some` when `--ipfs-server` is enabled, `None` otherwise. - bitswap_handle: Option, } impl Litep2pNetworkService { @@ -232,7 +229,6 @@ impl Litep2pNetworkService { request_response_protocols: HashMap>, listen_addresses: Arc>>, external_addresses: PublicAddresses, - bitswap_handle: Option, ) -> Self { Self { local_peer_id, @@ -244,13 +240,8 @@ impl Litep2pNetworkService { request_response_protocols, listen_addresses, external_addresses, - bitswap_handle, } } - - pub(crate) fn bitswap_handle(&self) -> Option { - self.bitswap_handle.clone() - } } impl NetworkSigner for Litep2pNetworkService { @@ -592,8 +583,4 @@ impl NetworkRequest for Litep2pNetworkService { } } -impl crate::service::traits::BitswapProvider for Litep2pNetworkService { - fn bitswap_handle(&self) -> Option { - self.bitswap_handle() - } -} + diff --git a/substrate/client/network/src/service.rs b/substrate/client/network/src/service.rs index ecf8db7ad712..1bef52ff4c77 100644 --- a/substrate/client/network/src/service.rs +++ b/substrate/client/network/src/service.rs @@ -1228,13 +1228,6 @@ where } } -impl crate::service::traits::BitswapProvider for NetworkService -where - B: BlockT + 'static, - H: ExHashT, -{ -} - /// A `NotificationSender` allows for sending notifications to a peer with a chosen protocol. #[must_use] pub struct NotificationSender { diff --git a/substrate/client/network/src/service/traits.rs b/substrate/client/network/src/service/traits.rs index 1ff44fa1d02a..bf4a641cdf29 100644 --- a/substrate/client/network/src/service/traits.rs +++ b/substrate/client/network/src/service/traits.rs @@ -53,27 +53,6 @@ use std::{ pub use libp2p::identity::SigningError; -/// Access to the user-facing Bitswap handle. -/// -/// Returns `Some` when the node is running on a backend that supports Bitswap (litep2p) AND -/// `--ipfs-server` is enabled, otherwise `None`. Implemented as a separate provider trait so -/// it can be supplied a default `None` impl for backends that do not support Bitswap. -pub trait BitswapProvider { - /// Returns the user-facing Bitswap handle, or `None` if Bitswap is not available. - fn bitswap_handle(&self) -> Option { - None - } -} - -impl BitswapProvider for Arc -where - T: BitswapProvider + ?Sized, -{ - fn bitswap_handle(&self) -> Option { - T::bitswap_handle(self) - } -} - /// Supertrait defining the services provided by [`NetworkBackend`] service handle. pub trait NetworkService: NetworkSigner @@ -83,7 +62,6 @@ pub trait NetworkService: + NetworkEventStream + NetworkStateInfo + NetworkRequest - + BitswapProvider + Send + Sync + 'static @@ -98,7 +76,6 @@ impl NetworkService for T where + NetworkEventStream + NetworkStateInfo + NetworkRequest - + BitswapProvider + Send + Sync + 'static diff --git a/substrate/client/service/Cargo.toml b/substrate/client/service/Cargo.toml index ce354be9d66c..4b07156a9dc0 100644 --- a/substrate/client/service/Cargo.toml +++ b/substrate/client/service/Cargo.toml @@ -36,6 +36,7 @@ sc-executor = { workspace = true, default-features = true } sc-informant = { workspace = true, default-features = true } sc-keystore = { workspace = true, default-features = true } sc-network = { workspace = true, default-features = true } +sc-network-bitswap = { workspace = true, default-features = true } sc-network-common = { workspace = true, default-features = true } sc-network-light = { workspace = true, default-features = true } sc-network-sync = { workspace = true, default-features = true } diff --git a/substrate/client/service/src/builder.rs b/substrate/client/service/src/builder.rs index 05a2ebbf86b5..3340354b3eb5 100644 --- a/substrate/client/service/src/builder.rs +++ b/substrate/client/service/src/builder.rs @@ -64,7 +64,7 @@ use sc_network_sync::{ SyncingStrategy, }, warp_request_handler::RequestHandler as WarpSyncRequestHandler, - SyncEvent, SyncEventStream, SyncingService, WarpSyncConfig, + SyncingService, WarpSyncConfig, }; use sc_rpc::{ author::AuthorApiServer, @@ -1014,6 +1014,7 @@ pub fn build_network( TracingUnboundedSender>, sc_network_transactions::TransactionsHandlerController<::Hash>, Arc>, + Option, ), Error, > @@ -1170,39 +1171,13 @@ where pub blocks_pruning: BlocksPruning, } -fn spawn_bitswap_sync_peer_events( - spawn_handle: &SpawnTaskHandle, - sync_service: Arc>, - bitswap_peer_event_tx: tokio::sync::mpsc::Sender, -) { - let mut sync_event_stream = sync_service.event_stream("bitswap-sync-peers"); - spawn_handle.spawn("bitswap-sync-peer-events", Some("networking"), async move { - while let Some(event) = sync_event_stream.next().await { - let peer_event = match event { - SyncEvent::PeerConnected(peer) => { - sc_network::bitswap::PeerEvent::Connected { peer: peer.into() } - }, - SyncEvent::PeerDisconnected(peer) => { - sc_network::bitswap::PeerEvent::Disconnected { peer: peer.into() } - }, - }; - - match bitswap_peer_event_tx.try_send(peer_event) { - Ok(()) => {}, - Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { - log::warn!( - target: "sub-libp2p::bitswap", - "dropping sync peer event because BitswapService is backlogged", - ); - }, - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => break, - } - } - }); -} - /// Build the network service, the network status sinks and an RPC sender, this is a lower-level /// version of [`build_network`] for those needing more control. +/// +/// The final tuple element is the Bitswap user handle. It is `Some` when `--ipfs-server` is +/// enabled on a litep2p backend, and `None` otherwise. Consumers that need a +/// `BitswapHandle` (e.g. omni-node populating a `BitswapHandleSlot`) read it directly from +/// this output instead of querying the network service after the fact. pub fn build_network_advanced( params: BuildNetworkAdvancedParams, ) -> Result< @@ -1211,6 +1186,7 @@ pub fn build_network_advanced( TracingUnboundedSender>, sc_network_transactions::TransactionsHandlerController<::Hash>, Arc>, + Option, ), Error, > @@ -1264,7 +1240,15 @@ where // Initialize IPFS server. Bitswap is only supported on the litep2p backend; reject the // libp2p + `--ipfs-server` combination loudly rather than silently disabling Bitswap. - let ipfs_config = if net_config.network_config.ipfs_server { + // + // The handler future and user-facing handle are produced here, but the handler is only + // spawned AFTER `Net::new` returns `Ok` (see below) so that a network construction + // failure does not leave an orphan bitswap task running. The `user_handle` is later + // returned to the caller via the build-network output tuple. + let (bitswap_handler, bitswap_user_handle, ipfs_config) = if net_config + .network_config + .ipfs_server + { if matches!( net_config.network_config.network_backend, sc_network::config::NetworkBackendType::Libp2p @@ -1276,29 +1260,30 @@ where )); } - let (handler, bitswap_wiring) = sc_network::bitswap::start::( - client.clone(), - sc_network::bitswap::BitswapServiceConfig::default(), - ); - spawn_handle.spawn("bitswap-service", Some("networking"), handler); - spawn_bitswap_sync_peer_events( - &spawn_handle, - sync_service.clone(), - bitswap_wiring.peer_event_tx.clone(), - ); - let ipfs_num_blocks = match blocks_pruning { BlocksPruning::KeepAll | BlocksPruning::KeepFinalized => IPFS_MAX_BLOCKS, BlocksPruning::Some(num) => std::cmp::min(num, IPFS_MAX_BLOCKS), }; - Some(IpfsConfig { - bitswap_wiring: Some(bitswap_wiring), - block_provider: Box::new(IpfsIndexedTransactions::new(client.clone(), ipfs_num_blocks)), - bootnodes: net_config.network_config.ipfs_bootnodes.clone(), - }) + // `IpfsConfig::new` mints the litep2p `(Config, BitswapHandle)` pair internally and + // returns the transport handle alongside the config. The handle is owned by the + // bitswap service actor; the config is installed by the litep2p backend during + // `Net::new`. + let (ipfs_config, litep2p_bitswap_handle) = IpfsConfig::new( + Box::new(IpfsIndexedTransactions::new(client.clone(), ipfs_num_blocks)), + net_config.network_config.ipfs_bootnodes.clone(), + ); + + let (handler, bitswap_user_handle) = sc_network_bitswap::start::( + client.clone(), + &*sync_service, + litep2p_bitswap_handle, + sc_network_bitswap::BitswapServiceConfig::default(), + ); + + (Some(handler), Some(bitswap_user_handle), Some(ipfs_config)) } else { - None + (None, None, None) }; // Create transactions protocol and add it to the list of supported protocols of @@ -1338,6 +1323,12 @@ where let network_mut = Net::new(network_params)?; let network = network_mut.network_service().clone(); + // Only spawn the bitswap actor after `Net::new` returned `Ok` so a network construction + // failure does not leave an orphan task running with a handle nobody will ever consume. + if let Some(handler) = bitswap_handler { + spawn_handle.spawn("bitswap-service", Some("networking"), handler); + } + let (tx_handler, tx_handler_controller) = transactions_handler_proto.build( network.clone(), sync_service.clone(), @@ -1394,7 +1385,7 @@ where // the service will shut down. spawn_essential_handle.spawn_blocking("network-worker", Some("networking"), future); - Ok((network, system_rpc_tx, tx_handler_controller, sync_service.clone())) + Ok((network, system_rpc_tx, tx_handler_controller, sync_service.clone(), bitswap_user_handle)) } /// Configuration for [`build_default_syncing_engine`]. diff --git a/substrate/client/storage-chain-sync/Cargo.toml b/substrate/client/storage-chain-sync/Cargo.toml index a3db9bd7d1de..ed04c546cc1c 100644 --- a/substrate/client/storage-chain-sync/Cargo.toml +++ b/substrate/client/storage-chain-sync/Cargo.toml @@ -22,6 +22,7 @@ sc-client-api = { workspace = true, default-features = true } sc-client-db = { workspace = true, default-features = true } sc-consensus = { workspace = true, default-features = true } sc-network = { workspace = true, default-features = true } +sc-network-bitswap = { workspace = true, default-features = true } sp-api = { workspace = true, default-features = true } sp-blockchain = { workspace = true, default-features = true } sp-consensus = { workspace = true, default-features = true } diff --git a/substrate/client/storage-chain-sync/src/fetcher.rs b/substrate/client/storage-chain-sync/src/fetcher.rs index 3a21909e72a0..7db6569d8d78 100644 --- a/substrate/client/storage-chain-sync/src/fetcher.rs +++ b/substrate/client/storage-chain-sync/src/fetcher.rs @@ -18,19 +18,19 @@ //! Bitswap-based fetcher for indexed-transaction blobs. //! -//! Thin adapter over [`sc_network::bitswap::BitswapHandle`]. Peer selection, +//! Thin adapter over [`sc_network_bitswap::BitswapHandle`]. Peer selection, //! per-peer timeouts, retries and hash verification all live in the bitswap actor //! itself. This fetcher's only jobs are: //! //! 1. building the per-want CIDs from the runtime-declared [`RenewWant`]s, -//! 2. chunking by [`sc_network::bitswap::MAX_CIDS_PER_REQUEST`] and submitting all chunks +//! 2. chunking by [`sc_network_bitswap::MAX_CIDS_PER_REQUEST`] and submitting all chunks //! concurrently, //! 3. draining the per-chunk streams into a `HashMap>`. use crate::RenewWant; use cid::{multihash::Multihash, Cid}; use futures::{future, stream::FuturesUnordered, StreamExt}; -use sc_network::bitswap::{BitswapError, BitswapRequest, FetchOutcome, MAX_CIDS_PER_REQUEST}; +use sc_network_bitswap::{BitswapError, BitswapRequest, FetchOutcome, MAX_CIDS_PER_REQUEST}; use sp_runtime::traits::Block as BlockT; use sp_transaction_storage_proof::ContentHash; use std::{ diff --git a/substrate/client/storage-chain-sync/src/lib.rs b/substrate/client/storage-chain-sync/src/lib.rs index cc38a779bfc9..74bdd0e4fdfe 100644 --- a/substrate/client/storage-chain-sync/src/lib.rs +++ b/substrate/client/storage-chain-sync/src/lib.rs @@ -577,7 +577,7 @@ fn classify_body( mod tests { use super::*; use codec::Encode; - use sc_network::bitswap::RAW_CODEC; + use sc_network_bitswap::RAW_CODEC; use sp_runtime::{generic, traits::BlakeTwo256, OpaqueExtrinsic}; use std::collections::HashSet; diff --git a/substrate/client/storage-chain-sync/tests/it.rs b/substrate/client/storage-chain-sync/tests/it.rs index 938a1eb527a8..46c16777214c 100644 --- a/substrate/client/storage-chain-sync/tests/it.rs +++ b/substrate/client/storage-chain-sync/tests/it.rs @@ -66,7 +66,7 @@ fn info( content_hash, size, hashing, - cid_codec: sc_network::bitswap::RAW_CODEC, + cid_codec: sc_network_bitswap::RAW_CODEC, extrinsic_index, } } @@ -152,7 +152,7 @@ async fn import_attached_changes_propagates_runtime_declared_codec_to_bitswap_ci "bitswap request must carry the runtime-declared codec ({DAG_PB_CODEC:#x}); observed: {observed:?}", ); assert!( - observed.iter().all(|cid| cid.codec() != sc_network::bitswap::RAW_CODEC), + observed.iter().all(|cid| cid.codec() != sc_network_bitswap::RAW_CODEC), "no request should fall back to hard-coded RAW_CODEC; observed: {observed:?}", ); @@ -532,7 +532,7 @@ mod mock { BlockCheckParams, BlockImport, BlockImportParams, ImportResult, ImportedAux, StateAction, StorageChanges as ConsensusStorageChanges, }; - use sc_network::bitswap::{ + use sc_network_bitswap::{ BitswapError, BitswapRequest, Cid as BitswapCid, FetchItem, FetchOutcome, RAW_CODEC, }; use sp_api::{ApiError, ConstructRuntimeApi}; diff --git a/substrate/frame/revive/dev-node/node/src/service.rs b/substrate/frame/revive/dev-node/node/src/service.rs index bb98d69a4727..16422cacc403 100644 --- a/substrate/frame/revive/dev-node/node/src/service.rs +++ b/substrate/frame/revive/dev-node/node/src/service.rs @@ -127,7 +127,7 @@ pub fn new_full::Ha >::new(&config.network, None); let metrics = Network::register_notification_metrics(None); - let (network, system_rpc_tx, tx_handler_controller, sync_service) = + let (network, system_rpc_tx, tx_handler_controller, sync_service, _bitswap_handle) = sc_service::build_network(sc_service::BuildNetworkParams { config: &config, net_config, diff --git a/templates/minimal/node/src/service.rs b/templates/minimal/node/src/service.rs index da8f225054b2..0ad25e2e3469 100644 --- a/templates/minimal/node/src/service.rs +++ b/templates/minimal/node/src/service.rs @@ -135,7 +135,7 @@ pub fn new_full::Ha config.prometheus_config.as_ref().map(|cfg| &cfg.registry), ); - let (network, system_rpc_tx, tx_handler_controller, sync_service) = + let (network, system_rpc_tx, tx_handler_controller, sync_service, _bitswap_handle) = sc_service::build_network(sc_service::BuildNetworkParams { config: &config, net_config, diff --git a/templates/parachain/node/src/service.rs b/templates/parachain/node/src/service.rs index e58ab306f064..59efae9c26e2 100644 --- a/templates/parachain/node/src/service.rs +++ b/templates/parachain/node/src/service.rs @@ -286,7 +286,7 @@ pub async fn start_parachain_node( // NOTE: because we use Aura here explicitly, we can use `CollatorSybilResistance::Resistant` // when starting the network. - let (network, system_rpc_tx, tx_handler_controller, sync_service) = + let (network, system_rpc_tx, tx_handler_controller, sync_service, _bitswap_handle) = build_network(BuildNetworkParams { parachain_config: ¶chain_config, net_config, diff --git a/templates/solochain/node/src/service.rs b/templates/solochain/node/src/service.rs index 331a1409c5fa..106677bedb49 100644 --- a/templates/solochain/node/src/service.rs +++ b/templates/solochain/node/src/service.rs @@ -171,7 +171,7 @@ pub fn new_full< Vec::default(), )); - let (network, system_rpc_tx, tx_handler_controller, sync_service) = + let (network, system_rpc_tx, tx_handler_controller, sync_service, _bitswap_handle) = sc_service::build_network(sc_service::BuildNetworkParams { config: &config, net_config, From 27d8c46ff84c377997536d7ce833886a731348e7 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Thu, 2 Jul 2026 10:32:30 +0200 Subject: [PATCH 06/32] Remove unwanted file, fix closed channel bug --- .sisyphus/REVIEW.md | 502 ------------------ Cargo.lock | 5 - prdoc/pr_12052.prdoc | 53 +- prdoc/pr_12243.prdoc | 32 -- substrate/client/network/Cargo.toml | 8 +- substrate/client/network/bitswap/Cargo.toml | 2 - .../client/network/bitswap/src/handle.rs | 23 +- .../client/network/bitswap/src/service.rs | 118 +++- .../client/network/src/litep2p/service.rs | 2 - substrate/client/service/src/builder.rs | 70 ++- 10 files changed, 183 insertions(+), 632 deletions(-) delete mode 100644 .sisyphus/REVIEW.md delete mode 100644 prdoc/pr_12243.prdoc diff --git a/.sisyphus/REVIEW.md b/.sisyphus/REVIEW.md deleted file mode 100644 index e20b58fa8fae..000000000000 --- a/.sisyphus/REVIEW.md +++ /dev/null @@ -1,502 +0,0 @@ -# Bitswap API Refactor — Review Walkthrough - -This walkthrough takes you through the staged changes in **reading order** (build understanding fastest), not file order. Each section names the files, the key lines, and what to look for when reviewing. - -**TL;DR diff stats**: 14 modified files, 4 deleted files, 3 new files. Net: **+1340 / -2369** lines (≈ -1000 lines overall, even after the new state machine). - -**Tests**: `cargo test -p sc-network bitswap::` — **18/18 pass.** -**Build**: `SKIP_WASM_BUILD=1 cargo check -p sc-network -p sc-service --all-targets` — clean. `cargo check -p staging-node-cli` — clean. - ---- - -## How to read this - -Each step calls out one file or one cluster, with: -- **Why this matters** — 1-2 sentence summary of the architectural intent. -- **Where to look** — file path + key line range. -- **What to verify** — what the reviewer should specifically check. - -Skim the diff stats first, then walk through in this order. Total reading time ≈ 25-40 min. - ---- - -## Step 0 — Context and scope - -Before opening any file, two pieces of context anchor the review: - -- The full design is in [Resolved Design](file:///home/sebastian/Documents/aidump/aidump/Bitswap%20API%20redesign/Resolved%20Design.md). The 10 decisions in §9 of that doc are the contract this PR delivers. -- This PR is **substrate-only**. Cumulus `storage-chain-sync` (the only consumer of the old API) is migrated in a follow-up. Master will not compile for `cumulus-client-storage-chain-sync` between this PR's merge and that follow-up. The breakage is intentional and called out in the [prdoc](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/prdoc/pr_12052.prdoc) and the doc. - -Open [prdoc/pr_12052.prdoc](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/prdoc/pr_12052.prdoc) and read it once. It's 33 lines and sets the framing for everything that follows. - ---- - -## Step 1 — The new public API surface - -**Why this matters**: this is the only thing downstream callers ever touch. If you only review one file, review this one. - -**File**: [substrate/client/network/src/bitswap/handle.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs) (new, 236 lines) - -**What to verify**: - -1. The types and shapes match the [Resolved Design §2](file:///home/sebastian/Documents/aidump/aidump/Bitswap%20API%20redesign/Resolved%20Design.md): - - `BitswapHandle` is `Clone`, holds a single `mpsc::Sender`. - - The single public method is [`request_stream`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs#L121-L162). No `request`. - - Return shape is `Result>, BitswapError>` — outer admission errors, inner per-item errors (decision Q3). - - `FetchOutcome` has only two variants — `Block(Vec)` and `Missing`. All operational causes for missing collapse into one variant (decision Q5/Q8). - - `BitswapError` has 5 variants: `Unavailable`, `ServiceClosed`, `InvalidCid { cid }`, `Overloaded`, `TooManyCids { requested, max }`. `InvalidCid` carries only the `Cid` (decision Q8). - - `BitswapServiceConfig` has only `request_timeout: Duration`, default 30s (decision Q9). - -2. Admission logic in [`request_stream`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs#L137-L162): - - Empty wantlist → immediately-closed receiver, not an error. - - `cids.len() > MAX_CIDS_PER_REQUEST` (=16) → `TooManyCids`. - - First unsupported CID → `InvalidCid`. - - Sink capacity is `cids.len() + 1` — the `+1` reserves a slot for a possible terminal `Err(ServiceClosed)`. Comment on [line 23-24](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs#L23-L24) of that area explains this invariant. - - `cmd_tx.try_send` maps Full → `Overloaded`, Closed → `ServiceClosed`. - -3. [`BitswapCommand`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs#L178-L191) is `pub(crate)` only — the actor's internal message type. - -4. [`PeerEvent`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs#L193-L216) is `pub` (the litep2p backend publishes into it). Three variants: `Snapshot { peers }`, `Connected { peer }`, `Disconnected { peer }`. The `Snapshot` variant is defined for forward-compat (currently never sent — see Step 4 for why this is fine). - -5. [`BitswapWiring`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/handle.rs#L218-L237) carries everything needed to install Bitswap into the litep2p backend: `litep2p_config` (consumed by `with_libp2p_bitswap`), `user_handle` (stored on `Litep2pNetworkService`), `peer_event_tx` (the main-loop publishes into this). - -**Red flags to look for**: -- Any `pub` item without a docstring → there shouldn't be any. -- Admission that pushes an error onto the sink instead of returning it — admission errors are synchronous Returns; only `ServiceClosed` and `Overloaded` can flow through the sink (and only the actor inserts them). - ---- - -## Step 2 — The trait surgery in `NetworkService` / `NetworkBackend` - -**Why this matters**: this is the user-visible surface change. `NetworkService` gains `bitswap_handle()`. `NetworkBackend` loses `BitswapConfig`/`bitswap_server`. The mechanism is a new supertrait `BitswapProvider`. This pattern is non-obvious — make sure you understand it before reading the impl sites. - -**File**: [substrate/client/network/src/service/traits.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service/traits.rs) - -**What to verify**: - -1. [`BitswapProvider` trait](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service/traits.rs#L56-L67) has a default `bitswap_handle() -> None` body. Backends that don't support Bitswap (libp2p) can use the default, backends that do (litep2p) override. - -2. [`impl BitswapProvider for Arc`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service/traits.rs#L69-L76) blanket. This is required because `NetworkBackend::NetworkService` is wrapped in `Arc` (e.g. `Arc>`). Every other subtrait in this file has the same `Arc` blanket impl — search for `^impl .* for Arc` and you'll see 8 of them. - -3. [`NetworkService` supertrait](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service/traits.rs#L79-L92) gains `+ BitswapProvider`. The blanket impl on `T` (line 94-107) also gains it. **This is the key insight**: because `NetworkService` is a marker-trait with a blanket impl over its subtraits, every concrete type that implements all 7 (now 8) subtraits automatically gets `NetworkService`. Adding the new method means adding a new subtrait, not adding a method to the marker. - -4. [`NetworkBackend` trait](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service/traits.rs#L131-L181): `type BitswapConfig` and `fn bitswap_server` are GONE. Compare with the diff hunk showing lines 148-149 and 162-167 removed. - -**Red flags**: -- The `?Sized` bound on [`impl BitswapProvider for Arc`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service/traits.rs#L69-L72) — required because `Arc` is constructed elsewhere (Litep2pNetworkBackend returns `Arc` from `network_service()`). Without `?Sized` the blanket wouldn't apply to `Arc`. -- Should the default `None` body be in the trait at all? Yes — the libp2p backend benefits from it (Step 3). Removing the default would force every NetworkService implementer (including third-party ones, if any) to add a stub impl. - ---- - -## Step 3 — The two `BitswapProvider` impls (libp2p stub, litep2p real) - -**Why this matters**: confirms the trait machinery works for both backends. - -**File 1**: [substrate/client/network/src/service.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/service.rs) (libp2p `NetworkWorker`) - -Look for the line `impl crate::service::traits::BitswapProvider for NetworkService` near the end of the existing 7-trait-impl block (after `NetworkRequest`). The impl body is **empty** — it uses the default `None`. That's the entire change on the libp2p side. - -Also verify the diff REMOVES `type BitswapConfig = RequestResponseConfig` and the `fn bitswap_server` impl in the `impl NetworkBackend for NetworkWorker` block. - -**File 2**: [substrate/client/network/src/litep2p/service.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/litep2p/service.rs) - -Two things to verify: - -1. The struct field [`bitswap_handle: Option`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/litep2p/service.rs#L221-L222) replaces the old `bitswap_cmd_tx: Option>`. The constructor takes it as a parameter (line 237). - -2. The `BitswapProvider` impl at the end of the file delegates to the inherent method: - ```rust - impl crate::service::traits::BitswapProvider for Litep2pNetworkService { - fn bitswap_handle(&self) -> Option { - self.bitswap_handle() - } - } - ``` - The inherent method (around line 251) just `.clone()`s the field. - -3. The whole `route_bitswap_request` method (was lines 253-324) is **gone**. - -4. The bitswap protocol-name special-case in `start_request` (was around line 648, `if protocol.as_ref() == crate::bitswap::PROTOCOL_NAME`) is **gone**. - -**Red flags**: -- The libp2p impl is empty — make sure that's not because I forgot something. Default body returns `None`, which is correct semantics for libp2p (Bitswap not supported there). -- `Litep2pNetworkService` no longer imports `prost::Message` or `ProtoBitswapWantType` — confirm the removed imports in the diff. - ---- - -## Step 4 — The state machine: BitswapService actor - -**Why this matters**: this is the heart of the implementation. The actor's correctness is what makes the API safe. - -**File**: [substrate/client/network/src/bitswap/service.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs) (new, 1071 lines including tests) - -Recommended reading order WITHIN this file: - -### 4a. Internal constants ([lines 60-69](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L60-L69)) - -```rust -const MAX_OUTSTANDING_CIDS: usize = 1024; -const MAX_WAITERS_PER_CID: usize = 64; -const MAX_CONCURRENT_INBOUND_LOOKUPS: usize = 8; -const CMD_CHANNEL_CAPACITY: usize = 256; -const PEER_EVENT_CHANNEL_CAPACITY: usize = 64; -const LOOKUP_CHANNEL_CAPACITY: usize = 64; -const PER_PEER_TIMEOUT: Duration = Duration::from_secs(5); -const PEER_FANOUT_CAP: usize = 1; -const PEER_TIMEOUT_SWEEP_INTERVAL: Duration = Duration::from_secs(1); -``` - -These match [Resolved Design §5](file:///home/sebastian/Documents/aidump/aidump/Bitswap%20API%20redesign/Resolved%20Design.md). Verify the values are as the design says. - -### 4b. `BitswapTransport` trait ([lines 35-54](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L35-L54)) - -This is the test seam. `LitepBitswapHandle` is wrapped behind this trait so unit tests can inject mock events. The production impl is a 3-method passthrough. - -**What to verify**: only 3 methods (`next_event`, `send_request`, `send_response`). No leaky abstractions. Production impl is trivial. - -### 4c. Data model ([lines 71-98](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L71-L98)) - -```rust -struct CidState { - tried_peers: HashSet, - in_flight_peers: HashMap, - waiters: SmallVec<[WaiterId; 2]>, -} - -struct Waiter { - cids_remaining: HashSet, - sink: mpsc::Sender, - delay_key: delay_queue::Key, -} -``` - -**This is the most important diff in the PR.** Confirm: -- `CidState` does **not** carry a `deadline` field. Per-waiter deadlines, not per-CID (decision Q1). -- `CidState.waiters` is a SmallVec — most CIDs have 1-2 waiters; allocation-free in the common case. -- `Waiter.cids_remaining` is a `HashSet` — we decrement it as each CID resolves; the waiter completes when it's empty. -- `Waiter.delay_key` is the `DelayQueue::Key` returned at insert time, used to cancel-on-early-completion (function [`drop_waiter`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L364-L376)). - -### 4d. `BitswapService` struct ([lines 100-115](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L100-L115)) - -Six "wire" fields (cmd_rx, peer_event_rx, lookup_{tx,rx}, lookup_semaphore, waiter_deadlines) and three "state" fields (connected_peers, wants, waiters). Plus `handle`, `client`, `config`. - -**What to verify**: -- `handle: Box` — the test seam. -- `wants: HashMap` — deduplicated across waiters (key insight). -- `waiters: SlotMap` — slotmap because we need stable keys across removals. -- `waiter_deadlines: DelayQueue` — fires when a waiter's deadline elapses; produces a `WaiterId` we look up in `waiters`. - -### 4e. `pub fn start` ([lines 122-154](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L122-L154)) - -The entry point called by `build_network`. Returns `(future, BitswapWiring)`. The future MUST be spawned; the wiring MUST be passed into the litep2p backend. - -**What to verify**: the three channels (cmd, peer_event, lookup) are constructed here with their capacity constants. The `LitepConfig::new()` call is where the litep2p protocol config + handle pair come from. - -### 4f. The `run()` loop ([lines 159-204](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L159-L204)) - -**Six select arms** (one more than the Resolved Design — peer-timeout sweep was added as a separate arm rather than opportunistic checks, for clarity): - -1. Inbound BitswapEvent (Request / Response). -2. User commands (RequestStream). -3. Peer events (Snapshot / Connected / Disconnected). -4. Waiter deadline expiry — gated on `!self.waiter_deadlines.is_empty()` (DelayQueue panics if you poll it empty). -5. Completed blocking lookup → forward via `handle.send_response`. -6. Per-peer timeout ticker. - -**What to verify**: -- No `.await` on storage inside any arm body. Confirm by reading each arm. -- No `.await` on user sinks. The actor uses `try_send` everywhere. Search for `sink.try_send` (about 4 hits). -- No `.await` on the litep2p `handle.send_request` / `send_response` that could conceivably block for an unbounded time. (litep2p's internal channel is 4096 entries deep per the registry source — not infinite, but in practice never blocks. If this becomes a concern, the fix is routing outbound through an internal sender task.) -- `None` from any channel → `shutdown_waiters()` is called → all waiters get `Err(ServiceClosed)` and the loop returns. - -### 4g. `on_request_stream` ([lines 206-244](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L206-L244)) - -Admission-time validation in the actor. Handle.rs already did the cheap checks; this is the second-line defense against races. - -**What to verify**: -- `new_cid_count` filter only counts CIDs not already in `wants` — overlapping waiters don't double-charge the budget. -- `MAX_WAITERS_PER_CID` cap. -- `SlotMap::insert_with_key` is used so the `Waiter`'s `delay_key` can be inserted into `DelayQueue` with the slot's actual key — no placeholder dance needed. -- After insertion, every CID gets `top_up_in_flight` called. - -### 4h. Peer fanout: `top_up_in_flight` ([lines 246-265](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L246-L265)) - -**This is where CAP=1 lives.** - -**What to verify**: -- Returns early if `in_flight_peers.len() >= PEER_FANOUT_CAP` (= 1 in v1). -- Picks the first peer in `connected_peers \ tried_peers \ in_flight_peers`. -- Records the per-peer deadline as `Instant::now() + PER_PEER_TIMEOUT` (= 5s). -- Calls `self.handle.send_request(peer, vec![(cid, WantType::Block)]).await` — sends only one CID per request (no batching across waiters). This is intentional; batching is a future optimization that doesn't change correctness. - -### 4i. Hash verification: `on_inbound_response` + `recompute_cid` ([lines 277-321 and 478-487](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L277-L321)) - -**This is the security-critical part.** - -For each inbound `Block`: -1. Move the peer to `tried_peers` for the CID it responded for. -2. Recompute the CID from `(prefix.codec, prefix.mh_type, hash(prefix.mh_type, data))` via [`recompute_cid`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L478-L487). -3. Look up the recomputed CID in `wants`. If absent → dropped silently. -4. If present → call `deliver_block`. - -**What to verify**: -- The recomputed CID — NOT the claimed CID — is what's looked up in `wants`. A peer cannot make us accept arbitrary bytes by lying about the CID in the response. -- [`hash_for_multihash_code`](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L489-L496) supports only the 3 blessed multihash codes (Blake2b-256, SHA2-256, Keccak-256). Unsupported codes → `None` → recompute fails → block dropped. -- Presence responses (`Have`/`DontHave`) move the peer to `tried_peers` and trigger top-up. They do NOT directly cause `Missing` for the waiter — that happens at deadline time when no peer ever delivered the block. - -### 4j. Multi-waiter delivery: `deliver_block` ([lines 332-352](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L332-L352)) - -**What to verify**: -- The bytes are wrapped in `Arc>` internally to avoid cloning across multiple waiters interested in the same CID. The clone at delivery time is unavoidable (mpsc::Sender takes ownership), but it's at most one Vec clone per waiter, not the whole-payload-per-waiter that a naive impl would do. -- `try_send` on the sink — a slow/closed waiter triggers `drop_waiter` for that one waiter, NOT for the others. -- After the loop, if no waiter remains, the CID's CidState is removed (via `drop_waiter`'s GC logic). - -### 4k. Cancellation: `drop_waiter` ([lines 364-376](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L364-L376)) - -**What to verify**: -- Removes the waiter from `waiters` (SlotMap). -- Removes it from `waiter_deadlines` (so the DelayQueue doesn't fire later). -- For each `cid` in the waiter's `cids_remaining`, removes the waiter from `CidState.waiters`. If the CidState has no waiters and no in-flight peers left → it's GC'd from `wants`. - -### 4l. Deadline expiry: `on_waiter_expired` ([lines 378-395](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L378-L395)) - -Each remaining CID gets `Ok((cid, Missing))` sent via `try_send`. The waiter is then removed from every `CidState.waiters`. Same GC as `drop_waiter`. No `Err(...)` is emitted — Missing is the right outcome, not an error. - -### 4m. Peer events: `on_peer_*` ([lines 397-432](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L397-L432)) - -**What to verify**: -- `Connected`: insert into `connected_peers`, then top up every CID in `wants` (since a previously-blocked CID might now have a peer to try). -- `Disconnected`: remove from `connected_peers`. Also remove the peer from `in_flight_peers` of every CID. Then top up those CIDs (failover). -- `Snapshot`: replaces `connected_peers` wholesale, then top up. **Currently never sent** — the actor is constructed before the litep2p backend's `run()` starts, so there are no pre-existing peers to snapshot. The variant exists for forward-compat. - -### 4n. Per-peer timeout sweep ([lines 434-457](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L434-L457)) - -Fires every 1s. Walks `wants` and removes `in_flight_peers` entries whose `Instant` deadline has passed. Each timed-out peer is moved to `tried_peers`, then top-up is called. - -**What to verify**: -- The retain-based pattern is idiomatic. -- After collecting timed-out `(cid, peer)` pairs, the peers are added to `tried_peers` and top-up is called per CID. The split-borrow pattern is necessary because the `retain` closure can't call `self.top_up_in_flight`. - -### 4o. Inbound serving: `on_inbound_request` ([lines 459-475](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L459-L475)) - -**This addresses the [[Bitswap Service Blocking Follow Up]] concern.** - -**What to verify**: -- `lookup_semaphore.try_acquire_owned()` bounds inbound serving concurrency at 8. If saturated → drop the request silently. Old peer eventually times out — no fabricated DontHave. -- `tokio::task::spawn_blocking` for the actual DB read. The semaphore permit is dropped when the spawned task completes. -- Completion arrives on the loop's arm 5 (`lookup_rx`) which then forwards via `handle.send_response`. This is the only place the actor's main loop interacts with disk IO. - -### 4p. Tests ([lines 562-1071](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L562-L1071)) - -16 tests, one per scenario from [Resolved Design §7.1](file:///home/sebastian/Documents/aidump/aidump/Bitswap%20API%20redesign/Resolved%20Design.md). The `TestRig` harness ([lines 596-635](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/service.rs#L596-L635)) gives each test a `MockTransport` for injecting BitswapEvents and observing outbound traffic. - -**What to verify**: -- Most tests use `#[tokio::test(start_paused = true)]` + `tokio::time::advance()` to deterministically test deadlines. No real wall-clock waits. -- `corrupted_block_rejected_then_missing_at_deadline` — hash verification works: peer sends bytes that don't hash to the claimed CID, block is silently dropped, eventually Missing. -- `inbound_request_with_known_block_serves_it` — full inbound path against a real `substrate-test-runtime-client` with an indexed transaction. -- `service_shutdown_emits_service_closed` — dropping the inbound channel causes the actor to call `shutdown_waiters`, which emits `Err(ServiceClosed)` on each waiter's sink. - -**Run them yourself**: -```bash -cd /home/sebastian/work/tries/2026-05-13-bitswap-API-improvement -cargo test -p sc-network bitswap:: -``` -Expected: `18 passed; 0 failed`. - ---- - -## Step 5 — The bitswap module trimmed down - -**Why this matters**: confirms what survives from the old module, and that nothing relies on the deleted protobuf schema. - -**File**: [substrate/client/network/src/bitswap/mod.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/mod.rs) - -The file went from 665 → ~110 lines. What survives: - -- License + module doc. -- `mod handle; mod service;` + re-exports of the public types. -- `MAX_WANTED_BLOCKS` (16), `RAW_CODEC` (0x55), the three multihash constants. -- `is_cid_supported` + `is_supported_multihash_code` — the validation helpers. -- The `Prefix` struct + `From<&Cid>` impl. **No more `to_bytes`** — the actor doesn't construct protobuf prefixes, litep2p does that internally. -- 2 unit tests for `is_cid_supported`. - -What's GONE: -- `BitswapRequestHandler` (the libp2p request-response handler) — 152 lines, deleted. -- `RequestHandlerError` enum. -- The 7 libp2p-specific tests (`undecodable_message`, `empty_want_list`, `too_long_want_list`, `transaction_not_found`, `transaction_found`, `transaction_not_found_sends_dont_have_when_requested`, `transaction_found_sends_have_for_want_have`). -- `mod schema` and the protobuf re-export `BitswapProtoMessage`. -- The `decode_prefix` function and `PrefixDecodeError` type. -- `mod client` + the entire `pub use client::*` re-export block. - ---- - -## Step 6 — Deleted files (the litep2p shim, the libp2p path, the protobuf schema) - -**Why this matters**: these are the deletions that make the new design coherent. None of them should have hidden references elsewhere. - -| Deleted file | Reason | Verify by | -|---|---|---| -| [substrate/client/network/src/bitswap/client.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/client.rs) (906 lines) | The old `request_bitswap_blocks` / `request_bitswap_blocks_unverified` helper — the "req-resp mockery" the issue calls out. | `rg request_bitswap_blocks` → only mentions in docs. | -| [substrate/client/network/src/litep2p/bitswap.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/litep2p/bitswap.rs) (653 lines) | The old `BitswapService` + `PendingBatch` shim that re-encoded protobuf bytes. | `rg PendingBatch\|BitswapOutboundCmd` → no hits. | -| [substrate/client/network/src/bitswap/schema.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/bitswap/schema.rs) | Just `include!`-ed the prost-generated protobuf bindings. No longer needed (litep2p owns the wire format). | `rg BitswapProtoMessage` → no hits. | -| [substrate/client/network/src/schema/bitswap.v1.2.0.proto](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/schema/bitswap.v1.2.0.proto) | The protobuf source file. | `rg \\.proto` (in sc-network) → no hits. | -| [substrate/client/network/build.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/build.rs) | Compiled the protobuf. | (file gone; cargo skips build step entirely.) | - -**What to verify**: no compilation references to any of these from elsewhere. The two `prost` / `prost-build` dependencies are removed from Cargo.toml (next step). - ---- - -## Step 7 — Cargo.toml dependency churn - -**File**: [substrate/client/network/Cargo.toml](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/Cargo.toml) - -Adds: -- `slotmap = { workspace = true }` — for stable waiter IDs. -- `tokio-util = { features = ["time"], workspace = true }` — for `DelayQueue`. -- `tokio` features: added `rt` (needed by `tokio::task::spawn_blocking`). -- In dev-deps: `tokio` features added `test-util` (for `start_paused = true` + `tokio::time::advance`). - -Removes: -- `prost = { workspace = true }` — no longer parsing/constructing Bitswap protobuf in this crate. -- `[build-dependencies] prost-build = ...` — entire section gone with build.rs. - -**What to verify**: all four added items are workspace-managed (no version pins introduced here). `tokio-util` is now in both `[dependencies]` (with `time` feature) and `[dev-dependencies]` (with `compat` feature) — cargo merges features, this is correct. - ---- - -## Step 8 — Wiring: `Litep2pNetworkBackend` and `build_network` - -**Why this matters**: connects the new service to the actual node startup path. - -### 8a. `IpfsConfig` lost its generics - -**File**: [substrate/client/network/src/config.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/config.rs) - -Old: `pub struct IpfsConfig> { bitswap_config: N::BitswapConfig, block_provider, bootnodes }`. - -New: `pub struct IpfsConfig { bitswap_wiring: Option, block_provider, bootnodes }`. - -Two things to verify: -1. The generics dropped because no remaining field is generic over `(Block, H, N)`. -2. `Params::ipfs_config: Option` (no generics) — this means the 5 test/bench harnesses that set `ipfs_config: None` continue to compile unchanged (verified during implementation by running `cargo check` workspace-wide). - -### 8b. `Litep2pNetworkBackend::new` - -**File**: [substrate/client/network/src/litep2p/mod.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/litep2p/mod.rs) - -The bitswap-init block (was lines 511-529 in the old code) was replaced. New behavior: - -```rust -if let Some(ipfs) = params.ipfs_config { - let wiring = ipfs.bitswap_wiring.expect("...; qed"); - config_builder = config_builder.with_libp2p_bitswap(wiring.litep2p_config); - bitswap_user_handle = Some(wiring.user_handle); - bitswap_peer_event_tx = Some(wiring.peer_event_tx); - // ... DHT setup (unchanged) ... -} -``` - -The `.expect("...; qed")` is the invariant: if `ipfs_config` is `Some`, `bitswap_wiring` must also be `Some`. This invariant is upheld by `build_network` (step 8c). - -Two new fields on `Litep2pNetworkBackend`: -- `bitswap_peer_event_tx: Option>` -- `bitswap_peer_conn_count: HashMap` — see step 8d below. - -### 8c. `build_network` (the libp2p+ipfs_server guard) - -**File**: [substrate/client/service/src/builder.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/service/src/builder.rs) - -The old `Net::bitswap_server(client.clone())` call (was line 1246) is replaced by: - -1. A runtime guard: `if libp2p backend && ipfs_server → return Err(...)`. The error message says "Bitswap requires the litep2p network backend; set --network-backend litep2p or disable --ipfs-server". -2. A call to `sc_network::bitswap::start::(client.clone(), BitswapServiceConfig::default())`. -3. Spawn the returned future as `"bitswap-service"`. -4. Construct `IpfsConfig { bitswap_wiring: Some(bitswap_wiring), ... }`. - -**What to verify**: -- The guard fires BEFORE `bitswap::start` is called → no wasted setup on the error path. -- The match expression uses `matches!(..., NetworkBackendType::Libp2p)` — easy to read. -- The error type is `Error::Other(...)` which is the existing pattern for build-time configuration errors in this file. - -### 8d. Peer-event broadcast from the litep2p main loop - -**File**: [substrate/client/network/src/litep2p/mod.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/litep2p/mod.rs) (the `run()` method, around the `ConnectionEstablished` / `ConnectionClosed` arms) - -**What to verify**: -- The bitswap publish happens **BEFORE** the `let Some(metrics) = &self.metrics else { continue }` guard. This is intentional — the main loop's `self.peers` map is only populated when metrics are enabled, so I cannot rely on it for bitswap peer dedup. The new `bitswap_peer_conn_count` map is populated unconditionally (when bitswap is enabled). -- The dedup logic: increment on every `ConnectionEstablished`, publish `Connected` only on 0→1 transition. Decrement on every `ConnectionClosed`, publish `Disconnected` only on 1→0 transition. This handles peers with multiple concurrent connections correctly. -- `tx.try_send(...)` is used (never blocks the main loop). If the actor's peer_event_rx is full, the event is silently dropped — acceptable because the next ConnectionEstablished/Closed will re-converge state. - ---- - -## Step 9 — Re-exports - -**File**: [substrate/client/network/src/lib.rs](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/substrate/client/network/src/lib.rs) - -The `pub use service::traits::{...}` block gains `BitswapProvider`. That's the only change. - -`BitswapHandle`, `BitswapError`, `FetchOutcome`, etc are re-exported via `crate::bitswap::*` — `bitswap` was already a `pub` module, no change needed. - -**What to verify**: callers can write either `use sc_network::bitswap::BitswapHandle;` or `use sc_network::BitswapProvider;` (the trait — needed to call `.bitswap_handle()` on a NetworkService). - ---- - -## Step 10 — The prdoc - -**File**: [prdoc/pr_12052.prdoc](file:///home/sebastian/work/tries/2026-05-13-bitswap-API-improvement/prdoc/pr_12052.prdoc) - -33 lines. Sets reviewer expectation: - -- `sc-network`: major bump (API surface changed — `bitswap_server` trait method gone, `BitswapConfig` associated type gone). -- `sc-service`: minor bump (just one site touched, behavior preserved when ipfs_server=false). -- Calls out the substrate-only scope and the temporary cumulus breakage. - ---- - -## Final QA you should run yourself - -```bash -cd /home/sebastian/work/tries/2026-05-13-bitswap-API-improvement - -# 1. Build sc-network + sc-service. -SKIP_WASM_BUILD=1 cargo check -p sc-network -p sc-service --all-targets - -# 2. Build the node binary (this is what verifies end-to-end wiring). -SKIP_WASM_BUILD=1 cargo check -p staging-node-cli --lib - -# 3. Run the bitswap test suite. -cargo test -p sc-network bitswap:: - -# 4. (Optional) Confirm the format pass was applied. -cargo fmt -p sc-network -p sc-service --check -``` - -Expected: -- All checks pass. -- 18/18 bitswap tests pass. -- `cargo fmt --check` is clean. - ---- - -## What's deliberately NOT covered by this PR - -(So you don't ask about them.) - -- Cumulus consumer migration — separate follow-up PR. -- A zombienet end-to-end integration test that drives Bitswap between two nodes — the Resolved Design §7.2 lists it as future work. Unit tests cover the actor's logic; the real two-node test requires the cumulus migration to give us an actual production consumer. -- A clippy pass — let CI run that. Local clippy on `-p sc-network` is clean against the touched code (the warnings that remain are pre-existing in untouched files). -- Real bitswap sessions, HAVE preflight, per-handle quotas, per-call deadline overrides, higher peer-fanout CAP — listed as known follow-ups in Resolved Design §10. - ---- - -## Total time to review - -If you trust the test suite (and you should — 16 new scenario tests cover the decision matrix), the minimum useful review is: - -1. **Skim Step 0 + Step 1** (5 min): confirms the public API matches the design contract. -2. **Read Step 2** (5 min): confirms the trait surgery is sound. -3. **Read Step 4a → 4o** (15-20 min): confirms the actor's correctness invariants. The 4i (hash verification) and 4o (inbound offload) sections deserve special attention. -4. **Skim Step 8** (5 min): confirms the wiring is intentional. -5. **Run the QA commands above** (10-30 min depending on cold/warm cargo cache). - -≈ 45 min for a high-confidence review. Up to 90 min if you want to read every test individually. diff --git a/Cargo.lock b/Cargo.lock index 8777be611e6a..950f4f718429 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21474,7 +21474,6 @@ dependencies = [ "litep2p 0.14.3", "log", "mockall", - "multihash-codetable", "multistream-select", "parity-scale-codec", "parking_lot 0.12.3", @@ -21490,13 +21489,11 @@ dependencies = [ "schnellru", "serde", "serde_json", - "slotmap", "smallvec", "sp-arithmetic 23.0.0", "sp-blockchain 28.0.0", "sp-consensus 0.32.0", "sp-core 28.0.0", - "sp-crypto-hashing 0.1.0", "sp-runtime 31.0.1", "sp-tracing 16.0.0", "substrate-prometheus-endpoint 0.17.0", @@ -21573,7 +21570,6 @@ dependencies = [ "futures", "litep2p 0.14.3", "log", - "multihash-codetable", "sc-block-builder", "sc-client-api 28.0.0", "sc-network-sync", @@ -21584,7 +21580,6 @@ dependencies = [ "sp-core 28.0.0", "sp-crypto-hashing 0.1.0", "sp-runtime 31.0.1", - "sp-tracing 16.0.0", "substrate-test-runtime", "substrate-test-runtime-client", "thiserror 1.0.65", diff --git a/prdoc/pr_12052.prdoc b/prdoc/pr_12052.prdoc index 6f28884972fe..c798df5143d1 100644 --- a/prdoc/pr_12052.prdoc +++ b/prdoc/pr_12052.prdoc @@ -1,33 +1,54 @@ -title: "Network: refactor Bitswap client API to a long-lived service" +title: "Network: rework the Bitswap client into a long-lived service in `sc-network-bitswap`" doc: - audience: Node Dev description: | - Replaces the request-response-shaped `request_bitswap_blocks` helper introduced in - PR #12038 with a long-lived Bitswap service exposed via - `NetworkService::bitswap_handle()`. The handle's only method is + Replaces the request-response-shaped `request_bitswap_blocks` helper with a + long-lived Bitswap service living in the new `sc-network-bitswap` crate. The + service is started by `sc-service` when `--ipfs-server` is enabled and its + user-facing `BitswapHandle` is returned as the last element of the + `build_network`/`build_network_advanced` output tuple (`None` when Bitswap is + not enabled). The handle's only method is `request_stream(cids) -> Receiver>`, which streams per-CID outcomes as they resolve. - The service runs an internal actor that owns peer selection, per-peer timeouts (5s), - per-waiter deadlines (configurable, default 30s), hash verification on every received - block, and bounded admission. Inbound serving is off-loaded to a bounded + The service runs an internal actor that owns peer selection (fed by + `sc-network-sync` peer connect/disconnect events), per-peer timeouts (5s), + per-waiter deadlines (default 30s), hash verification of every received block, + and bounded admission. Inbound serving is off-loaded to a bounded `spawn_blocking` worker pool so storage reads never block outbound traffic or - response correlation. + response correlation. Inbound serving keeps running even if no consumer holds a + `BitswapHandle`. - The libp2p Bitswap implementation is removed entirely. Enabling `--ipfs-server` - together with `--network-backend libp2p` now fails at startup with a clear error. + The libp2p Bitswap implementation is removed; Bitswap now requires the litep2p + network backend. Enabling `--ipfs-server` together with + `--network-backend libp2p` fails at startup with a clear error. The + `NetworkBackend::bitswap_server` trait method and the `BitswapConfig` + associated type are removed; `sc_network::config::IpfsConfig` is now built via + `IpfsConfig::new`, which also mints the transport handle consumed by + `sc_network_bitswap::start`. - The unverified Bitswap client codepath is removed as obsolete. + `sc-storage-chain-sync` is migrated to the new API: `IndexedTransactionFetcher` + no longer rotates across peers or applies its own timeouts; it builds CIDs, + chunks the wantlist and drains the result streams. `BitswapPeerSource`, + `NetworkHandle` and `SyncingHandle` are replaced by a single + `BitswapHandleSlot`, which the omni-node populates from the `build_network` + output. Closes https://github.com/paritytech/polkadot-sdk/issues/12052. - Note: this PR is substrate-only. The cumulus storage-chain-sync consumer of the old - helper is migrated in a separate follow-up PR; until that follow-up lands, - `cumulus-client-storage-chain-sync` will not compile against master. - crates: - name: sc-network bump: major + - name: sc-network-bitswap + bump: major - name: sc-service - bump: minor + bump: major + - name: sc-storage-chain-sync + bump: major + - name: cumulus-client-service + bump: major + - name: polkadot-omni-node-lib + bump: major + - name: polkadot-service + bump: patch diff --git a/prdoc/pr_12243.prdoc b/prdoc/pr_12243.prdoc deleted file mode 100644 index ff1963f49c81..000000000000 --- a/prdoc/pr_12243.prdoc +++ /dev/null @@ -1,32 +0,0 @@ -title: "Storage-chain sync: migrate to the new bitswap `BitswapHandle` API" - -doc: - - audience: Node Dev - description: | - Adapts `sc-storage-chain-sync` and the omni-node wiring to the redesigned - bitswap client API introduced in PR #12052. - - The `IndexedTransactionFetcher` no longer rotates across peers, applies its - own per-peer timeouts, or queries `SyncingService::peers_info()`. All of - that has moved into the bitswap service actor. The fetcher is now a thin - adapter that builds CIDs, chunks the wantlist by `MAX_CIDS_PER_REQUEST`, - submits all chunks concurrently via `BitswapHandle::request_stream`, and - drains the resulting per-chunk streams. - - `BitswapPeerSource`, `NetworkHandle` and `SyncingHandle` are removed. - They are replaced by a single `BitswapHandleSlot` and a crate-internal - `BitswapClient` trait so the integration tests can mock at the bitswap - handle boundary without depending on the bitswap protobuf schema. - - The omni-node wiring in `polkadot-omni-node-lib` is updated accordingly: - the partial-components tuple carries one bitswap-handle slot instead of - two handles, and `start_node` populates the slot from the bitswap handle - returned in the build-network output tuple (see the follow-up PR that - extracts the bitswap crate; `NetworkService::bitswap_handle()` is - removed as part of that move). - -crates: - - name: sc-storage-chain-sync - bump: major - - name: polkadot-omni-node-lib - bump: patch diff --git a/substrate/client/network/Cargo.toml b/substrate/client/network/Cargo.toml index cc2470d863e7..6d2b62eddf59 100644 --- a/substrate/client/network/Cargo.toml +++ b/substrate/client/network/Cargo.toml @@ -55,17 +55,14 @@ sc-utils = { workspace = true, default-features = true } schnellru = { workspace = true } serde = { features = ["derive"], workspace = true, default-features = true } serde_json = { workspace = true, default-features = true } -slotmap = { workspace = true } smallvec = { workspace = true, default-features = true } sp-arithmetic = { workspace = true, default-features = true } sp-blockchain = { workspace = true, default-features = true } sp-core = { workspace = true, default-features = true } -sp-crypto-hashing = { workspace = true, default-features = true } sp-runtime = { workspace = true, default-features = true } thiserror = { workspace = true } -tokio = { features = ["macros", "rt", "sync"], workspace = true, default-features = true } +tokio = { features = ["macros", "sync"], workspace = true, default-features = true } tokio-stream = { workspace = true } -tokio-util = { features = ["time"], workspace = true } unsigned-varint = { features = ["asynchronous_codec", "futures"], workspace = true } void = { workspace = true } wasm-timer = { workspace = true } @@ -73,7 +70,6 @@ zeroize = { workspace = true, default-features = true } [dev-dependencies] assert_matches = { workspace = true } -multihash-codetable = { workspace = true } multistream-select = { workspace = true } sc-block-builder = { workspace = true, default-features = true } sp-consensus = { workspace = true, default-features = true } @@ -81,7 +77,7 @@ sp-tracing = { workspace = true, default-features = true } substrate-test-runtime = { workspace = true } substrate-test-runtime-client = { workspace = true } tempfile = { workspace = true } -tokio = { features = ["macros", "rt-multi-thread", "test-util"], workspace = true, default-features = true } +tokio = { features = ["macros", "rt-multi-thread"], workspace = true, default-features = true } tokio-util = { features = ["compat"], workspace = true } criterion = { workspace = true, default-features = true, features = ["async_tokio"] } diff --git a/substrate/client/network/bitswap/Cargo.toml b/substrate/client/network/bitswap/Cargo.toml index b92ee14674bf..0522bc8467cf 100644 --- a/substrate/client/network/bitswap/Cargo.toml +++ b/substrate/client/network/bitswap/Cargo.toml @@ -34,10 +34,8 @@ tokio = { features = ["macros", "rt", "sync"], workspace = true, default-feature tokio-util = { features = ["time"], workspace = true } [dev-dependencies] -multihash-codetable = { workspace = true } sc-block-builder = { workspace = true, default-features = true } sp-consensus = { workspace = true, default-features = true } -sp-tracing = { workspace = true, default-features = true } substrate-test-runtime = { workspace = true } substrate-test-runtime-client = { workspace = true } tokio = { features = ["macros", "rt-multi-thread", "test-util"], workspace = true, default-features = true } diff --git a/substrate/client/network/bitswap/src/handle.rs b/substrate/client/network/bitswap/src/handle.rs index 0b19bc2838bb..427ffaf0c8a6 100644 --- a/substrate/client/network/bitswap/src/handle.rs +++ b/substrate/client/network/bitswap/src/handle.rs @@ -48,9 +48,10 @@ pub enum FetchOutcome { /// /// `BitswapError` is returned **synchronously** from [`BitswapHandle::request_stream`] for /// admission-time failures. It also appears at most once **inside** the returned stream -/// (as the `Err` variant of a stream item) to signal `ServiceClosed` mid-stream. All -/// per-CID failure modes collapse into [`FetchOutcome::Missing`] instead of producing an -/// error. +/// (as the `Err` variant of a stream item): `Overloaded` as the only item when the service +/// actor rejects the request (too many outstanding CIDs or waiters), or `ServiceClosed` +/// when the service task shuts down mid-stream. All per-CID failure modes collapse into +/// [`FetchOutcome::Missing`] instead of producing an error. #[derive(Debug, thiserror::Error)] pub enum BitswapError { /// `ipfs_server` is not enabled, or the network backend does not support Bitswap. @@ -124,12 +125,13 @@ impl BitswapHandle { /// Each item is: /// - `Ok((cid, FetchOutcome::Block(bytes)))` when a peer delivered hash-verified bytes. /// - `Ok((cid, FetchOutcome::Missing))` when the per-waiter deadline expired without a block. + /// - `Err(BitswapError::Overloaded)` once as the only item, if the service actor rejects the + /// request (too many outstanding CIDs or waiters). /// - `Err(BitswapError::ServiceClosed)` once, if the service task shuts down mid-stream. /// - /// The stream closes when either every CID has produced an outcome, or - /// `ServiceClosed` has been emitted. Callers that need to know whether all CIDs were - /// covered should track the requested set against the items received before the - /// stream closed. + /// The stream closes when either every CID has produced an outcome, or an `Err` item + /// has been emitted. Callers that need to know whether all CIDs were covered should + /// track the requested set against the items received before the stream closed. /// /// Returns a synchronous `BitswapError` for admission-time failures (`Unavailable`, /// `ServiceClosed`, `InvalidCid`, `Overloaded`, `TooManyCids`). @@ -157,8 +159,9 @@ impl BitswapHandle { } } - // `cids.len() + 1` reserves one slot for a possible terminal `Err(ServiceClosed)`, - // so the actor's `try_send` for outcomes never fails for well-behaved callers. + // `cids.len() + 1` reserves one slot for a possible terminal `Err` item + // (`Overloaded` or `ServiceClosed`), so the actor's `try_send` for outcomes never + // fails for well-behaved callers. let (sink, rx) = mpsc::channel(cids.len() + 1); self.cmd_tx.try_send(BitswapCommand::RequestStream { cids, sink }).map_err( @@ -221,5 +224,3 @@ pub(crate) enum BitswapCommand { sink: mpsc::Sender, }, } - - diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 875f952e2590..bb1eb6eb2502 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -310,6 +310,10 @@ pub(crate) struct BitswapService { config: BitswapServiceConfig, cmd_rx: mpsc::Receiver, + /// Set once every [`BitswapHandle`] has been dropped. The actor then stops polling + /// `cmd_rx` but keeps serving inbound wantlists: `--ipfs-server` nodes must keep + /// serving even when no local consumer holds a handle. + cmd_channel_closed: bool, sync_event_stream: Pin + Send>>, inbound_lookup_pool: InboundLookupPool, inbound_lookup_rx: mpsc::Receiver, @@ -356,6 +360,7 @@ where handle: Box::new(litep2p_handle), config, cmd_rx, + cmd_channel_closed: false, sync_event_stream, inbound_lookup_pool, inbound_lookup_rx, @@ -391,13 +396,19 @@ impl BitswapService { }, }, - cmd = self.cmd_rx.recv() => match cmd { + cmd = self.cmd_rx.recv(), if !self.cmd_channel_closed => match cmd { Some(BitswapCommand::RequestStream { cids, sink }) => self.on_request_stream(cids, sink).await, None => { - log::debug!(target: LOG_TARGET, "command channel closed; shutting down"); - self.shutdown_waiters(); - return; + // All user handles were dropped, so no new outbound requests can + // arrive. Keep running: this node may still serve inbound + // wantlists (`--ipfs-server`), and already-admitted waiters + // still resolve normally through their receivers. + log::debug!( + target: LOG_TARGET, + "all bitswap handles dropped; serving inbound requests only", + ); + self.cmd_channel_closed = true; }, }, @@ -480,27 +491,32 @@ impl BitswapService { ResponseType::Block { cid: claimed_cid, block } => { self.mark_peer_done_for_cid(peer, claimed_cid); - let recomputed = match recompute_cid(&claimed_cid, &block) { - Ok(c) => c, + match recompute_cid(&claimed_cid, &block) { + Ok(recomputed) if recomputed == claimed_cid => { + if self.wants.contains(&claimed_cid) { + self.deliver_block(claimed_cid, block); + } else { + log::debug!( + target: LOG_TARGET, + "{peer:?} sent unsolicited or unwanted block for {claimed_cid}", + ); + } + }, + Ok(_) => { + log::debug!( + target: LOG_TARGET, + "{peer:?} sent block for {claimed_cid} that failed CID verification", + ); + cids_to_top_up.insert(claimed_cid); + }, Err(e) => { log::debug!( target: LOG_TARGET, "{peer:?} sent block for {claimed_cid} that failed prefix decode: {e}", ); cids_to_top_up.insert(claimed_cid); - continue; }, - }; - - if !self.wants.contains(&recomputed) { - log::debug!( - target: LOG_TARGET, - "{peer:?} sent unsolicited or unwanted block for {recomputed}", - ); - continue; } - - self.deliver_block(recomputed, block); }, ResponseType::Presence { cid, presence } => { self.mark_peer_done_for_cid(peer, cid); @@ -531,15 +547,13 @@ impl BitswapService { fn deliver_block(&mut self, cid: Cid, bytes: Vec) { let Some(waiter_ids) = self.wants.take_waiters_for_delivered_cid(cid) else { return }; - let bytes = Arc::new(bytes); for waiter_id in waiter_ids { let Some(waiter) = self.waiters.get_mut(waiter_id) else { continue }; if !waiter.cids_remaining.remove(&cid) { continue; } - let payload = Vec::clone(&bytes); - if waiter.sink.try_send(Ok((cid, FetchOutcome::Block(payload)))).is_err() { + if waiter.sink.try_send(Ok((cid, FetchOutcome::Block(bytes.clone())))).is_err() { log::trace!(target: LOG_TARGET, "waiter sink full/closed for {cid}"); self.drop_waiter(waiter_id); continue; @@ -742,6 +756,7 @@ mod tests { handle: Box::new(transport), config, cmd_rx, + cmd_channel_closed: false, sync_event_stream, inbound_lookup_pool, inbound_lookup_rx, @@ -1120,6 +1135,69 @@ mod tests { assert!(matches!(item, Ok((_, FetchOutcome::Missing)))); } + #[tokio::test(start_paused = true)] + async fn corrupted_block_triggers_failover_to_next_peer() { + let mut rig = empty_rig(); + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let data = b"genuine-payload".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.sync_event_tx.send(sync_connected(peer_a)).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer_b)).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + + let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer: first_peer, + responses: vec![ResponseType::Block { cid, block: b"corrupted".to_vec() }], + }) + .await + .unwrap(); + + let (second_peer, _) = drain_next(&mut rig.outbound_req_rx) + .await + .expect("failover WANT after corrupted block"); + assert_ne!(first_peer, second_peer); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer: second_peer, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + let item = drain_next(&mut rx).await.expect("item"); + assert!(matches!(item, Ok((c, FetchOutcome::Block(b))) if c == cid && b == data)); + } + + #[tokio::test(start_paused = true)] + async fn inbound_serving_continues_after_all_handles_dropped() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [3u8; 32]); + + drop(rig.user_handle); + sleep(Duration::from_millis(1)).await; + + rig.inbound_tx + .send(BitswapEvent::Request { peer, cids: vec![(cid, WantType::Block)] }) + .await + .unwrap(); + + let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx) + .await + .expect("service must keep serving inbound after all handles are dropped"); + assert_eq!(resp_peer, peer); + assert!(matches!( + responses[0], + ResponseType::Presence { presence: BlockPresenceType::DontHave, .. } + )); + } + #[tokio::test] async fn admission_too_many_cids_rejected() { let rig = empty_rig(); diff --git a/substrate/client/network/src/litep2p/service.rs b/substrate/client/network/src/litep2p/service.rs index ac587a545c77..8638ffed967c 100644 --- a/substrate/client/network/src/litep2p/service.rs +++ b/substrate/client/network/src/litep2p/service.rs @@ -582,5 +582,3 @@ impl NetworkRequest for Litep2pNetworkService { } } } - - diff --git a/substrate/client/service/src/builder.rs b/substrate/client/service/src/builder.rs index 3340354b3eb5..bd286e3bd4f6 100644 --- a/substrate/client/service/src/builder.rs +++ b/substrate/client/service/src/builder.rs @@ -1245,46 +1245,44 @@ where // spawned AFTER `Net::new` returns `Ok` (see below) so that a network construction // failure does not leave an orphan bitswap task running. The `user_handle` is later // returned to the caller via the build-network output tuple. - let (bitswap_handler, bitswap_user_handle, ipfs_config) = if net_config - .network_config - .ipfs_server - { - if matches!( - net_config.network_config.network_backend, - sc_network::config::NetworkBackendType::Libp2p - ) { - return Err(Error::Other( - "Bitswap requires the litep2p network backend; \ + let (bitswap_handler, bitswap_user_handle, ipfs_config) = + if net_config.network_config.ipfs_server { + if matches!( + net_config.network_config.network_backend, + sc_network::config::NetworkBackendType::Libp2p + ) { + return Err(Error::Other( + "Bitswap requires the litep2p network backend; \ set --network-backend litep2p or disable --ipfs-server" - .into(), - )); - } - - let ipfs_num_blocks = match blocks_pruning { - BlocksPruning::KeepAll | BlocksPruning::KeepFinalized => IPFS_MAX_BLOCKS, - BlocksPruning::Some(num) => std::cmp::min(num, IPFS_MAX_BLOCKS), - }; + .into(), + )); + } - // `IpfsConfig::new` mints the litep2p `(Config, BitswapHandle)` pair internally and - // returns the transport handle alongside the config. The handle is owned by the - // bitswap service actor; the config is installed by the litep2p backend during - // `Net::new`. - let (ipfs_config, litep2p_bitswap_handle) = IpfsConfig::new( - Box::new(IpfsIndexedTransactions::new(client.clone(), ipfs_num_blocks)), - net_config.network_config.ipfs_bootnodes.clone(), - ); + let ipfs_num_blocks = match blocks_pruning { + BlocksPruning::KeepAll | BlocksPruning::KeepFinalized => IPFS_MAX_BLOCKS, + BlocksPruning::Some(num) => std::cmp::min(num, IPFS_MAX_BLOCKS), + }; + + // `IpfsConfig::new` mints the litep2p `(Config, BitswapHandle)` pair internally and + // returns the transport handle alongside the config. The handle is owned by the + // bitswap service actor; the config is installed by the litep2p backend during + // `Net::new`. + let (ipfs_config, litep2p_bitswap_handle) = IpfsConfig::new( + Box::new(IpfsIndexedTransactions::new(client.clone(), ipfs_num_blocks)), + net_config.network_config.ipfs_bootnodes.clone(), + ); - let (handler, bitswap_user_handle) = sc_network_bitswap::start::( - client.clone(), - &*sync_service, - litep2p_bitswap_handle, - sc_network_bitswap::BitswapServiceConfig::default(), - ); + let (handler, bitswap_user_handle) = sc_network_bitswap::start::( + client.clone(), + &*sync_service, + litep2p_bitswap_handle, + sc_network_bitswap::BitswapServiceConfig::default(), + ); - (Some(handler), Some(bitswap_user_handle), Some(ipfs_config)) - } else { - (None, None, None) - }; + (Some(handler), Some(bitswap_user_handle), Some(ipfs_config)) + } else { + (None, None, None) + }; // Create transactions protocol and add it to the list of supported protocols of let (transactions_handler_proto, transactions_config) = From 75fa92ab8c7d5eafdf3d6260cadf4762207b4c2d Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Fri, 3 Jul 2026 18:16:52 +0200 Subject: [PATCH 07/32] Clean up --- .../client/network/bitswap/src/handle.rs | 68 ++----- substrate/client/network/bitswap/src/lib.rs | 34 +--- .../client/network/bitswap/src/service.rs | 186 ++++++------------ substrate/client/network/src/config.rs | 24 +-- substrate/client/service/src/builder.rs | 24 +-- .../client/storage-chain-sync/src/fetcher.rs | 72 +++---- 6 files changed, 121 insertions(+), 287 deletions(-) diff --git a/substrate/client/network/bitswap/src/handle.rs b/substrate/client/network/bitswap/src/handle.rs index 427ffaf0c8a6..7760798ceae1 100644 --- a/substrate/client/network/bitswap/src/handle.rs +++ b/substrate/client/network/bitswap/src/handle.rs @@ -26,37 +26,28 @@ use super::{is_cid_supported, Cid, MAX_WANTED_BLOCKS}; use async_trait::async_trait; -use std::{sync::Arc, time::Duration}; +use std::time::Duration; use tokio::sync::mpsc; /// Outcome of a single Bitswap fetch for one CID. -/// -/// Operational causes for `Missing` (timeout, no peers, all DONT_HAVE, verification failure) -/// are collapsed into one variant on purpose — they are not actionable from the caller's -/// perspective. Diagnostic distinction is available via tracing/metrics on the service side. #[derive(Debug)] pub enum FetchOutcome { /// Hash-verified bytes for the requested CID. Block(Vec), - /// The block was not retrieved before the request deadline expired. Could mean any of: - /// no peers were available, every peer replied DONT_HAVE, every peer timed out, or every - /// candidate block failed CID verification. + /// The block was not retrieved before the request deadline expired: no peers were + /// available, every peer replied DONT_HAVE or timed out, or every candidate block + /// failed CID verification. Missing, } /// Service-level Bitswap errors. /// -/// `BitswapError` is returned **synchronously** from [`BitswapHandle::request_stream`] for -/// admission-time failures. It also appears at most once **inside** the returned stream -/// (as the `Err` variant of a stream item): `Overloaded` as the only item when the service -/// actor rejects the request (too many outstanding CIDs or waiters), or `ServiceClosed` -/// when the service task shuts down mid-stream. All per-CID failure modes collapse into -/// [`FetchOutcome::Missing`] instead of producing an error. +/// Returned synchronously from [`BitswapHandle::request_stream`] for admission-time +/// failures, and appearing at most once inside the returned stream (`Overloaded` or +/// `ServiceClosed`). Per-CID failure modes collapse into [`FetchOutcome::Missing`] +/// instead of producing an error. #[derive(Debug, thiserror::Error)] pub enum BitswapError { - /// `ipfs_server` is not enabled, or the network backend does not support Bitswap. - #[error("Bitswap is not available on this node")] - Unavailable, /// The Bitswap service task has shut down. #[error("Bitswap service is closed")] ServiceClosed, @@ -81,9 +72,6 @@ pub enum BitswapError { } /// Maximum number of CIDs accepted in a single [`BitswapHandle::request_stream`] call. -/// -/// Matches the Bitswap v1.2.0 wantlist-entry cap that the rest of the codebase already -/// enforces. pub const MAX_CIDS_PER_REQUEST: usize = MAX_WANTED_BLOCKS; /// Configuration applied at service construction time. @@ -130,13 +118,11 @@ impl BitswapHandle { /// - `Err(BitswapError::ServiceClosed)` once, if the service task shuts down mid-stream. /// /// The stream closes when either every CID has produced an outcome, or an `Err` item - /// has been emitted. Callers that need to know whether all CIDs were covered should - /// track the requested set against the items received before the stream closed. + /// has been emitted. /// - /// Returns a synchronous `BitswapError` for admission-time failures (`Unavailable`, - /// `ServiceClosed`, `InvalidCid`, `Overloaded`, `TooManyCids`). - /// - /// An empty `cids` slice returns an immediately-closed receiver, not an error. + /// Returns a synchronous `BitswapError` for admission-time failures (`ServiceClosed`, + /// `InvalidCid`, `Overloaded`, `TooManyCids`). An empty `cids` slice returns an + /// immediately-closed receiver, not an error. pub async fn request_stream( &self, cids: Vec, @@ -175,11 +161,8 @@ impl BitswapHandle { } } -/// Object-safe surface over [`BitswapHandle::request_stream`]. -/// -/// Hold an `Arc` to abstract over the bitswap client for testing or -/// for late-bound wiring. The trait carries no methods beyond `request_stream`; consumers -/// that need other [`BitswapHandle`] functionality should keep the concrete type. +/// Object-safe surface over [`BitswapHandle::request_stream`], allowing consumers to mock +/// the bitswap client in tests. #[async_trait] pub trait BitswapRequest: Send + Sync { /// Submit a wantlist. See [`BitswapHandle::request_stream`] for full semantics. @@ -199,28 +182,9 @@ impl BitswapRequest for BitswapHandle { } } -#[async_trait] -impl BitswapRequest for Arc -where - T: BitswapRequest + ?Sized, -{ - async fn request_stream( - &self, - cids: Vec, - ) -> Result, BitswapError> { - T::request_stream(self, cids).await - } -} - /// Internal command sent from a [`BitswapHandle`] to the service actor. #[derive(Debug)] pub(crate) enum BitswapCommand { - /// Submit a streaming request. The actor inserts a `Waiter` keyed by these `cids`, with - /// the configured deadline, and writes per-CID outcomes into `sink` as they resolve. - RequestStream { - /// Wantlist (already validated for emptiness, cap, and CID support at admission). - cids: Vec, - /// Sink for `Ok((cid, outcome))` items and the optional final `Err(ServiceClosed)`. - sink: mpsc::Sender, - }, + /// Submit a streaming request: fetch `cids` and write per-CID outcomes into `sink`. + RequestStream { cids: Vec, sink: mpsc::Sender }, } diff --git a/substrate/client/network/bitswap/src/lib.rs b/substrate/client/network/bitswap/src/lib.rs index 37f97a5cac45..219472d3c721 100644 --- a/substrate/client/network/bitswap/src/lib.rs +++ b/substrate/client/network/bitswap/src/lib.rs @@ -20,12 +20,6 @@ //! Implements both the server side (serving indexed-transaction blocks to peers that send //! Bitswap v1.2.0 wantlists) and the client side (a long-lived service plus user-facing //! [`BitswapHandle`] for fetching CIDs from the network). -//! -//! Only available on the litep2p network backend. The caller mints the litep2p protocol -//! config + transport handle pair via `litep2p::protocol::libp2p::bitswap::Config::new`, -//! hands the transport handle to [`start`], and routes the litep2p config into the -//! backend's IPFS layer. The user-facing handle returned by [`start`] is exposed to -//! consumers via the build-network output of `sc-service`. use cid::Version as CidVersion; @@ -62,30 +56,10 @@ pub const KECCAK_256_MULTIHASH_CODE: u64 = 0x1b; pub fn is_cid_supported(cid: &Cid) -> bool { cid.version() != CidVersion::V0 && cid.hash().size() == 32 && - is_supported_multihash_code(cid.hash().code()) -} - -pub(crate) fn is_supported_multihash_code(code: u64) -> bool { - matches!(code, BLAKE2B_256_MULTIHASH_CODE | SHA2_256_MULTIHASH_CODE | KECCAK_256_MULTIHASH_CODE) -} - -#[derive(PartialEq, Eq, Clone, Debug)] -pub(crate) struct Prefix { - pub version: CidVersion, - pub codec: u64, - pub mh_type: u64, - pub mh_len: u8, -} - -impl From<&Cid> for Prefix { - fn from(cid: &Cid) -> Self { - Self { - version: cid.version(), - codec: cid.codec(), - mh_type: cid.hash().code(), - mh_len: cid.hash().size(), - } - } + matches!( + cid.hash().code(), + BLAKE2B_256_MULTIHASH_CODE | SHA2_256_MULTIHASH_CODE | KECCAK_256_MULTIHASH_CODE + ) } #[cfg(test)] diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index bb1eb6eb2502..f1ef77a83212 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -21,16 +21,13 @@ //! the inbound (serve indexed-transaction blocks to peers) and outbound (fetch CIDs from //! peers on behalf of [`BitswapHandle`] callers) Bitswap flows. //! -//! The actor runs a single [`tokio::select!`] loop with six arms; see [`BitswapService::run`]. -//! -//! Peer connect/disconnect tracking comes from `sc-network-sync`'s [`SyncEventStream`]; the -//! actor subscribes inside [`start`] and consumes [`SyncEvent`] directly. The sync engine -//! replays `PeerConnected` for every currently-connected peer when a new subscriber registers, -//! so the actor sees the up-to-date peer set on startup without any extra wiring. +//! Peer connect/disconnect tracking comes from `sc-network-sync`'s [`SyncEventStream`]. The +//! sync engine replays `PeerConnected` for every currently-connected peer when a new +//! subscriber registers, so the actor sees the full peer set on startup. use super::{ is_cid_supported, BitswapCommand, BitswapHandle, BitswapServiceConfig, Cid, FetchItem, - FetchOutcome, Prefix, BLAKE2B_256_MULTIHASH_CODE, KECCAK_256_MULTIHASH_CODE, LOG_TARGET, + FetchOutcome, BLAKE2B_256_MULTIHASH_CODE, KECCAK_256_MULTIHASH_CODE, LOG_TARGET, MAX_WANTED_BLOCKS, SHA2_256_MULTIHASH_CODE, }; use crate::handle::BitswapError; @@ -252,15 +249,7 @@ struct Waiter { delay_key: delay_queue::Key, } -struct InboundLookupResult { - peer: litep2p::PeerId, - responses: Vec, -} - -enum LookupAdmission { - Submitted, - Overloaded, -} +type InboundLookupResult = (litep2p::PeerId, Vec); struct InboundLookupPool { client: Arc + Send + Sync>, @@ -283,9 +272,10 @@ impl InboundLookupPool { ) } - fn submit(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) -> LookupAdmission { + /// Serve the wantlist on a blocking worker. Returns `false` if the pool is saturated. + fn try_submit(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) -> bool { let Ok(permit) = self.semaphore.clone().try_acquire_owned() else { - return LookupAdmission::Overloaded; + return false; }; let client = self.client.clone(); @@ -293,54 +283,36 @@ impl InboundLookupPool { tokio::task::spawn_blocking(move || { let _permit = permit; let responses = serve_inbound(&*client, cids); - let _ = result_tx.try_send(InboundLookupResult { peer, responses }); + let _ = result_tx.try_send((peer, responses)); }); - LookupAdmission::Submitted + true } } -/// Peer-id type the actor stores in its connected-peer set and dispatches requests over. -/// We translate from [`sc_network_sync::SyncEvent`]'s `sc_network_types::PeerId` (which the -/// sync engine emits) at the actor boundary. -type ActorPeerId = litep2p::PeerId; - pub(crate) struct BitswapService { handle: Box, config: BitswapServiceConfig, cmd_rx: mpsc::Receiver, - /// Set once every [`BitswapHandle`] has been dropped. The actor then stops polling - /// `cmd_rx` but keeps serving inbound wantlists: `--ipfs-server` nodes must keep - /// serving even when no local consumer holds a handle. + /// Set once every [`BitswapHandle`] has been dropped; the actor then stops polling + /// `cmd_rx` but keeps serving inbound wantlists. cmd_channel_closed: bool, sync_event_stream: Pin + Send>>, inbound_lookup_pool: InboundLookupPool, inbound_lookup_rx: mpsc::Receiver, waiter_deadlines: DelayQueue, - connected_peers: HashSet, + connected_peers: HashSet, wants: WantSet, waiters: SlotMap, } -/// Build, wire and return the Bitswap service. -/// -/// The returned future MUST be spawned on the runtime; the returned [`BitswapHandle`] is the -/// user-facing handle exposed to consumers via the build-network output of `sc-service`. -/// -/// `litep2p_handle` is the transport-side handle minted by the caller via -/// `litep2p::protocol::libp2p::bitswap::Config::new`; the corresponding `Config` value -/// produced by the same call must be installed into the litep2p backend's IPFS layer by the -/// caller. Owning that pair-up at the caller keeps `sc-network-bitswap` from instantiating -/// the litep2p protocol config it does not own. +/// Build the Bitswap service, returning the service future and the user-facing handle. /// -/// `sync` is anything implementing [`SyncEventStream`] (concretely, `&*sync_service` where -/// `sync_service: Arc>` — `Arc` has a blanket [`SyncEventStream`] impl). -/// The actor subscribes inside this function via `sync.event_stream("bitswap")`; the engine -/// replays `SyncEvent::PeerConnected` for every currently-tracked peer the first time it -/// processes the subscription command, so we get an accurate startup snapshot without any -/// extra synchronisation. +/// The future must be spawned by the caller. `litep2p_handle` is the transport-side handle +/// created by `litep2p::protocol::libp2p::bitswap::Config::new`; the corresponding `Config` +/// must be installed into the litep2p backend by the caller. pub fn start( client: Arc + Send + Sync>, sync: &S, @@ -400,10 +372,9 @@ impl BitswapService { Some(BitswapCommand::RequestStream { cids, sink }) => self.on_request_stream(cids, sink).await, None => { - // All user handles were dropped, so no new outbound requests can - // arrive. Keep running: this node may still serve inbound - // wantlists (`--ipfs-server`), and already-admitted waiters - // still resolve normally through their receivers. + // All user handles were dropped. Keep running: the node may still + // serve inbound wantlists, and already-admitted waiters still + // resolve normally. log::debug!( target: LOG_TARGET, "all bitswap handles dropped; serving inbound requests only", @@ -428,8 +399,8 @@ impl BitswapService { } }, - Some(result) = self.inbound_lookup_rx.recv() => { - self.handle.send_response(result.peer, result.responses).await; + Some((peer, responses)) = self.inbound_lookup_rx.recv() => { + self.handle.send_response(peer, responses).await; }, _ = peer_timeout_ticker.tick() => { @@ -440,9 +411,7 @@ impl BitswapService { } async fn on_request_stream(&mut self, cids: Vec, sink: mpsc::Sender) { - // New CIDs are CIDs not already in `wants`. We re-check the overall outstanding-CIDs - // budget against the *new* additions, otherwise overlapping waiters would be charged - // twice for the same CID. + // Charge the outstanding-CIDs budget only for CIDs not already in `wants`. let new_cid_count = self.wants.new_cid_count(&cids); if self.wants.len() + new_cid_count > MAX_OUTSTANDING_CIDS { let _ = sink.try_send(Err(BitswapError::Overloaded)); @@ -489,37 +458,25 @@ impl BitswapService { for response in responses { match response { ResponseType::Block { cid: claimed_cid, block } => { - self.mark_peer_done_for_cid(peer, claimed_cid); - - match recompute_cid(&claimed_cid, &block) { - Ok(recomputed) if recomputed == claimed_cid => { - if self.wants.contains(&claimed_cid) { - self.deliver_block(claimed_cid, block); - } else { - log::debug!( - target: LOG_TARGET, - "{peer:?} sent unsolicited or unwanted block for {claimed_cid}", - ); - } - }, - Ok(_) => { - log::debug!( - target: LOG_TARGET, - "{peer:?} sent block for {claimed_cid} that failed CID verification", - ); - cids_to_top_up.insert(claimed_cid); - }, - Err(e) => { - log::debug!( - target: LOG_TARGET, - "{peer:?} sent block for {claimed_cid} that failed prefix decode: {e}", - ); - cids_to_top_up.insert(claimed_cid); - }, + self.wants.mark_peer_done_for_cid(peer, claimed_cid); + + if recompute_cid(&claimed_cid, &block) != Some(claimed_cid) { + log::debug!( + target: LOG_TARGET, + "{peer:?} sent block for {claimed_cid} that failed CID verification", + ); + cids_to_top_up.insert(claimed_cid); + } else if self.wants.contains(&claimed_cid) { + self.deliver_block(claimed_cid, block); + } else { + log::debug!( + target: LOG_TARGET, + "{peer:?} sent unsolicited or unwanted block for {claimed_cid}", + ); } }, ResponseType::Presence { cid, presence } => { - self.mark_peer_done_for_cid(peer, cid); + self.wants.mark_peer_done_for_cid(peer, cid); match presence { BlockPresenceType::DontHave => { log::trace!(target: LOG_TARGET, "{peer:?} DONT_HAVE {cid}"); @@ -541,10 +498,6 @@ impl BitswapService { } } - fn mark_peer_done_for_cid(&mut self, peer: litep2p::PeerId, cid: Cid) { - self.wants.mark_peer_done_for_cid(peer, cid); - } - fn deliver_block(&mut self, cid: Cid, bytes: Vec) { let Some(waiter_ids) = self.wants.take_waiters_for_delivered_cid(cid) else { return }; @@ -574,16 +527,15 @@ impl BitswapService { } fn on_waiter_expired(&mut self, id: WaiterId) { - let Some(mut waiter) = self.waiters.remove(id) else { return }; - let remaining: Vec = waiter.cids_remaining.drain().collect(); - for cid in &remaining { + let Some(waiter) = self.waiters.remove(id) else { return }; + for cid in &waiter.cids_remaining { let _ = waiter.sink.try_send(Ok((*cid, FetchOutcome::Missing))); self.wants.remove_waiter(*cid, id); } log::trace!( target: LOG_TARGET, "waiter {id:?} expired; emitted Missing for {} CIDs", - remaining.len(), + waiter.cids_remaining.len(), ); } @@ -618,7 +570,7 @@ impl BitswapService { ); return; } - if let LookupAdmission::Overloaded = self.inbound_lookup_pool.submit(peer, cids) { + if !self.inbound_lookup_pool.try_submit(peer, cids) { log::trace!( target: LOG_TARGET, "inbound serving pool saturated; dropping wantlist from {peer:?}", @@ -627,24 +579,21 @@ impl BitswapService { } fn shutdown_waiters(&mut self) { - let ids: Vec = self.waiters.keys().collect(); - for id in ids { - if let Some(waiter) = self.waiters.remove(id) { - let _ = waiter.sink.try_send(Err(BitswapError::ServiceClosed)); - self.waiter_deadlines.remove(&waiter.delay_key); - } + for (_, waiter) in self.waiters.drain() { + let _ = waiter.sink.try_send(Err(BitswapError::ServiceClosed)); + self.waiter_deadlines.remove(&waiter.delay_key); } self.wants.clear(); } } -fn recompute_cid(reference_cid: &Cid, data: &[u8]) -> Result { - let prefix: Prefix = reference_cid.into(); - let digest = hash_for_multihash_code(prefix.mh_type, data) - .ok_or_else(|| format!("unsupported multihash code {}", prefix.mh_type))?; - let mh = CidMultihash::<64>::wrap(prefix.mh_type, &digest) - .map_err(|e| format!("multihash wrap failed: {e}"))?; - Ok(Cid::new_v1(prefix.codec, mh)) +/// Rebuild the CID for `data` using the hashing and codec of `reference_cid`. Returns `None` +/// for unsupported multihash codes. +fn recompute_cid(reference_cid: &Cid, data: &[u8]) -> Option { + let code = reference_cid.hash().code(); + let digest = hash_for_multihash_code(code, data)?; + let mh = CidMultihash::<64>::wrap(code, &digest).ok()?; + Some(Cid::new_v1(reference_cid.codec(), mh)) } fn hash_for_multihash_code(multihash_code: u64, data: &[u8]) -> Option<[u8; 32]> { @@ -719,13 +668,9 @@ mod tests { } } - /// Test helper: a sender for `SyncEvent`s that closes when dropped. We feed it via a - /// tokio mpsc and adapt to a `Stream` in [`build_rig_with`]. - type SyncEventSender = mpsc::Sender; - struct TestRig { user_handle: BitswapHandle, - sync_event_tx: SyncEventSender, + sync_event_tx: mpsc::Sender, inbound_tx: mpsc::Sender, outbound_req_rx: mpsc::Receiver<(litep2p::PeerId, Vec<(Cid, WantType)>)>, outbound_resp_rx: mpsc::Receiver<(litep2p::PeerId, Vec)>, @@ -780,8 +725,7 @@ mod tests { } fn empty_rig() -> TestRig { - let client = Arc::new(substrate_test_runtime_client::new()); - build_rig_with(client, BitswapServiceConfig { request_timeout: Duration::from_secs(30) }) + short_deadline_rig(Duration::from_secs(30)) } fn short_deadline_rig(deadline: Duration) -> TestRig { @@ -804,22 +748,18 @@ mod tests { timeout(Duration::from_secs(2), rx.recv()).await.ok().flatten() } - /// Synthesise a sync `PeerConnected` for a litep2p peer. Tests use a small helper - /// because [`SyncEvent::PeerConnected`] carries [`sc_network_types::PeerId`], and we - /// want the same peer to appear in outbound bitswap requests as a [`litep2p::PeerId`]. + /// [`SyncEvent`] carries [`sc_network_types::PeerId`]; convert from the byte-compatible + /// [`litep2p::PeerId`] used on the transport side. + fn to_types_peer(peer: litep2p::PeerId) -> TypesPeerId { + TypesPeerId::from_bytes(&peer.to_bytes()).expect("valid peer-id bytes; qed") + } + fn sync_connected(peer: litep2p::PeerId) -> SyncEvent { - // `litep2p::PeerId` <-> `sc_network_types::PeerId` are wire-compatible via bytes. - let bytes = peer.to_bytes(); - let types_peer = TypesPeerId::from_bytes(&bytes) - .expect("litep2p PeerId bytes are valid sc_network_types PeerId bytes; qed"); - SyncEvent::PeerConnected(types_peer) + SyncEvent::PeerConnected(to_types_peer(peer)) } fn sync_disconnected(peer: litep2p::PeerId) -> SyncEvent { - let bytes = peer.to_bytes(); - let types_peer = TypesPeerId::from_bytes(&bytes) - .expect("litep2p PeerId bytes are valid sc_network_types PeerId bytes; qed"); - SyncEvent::PeerDisconnected(types_peer) + SyncEvent::PeerDisconnected(to_types_peer(peer)) } #[test] diff --git a/substrate/client/network/src/config.rs b/substrate/client/network/src/config.rs index 11005ab8fb8c..23040dffd86c 100644 --- a/substrate/client/network/src/config.rs +++ b/substrate/client/network/src/config.rs @@ -37,12 +37,9 @@ pub use crate::{ pub use sc_network_types::{build_multiaddr, ed25519}; -/// Litep2p transport-side Bitswap handle. -/// -/// Re-exported here so callers wiring bitswap (typically `sc-service`) need not depend on -/// `litep2p` directly. Pair-minted with [`IpfsConfig`] via [`IpfsConfig::new`]; the handle is -/// then handed to `sc_network_bitswap::start` while the config is consumed by the litep2p -/// backend. +/// Litep2p transport-side Bitswap handle, created together with [`IpfsConfig`] via +/// [`IpfsConfig::new`]. Re-exported so callers wiring bitswap (typically `sc-service`) +/// need not depend on `litep2p` directly. pub use litep2p::protocol::libp2p::bitswap::BitswapHandle as LitepBitswapHandle; use sc_network_types::{ multiaddr::{self, Multiaddr}, @@ -775,9 +772,7 @@ impl NetworkConfiguration { /// IPFS server configuration. pub struct IpfsConfig { - /// Litep2p Bitswap protocol config; consumed by `Litep2pConfigBuilder::with_libp2p_bitswap` - /// inside the network backend. Pair-minted with the transport handle returned alongside - /// from [`IpfsConfig::new`]. + /// Litep2p Bitswap protocol config, consumed by the litep2p network backend. pub litep2p_bitswap_config: litep2p::protocol::libp2p::bitswap::Config, /// Indexed transactions provider. pub block_provider: Box, @@ -786,14 +781,11 @@ pub struct IpfsConfig { } impl IpfsConfig { - /// Construct an [`IpfsConfig`] together with the litep2p transport-side Bitswap handle - /// that the bitswap service actor must own. + /// Construct an [`IpfsConfig`] together with the litep2p transport-side Bitswap handle. /// - /// The litep2p `(Config, BitswapHandle)` pair shares one protocol negotiator and must - /// therefore be minted together; returning them from the same call makes that constraint - /// type-enforced. The caller hands the returned [`LitepBitswapHandle`] to - /// `sc_network_bitswap::start`, then passes the resulting `IpfsConfig` to - /// `sc-network` via `Params::ipfs_config`. + /// The two are created by the same `litep2p::protocol::libp2p::bitswap::Config::new` + /// call and belong together: the handle goes to `sc_network_bitswap::start`, the config + /// to `sc-network` via `Params::ipfs_config`. pub fn new( block_provider: Box, bootnodes: Vec, diff --git a/substrate/client/service/src/builder.rs b/substrate/client/service/src/builder.rs index bd286e3bd4f6..a81c78bbeb92 100644 --- a/substrate/client/service/src/builder.rs +++ b/substrate/client/service/src/builder.rs @@ -1174,10 +1174,8 @@ where /// Build the network service, the network status sinks and an RPC sender, this is a lower-level /// version of [`build_network`] for those needing more control. /// -/// The final tuple element is the Bitswap user handle. It is `Some` when `--ipfs-server` is -/// enabled on a litep2p backend, and `None` otherwise. Consumers that need a -/// `BitswapHandle` (e.g. omni-node populating a `BitswapHandleSlot`) read it directly from -/// this output instead of querying the network service after the fact. +/// The final tuple element is the Bitswap user handle; `Some` when `--ipfs-server` is +/// enabled, `None` otherwise. pub fn build_network_advanced( params: BuildNetworkAdvancedParams, ) -> Result< @@ -1238,13 +1236,7 @@ where // install request handlers to `FullNetworkConfiguration` net_config.add_request_response_protocol(light_client_request_protocol_config); - // Initialize IPFS server. Bitswap is only supported on the litep2p backend; reject the - // libp2p + `--ipfs-server` combination loudly rather than silently disabling Bitswap. - // - // The handler future and user-facing handle are produced here, but the handler is only - // spawned AFTER `Net::new` returns `Ok` (see below) so that a network construction - // failure does not leave an orphan bitswap task running. The `user_handle` is later - // returned to the caller via the build-network output tuple. + // Initialize the IPFS server. Bitswap is only supported on the litep2p backend. let (bitswap_handler, bitswap_user_handle, ipfs_config) = if net_config.network_config.ipfs_server { if matches!( @@ -1263,10 +1255,8 @@ where BlocksPruning::Some(num) => std::cmp::min(num, IPFS_MAX_BLOCKS), }; - // `IpfsConfig::new` mints the litep2p `(Config, BitswapHandle)` pair internally and - // returns the transport handle alongside the config. The handle is owned by the - // bitswap service actor; the config is installed by the litep2p backend during - // `Net::new`. + // The bitswap service owns the transport handle; the config is installed by + // the litep2p backend during `Net::new`. let (ipfs_config, litep2p_bitswap_handle) = IpfsConfig::new( Box::new(IpfsIndexedTransactions::new(client.clone(), ipfs_num_blocks)), net_config.network_config.ipfs_bootnodes.clone(), @@ -1321,8 +1311,8 @@ where let network_mut = Net::new(network_params)?; let network = network_mut.network_service().clone(); - // Only spawn the bitswap actor after `Net::new` returned `Ok` so a network construction - // failure does not leave an orphan task running with a handle nobody will ever consume. + // Spawn the bitswap actor only after `Net::new` succeeded so a network construction + // failure does not leave an orphan task running. if let Some(handler) = bitswap_handler { spawn_handle.spawn("bitswap-service", Some("networking"), handler); } diff --git a/substrate/client/storage-chain-sync/src/fetcher.rs b/substrate/client/storage-chain-sync/src/fetcher.rs index 7db6569d8d78..a904cfe7e5b3 100644 --- a/substrate/client/storage-chain-sync/src/fetcher.rs +++ b/substrate/client/storage-chain-sync/src/fetcher.rs @@ -18,18 +18,13 @@ //! Bitswap-based fetcher for indexed-transaction blobs. //! -//! Thin adapter over [`sc_network_bitswap::BitswapHandle`]. Peer selection, -//! per-peer timeouts, retries and hash verification all live in the bitswap actor -//! itself. This fetcher's only jobs are: -//! -//! 1. building the per-want CIDs from the runtime-declared [`RenewWant`]s, -//! 2. chunking by [`sc_network_bitswap::MAX_CIDS_PER_REQUEST`] and submitting all chunks -//! concurrently, -//! 3. draining the per-chunk streams into a `HashMap>`. +//! Thin adapter over [`sc_network_bitswap::BitswapHandle`]: builds the per-want CIDs, +//! submits them in chunks of [`MAX_CIDS_PER_REQUEST`] and collects the outcomes. Peer +//! selection, timeouts, retries and hash verification live in the bitswap service. use crate::RenewWant; use cid::{multihash::Multihash, Cid}; -use futures::{future, stream::FuturesUnordered, StreamExt}; +use futures::future; use sc_network_bitswap::{BitswapError, BitswapRequest, FetchOutcome, MAX_CIDS_PER_REQUEST}; use sp_runtime::traits::Block as BlockT; use sp_transaction_storage_proof::ContentHash; @@ -40,23 +35,20 @@ use std::{ const LOG_TARGET: &str = "storage-chain-fetcher"; -/// Late-bound bitswap handle slot, populated by the omni-node after `build_network`. +/// Late-bound bitswap handle slot, populated by the node after `build_network`. /// -/// The slot exists because [`crate::StorageChainBlockImport`] is constructed before -/// `build_network` runs (the block import is consumed when building the import queue, -/// which is in turn consumed by `build_network`). The handle becomes available only -/// once the network service has been built; the `OnceLock` carries it across that -/// boundary without changing the block-import's public API. +/// [`crate::StorageChainBlockImport`] is constructed before `build_network` runs; the +/// `OnceLock` carries the handle across that boundary. pub type BitswapHandleSlot = Arc>>; /// Infrastructure-level fetch failure surfaced to [`crate::StorageChainBlockImport`]. #[derive(Debug, thiserror::Error)] pub enum FetchError { - /// The bitswap handle has not been set yet (called before `build_network` finished) - /// or bitswap is not configured (`--ipfs-server` not enabled, or libp2p backend in use). + /// The bitswap handle has not been set, either because `build_network` has not finished + /// yet or because bitswap is not configured (`--ipfs-server` not enabled). #[error("bitswap handle not yet set; storage-chain blocks cannot be fetched before build_network completes")] BitswapHandleUnset, - /// CID construction failed for the given (hashing, hash) pair. Bug indicator. + /// CID construction failed for the given (hashing, hash) pair. #[error("failed to construct multihash for CID: {0}")] Multihash(String), /// The bitswap service rejected the request at admission, or shut down mid-stream. @@ -87,14 +79,9 @@ impl IndexedTransactionFetcher { Self { bitswap, _phantom: std::marker::PhantomData } } - /// Resolve a batch of indexed-transaction hashes via bitswap. - /// - /// Each want carries the runtime-declared `cid_codec` so the request CID's - /// codec matches what the producing runtime announced. - /// - /// Returns only successfully fetched entries. A short result means the caller - /// (the block import) will surface a `ConsensusError` and the import will be - /// retried later. + /// Resolve a batch of indexed-transaction hashes via bitswap. Each want carries the + /// runtime-declared `cid_codec` so the request CID matches what the producing runtime + /// announced. Returns only successfully fetched entries. pub(crate) async fn fetch_many( &self, wants: &[RenewWant], @@ -114,27 +101,17 @@ impl IndexedTransactionFetcher { cids.push(cid); } - let chunks: Vec> = - cids.chunks(MAX_CIDS_PER_REQUEST).map(<[Cid]>::to_vec).collect(); - - let receivers = - future::try_join_all(chunks.into_iter().map(|chunk| handle.request_stream(chunk))) - .await?; + let receivers = future::try_join_all( + cids.chunks(MAX_CIDS_PER_REQUEST) + .map(|chunk| handle.request_stream(chunk.to_vec())), + ) + .await?; + // Every receiver is sized to buffer all its outcomes, so the requests progress + // concurrently regardless of drain order. let mut acquired: HashMap> = HashMap::with_capacity(wants.len()); - let mut streams: FuturesUnordered<_> = receivers - .into_iter() - .map(|mut rx| async move { - let mut out = Vec::new(); - while let Some(item) = rx.recv().await { - out.push(item); - } - out - }) - .collect(); - - while let Some(items) = streams.next().await { - for item in items { + for mut rx in receivers { + while let Some(item) = rx.recv().await { match item { Ok((cid, FetchOutcome::Block(bytes))) => { if let Some(hash) = by_cid.get(&cid) { @@ -147,10 +124,7 @@ impl IndexedTransactionFetcher { } }, Ok((cid, FetchOutcome::Missing)) => { - log::debug!( - target: LOG_TARGET, - "bitswap returned Missing for {cid}", - ); + log::debug!(target: LOG_TARGET, "bitswap returned Missing for {cid}"); }, Err(BitswapError::ServiceClosed) => { log::warn!( From 0e9877328d3b7cf73bff60be2455d2e9ff9663d0 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Fri, 3 Jul 2026 18:53:45 +0200 Subject: [PATCH 08/32] Restore base-branch changes lost during rebase, drop unused rand dep --- Cargo.lock | 1 - substrate/client/network/src/litep2p/mod.rs | 193 ++++++++++++++---- substrate/client/service/src/builder.rs | 6 +- .../client/storage-chain-sync/Cargo.toml | 1 - 4 files changed, 153 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 950f4f718429..17c33c0c091d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22189,7 +22189,6 @@ dependencies = [ "futures", "log", "parity-scale-codec", - "rand 0.8.5", "rstest", "sc-client-api 28.0.0", "sc-client-db", diff --git a/substrate/client/network/src/litep2p/mod.rs b/substrate/client/network/src/litep2p/mod.rs index 49092ed5b696..dbad4c6e36d0 100644 --- a/substrate/client/network/src/litep2p/mod.rs +++ b/substrate/client/network/src/litep2p/mod.rs @@ -47,7 +47,7 @@ use crate::{ NetworkStatus, NotificationService, ProtocolName, }; -use codec::Encode; +use codec::{Decode, Encode}; use futures::StreamExt; use litep2p::{ config::ConfigBuilder, @@ -60,7 +60,9 @@ use litep2p::{ }, transport::{ tcp::config::Config as TcpTransportConfig, - websocket::config::Config as WebSocketTransportConfig, ConnectionLimitsConfig, Endpoint, + webrtc::{config::Config as WebRtcTransportConfig, DtlsCertificate}, + websocket::config::Config as WebSocketTransportConfig, + ConnectionLimitsConfig, Endpoint, }, types::{ multiaddr::{Multiaddr, Protocol}, @@ -96,6 +98,9 @@ mod peerstore; mod service; mod shim; +/// File name for the persisted WebRTC DTLS certificate. +pub const NODE_KEY_WEBRTC_FILE: &str = "webrtc_certificate"; + /// Litep2p bandwidth sink. struct Litep2pBandwidthSink { sink: litep2p::BandwidthSink, @@ -272,65 +277,106 @@ impl Litep2pNetworkBackend { }; let config_builder = ConfigBuilder::new(); - let (tcp, websocket): (Vec>, Vec>) = config - .network_config - .listen_addresses - .iter() - .filter_map(|address| { - use sc_network_types::multiaddr::Protocol; + let listen_addr_len = config.network_config.listen_addresses.len(); + let mut tcp_addresses = Vec::with_capacity(listen_addr_len); + let mut websocket_addresses = Vec::with_capacity(listen_addr_len); + let mut webrtc_addresses = Vec::with_capacity(listen_addr_len); - let mut iter = address.iter(); + for addr in &config.network_config.listen_addresses { + use sc_network_types::multiaddr::Protocol; - match iter.next() { - Some(Protocol::Ip4(_) | Protocol::Ip6(_)) => {}, - protocol => { - log::error!( - target: LOG_TARGET, - "unknown protocol {protocol:?}, ignoring {address:?}", - ); + let mut iter = addr.iter(); - return None; - }, - } + let ip_version = iter.next(); + let Some(Protocol::Ip4(_) | Protocol::Ip6(_)) = ip_version else { + log::error!( + target: LOG_TARGET, + "unknown protocol {ip_version:?}, ignoring {addr:?}", + ); + continue; + }; - match iter.next() { - Some(Protocol::Tcp(_)) => match iter.next() { - Some(Protocol::Ws(_) | Protocol::Wss(_)) => { - Some((None, Some(address.clone()))) - }, - Some(Protocol::P2p(_)) | None => Some((Some(address.clone()), None)), - protocol => { - log::error!( - target: LOG_TARGET, - "unknown protocol {protocol:?}, ignoring {address:?}", - ); - None - }, - }, - protocol => { - log::error!( + let transport_layer = iter.next(); + let protocol_type = iter.next(); + + match (&transport_layer, &protocol_type) { + // Plain TCP address. + (Some(Protocol::Tcp(_)), Some(Protocol::P2p(_)) | None) => { + tcp_addresses.push(addr.clone()); + }, + + // Websocket address. + (Some(Protocol::Tcp(_)), Some(Protocol::Ws(_) | Protocol::Wss(_))) => { + websocket_addresses.push(addr.clone()); + }, + // WebRTCDirecet address. + (Some(Protocol::Udp(_)), Some(Protocol::WebRTCDirect)) => { + // Ignore WebRTC addresses unless the experimental feature is enabled. + if !config.network_config.experimental_webrtc { + log::warn!( target: LOG_TARGET, - "unknown protocol {protocol:?}, ignoring {address:?}", + "WebRTC address provided but --experimental-webrtc flag not enabled, ignoring {addr:?}" ); - None - }, - } - }) - .unzip(); + continue; + } + webrtc_addresses.push(addr.clone()); + }, + _ => { + log::error!( + target: LOG_TARGET, + "unknown transport layer {transport_layer:?} and protocol type {protocol_type:?}, ignoring {addr:?}", + ); + }, + }; + } - config_builder + let mut config_builder = config_builder .with_websocket(WebSocketTransportConfig { - listen_addresses: websocket.into_iter().flatten().map(Into::into).collect(), + listen_addresses: websocket_addresses.into_iter().map(Into::into).collect(), yamux_config: litep2p::yamux::Config::default(), nodelay: true, ..Default::default() }) .with_tcp(TcpTransportConfig { - listen_addresses: tcp.into_iter().flatten().map(Into::into).collect(), + listen_addresses: tcp_addresses.into_iter().map(Into::into).collect(), yamux_config: litep2p::yamux::Config::default(), nodelay: true, ..Default::default() - }) + }); + + if !webrtc_addresses.is_empty() { + config_builder = config_builder.with_webrtc({ + // If WebRTC has been specified within the listen address and there + // is an on-disk config dir, attempt to use an already existing + // DTLS certificate, or generate a fresh one. + // Otherwise, fall back to an ephemeral certificate. + let certificate = match &config.network_config.net_config_path { + Some(dir) => { + read_or_generate_webrtc_certificate(&dir.join(NODE_KEY_WEBRTC_FILE)) + }, + None => { + log::warn!( + target: LOG_TARGET, + "WebRtc enabled but no networking path specified, using an ephemeral certificate" + ); + None + }, + }; + + WebRtcTransportConfig { + listen_addresses: webrtc_addresses.into_iter().map(Into::into).collect(), + certificate, + ..Default::default() + } + }); + } else if config.network_config.experimental_webrtc { + log::warn!( + target: LOG_TARGET, + "WebRtc enabled but no listen address specified" + ); + } + + config_builder } } @@ -345,6 +391,14 @@ impl NetworkBackend for Litep2pNetworkBac where Self: Sized, { + // Install the ring CryptoProvider for rustls before any TLS connections are made. + if let Err(err) = rustls::crypto::ring::default_provider().install_default() { + log::warn!( + target: LOG_TARGET, + "failed to install ring CryptoProvider for rustls, another provider might be installed: {err:?}", + ); + } + let (keypair, local_peer_id) = Self::get_keypair(¶ms.network_config.network_config.node_key)?; let (cmd_tx, cmd_rx) = tracing_unbounded("mpsc_network_worker", 100_000); @@ -482,6 +536,9 @@ impl NetworkBackend for Litep2pNetworkBac Some(Protocol::Ws(_) | Protocol::Wss(_) | Protocol::Tcp(_)) => { address.with(Protocol::P2p(peer.into())) }, + Some(Protocol::WebRTCDirect | Protocol::Certhash(_)) => { + address.with(Protocol::P2p(peer.into())) + }, Some(Protocol::P2p(_)) => address, _ => return acc, }; @@ -1277,3 +1334,49 @@ impl NetworkBackend for Litep2pNetworkBac } } } + +/// Load the encoded WebRTC DTLS certificate from `file`, or generate a new one, +/// persist it (0600), and return it. +/// +/// Returns `None` if any error occurred while reading, writing, or generating the certificate. +fn read_or_generate_webrtc_certificate(file: &std::path::Path) -> Option { + match inner_read_or_generate_webrtc_certificate(file) { + Ok(maybe_certificate) => maybe_certificate, + Err(err) => { + log::warn!(target: LOG_TARGET, "{err}"); + None + }, + } +} + +/// Inner helper that wraps errors with context, the caller logs them. +fn inner_read_or_generate_webrtc_certificate( + file: &std::path::Path, +) -> Result, String> { + match std::fs::read(file) { + Ok(bytes) => { + log::info!(target: LOG_TARGET, "WebRTC certificate found at {file:?}, using existing one"); + let (certificate, private_key) = Decode::decode(&mut bytes.as_slice()) + .map_err(|err| format!("Failed to decode WebRTC certificate: {err:?}"))?; + DtlsCertificate::load(certificate, private_key) + .map_err(|err| format!("Failed to load WebRTC certificate: {err:?}")) + .map(Some) + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + log::info!(target: LOG_TARGET, "No WebRTC certificate found at {file:?}, generating a new one"); + file.parent() + .map_or(Ok(()), fs::create_dir_all) + .map_err(|err| format!("Failed to create WebRTC certificate directory: {err:?}"))?; + let certificate = DtlsCertificate::new() + .map_err(|err| format!("Failed to generate WebRTC certificate: {err:?}"))?; + let certificate_bytes = certificate.as_parts().encode(); + crate::config::write_secret_file(file, &certificate_bytes).map_err(|err| { + format!("Failed to persist WebRTC certificate to {file:?}: {err:?}") + })?; + Ok(Some(certificate)) + }, + Err(err) => Err(format!( + "Failed to read WebRTC certificate at {file:?}: {err:?}, using an ephemeral one" + )), + } +} diff --git a/substrate/client/service/src/builder.rs b/substrate/client/service/src/builder.rs index a81c78bbeb92..7b1e3a18b532 100644 --- a/substrate/client/service/src/builder.rs +++ b/substrate/client/service/src/builder.rs @@ -558,7 +558,11 @@ where spawn_handle.spawn( "txpool-notifications", Some("transaction-pool"), - sc_transaction_pool::notification_future(client.clone(), transaction_pool.clone()), + sc_transaction_pool::notification_future( + client.clone(), + transaction_pool.clone(), + config.transaction_pool.use_all_block_notifications(), + ), ); spawn_handle.spawn( diff --git a/substrate/client/storage-chain-sync/Cargo.toml b/substrate/client/storage-chain-sync/Cargo.toml index ed04c546cc1c..fd6f29d93943 100644 --- a/substrate/client/storage-chain-sync/Cargo.toml +++ b/substrate/client/storage-chain-sync/Cargo.toml @@ -17,7 +17,6 @@ cid = { workspace = true } codec = { workspace = true, default-features = true } futures = { workspace = true } log = { workspace = true, default-features = true } -rand = { workspace = true, default-features = true } sc-client-api = { workspace = true, default-features = true } sc-client-db = { workspace = true, default-features = true } sc-consensus = { workspace = true, default-features = true } From c41d8edbfcc9b0e1dcd69128f8c9cc167b9fde08 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Fri, 3 Jul 2026 20:29:12 +0200 Subject: [PATCH 09/32] Remove unneeded limit --- .../client/network/bitswap/src/handle.rs | 27 ++----- substrate/client/network/bitswap/src/lib.rs | 4 +- .../client/network/bitswap/src/service.rs | 78 ++++++++++--------- .../client/storage-chain-sync/src/fetcher.rs | 59 ++++++-------- 4 files changed, 75 insertions(+), 93 deletions(-) diff --git a/substrate/client/network/bitswap/src/handle.rs b/substrate/client/network/bitswap/src/handle.rs index 7760798ceae1..763bcc75a71d 100644 --- a/substrate/client/network/bitswap/src/handle.rs +++ b/substrate/client/network/bitswap/src/handle.rs @@ -23,7 +23,7 @@ //! Cheap to clone. Submit work via [`BitswapHandle::request_stream`], drain the receiver to //! get per-CID outcomes as they resolve. -use super::{is_cid_supported, Cid, MAX_WANTED_BLOCKS}; +use super::{is_cid_supported, Cid}; use async_trait::async_trait; use std::time::Duration; @@ -61,19 +61,8 @@ pub enum BitswapError { /// The service has too many in-flight wants. #[error("Bitswap service is overloaded")] Overloaded, - /// Per-call CID count exceeds [`MAX_CIDS_PER_REQUEST`]. - #[error("too many CIDs in request: {requested} > {max}")] - TooManyCids { - /// CIDs requested in the failing call. - requested: usize, - /// Service-level maximum. - max: usize, - }, } -/// Maximum number of CIDs accepted in a single [`BitswapHandle::request_stream`] call. -pub const MAX_CIDS_PER_REQUEST: usize = MAX_WANTED_BLOCKS; - /// Configuration applied at service construction time. #[derive(Debug, Clone)] pub struct BitswapServiceConfig { @@ -120,9 +109,12 @@ impl BitswapHandle { /// The stream closes when either every CID has produced an outcome, or an `Err` item /// has been emitted. /// + /// There is no per-call CID cap; the request size is bounded by the actor's overall + /// outstanding-CIDs budget, exceeding which yields the in-stream `Overloaded` above. + /// /// Returns a synchronous `BitswapError` for admission-time failures (`ServiceClosed`, - /// `InvalidCid`, `Overloaded`, `TooManyCids`). An empty `cids` slice returns an - /// immediately-closed receiver, not an error. + /// `InvalidCid`, `Overloaded`). An empty `cids` slice returns an immediately-closed + /// receiver, not an error. pub async fn request_stream( &self, cids: Vec, @@ -132,13 +124,6 @@ impl BitswapHandle { return Ok(rx); } - if cids.len() > MAX_CIDS_PER_REQUEST { - return Err(BitswapError::TooManyCids { - requested: cids.len(), - max: MAX_CIDS_PER_REQUEST, - }); - } - for cid in &cids { if !is_cid_supported(cid) { return Err(BitswapError::InvalidCid { cid: *cid }); diff --git a/substrate/client/network/bitswap/src/lib.rs b/substrate/client/network/bitswap/src/lib.rs index 219472d3c721..0cf0057172e1 100644 --- a/substrate/client/network/bitswap/src/lib.rs +++ b/substrate/client/network/bitswap/src/lib.rs @@ -30,13 +30,13 @@ pub use cid::Cid; pub(crate) use handle::BitswapCommand; pub use handle::{ BitswapError, BitswapHandle, BitswapRequest, BitswapServiceConfig, FetchItem, FetchOutcome, - MAX_CIDS_PER_REQUEST, }; pub use service::start; pub(crate) const LOG_TARGET: &str = "sub-libp2p::bitswap"; -/// Max number of blocks per wantlist, enforced both on the wire and at admission. +/// Max number of wantlist entries per Bitswap message: inbound wantlists with more +/// entries are ignored, and outbound WANT bundles are split at this size. pub const MAX_WANTED_BLOCKS: usize = 16; /// IPFS raw multicodec used for indexed transaction payload bytes. diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index f1ef77a83212..307bd79f7ccc 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -438,17 +438,24 @@ impl BitswapService { self.wants.add_waiter(*cid, waiter_id); } - for cid in cids { - self.top_up_in_flight(cid).await; - } + self.top_up_in_flight(cids).await; } - async fn top_up_in_flight(&mut self, cid: Cid) { - if let Some(peer) = - self.wants.next_peer_to_request(cid, &self.connected_peers, Instant::now()) - { - log::trace!(target: LOG_TARGET, "WANT-BLOCK {cid} -> {peer:?}"); - self.handle.send_request(peer, vec![(cid, WantType::Block)]).await; + /// Dispatch WANT-BLOCK requests for the given CIDs, bundling CIDs assigned to the same + /// peer into wantlist messages of up to [`MAX_WANTED_BLOCKS`] entries. + async fn top_up_in_flight(&mut self, cids: impl IntoIterator) { + let now = Instant::now(); + let mut by_peer: HashMap> = HashMap::new(); + for cid in cids { + if let Some(peer) = self.wants.next_peer_to_request(cid, &self.connected_peers, now) { + log::trace!(target: LOG_TARGET, "WANT-BLOCK {cid} -> {peer:?}"); + by_peer.entry(peer).or_default().push((cid, WantType::Block)); + } + } + for (peer, wants) in by_peer { + for chunk in wants.chunks(MAX_WANTED_BLOCKS) { + self.handle.send_request(peer, chunk.to_vec()).await; + } } } @@ -491,11 +498,7 @@ impl BitswapService { } } - for cid in cids_to_top_up { - if self.wants.contains(&cid) { - self.top_up_in_flight(cid).await; - } - } + self.top_up_in_flight(cids_to_top_up).await; } fn deliver_block(&mut self, cid: Cid, bytes: Vec) { @@ -542,23 +545,18 @@ impl BitswapService { async fn on_peer_connected(&mut self, peer: litep2p::PeerId) { self.connected_peers.insert(peer); let cids = self.wants.all_cids(); - for cid in cids { - self.top_up_in_flight(cid).await; - } + self.top_up_in_flight(cids).await; } async fn on_peer_disconnected(&mut self, peer: litep2p::PeerId) { self.connected_peers.remove(&peer); let cids_to_top_up = self.wants.remove_in_flight_peer(peer); - for cid in cids_to_top_up { - self.top_up_in_flight(cid).await; - } + self.top_up_in_flight(cids_to_top_up).await; } async fn sweep_per_peer_timeouts(&mut self) { - for cid in self.wants.expire_peer_timeouts(Instant::now()) { - self.top_up_in_flight(cid).await; - } + let cids = self.wants.expire_peer_timeouts(Instant::now()); + self.top_up_in_flight(cids).await; } fn on_inbound_request(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { @@ -987,8 +985,8 @@ mod tests { rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); let mut rx = rig.user_handle.request_stream(vec![cid_a, cid_b]).await.unwrap(); - let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound a or b"); - let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound the other"); + let (_, entries) = drain_next(&mut rig.outbound_req_rx).await.expect("bundled WANT"); + assert_eq!(entries.len(), 2, "both CIDs go to the same peer in one message"); tokio::time::advance(Duration::from_millis(60)).await; @@ -1138,9 +1136,12 @@ mod tests { )); } - #[tokio::test] - async fn admission_too_many_cids_rejected() { - let rig = empty_rig(); + #[tokio::test(start_paused = true)] + async fn outbound_wants_bundled_and_split_at_message_cap() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + let cids: Vec = (0..=MAX_WANTED_BLOCKS as u8) .map(|i| { let mut d = [0u8; 32]; @@ -1149,14 +1150,19 @@ mod tests { }) .collect(); - let err = rig.user_handle.request_stream(cids).await.err().expect("err"); - match err { - BitswapError::TooManyCids { requested, max } => { - assert_eq!(requested, MAX_WANTED_BLOCKS + 1); - assert_eq!(max, MAX_WANTED_BLOCKS); - }, - other => panic!("expected TooManyCids, got {other:?}"), - } + let _rx = rig.user_handle.request_stream(cids.clone()).await.unwrap(); + + let (peer_a, first) = drain_next(&mut rig.outbound_req_rx).await.expect("first bundle"); + let (peer_b, second) = drain_next(&mut rig.outbound_req_rx).await.expect("second bundle"); + assert_eq!(peer_a, peer); + assert_eq!(peer_b, peer); + + let mut sizes = [first.len(), second.len()]; + sizes.sort(); + assert_eq!(sizes, [1, MAX_WANTED_BLOCKS]); + + let sent: HashSet = first.into_iter().chain(second).map(|(cid, _)| cid).collect(); + assert_eq!(sent, cids.into_iter().collect::>()); } #[tokio::test] diff --git a/substrate/client/storage-chain-sync/src/fetcher.rs b/substrate/client/storage-chain-sync/src/fetcher.rs index a904cfe7e5b3..4d145354e6e3 100644 --- a/substrate/client/storage-chain-sync/src/fetcher.rs +++ b/substrate/client/storage-chain-sync/src/fetcher.rs @@ -19,13 +19,12 @@ //! Bitswap-based fetcher for indexed-transaction blobs. //! //! Thin adapter over [`sc_network_bitswap::BitswapHandle`]: builds the per-want CIDs, -//! submits them in chunks of [`MAX_CIDS_PER_REQUEST`] and collects the outcomes. Peer -//! selection, timeouts, retries and hash verification live in the bitswap service. +//! submits them and collects the outcomes. Peer selection, timeouts, retries and hash +//! verification live in the bitswap service. use crate::RenewWant; use cid::{multihash::Multihash, Cid}; -use futures::future; -use sc_network_bitswap::{BitswapError, BitswapRequest, FetchOutcome, MAX_CIDS_PER_REQUEST}; +use sc_network_bitswap::{BitswapError, BitswapRequest, FetchOutcome}; use sp_runtime::traits::Block as BlockT; use sp_transaction_storage_proof::ContentHash; use std::{ @@ -101,40 +100,32 @@ impl IndexedTransactionFetcher { cids.push(cid); } - let receivers = future::try_join_all( - cids.chunks(MAX_CIDS_PER_REQUEST) - .map(|chunk| handle.request_stream(chunk.to_vec())), - ) - .await?; + let mut rx = handle.request_stream(cids).await?; - // Every receiver is sized to buffer all its outcomes, so the requests progress - // concurrently regardless of drain order. let mut acquired: HashMap> = HashMap::with_capacity(wants.len()); - for mut rx in receivers { - while let Some(item) = rx.recv().await { - match item { - Ok((cid, FetchOutcome::Block(bytes))) => { - if let Some(hash) = by_cid.get(&cid) { - log::debug!( - target: LOG_TARGET, - "bitswap fetched {} bytes for {hash:?}", - bytes.len(), - ); - acquired.insert(*hash, bytes); - } - }, - Ok((cid, FetchOutcome::Missing)) => { - log::debug!(target: LOG_TARGET, "bitswap returned Missing for {cid}"); - }, - Err(BitswapError::ServiceClosed) => { - log::warn!( + while let Some(item) = rx.recv().await { + match item { + Ok((cid, FetchOutcome::Block(bytes))) => { + if let Some(hash) = by_cid.get(&cid) { + log::debug!( target: LOG_TARGET, - "bitswap service closed mid-stream; returning partial result", + "bitswap fetched {} bytes for {hash:?}", + bytes.len(), ); - return Ok(acquired); - }, - Err(other) => return Err(FetchError::Bitswap(other)), - } + acquired.insert(*hash, bytes); + } + }, + Ok((cid, FetchOutcome::Missing)) => { + log::debug!(target: LOG_TARGET, "bitswap returned Missing for {cid}"); + }, + Err(BitswapError::ServiceClosed) => { + log::warn!( + target: LOG_TARGET, + "bitswap service closed mid-stream; returning partial result", + ); + return Ok(acquired); + }, + Err(other) => return Err(FetchError::Bitswap(other)), } } From e67dc17b3665ba9e9d4dcd1ae71aec74201fe9ae Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Wed, 15 Jul 2026 12:06:10 +0200 Subject: [PATCH 10/32] Remove CID limit, move timeout to caller --- Cargo.lock | 2 +- prdoc/pr_12052.prdoc | 31 +- substrate/client/network/bitswap/Cargo.toml | 2 +- .../client/network/bitswap/src/handle.rs | 72 +-- substrate/client/network/bitswap/src/lib.rs | 4 +- .../client/network/bitswap/src/service.rs | 462 ++++++++++++------ substrate/client/service/src/builder.rs | 1 - .../client/storage-chain-sync/Cargo.toml | 2 +- .../client/storage-chain-sync/src/fetcher.rs | 51 +- .../client/storage-chain-sync/tests/it.rs | 16 +- 10 files changed, 403 insertions(+), 240 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17c33c0c091d..b0b91245d82d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21572,6 +21572,7 @@ dependencies = [ "log", "sc-block-builder", "sc-client-api 28.0.0", + "sc-network-common 0.33.0", "sc-network-sync", "sc-network-types 0.10.0", "slotmap", @@ -21585,7 +21586,6 @@ dependencies = [ "thiserror 1.0.65", "tokio", "tokio-stream", - "tokio-util", ] [[package]] diff --git a/prdoc/pr_12052.prdoc b/prdoc/pr_12052.prdoc index c798df5143d1..d02e7d104b03 100644 --- a/prdoc/pr_12052.prdoc +++ b/prdoc/pr_12052.prdoc @@ -9,16 +9,21 @@ doc: user-facing `BitswapHandle` is returned as the last element of the `build_network`/`build_network_advanced` output tuple (`None` when Bitswap is not enabled). The handle's only method is - `request_stream(cids) -> Receiver>`, - which streams per-CID outcomes as they resolve. + `request_stream(cids) -> Receiver), BitswapError>>`, + which streams hash-verified per-CID results as they resolve. + + Requests of any size are accepted: at most 1024 CIDs have in-flight peer + requests at a time (the dispatch window), the rest queue inside the service and + are dispatched as slots free up. Requests carry no service-side deadline; the + service retries unresolved CIDs as peers connect, and the caller bounds the + wait by dropping the receiver, which cancels the remaining wants. The service runs an internal actor that owns peer selection (fed by - `sc-network-sync` peer connect/disconnect events), per-peer timeouts (5s), - per-waiter deadlines (default 30s), hash verification of every received block, - and bounded admission. Inbound serving is off-loaded to a bounded - `spawn_blocking` worker pool so storage reads never block outbound traffic or - response correlation. Inbound serving keeps running even if no consumer holds a - `BitswapHandle`. + `sc-network-sync` peer connect/disconnect events), per-peer timeouts (5s) and + hash verification of every received block. Inbound serving is off-loaded to a + bounded `spawn_blocking` worker pool so storage reads never block outbound + traffic or response correlation. Inbound serving keeps running even if no + consumer holds a `BitswapHandle`. The libp2p Bitswap implementation is removed; Bitswap now requires the litep2p network backend. Enabling `--ipfs-server` together with @@ -29,11 +34,11 @@ doc: `sc_network_bitswap::start`. `sc-storage-chain-sync` is migrated to the new API: `IndexedTransactionFetcher` - no longer rotates across peers or applies its own timeouts; it builds CIDs, - chunks the wantlist and drains the result streams. `BitswapPeerSource`, - `NetworkHandle` and `SyncingHandle` are replaced by a single - `BitswapHandleSlot`, which the omni-node populates from the `build_network` - output. + no longer rotates across peers; it builds CIDs, submits them in one request and + drains the result stream under a time budget that scales with the wantlist + size. `BitswapPeerSource`, `NetworkHandle` and `SyncingHandle` are replaced by + a single `BitswapHandleSlot`, which the omni-node populates from the + `build_network` output. Closes https://github.com/paritytech/polkadot-sdk/issues/12052. diff --git a/substrate/client/network/bitswap/Cargo.toml b/substrate/client/network/bitswap/Cargo.toml index 0522bc8467cf..021a17c1e5a2 100644 --- a/substrate/client/network/bitswap/Cargo.toml +++ b/substrate/client/network/bitswap/Cargo.toml @@ -22,6 +22,7 @@ futures = { workspace = true } litep2p = { workspace = true } log = { workspace = true, default-features = true } sc-client-api = { workspace = true, default-features = true } +sc-network-common = { workspace = true, default-features = true } sc-network-sync = { workspace = true, default-features = true } sc-network-types = { workspace = true, default-features = true } slotmap = { workspace = true } @@ -31,7 +32,6 @@ sp-crypto-hashing = { workspace = true, default-features = true } sp-runtime = { workspace = true, default-features = true } thiserror = { workspace = true } tokio = { features = ["macros", "rt", "sync"], workspace = true, default-features = true } -tokio-util = { features = ["time"], workspace = true } [dev-dependencies] sc-block-builder = { workspace = true, default-features = true } diff --git a/substrate/client/network/bitswap/src/handle.rs b/substrate/client/network/bitswap/src/handle.rs index 763bcc75a71d..0f07f855c09d 100644 --- a/substrate/client/network/bitswap/src/handle.rs +++ b/substrate/client/network/bitswap/src/handle.rs @@ -20,32 +20,22 @@ //! The handle is returned by [`crate::start`] when the node is configured with //! `--ipfs-server` and uses the litep2p network backend. //! -//! Cheap to clone. Submit work via [`BitswapHandle::request_stream`], drain the receiver to -//! get per-CID outcomes as they resolve. +//! Cheap to clone. Submit work via [`BitswapHandle::request_stream`], drain the receiver +//! to get per-CID results as they resolve. The service retries unresolved CIDs for as +//! long as the request is alive; the caller owns the time budget: apply a timeout while +//! draining and drop the receiver to give up. Dropping the receiver cancels all wants +//! remaining in the request. use super::{is_cid_supported, Cid}; use async_trait::async_trait; -use std::time::Duration; use tokio::sync::mpsc; -/// Outcome of a single Bitswap fetch for one CID. -#[derive(Debug)] -pub enum FetchOutcome { - /// Hash-verified bytes for the requested CID. - Block(Vec), - /// The block was not retrieved before the request deadline expired: no peers were - /// available, every peer replied DONT_HAVE or timed out, or every candidate block - /// failed CID verification. - Missing, -} - /// Service-level Bitswap errors. /// /// Returned synchronously from [`BitswapHandle::request_stream`] for admission-time /// failures, and appearing at most once inside the returned stream (`Overloaded` or -/// `ServiceClosed`). Per-CID failure modes collapse into [`FetchOutcome::Missing`] -/// instead of producing an error. +/// `ServiceClosed`). #[derive(Debug, thiserror::Error)] pub enum BitswapError { /// The Bitswap service task has shut down. @@ -58,28 +48,15 @@ pub enum BitswapError { /// The offending CID. cid: Cid, }, - /// The service has too many in-flight wants. + /// The service cannot accept the request: the command channel is full, or too many + /// concurrent requests want the same CID. #[error("Bitswap service is overloaded")] Overloaded, } -/// Configuration applied at service construction time. -#[derive(Debug, Clone)] -pub struct BitswapServiceConfig { - /// Per-waiter deadline. Each call to [`BitswapHandle::request_stream`] inherits this - /// value; the receiver will yield `Missing` for any CID still unresolved at the - /// deadline and then close. - pub request_timeout: Duration, -} - -impl Default for BitswapServiceConfig { - fn default() -> Self { - Self { request_timeout: Duration::from_secs(30) } - } -} - -/// Item carried on the receiver returned by [`BitswapHandle::request_stream`]. -pub type FetchItem = Result<(Cid, FetchOutcome), BitswapError>; +/// Item carried on the receiver returned by [`BitswapHandle::request_stream`]: the +/// hash-verified bytes for one requested CID, or a terminal service error. +pub type FetchItem = Result<(Cid, Vec), BitswapError>; /// User-facing handle to the Bitswap service. /// @@ -96,25 +73,24 @@ impl BitswapHandle { Self { cmd_tx } } - /// Submit a wantlist. Returns a receiver that yields one item per requested CID, in - /// the order they resolve. + /// Submit a wantlist. Returns a receiver that yields `Ok((cid, bytes))` with + /// hash-verified bytes for each requested CID, in the order they resolve. /// - /// Each item is: - /// - `Ok((cid, FetchOutcome::Block(bytes)))` when a peer delivered hash-verified bytes. - /// - `Ok((cid, FetchOutcome::Missing))` when the per-waiter deadline expired without a block. - /// - `Err(BitswapError::Overloaded)` once as the only item, if the service actor rejects the - /// request (too many outstanding CIDs or waiters). - /// - `Err(BitswapError::ServiceClosed)` once, if the service task shuts down mid-stream. + /// The stream closes once every CID has been delivered. A CID that no connected peer + /// can serve stays unresolved indefinitely; the service keeps retrying as peers + /// connect. To bound the wait, apply a timeout while draining and drop the receiver — + /// dropping it cancels all wants remaining in this request. /// - /// The stream closes when either every CID has produced an outcome, or an `Err` item - /// has been emitted. + /// There is no per-call CID cap: requests larger than the service's dispatch window + /// are queued internally and fetched as window slots free up. /// - /// There is no per-call CID cap; the request size is bounded by the actor's overall - /// outstanding-CIDs budget, exceeding which yields the in-stream `Overloaded` above. + /// `Err(BitswapError::ServiceClosed)` is yielded once, as the final item, if the + /// service shuts down mid-request. `Err(BitswapError::Overloaded)` is yielded once as + /// the only item if too many concurrent requests want one of the CIDs. /// /// Returns a synchronous `BitswapError` for admission-time failures (`ServiceClosed`, - /// `InvalidCid`, `Overloaded`). An empty `cids` slice returns an immediately-closed - /// receiver, not an error. + /// `InvalidCid`, or `Overloaded` when the command channel is full). An empty `cids` + /// slice returns an immediately-closed receiver, not an error. pub async fn request_stream( &self, cids: Vec, diff --git a/substrate/client/network/bitswap/src/lib.rs b/substrate/client/network/bitswap/src/lib.rs index 0cf0057172e1..2ac3c148f92f 100644 --- a/substrate/client/network/bitswap/src/lib.rs +++ b/substrate/client/network/bitswap/src/lib.rs @@ -28,9 +28,7 @@ mod service; pub use cid::Cid; pub(crate) use handle::BitswapCommand; -pub use handle::{ - BitswapError, BitswapHandle, BitswapRequest, BitswapServiceConfig, FetchItem, FetchOutcome, -}; +pub use handle::{BitswapError, BitswapHandle, BitswapRequest, FetchItem}; pub use service::start; pub(crate) const LOG_TARGET: &str = "sub-libp2p::bitswap"; diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 307bd79f7ccc..6d3cc6308569 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -21,14 +21,20 @@ //! the inbound (serve indexed-transaction blocks to peers) and outbound (fetch CIDs from //! peers on behalf of [`BitswapHandle`] callers) Bitswap flows. //! +//! Outbound requests of any size are accepted; at most [`MAX_LIVE_CIDS`] CIDs have +//! in-flight peer requests at a time, the rest queue and are dispatched as window slots +//! free up. Requests carry no service-side deadline: the caller bounds the wait by +//! dropping the receiver, which a periodic sweep detects to release the wants. +//! //! Peer connect/disconnect tracking comes from `sc-network-sync`'s [`SyncEventStream`]. The //! sync engine replays `PeerConnected` for every currently-connected peer when a new -//! subscriber registers, so the actor sees the full peer set on startup. +//! subscriber registers, so the actor sees the full peer set on startup. Light-client +//! peers are not tracked: they do not hold the indexed-transaction data served over +//! bitswap. use super::{ - is_cid_supported, BitswapCommand, BitswapHandle, BitswapServiceConfig, Cid, FetchItem, - FetchOutcome, BLAKE2B_256_MULTIHASH_CODE, KECCAK_256_MULTIHASH_CODE, LOG_TARGET, - MAX_WANTED_BLOCKS, SHA2_256_MULTIHASH_CODE, + is_cid_supported, BitswapCommand, BitswapHandle, Cid, FetchItem, BLAKE2B_256_MULTIHASH_CODE, + KECCAK_256_MULTIHASH_CODE, LOG_TARGET, MAX_WANTED_BLOCKS, SHA2_256_MULTIHASH_CODE, }; use crate::handle::BitswapError; @@ -39,13 +45,14 @@ use litep2p::protocol::libp2p::bitswap::{ BitswapEvent, BitswapHandle as LitepBitswapHandle, BlockPresenceType, ResponseType, WantType, }; use sc_client_api::BlockBackend; +use sc_network_common::role::Roles; use sc_network_sync::{SyncEvent, SyncEventStream}; use slotmap::{new_key_type, SlotMap}; use smallvec::SmallVec; use sp_core::H256; use sp_runtime::traits::Block as BlockT; use std::{ - collections::{HashMap, HashSet}, + collections::{HashMap, HashSet, VecDeque}, future::Future, pin::Pin, sync::Arc, @@ -55,7 +62,6 @@ use tokio::{ sync::{mpsc, Semaphore}, time::Instant, }; -use tokio_util::time::{delay_queue, DelayQueue}; #[async_trait] pub(crate) trait BitswapTransport: Send { @@ -79,14 +85,17 @@ impl BitswapTransport for LitepBitswapHandle { } } -const MAX_OUTSTANDING_CIDS: usize = 1024; +/// Dispatch-window size: max number of CIDs with in-flight peer requests at once. CIDs +/// beyond the window are queued and dispatched as slots free up, so requests of any size +/// are accepted. +const MAX_LIVE_CIDS: usize = 1024; const MAX_WAITERS_PER_CID: usize = 64; const MAX_CONCURRENT_INBOUND_LOOKUPS: usize = 8; const CMD_CHANNEL_CAPACITY: usize = 256; const LOOKUP_CHANNEL_CAPACITY: usize = 64; const PER_PEER_TIMEOUT: Duration = Duration::from_secs(5); const PEER_FANOUT_CAP: usize = 1; -const PEER_TIMEOUT_SWEEP_INTERVAL: Duration = Duration::from_secs(1); +const SWEEP_INTERVAL: Duration = Duration::from_secs(1); new_key_type! { struct WaiterId; } @@ -95,6 +104,8 @@ struct CidState { tried_peers: HashSet, in_flight_peers: HashMap, waiters: SmallVec<[WaiterId; 2]>, + /// Whether this CID currently has a valid entry in [`WantSet::pending`]. + pending: bool, } impl CidState { @@ -103,6 +114,7 @@ impl CidState { tried_peers: HashSet::new(), in_flight_peers: HashMap::new(), waiters: SmallVec::new(), + pending: false, } } @@ -117,25 +129,25 @@ impl CidState { struct WantSet { inner: HashMap, + /// FIFO of CIDs waiting for a free dispatch-window slot. May contain stale entries + /// (resolved, abandoned or already-dispatched CIDs); those are skipped on pop, guarded + /// by [`CidState::pending`]. + pending: VecDeque, + /// Number of CIDs with at least one in-flight peer request. + live: usize, + /// Dispatch-window size. + max_live: usize, } impl WantSet { - fn new() -> Self { - Self { inner: HashMap::new() } - } - - fn len(&self) -> usize { - self.inner.len() + fn new(max_live: usize) -> Self { + Self { inner: HashMap::new(), pending: VecDeque::new(), live: 0, max_live } } fn contains(&self, cid: &Cid) -> bool { self.inner.contains_key(cid) } - fn new_cid_count(&self, cids: &[Cid]) -> usize { - cids.iter().filter(|cid| !self.inner.contains_key(cid)).count() - } - fn waiter_count(&self, cid: &Cid) -> usize { self.inner.get(cid).map_or(0, |state| state.waiters.len()) } @@ -156,7 +168,29 @@ impl WantSet { } fn take_waiters_for_delivered_cid(&mut self, cid: Cid) -> Option> { - self.inner.remove(&cid).map(|state| state.waiters) + self.inner.remove(&cid).map(|state| { + if !state.in_flight_peers.is_empty() { + self.live -= 1; + } + state.waiters + }) + } + + fn has_window_capacity(&self) -> bool { + self.live < self.max_live + } + + /// Pop the next CID waiting for a window slot, skipping stale queue entries. + fn pop_pending(&mut self) -> Option { + while let Some(cid) = self.pending.pop_front() { + if let Some(state) = self.inner.get_mut(&cid) { + if state.pending { + state.pending = false; + return Some(cid); + } + } + } + None } fn next_peer_to_request( @@ -170,6 +204,16 @@ impl WantSet { return None; } + // Dispatch window full: queue the CID for promotion when a slot frees up. + if state.in_flight_peers.is_empty() && !self.has_window_capacity() { + let state = self.inner.get_mut(&cid).expect("checked above; qed"); + if !state.pending { + state.pending = true; + self.pending.push_back(cid); + } + return None; + } + let peer = connected_peers .iter() .find(|peer| { @@ -178,25 +222,35 @@ impl WantSet { .copied()?; let state = self.inner.get_mut(&cid).expect("checked above; qed"); + if state.in_flight_peers.is_empty() { + self.live += 1; + } state.in_flight_peers.insert(peer, now + PER_PEER_TIMEOUT); + state.pending = false; Some(peer) } fn mark_peer_done_for_cid(&mut self, peer: litep2p::PeerId, cid: Cid) { if let Some(state) = self.inner.get_mut(&cid) { - state.in_flight_peers.remove(&peer); + if state.in_flight_peers.remove(&peer).is_some() && state.in_flight_peers.is_empty() { + self.live -= 1; + } state.tried_peers.insert(peer); } self.remove_if_idle(cid); } fn remove_in_flight_peer(&mut self, peer: litep2p::PeerId) -> Vec { - let affected: Vec = self - .inner - .iter_mut() - .filter_map(|(cid, state)| state.in_flight_peers.remove(&peer).map(|_| *cid)) - .collect(); + let mut affected: Vec = Vec::new(); + for (cid, state) in self.inner.iter_mut() { + if state.in_flight_peers.remove(&peer).is_some() { + if state.in_flight_peers.is_empty() { + self.live -= 1; + } + affected.push(*cid); + } + } self.remove_idle_and_filter_existing(affected) } @@ -204,6 +258,7 @@ impl WantSet { fn expire_peer_timeouts(&mut self, now: Instant) -> Vec { let mut timed_out: Vec<(Cid, litep2p::PeerId)> = Vec::new(); for (cid, state) in self.inner.iter_mut() { + let had_in_flight = !state.in_flight_peers.is_empty(); state.in_flight_peers.retain(|peer, deadline| { if *deadline <= now { timed_out.push((*cid, *peer)); @@ -212,6 +267,9 @@ impl WantSet { true } }); + if had_in_flight && state.in_flight_peers.is_empty() { + self.live -= 1; + } } let mut cids = Vec::with_capacity(timed_out.len()); @@ -227,6 +285,8 @@ impl WantSet { fn clear(&mut self) { self.inner.clear(); + self.pending.clear(); + self.live = 0; } fn remove_idle_and_filter_existing(&mut self, cids: Vec) -> Vec { @@ -246,7 +306,6 @@ impl WantSet { struct Waiter { cids_remaining: HashSet, sink: mpsc::Sender, - delay_key: delay_queue::Key, } type InboundLookupResult = (litep2p::PeerId, Vec); @@ -292,7 +351,6 @@ impl InboundLookupPool { pub(crate) struct BitswapService { handle: Box, - config: BitswapServiceConfig, cmd_rx: mpsc::Receiver, /// Set once every [`BitswapHandle`] has been dropped; the actor then stops polling @@ -302,7 +360,6 @@ pub(crate) struct BitswapService { inbound_lookup_pool: InboundLookupPool, inbound_lookup_rx: mpsc::Receiver, - waiter_deadlines: DelayQueue, connected_peers: HashSet, wants: WantSet, waiters: SlotMap, @@ -317,7 +374,6 @@ pub fn start( client: Arc + Send + Sync>, sync: &S, litep2p_handle: LitepBitswapHandle, - config: BitswapServiceConfig, ) -> (Pin + Send>>, BitswapHandle) where S: SyncEventStream + ?Sized, @@ -330,15 +386,13 @@ where let service = BitswapService { handle: Box::new(litep2p_handle), - config, cmd_rx, cmd_channel_closed: false, sync_event_stream, inbound_lookup_pool, inbound_lookup_rx, - waiter_deadlines: DelayQueue::new(), connected_peers: HashSet::new(), - wants: WantSet::new(), + wants: WantSet::new(MAX_LIVE_CIDS), waiters: SlotMap::with_key(), }; @@ -350,9 +404,9 @@ where impl BitswapService { async fn run(mut self) { log::debug!(target: LOG_TARGET, "BitswapService starting"); - let mut peer_timeout_ticker = tokio::time::interval(PEER_TIMEOUT_SWEEP_INTERVAL); - peer_timeout_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - peer_timeout_ticker.tick().await; + let mut sweep_ticker = tokio::time::interval(SWEEP_INTERVAL); + sweep_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + sweep_ticker.tick().await; loop { tokio::select! { @@ -384,7 +438,8 @@ impl BitswapService { }, sync_ev = self.sync_event_stream.next() => match sync_ev { - Some(SyncEvent::PeerConnected(peer)) => self.on_peer_connected(peer.into()).await, + Some(SyncEvent::PeerConnected { peer_id, roles }) => + self.on_peer_connected(peer_id.into(), roles).await, Some(SyncEvent::PeerDisconnected(peer)) => self.on_peer_disconnected(peer.into()).await, None => { log::debug!(target: LOG_TARGET, "sync event stream ended; shutting down"); @@ -393,31 +448,18 @@ impl BitswapService { }, }, - maybe_expired = self.waiter_deadlines.next(), if !self.waiter_deadlines.is_empty() => { - if let Some(expired) = maybe_expired { - self.on_waiter_expired(expired.into_inner()); - } - }, - Some((peer, responses)) = self.inbound_lookup_rx.recv() => { self.handle.send_response(peer, responses).await; }, - _ = peer_timeout_ticker.tick() => { - self.sweep_per_peer_timeouts().await; + _ = sweep_ticker.tick() => { + self.on_sweep().await; }, } } } async fn on_request_stream(&mut self, cids: Vec, sink: mpsc::Sender) { - // Charge the outstanding-CIDs budget only for CIDs not already in `wants`. - let new_cid_count = self.wants.new_cid_count(&cids); - if self.wants.len() + new_cid_count > MAX_OUTSTANDING_CIDS { - let _ = sink.try_send(Err(BitswapError::Overloaded)); - return; - } - for cid in &cids { if self.wants.waiter_count(cid) >= MAX_WAITERS_PER_CID { let _ = sink.try_send(Err(BitswapError::Overloaded)); @@ -425,14 +467,8 @@ impl BitswapService { } } - let deadline = Instant::now() + self.config.request_timeout; let cids_remaining: HashSet = cids.iter().copied().collect(); - - let waiter_id = self.waiters.insert_with_key(|id| Waiter { - cids_remaining, - sink, - delay_key: self.waiter_deadlines.insert_at(id, deadline), - }); + let waiter_id = self.waiters.insert(Waiter { cids_remaining, sink }); for cid in &cids { self.wants.add_waiter(*cid, waiter_id); @@ -441,8 +477,9 @@ impl BitswapService { self.top_up_in_flight(cids).await; } - /// Dispatch WANT-BLOCK requests for the given CIDs, bundling CIDs assigned to the same - /// peer into wantlist messages of up to [`MAX_WANTED_BLOCKS`] entries. + /// Dispatch WANT-BLOCK requests for the given CIDs, then promote queued CIDs into any + /// remaining dispatch-window capacity. CIDs assigned to the same peer are bundled into + /// wantlist messages of up to [`MAX_WANTED_BLOCKS`] entries. async fn top_up_in_flight(&mut self, cids: impl IntoIterator) { let now = Instant::now(); let mut by_peer: HashMap> = HashMap::new(); @@ -452,6 +489,15 @@ impl BitswapService { by_peer.entry(peer).or_default().push((cid, WantType::Block)); } } + + while self.wants.has_window_capacity() { + let Some(cid) = self.wants.pop_pending() else { break }; + if let Some(peer) = self.wants.next_peer_to_request(cid, &self.connected_peers, now) { + log::trace!(target: LOG_TARGET, "WANT-BLOCK {cid} -> {peer:?} (promoted)"); + by_peer.entry(peer).or_default().push((cid, WantType::Block)); + } + } + for (peer, wants) in by_peer { for chunk in wants.chunks(MAX_WANTED_BLOCKS) { self.handle.send_request(peer, chunk.to_vec()).await; @@ -509,7 +555,7 @@ impl BitswapService { if !waiter.cids_remaining.remove(&cid) { continue; } - if waiter.sink.try_send(Ok((cid, FetchOutcome::Block(bytes.clone())))).is_err() { + if waiter.sink.try_send(Ok((cid, bytes.clone()))).is_err() { log::trace!(target: LOG_TARGET, "waiter sink full/closed for {cid}"); self.drop_waiter(waiter_id); continue; @@ -522,27 +568,17 @@ impl BitswapService { fn drop_waiter(&mut self, id: WaiterId) { let Some(waiter) = self.waiters.remove(id) else { return }; - self.waiter_deadlines.remove(&waiter.delay_key); for cid in &waiter.cids_remaining { self.wants.remove_waiter(*cid, id); } } - fn on_waiter_expired(&mut self, id: WaiterId) { - let Some(waiter) = self.waiters.remove(id) else { return }; - for cid in &waiter.cids_remaining { - let _ = waiter.sink.try_send(Ok((*cid, FetchOutcome::Missing))); - self.wants.remove_waiter(*cid, id); + async fn on_peer_connected(&mut self, peer: litep2p::PeerId, roles: Roles) { + // Light clients do not hold the indexed-transaction data served over bitswap. + if roles.is_light() { + return; } - log::trace!( - target: LOG_TARGET, - "waiter {id:?} expired; emitted Missing for {} CIDs", - waiter.cids_remaining.len(), - ); - } - - async fn on_peer_connected(&mut self, peer: litep2p::PeerId) { self.connected_peers.insert(peer); let cids = self.wants.all_cids(); self.top_up_in_flight(cids).await; @@ -554,7 +590,20 @@ impl BitswapService { self.top_up_in_flight(cids_to_top_up).await; } - async fn sweep_per_peer_timeouts(&mut self) { + /// Periodic housekeeping: drop waiters whose receiver was dropped (the caller gave up + /// or applied its own timeout), then expire per-peer request timeouts and retry the + /// affected CIDs on other peers. + async fn on_sweep(&mut self) { + let abandoned: Vec = self + .waiters + .iter() + .filter_map(|(id, waiter)| waiter.sink.is_closed().then_some(id)) + .collect(); + for id in abandoned { + log::trace!(target: LOG_TARGET, "dropping abandoned waiter {id:?}"); + self.drop_waiter(id); + } + let cids = self.wants.expire_peer_timeouts(Instant::now()); self.top_up_in_flight(cids).await; } @@ -579,7 +628,6 @@ impl BitswapService { fn shutdown_waiters(&mut self) { for (_, waiter) in self.waiters.drain() { let _ = waiter.sink.try_send(Err(BitswapError::ServiceClosed)); - self.waiter_deadlines.remove(&waiter.delay_key); } self.wants.clear(); } @@ -677,7 +725,7 @@ mod tests { fn build_rig_with( client: Arc + Send + Sync>, - config: BitswapServiceConfig, + max_live_cids: usize, ) -> TestRig { let (inbound_tx, inbound_rx) = mpsc::channel(64); let (outbound_req_tx, outbound_req_rx) = mpsc::channel(64); @@ -697,15 +745,13 @@ mod tests { let service: BitswapService = BitswapService { handle: Box::new(transport), - config, cmd_rx, cmd_channel_closed: false, sync_event_stream, inbound_lookup_pool, inbound_lookup_rx, - waiter_deadlines: DelayQueue::new(), connected_peers: HashSet::new(), - wants: WantSet::new(), + wants: WantSet::new(max_live_cids), waiters: SlotMap::with_key(), }; @@ -723,12 +769,13 @@ mod tests { } fn empty_rig() -> TestRig { - short_deadline_rig(Duration::from_secs(30)) + let client = Arc::new(substrate_test_runtime_client::new()); + build_rig_with(client, MAX_LIVE_CIDS) } - fn short_deadline_rig(deadline: Duration) -> TestRig { + fn small_window_rig(max_live_cids: usize) -> TestRig { let client = Arc::new(substrate_test_runtime_client::new()); - build_rig_with(client, BitswapServiceConfig { request_timeout: deadline }) + build_rig_with(client, max_live_cids) } fn cid_for_data(mh_code: u64, data: &[u8]) -> Cid { @@ -753,7 +800,11 @@ mod tests { } fn sync_connected(peer: litep2p::PeerId) -> SyncEvent { - SyncEvent::PeerConnected(to_types_peer(peer)) + SyncEvent::PeerConnected { peer_id: to_types_peer(peer), roles: Roles::FULL } + } + + fn sync_connected_light(peer: litep2p::PeerId) -> SyncEvent { + SyncEvent::PeerConnected { peer_id: to_types_peer(peer), roles: Roles::LIGHT } } fn sync_disconnected(peer: litep2p::PeerId) -> SyncEvent { @@ -766,7 +817,7 @@ mod tests { let peer = litep2p::PeerId::random(); let mut waiter_ids = SlotMap::with_key(); let waiter_id = waiter_ids.insert(()); - let mut wants = WantSet::new(); + let mut wants = WantSet::new(MAX_LIVE_CIDS); wants.add_waiter(cid, waiter_id); let selected = @@ -780,6 +831,32 @@ mod tests { assert!(!wants.contains(&cid)); } + #[test] + fn want_set_window_queues_and_promotes() { + let cid_a = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x01; 32]); + let cid_b = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x02; 32]); + let peer = litep2p::PeerId::random(); + let peers = HashSet::from([peer]); + let mut waiter_ids = SlotMap::with_key(); + let waiter_id = waiter_ids.insert(()); + let mut wants = WantSet::new(1); + + wants.add_waiter(cid_a, waiter_id); + wants.add_waiter(cid_b, waiter_id); + + assert_eq!(wants.next_peer_to_request(cid_a, &peers, Instant::now()), Some(peer)); + // Window (size 1) is full: `cid_b` must queue instead of dispatching. + assert_eq!(wants.next_peer_to_request(cid_b, &peers, Instant::now()), None); + assert!(!wants.has_window_capacity()); + + // Delivering `cid_a` frees the slot; `cid_b` is promoted. + wants.take_waiters_for_delivered_cid(cid_a); + assert!(wants.has_window_capacity()); + assert_eq!(wants.pop_pending(), Some(cid_b)); + assert_eq!(wants.next_peer_to_request(cid_b, &peers, Instant::now()), Some(peer)); + assert_eq!(wants.pop_pending(), None); + } + #[tokio::test(start_paused = true)] async fn single_cid_single_peer_block_response() { let mut rig = empty_rig(); @@ -805,7 +882,7 @@ mod tests { let item = drain_next(&mut rx).await.expect("item"); match item { - Ok((got_cid, FetchOutcome::Block(bytes))) => { + Ok((got_cid, bytes)) => { assert_eq!(got_cid, cid); assert_eq!(bytes, data); }, @@ -814,8 +891,29 @@ mod tests { } #[tokio::test(start_paused = true)] - async fn dont_have_then_missing_at_deadline() { - let mut rig = short_deadline_rig(Duration::from_millis(50)); + async fn light_client_peers_are_not_tracked() { + let mut rig = empty_rig(); + let light_peer = litep2p::PeerId::random(); + let full_peer = litep2p::PeerId::random(); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [9u8; 32]); + + rig.sync_event_tx.send(sync_connected_light(light_peer)).await.unwrap(); + let _rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + + // The only connected peer is a light client: no WANT must be dispatched. + assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); + + // Once a full peer connects, the pending want goes out to it. + rig.sync_event_tx.send(sync_connected(full_peer)).await.unwrap(); + let (out_peer, out_cids) = + drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); + assert_eq!(out_peer, full_peer); + assert_eq!(out_cids, vec![(cid, WantType::Block)]); + } + + #[tokio::test(start_paused = true)] + async fn dont_have_from_only_peer_leaves_stream_open() { + let mut rig = empty_rig(); let peer = litep2p::PeerId::random(); let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [7u8; 32]); @@ -835,26 +933,42 @@ mod tests { .await .unwrap(); - tokio::time::advance(Duration::from_millis(60)).await; - - let item = drain_next(&mut rx).await.expect("item"); - assert!(matches!(item, Ok((_, FetchOutcome::Missing)))); + // No other peers to try: the want stays unresolved, the stream stays open until + // the caller gives up. + tokio::time::advance(Duration::from_secs(60)).await; + assert!(matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty))); } #[tokio::test(start_paused = true)] - async fn per_peer_timeout_followed_by_deadline_missing() { - let mut rig = short_deadline_rig(Duration::from_millis(100)); - let peer = litep2p::PeerId::random(); - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [42u8; 32]); + async fn per_peer_timeout_triggers_failover() { + let mut rig = empty_rig(); + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let data = b"after-timeout".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer_a)).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer_b)).await.unwrap(); let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); - let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); + let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - tokio::time::advance(Duration::from_millis(120)).await; + // The unanswered request times out after `PER_PEER_TIMEOUT`; the sweep retries on + // the other peer. + tokio::time::advance(PER_PEER_TIMEOUT + Duration::from_secs(2)).await; + + let (second_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); + assert_ne!(first_peer, second_peer); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer: second_peer, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); let item = drain_next(&mut rx).await.expect("item"); - assert!(matches!(item, Ok((_, FetchOutcome::Missing)))); + assert!(matches!(item, Ok((c, b)) if c == cid && b == data)); } #[tokio::test(start_paused = true)] @@ -895,7 +1009,7 @@ mod tests { let item = drain_next(&mut rx).await.expect("item"); match item { - Ok((got, FetchOutcome::Block(bytes))) => { + Ok((got, bytes)) => { assert_eq!(got, cid); assert_eq!(bytes, data); }, @@ -904,18 +1018,23 @@ mod tests { } #[tokio::test(start_paused = true)] - async fn zero_peers_at_admission_missing_at_deadline() { - let mut rig = short_deadline_rig(Duration::from_millis(50)); + async fn receiver_drop_cancels_wants() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [1u8; 32]); - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + // No peers connected: nothing is dispatched, the want just sits there. assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); - tokio::time::advance(Duration::from_millis(60)).await; + // The caller gives up; the sweep drops the abandoned waiter. + drop(rx); + tokio::time::advance(Duration::from_secs(2)).await; - let item = drain_next(&mut rx).await.expect("item"); - assert!(matches!(item, Ok((_, FetchOutcome::Missing)))); + // A peer connecting afterwards must not trigger a WANT for the cancelled request. + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); } #[tokio::test(start_paused = true)] @@ -942,8 +1061,8 @@ mod tests { let item_a = drain_next(&mut rx_a).await.expect("a"); let item_b = drain_next(&mut rx_b).await.expect("b"); - assert!(matches!(&item_a, Ok((c, FetchOutcome::Block(b))) if *c == cid && *b == data)); - assert!(matches!(&item_b, Ok((c, FetchOutcome::Block(b))) if *c == cid && *b == data)); + assert!(matches!(&item_a, Ok((c, b)) if *c == cid && *b == data)); + assert!(matches!(&item_b, Ok((c, b)) if *c == cid && *b == data)); } #[tokio::test(start_paused = true)] @@ -972,36 +1091,46 @@ mod tests { .unwrap(); let item_b = drain_next(&mut rx_b).await.expect("b"); - assert!(matches!(item_b, Ok((c, FetchOutcome::Block(b))) if c == cid && b == data)); + assert!(matches!(item_b, Ok((c, b)) if c == cid && b == data)); } #[tokio::test(start_paused = true)] - async fn waiter_deadline_emits_missing_for_unresolved() { - let mut rig = short_deadline_rig(Duration::from_millis(40)); + async fn dispatch_window_queues_excess_cids_and_promotes_on_delivery() { + let mut rig = small_window_rig(4); let peer = litep2p::PeerId::random(); - let cid_a = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [1u8; 32]); - let cid_b = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [2u8; 32]); - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); - let mut rx = rig.user_handle.request_stream(vec![cid_a, cid_b]).await.unwrap(); - let (_, entries) = drain_next(&mut rig.outbound_req_rx).await.expect("bundled WANT"); - assert_eq!(entries.len(), 2, "both CIDs go to the same peer in one message"); + let payloads: Vec> = (0..5u8).map(|i| vec![i; 8]).collect(); + let cids: Vec = + payloads.iter().map(|p| cid_for_data(BLAKE2B_256_MULTIHASH_CODE, p)).collect(); - tokio::time::advance(Duration::from_millis(60)).await; + let mut rx = rig.user_handle.request_stream(cids.clone()).await.unwrap(); - let mut seen = HashSet::new(); - for _ in 0..2 { - let item = drain_next(&mut rx).await.expect("missing item"); - match item { - Ok((c, FetchOutcome::Missing)) => { - seen.insert(c); - }, - other => panic!("expected Missing, got {other:?}"), - } - } - assert!(seen.contains(&cid_a)); - assert!(seen.contains(&cid_b)); + // Only the window (4 CIDs) is dispatched; the fifth is queued. + let (_, entries) = drain_next(&mut rig.outbound_req_rx).await.expect("first bundle"); + assert_eq!(entries.len(), 4); + let dispatched: HashSet = entries.iter().map(|(cid, _)| *cid).collect(); + let queued_idx = + cids.iter().position(|cid| !dispatched.contains(cid)).expect("one CID queued"); + + // Answering one dispatched CID frees a slot and promotes the queued CID. + let answered_idx = cids.iter().position(|cid| dispatched.contains(cid)).unwrap(); + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Block { + cid: cids[answered_idx], + block: payloads[answered_idx].clone(), + }], + }) + .await + .unwrap(); + + let item = drain_next(&mut rx).await.expect("delivered block"); + assert!(matches!(item, Ok((c, _)) if c == cids[answered_idx])); + + let (_, promoted) = drain_next(&mut rig.outbound_req_rx).await.expect("promoted WANT"); + assert_eq!(promoted, vec![(cids[queued_idx], WantType::Block)]); } #[tokio::test(start_paused = true)] @@ -1022,10 +1151,7 @@ mod tests { let block = block_builder.build().unwrap().block; client.import(BlockOrigin::File, block).await.unwrap(); - let mut rig = build_rig_with( - Arc::new(client), - BitswapServiceConfig { request_timeout: Duration::from_secs(30) }, - ); + let mut rig = build_rig_with(Arc::new(client), MAX_LIVE_CIDS); let peer = litep2p::PeerId::random(); rig.inbound_tx @@ -1046,8 +1172,8 @@ mod tests { } #[tokio::test(start_paused = true)] - async fn corrupted_block_rejected_then_missing_at_deadline() { - let mut rig = short_deadline_rig(Duration::from_millis(50)); + async fn corrupted_block_from_only_peer_is_not_delivered() { + let mut rig = empty_rig(); let peer = litep2p::PeerId::random(); let real = b"the-real-payload".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &real); @@ -1067,10 +1193,10 @@ mod tests { .await .unwrap(); - tokio::time::advance(Duration::from_millis(60)).await; - - let item = drain_next(&mut rx).await.expect("item"); - assert!(matches!(item, Ok((_, FetchOutcome::Missing)))); + // The corrupted block is rejected; no other peer can serve the CID, so the + // stream stays open without delivering anything. + tokio::time::advance(Duration::from_secs(60)).await; + assert!(matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty))); } #[tokio::test(start_paused = true)] @@ -1109,7 +1235,7 @@ mod tests { .unwrap(); let item = drain_next(&mut rx).await.expect("item"); - assert!(matches!(item, Ok((c, FetchOutcome::Block(b))) if c == cid && b == data)); + assert!(matches!(item, Ok((c, b)) if c == cid && b == data)); } #[tokio::test(start_paused = true)] @@ -1228,23 +1354,26 @@ mod tests { .unwrap(); let item = drain_next(&mut rx).await.expect("item"); - assert!(matches!(item, Ok((c, FetchOutcome::Block(b))) if c == cid && b == data)); + assert!(matches!(item, Ok((c, b)) if c == cid && b == data)); } #[tokio::test(start_paused = true)] - async fn late_response_after_waiter_gone_is_dropped() { - let mut rig = short_deadline_rig(Duration::from_millis(20)); + async fn late_response_after_receiver_drop_is_ignored_and_cid_refetchable() { + let mut rig = empty_rig(); let peer = litep2p::PeerId::random(); let data = b"too-late".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); - tokio::time::advance(Duration::from_millis(40)).await; - let _missing = drain_next(&mut rx).await.expect("missing"); + // Caller gives up; the sweep drops the waiter while the peer request is still + // in flight. + drop(rx); + tokio::time::advance(Duration::from_secs(2)).await; + // The late response hits no waiter and is dropped. rig.inbound_tx .send(BitswapEvent::Response { peer, @@ -1252,8 +1381,39 @@ mod tests { }) .await .unwrap(); - + // Let the actor process the late response before the fresh request goes in. sleep(Duration::from_millis(1)).await; - assert!(rx.recv().await.is_none()); + + // A fresh request for the same CID starts from a clean slate: the peer is asked + // again and the block is delivered. + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("fresh WANT"); + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + let item = drain_next(&mut rx).await.expect("item"); + assert!(matches!(item, Ok((c, b)) if c == cid && b == data)); + } + + #[tokio::test(start_paused = true)] + async fn too_many_waiters_per_cid_yields_overloaded() { + let rig = empty_rig(); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x77; 32]); + + let mut receivers = Vec::new(); + for _ in 0..MAX_WAITERS_PER_CID { + receivers.push(rig.user_handle.request_stream(vec![cid]).await.unwrap()); + } + // Give the actor a chance to admit all waiters before the one-too-many request. + sleep(Duration::from_millis(10)).await; + + let mut rejected = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let item = drain_next(&mut rejected).await.expect("item"); + assert!(matches!(item, Err(BitswapError::Overloaded))); } } diff --git a/substrate/client/service/src/builder.rs b/substrate/client/service/src/builder.rs index 7b1e3a18b532..635ffd4d7e1c 100644 --- a/substrate/client/service/src/builder.rs +++ b/substrate/client/service/src/builder.rs @@ -1270,7 +1270,6 @@ where client.clone(), &*sync_service, litep2p_bitswap_handle, - sc_network_bitswap::BitswapServiceConfig::default(), ); (Some(handler), Some(bitswap_user_handle), Some(ipfs_config)) diff --git a/substrate/client/storage-chain-sync/Cargo.toml b/substrate/client/storage-chain-sync/Cargo.toml index fd6f29d93943..ae6780724859 100644 --- a/substrate/client/storage-chain-sync/Cargo.toml +++ b/substrate/client/storage-chain-sync/Cargo.toml @@ -32,7 +32,7 @@ sp-state-machine = { workspace = true, default-features = true } sp-transaction-storage-proof = { workspace = true, default-features = true } sp-trie = { workspace = true, default-features = true } thiserror = { workspace = true } -tokio = { workspace = true, default-features = true, features = ["sync"] } +tokio = { workspace = true, default-features = true, features = ["sync", "time"] } [dev-dependencies] rstest = { workspace = true } diff --git a/substrate/client/storage-chain-sync/src/fetcher.rs b/substrate/client/storage-chain-sync/src/fetcher.rs index 4d145354e6e3..04b43e7ea505 100644 --- a/substrate/client/storage-chain-sync/src/fetcher.rs +++ b/substrate/client/storage-chain-sync/src/fetcher.rs @@ -19,21 +19,37 @@ //! Bitswap-based fetcher for indexed-transaction blobs. //! //! Thin adapter over [`sc_network_bitswap::BitswapHandle`]: builds the per-want CIDs, -//! submits them and collects the outcomes. Peer selection, timeouts, retries and hash -//! verification live in the bitswap service. +//! submits them and collects the outcomes under a size-scaled time budget. Peer +//! selection, retries and hash verification live in the bitswap service; the fetch +//! deadline lives here, since the service retries indefinitely and the caller owns the +//! time budget. use crate::RenewWant; use cid::{multihash::Multihash, Cid}; -use sc_network_bitswap::{BitswapError, BitswapRequest, FetchOutcome}; +use sc_network_bitswap::{BitswapError, BitswapRequest}; use sp_runtime::traits::Block as BlockT; use sp_transaction_storage_proof::ContentHash; use std::{ collections::HashMap, sync::{Arc, OnceLock}, + time::Duration, }; const LOG_TARGET: &str = "storage-chain-fetcher"; +/// Base time budget for a single [`IndexedTransactionFetcher::fetch_many`] call. +const FETCH_TIMEOUT_BASE: Duration = Duration::from_secs(30); +/// Additional budget per requested CID: large wantlists queue behind the bitswap +/// service's dispatch window and need proportionally more time. +const FETCH_TIMEOUT_PER_CID: Duration = Duration::from_millis(100); +/// Hard cap so a hopeless fetch cannot stall block import for too long. +const FETCH_TIMEOUT_MAX: Duration = Duration::from_secs(600); + +fn fetch_timeout(cid_count: usize) -> Duration { + let per_cid = FETCH_TIMEOUT_PER_CID.saturating_mul(cid_count.min(u32::MAX as usize) as u32); + FETCH_TIMEOUT_BASE.saturating_add(per_cid).min(FETCH_TIMEOUT_MAX) +} + /// Late-bound bitswap handle slot, populated by the node after `build_network`. /// /// [`crate::StorageChainBlockImport`] is constructed before `build_network` runs; the @@ -80,7 +96,8 @@ impl IndexedTransactionFetcher { /// Resolve a batch of indexed-transaction hashes via bitswap. Each want carries the /// runtime-declared `cid_codec` so the request CID matches what the producing runtime - /// announced. Returns only successfully fetched entries. + /// announced. Returns only successfully fetched entries; entries unresolved when the + /// time budget expires are simply absent. pub(crate) async fn fetch_many( &self, wants: &[RenewWant], @@ -102,10 +119,11 @@ impl IndexedTransactionFetcher { let mut rx = handle.request_stream(cids).await?; + let deadline = tokio::time::Instant::now() + fetch_timeout(wants.len()); let mut acquired: HashMap> = HashMap::with_capacity(wants.len()); - while let Some(item) = rx.recv().await { - match item { - Ok((cid, FetchOutcome::Block(bytes))) => { + loop { + match tokio::time::timeout_at(deadline, rx.recv()).await { + Ok(Some(Ok((cid, bytes)))) => { if let Some(hash) = by_cid.get(&cid) { log::debug!( target: LOG_TARGET, @@ -115,17 +133,26 @@ impl IndexedTransactionFetcher { acquired.insert(*hash, bytes); } }, - Ok((cid, FetchOutcome::Missing)) => { - log::debug!(target: LOG_TARGET, "bitswap returned Missing for {cid}"); - }, - Err(BitswapError::ServiceClosed) => { + Ok(Some(Err(BitswapError::ServiceClosed))) => { log::warn!( target: LOG_TARGET, "bitswap service closed mid-stream; returning partial result", ); return Ok(acquired); }, - Err(other) => return Err(FetchError::Bitswap(other)), + Ok(Some(Err(other))) => return Err(FetchError::Bitswap(other)), + // The stream closed: every CID was delivered. + Ok(None) => break, + // Time budget expired. Dropping the receiver cancels the remaining wants. + Err(_) => { + log::debug!( + target: LOG_TARGET, + "bitswap fetch timed out with {}/{} entries resolved", + acquired.len(), + wants.len(), + ); + break; + }, } } diff --git a/substrate/client/storage-chain-sync/tests/it.rs b/substrate/client/storage-chain-sync/tests/it.rs index 46c16777214c..b6d3af7f48be 100644 --- a/substrate/client/storage-chain-sync/tests/it.rs +++ b/substrate/client/storage-chain-sync/tests/it.rs @@ -533,7 +533,7 @@ mod mock { StorageChanges as ConsensusStorageChanges, }; use sc_network_bitswap::{ - BitswapError, BitswapRequest, Cid as BitswapCid, FetchItem, FetchOutcome, RAW_CODEC, + BitswapError, BitswapRequest, Cid as BitswapCid, FetchItem, RAW_CODEC, }; use sp_api::{ApiError, ConstructRuntimeApi}; use sp_consensus::{BlockOrigin, Error as ConsensusError}; @@ -809,9 +809,9 @@ mod mock { /// In-memory mock of [`BitswapRequest`] for the integration tests. /// /// Stores a `ContentHash -> Vec` map. On `request_stream`, returns a receiver - /// pre-loaded with one [`FetchOutcome::Block`] per known CID and one - /// [`FetchOutcome::Missing`] per unknown CID. Records every observed CID and counts - /// every call for assertions. + /// pre-loaded with the bytes of every known CID; unknown CIDs produce nothing, and the + /// closed channel signals end-of-request (the fetcher treats absent entries as + /// missing). Records every observed CID and counts every call for assertions. #[derive(Default)] pub(super) struct MockBitswap { responses: Mutex>>, @@ -846,11 +846,9 @@ mod mock { for cid in cids { observed.push(cid); let digest: Option = cid.hash().digest().try_into().ok(); - let outcome = match digest.and_then(|d| responses.get(&d).cloned()) { - Some(bytes) => FetchOutcome::Block(bytes), - None => FetchOutcome::Missing, - }; - tx.try_send(Ok((cid, outcome))).expect("channel sized for cids.len()"); + if let Some(bytes) = digest.and_then(|d| responses.get(&d).cloned()) { + tx.try_send(Ok((cid, bytes))).expect("channel sized for cids.len()"); + } } Ok(rx) } From b424172d465d6fbc741c1dd46a4041cdb85fd002 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Wed, 15 Jul 2026 18:40:17 +0200 Subject: [PATCH 11/32] Treat HAVE as a positive signal A peer answering HAVE for a wanted CID was marked tried and permanently excluded, so the one peer that advertised having the block was never asked for it again. A first HAVE now keeps the peer eligible and preferred for an immediate WANT-BLOCK re-ask; a second HAVE without the block marks the peer tried (bounding a HAVE-looping peer to two requests per CID). --- .../client/network/bitswap/src/service.rs | 134 +++++++++++++++++- 1 file changed, 127 insertions(+), 7 deletions(-) diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 6d3cc6308569..21800446f918 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -103,6 +103,9 @@ new_key_type! { struct WaiterId; } struct CidState { tried_peers: HashSet, in_flight_peers: HashMap, + /// Peers that answered HAVE for this CID. Preferred on dispatch; a second HAVE from + /// the same peer marks it tried. + have_peers: SmallVec<[litep2p::PeerId; 1]>, waiters: SmallVec<[WaiterId; 2]>, /// Whether this CID currently has a valid entry in [`WantSet::pending`]. pending: bool, @@ -113,6 +116,7 @@ impl CidState { Self { tried_peers: HashSet::new(), in_flight_peers: HashMap::new(), + have_peers: SmallVec::new(), waiters: SmallVec::new(), pending: false, } @@ -214,11 +218,15 @@ impl WantSet { return None; } - let peer = connected_peers + let eligible = |peer: &litep2p::PeerId| { + !state.tried_peers.contains(peer) && !state.in_flight_peers.contains_key(peer) + }; + // Peers that answered HAVE for this CID are asked first. + let peer = state + .have_peers .iter() - .find(|peer| { - !state.tried_peers.contains(peer) && !state.in_flight_peers.contains_key(peer) - }) + .find(|peer| connected_peers.contains(*peer) && eligible(peer)) + .or_else(|| connected_peers.iter().find(|peer| eligible(peer))) .copied()?; let state = self.inner.get_mut(&cid).expect("checked above; qed"); @@ -241,6 +249,24 @@ impl WantSet { self.remove_if_idle(cid); } + /// Record a HAVE from `peer` for `cid` and release its in-flight slot. + /// + /// A first HAVE keeps the peer eligible (and preferred) for a re-ask; a repeated HAVE + /// without the block marks the peer tried. + fn note_peer_have_for_cid(&mut self, peer: litep2p::PeerId, cid: Cid) { + if let Some(state) = self.inner.get_mut(&cid) { + if state.have_peers.contains(&peer) { + state.tried_peers.insert(peer); + } else { + state.have_peers.push(peer); + } + if state.in_flight_peers.remove(&peer).is_some() && state.in_flight_peers.is_empty() { + self.live -= 1; + } + } + self.remove_if_idle(cid); + } + fn remove_in_flight_peer(&mut self, peer: litep2p::PeerId) -> Vec { let mut affected: Vec = Vec::new(); for (cid, state) in self.inner.iter_mut() { @@ -529,17 +555,17 @@ impl BitswapService { } }, ResponseType::Presence { cid, presence } => { - self.wants.mark_peer_done_for_cid(peer, cid); match presence { BlockPresenceType::DontHave => { log::trace!(target: LOG_TARGET, "{peer:?} DONT_HAVE {cid}"); - cids_to_top_up.insert(cid); + self.wants.mark_peer_done_for_cid(peer, cid); }, BlockPresenceType::Have => { log::trace!(target: LOG_TARGET, "{peer:?} HAVE {cid}"); - cids_to_top_up.insert(cid); + self.wants.note_peer_have_for_cid(peer, cid); }, } + cids_to_top_up.insert(cid); }, } } @@ -890,6 +916,100 @@ mod tests { } } + #[tokio::test(start_paused = true)] + async fn have_response_reasks_same_peer_then_delivers() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let data = b"payload-have".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + + let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + assert_eq!(out_peer, peer); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Presence { cid, presence: BlockPresenceType::Have }], + }) + .await + .unwrap(); + + // A HAVE keeps the peer eligible: it is re-asked for the block. + let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("re-ask"); + assert_eq!(out_peer, peer); + assert_eq!(out_cids, vec![(cid, WantType::Block)]); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + let item = drain_next(&mut rx).await.expect("item"); + match item { + Ok((got_cid, bytes)) => { + assert_eq!(got_cid, cid); + assert_eq!(bytes, data); + }, + other => panic!("expected Block, got {other:?}"), + } + } + + #[tokio::test(start_paused = true)] + async fn second_have_without_block_moves_to_other_peer() { + let mut rig = empty_rig(); + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let data = b"payload-have-2".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.sync_event_tx.send(sync_connected(peer_a)).await.unwrap(); + rig.sync_event_tx.send(sync_connected(peer_b)).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + + let (first, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + let other = if first == peer_a { peer_b } else { peer_a }; + + let have = |peer| BitswapEvent::Response { + peer, + responses: vec![ResponseType::Presence { cid, presence: BlockPresenceType::Have }], + }; + + // First HAVE earns the peer a re-ask. + rig.inbound_tx.send(have(first)).await.unwrap(); + let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("re-ask"); + assert_eq!(out_peer, first); + + // Second HAVE without the block: the peer counts as tried, the want moves on. + rig.inbound_tx.send(have(first)).await.unwrap(); + let (out_peer, out_cids) = + drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); + assert_eq!(out_peer, other); + assert_eq!(out_cids, vec![(cid, WantType::Block)]); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer: other, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + let item = drain_next(&mut rx).await.expect("item"); + match item { + Ok((got_cid, bytes)) => { + assert_eq!(got_cid, cid); + assert_eq!(bytes, data); + }, + other => panic!("expected Block, got {other:?}"), + } + } + #[tokio::test(start_paused = true)] async fn light_client_peers_are_not_tracked() { let mut rig = empty_rig(); From 215cba3d2ac84318e5c70bcd4a216ae6ddefbf8f Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Wed, 15 Jul 2026 23:42:01 +0200 Subject: [PATCH 12/32] Retry unresolved CIDs in rounds Once every connected peer was tried for a CID, the want stalled forever: tried_peers was insert-only, so a single timeout or DONT_HAVE from the only data-holding peer parked the CID until a brand-new peer connected, burning the caller's whole fetch budget. An exhausted CID now schedules a new round: after ROUND_RETRY_DELAY the sweep clears its per-round peer state (tried_peers, have_peers) and every connected peer becomes eligible again. Dispatching cancels a scheduled round, so a fresh peer connecting mid-park is used immediately without a duplicate re-ask later. --- .../client/network/bitswap/src/service.rs | 195 +++++++++++++++++- 1 file changed, 190 insertions(+), 5 deletions(-) diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 21800446f918..fcb2a161dd73 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -25,6 +25,8 @@ //! in-flight peer requests at a time, the rest queue and are dispatched as window slots //! free up. Requests carry no service-side deadline: the caller bounds the wait by //! dropping the receiver, which a periodic sweep detects to release the wants. +//! Unresolved CIDs are retried in rounds: once every connected peer has been tried, +//! all peers become eligible again after [`ROUND_RETRY_DELAY`]. //! //! Peer connect/disconnect tracking comes from `sc-network-sync`'s [`SyncEventStream`]. The //! sync engine replays `PeerConnected` for every currently-connected peer when a new @@ -94,6 +96,8 @@ const MAX_CONCURRENT_INBOUND_LOOKUPS: usize = 8; const CMD_CHANNEL_CAPACITY: usize = 256; const LOOKUP_CHANNEL_CAPACITY: usize = 64; const PER_PEER_TIMEOUT: Duration = Duration::from_secs(5); +/// Delay before an exhausted CID (every connected peer tried) starts a new round. +const ROUND_RETRY_DELAY: Duration = Duration::from_secs(5); const PEER_FANOUT_CAP: usize = 1; const SWEEP_INTERVAL: Duration = Duration::from_secs(1); @@ -109,6 +113,9 @@ struct CidState { waiters: SmallVec<[WaiterId; 2]>, /// Whether this CID currently has a valid entry in [`WantSet::pending`]. pending: bool, + /// When set, every connected peer has been tried; a new round (with cleared per-round + /// peer state) starts once this instant passes. Cleared on dispatch. + next_round_at: Option, } impl CidState { @@ -119,6 +126,7 @@ impl CidState { have_peers: SmallVec::new(), waiters: SmallVec::new(), pending: false, + next_round_at: None, } } @@ -222,12 +230,27 @@ impl WantSet { !state.tried_peers.contains(peer) && !state.in_flight_peers.contains_key(peer) }; // Peers that answered HAVE for this CID are asked first. - let peer = state + let Some(peer) = state .have_peers .iter() .find(|peer| connected_peers.contains(*peer) && eligible(peer)) .or_else(|| connected_peers.iter().find(|peer| eligible(peer))) - .copied()?; + .copied() + else { + // Round exhausted: every connected peer has been tried. Schedule a new round, + // started by the sweep once the delay passes. + if !connected_peers.is_empty() && state.in_flight_peers.is_empty() { + let state = self.inner.get_mut(&cid).expect("checked above; qed"); + if state.next_round_at.is_none() { + state.next_round_at = Some(now + ROUND_RETRY_DELAY); + log::trace!( + target: LOG_TARGET, + "all peers tried for {cid}, scheduling new round", + ); + } + } + return None; + }; let state = self.inner.get_mut(&cid).expect("checked above; qed"); if state.in_flight_peers.is_empty() { @@ -235,6 +258,7 @@ impl WantSet { } state.in_flight_peers.insert(peer, now + PER_PEER_TIMEOUT); state.pending = false; + state.next_round_at = None; Some(peer) } @@ -309,6 +333,24 @@ impl WantSet { self.remove_idle_and_filter_existing(cids) } + /// Start a new round for CIDs whose retry delay has passed: clear per-round peer state + /// so every connected peer is eligible again. + fn restart_exhausted_rounds(&mut self, now: Instant) -> Vec { + let mut cids = Vec::new(); + for (cid, state) in self.inner.iter_mut() { + if state.has_waiters() && + state.in_flight_peers.is_empty() && + state.next_round_at.is_some_and(|at| at <= now) + { + state.tried_peers.clear(); + state.have_peers.clear(); + state.next_round_at = None; + cids.push(*cid); + } + } + cids + } + fn clear(&mut self) { self.inner.clear(); self.pending.clear(); @@ -617,8 +659,8 @@ impl BitswapService { } /// Periodic housekeeping: drop waiters whose receiver was dropped (the caller gave up - /// or applied its own timeout), then expire per-peer request timeouts and retry the - /// affected CIDs on other peers. + /// or applied its own timeout), expire per-peer request timeouts, and start new rounds + /// for CIDs whose retry delay has passed. async fn on_sweep(&mut self) { let abandoned: Vec = self .waiters @@ -630,7 +672,9 @@ impl BitswapService { self.drop_waiter(id); } - let cids = self.wants.expire_peer_timeouts(Instant::now()); + let now = Instant::now(); + let mut cids = self.wants.expire_peer_timeouts(now); + cids.extend(self.wants.restart_exhausted_rounds(now)); self.top_up_in_flight(cids).await; } @@ -1010,6 +1054,147 @@ mod tests { } } + #[tokio::test(start_paused = true)] + async fn exhausted_round_reasks_peer_after_delay() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let data = b"payload-round".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Presence { + cid, + presence: BlockPresenceType::DontHave, + }], + }) + .await + .unwrap(); + + // The only peer is tried: nothing is dispatched before the round delay passes. + let no_req = + timeout(ROUND_RETRY_DELAY - Duration::from_secs(1), rig.outbound_req_rx.recv()).await; + assert!(no_req.is_err()); + + // After the delay the round restarts and the same peer is asked again. + let (out_peer, out_cids) = + drain_next(&mut rig.outbound_req_rx).await.expect("new-round WANT"); + assert_eq!(out_peer, peer); + assert_eq!(out_cids, vec![(cid, WantType::Block)]); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + let item = drain_next(&mut rx).await.expect("item"); + match item { + Ok((got_cid, bytes)) => { + assert_eq!(got_cid, cid); + assert_eq!(bytes, data); + }, + other => panic!("expected Block, got {other:?}"), + } + } + + #[tokio::test(start_paused = true)] + async fn timed_out_peer_is_reasked_next_round() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let data = b"payload-round-2".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + + // No response: the per-peer timeout marks the peer tried, then the round restarts + // and the same peer is asked again. + let (out_peer, out_cids) = + timeout(PER_PEER_TIMEOUT + ROUND_RETRY_DELAY * 2, rig.outbound_req_rx.recv()) + .await + .expect("re-ask after timeout and round delay") + .expect("transport channel open"); + assert_eq!(out_peer, peer); + assert_eq!(out_cids, vec![(cid, WantType::Block)]); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + let item = drain_next(&mut rx).await.expect("item"); + match item { + Ok((got_cid, bytes)) => { + assert_eq!(got_cid, cid); + assert_eq!(bytes, data); + }, + other => panic!("expected Block, got {other:?}"), + } + } + + #[tokio::test(start_paused = true)] + async fn new_peer_is_asked_immediately_while_round_is_parked() { + let mut rig = empty_rig(); + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let data = b"payload-round-3".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.sync_event_tx.send(sync_connected(peer_a)).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer: peer_a, + responses: vec![ResponseType::Presence { + cid, + presence: BlockPresenceType::DontHave, + }], + }) + .await + .unwrap(); + + // The CID is parked awaiting a new round; a fresh peer is dispatched immediately, + // without waiting for the round delay. + rig.sync_event_tx.send(sync_connected(peer_b)).await.unwrap(); + let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("WANT to new peer"); + assert_eq!(out_peer, peer_b); + + rig.inbound_tx + .send(BitswapEvent::Response { + peer: peer_b, + responses: vec![ResponseType::Block { cid, block: data.clone() }], + }) + .await + .unwrap(); + + let item = drain_next(&mut rx).await.expect("item"); + match item { + Ok((got_cid, bytes)) => { + assert_eq!(got_cid, cid); + assert_eq!(bytes, data); + }, + other => panic!("expected Block, got {other:?}"), + } + + // The dispatch cancelled the scheduled round: no stray re-ask later. + tokio::time::advance(ROUND_RETRY_DELAY * 2).await; + assert!(rig.outbound_req_rx.try_recv().is_err()); + } + #[tokio::test(start_paused = true)] async fn light_client_peers_are_not_tracked() { let mut rig = empty_rig(); From 3af6891cb92f2195d402813d4ce746f5000403d2 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Wed, 15 Jul 2026 23:43:05 +0200 Subject: [PATCH 13/32] Drop unused deps, deduplicate test rig helpers --- Cargo.lock | 2 -- substrate/client/network/bitswap/src/service.rs | 3 +-- substrate/client/storage-chain-sync/Cargo.toml | 2 -- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b0b91245d82d..bed77aca2396 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22186,14 +22186,12 @@ version = "0.1.0" dependencies = [ "async-trait", "cid", - "futures", "log", "parity-scale-codec", "rstest", "sc-client-api 28.0.0", "sc-client-db", "sc-consensus", - "sc-network 0.34.0", "sc-network-bitswap", "sp-api 26.0.0", "sp-blockchain 28.0.0", diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index fcb2a161dd73..468d70683347 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -839,8 +839,7 @@ mod tests { } fn empty_rig() -> TestRig { - let client = Arc::new(substrate_test_runtime_client::new()); - build_rig_with(client, MAX_LIVE_CIDS) + small_window_rig(MAX_LIVE_CIDS) } fn small_window_rig(max_live_cids: usize) -> TestRig { diff --git a/substrate/client/storage-chain-sync/Cargo.toml b/substrate/client/storage-chain-sync/Cargo.toml index ae6780724859..b3b23df88106 100644 --- a/substrate/client/storage-chain-sync/Cargo.toml +++ b/substrate/client/storage-chain-sync/Cargo.toml @@ -15,12 +15,10 @@ workspace = true async-trait = { workspace = true } cid = { workspace = true } codec = { workspace = true, default-features = true } -futures = { workspace = true } log = { workspace = true, default-features = true } sc-client-api = { workspace = true, default-features = true } sc-client-db = { workspace = true, default-features = true } sc-consensus = { workspace = true, default-features = true } -sc-network = { workspace = true, default-features = true } sc-network-bitswap = { workspace = true, default-features = true } sp-api = { workspace = true, default-features = true } sp-blockchain = { workspace = true, default-features = true } From 7771bba24c0423ec4a966072a5c56d55bfa9ddee Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Thu, 16 Jul 2026 10:03:21 +0200 Subject: [PATCH 14/32] Improve response handling, reset tried_peers --- .../client/network/bitswap/src/handle.rs | 11 +- substrate/client/network/bitswap/src/lib.rs | 4 +- .../client/network/bitswap/src/service.rs | 252 +++++++++++++++--- substrate/client/network/src/config.rs | 7 +- substrate/client/network/src/litep2p/mod.rs | 2 + .../client/network/src/service/traits.rs | 3 + substrate/client/service/src/builder.rs | 15 +- .../client/storage-chain-sync/src/fetcher.rs | 34 ++- 8 files changed, 262 insertions(+), 66 deletions(-) diff --git a/substrate/client/network/bitswap/src/handle.rs b/substrate/client/network/bitswap/src/handle.rs index 0f07f855c09d..35caf8fef83b 100644 --- a/substrate/client/network/bitswap/src/handle.rs +++ b/substrate/client/network/bitswap/src/handle.rs @@ -17,9 +17,6 @@ //! Public user-facing handle for the Bitswap service. //! -//! The handle is returned by [`crate::start`] when the node is configured with -//! `--ipfs-server` and uses the litep2p network backend. -//! //! Cheap to clone. Submit work via [`BitswapHandle::request_stream`], drain the receiver //! to get per-CID results as they resolve. The service retries unresolved CIDs for as //! long as the request is alive; the caller owns the time budget: apply a timeout while @@ -60,15 +57,14 @@ pub type FetchItem = Result<(Cid, Vec), BitswapError>; /// User-facing handle to the Bitswap service. /// -/// Cheap to clone. Created at network construction time and returned by [`crate::start`]. +/// Cheap to clone. #[derive(Debug, Clone)] pub struct BitswapHandle { cmd_tx: mpsc::Sender, } impl BitswapHandle { - /// Construct a new handle around an existing command sender. Used internally by - /// [`crate::start`]. + /// Construct a new handle around an existing command sender. pub(crate) fn new(cmd_tx: mpsc::Sender) -> Self { Self { cmd_tx } } @@ -81,8 +77,7 @@ impl BitswapHandle { /// connect. To bound the wait, apply a timeout while draining and drop the receiver — /// dropping it cancels all wants remaining in this request. /// - /// There is no per-call CID cap: requests larger than the service's dispatch window - /// are queued internally and fetched as window slots free up. + /// There is no per-call CID cap. /// /// `Err(BitswapError::ServiceClosed)` is yielded once, as the final item, if the /// service shuts down mid-request. `Err(BitswapError::Overloaded)` is yielded once as diff --git a/substrate/client/network/bitswap/src/lib.rs b/substrate/client/network/bitswap/src/lib.rs index 2ac3c148f92f..5884fb1af30e 100644 --- a/substrate/client/network/bitswap/src/lib.rs +++ b/substrate/client/network/bitswap/src/lib.rs @@ -33,8 +33,8 @@ pub use service::start; pub(crate) const LOG_TARGET: &str = "sub-libp2p::bitswap"; -/// Max number of wantlist entries per Bitswap message: inbound wantlists with more -/// entries are ignored, and outbound WANT bundles are split at this size. +/// Max number of wantlist entries per Bitswap message: inbound wantlist entries beyond +/// this are answered with `DONT_HAVE`, and outbound WANT bundles are split at this size. pub const MAX_WANTED_BLOCKS: usize = 16; /// IPFS raw multicodec used for indexed transaction payload bytes. diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 468d70683347..4230526501ce 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -28,11 +28,13 @@ //! Unresolved CIDs are retried in rounds: once every connected peer has been tried, //! all peers become eligible again after [`ROUND_RETRY_DELAY`]. //! -//! Peer connect/disconnect tracking comes from `sc-network-sync`'s [`SyncEventStream`]. The -//! sync engine replays `PeerConnected` for every currently-connected peer when a new -//! subscriber registers, so the actor sees the full peer set on startup. Light-client -//! peers are not tracked: they do not hold the indexed-transaction data served over -//! bitswap. +//! Inbound wantlists are looked up on a bounded blocking-worker pool and always get an +//! answer: entries the service will not serve (pool saturated, wantlist over +//! [`MAX_WANTED_BLOCKS`] entries, unsupported CID) receive `DONT_HAVE` instead of silence. +//! +//! Peer connect/disconnect tracking comes from `sc-network-sync`'s [`SyncEventStream`], +//! which reports already-connected peers on subscription. Light-client peers are not +//! tracked: they do not hold the indexed-transaction data served over bitswap. use super::{ is_cid_supported, BitswapCommand, BitswapHandle, Cid, FetchItem, BLAKE2B_256_MULTIHASH_CODE, @@ -387,22 +389,21 @@ struct InboundLookupPool { impl InboundLookupPool { fn new( client: Arc + Send + Sync>, + max_lookups: usize, ) -> (Self, mpsc::Receiver) { let (result_tx, result_rx) = mpsc::channel(LOOKUP_CHANNEL_CAPACITY); - ( - Self { - client, - result_tx, - semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_INBOUND_LOOKUPS)), - }, - result_rx, - ) + (Self { client, result_tx, semaphore: Arc::new(Semaphore::new(max_lookups)) }, result_rx) } - /// Serve the wantlist on a blocking worker. Returns `false` if the pool is saturated. - fn try_submit(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) -> bool { + /// Serve the wantlist on a blocking worker. Returns the wantlist back if the pool is + /// saturated. + fn try_submit( + &self, + peer: litep2p::PeerId, + cids: Vec<(Cid, WantType)>, + ) -> Result<(), Vec<(Cid, WantType)>> { let Ok(permit) = self.semaphore.clone().try_acquire_owned() else { - return false; + return Err(cids); }; let client = self.client.clone(); @@ -410,10 +411,12 @@ impl InboundLookupPool { tokio::task::spawn_blocking(move || { let _permit = permit; let responses = serve_inbound(&*client, cids); - let _ = result_tx.try_send((peer, responses)); + // Blocking worker: wait for a channel slot rather than discarding the computed + // responses when the actor is momentarily behind on draining results. + let _ = result_tx.blocking_send((peer, responses)); }); - true + Ok(()) } } @@ -447,7 +450,8 @@ where S: SyncEventStream + ?Sized, { let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); - let (inbound_lookup_pool, inbound_lookup_rx) = InboundLookupPool::new(client); + let (inbound_lookup_pool, inbound_lookup_rx) = + InboundLookupPool::new(client, MAX_CONCURRENT_INBOUND_LOOKUPS); let user_handle = BitswapHandle::new(cmd_tx); let sync_event_stream = sync.event_stream("bitswap"); @@ -480,7 +484,7 @@ impl BitswapService { tokio::select! { event = self.handle.next_event() => match event { Some(BitswapEvent::Request { peer, cids }) => - self.on_inbound_request(peer, cids), + self.on_inbound_request(peer, cids).await, Some(BitswapEvent::Response { peer, responses }) => self.on_inbound_response(peer, responses).await, None => { @@ -678,20 +682,34 @@ impl BitswapService { self.top_up_in_flight(cids).await; } - fn on_inbound_request(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { - if cids.len() > MAX_WANTED_BLOCKS { + /// Serve an inbound wantlist, never silently: a `DONT_HAVE` lets the requester fail + /// over to another peer immediately, while silence costs it a full request timeout. + /// Entries beyond [`MAX_WANTED_BLOCKS`] (bounding the DB work per wantlist) and whole + /// wantlists refused by a saturated lookup pool are answered with `DONT_HAVE`. + async fn on_inbound_request(&mut self, peer: litep2p::PeerId, mut cids: Vec<(Cid, WantType)>) { + let mut refused = if cids.len() > MAX_WANTED_BLOCKS { + cids.split_off(MAX_WANTED_BLOCKS) + } else { + Vec::new() + }; + + if let Err(cids) = self.inbound_lookup_pool.try_submit(peer, cids) { log::trace!( target: LOG_TARGET, - "ignored inbound wantlist from {peer:?} with {} entries (cap {MAX_WANTED_BLOCKS})", - cids.len(), + "inbound serving pool saturated; answering DONT_HAVE to {peer:?}", ); - return; + refused.extend(cids); } - if !self.inbound_lookup_pool.try_submit(peer, cids) { - log::trace!( - target: LOG_TARGET, - "inbound serving pool saturated; dropping wantlist from {peer:?}", - ); + + if !refused.is_empty() { + let responses = refused + .into_iter() + .map(|(cid, _)| ResponseType::Presence { + cid, + presence: BlockPresenceType::DontHave, + }) + .collect(); + self.handle.send_response(peer, responses).await; } } @@ -726,8 +744,10 @@ fn serve_inbound( cids: Vec<(Cid, WantType)>, ) -> Vec { cids.into_iter() - .filter(|(cid, _)| is_cid_supported(cid)) .map(|(cid, want_type)| { + if !is_cid_supported(&cid) { + return ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }; + } let hash = H256::from_slice(&cid.hash().digest()[0..32]); let transaction = match client.indexed_transaction(hash) { Ok(t) => t, @@ -796,13 +816,21 @@ mod tests { fn build_rig_with( client: Arc + Send + Sync>, max_live_cids: usize, + ) -> TestRig { + build_rig_with_lookup_limit(client, max_live_cids, MAX_CONCURRENT_INBOUND_LOOKUPS) + } + + fn build_rig_with_lookup_limit( + client: Arc + Send + Sync>, + max_live_cids: usize, + max_lookups: usize, ) -> TestRig { let (inbound_tx, inbound_rx) = mpsc::channel(64); let (outbound_req_tx, outbound_req_rx) = mpsc::channel(64); let (outbound_resp_tx, outbound_resp_rx) = mpsc::channel(64); let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); let (sync_event_tx, sync_event_rx) = mpsc::channel::(64); - let (inbound_lookup_pool, inbound_lookup_rx) = InboundLookupPool::new(client); + let (inbound_lookup_pool, inbound_lookup_rx) = InboundLookupPool::new(client, max_lookups); let transport = MockTransport { inbound: AsyncMutex::new(inbound_rx), @@ -1475,6 +1503,166 @@ mod tests { } } + type BlockchainResult = sc_client_api::blockchain::Result; + + /// A backend whose `indexed_transaction` parks until the paired sender fires, keeping + /// an inbound lookup worker occupied for as long as the test needs. + struct GatedBackend { + gate: std::sync::Mutex>, + } + + impl BlockBackend for GatedBackend { + fn block_body( + &self, + _hash: H256, + ) -> BlockchainResult>> { + unimplemented!() + } + + fn block_indexed_body(&self, _hash: H256) -> BlockchainResult>>> { + unimplemented!() + } + + fn block_indexed_hashes(&self, _hash: H256) -> BlockchainResult>> { + unimplemented!() + } + + fn block( + &self, + _hash: H256, + ) -> BlockchainResult>> + { + unimplemented!() + } + + fn block_status(&self, _hash: H256) -> BlockchainResult { + unimplemented!() + } + + fn justifications( + &self, + _hash: H256, + ) -> BlockchainResult> { + unimplemented!() + } + + fn block_hash(&self, _number: u64) -> BlockchainResult> { + unimplemented!() + } + + fn indexed_transaction(&self, _hash: H256) -> BlockchainResult>> { + let _ = self.gate.lock().unwrap().recv(); + Ok(None) + } + + fn requires_full_sync(&self) -> bool { + false + } + } + + #[tokio::test] + async fn saturated_inbound_pool_answers_dont_have() { + let (gate_tx, gate_rx) = std::sync::mpsc::channel(); + let backend = Arc::new(GatedBackend { gate: std::sync::Mutex::new(gate_rx) }); + let mut rig = build_rig_with_lookup_limit(backend, MAX_LIVE_CIDS, 1); + + let peer = litep2p::PeerId::random(); + let gated_cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x0a; 32]); + let refused_cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x0b; 32]); + + // The first wantlist occupies the only lookup worker (parked on the gate); the + // second finds the pool saturated. + for cid in [gated_cid, refused_cid] { + rig.inbound_tx + .send(BitswapEvent::Request { peer, cids: vec![(cid, WantType::Block)] }) + .await + .unwrap(); + } + + // The refused wantlist is answered with DONT_HAVE instead of silence. + let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx) + .await + .expect("DONT_HAVE for refused wantlist"); + assert_eq!(resp_peer, peer); + assert!(matches!( + &responses[..], + [ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }] + if *cid == refused_cid + )); + + // Releasing the worker lets the parked wantlist be served normally. + gate_tx.send(()).unwrap(); + let (_, responses) = drain_next(&mut rig.outbound_resp_rx) + .await + .expect("response for gated wantlist"); + assert!(matches!( + &responses[..], + [ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }] + if *cid == gated_cid + )); + } + + #[tokio::test] + async fn oversized_wantlist_is_answered_for_every_entry() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + + let cids: Vec<(Cid, WantType)> = (0..(MAX_WANTED_BLOCKS + 2) as u8) + .map(|i| { + let mut digest = [0u8; 32]; + digest[0] = i; + (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, digest), WantType::Block) + }) + .collect(); + + rig.inbound_tx + .send(BitswapEvent::Request { peer, cids: cids.clone() }) + .await + .unwrap(); + + // Entries beyond `MAX_WANTED_BLOCKS` get an immediate DONT_HAVE; the first + // `MAX_WANTED_BLOCKS` are looked up and answered too (DONT_HAVE as well: the test + // client holds no indexed data). Every entry must receive a reply. + let mut answered = HashSet::new(); + while answered.len() < cids.len() { + let (resp_peer, responses) = + drain_next(&mut rig.outbound_resp_rx).await.expect("reply for every entry"); + assert_eq!(resp_peer, peer); + for response in responses { + match response { + ResponseType::Presence { cid, presence: BlockPresenceType::DontHave } => { + assert!(answered.insert(cid), "duplicate reply for {cid}"); + }, + other => panic!("expected DONT_HAVE, got {other:?}"), + } + } + } + assert_eq!(answered, cids.into_iter().map(|(cid, _)| cid).collect()); + } + + #[tokio::test] + async fn unsupported_cid_in_wantlist_gets_dont_have() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let unsupported = Cid::new_v1( + RAW_CODEC, + CidMultihash::<64>::wrap(0x99 /* unsupported */, &[0u8; 32]).unwrap(), + ); + + rig.inbound_tx + .send(BitswapEvent::Request { peer, cids: vec![(unsupported, WantType::Block)] }) + .await + .unwrap(); + + let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("reply"); + assert_eq!(resp_peer, peer); + assert!(matches!( + &responses[..], + [ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }] + if *cid == unsupported + )); + } + #[tokio::test(start_paused = true)] async fn corrupted_block_from_only_peer_is_not_delivered() { let mut rig = empty_rig(); diff --git a/substrate/client/network/src/config.rs b/substrate/client/network/src/config.rs index 23040dffd86c..c360a29bba8e 100644 --- a/substrate/client/network/src/config.rs +++ b/substrate/client/network/src/config.rs @@ -38,8 +38,7 @@ pub use crate::{ pub use sc_network_types::{build_multiaddr, ed25519}; /// Litep2p transport-side Bitswap handle, created together with [`IpfsConfig`] via -/// [`IpfsConfig::new`]. Re-exported so callers wiring bitswap (typically `sc-service`) -/// need not depend on `litep2p` directly. +/// [`IpfsConfig::new`]. Re-exported so callers need not depend on `litep2p` directly. pub use litep2p::protocol::libp2p::bitswap::BitswapHandle as LitepBitswapHandle; use sc_network_types::{ multiaddr::{self, Multiaddr}, @@ -782,10 +781,6 @@ pub struct IpfsConfig { impl IpfsConfig { /// Construct an [`IpfsConfig`] together with the litep2p transport-side Bitswap handle. - /// - /// The two are created by the same `litep2p::protocol::libp2p::bitswap::Config::new` - /// call and belong together: the handle goes to `sc_network_bitswap::start`, the config - /// to `sc-network` via `Params::ipfs_config`. pub fn new( block_provider: Box, bootnodes: Vec, diff --git a/substrate/client/network/src/litep2p/mod.rs b/substrate/client/network/src/litep2p/mod.rs index dbad4c6e36d0..3c666a291ac3 100644 --- a/substrate/client/network/src/litep2p/mod.rs +++ b/substrate/client/network/src/litep2p/mod.rs @@ -382,6 +382,8 @@ impl Litep2pNetworkBackend { #[async_trait::async_trait] impl NetworkBackend for Litep2pNetworkBackend { + const SUPPORTS_IPFS: bool = true; + type NotificationProtocolConfig = NotificationProtocolConfig; type RequestResponseProtocolConfig = RequestResponseConfig; type NetworkService = Arc; diff --git a/substrate/client/network/src/service/traits.rs b/substrate/client/network/src/service/traits.rs index bf4a641cdf29..9b5d30818662 100644 --- a/substrate/client/network/src/service/traits.rs +++ b/substrate/client/network/src/service/traits.rs @@ -110,6 +110,9 @@ pub trait PeerStore { /// Networking backend. #[async_trait::async_trait] pub trait NetworkBackend: Send + 'static { + /// Whether this backend supports Bitswap ([`crate::config::Params::ipfs_config`]). + const SUPPORTS_IPFS: bool = false; + /// Type representing notification protocol-related configuration. type NotificationProtocolConfig: NotificationConfig; diff --git a/substrate/client/service/src/builder.rs b/substrate/client/service/src/builder.rs index 635ffd4d7e1c..daf45442e0ce 100644 --- a/substrate/client/service/src/builder.rs +++ b/substrate/client/service/src/builder.rs @@ -1178,8 +1178,8 @@ where /// Build the network service, the network status sinks and an RPC sender, this is a lower-level /// version of [`build_network`] for those needing more control. /// -/// The final tuple element is the Bitswap user handle; `Some` when `--ipfs-server` is -/// enabled, `None` otherwise. +/// The final tuple element is the Bitswap user handle; `Some` when the IPFS server is +/// enabled in the network configuration, `None` otherwise. pub fn build_network_advanced( params: BuildNetworkAdvancedParams, ) -> Result< @@ -1240,16 +1240,13 @@ where // install request handlers to `FullNetworkConfiguration` net_config.add_request_response_protocol(light_client_request_protocol_config); - // Initialize the IPFS server. Bitswap is only supported on the litep2p backend. + // Initialize the IPFS server. let (bitswap_handler, bitswap_user_handle, ipfs_config) = if net_config.network_config.ipfs_server { - if matches!( - net_config.network_config.network_backend, - sc_network::config::NetworkBackendType::Libp2p - ) { + if !Net::SUPPORTS_IPFS { return Err(Error::Other( - "Bitswap requires the litep2p network backend; \ - set --network-backend litep2p or disable --ipfs-server" + "the selected network backend does not support Bitswap; \ + set --network-backend litep2p or disable --ipfs-server" .into(), )); } diff --git a/substrate/client/storage-chain-sync/src/fetcher.rs b/substrate/client/storage-chain-sync/src/fetcher.rs index 04b43e7ea505..c93abbd1c51b 100644 --- a/substrate/client/storage-chain-sync/src/fetcher.rs +++ b/substrate/client/storage-chain-sync/src/fetcher.rs @@ -50,19 +50,19 @@ fn fetch_timeout(cid_count: usize) -> Duration { FETCH_TIMEOUT_BASE.saturating_add(per_cid).min(FETCH_TIMEOUT_MAX) } -/// Late-bound bitswap handle slot, populated by the node after `build_network`. +/// Late-bound slot for the bitswap handle. /// -/// [`crate::StorageChainBlockImport`] is constructed before `build_network` runs; the -/// `OnceLock` carries the handle across that boundary. +/// Allows constructing a fetcher before the handle exists; fetches fail with +/// [`FetchError::BitswapUnavailable`] until the slot is populated. pub type BitswapHandleSlot = Arc>>; /// Infrastructure-level fetch failure surfaced to [`crate::StorageChainBlockImport`]. #[derive(Debug, thiserror::Error)] pub enum FetchError { - /// The bitswap handle has not been set, either because `build_network` has not finished - /// yet or because bitswap is not configured (`--ipfs-server` not enabled). - #[error("bitswap handle not yet set; storage-chain blocks cannot be fetched before build_network completes")] - BitswapHandleUnset, + /// No bitswap handle is available: bitswap is disabled on this node, or the network + /// has not been initialized yet. + #[error("bitswap unavailable: disabled on this node, or network not yet initialized")] + BitswapUnavailable, /// CID construction failed for the given (hashing, hash) pair. #[error("failed to construct multihash for CID: {0}")] Multihash(String), @@ -105,7 +105,7 @@ impl IndexedTransactionFetcher { if wants.is_empty() { return Ok(HashMap::new()); } - let handle = self.bitswap.get().ok_or(FetchError::BitswapHandleUnset)?; + let handle = self.bitswap.get().ok_or(FetchError::BitswapUnavailable)?; let mut by_cid: HashMap = HashMap::with_capacity(wants.len()); let mut cids: Vec = Vec::with_capacity(wants.len()); @@ -117,7 +117,16 @@ impl IndexedTransactionFetcher { cids.push(cid); } - let mut rx = handle.request_stream(cids).await?; + let mut rx = match handle.request_stream(cids).await { + Ok(rx) => rx, + // Transient congestion: degrade to an empty partial result instead of failing + // the import. + Err(BitswapError::Overloaded) => { + log::debug!(target: LOG_TARGET, "bitswap service overloaded, deferring fetch"); + return Ok(HashMap::new()); + }, + Err(other) => return Err(FetchError::Bitswap(other)), + }; let deadline = tokio::time::Instant::now() + fetch_timeout(wants.len()); let mut acquired: HashMap> = HashMap::with_capacity(wants.len()); @@ -140,6 +149,13 @@ impl IndexedTransactionFetcher { ); return Ok(acquired); }, + Ok(Some(Err(BitswapError::Overloaded))) => { + log::debug!( + target: LOG_TARGET, + "bitswap service overloaded; returning partial result", + ); + return Ok(acquired); + }, Ok(Some(Err(other))) => return Err(FetchError::Bitswap(other)), // The stream closed: every CID was delivered. Ok(None) => break, From 0d16cb81fd66a6cccd7da20bf15ce751639614b4 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Thu, 16 Jul 2026 10:49:12 +0200 Subject: [PATCH 15/32] Use rstest for testing --- Cargo.lock | 1 + substrate/client/network/bitswap/Cargo.toml | 1 + .../client/network/bitswap/src/service.rs | 589 +++++------------- 3 files changed, 157 insertions(+), 434 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bed77aca2396..007316fe6431 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21570,6 +21570,7 @@ dependencies = [ "futures", "litep2p 0.14.3", "log", + "rstest", "sc-block-builder", "sc-client-api 28.0.0", "sc-network-common 0.33.0", diff --git a/substrate/client/network/bitswap/Cargo.toml b/substrate/client/network/bitswap/Cargo.toml index 021a17c1e5a2..d204c9f6fe3b 100644 --- a/substrate/client/network/bitswap/Cargo.toml +++ b/substrate/client/network/bitswap/Cargo.toml @@ -34,6 +34,7 @@ thiserror = { workspace = true } tokio = { features = ["macros", "rt", "sync"], workspace = true, default-features = true } [dev-dependencies] +rstest = { workspace = true } sc-block-builder = { workspace = true, default-features = true } sp-consensus = { workspace = true, default-features = true } substrate-test-runtime = { workspace = true } diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 4230526501ce..16eda73f9930 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -771,6 +771,7 @@ fn serve_inbound( mod tests { use super::*; use crate::RAW_CODEC; + use rstest::rstest; use sc_block_builder::BlockBuilderBuilder; use sc_network_sync::SyncEvent; use sc_network_types::PeerId as TypesPeerId; @@ -908,6 +909,53 @@ mod tests { SyncEvent::PeerDisconnected(to_types_peer(peer)) } + impl TestRig { + async fn connect(&self, peer: litep2p::PeerId) { + self.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + } + + async fn send_response(&self, peer: litep2p::PeerId, responses: Vec) { + self.inbound_tx.send(BitswapEvent::Response { peer, responses }).await.unwrap(); + } + + async fn send_block(&self, peer: litep2p::PeerId, cid: Cid, data: &[u8]) { + self.send_response(peer, vec![ResponseType::Block { cid, block: data.to_vec() }]) + .await; + } + + async fn send_presence( + &self, + peer: litep2p::PeerId, + cid: Cid, + presence: BlockPresenceType, + ) { + self.send_response(peer, vec![ResponseType::Presence { cid, presence }]).await; + } + + async fn send_wantlist(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { + self.inbound_tx.send(BitswapEvent::Request { peer, cids }).await.unwrap(); + } + } + + /// Expect the next stream item to deliver the block for `cid` with payload `data`. + async fn expect_block(rx: &mut mpsc::Receiver, cid: Cid, data: &[u8]) { + match drain_next(rx).await.expect("stream item") { + Ok((got_cid, bytes)) => { + assert_eq!(got_cid, cid); + assert_eq!(bytes, data); + }, + other => panic!("expected block, got {other:?}"), + } + } + + fn assert_single_dont_have(responses: &[ResponseType], cid: Cid) { + assert!(matches!( + responses, + [ResponseType::Presence { cid: got, presence: BlockPresenceType::DontHave }] + if *got == cid + )); + } + #[test] fn want_set_removes_cid_after_last_waiter_and_peer_complete() { let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xaa; 32]); @@ -961,7 +1009,7 @@ mod tests { let data = b"payload-a".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + rig.connect(peer).await; let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let (out_peer, out_cids) = @@ -969,22 +1017,8 @@ mod tests { assert_eq!(out_peer, peer); assert_eq!(out_cids, vec![(cid, WantType::Block)]); - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item = drain_next(&mut rx).await.expect("item"); - match item { - Ok((got_cid, bytes)) => { - assert_eq!(got_cid, cid); - assert_eq!(bytes, data); - }, - other => panic!("expected Block, got {other:?}"), - } + rig.send_block(peer, cid, &data).await; + expect_block(&mut rx, cid, &data).await; } #[tokio::test(start_paused = true)] @@ -994,41 +1028,21 @@ mod tests { let data = b"payload-have".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + rig.connect(peer).await; let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); assert_eq!(out_peer, peer); - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Presence { cid, presence: BlockPresenceType::Have }], - }) - .await - .unwrap(); + rig.send_presence(peer, cid, BlockPresenceType::Have).await; // A HAVE keeps the peer eligible: it is re-asked for the block. let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("re-ask"); assert_eq!(out_peer, peer); assert_eq!(out_cids, vec![(cid, WantType::Block)]); - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item = drain_next(&mut rx).await.expect("item"); - match item { - Ok((got_cid, bytes)) => { - assert_eq!(got_cid, cid); - assert_eq!(bytes, data); - }, - other => panic!("expected Block, got {other:?}"), - } + rig.send_block(peer, cid, &data).await; + expect_block(&mut rx, cid, &data).await; } #[tokio::test(start_paused = true)] @@ -1039,136 +1053,65 @@ mod tests { let data = b"payload-have-2".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.sync_event_tx.send(sync_connected(peer_a)).await.unwrap(); - rig.sync_event_tx.send(sync_connected(peer_b)).await.unwrap(); + rig.connect(peer_a).await; + rig.connect(peer_b).await; let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let (first, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); let other = if first == peer_a { peer_b } else { peer_a }; - let have = |peer| BitswapEvent::Response { - peer, - responses: vec![ResponseType::Presence { cid, presence: BlockPresenceType::Have }], - }; - // First HAVE earns the peer a re-ask. - rig.inbound_tx.send(have(first)).await.unwrap(); + rig.send_presence(first, cid, BlockPresenceType::Have).await; let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("re-ask"); assert_eq!(out_peer, first); // Second HAVE without the block: the peer counts as tried, the want moves on. - rig.inbound_tx.send(have(first)).await.unwrap(); + rig.send_presence(first, cid, BlockPresenceType::Have).await; let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); assert_eq!(out_peer, other); assert_eq!(out_cids, vec![(cid, WantType::Block)]); - rig.inbound_tx - .send(BitswapEvent::Response { - peer: other, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item = drain_next(&mut rx).await.expect("item"); - match item { - Ok((got_cid, bytes)) => { - assert_eq!(got_cid, cid); - assert_eq!(bytes, data); - }, - other => panic!("expected Block, got {other:?}"), - } + rig.send_block(other, cid, &data).await; + expect_block(&mut rx, cid, &data).await; } + /// The only peer counts as tried (via DONT_HAVE, or an unanswered request hitting the + /// per-peer timeout); after [`ROUND_RETRY_DELAY`] the round restarts and the same peer + /// is asked again. + #[rstest] + #[case::after_dont_have(true)] + #[case::after_timeout(false)] #[tokio::test(start_paused = true)] - async fn exhausted_round_reasks_peer_after_delay() { + async fn exhausted_round_reasks_peer_after_delay(#[case] answer_dont_have: bool) { let mut rig = empty_rig(); let peer = litep2p::PeerId::random(); let data = b"payload-round".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + rig.connect(peer).await; let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Presence { - cid, - presence: BlockPresenceType::DontHave, - }], - }) - .await - .unwrap(); - - // The only peer is tried: nothing is dispatched before the round delay passes. - let no_req = - timeout(ROUND_RETRY_DELAY - Duration::from_secs(1), rig.outbound_req_rx.recv()).await; - assert!(no_req.is_err()); - - // After the delay the round restarts and the same peer is asked again. - let (out_peer, out_cids) = - drain_next(&mut rig.outbound_req_rx).await.expect("new-round WANT"); - assert_eq!(out_peer, peer); - assert_eq!(out_cids, vec![(cid, WantType::Block)]); - - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item = drain_next(&mut rx).await.expect("item"); - match item { - Ok((got_cid, bytes)) => { - assert_eq!(got_cid, cid); - assert_eq!(bytes, data); - }, - other => panic!("expected Block, got {other:?}"), + if answer_dont_have { + rig.send_presence(peer, cid, BlockPresenceType::DontHave).await; + // The only peer is tried: nothing is dispatched before the round delay passes. + let no_req = + timeout(ROUND_RETRY_DELAY - Duration::from_secs(1), rig.outbound_req_rx.recv()) + .await; + assert!(no_req.is_err()); } - } - - #[tokio::test(start_paused = true)] - async fn timed_out_peer_is_reasked_next_round() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let data = b"payload-round-2".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); - let _ = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - // No response: the per-peer timeout marks the peer tried, then the round restarts - // and the same peer is asked again. let (out_peer, out_cids) = timeout(PER_PEER_TIMEOUT + ROUND_RETRY_DELAY * 2, rig.outbound_req_rx.recv()) .await - .expect("re-ask after timeout and round delay") + .expect("new-round WANT") .expect("transport channel open"); assert_eq!(out_peer, peer); assert_eq!(out_cids, vec![(cid, WantType::Block)]); - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item = drain_next(&mut rx).await.expect("item"); - match item { - Ok((got_cid, bytes)) => { - assert_eq!(got_cid, cid); - assert_eq!(bytes, data); - }, - other => panic!("expected Block, got {other:?}"), - } + rig.send_block(peer, cid, &data).await; + expect_block(&mut rx, cid, &data).await; } #[tokio::test(start_paused = true)] @@ -1179,43 +1122,20 @@ mod tests { let data = b"payload-round-3".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.sync_event_tx.send(sync_connected(peer_a)).await.unwrap(); + rig.connect(peer_a).await; let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - rig.inbound_tx - .send(BitswapEvent::Response { - peer: peer_a, - responses: vec![ResponseType::Presence { - cid, - presence: BlockPresenceType::DontHave, - }], - }) - .await - .unwrap(); + rig.send_presence(peer_a, cid, BlockPresenceType::DontHave).await; // The CID is parked awaiting a new round; a fresh peer is dispatched immediately, // without waiting for the round delay. - rig.sync_event_tx.send(sync_connected(peer_b)).await.unwrap(); + rig.connect(peer_b).await; let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("WANT to new peer"); assert_eq!(out_peer, peer_b); - rig.inbound_tx - .send(BitswapEvent::Response { - peer: peer_b, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item = drain_next(&mut rx).await.expect("item"); - match item { - Ok((got_cid, bytes)) => { - assert_eq!(got_cid, cid); - assert_eq!(bytes, data); - }, - other => panic!("expected Block, got {other:?}"), - } + rig.send_block(peer_b, cid, &data).await; + expect_block(&mut rx, cid, &data).await; // The dispatch cancelled the scheduled round: no stray re-ask later. tokio::time::advance(ROUND_RETRY_DELAY * 2).await; @@ -1236,117 +1156,84 @@ mod tests { assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); // Once a full peer connects, the pending want goes out to it. - rig.sync_event_tx.send(sync_connected(full_peer)).await.unwrap(); + rig.connect(full_peer).await; let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); assert_eq!(out_peer, full_peer); assert_eq!(out_cids, vec![(cid, WantType::Block)]); } + /// The only peer fails the request (DONT_HAVE, or a block failing CID verification): + /// nothing is delivered and the stream stays open until the caller gives up. + #[rstest] + #[case::dont_have(None)] + #[case::corrupted_block(Some(b"NOT-the-real-payload".as_slice()))] #[tokio::test(start_paused = true)] - async fn dont_have_from_only_peer_leaves_stream_open() { + async fn failure_from_only_peer_leaves_stream_open(#[case] block: Option<&[u8]>) { let mut rig = empty_rig(); let peer = litep2p::PeerId::random(); - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [7u8; 32]); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, b"the-real-payload"); - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + rig.connect(peer).await; let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Presence { - cid, - presence: BlockPresenceType::DontHave, - }], - }) - .await - .unwrap(); + match block { + Some(corrupted) => rig.send_block(peer, cid, corrupted).await, + None => rig.send_presence(peer, cid, BlockPresenceType::DontHave).await, + } - // No other peers to try: the want stays unresolved, the stream stays open until - // the caller gives up. tokio::time::advance(Duration::from_secs(60)).await; assert!(matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty))); } - #[tokio::test(start_paused = true)] - async fn per_peer_timeout_triggers_failover() { - let mut rig = empty_rig(); - let peer_a = litep2p::PeerId::random(); - let peer_b = litep2p::PeerId::random(); - let data = b"after-timeout".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.sync_event_tx.send(sync_connected(peer_a)).await.unwrap(); - rig.sync_event_tx.send(sync_connected(peer_b)).await.unwrap(); - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); - let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - - // The unanswered request times out after `PER_PEER_TIMEOUT`; the sweep retries on - // the other peer. - tokio::time::advance(PER_PEER_TIMEOUT + Duration::from_secs(2)).await; - - let (second_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); - assert_ne!(first_peer, second_peer); - - rig.inbound_tx - .send(BitswapEvent::Response { - peer: second_peer, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item = drain_next(&mut rx).await.expect("item"); - assert!(matches!(item, Ok((c, b)) if c == cid && b == data)); + enum FailoverTrigger { + DontHave, + Timeout, + CorruptedBlock, + Disconnect, } + /// However the first peer fails the request — DONT_HAVE, an unanswered request timing + /// out, a block failing CID verification, or disconnecting — the want fails over to + /// the other connected peer. + #[rstest] + #[case::dont_have(FailoverTrigger::DontHave)] + #[case::timeout(FailoverTrigger::Timeout)] + #[case::corrupted_block(FailoverTrigger::CorruptedBlock)] + #[case::disconnect(FailoverTrigger::Disconnect)] #[tokio::test(start_paused = true)] - async fn two_peers_first_dont_have_second_block() { + async fn first_peer_failure_triggers_failover(#[case] trigger: FailoverTrigger) { let mut rig = empty_rig(); let peer_a = litep2p::PeerId::random(); let peer_b = litep2p::PeerId::random(); - let data = b"after-failover".to_vec(); + let data = b"failover-payload".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.sync_event_tx.send(sync_connected(peer_a)).await.unwrap(); - rig.sync_event_tx.send(sync_connected(peer_b)).await.unwrap(); + rig.connect(peer_a).await; + rig.connect(peer_b).await; let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); - let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - rig.inbound_tx - .send(BitswapEvent::Response { - peer: first_peer, - responses: vec![ResponseType::Presence { - cid, - presence: BlockPresenceType::DontHave, - }], - }) - .await - .unwrap(); + match trigger { + FailoverTrigger::DontHave => { + rig.send_presence(first_peer, cid, BlockPresenceType::DontHave).await + }, + FailoverTrigger::Timeout => { + tokio::time::advance(PER_PEER_TIMEOUT + Duration::from_secs(2)).await + }, + FailoverTrigger::CorruptedBlock => rig.send_block(first_peer, cid, b"corrupted").await, + FailoverTrigger::Disconnect => { + rig.sync_event_tx.send(sync_disconnected(first_peer)).await.unwrap() + }, + } - let (second_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("second WANT"); + let (second_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); assert_ne!(first_peer, second_peer); - rig.inbound_tx - .send(BitswapEvent::Response { - peer: second_peer, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item = drain_next(&mut rx).await.expect("item"); - match item { - Ok((got, bytes)) => { - assert_eq!(got, cid); - assert_eq!(bytes, data); - }, - other => panic!("expected Block, got {other:?}"), - } + rig.send_block(second_peer, cid, &data).await; + expect_block(&mut rx, cid, &data).await; } #[tokio::test(start_paused = true)] @@ -1365,7 +1252,7 @@ mod tests { tokio::time::advance(Duration::from_secs(2)).await; // A peer connecting afterwards must not trigger a WANT for the cancelled request. - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + rig.connect(peer).await; assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); } @@ -1376,25 +1263,16 @@ mod tests { let data = b"shared".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + rig.connect(peer).await; let mut rx_a = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let mut rx_b = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item_a = drain_next(&mut rx_a).await.expect("a"); - let item_b = drain_next(&mut rx_b).await.expect("b"); - assert!(matches!(&item_a, Ok((c, b)) if *c == cid && *b == data)); - assert!(matches!(&item_b, Ok((c, b)) if *c == cid && *b == data)); + rig.send_block(peer, cid, &data).await; + expect_block(&mut rx_a, cid, &data).await; + expect_block(&mut rx_b, cid, &data).await; } #[tokio::test(start_paused = true)] @@ -1404,7 +1282,7 @@ mod tests { let data = b"survivor".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + rig.connect(peer).await; let rx_a = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let mut rx_b = rig.user_handle.request_stream(vec![cid]).await.unwrap(); @@ -1414,23 +1292,15 @@ mod tests { let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item_b = drain_next(&mut rx_b).await.expect("b"); - assert!(matches!(item_b, Ok((c, b)) if c == cid && b == data)); + rig.send_block(peer, cid, &data).await; + expect_block(&mut rx_b, cid, &data).await; } #[tokio::test(start_paused = true)] async fn dispatch_window_queues_excess_cids_and_promotes_on_delivery() { let mut rig = small_window_rig(4); let peer = litep2p::PeerId::random(); - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + rig.connect(peer).await; let payloads: Vec> = (0..5u8).map(|i| vec![i; 8]).collect(); let cids: Vec = @@ -1447,19 +1317,8 @@ mod tests { // Answering one dispatched CID frees a slot and promotes the queued CID. let answered_idx = cids.iter().position(|cid| dispatched.contains(cid)).unwrap(); - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Block { - cid: cids[answered_idx], - block: payloads[answered_idx].clone(), - }], - }) - .await - .unwrap(); - - let item = drain_next(&mut rx).await.expect("delivered block"); - assert!(matches!(item, Ok((c, _)) if c == cids[answered_idx])); + rig.send_block(peer, cids[answered_idx], &payloads[answered_idx]).await; + expect_block(&mut rx, cids[answered_idx], &payloads[answered_idx]).await; let (_, promoted) = drain_next(&mut rig.outbound_req_rx).await.expect("promoted WANT"); assert_eq!(promoted, vec![(cids[queued_idx], WantType::Block)]); @@ -1486,10 +1345,7 @@ mod tests { let mut rig = build_rig_with(Arc::new(client), MAX_LIVE_CIDS); let peer = litep2p::PeerId::random(); - rig.inbound_tx - .send(BitswapEvent::Request { peer, cids: vec![(cid, WantType::Block)] }) - .await - .unwrap(); + rig.send_wantlist(peer, vec![(cid, WantType::Block)]).await; let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("response"); assert_eq!(resp_peer, peer); @@ -1573,10 +1429,7 @@ mod tests { // The first wantlist occupies the only lookup worker (parked on the gate); the // second finds the pool saturated. for cid in [gated_cid, refused_cid] { - rig.inbound_tx - .send(BitswapEvent::Request { peer, cids: vec![(cid, WantType::Block)] }) - .await - .unwrap(); + rig.send_wantlist(peer, vec![(cid, WantType::Block)]).await; } // The refused wantlist is answered with DONT_HAVE instead of silence. @@ -1584,22 +1437,14 @@ mod tests { .await .expect("DONT_HAVE for refused wantlist"); assert_eq!(resp_peer, peer); - assert!(matches!( - &responses[..], - [ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }] - if *cid == refused_cid - )); + assert_single_dont_have(&responses, refused_cid); // Releasing the worker lets the parked wantlist be served normally. gate_tx.send(()).unwrap(); let (_, responses) = drain_next(&mut rig.outbound_resp_rx) .await .expect("response for gated wantlist"); - assert!(matches!( - &responses[..], - [ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }] - if *cid == gated_cid - )); + assert_single_dont_have(&responses, gated_cid); } #[tokio::test] @@ -1615,10 +1460,7 @@ mod tests { }) .collect(); - rig.inbound_tx - .send(BitswapEvent::Request { peer, cids: cids.clone() }) - .await - .unwrap(); + rig.send_wantlist(peer, cids.clone()).await; // Entries beyond `MAX_WANTED_BLOCKS` get an immediate DONT_HAVE; the first // `MAX_WANTED_BLOCKS` are looked up and answered too (DONT_HAVE as well: the test @@ -1649,85 +1491,11 @@ mod tests { CidMultihash::<64>::wrap(0x99 /* unsupported */, &[0u8; 32]).unwrap(), ); - rig.inbound_tx - .send(BitswapEvent::Request { peer, cids: vec![(unsupported, WantType::Block)] }) - .await - .unwrap(); + rig.send_wantlist(peer, vec![(unsupported, WantType::Block)]).await; let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("reply"); assert_eq!(resp_peer, peer); - assert!(matches!( - &responses[..], - [ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }] - if *cid == unsupported - )); - } - - #[tokio::test(start_paused = true)] - async fn corrupted_block_from_only_peer_is_not_delivered() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let real = b"the-real-payload".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &real); - - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); - let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); - - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Block { - cid, - block: b"NOT-the-real-payload".to_vec(), - }], - }) - .await - .unwrap(); - - // The corrupted block is rejected; no other peer can serve the CID, so the - // stream stays open without delivering anything. - tokio::time::advance(Duration::from_secs(60)).await; - assert!(matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty))); - } - - #[tokio::test(start_paused = true)] - async fn corrupted_block_triggers_failover_to_next_peer() { - let mut rig = empty_rig(); - let peer_a = litep2p::PeerId::random(); - let peer_b = litep2p::PeerId::random(); - let data = b"genuine-payload".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.sync_event_tx.send(sync_connected(peer_a)).await.unwrap(); - rig.sync_event_tx.send(sync_connected(peer_b)).await.unwrap(); - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); - - let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - - rig.inbound_tx - .send(BitswapEvent::Response { - peer: first_peer, - responses: vec![ResponseType::Block { cid, block: b"corrupted".to_vec() }], - }) - .await - .unwrap(); - - let (second_peer, _) = drain_next(&mut rig.outbound_req_rx) - .await - .expect("failover WANT after corrupted block"); - assert_ne!(first_peer, second_peer); - - rig.inbound_tx - .send(BitswapEvent::Response { - peer: second_peer, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item = drain_next(&mut rx).await.expect("item"); - assert!(matches!(item, Ok((c, b)) if c == cid && b == data)); + assert_single_dont_have(&responses, unsupported); } #[tokio::test(start_paused = true)] @@ -1739,6 +1507,7 @@ mod tests { drop(rig.user_handle); sleep(Duration::from_millis(1)).await; + // `rig.user_handle` is moved out, so the `&self` helper is unusable here. rig.inbound_tx .send(BitswapEvent::Request { peer, cids: vec![(cid, WantType::Block)] }) .await @@ -1748,17 +1517,14 @@ mod tests { .await .expect("service must keep serving inbound after all handles are dropped"); assert_eq!(resp_peer, peer); - assert!(matches!( - responses[0], - ResponseType::Presence { presence: BlockPresenceType::DontHave, .. } - )); + assert_single_dont_have(&responses, cid); } #[tokio::test(start_paused = true)] async fn outbound_wants_bundled_and_split_at_message_cap() { let mut rig = empty_rig(); let peer = litep2p::PeerId::random(); - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + rig.connect(peer).await; let cids: Vec = (0..=MAX_WANTED_BLOCKS as u8) .map(|i| { @@ -1808,7 +1574,7 @@ mod tests { let peer = litep2p::PeerId::random(); let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xee; 32]); - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + rig.connect(peer).await; let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await; @@ -1818,37 +1584,6 @@ mod tests { assert!(matches!(item, Err(BitswapError::ServiceClosed))); } - #[tokio::test(start_paused = true)] - async fn peer_disconnect_triggers_failover() { - let mut rig = empty_rig(); - let peer_a = litep2p::PeerId::random(); - let peer_b = litep2p::PeerId::random(); - let data = b"after-disconnect".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.sync_event_tx.send(sync_connected(peer_a)).await.unwrap(); - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); - let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - assert_eq!(first_peer, peer_a); - - rig.sync_event_tx.send(sync_connected(peer_b)).await.unwrap(); - rig.sync_event_tx.send(sync_disconnected(peer_a)).await.unwrap(); - - let (second_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); - assert_eq!(second_peer, peer_b); - - rig.inbound_tx - .send(BitswapEvent::Response { - peer: peer_b, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item = drain_next(&mut rx).await.expect("item"); - assert!(matches!(item, Ok((c, b)) if c == cid && b == data)); - } - #[tokio::test(start_paused = true)] async fn late_response_after_receiver_drop_is_ignored_and_cid_refetchable() { let mut rig = empty_rig(); @@ -1856,7 +1591,7 @@ mod tests { let data = b"too-late".to_vec(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - rig.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + rig.connect(peer).await; let rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); @@ -1866,13 +1601,7 @@ mod tests { tokio::time::advance(Duration::from_secs(2)).await; // The late response hits no waiter and is dropped. - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); + rig.send_block(peer, cid, &data).await; // Let the actor process the late response before the fresh request goes in. sleep(Duration::from_millis(1)).await; @@ -1880,16 +1609,8 @@ mod tests { // again and the block is delivered. let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("fresh WANT"); - rig.inbound_tx - .send(BitswapEvent::Response { - peer, - responses: vec![ResponseType::Block { cid, block: data.clone() }], - }) - .await - .unwrap(); - - let item = drain_next(&mut rx).await.expect("item"); - assert!(matches!(item, Ok((c, b)) if c == cid && b == data)); + rig.send_block(peer, cid, &data).await; + expect_block(&mut rx, cid, &data).await; } #[tokio::test(start_paused = true)] From 82c50e62b3ea400c72568f556a89c0bcdcb07a0a Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Thu, 16 Jul 2026 11:22:18 +0200 Subject: [PATCH 16/32] Add proptest --- Cargo.lock | 1 + substrate/client/network/bitswap/Cargo.toml | 1 + .../client/network/bitswap/src/service.rs | 180 ++++++++++++++++++ 3 files changed, 182 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 007316fe6431..9db333abb621 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21570,6 +21570,7 @@ dependencies = [ "futures", "litep2p 0.14.3", "log", + "proptest", "rstest", "sc-block-builder", "sc-client-api 28.0.0", diff --git a/substrate/client/network/bitswap/Cargo.toml b/substrate/client/network/bitswap/Cargo.toml index d204c9f6fe3b..c134c7b4fb24 100644 --- a/substrate/client/network/bitswap/Cargo.toml +++ b/substrate/client/network/bitswap/Cargo.toml @@ -34,6 +34,7 @@ thiserror = { workspace = true } tokio = { features = ["macros", "rt", "sync"], workspace = true, default-features = true } [dev-dependencies] +proptest = { workspace = true } rstest = { workspace = true } sc-block-builder = { workspace = true, default-features = true } sp-consensus = { workspace = true, default-features = true } diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 16eda73f9930..a1ecc1639366 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -1630,3 +1630,183 @@ mod tests { assert!(matches!(item, Err(BitswapError::Overloaded))); } } + +#[cfg(test)] +mod proptests { + use super::*; + use crate::RAW_CODEC; + use proptest::prelude::*; + + const NUM_CIDS: u8 = 8; + const NUM_PEERS: usize = 4; + /// Small dispatch window so op sequences regularly hit the queueing path. + const SMALL_WINDOW: usize = 3; + + /// One step of the actor's observable behavior against [`WantSet`]. + #[derive(Debug, Clone)] + enum Op { + /// `on_request_stream`: admit a waiter for a CID. + AddWaiter(u8), + /// Sweep of an abandoned waiter: remove one active waiter. + RemoveWaiter(usize), + /// `deliver_block`: a verified block arrived. + Deliver(u8), + /// Block/DONT_HAVE outcome (possibly unsolicited) from a peer. + MarkPeerDone(usize, u8), + /// HAVE outcome from a peer. + NoteHave(usize, u8), + Connect(usize), + Disconnect(usize), + /// Advance time and run the sweep (per-peer timeouts + round restarts). + Sweep(u64), + } + + fn op_strategy() -> impl Strategy { + prop_oneof![ + (0..NUM_CIDS).prop_map(Op::AddWaiter), + any::().prop_map(Op::RemoveWaiter), + (0..NUM_CIDS).prop_map(Op::Deliver), + (0..NUM_PEERS, 0..NUM_CIDS).prop_map(|(p, c)| Op::MarkPeerDone(p, c)), + (0..NUM_PEERS, 0..NUM_CIDS).prop_map(|(p, c)| Op::NoteHave(p, c)), + (0..NUM_PEERS).prop_map(Op::Connect), + (0..NUM_PEERS).prop_map(Op::Disconnect), + (1u64..8).prop_map(Op::Sweep), + ] + } + + struct Harness { + wants: WantSet, + /// Active waiters and the CID each waits on. One CID per waiter: multi-CID + /// requests add nothing at the `WantSet` level. + waiters: SlotMap, + peers: Vec, + cids: Vec, + connected: HashSet, + now: Instant, + } + + impl Harness { + fn new() -> Self { + let cids = (0..NUM_CIDS) + .map(|i| { + let mh = + CidMultihash::<64>::wrap(BLAKE2B_256_MULTIHASH_CODE, &[i; 32]).unwrap(); + Cid::new_v1(RAW_CODEC, mh) + }) + .collect(); + Self { + wants: WantSet::new(SMALL_WINDOW), + waiters: SlotMap::with_key(), + peers: (0..NUM_PEERS).map(|_| litep2p::PeerId::random()).collect(), + cids, + connected: HashSet::new(), + now: Instant::now(), + } + } + + fn apply(&mut self, op: Op) { + match op { + Op::AddWaiter(c) => { + let cid = self.cids[c as usize]; + let id = self.waiters.insert(cid); + self.wants.add_waiter(cid, id); + }, + Op::RemoveWaiter(seed) => { + let Some(id) = self.waiters.keys().nth(seed % self.waiters.len().max(1)) else { + return; + }; + let cid = self.waiters.remove(id).expect("key just listed; qed"); + self.wants.remove_waiter(cid, id); + }, + Op::Deliver(c) => { + let waiter_ids = self + .wants + .take_waiters_for_delivered_cid(self.cids[c as usize]) + .unwrap_or_default(); + for id in waiter_ids { + self.waiters.remove(id); + } + }, + Op::MarkPeerDone(p, c) => { + self.wants.mark_peer_done_for_cid(self.peers[p], self.cids[c as usize]) + }, + Op::NoteHave(p, c) => { + self.wants.note_peer_have_for_cid(self.peers[p], self.cids[c as usize]) + }, + Op::Connect(p) => { + self.connected.insert(self.peers[p]); + }, + Op::Disconnect(p) => { + self.connected.remove(&self.peers[p]); + let _ = self.wants.remove_in_flight_peer(self.peers[p]); + }, + Op::Sweep(secs) => { + self.now += Duration::from_secs(secs); + let _ = self.wants.expire_peer_timeouts(self.now); + let _ = self.wants.restart_exhausted_rounds(self.now); + }, + } + } + + /// Mirror `BitswapService::top_up_in_flight`: after every event the service + /// re-dispatches everything dispatchable. + fn top_up(&mut self) { + for cid in self.wants.all_cids() { + let _ = self.wants.next_peer_to_request(cid, &self.connected, self.now); + } + while self.wants.has_window_capacity() { + let Some(cid) = self.wants.pop_pending() else { break }; + let _ = self.wants.next_peer_to_request(cid, &self.connected, self.now); + } + } + + fn check_invariants(&self) { + let live = self.wants.inner.values().filter(|s| !s.in_flight_peers.is_empty()).count(); + assert_eq!(self.wants.live, live, "live counter drifted"); + assert!(self.wants.live <= self.wants.max_live, "dispatch window overrun"); + + for (cid, state) in &self.wants.inner { + assert!(state.in_flight_peers.len() <= PEER_FANOUT_CAP, "fanout cap exceeded"); + assert!(!state.is_idle(), "idle entry retained for {cid}"); + assert!( + state.in_flight_peers.keys().all(|p| !state.tried_peers.contains(p)), + "peer both tried and in flight for {cid}", + ); + if state.pending { + assert!( + self.wants.pending.contains(cid), + "pending flag without queue entry for {cid}", + ); + } + // A parked round is never overdue: the sweep restarts due rounds, and a + // dispatch re-parks strictly into the future. An overdue round that + // stays parked is the "peer blacklisted forever" bug. + if let Some(at) = state.next_round_at { + assert!(at > self.now, "overdue round never restarted for {cid}"); + } + // Liveness after a top-up pass: a waited-on CID must be somewhere on the + // path to resolution — in flight, queued for a window slot, or parked + // awaiting a new round. Anything else is the stranded-forever bug. + if state.has_waiters() && !self.connected.is_empty() { + assert!( + !state.in_flight_peers.is_empty() || + state.pending || state.next_round_at.is_some(), + "stranded CID {cid}", + ); + } + } + } + } + + proptest! { + #[test] + fn want_set_invariants_hold(ops in prop::collection::vec(op_strategy(), 1..256)) { + let mut harness = Harness::new(); + for op in ops { + harness.apply(op); + harness.top_up(); + harness.check_invariants(); + } + } + } +} From e5c872036a324753af820fd7ce2e0a3e86c05861 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Thu, 16 Jul 2026 14:57:14 +0200 Subject: [PATCH 17/32] Clean up comments, add metrics --- Cargo.lock | 2 + substrate/client/network/bitswap/Cargo.toml | 6 +- .../client/network/bitswap/src/handle.rs | 31 +- substrate/client/network/bitswap/src/lib.rs | 1 + .../client/network/bitswap/src/metrics.rs | 273 ++++++++++++++++++ .../client/network/bitswap/src/service.rs | 268 +++++++++++------ substrate/client/service/src/builder.rs | 78 +++-- .../client/storage-chain-sync/src/fetcher.rs | 30 +- .../client/storage-chain-sync/src/lib.rs | 8 +- .../client/storage-chain-sync/tests/it.rs | 8 +- 10 files changed, 524 insertions(+), 181 deletions(-) create mode 100644 substrate/client/network/bitswap/src/metrics.rs diff --git a/Cargo.lock b/Cargo.lock index 9db333abb621..7349dfae79be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21571,6 +21571,7 @@ dependencies = [ "litep2p 0.14.3", "log", "proptest", + "rand 0.8.5", "rstest", "sc-block-builder", "sc-client-api 28.0.0", @@ -21583,6 +21584,7 @@ dependencies = [ "sp-core 28.0.0", "sp-crypto-hashing 0.1.0", "sp-runtime 31.0.1", + "substrate-prometheus-endpoint 0.17.0", "substrate-test-runtime", "substrate-test-runtime-client", "thiserror 1.0.65", diff --git a/substrate/client/network/bitswap/Cargo.toml b/substrate/client/network/bitswap/Cargo.toml index c134c7b4fb24..cf2caf5a287c 100644 --- a/substrate/client/network/bitswap/Cargo.toml +++ b/substrate/client/network/bitswap/Cargo.toml @@ -21,22 +21,24 @@ cid = { workspace = true } futures = { workspace = true } litep2p = { workspace = true } log = { workspace = true, default-features = true } +prometheus-endpoint = { workspace = true, default-features = true } +rand = { workspace = true, default-features = true } sc-client-api = { workspace = true, default-features = true } sc-network-common = { workspace = true, default-features = true } sc-network-sync = { workspace = true, default-features = true } -sc-network-types = { workspace = true, default-features = true } slotmap = { workspace = true } smallvec = { workspace = true, default-features = true } sp-core = { workspace = true, default-features = true } sp-crypto-hashing = { workspace = true, default-features = true } sp-runtime = { workspace = true, default-features = true } thiserror = { workspace = true } -tokio = { features = ["macros", "rt", "sync"], workspace = true, default-features = true } +tokio = { features = ["macros", "rt", "sync", "time"], workspace = true, default-features = true } [dev-dependencies] proptest = { workspace = true } rstest = { workspace = true } sc-block-builder = { workspace = true, default-features = true } +sc-network-types = { workspace = true, default-features = true } sp-consensus = { workspace = true, default-features = true } substrate-test-runtime = { workspace = true } substrate-test-runtime-client = { workspace = true } diff --git a/substrate/client/network/bitswap/src/handle.rs b/substrate/client/network/bitswap/src/handle.rs index 35caf8fef83b..1912541a66d0 100644 --- a/substrate/client/network/bitswap/src/handle.rs +++ b/substrate/client/network/bitswap/src/handle.rs @@ -15,17 +15,10 @@ // You should have received a copy of the GNU General Public License // along with Substrate. If not, see . -//! Public user-facing handle for the Bitswap service. -//! -//! Cheap to clone. Submit work via [`BitswapHandle::request_stream`], drain the receiver -//! to get per-CID results as they resolve. The service retries unresolved CIDs for as -//! long as the request is alive; the caller owns the time budget: apply a timeout while -//! draining and drop the receiver to give up. Dropping the receiver cancels all wants -//! remaining in the request. +//! User-facing API for submitting Bitswap requests. use super::{is_cid_supported, Cid}; -use async_trait::async_trait; use tokio::sync::mpsc; /// Service-level Bitswap errors. @@ -72,10 +65,8 @@ impl BitswapHandle { /// Submit a wantlist. Returns a receiver that yields `Ok((cid, bytes))` with /// hash-verified bytes for each requested CID, in the order they resolve. /// - /// The stream closes once every CID has been delivered. A CID that no connected peer - /// can serve stays unresolved indefinitely; the service keeps retrying as peers - /// connect. To bound the wait, apply a timeout while draining and drop the receiver — - /// dropping it cancels all wants remaining in this request. + /// The stream closes once every CID has been delivered. Unresolved CIDs are retried + /// until the receiver is dropped. /// /// There is no per-call CID cap. /// @@ -86,7 +77,7 @@ impl BitswapHandle { /// Returns a synchronous `BitswapError` for admission-time failures (`ServiceClosed`, /// `InvalidCid`, or `Overloaded` when the command channel is full). An empty `cids` /// slice returns an immediately-closed receiver, not an error. - pub async fn request_stream( + pub fn request_stream( &self, cids: Vec, ) -> Result, BitswapError> { @@ -119,22 +110,14 @@ impl BitswapHandle { /// Object-safe surface over [`BitswapHandle::request_stream`], allowing consumers to mock /// the bitswap client in tests. -#[async_trait] pub trait BitswapRequest: Send + Sync { /// Submit a wantlist. See [`BitswapHandle::request_stream`] for full semantics. - async fn request_stream( - &self, - cids: Vec, - ) -> Result, BitswapError>; + fn request_stream(&self, cids: Vec) -> Result, BitswapError>; } -#[async_trait] impl BitswapRequest for BitswapHandle { - async fn request_stream( - &self, - cids: Vec, - ) -> Result, BitswapError> { - BitswapHandle::request_stream(self, cids).await + fn request_stream(&self, cids: Vec) -> Result, BitswapError> { + BitswapHandle::request_stream(self, cids) } } diff --git a/substrate/client/network/bitswap/src/lib.rs b/substrate/client/network/bitswap/src/lib.rs index 5884fb1af30e..595fff4776fc 100644 --- a/substrate/client/network/bitswap/src/lib.rs +++ b/substrate/client/network/bitswap/src/lib.rs @@ -24,6 +24,7 @@ use cid::Version as CidVersion; mod handle; +mod metrics; mod service; pub use cid::Cid; diff --git a/substrate/client/network/bitswap/src/metrics.rs b/substrate/client/network/bitswap/src/metrics.rs new file mode 100644 index 000000000000..9523d2d15a14 --- /dev/null +++ b/substrate/client/network/bitswap/src/metrics.rs @@ -0,0 +1,273 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use litep2p::protocol::libp2p::bitswap::{BlockPresenceType, ResponseType}; +use prometheus_endpoint::{ + exponential_buckets, register, Counter, CounterVec, Gauge, Histogram, HistogramOpts, Opts, + PrometheusError, Registry, U64, +}; +use std::{sync::Arc, time::Duration}; + +pub(crate) mod outcomes { + pub(crate) const BLOCK_SERVED: &str = "block_served"; + pub(crate) const HAVE: &str = "have"; + pub(crate) const DONT_HAVE: &str = "dont_have"; + pub(crate) const UNSUPPORTED_CID: &str = "unsupported_cid"; +} + +pub(crate) mod errors { + pub(crate) const TOO_MANY_ENTRIES: &str = "too_many_entries"; + pub(crate) const CLIENT: &str = "client"; + pub(crate) const LOOKUP_POOL_SATURATED: &str = "lookup_pool_saturated"; +} + +pub(crate) mod outbound_events { + pub(crate) const REQUESTED: &str = "requested"; + pub(crate) const DELIVERED: &str = "delivered"; + pub(crate) const TIMED_OUT: &str = "timed_out"; + pub(crate) const ROUND_RESTARTED: &str = "round_restarted"; + pub(crate) const ABANDONED: &str = "abandoned"; + pub(crate) const OVERLOADED: &str = "overloaded"; + pub(crate) const VERIFICATION_FAILED: &str = "verification_failed"; +} + +struct Inner { + entries_total: CounterVec, + request_errors_total: CounterVec, + inbound_request_duration_seconds: Histogram, + response_bytes_total: Counter, + outbound_events_total: CounterVec, + live_cids: Gauge, + queued_cids: Gauge, + waiters: Gauge, +} + +impl Inner { + fn register(registry: &Registry) -> Result { + Ok(Self { + entries_total: register( + CounterVec::new( + Opts::new( + "substrate_sub_libp2p_bitswap_entries_total", + "Total number of bitswap wantlist entries processed, by outcome", + ), + &["outcome"], + )?, + registry, + )?, + request_errors_total: register( + CounterVec::new( + Opts::new( + "substrate_sub_libp2p_bitswap_request_errors_total", + "Total number of bitswap inbound requests rejected, by reason", + ), + &["reason"], + )?, + registry, + )?, + inbound_request_duration_seconds: register( + Histogram::with_opts(HistogramOpts { + common_opts: Opts::new( + "substrate_sub_libp2p_bitswap_inbound_request_duration_seconds", + "Duration of handling an inbound bitswap wantlist, in seconds", + ), + buckets: exponential_buckets(0.001, 2.0, 16) + .expect("parameters are valid; qed"), + })?, + registry, + )?, + response_bytes_total: register( + Counter::new( + "substrate_sub_libp2p_bitswap_response_bytes_total", + "Total payload bytes sent in bitswap responses to inbound wantlists", + )?, + registry, + )?, + outbound_events_total: register( + CounterVec::new( + Opts::new( + "substrate_sub_libp2p_bitswap_outbound_events_total", + "Total number of outbound bitswap events, by event", + ), + &["event"], + )?, + registry, + )?, + live_cids: register( + Gauge::new( + "substrate_sub_libp2p_bitswap_live_cids", + "Number of CIDs with an in-flight peer request", + )?, + registry, + )?, + queued_cids: register( + Gauge::new( + "substrate_sub_libp2p_bitswap_queued_cids", + "Number of CIDs waiting for a dispatch-window slot", + )?, + registry, + )?, + waiters: register( + Gauge::new( + "substrate_sub_libp2p_bitswap_waiters", + "Number of active outbound bitswap requests", + )?, + registry, + )?, + }) + } +} + +#[derive(Clone, Default)] +pub(crate) struct BitswapMetrics { + inner: Option>, +} + +impl BitswapMetrics { + pub(crate) fn new(registry: Option<&Registry>) -> Result { + Ok(Self { inner: registry.map(Inner::register).transpose()?.map(Arc::new) }) + } + + pub(crate) fn record_entry(&self, outcome: &str) { + if let Some(inner) = &self.inner { + inner.entries_total.with_label_values(&[outcome]).inc(); + } + } + + pub(crate) fn record_response(&self, response: &ResponseType) { + let outcome = match response { + ResponseType::Block { .. } => outcomes::BLOCK_SERVED, + ResponseType::Presence { presence: BlockPresenceType::Have, .. } => outcomes::HAVE, + ResponseType::Presence { presence: BlockPresenceType::DontHave, .. } => { + outcomes::DONT_HAVE + }, + }; + self.record_entry(outcome); + } + + pub(crate) fn record_error(&self, reason: &str) { + if let Some(inner) = &self.inner { + inner.request_errors_total.with_label_values(&[reason]).inc(); + } + } + + pub(crate) fn record_duration(&self, duration: Duration) { + if let Some(inner) = &self.inner { + inner.inbound_request_duration_seconds.observe(duration.as_secs_f64()); + } + } + + pub(crate) fn record_responses(&self, responses: &[ResponseType]) { + for response in responses { + self.record_response(response); + } + self.record_response_bytes(responses); + } + + pub(crate) fn record_response_bytes(&self, responses: &[ResponseType]) { + self.add_response_bytes(response_payload_bytes(responses)); + } + + pub(crate) fn record_outbound(&self, event: &str, count: usize) { + if let Some(inner) = &self.inner { + inner.outbound_events_total.with_label_values(&[event]).inc_by(count as u64); + } + } + + pub(crate) fn set_state(&self, live_cids: usize, queued_cids: usize, waiters: usize) { + if let Some(inner) = &self.inner { + inner.live_cids.set(live_cids as u64); + inner.queued_cids.set(queued_cids as u64); + inner.waiters.set(waiters as u64); + } + } + + fn add_response_bytes(&self, bytes: u64) { + if let Some(inner) = &self.inner { + inner.response_bytes_total.inc_by(bytes); + } + } +} + +fn response_payload_bytes(responses: &[ResponseType]) -> u64 { + responses + .iter() + .map(|response| match response { + ResponseType::Block { cid, block } => cid.to_bytes().len() + block.len(), + ResponseType::Presence { cid, .. } => cid.to_bytes().len(), + }) + .sum::() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use cid::{multihash::Multihash as CidMultihash, Cid}; + + fn make_cid() -> Cid { + let mh = CidMultihash::<64>::wrap(0xb220, &[0u8; 32]).unwrap(); + Cid::new_v1(0x55, mh) + } + + #[test] + fn disabled_metrics_are_no_ops() { + let metrics = BitswapMetrics::default(); + metrics.record_entry(outcomes::BLOCK_SERVED); + metrics.record_error(errors::CLIENT); + metrics.record_duration(Duration::from_millis(1)); + metrics.record_outbound(outbound_events::REQUESTED, 1); + metrics.set_state(1, 2, 3); + } + + #[test] + fn enabled_metrics_record_inbound_and_outbound_activity() { + let registry = Registry::new(); + let metrics = BitswapMetrics::new(Some(®istry)).unwrap(); + let cid = make_cid(); + let responses = [ + ResponseType::Block { cid, block: vec![1, 2, 3] }, + ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }, + ]; + + metrics.record_responses(&responses); + metrics.record_error(errors::TOO_MANY_ENTRIES); + metrics.record_duration(Duration::from_millis(5)); + metrics.record_outbound(outbound_events::REQUESTED, 2); + metrics.set_state(1, 2, 3); + + let inner = metrics.inner.as_ref().unwrap(); + assert_eq!(inner.entries_total.with_label_values(&[outcomes::BLOCK_SERVED]).get(), 1); + assert_eq!(inner.entries_total.with_label_values(&[outcomes::DONT_HAVE]).get(), 1); + assert_eq!( + inner.request_errors_total.with_label_values(&[errors::TOO_MANY_ENTRIES]).get(), + 1 + ); + assert_eq!( + inner + .outbound_events_total + .with_label_values(&[outbound_events::REQUESTED]) + .get(), + 2 + ); + assert_eq!(inner.live_cids.get(), 1); + assert_eq!(inner.queued_cids.get(), 2); + assert_eq!(inner.waiters.get(), 3); + assert_eq!(inner.inbound_request_duration_seconds.get_sample_count(), 1); + assert_eq!(inner.response_bytes_total.get(), response_payload_bytes(&responses)); + } +} diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index a1ecc1639366..96ee4ee53fa8 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -15,32 +15,18 @@ // You should have received a copy of the GNU General Public License // along with Substrate. If not, see . -//! Bitswap service actor. -//! -//! Owns the litep2p [`litep2p::protocol::libp2p::bitswap::BitswapHandle`] and drives both -//! the inbound (serve indexed-transaction blocks to peers) and outbound (fetch CIDs from -//! peers on behalf of [`BitswapHandle`] callers) Bitswap flows. -//! -//! Outbound requests of any size are accepted; at most [`MAX_LIVE_CIDS`] CIDs have -//! in-flight peer requests at a time, the rest queue and are dispatched as window slots -//! free up. Requests carry no service-side deadline: the caller bounds the wait by -//! dropping the receiver, which a periodic sweep detects to release the wants. -//! Unresolved CIDs are retried in rounds: once every connected peer has been tried, -//! all peers become eligible again after [`ROUND_RETRY_DELAY`]. -//! -//! Inbound wantlists are looked up on a bounded blocking-worker pool and always get an -//! answer: entries the service will not serve (pool saturated, wantlist over -//! [`MAX_WANTED_BLOCKS`] entries, unsupported CID) receive `DONT_HAVE` instead of silence. -//! -//! Peer connect/disconnect tracking comes from `sc-network-sync`'s [`SyncEventStream`], -//! which reports already-connected peers on subscription. Light-client peers are not -//! tracked: they do not hold the indexed-transaction data served over bitswap. +//! Actor that serves inbound wantlists and resolves outbound CID requests over litep2p. use super::{ is_cid_supported, BitswapCommand, BitswapHandle, Cid, FetchItem, BLAKE2B_256_MULTIHASH_CODE, KECCAK_256_MULTIHASH_CODE, LOG_TARGET, MAX_WANTED_BLOCKS, SHA2_256_MULTIHASH_CODE, }; -use crate::handle::BitswapError; +use crate::{ + handle::BitswapError, + metrics::{ + errors as metric_errors, outbound_events, outcomes as metric_outcomes, BitswapMetrics, + }, +}; use async_trait::async_trait; use cid::multihash::Multihash as CidMultihash; @@ -48,6 +34,8 @@ use futures::{Stream, StreamExt}; use litep2p::protocol::libp2p::bitswap::{ BitswapEvent, BitswapHandle as LitepBitswapHandle, BlockPresenceType, ResponseType, WantType, }; +use prometheus_endpoint::Registry; +use rand::{seq::IteratorRandom, Rng}; use sc_client_api::BlockBackend; use sc_network_common::role::Roles; use sc_network_sync::{SyncEvent, SyncEventStream}; @@ -149,13 +137,17 @@ struct WantSet { pending: VecDeque, /// Number of CIDs with at least one in-flight peer request. live: usize, + /// Number of CIDs with [`CidState::pending`] set, maintained incrementally: the + /// metrics path reads it after every actor event, so recomputing it by scanning + /// `inner` would make large requests quadratic. + queued: usize, /// Dispatch-window size. max_live: usize, } impl WantSet { fn new(max_live: usize) -> Self { - Self { inner: HashMap::new(), pending: VecDeque::new(), live: 0, max_live } + Self { inner: HashMap::new(), pending: VecDeque::new(), live: 0, queued: 0, max_live } } fn contains(&self, cid: &Cid) -> bool { @@ -166,6 +158,10 @@ impl WantSet { self.inner.get(cid).map_or(0, |state| state.waiters.len()) } + fn queued_count(&self) -> usize { + self.queued + } + fn add_waiter(&mut self, cid: Cid, waiter: WaiterId) { self.inner.entry(cid).or_insert_with(CidState::new).waiters.push(waiter); } @@ -186,6 +182,9 @@ impl WantSet { if !state.in_flight_peers.is_empty() { self.live -= 1; } + if state.pending { + self.queued -= 1; + } state.waiters }) } @@ -200,6 +199,7 @@ impl WantSet { if let Some(state) = self.inner.get_mut(&cid) { if state.pending { state.pending = false; + self.queued -= 1; return Some(cid); } } @@ -207,11 +207,12 @@ impl WantSet { None } - fn next_peer_to_request( + fn next_peer_to_request( &mut self, cid: Cid, connected_peers: &HashSet, now: Instant, + rng: &mut R, ) -> Option { let state = self.inner.get(&cid)?; if !state.has_waiters() || state.in_flight_peers.len() >= PEER_FANOUT_CAP { @@ -224,6 +225,7 @@ impl WantSet { if !state.pending { state.pending = true; self.pending.push_back(cid); + self.queued += 1; } return None; } @@ -235,8 +237,9 @@ impl WantSet { let Some(peer) = state .have_peers .iter() - .find(|peer| connected_peers.contains(*peer) && eligible(peer)) - .or_else(|| connected_peers.iter().find(|peer| eligible(peer))) + .filter(|peer| connected_peers.contains(*peer) && eligible(peer)) + .choose(&mut *rng) + .or_else(|| connected_peers.iter().filter(|peer| eligible(peer)).choose(&mut *rng)) .copied() else { // Round exhausted: every connected peer has been tried. Schedule a new round, @@ -259,6 +262,9 @@ impl WantSet { self.live += 1; } state.in_flight_peers.insert(peer, now + PER_PEER_TIMEOUT); + if state.pending { + self.queued -= 1; + } state.pending = false; state.next_round_at = None; @@ -307,7 +313,10 @@ impl WantSet { self.remove_idle_and_filter_existing(affected) } - fn expire_peer_timeouts(&mut self, now: Instant) -> Vec { + /// Expire in-flight requests whose per-peer deadline passed. Returns the affected CIDs + /// still wanted (for re-dispatch) and the total number of timed-out peer requests + /// (for metrics; also counts requests whose CID entry was removed as idle). + fn expire_peer_timeouts(&mut self, now: Instant) -> (Vec, usize) { let mut timed_out: Vec<(Cid, litep2p::PeerId)> = Vec::new(); for (cid, state) in self.inner.iter_mut() { let had_in_flight = !state.in_flight_peers.is_empty(); @@ -323,6 +332,7 @@ impl WantSet { self.live -= 1; } } + let timed_out_count = timed_out.len(); let mut cids = Vec::with_capacity(timed_out.len()); for (cid, peer) in timed_out { @@ -332,7 +342,7 @@ impl WantSet { } } - self.remove_idle_and_filter_existing(cids) + (self.remove_idle_and_filter_existing(cids), timed_out_count) } /// Start a new round for CIDs whose retry delay has passed: clear per-round peer state @@ -357,6 +367,7 @@ impl WantSet { self.inner.clear(); self.pending.clear(); self.live = 0; + self.queued = 0; } fn remove_idle_and_filter_existing(&mut self, cids: Vec) -> Vec { @@ -368,7 +379,10 @@ impl WantSet { fn remove_if_idle(&mut self, cid: Cid) { if self.inner.get(&cid).is_some_and(CidState::is_idle) { - self.inner.remove(&cid); + let state = self.inner.remove(&cid).expect("just checked; qed"); + if state.pending { + self.queued -= 1; + } } } } @@ -384,15 +398,20 @@ struct InboundLookupPool { client: Arc + Send + Sync>, result_tx: mpsc::Sender, semaphore: Arc, + metrics: BitswapMetrics, } impl InboundLookupPool { fn new( client: Arc + Send + Sync>, max_lookups: usize, + metrics: BitswapMetrics, ) -> (Self, mpsc::Receiver) { let (result_tx, result_rx) = mpsc::channel(LOOKUP_CHANNEL_CAPACITY); - (Self { client, result_tx, semaphore: Arc::new(Semaphore::new(max_lookups)) }, result_rx) + ( + Self { client, result_tx, semaphore: Arc::new(Semaphore::new(max_lookups)), metrics }, + result_rx, + ) } /// Serve the wantlist on a blocking worker. Returns the wantlist back if the pool is @@ -408,9 +427,10 @@ impl InboundLookupPool { let client = self.client.clone(); let result_tx = self.result_tx.clone(); + let metrics = self.metrics.clone(); tokio::task::spawn_blocking(move || { let _permit = permit; - let responses = serve_inbound(&*client, cids); + let responses = serve_inbound(&*client, cids, &metrics); // Blocking worker: wait for a channel slot rather than discarding the computed // responses when the actor is momentarily behind on draining results. let _ = result_tx.blocking_send((peer, responses)); @@ -434,6 +454,7 @@ pub(crate) struct BitswapService { connected_peers: HashSet, wants: WantSet, waiters: SlotMap, + metrics: BitswapMetrics, } /// Build the Bitswap service, returning the service future and the user-facing handle. @@ -445,13 +466,18 @@ pub fn start( client: Arc + Send + Sync>, sync: &S, litep2p_handle: LitepBitswapHandle, + metrics_registry: Option<&Registry>, ) -> (Pin + Send>>, BitswapHandle) where S: SyncEventStream + ?Sized, { let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); + let metrics = BitswapMetrics::new(metrics_registry).unwrap_or_else(|err| { + log::debug!(target: LOG_TARGET, "failed to register bitswap metrics: {err}"); + BitswapMetrics::default() + }); let (inbound_lookup_pool, inbound_lookup_rx) = - InboundLookupPool::new(client, MAX_CONCURRENT_INBOUND_LOOKUPS); + InboundLookupPool::new(client, MAX_CONCURRENT_INBOUND_LOOKUPS, metrics.clone()); let user_handle = BitswapHandle::new(cmd_tx); let sync_event_stream = sync.event_stream("bitswap"); @@ -466,6 +492,7 @@ where connected_peers: HashSet::new(), wants: WantSet::new(MAX_LIVE_CIDS), waiters: SlotMap::with_key(), + metrics, }; let future = Box::pin(async move { service.run().await }); @@ -479,6 +506,7 @@ impl BitswapService { let mut sweep_ticker = tokio::time::interval(SWEEP_INTERVAL); sweep_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); sweep_ticker.tick().await; + self.update_metrics(); loop { tokio::select! { @@ -528,12 +556,14 @@ impl BitswapService { self.on_sweep().await; }, } + self.update_metrics(); } } async fn on_request_stream(&mut self, cids: Vec, sink: mpsc::Sender) { for cid in &cids { if self.wants.waiter_count(cid) >= MAX_WAITERS_PER_CID { + self.metrics.record_outbound(outbound_events::OVERLOADED, 1); let _ = sink.try_send(Err(BitswapError::Overloaded)); return; } @@ -554,23 +584,32 @@ impl BitswapService { /// wantlist messages of up to [`MAX_WANTED_BLOCKS`] entries. async fn top_up_in_flight(&mut self, cids: impl IntoIterator) { let now = Instant::now(); - let mut by_peer: HashMap> = HashMap::new(); - for cid in cids { - if let Some(peer) = self.wants.next_peer_to_request(cid, &self.connected_peers, now) { - log::trace!(target: LOG_TARGET, "WANT-BLOCK {cid} -> {peer:?}"); - by_peer.entry(peer).or_default().push((cid, WantType::Block)); + let by_peer = { + let mut rng = rand::thread_rng(); + let mut by_peer: HashMap> = HashMap::new(); + for cid in cids { + if let Some(peer) = + self.wants.next_peer_to_request(cid, &self.connected_peers, now, &mut rng) + { + log::trace!(target: LOG_TARGET, "WANT-BLOCK {cid} -> {peer:?}"); + by_peer.entry(peer).or_default().push((cid, WantType::Block)); + } } - } - while self.wants.has_window_capacity() { - let Some(cid) = self.wants.pop_pending() else { break }; - if let Some(peer) = self.wants.next_peer_to_request(cid, &self.connected_peers, now) { - log::trace!(target: LOG_TARGET, "WANT-BLOCK {cid} -> {peer:?} (promoted)"); - by_peer.entry(peer).or_default().push((cid, WantType::Block)); + while self.wants.has_window_capacity() { + let Some(cid) = self.wants.pop_pending() else { break }; + if let Some(peer) = + self.wants.next_peer_to_request(cid, &self.connected_peers, now, &mut rng) + { + log::trace!(target: LOG_TARGET, "WANT-BLOCK {cid} -> {peer:?} (promoted)"); + by_peer.entry(peer).or_default().push((cid, WantType::Block)); + } } - } + by_peer + }; for (peer, wants) in by_peer { + self.metrics.record_outbound(outbound_events::REQUESTED, wants.len()); for chunk in wants.chunks(MAX_WANTED_BLOCKS) { self.handle.send_request(peer, chunk.to_vec()).await; } @@ -586,6 +625,7 @@ impl BitswapService { self.wants.mark_peer_done_for_cid(peer, claimed_cid); if recompute_cid(&claimed_cid, &block) != Some(claimed_cid) { + self.metrics.record_outbound(outbound_events::VERIFICATION_FAILED, 1); log::debug!( target: LOG_TARGET, "{peer:?} sent block for {claimed_cid} that failed CID verification", @@ -621,6 +661,7 @@ impl BitswapService { fn deliver_block(&mut self, cid: Cid, bytes: Vec) { let Some(waiter_ids) = self.wants.take_waiters_for_delivered_cid(cid) else { return }; + self.metrics.record_outbound(outbound_events::DELIVERED, 1); for waiter_id in waiter_ids { let Some(waiter) = self.waiters.get_mut(waiter_id) else { continue }; @@ -671,14 +712,18 @@ impl BitswapService { .iter() .filter_map(|(id, waiter)| waiter.sink.is_closed().then_some(id)) .collect(); + self.metrics.record_outbound(outbound_events::ABANDONED, abandoned.len()); for id in abandoned { log::trace!(target: LOG_TARGET, "dropping abandoned waiter {id:?}"); self.drop_waiter(id); } let now = Instant::now(); - let mut cids = self.wants.expire_peer_timeouts(now); - cids.extend(self.wants.restart_exhausted_rounds(now)); + let (mut cids, timed_out) = self.wants.expire_peer_timeouts(now); + self.metrics.record_outbound(outbound_events::TIMED_OUT, timed_out); + let restarted = self.wants.restart_exhausted_rounds(now); + self.metrics.record_outbound(outbound_events::ROUND_RESTARTED, restarted.len()); + cids.extend(restarted); self.top_up_in_flight(cids).await; } @@ -688,12 +733,14 @@ impl BitswapService { /// wantlists refused by a saturated lookup pool are answered with `DONT_HAVE`. async fn on_inbound_request(&mut self, peer: litep2p::PeerId, mut cids: Vec<(Cid, WantType)>) { let mut refused = if cids.len() > MAX_WANTED_BLOCKS { + self.metrics.record_error(metric_errors::TOO_MANY_ENTRIES); cids.split_off(MAX_WANTED_BLOCKS) } else { Vec::new() }; if let Err(cids) = self.inbound_lookup_pool.try_submit(peer, cids) { + self.metrics.record_error(metric_errors::LOOKUP_POOL_SATURATED); log::trace!( target: LOG_TARGET, "inbound serving pool saturated; answering DONT_HAVE to {peer:?}", @@ -702,22 +749,29 @@ impl BitswapService { } if !refused.is_empty() { - let responses = refused + let responses: Vec<_> = refused .into_iter() .map(|(cid, _)| ResponseType::Presence { cid, presence: BlockPresenceType::DontHave, }) .collect(); + self.metrics.record_responses(&responses); self.handle.send_response(peer, responses).await; } } + fn update_metrics(&self) { + self.metrics + .set_state(self.wants.live, self.wants.queued_count(), self.waiters.len()); + } + fn shutdown_waiters(&mut self) { for (_, waiter) in self.waiters.drain() { let _ = waiter.sink.try_send(Err(BitswapError::ServiceClosed)); } self.wants.clear(); + self.update_metrics(); } } @@ -742,35 +796,46 @@ fn hash_for_multihash_code(multihash_code: u64, data: &[u8]) -> Option<[u8; 32]> fn serve_inbound( client: &(dyn BlockBackend + Send + Sync), cids: Vec<(Cid, WantType)>, + metrics: &BitswapMetrics, ) -> Vec { - cids.into_iter() + let started = std::time::Instant::now(); + let responses = cids + .into_iter() .map(|(cid, want_type)| { if !is_cid_supported(&cid) { + metrics.record_entry(metric_outcomes::UNSUPPORTED_CID); return ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }; } let hash = H256::from_slice(&cid.hash().digest()[0..32]); let transaction = match client.indexed_transaction(hash) { Ok(t) => t, Err(e) => { + metrics.record_error(metric_errors::CLIENT); log::error!(target: LOG_TARGET, "indexed_transaction({hash}) failed: {e}"); None }, }; - match (transaction, want_type) { + let response = match (transaction, want_type) { (Some(transaction), WantType::Block) => { ResponseType::Block { cid, block: transaction } }, (Some(_), _) => ResponseType::Presence { cid, presence: BlockPresenceType::Have }, (None, _) => ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }, - } + }; + metrics.record_response(&response); + response }) - .collect() + .collect::>(); + metrics.record_response_bytes(&responses); + metrics.record_duration(started.elapsed()); + responses } #[cfg(test)] mod tests { use super::*; use crate::RAW_CODEC; + use rand::{rngs::StdRng, SeedableRng}; use rstest::rstest; use sc_block_builder::BlockBuilderBuilder; use sc_network_sync::SyncEvent; @@ -831,7 +896,9 @@ mod tests { let (outbound_resp_tx, outbound_resp_rx) = mpsc::channel(64); let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); let (sync_event_tx, sync_event_rx) = mpsc::channel::(64); - let (inbound_lookup_pool, inbound_lookup_rx) = InboundLookupPool::new(client, max_lookups); + let metrics = BitswapMetrics::default(); + let (inbound_lookup_pool, inbound_lookup_rx) = + InboundLookupPool::new(client, max_lookups, metrics.clone()); let transport = MockTransport { inbound: AsyncMutex::new(inbound_rx), @@ -852,6 +919,7 @@ mod tests { connected_peers: HashSet::new(), wants: WantSet::new(max_live_cids), waiters: SlotMap::with_key(), + metrics, }; let user_handle = BitswapHandle::new(cmd_tx); @@ -963,10 +1031,12 @@ mod tests { let mut waiter_ids = SlotMap::with_key(); let waiter_id = waiter_ids.insert(()); let mut wants = WantSet::new(MAX_LIVE_CIDS); + let mut rng = StdRng::seed_from_u64(0); wants.add_waiter(cid, waiter_id); - let selected = - wants.next_peer_to_request(cid, &HashSet::from([peer]), Instant::now()).unwrap(); + let selected = wants + .next_peer_to_request(cid, &HashSet::from([peer]), Instant::now(), &mut rng) + .unwrap(); assert_eq!(selected, peer); wants.remove_waiter(cid, waiter_id); @@ -985,23 +1055,46 @@ mod tests { let mut waiter_ids = SlotMap::with_key(); let waiter_id = waiter_ids.insert(()); let mut wants = WantSet::new(1); + let mut rng = StdRng::seed_from_u64(0); wants.add_waiter(cid_a, waiter_id); wants.add_waiter(cid_b, waiter_id); - assert_eq!(wants.next_peer_to_request(cid_a, &peers, Instant::now()), Some(peer)); + assert_eq!(wants.next_peer_to_request(cid_a, &peers, Instant::now(), &mut rng), Some(peer)); // Window (size 1) is full: `cid_b` must queue instead of dispatching. - assert_eq!(wants.next_peer_to_request(cid_b, &peers, Instant::now()), None); + assert_eq!(wants.next_peer_to_request(cid_b, &peers, Instant::now(), &mut rng), None); assert!(!wants.has_window_capacity()); // Delivering `cid_a` frees the slot; `cid_b` is promoted. wants.take_waiters_for_delivered_cid(cid_a); assert!(wants.has_window_capacity()); assert_eq!(wants.pop_pending(), Some(cid_b)); - assert_eq!(wants.next_peer_to_request(cid_b, &peers, Instant::now()), Some(peer)); + assert_eq!(wants.next_peer_to_request(cid_b, &peers, Instant::now(), &mut rng), Some(peer)); assert_eq!(wants.pop_pending(), None); } + #[test] + fn peer_selection_varies_across_cids() { + let peers: HashSet<_> = (0..3).map(|_| litep2p::PeerId::random()).collect(); + let mut waiter_ids = SlotMap::with_key(); + let mut wants = WantSet::new(32); + let mut rng = StdRng::seed_from_u64(0); + let mut selected = HashSet::new(); + + for byte in 0..32 { + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [byte; 32]); + let waiter = waiter_ids.insert(()); + wants.add_waiter(cid, waiter); + selected.insert( + wants + .next_peer_to_request(cid, &peers, Instant::now(), &mut rng) + .expect("a connected peer is eligible"), + ); + } + + assert!(selected.len() > 1, "fresh CIDs should not all select the same peer"); + } + #[tokio::test(start_paused = true)] async fn single_cid_single_peer_block_response() { let mut rig = empty_rig(); @@ -1010,7 +1103,7 @@ mod tests { let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); rig.connect(peer).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); @@ -1029,7 +1122,7 @@ mod tests { let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); rig.connect(peer).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); assert_eq!(out_peer, peer); @@ -1055,7 +1148,7 @@ mod tests { rig.connect(peer_a).await; rig.connect(peer_b).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); let (first, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); let other = if first == peer_a { peer_b } else { peer_a }; @@ -1090,7 +1183,7 @@ mod tests { let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); rig.connect(peer).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); if answer_dont_have { @@ -1123,7 +1216,7 @@ mod tests { let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); rig.connect(peer_a).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); rig.send_presence(peer_a, cid, BlockPresenceType::DontHave).await; @@ -1150,7 +1243,7 @@ mod tests { let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [9u8; 32]); rig.sync_event_tx.send(sync_connected_light(light_peer)).await.unwrap(); - let _rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let _rx = rig.user_handle.request_stream(vec![cid]).unwrap(); // The only connected peer is a light client: no WANT must be dispatched. assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); @@ -1175,7 +1268,7 @@ mod tests { let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, b"the-real-payload"); rig.connect(peer).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); @@ -1213,7 +1306,7 @@ mod tests { rig.connect(peer_a).await; rig.connect(peer_b).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); match trigger { @@ -1242,7 +1335,7 @@ mod tests { let peer = litep2p::PeerId::random(); let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [1u8; 32]); - let rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let rx = rig.user_handle.request_stream(vec![cid]).unwrap(); // No peers connected: nothing is dispatched, the want just sits there. assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); @@ -1265,8 +1358,8 @@ mod tests { rig.connect(peer).await; - let mut rx_a = rig.user_handle.request_stream(vec![cid]).await.unwrap(); - let mut rx_b = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rx_a = rig.user_handle.request_stream(vec![cid]).unwrap(); + let mut rx_b = rig.user_handle.request_stream(vec![cid]).unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); @@ -1284,8 +1377,8 @@ mod tests { rig.connect(peer).await; - let rx_a = rig.user_handle.request_stream(vec![cid]).await.unwrap(); - let mut rx_b = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let rx_a = rig.user_handle.request_stream(vec![cid]).unwrap(); + let mut rx_b = rig.user_handle.request_stream(vec![cid]).unwrap(); drop(rx_a); sleep(Duration::from_millis(1)).await; @@ -1306,7 +1399,7 @@ mod tests { let cids: Vec = payloads.iter().map(|p| cid_for_data(BLAKE2B_256_MULTIHASH_CODE, p)).collect(); - let mut rx = rig.user_handle.request_stream(cids.clone()).await.unwrap(); + let mut rx = rig.user_handle.request_stream(cids.clone()).unwrap(); // Only the window (4 CIDs) is dispatched; the fifth is queued. let (_, entries) = drain_next(&mut rig.outbound_req_rx).await.expect("first bundle"); @@ -1507,7 +1600,6 @@ mod tests { drop(rig.user_handle); sleep(Duration::from_millis(1)).await; - // `rig.user_handle` is moved out, so the `&self` helper is unusable here. rig.inbound_tx .send(BitswapEvent::Request { peer, cids: vec![(cid, WantType::Block)] }) .await @@ -1534,7 +1626,7 @@ mod tests { }) .collect(); - let _rx = rig.user_handle.request_stream(cids.clone()).await.unwrap(); + let _rx = rig.user_handle.request_stream(cids.clone()).unwrap(); let (peer_a, first) = drain_next(&mut rig.outbound_req_rx).await.expect("first bundle"); let (peer_b, second) = drain_next(&mut rig.outbound_req_rx).await.expect("second bundle"); @@ -1557,14 +1649,14 @@ mod tests { CidMultihash::<64>::wrap(0x99 /* unsupported */, &[0u8; 32]).unwrap(), ); - let err = rig.user_handle.request_stream(vec![bad]).await.err().expect("err"); + let err = rig.user_handle.request_stream(vec![bad]).err().expect("err"); assert!(matches!(err, BitswapError::InvalidCid { .. })); } #[tokio::test] async fn admission_empty_returns_closed_receiver() { let rig = empty_rig(); - let mut rx = rig.user_handle.request_stream(vec![]).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![]).unwrap(); assert!(rx.recv().await.is_none()); } @@ -1575,7 +1667,7 @@ mod tests { let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xee; 32]); rig.connect(peer).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await; drop(rig.inbound_tx); @@ -1592,7 +1684,7 @@ mod tests { let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); rig.connect(peer).await; - let rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let rx = rig.user_handle.request_stream(vec![cid]).unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); // Caller gives up; the sweep drops the waiter while the peer request is still @@ -1607,7 +1699,7 @@ mod tests { // A fresh request for the same CID starts from a clean slate: the peer is asked // again and the block is delivered. - let mut rx = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); let _ = drain_next(&mut rig.outbound_req_rx).await.expect("fresh WANT"); rig.send_block(peer, cid, &data).await; expect_block(&mut rx, cid, &data).await; @@ -1620,12 +1712,12 @@ mod tests { let mut receivers = Vec::new(); for _ in 0..MAX_WAITERS_PER_CID { - receivers.push(rig.user_handle.request_stream(vec![cid]).await.unwrap()); + receivers.push(rig.user_handle.request_stream(vec![cid]).unwrap()); } // Give the actor a chance to admit all waiters before the one-too-many request. sleep(Duration::from_millis(10)).await; - let mut rejected = rig.user_handle.request_stream(vec![cid]).await.unwrap(); + let mut rejected = rig.user_handle.request_stream(vec![cid]).unwrap(); let item = drain_next(&mut rejected).await.expect("item"); assert!(matches!(item, Err(BitswapError::Overloaded))); } @@ -1636,28 +1728,22 @@ mod proptests { use super::*; use crate::RAW_CODEC; use proptest::prelude::*; + use rand::{rngs::StdRng, SeedableRng}; const NUM_CIDS: u8 = 8; const NUM_PEERS: usize = 4; /// Small dispatch window so op sequences regularly hit the queueing path. const SMALL_WINDOW: usize = 3; - /// One step of the actor's observable behavior against [`WantSet`]. #[derive(Debug, Clone)] enum Op { - /// `on_request_stream`: admit a waiter for a CID. AddWaiter(u8), - /// Sweep of an abandoned waiter: remove one active waiter. RemoveWaiter(usize), - /// `deliver_block`: a verified block arrived. Deliver(u8), - /// Block/DONT_HAVE outcome (possibly unsolicited) from a peer. MarkPeerDone(usize, u8), - /// HAVE outcome from a peer. NoteHave(usize, u8), Connect(usize), Disconnect(usize), - /// Advance time and run the sweep (per-peer timeouts + round restarts). Sweep(u64), } @@ -1683,6 +1769,7 @@ mod proptests { cids: Vec, connected: HashSet, now: Instant, + rng: StdRng, } impl Harness { @@ -1701,6 +1788,7 @@ mod proptests { cids, connected: HashSet::new(), now: Instant::now(), + rng: StdRng::seed_from_u64(0), } } @@ -1752,11 +1840,13 @@ mod proptests { /// re-dispatches everything dispatchable. fn top_up(&mut self) { for cid in self.wants.all_cids() { - let _ = self.wants.next_peer_to_request(cid, &self.connected, self.now); + let _ = + self.wants.next_peer_to_request(cid, &self.connected, self.now, &mut self.rng); } while self.wants.has_window_capacity() { let Some(cid) = self.wants.pop_pending() else { break }; - let _ = self.wants.next_peer_to_request(cid, &self.connected, self.now); + let _ = + self.wants.next_peer_to_request(cid, &self.connected, self.now, &mut self.rng); } } @@ -1764,6 +1854,8 @@ mod proptests { let live = self.wants.inner.values().filter(|s| !s.in_flight_peers.is_empty()).count(); assert_eq!(self.wants.live, live, "live counter drifted"); assert!(self.wants.live <= self.wants.max_live, "dispatch window overrun"); + let queued = self.wants.inner.values().filter(|s| s.pending).count(); + assert_eq!(self.wants.queued, queued, "queued counter drifted"); for (cid, state) in &self.wants.inner { assert!(state.in_flight_peers.len() <= PEER_FANOUT_CAP, "fanout cap exceeded"); diff --git a/substrate/client/service/src/builder.rs b/substrate/client/service/src/builder.rs index daf45442e0ce..c1c7f29f95cc 100644 --- a/substrate/client/service/src/builder.rs +++ b/substrate/client/service/src/builder.rs @@ -95,6 +95,8 @@ use sp_keystore::KeystorePtr; use sp_runtime::traits::{Block as BlockT, BlockIdTo, NumberFor, Zero}; use sp_storage::{ChildInfo, ChildType, PrefixedStorageKey}; use std::{ + future::Future, + pin::Pin, str::FromStr, sync::Arc, time::{Duration, SystemTime}, @@ -1175,6 +1177,12 @@ where pub blocks_pruning: BlocksPruning, } +struct BitswapInitialization { + handler: Option + Send>>>, + handle: Option, + config: Option, +} + /// Build the network service, the network status sinks and an RPC sender, this is a lower-level /// version of [`build_network`] for those needing more control. /// @@ -1241,38 +1249,42 @@ where net_config.add_request_response_protocol(light_client_request_protocol_config); // Initialize the IPFS server. - let (bitswap_handler, bitswap_user_handle, ipfs_config) = - if net_config.network_config.ipfs_server { - if !Net::SUPPORTS_IPFS { - return Err(Error::Other( - "the selected network backend does not support Bitswap; \ + let bitswap = if net_config.network_config.ipfs_server { + if !Net::SUPPORTS_IPFS { + return Err(Error::Other( + "the selected network backend does not support Bitswap; \ set --network-backend litep2p or disable --ipfs-server" - .into(), - )); - } + .into(), + )); + } - let ipfs_num_blocks = match blocks_pruning { - BlocksPruning::KeepAll | BlocksPruning::KeepFinalized => IPFS_MAX_BLOCKS, - BlocksPruning::Some(num) => std::cmp::min(num, IPFS_MAX_BLOCKS), - }; + let ipfs_num_blocks = match blocks_pruning { + BlocksPruning::KeepAll | BlocksPruning::KeepFinalized => IPFS_MAX_BLOCKS, + BlocksPruning::Some(num) => std::cmp::min(num, IPFS_MAX_BLOCKS), + }; - // The bitswap service owns the transport handle; the config is installed by - // the litep2p backend during `Net::new`. - let (ipfs_config, litep2p_bitswap_handle) = IpfsConfig::new( - Box::new(IpfsIndexedTransactions::new(client.clone(), ipfs_num_blocks)), - net_config.network_config.ipfs_bootnodes.clone(), - ); + // The bitswap service owns the transport handle; the config is installed by + // the litep2p backend during `Net::new`. + let (ipfs_config, litep2p_bitswap_handle) = IpfsConfig::new( + Box::new(IpfsIndexedTransactions::new(client.clone(), ipfs_num_blocks)), + net_config.network_config.ipfs_bootnodes.clone(), + ); - let (handler, bitswap_user_handle) = sc_network_bitswap::start::( - client.clone(), - &*sync_service, - litep2p_bitswap_handle, - ); + let (handler, bitswap_user_handle) = sc_network_bitswap::start::( + client.clone(), + &*sync_service, + litep2p_bitswap_handle, + metrics_registry, + ); - (Some(handler), Some(bitswap_user_handle), Some(ipfs_config)) - } else { - (None, None, None) - }; + BitswapInitialization { + handler: Some(handler), + handle: Some(bitswap_user_handle), + config: Some(ipfs_config), + } + } else { + BitswapInitialization { handler: None, handle: None, config: None } + }; // Create transactions protocol and add it to the list of supported protocols of let (transactions_handler_proto, transactions_config) = @@ -1303,7 +1315,7 @@ where fork_id: fork_id.map(ToOwned::to_owned), metrics_registry: metrics_registry.cloned(), block_announce_config, - ipfs_config, + ipfs_config: bitswap.config, notification_metrics: metrics, }; @@ -1312,9 +1324,11 @@ where let network = network_mut.network_service().clone(); // Spawn the bitswap actor only after `Net::new` succeeded so a network construction - // failure does not leave an orphan task running. - if let Some(handler) = bitswap_handler { - spawn_handle.spawn("bitswap-service", Some("networking"), handler); + // failure does not leave an orphan task running. Essential: on storage chains block + // import depends on the actor for indexed-transaction fetches, so if it dies the node + // must shut down instead of stalling sync silently. + if let Some(handler) = bitswap.handler { + spawn_essential_handle.spawn("bitswap-service", Some("networking"), handler); } let (tx_handler, tx_handler_controller) = transactions_handler_proto.build( @@ -1373,7 +1387,7 @@ where // the service will shut down. spawn_essential_handle.spawn_blocking("network-worker", Some("networking"), future); - Ok((network, system_rpc_tx, tx_handler_controller, sync_service.clone(), bitswap_user_handle)) + Ok((network, system_rpc_tx, tx_handler_controller, sync_service.clone(), bitswap.handle)) } /// Configuration for [`build_default_syncing_engine`]. diff --git a/substrate/client/storage-chain-sync/src/fetcher.rs b/substrate/client/storage-chain-sync/src/fetcher.rs index c93abbd1c51b..5f23d85b46f2 100644 --- a/substrate/client/storage-chain-sync/src/fetcher.rs +++ b/substrate/client/storage-chain-sync/src/fetcher.rs @@ -16,18 +16,11 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! Bitswap-based fetcher for indexed-transaction blobs. -//! -//! Thin adapter over [`sc_network_bitswap::BitswapHandle`]: builds the per-want CIDs, -//! submits them and collects the outcomes under a size-scaled time budget. Peer -//! selection, retries and hash verification live in the bitswap service; the fetch -//! deadline lives here, since the service retries indefinitely and the caller owns the -//! time budget. +//! Fetches indexed-transaction blobs through the shared Bitswap service. use crate::RenewWant; use cid::{multihash::Multihash, Cid}; use sc_network_bitswap::{BitswapError, BitswapRequest}; -use sp_runtime::traits::Block as BlockT; use sp_transaction_storage_proof::ContentHash; use std::{ collections::HashMap, @@ -72,26 +65,15 @@ pub enum FetchError { } /// Fetcher that resolves indexed-transaction hashes via bitswap. -/// -/// Holds the late-bound [`BitswapRequest`] slot. The block-import path holds one -/// of these and calls [`Self::fetch_many`] for each batch of missing renew hashes. -/// -/// Cloning is cheap: the only field is an `Arc`. -pub struct IndexedTransactionFetcher { +#[derive(Clone)] +pub struct IndexedTransactionFetcher { bitswap: BitswapHandleSlot, - _phantom: std::marker::PhantomData, -} - -impl Clone for IndexedTransactionFetcher { - fn clone(&self) -> Self { - Self { bitswap: self.bitswap.clone(), _phantom: std::marker::PhantomData } - } } -impl IndexedTransactionFetcher { +impl IndexedTransactionFetcher { /// Build a new fetcher backed by the given late-bound bitswap handle slot. pub fn new(bitswap: BitswapHandleSlot) -> Self { - Self { bitswap, _phantom: std::marker::PhantomData } + Self { bitswap } } /// Resolve a batch of indexed-transaction hashes via bitswap. Each want carries the @@ -117,7 +99,7 @@ impl IndexedTransactionFetcher { cids.push(cid); } - let mut rx = match handle.request_stream(cids).await { + let mut rx = match handle.request_stream(cids) { Ok(rx) => rx, // Transient congestion: degrade to an empty partial result instead of failing // the import. diff --git a/substrate/client/storage-chain-sync/src/lib.rs b/substrate/client/storage-chain-sync/src/lib.rs index 74bdd0e4fdfe..d40503f8873d 100644 --- a/substrate/client/storage-chain-sync/src/lib.rs +++ b/substrate/client/storage-chain-sync/src/lib.rs @@ -118,7 +118,7 @@ pub(crate) struct RenewWant { pub struct StorageChainBlockImport { inner: Inner, client: Arc, - fetcher: IndexedTransactionFetcher, + fetcher: IndexedTransactionFetcher, _phantom: PhantomData, } @@ -134,11 +134,7 @@ impl Clone for StorageChainBlockImport StorageChainBlockImport { - pub fn new( - inner: Inner, - client: Arc, - fetcher: IndexedTransactionFetcher, - ) -> Self { + pub fn new(inner: Inner, client: Arc, fetcher: IndexedTransactionFetcher) -> Self { Self { inner, client, fetcher, _phantom: PhantomData } } } diff --git a/substrate/client/storage-chain-sync/tests/it.rs b/substrate/client/storage-chain-sync/tests/it.rs index b6d3af7f48be..7fd742c4716c 100644 --- a/substrate/client/storage-chain-sync/tests/it.rs +++ b/substrate/client/storage-chain-sync/tests/it.rs @@ -833,9 +833,8 @@ mod mock { } } - #[async_trait] impl BitswapRequest for MockBitswap { - async fn request_stream( + fn request_stream( &self, cids: Vec, ) -> Result, BitswapError> { @@ -1133,8 +1132,7 @@ mod mock { let inner = TestInner::recording(); let captured = inner.captured.clone(); - let fetcher = - IndexedTransactionFetcher::::new(populated_bitswap_slot(network.clone())); + let fetcher = IndexedTransactionFetcher::new(populated_bitswap_slot(network.clone())); let wrapper = StorageChainBlockImport::new(inner, api.clone(), fetcher); Harness { wrapper, api, captured, network } @@ -1155,7 +1153,7 @@ mod mock { let inner = TestInner::recording(); let captured = inner.captured.clone(); - let fetcher = IndexedTransactionFetcher::::new(populated_bitswap_slot(network)); + let fetcher = IndexedTransactionFetcher::new(populated_bitswap_slot(network)); let wrapper = StorageChainBlockImport::new(inner, api.clone(), fetcher); BlockExecutionHarness { wrapper, api, captured, content_hash } From 27519d4dc03a69b4ac8699756617bac9da8ecfb2 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Fri, 17 Jul 2026 21:17:29 +0200 Subject: [PATCH 18/32] Add inbound queue for server --- substrate/client/network/bitswap/src/lib.rs | 4 +- .../client/network/bitswap/src/metrics.rs | 18 +- .../client/network/bitswap/src/service.rs | 326 ++++++++++++++---- 3 files changed, 272 insertions(+), 76 deletions(-) diff --git a/substrate/client/network/bitswap/src/lib.rs b/substrate/client/network/bitswap/src/lib.rs index 595fff4776fc..55fbe3f38b6d 100644 --- a/substrate/client/network/bitswap/src/lib.rs +++ b/substrate/client/network/bitswap/src/lib.rs @@ -34,8 +34,8 @@ pub use service::start; pub(crate) const LOG_TARGET: &str = "sub-libp2p::bitswap"; -/// Max number of wantlist entries per Bitswap message: inbound wantlist entries beyond -/// this are answered with `DONT_HAVE`, and outbound WANT bundles are split at this size. +/// Bitswap batch size: inbound wantlists are looked up and answered in batches of this +/// many entries, and outbound WANT bundles are split at this size. pub const MAX_WANTED_BLOCKS: usize = 16; /// IPFS raw multicodec used for indexed transaction payload bytes. diff --git a/substrate/client/network/bitswap/src/metrics.rs b/substrate/client/network/bitswap/src/metrics.rs index 9523d2d15a14..8e53a6654fa2 100644 --- a/substrate/client/network/bitswap/src/metrics.rs +++ b/substrate/client/network/bitswap/src/metrics.rs @@ -28,12 +28,11 @@ pub(crate) mod outcomes { pub(crate) const HAVE: &str = "have"; pub(crate) const DONT_HAVE: &str = "dont_have"; pub(crate) const UNSUPPORTED_CID: &str = "unsupported_cid"; + pub(crate) const DROPPED_OVERFLOW: &str = "dropped_overflow"; } pub(crate) mod errors { - pub(crate) const TOO_MANY_ENTRIES: &str = "too_many_entries"; pub(crate) const CLIENT: &str = "client"; - pub(crate) const LOOKUP_POOL_SATURATED: &str = "lookup_pool_saturated"; } pub(crate) mod outbound_events { @@ -144,8 +143,12 @@ impl BitswapMetrics { } pub(crate) fn record_entry(&self, outcome: &str) { + self.record_entries(outcome, 1); + } + + pub(crate) fn record_entries(&self, outcome: &str, count: usize) { if let Some(inner) = &self.inner { - inner.entries_total.with_label_values(&[outcome]).inc(); + inner.entries_total.with_label_values(&[outcome]).inc_by(count as u64); } } @@ -245,7 +248,8 @@ mod tests { ]; metrics.record_responses(&responses); - metrics.record_error(errors::TOO_MANY_ENTRIES); + metrics.record_error(errors::CLIENT); + metrics.record_entries(outcomes::DROPPED_OVERFLOW, 7); metrics.record_duration(Duration::from_millis(5)); metrics.record_outbound(outbound_events::REQUESTED, 2); metrics.set_state(1, 2, 3); @@ -253,10 +257,8 @@ mod tests { let inner = metrics.inner.as_ref().unwrap(); assert_eq!(inner.entries_total.with_label_values(&[outcomes::BLOCK_SERVED]).get(), 1); assert_eq!(inner.entries_total.with_label_values(&[outcomes::DONT_HAVE]).get(), 1); - assert_eq!( - inner.request_errors_total.with_label_values(&[errors::TOO_MANY_ENTRIES]).get(), - 1 - ); + assert_eq!(inner.entries_total.with_label_values(&[outcomes::DROPPED_OVERFLOW]).get(), 7); + assert_eq!(inner.request_errors_total.with_label_values(&[errors::CLIENT]).get(), 1); assert_eq!( inner .outbound_events_total diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 96ee4ee53fa8..f83b3e8cc056 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -51,7 +51,7 @@ use std::{ time::Duration, }; use tokio::{ - sync::{mpsc, Semaphore}, + sync::{mpsc, OwnedSemaphorePermit, Semaphore}, time::Instant, }; @@ -83,6 +83,10 @@ impl BitswapTransport for LitepBitswapHandle { const MAX_LIVE_CIDS: usize = 1024; const MAX_WAITERS_PER_CID: usize = 64; const MAX_CONCURRENT_INBOUND_LOOKUPS: usize = 8; +/// Per-peer bound on queued inbound wantlist entries. Sized to a client's largest +/// possible burst (our own client dispatches at most [`MAX_LIVE_CIDS`] wants at one +/// peer), so a well-behaved requester never has entries dropped. +const MAX_QUEUED_INBOUND_ENTRIES_PER_PEER: usize = MAX_LIVE_CIDS; const CMD_CHANNEL_CAPACITY: usize = 256; const LOOKUP_CHANNEL_CAPACITY: usize = 64; const PER_PEER_TIMEOUT: Duration = Duration::from_secs(5); @@ -414,29 +418,86 @@ impl InboundLookupPool { ) } - /// Serve the wantlist on a blocking worker. Returns the wantlist back if the pool is - /// saturated. - fn try_submit( + /// Reserve a worker slot, or `None` if all workers are busy. Dropping the permit + /// unused returns the slot. + fn try_acquire_worker(&self) -> Option { + self.semaphore.clone().try_acquire_owned().ok() + } + + /// Serve the wantlist on a blocking worker occupying `permit`'s slot. + fn submit( &self, + permit: OwnedSemaphorePermit, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>, - ) -> Result<(), Vec<(Cid, WantType)>> { - let Ok(permit) = self.semaphore.clone().try_acquire_owned() else { - return Err(cids); - }; - + ) { let client = self.client.clone(); let result_tx = self.result_tx.clone(); let metrics = self.metrics.clone(); tokio::task::spawn_blocking(move || { - let _permit = permit; let responses = serve_inbound(&*client, cids, &metrics); + // Release the worker slot before publishing the result, so that when the actor + // sees the result it can immediately dispatch the next queued batch. + drop(permit); // Blocking worker: wait for a channel slot rather than discarding the computed // responses when the actor is momentarily behind on draining results. let _ = result_tx.blocking_send((peer, responses)); }); + } +} + +/// Per-peer FIFO queues of inbound wantlist entries, drained round-robin in +/// [`MAX_WANTED_BLOCKS`]-entry batches so one peer's backlog cannot head-of-line-block +/// other requesters. +/// +/// Entries beyond the per-peer cap are dropped silently: bitswap clients treat wants as +/// standing state and re-send unanswered wants, while answering `DONT_HAVE` under load +/// would misreport blocks we may well have. +struct InboundQueue { + per_peer: HashMap>, + /// Peers with queued entries, each exactly once, in service order. + rotation: VecDeque, + /// Per-peer entry cap. + max_entries_per_peer: usize, +} + +impl InboundQueue { + fn new(max_entries_per_peer: usize) -> Self { + Self { per_peer: HashMap::new(), rotation: VecDeque::new(), max_entries_per_peer } + } - Ok(()) + /// Queue `entries` for `peer`. Returns the number of entries dropped because the + /// peer's queue is full. + fn enqueue(&mut self, peer: litep2p::PeerId, mut entries: Vec<(Cid, WantType)>) -> usize { + if entries.is_empty() { + return 0; + } + let queue = self.per_peer.entry(peer).or_default(); + let free = self.max_entries_per_peer.saturating_sub(queue.len()); + let dropped = entries.len().saturating_sub(free); + entries.truncate(free); + if queue.is_empty() && !entries.is_empty() { + self.rotation.push_back(peer); + } + queue.extend(entries); + if queue.is_empty() { + self.per_peer.remove(&peer); + } + dropped + } + + /// Take the next batch of up to [`MAX_WANTED_BLOCKS`] entries, rotating the serviced + /// peer to the back of the queue. + fn next_batch(&mut self) -> Option<(litep2p::PeerId, Vec<(Cid, WantType)>)> { + let peer = self.rotation.pop_front()?; + let queue = self.per_peer.get_mut(&peer).expect("peers in rotation have a queue; qed"); + let batch: Vec<_> = queue.drain(..queue.len().min(MAX_WANTED_BLOCKS)).collect(); + if queue.is_empty() { + self.per_peer.remove(&peer); + } else { + self.rotation.push_back(peer); + } + Some((peer, batch)) } } @@ -450,6 +511,7 @@ pub(crate) struct BitswapService { sync_event_stream: Pin + Send>>, inbound_lookup_pool: InboundLookupPool, inbound_lookup_rx: mpsc::Receiver, + inbound_queue: InboundQueue, connected_peers: HashSet, wants: WantSet, @@ -489,6 +551,7 @@ where sync_event_stream, inbound_lookup_pool, inbound_lookup_rx, + inbound_queue: InboundQueue::new(MAX_QUEUED_INBOUND_ENTRIES_PER_PEER), connected_peers: HashSet::new(), wants: WantSet::new(MAX_LIVE_CIDS), waiters: SlotMap::with_key(), @@ -512,7 +575,7 @@ impl BitswapService { tokio::select! { event = self.handle.next_event() => match event { Some(BitswapEvent::Request { peer, cids }) => - self.on_inbound_request(peer, cids).await, + self.on_inbound_request(peer, cids), Some(BitswapEvent::Response { peer, responses }) => self.on_inbound_response(peer, responses).await, None => { @@ -550,6 +613,8 @@ impl BitswapService { Some((peer, responses)) = self.inbound_lookup_rx.recv() => { self.handle.send_response(peer, responses).await; + // The finished worker freed its slot: pull in the next queued batch. + self.dispatch_inbound_lookups(); }, _ = sweep_ticker.tick() => { @@ -725,39 +790,32 @@ impl BitswapService { self.metrics.record_outbound(outbound_events::ROUND_RESTARTED, restarted.len()); cids.extend(restarted); self.top_up_in_flight(cids).await; - } - /// Serve an inbound wantlist, never silently: a `DONT_HAVE` lets the requester fail - /// over to another peer immediately, while silence costs it a full request timeout. - /// Entries beyond [`MAX_WANTED_BLOCKS`] (bounding the DB work per wantlist) and whole - /// wantlists refused by a saturated lookup pool are answered with `DONT_HAVE`. - async fn on_inbound_request(&mut self, peer: litep2p::PeerId, mut cids: Vec<(Cid, WantType)>) { - let mut refused = if cids.len() > MAX_WANTED_BLOCKS { - self.metrics.record_error(metric_errors::TOO_MANY_ENTRIES); - cids.split_off(MAX_WANTED_BLOCKS) - } else { - Vec::new() - }; + // Backstop for the inbound queue: a lookup worker that died without publishing a + // result frees its slot without waking the dispatch path. + self.dispatch_inbound_lookups(); + } - if let Err(cids) = self.inbound_lookup_pool.try_submit(peer, cids) { - self.metrics.record_error(metric_errors::LOOKUP_POOL_SATURATED); - log::trace!( + /// Queue an inbound wantlist for serving. Wantlists of any size are accepted and + /// served in [`MAX_WANTED_BLOCKS`]-entry batches; only entries beyond the per-peer + /// queue cap are dropped (see [`InboundQueue`] for why dropping beats `DONT_HAVE`). + fn on_inbound_request(&mut self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { + let dropped = self.inbound_queue.enqueue(peer, cids); + if dropped > 0 { + self.metrics.record_entries(metric_outcomes::DROPPED_OVERFLOW, dropped); + log::debug!( target: LOG_TARGET, - "inbound serving pool saturated; answering DONT_HAVE to {peer:?}", + "inbound queue for {peer:?} full; dropped {dropped} wantlist entries", ); - refused.extend(cids); } + self.dispatch_inbound_lookups(); + } - if !refused.is_empty() { - let responses: Vec<_> = refused - .into_iter() - .map(|(cid, _)| ResponseType::Presence { - cid, - presence: BlockPresenceType::DontHave, - }) - .collect(); - self.metrics.record_responses(&responses); - self.handle.send_response(peer, responses).await; + /// Move queued inbound batches onto free lookup workers, round-robin across peers. + fn dispatch_inbound_lookups(&mut self) { + while let Some(permit) = self.inbound_lookup_pool.try_acquire_worker() { + let Some((peer, batch)) = self.inbound_queue.next_batch() else { break }; + self.inbound_lookup_pool.submit(permit, peer, batch); } } @@ -815,18 +873,16 @@ fn serve_inbound( None }, }; - let response = match (transaction, want_type) { + match (transaction, want_type) { (Some(transaction), WantType::Block) => { ResponseType::Block { cid, block: transaction } }, (Some(_), _) => ResponseType::Presence { cid, presence: BlockPresenceType::Have }, (None, _) => ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }, - }; - metrics.record_response(&response); - response + } }) .collect::>(); - metrics.record_response_bytes(&responses); + metrics.record_responses(&responses); metrics.record_duration(started.elapsed()); responses } @@ -883,13 +939,19 @@ mod tests { client: Arc + Send + Sync>, max_live_cids: usize, ) -> TestRig { - build_rig_with_lookup_limit(client, max_live_cids, MAX_CONCURRENT_INBOUND_LOOKUPS) + build_rig_with_inbound_limits( + client, + max_live_cids, + MAX_CONCURRENT_INBOUND_LOOKUPS, + MAX_QUEUED_INBOUND_ENTRIES_PER_PEER, + ) } - fn build_rig_with_lookup_limit( + fn build_rig_with_inbound_limits( client: Arc + Send + Sync>, max_live_cids: usize, max_lookups: usize, + queued_entries_per_peer: usize, ) -> TestRig { let (inbound_tx, inbound_rx) = mpsc::channel(64); let (outbound_req_tx, outbound_req_rx) = mpsc::channel(64); @@ -916,6 +978,7 @@ mod tests { sync_event_stream, inbound_lookup_pool, inbound_lookup_rx, + inbound_queue: InboundQueue::new(queued_entries_per_peer), connected_peers: HashSet::new(), wants: WantSet::new(max_live_cids), waiters: SlotMap::with_key(), @@ -1095,6 +1158,50 @@ mod tests { assert!(selected.len() > 1, "fresh CIDs should not all select the same peer"); } + #[test] + fn inbound_queue_rotates_peers_between_batches() { + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let entry = |i: u8| (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32]), WantType::Block); + let mut queue = InboundQueue::new(MAX_QUEUED_INBOUND_ENTRIES_PER_PEER); + + // A backlogs two batches, B one entry: B is served between A's batches. + assert_eq!( + queue.enqueue(peer_a, (0..(MAX_WANTED_BLOCKS + 4) as u8).map(entry).collect()), + 0 + ); + assert_eq!(queue.enqueue(peer_b, vec![entry(0xff)]), 0); + + let (peer, batch) = queue.next_batch().unwrap(); + assert_eq!((peer, batch.len()), (peer_a, MAX_WANTED_BLOCKS)); + let (peer, batch) = queue.next_batch().unwrap(); + assert_eq!((peer, batch.len()), (peer_b, 1)); + let (peer, batch) = queue.next_batch().unwrap(); + assert_eq!((peer, batch.len()), (peer_a, 4)); + assert!(queue.next_batch().is_none()); + } + + #[test] + fn inbound_queue_drops_overflow_and_counts_it() { + let peer = litep2p::PeerId::random(); + let entry = |i: u8| (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32]), WantType::Block); + let mut queue = InboundQueue::new(4); + + assert_eq!(queue.enqueue(peer, (0..3).map(entry).collect()), 0); + // Only one slot left: two of the three new entries are dropped. + assert_eq!(queue.enqueue(peer, (3..6).map(entry).collect()), 2); + + let (_, batch) = queue.next_batch().unwrap(); + let kept: Vec = batch.into_iter().map(|(cid, _)| cid).collect(); + assert_eq!( + kept, + (0..4) + .map(|i| cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32])) + .collect::>() + ); + assert!(queue.next_batch().is_none()); + } + #[tokio::test(start_paused = true)] async fn single_cid_single_peer_block_response() { let mut rig = empty_rig(); @@ -1510,38 +1617,125 @@ mod tests { } #[tokio::test] - async fn saturated_inbound_pool_answers_dont_have() { + async fn busy_pool_queues_wantlists_until_worker_frees() { let (gate_tx, gate_rx) = std::sync::mpsc::channel(); let backend = Arc::new(GatedBackend { gate: std::sync::Mutex::new(gate_rx) }); - let mut rig = build_rig_with_lookup_limit(backend, MAX_LIVE_CIDS, 1); + let mut rig = build_rig_with_inbound_limits( + backend, + MAX_LIVE_CIDS, + 1, + MAX_QUEUED_INBOUND_ENTRIES_PER_PEER, + ); let peer = litep2p::PeerId::random(); - let gated_cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x0a; 32]); - let refused_cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x0b; 32]); + let first_cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x0a; 32]); + let second_cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x0b; 32]); // The first wantlist occupies the only lookup worker (parked on the gate); the - // second finds the pool saturated. - for cid in [gated_cid, refused_cid] { + // second waits in the peer's queue instead of being refused. + for cid in [first_cid, second_cid] { rig.send_wantlist(peer, vec![(cid, WantType::Block)]).await; } + let no_response = timeout(Duration::from_millis(300), rig.outbound_resp_rx.recv()).await; + assert!(no_response.is_err(), "nothing must be answered while the worker is parked"); - // The refused wantlist is answered with DONT_HAVE instead of silence. - let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx) - .await - .expect("DONT_HAVE for refused wantlist"); + // Releasing the worker serves both wantlists, in arrival order. + gate_tx.send(()).unwrap(); + let (resp_peer, responses) = + drain_next(&mut rig.outbound_resp_rx).await.expect("first response"); assert_eq!(resp_peer, peer); - assert_single_dont_have(&responses, refused_cid); + assert_single_dont_have(&responses, first_cid); - // Releasing the worker lets the parked wantlist be served normally. gate_tx.send(()).unwrap(); - let (_, responses) = drain_next(&mut rig.outbound_resp_rx) - .await - .expect("response for gated wantlist"); - assert_single_dont_have(&responses, gated_cid); + let (_, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("second response"); + assert_single_dont_have(&responses, second_cid); + } + + #[tokio::test] + async fn inbound_queue_round_robins_between_peers() { + let (gate_tx, gate_rx) = std::sync::mpsc::channel(); + let backend = Arc::new(GatedBackend { gate: std::sync::Mutex::new(gate_rx) }); + let mut rig = build_rig_with_inbound_limits( + backend, + MAX_LIVE_CIDS, + 1, + MAX_QUEUED_INBOUND_ENTRIES_PER_PEER, + ); + + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let wantlist = |tag: u8, len: usize| -> Vec<(Cid, WantType)> { + (0..len as u8) + .map(|i| { + let mut digest = [tag; 32]; + digest[1] = i; + (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, digest), WantType::Block) + }) + .collect() + }; + + // Peer A backlogs three batches: the first is dispatched immediately, two queue. + rig.send_wantlist(peer_a, wantlist(0xaa, 3 * MAX_WANTED_BLOCKS)).await; + // Peer B queues one batch behind A's backlog. + rig.send_wantlist(peer_b, wantlist(0xbb, MAX_WANTED_BLOCKS)).await; + + // Release one batch worth of lookups at a time and record who gets answered. + let mut served_order = Vec::new(); + for _ in 0..4 { + for _ in 0..MAX_WANTED_BLOCKS { + gate_tx.send(()).unwrap(); + } + let (resp_peer, responses) = + drain_next(&mut rig.outbound_resp_rx).await.expect("batch response"); + assert_eq!(responses.len(), MAX_WANTED_BLOCKS); + served_order.push(resp_peer); + } + + // Round-robin: B's batch is interleaved into A's backlog instead of waiting for + // all of it. (A goes twice first: its second batch re-entered the rotation + // before B arrived.) + assert_eq!(served_order, vec![peer_a, peer_a, peer_b, peer_a]); + } + + #[tokio::test] + async fn per_peer_queue_overflow_drops_newest_entries() { + let (gate_tx, gate_rx) = std::sync::mpsc::channel(); + let backend = Arc::new(GatedBackend { gate: std::sync::Mutex::new(gate_rx) }); + // Single gated worker, queue capped at 4 entries per peer. + let mut rig = build_rig_with_inbound_limits(backend, MAX_LIVE_CIDS, 1, 4); + + let peer = litep2p::PeerId::random(); + let cid = |i: u8| cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32]); + + // The first wantlist occupies the worker; the next four fill the queue; the + // sixth overflows the per-peer cap and is dropped silently. + for i in 0..6u8 { + rig.send_wantlist(peer, vec![(cid(i), WantType::Block)]).await; + } + + // Serve everything: the five accepted entries are answered, the sixth never is. + for _ in 0..5 { + gate_tx.send(()).unwrap(); + } + let mut answered = HashSet::new(); + while answered.len() < 5 { + let (_, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("response"); + for response in responses { + match response { + ResponseType::Presence { cid, presence: BlockPresenceType::DontHave } => { + assert!(answered.insert(cid), "duplicate reply for {cid}"); + }, + other => panic!("expected DONT_HAVE, got {other:?}"), + } + } + } + assert_eq!(answered, (0..5u8).map(cid).collect()); + let no_more = timeout(Duration::from_millis(300), rig.outbound_resp_rx.recv()).await; + assert!(no_more.is_err(), "the overflowed entry must not be answered"); } #[tokio::test] - async fn oversized_wantlist_is_answered_for_every_entry() { + async fn large_wantlist_is_served_in_batches_covering_every_entry() { let mut rig = empty_rig(); let peer = litep2p::PeerId::random(); @@ -1555,9 +1749,9 @@ mod tests { rig.send_wantlist(peer, cids.clone()).await; - // Entries beyond `MAX_WANTED_BLOCKS` get an immediate DONT_HAVE; the first - // `MAX_WANTED_BLOCKS` are looked up and answered too (DONT_HAVE as well: the test - // client holds no indexed data). Every entry must receive a reply. + // A wantlist larger than `MAX_WANTED_BLOCKS` is accepted whole and served in + // batches. Every entry gets a reply (DONT_HAVE: the test client holds no + // indexed data). let mut answered = HashSet::new(); while answered.len() < cids.len() { let (resp_peer, responses) = From 46d004e8bb190bbb67fb6b994bf869826a3ab74c Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Fri, 17 Jul 2026 22:10:59 +0200 Subject: [PATCH 19/32] Fix permit handling --- .../client/network/bitswap/src/service.rs | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index f83b3e8cc056..3b0ce6df3a99 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -88,7 +88,9 @@ const MAX_CONCURRENT_INBOUND_LOOKUPS: usize = 8; /// peer), so a well-behaved requester never has entries dropped. const MAX_QUEUED_INBOUND_ENTRIES_PER_PEER: usize = MAX_LIVE_CIDS; const CMD_CHANNEL_CAPACITY: usize = 256; -const LOOKUP_CHANNEL_CAPACITY: usize = 64; +/// One result-channel slot per worker: permits flow through the channel, so it can never +/// hold more than one result per worker and senders never block. +const LOOKUP_CHANNEL_CAPACITY: usize = MAX_CONCURRENT_INBOUND_LOOKUPS; const PER_PEER_TIMEOUT: Duration = Duration::from_secs(5); /// Delay before an exhausted CID (every connected peer tried) starts a new round. const ROUND_RETRY_DELAY: Duration = Duration::from_secs(5); @@ -396,7 +398,10 @@ struct Waiter { sink: mpsc::Sender, } -type InboundLookupResult = (litep2p::PeerId, Vec); +/// A served batch, carrying the worker permit: the slot is released only once the actor +/// has forwarded the responses, so downstream backpressure reaches the dispatch path and +/// at most [`MAX_CONCURRENT_INBOUND_LOOKUPS`] responses are retained at any time. +type InboundLookupResult = (litep2p::PeerId, Vec, OwnedSemaphorePermit); struct InboundLookupPool { client: Arc + Send + Sync>, @@ -436,12 +441,9 @@ impl InboundLookupPool { let metrics = self.metrics.clone(); tokio::task::spawn_blocking(move || { let responses = serve_inbound(&*client, cids, &metrics); - // Release the worker slot before publishing the result, so that when the actor - // sees the result it can immediately dispatch the next queued batch. - drop(permit); - // Blocking worker: wait for a channel slot rather than discarding the computed - // responses when the actor is momentarily behind on draining results. - let _ = result_tx.blocking_send((peer, responses)); + // The permit travels with the result and never blocks here: the channel has one + // slot per worker, so at most one outstanding result per permit exists. + let _ = result_tx.blocking_send((peer, responses, permit)); }); } } @@ -611,9 +613,11 @@ impl BitswapService { }, }, - Some((peer, responses)) = self.inbound_lookup_rx.recv() => { + Some((peer, responses, permit)) = self.inbound_lookup_rx.recv() => { self.handle.send_response(peer, responses).await; - // The finished worker freed its slot: pull in the next queued batch. + // The response is forwarded: free the worker slot and pull in the next + // queued batch. + drop(permit); self.dispatch_inbound_lookups(); }, From 384d9837bcdaf3e3c9e26ce29244dd8d9e59e2bf Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Fri, 17 Jul 2026 23:18:43 +0200 Subject: [PATCH 20/32] Introduce phase enum --- Cargo.lock | 1 - cumulus/polkadot-omni-node/lib/Cargo.toml | 1 - .../polkadot-omni-node/lib/src/common/spec.rs | 3 +- prdoc/pr_12052.prdoc | 38 +- substrate/client/network/bitswap/Cargo.toml | 2 +- .../client/network/bitswap/src/handle.rs | 50 +- substrate/client/network/bitswap/src/lib.rs | 14 +- .../client/network/bitswap/src/metrics.rs | 3 +- .../client/network/bitswap/src/service.rs | 471 ++++++++---------- substrate/client/service/src/builder.rs | 41 +- .../client/storage-chain-sync/src/fetcher.rs | 48 +- .../client/storage-chain-sync/src/lib.rs | 2 +- .../client/storage-chain-sync/tests/it.rs | 14 +- 13 files changed, 280 insertions(+), 408 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7349dfae79be..bc47eab9e224 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16939,7 +16939,6 @@ dependencies = [ "sc-hop", "sc-keystore", "sc-network 0.34.0", - "sc-network-bitswap", "sc-network-statement", "sc-network-sync", "sc-offchain", diff --git a/cumulus/polkadot-omni-node/lib/Cargo.toml b/cumulus/polkadot-omni-node/lib/Cargo.toml index e9a06ace33a9..9087805dd342 100644 --- a/cumulus/polkadot-omni-node/lib/Cargo.toml +++ b/cumulus/polkadot-omni-node/lib/Cargo.toml @@ -56,7 +56,6 @@ sc-executor = { workspace = true, default-features = true } sc-hop = { workspace = true, default-features = true } sc-keystore = { workspace = true, default-features = true } sc-network = { workspace = true, default-features = true } -sc-network-bitswap = { workspace = true, default-features = true } sc-network-statement = { workspace = true, default-features = true } sc-network-sync = { workspace = true, default-features = true } sc-offchain = { workspace = true, default-features = true } diff --git a/cumulus/polkadot-omni-node/lib/src/common/spec.rs b/cumulus/polkadot-omni-node/lib/src/common/spec.rs index 5bd2c06fd213..1cbb11b06eaf 100644 --- a/cumulus/polkadot-omni-node/lib/src/common/spec.rs +++ b/cumulus/polkadot-omni-node/lib/src/common/spec.rs @@ -49,7 +49,6 @@ use sc_executor::{HeapAllocStrategy, DEFAULT_HEAP_ALLOC_STRATEGY}; use sc_network::{ config::FullNetworkConfiguration, NetworkBackend, NetworkBlock, NetworkStateInfo, PeerId, }; -use sc_network_bitswap::BitswapRequest; use sc_service::{Configuration, ImportQueue, PartialComponents, TaskManager}; use sc_statement_store::Store; use sc_storage_chain_sync::{ @@ -462,7 +461,7 @@ pub(crate) trait NodeSpec: BaseNodeSpec { .await?; if let Some(handle) = bitswap_handle { - let _ = bitswap_slot.set(Arc::new(handle) as Arc); + let _ = bitswap_slot.set(Arc::new(handle)); } let peer_id = relay_chain_network.local_peer_id(); diff --git a/prdoc/pr_12052.prdoc b/prdoc/pr_12052.prdoc index d02e7d104b03..16463bb5e8f9 100644 --- a/prdoc/pr_12052.prdoc +++ b/prdoc/pr_12052.prdoc @@ -3,42 +3,22 @@ title: "Network: rework the Bitswap client into a long-lived service in `sc-netw doc: - audience: Node Dev description: | - Replaces the request-response-shaped `request_bitswap_blocks` helper with a - long-lived Bitswap service living in the new `sc-network-bitswap` crate. The - service is started by `sc-service` when `--ipfs-server` is enabled and its - user-facing `BitswapHandle` is returned as the last element of the - `build_network`/`build_network_advanced` output tuple (`None` when Bitswap is - not enabled). The handle's only method is - `request_stream(cids) -> Receiver), BitswapError>>`, - which streams hash-verified per-CID results as they resolve. + Replaces `request_bitswap_blocks` with `BitswapHandle` in the new + `sc-network-bitswap` crate. `BitswapHandle::request_stream` streams verified + blocks for requested CIDs. - Requests of any size are accepted: at most 1024 CIDs have in-flight peer - requests at a time (the dispatch window), the rest queue inside the service and - are dispatched as slots free up. Requests carry no service-side deadline; the - service retries unresolved CIDs as peers connect, and the caller bounds the - wait by dropping the receiver, which cancels the remaining wants. - - The service runs an internal actor that owns peer selection (fed by - `sc-network-sync` peer connect/disconnect events), per-peer timeouts (5s) and - hash verification of every received block. Inbound serving is off-loaded to a - bounded `spawn_blocking` worker pool so storage reads never block outbound - traffic or response correlation. Inbound serving keeps running even if no - consumer holds a `BitswapHandle`. + `build_network` and `build_network_advanced` now return an optional + `BitswapHandle` when `--ipfs-server` is enabled. The libp2p Bitswap implementation is removed; Bitswap now requires the litep2p network backend. Enabling `--ipfs-server` together with `--network-backend libp2p` fails at startup with a clear error. The `NetworkBackend::bitswap_server` trait method and the `BitswapConfig` - associated type are removed; `sc_network::config::IpfsConfig` is now built via - `IpfsConfig::new`, which also mints the transport handle consumed by - `sc_network_bitswap::start`. + associated type are removed. - `sc-storage-chain-sync` is migrated to the new API: `IndexedTransactionFetcher` - no longer rotates across peers; it builds CIDs, submits them in one request and - drains the result stream under a time budget that scales with the wantlist - size. `BitswapPeerSource`, `NetworkHandle` and `SyncingHandle` are replaced by - a single `BitswapHandleSlot`, which the omni-node populates from the - `build_network` output. + `sc-storage-chain-sync` now uses the streaming API and replaces + `BitswapPeerSource`, `NetworkHandle` and `SyncingHandle` with + `BitswapHandleSlot`. Closes https://github.com/paritytech/polkadot-sdk/issues/12052. diff --git a/substrate/client/network/bitswap/Cargo.toml b/substrate/client/network/bitswap/Cargo.toml index cf2caf5a287c..d2247f876bda 100644 --- a/substrate/client/network/bitswap/Cargo.toml +++ b/substrate/client/network/bitswap/Cargo.toml @@ -29,7 +29,6 @@ sc-network-sync = { workspace = true, default-features = true } slotmap = { workspace = true } smallvec = { workspace = true, default-features = true } sp-core = { workspace = true, default-features = true } -sp-crypto-hashing = { workspace = true, default-features = true } sp-runtime = { workspace = true, default-features = true } thiserror = { workspace = true } tokio = { features = ["macros", "rt", "sync", "time"], workspace = true, default-features = true } @@ -40,6 +39,7 @@ rstest = { workspace = true } sc-block-builder = { workspace = true, default-features = true } sc-network-types = { workspace = true, default-features = true } sp-consensus = { workspace = true, default-features = true } +sp-crypto-hashing = { workspace = true, default-features = true } substrate-test-runtime = { workspace = true } substrate-test-runtime-client = { workspace = true } tokio = { features = ["macros", "rt-multi-thread", "test-util"], workspace = true, default-features = true } diff --git a/substrate/client/network/bitswap/src/handle.rs b/substrate/client/network/bitswap/src/handle.rs index 1912541a66d0..302b1335a8d0 100644 --- a/substrate/client/network/bitswap/src/handle.rs +++ b/substrate/client/network/bitswap/src/handle.rs @@ -15,49 +15,39 @@ // You should have received a copy of the GNU General Public License // along with Substrate. If not, see . -//! User-facing API for submitting Bitswap requests. +//! Bitswap request API. use super::{is_cid_supported, Cid}; use tokio::sync::mpsc; -/// Service-level Bitswap errors. -/// -/// Returned synchronously from [`BitswapHandle::request_stream`] for admission-time -/// failures, and appearing at most once inside the returned stream (`Overloaded` or -/// `ServiceClosed`). +/// Bitswap request errors. #[derive(Debug, thiserror::Error)] pub enum BitswapError { - /// The Bitswap service task has shut down. + /// The service is unavailable. #[error("Bitswap service is closed")] ServiceClosed, - /// A CID in the wantlist is unsupported (bad version, bad multihash code, or bad digest - /// size). + /// A CID is unsupported. #[error("invalid CID for Bitswap: {cid}")] InvalidCid { - /// The offending CID. + /// Unsupported CID. cid: Cid, }, - /// The service cannot accept the request: the command channel is full, or too many - /// concurrent requests want the same CID. + /// The service is at capacity. #[error("Bitswap service is overloaded")] Overloaded, } -/// Item carried on the receiver returned by [`BitswapHandle::request_stream`]: the -/// hash-verified bytes for one requested CID, or a terminal service error. +/// A fetched block or request error. pub type FetchItem = Result<(Cid, Vec), BitswapError>; -/// User-facing handle to the Bitswap service. -/// -/// Cheap to clone. +/// Handle for submitting Bitswap requests. #[derive(Debug, Clone)] pub struct BitswapHandle { cmd_tx: mpsc::Sender, } impl BitswapHandle { - /// Construct a new handle around an existing command sender. pub(crate) fn new(cmd_tx: mpsc::Sender) -> Self { Self { cmd_tx } } @@ -68,15 +58,9 @@ impl BitswapHandle { /// The stream closes once every CID has been delivered. Unresolved CIDs are retried /// until the receiver is dropped. /// - /// There is no per-call CID cap. - /// /// `Err(BitswapError::ServiceClosed)` is yielded once, as the final item, if the /// service shuts down mid-request. `Err(BitswapError::Overloaded)` is yielded once as /// the only item if too many concurrent requests want one of the CIDs. - /// - /// Returns a synchronous `BitswapError` for admission-time failures (`ServiceClosed`, - /// `InvalidCid`, or `Overloaded` when the command channel is full). An empty `cids` - /// slice returns an immediately-closed receiver, not an error. pub fn request_stream( &self, cids: Vec, @@ -92,9 +76,6 @@ impl BitswapHandle { } } - // `cids.len() + 1` reserves one slot for a possible terminal `Err` item - // (`Overloaded` or `ServiceClosed`), so the actor's `try_send` for outcomes never - // fails for well-behaved callers. let (sink, rx) = mpsc::channel(cids.len() + 1); self.cmd_tx.try_send(BitswapCommand::RequestStream { cids, sink }).map_err( @@ -108,22 +89,7 @@ impl BitswapHandle { } } -/// Object-safe surface over [`BitswapHandle::request_stream`], allowing consumers to mock -/// the bitswap client in tests. -pub trait BitswapRequest: Send + Sync { - /// Submit a wantlist. See [`BitswapHandle::request_stream`] for full semantics. - fn request_stream(&self, cids: Vec) -> Result, BitswapError>; -} - -impl BitswapRequest for BitswapHandle { - fn request_stream(&self, cids: Vec) -> Result, BitswapError> { - BitswapHandle::request_stream(self, cids) - } -} - -/// Internal command sent from a [`BitswapHandle`] to the service actor. #[derive(Debug)] pub(crate) enum BitswapCommand { - /// Submit a streaming request: fetch `cids` and write per-CID outcomes into `sink`. RequestStream { cids: Vec, sink: mpsc::Sender }, } diff --git a/substrate/client/network/bitswap/src/lib.rs b/substrate/client/network/bitswap/src/lib.rs index 55fbe3f38b6d..bfc8aaa6fa98 100644 --- a/substrate/client/network/bitswap/src/lib.rs +++ b/substrate/client/network/bitswap/src/lib.rs @@ -15,11 +15,7 @@ // You should have received a copy of the GNU General Public License // along with Substrate. If not, see . -//! Bitswap support for Substrate. -//! -//! Implements both the server side (serving indexed-transaction blocks to peers that send -//! Bitswap v1.2.0 wantlists) and the client side (a long-lived service plus user-facing -//! [`BitswapHandle`] for fetching CIDs from the network). +//! Bitswap client and server. use cid::Version as CidVersion; @@ -29,13 +25,12 @@ mod service; pub use cid::Cid; pub(crate) use handle::BitswapCommand; -pub use handle::{BitswapError, BitswapHandle, BitswapRequest, FetchItem}; +pub use handle::{BitswapError, BitswapHandle, FetchItem}; pub use service::start; pub(crate) const LOG_TARGET: &str = "sub-libp2p::bitswap"; -/// Bitswap batch size: inbound wantlists are looked up and answered in batches of this -/// many entries, and outbound WANT bundles are split at this size. +/// Maximum entries per Bitswap message. pub const MAX_WANTED_BLOCKS: usize = 16; /// IPFS raw multicodec used for indexed transaction payload bytes. @@ -50,8 +45,7 @@ pub const SHA2_256_MULTIHASH_CODE: u64 = 0x12; /// Multihash code for Keccak-256, per the multicodec table. pub const KECCAK_256_MULTIHASH_CODE: u64 = 0x1b; -/// Check if a CID is supported by the Bitswap protocol: CIDv1, 32-byte digest, with a -/// supported multihash code (Blake2b-256, SHA2-256, or Keccak-256). +/// Returns whether Bitswap supports a CID. pub fn is_cid_supported(cid: &Cid) -> bool { cid.version() != CidVersion::V0 && cid.hash().size() == 32 && diff --git a/substrate/client/network/bitswap/src/metrics.rs b/substrate/client/network/bitswap/src/metrics.rs index 8e53a6654fa2..2d5fcf4d8351 100644 --- a/substrate/client/network/bitswap/src/metrics.rs +++ b/substrate/client/network/bitswap/src/metrics.rs @@ -42,7 +42,6 @@ pub(crate) mod outbound_events { pub(crate) const ROUND_RESTARTED: &str = "round_restarted"; pub(crate) const ABANDONED: &str = "abandoned"; pub(crate) const OVERLOADED: &str = "overloaded"; - pub(crate) const VERIFICATION_FAILED: &str = "verification_failed"; } struct Inner { @@ -86,7 +85,7 @@ impl Inner { "Duration of handling an inbound bitswap wantlist, in seconds", ), buckets: exponential_buckets(0.001, 2.0, 16) - .expect("parameters are valid; qed"), + .expect("valid histogram parameters"), })?, registry, )?, diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 3b0ce6df3a99..1b84cecd127d 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -15,11 +15,10 @@ // You should have received a copy of the GNU General Public License // along with Substrate. If not, see . -//! Actor that serves inbound wantlists and resolves outbound CID requests over litep2p. +//! Bitswap service. use super::{ - is_cid_supported, BitswapCommand, BitswapHandle, Cid, FetchItem, BLAKE2B_256_MULTIHASH_CODE, - KECCAK_256_MULTIHASH_CODE, LOG_TARGET, MAX_WANTED_BLOCKS, SHA2_256_MULTIHASH_CODE, + is_cid_supported, BitswapCommand, BitswapHandle, Cid, FetchItem, LOG_TARGET, MAX_WANTED_BLOCKS, }; use crate::{ handle::BitswapError, @@ -29,7 +28,6 @@ use crate::{ }; use async_trait::async_trait; -use cid::multihash::Multihash as CidMultihash; use futures::{Stream, StreamExt}; use litep2p::protocol::libp2p::bitswap::{ BitswapEvent, BitswapHandle as LitepBitswapHandle, BlockPresenceType, ResponseType, WantType, @@ -56,16 +54,42 @@ use tokio::{ }; #[async_trait] -pub(crate) trait BitswapTransport: Send { - async fn next_event(&mut self) -> Option; +trait BitswapTransport: Send { + async fn next_event(&mut self) -> Option; async fn send_request(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>); async fn send_response(&self, peer: litep2p::PeerId, responses: Vec); } +enum TransportEvent { + Request { peer: litep2p::PeerId, cids: Vec<(Cid, WantType)> }, + Response { peer: litep2p::PeerId, responses: Vec }, +} + +enum TransportResponse { + VerifiedBlock { cid: Cid, bytes: Vec }, + Presence { cid: Cid, presence: BlockPresenceType }, +} + #[async_trait] impl BitswapTransport for LitepBitswapHandle { - async fn next_event(&mut self) -> Option { - StreamExt::next(self).await + async fn next_event(&mut self) -> Option { + StreamExt::next(self).await.map(|event| match event { + BitswapEvent::Request { peer, cids } => TransportEvent::Request { peer, cids }, + BitswapEvent::Response { peer, responses } => TransportEvent::Response { + peer, + responses: responses + .into_iter() + .map(|response| match response { + ResponseType::Block { cid, block } => { + TransportResponse::VerifiedBlock { cid, bytes: block } + }, + ResponseType::Presence { cid, presence } => { + TransportResponse::Presence { cid, presence } + }, + }) + .collect(), + }, + }) } async fn send_request(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { @@ -77,61 +101,100 @@ impl BitswapTransport for LitepBitswapHandle { } } -/// Dispatch-window size: max number of CIDs with in-flight peer requests at once. CIDs -/// beyond the window are queued and dispatched as slots free up, so requests of any size -/// are accepted. const MAX_LIVE_CIDS: usize = 1024; const MAX_WAITERS_PER_CID: usize = 64; const MAX_CONCURRENT_INBOUND_LOOKUPS: usize = 8; -/// Per-peer bound on queued inbound wantlist entries. Sized to a client's largest -/// possible burst (our own client dispatches at most [`MAX_LIVE_CIDS`] wants at one -/// peer), so a well-behaved requester never has entries dropped. const MAX_QUEUED_INBOUND_ENTRIES_PER_PEER: usize = MAX_LIVE_CIDS; const CMD_CHANNEL_CAPACITY: usize = 256; -/// One result-channel slot per worker: permits flow through the channel, so it can never -/// hold more than one result per worker and senders never block. const LOOKUP_CHANNEL_CAPACITY: usize = MAX_CONCURRENT_INBOUND_LOOKUPS; const PER_PEER_TIMEOUT: Duration = Duration::from_secs(5); -/// Delay before an exhausted CID (every connected peer tried) starts a new round. const ROUND_RETRY_DELAY: Duration = Duration::from_secs(5); -const PEER_FANOUT_CAP: usize = 1; const SWEEP_INTERVAL: Duration = Duration::from_secs(1); new_key_type! { struct WaiterId; } -// Per-CID network scheduling state, deduplicated across overlapping waiters. +#[derive(Clone, Copy, Default)] +enum CidPhase { + #[default] + Ready, + Queued { + retry_at: Option, + }, + InFlight { + peer: litep2p::PeerId, + deadline: Instant, + }, + RetryAt(Instant), +} + +#[derive(Default)] struct CidState { tried_peers: HashSet, - in_flight_peers: HashMap, - /// Peers that answered HAVE for this CID. Preferred on dispatch; a second HAVE from - /// the same peer marks it tried. have_peers: SmallVec<[litep2p::PeerId; 1]>, waiters: SmallVec<[WaiterId; 2]>, - /// Whether this CID currently has a valid entry in [`WantSet::pending`]. - pending: bool, - /// When set, every connected peer has been tried; a new round (with cleared per-round - /// peer state) starts once this instant passes. Cleared on dispatch. - next_round_at: Option, + phase: CidPhase, } impl CidState { - fn new() -> Self { - Self { - tried_peers: HashSet::new(), - in_flight_peers: HashMap::new(), - have_peers: SmallVec::new(), - waiters: SmallVec::new(), - pending: false, - next_round_at: None, - } - } - fn has_waiters(&self) -> bool { !self.waiters.is_empty() } fn is_idle(&self) -> bool { - self.waiters.is_empty() && self.in_flight_peers.is_empty() + self.waiters.is_empty() && !matches!(self.phase, CidPhase::InFlight { .. }) + } + + fn is_queued(&self) -> bool { + matches!(self.phase, CidPhase::Queued { .. }) + } + + fn queue(&mut self) -> bool { + let retry_at = match self.phase { + CidPhase::Ready => None, + CidPhase::RetryAt(at) => Some(at), + CidPhase::Queued { .. } | CidPhase::InFlight { .. } => return false, + }; + self.phase = CidPhase::Queued { retry_at }; + true + } + + fn dequeue(&mut self) -> bool { + let CidPhase::Queued { retry_at } = self.phase else { return false }; + self.phase = retry_at.map_or(CidPhase::Ready, CidPhase::RetryAt); + true + } + + fn finish_peer(&mut self, peer: litep2p::PeerId) -> bool { + let CidPhase::InFlight { peer: active, .. } = self.phase else { return false }; + if active != peer { + return false; + } + self.phase = CidPhase::Ready; + true + } + + fn schedule_retry(&mut self, at: Instant) -> bool { + match &mut self.phase { + CidPhase::Ready => self.phase = CidPhase::RetryAt(at), + CidPhase::Queued { retry_at: slot @ None } => *slot = Some(at), + CidPhase::Queued { retry_at: Some(_) } | + CidPhase::InFlight { .. } | + CidPhase::RetryAt(_) => return false, + } + true + } + + fn restart_round(&mut self, now: Instant) -> bool { + self.phase = match self.phase { + CidPhase::RetryAt(at) if at <= now => CidPhase::Ready, + CidPhase::Queued { retry_at: Some(at) } if at <= now => { + CidPhase::Queued { retry_at: None } + }, + _ => return false, + }; + self.tried_peers.clear(); + self.have_peers.clear(); + true } } @@ -164,12 +227,8 @@ impl WantSet { self.inner.get(cid).map_or(0, |state| state.waiters.len()) } - fn queued_count(&self) -> usize { - self.queued - } - fn add_waiter(&mut self, cid: Cid, waiter: WaiterId) { - self.inner.entry(cid).or_insert_with(CidState::new).waiters.push(waiter); + self.inner.entry(cid).or_default().waiters.push(waiter); } fn remove_waiter(&mut self, cid: Cid, waiter: WaiterId) { @@ -185,10 +244,10 @@ impl WantSet { fn take_waiters_for_delivered_cid(&mut self, cid: Cid) -> Option> { self.inner.remove(&cid).map(|state| { - if !state.in_flight_peers.is_empty() { + if matches!(state.phase, CidPhase::InFlight { .. }) { self.live -= 1; } - if state.pending { + if state.is_queued() { self.queued -= 1; } state.waiters @@ -199,12 +258,10 @@ impl WantSet { self.live < self.max_live } - /// Pop the next CID waiting for a window slot, skipping stale queue entries. fn pop_pending(&mut self) -> Option { while let Some(cid) = self.pending.pop_front() { if let Some(state) = self.inner.get_mut(&cid) { - if state.pending { - state.pending = false; + if state.dequeue() { self.queued -= 1; return Some(cid); } @@ -221,25 +278,20 @@ impl WantSet { rng: &mut R, ) -> Option { let state = self.inner.get(&cid)?; - if !state.has_waiters() || state.in_flight_peers.len() >= PEER_FANOUT_CAP { + if !state.has_waiters() || matches!(state.phase, CidPhase::InFlight { .. }) { return None; } - // Dispatch window full: queue the CID for promotion when a slot frees up. - if state.in_flight_peers.is_empty() && !self.has_window_capacity() { - let state = self.inner.get_mut(&cid).expect("checked above; qed"); - if !state.pending { - state.pending = true; + if !self.has_window_capacity() { + let state = self.inner.get_mut(&cid).expect("CID exists"); + if state.queue() { self.pending.push_back(cid); self.queued += 1; } return None; } - let eligible = |peer: &litep2p::PeerId| { - !state.tried_peers.contains(peer) && !state.in_flight_peers.contains_key(peer) - }; - // Peers that answered HAVE for this CID are asked first. + let eligible = |peer: &litep2p::PeerId| !state.tried_peers.contains(peer); let Some(peer) = state .have_peers .iter() @@ -248,12 +300,9 @@ impl WantSet { .or_else(|| connected_peers.iter().filter(|peer| eligible(peer)).choose(&mut *rng)) .copied() else { - // Round exhausted: every connected peer has been tried. Schedule a new round, - // started by the sweep once the delay passes. - if !connected_peers.is_empty() && state.in_flight_peers.is_empty() { - let state = self.inner.get_mut(&cid).expect("checked above; qed"); - if state.next_round_at.is_none() { - state.next_round_at = Some(now + ROUND_RETRY_DELAY); + if !connected_peers.is_empty() { + let state = self.inner.get_mut(&cid).expect("CID exists"); + if state.schedule_retry(now + ROUND_RETRY_DELAY) { log::trace!( target: LOG_TARGET, "all peers tried for {cid}, scheduling new round", @@ -263,23 +312,19 @@ impl WantSet { return None; }; - let state = self.inner.get_mut(&cid).expect("checked above; qed"); - if state.in_flight_peers.is_empty() { - self.live += 1; - } - state.in_flight_peers.insert(peer, now + PER_PEER_TIMEOUT); - if state.pending { + let state = self.inner.get_mut(&cid).expect("CID exists"); + if state.is_queued() { self.queued -= 1; } - state.pending = false; - state.next_round_at = None; + self.live += 1; + state.phase = CidPhase::InFlight { peer, deadline: now + PER_PEER_TIMEOUT }; Some(peer) } fn mark_peer_done_for_cid(&mut self, peer: litep2p::PeerId, cid: Cid) { if let Some(state) = self.inner.get_mut(&cid) { - if state.in_flight_peers.remove(&peer).is_some() && state.in_flight_peers.is_empty() { + if state.finish_peer(peer) { self.live -= 1; } state.tried_peers.insert(peer); @@ -287,10 +332,6 @@ impl WantSet { self.remove_if_idle(cid); } - /// Record a HAVE from `peer` for `cid` and release its in-flight slot. - /// - /// A first HAVE keeps the peer eligible (and preferred) for a re-ask; a repeated HAVE - /// without the block marks the peer tried. fn note_peer_have_for_cid(&mut self, peer: litep2p::PeerId, cid: Cid) { if let Some(state) = self.inner.get_mut(&cid) { if state.have_peers.contains(&peer) { @@ -298,7 +339,7 @@ impl WantSet { } else { state.have_peers.push(peer); } - if state.in_flight_peers.remove(&peer).is_some() && state.in_flight_peers.is_empty() { + if state.finish_peer(peer) { self.live -= 1; } } @@ -306,12 +347,10 @@ impl WantSet { } fn remove_in_flight_peer(&mut self, peer: litep2p::PeerId) -> Vec { - let mut affected: Vec = Vec::new(); + let mut affected = Vec::new(); for (cid, state) in self.inner.iter_mut() { - if state.in_flight_peers.remove(&peer).is_some() { - if state.in_flight_peers.is_empty() { - self.live -= 1; - } + if state.finish_peer(peer) { + self.live -= 1; affected.push(*cid); } } @@ -320,49 +359,28 @@ impl WantSet { } /// Expire in-flight requests whose per-peer deadline passed. Returns the affected CIDs - /// still wanted (for re-dispatch) and the total number of timed-out peer requests - /// (for metrics; also counts requests whose CID entry was removed as idle). + /// still wanted (for re-dispatch) and the total number of timed-out peer requests. fn expire_peer_timeouts(&mut self, now: Instant) -> (Vec, usize) { - let mut timed_out: Vec<(Cid, litep2p::PeerId)> = Vec::new(); + let mut timed_out = Vec::new(); for (cid, state) in self.inner.iter_mut() { - let had_in_flight = !state.in_flight_peers.is_empty(); - state.in_flight_peers.retain(|peer, deadline| { - if *deadline <= now { - timed_out.push((*cid, *peer)); - false - } else { - true + if let CidPhase::InFlight { peer, deadline } = state.phase { + if deadline <= now { + state.phase = CidPhase::Ready; + state.tried_peers.insert(peer); + timed_out.push(*cid); } - }); - if had_in_flight && state.in_flight_peers.is_empty() { - self.live -= 1; } } let timed_out_count = timed_out.len(); + self.live -= timed_out_count; - let mut cids = Vec::with_capacity(timed_out.len()); - for (cid, peer) in timed_out { - if let Some(state) = self.inner.get_mut(&cid) { - state.tried_peers.insert(peer); - cids.push(cid); - } - } - - (self.remove_idle_and_filter_existing(cids), timed_out_count) + (self.remove_idle_and_filter_existing(timed_out), timed_out_count) } - /// Start a new round for CIDs whose retry delay has passed: clear per-round peer state - /// so every connected peer is eligible again. fn restart_exhausted_rounds(&mut self, now: Instant) -> Vec { let mut cids = Vec::new(); for (cid, state) in self.inner.iter_mut() { - if state.has_waiters() && - state.in_flight_peers.is_empty() && - state.next_round_at.is_some_and(|at| at <= now) - { - state.tried_peers.clear(); - state.have_peers.clear(); - state.next_round_at = None; + if state.has_waiters() && state.restart_round(now) { cids.push(*cid); } } @@ -385,8 +403,8 @@ impl WantSet { fn remove_if_idle(&mut self, cid: Cid) { if self.inner.get(&cid).is_some_and(CidState::is_idle) { - let state = self.inner.remove(&cid).expect("just checked; qed"); - if state.pending { + let state = self.inner.remove(&cid).expect("CID exists"); + if state.is_queued() { self.queued -= 1; } } @@ -441,25 +459,15 @@ impl InboundLookupPool { let metrics = self.metrics.clone(); tokio::task::spawn_blocking(move || { let responses = serve_inbound(&*client, cids, &metrics); - // The permit travels with the result and never blocks here: the channel has one - // slot per worker, so at most one outstanding result per permit exists. let _ = result_tx.blocking_send((peer, responses, permit)); }); } } -/// Per-peer FIFO queues of inbound wantlist entries, drained round-robin in -/// [`MAX_WANTED_BLOCKS`]-entry batches so one peer's backlog cannot head-of-line-block -/// other requesters. -/// -/// Entries beyond the per-peer cap are dropped silently: bitswap clients treat wants as -/// standing state and re-send unanswered wants, while answering `DONT_HAVE` under load -/// would misreport blocks we may well have. +/// Fair per-peer queues. Overflow is dropped rather than reported as `DONT_HAVE`. struct InboundQueue { per_peer: HashMap>, - /// Peers with queued entries, each exactly once, in service order. rotation: VecDeque, - /// Per-peer entry cap. max_entries_per_peer: usize, } @@ -492,7 +500,7 @@ impl InboundQueue { /// peer to the back of the queue. fn next_batch(&mut self) -> Option<(litep2p::PeerId, Vec<(Cid, WantType)>)> { let peer = self.rotation.pop_front()?; - let queue = self.per_peer.get_mut(&peer).expect("peers in rotation have a queue; qed"); + let queue = self.per_peer.get_mut(&peer).expect("queued peer has entries"); let batch: Vec<_> = queue.drain(..queue.len().min(MAX_WANTED_BLOCKS)).collect(); if queue.is_empty() { self.per_peer.remove(&peer); @@ -507,8 +515,6 @@ pub(crate) struct BitswapService { handle: Box, cmd_rx: mpsc::Receiver, - /// Set once every [`BitswapHandle`] has been dropped; the actor then stops polling - /// `cmd_rx` but keeps serving inbound wantlists. cmd_channel_closed: bool, sync_event_stream: Pin + Send>>, inbound_lookup_pool: InboundLookupPool, @@ -522,10 +528,6 @@ pub(crate) struct BitswapService { } /// Build the Bitswap service, returning the service future and the user-facing handle. -/// -/// The future must be spawned by the caller. `litep2p_handle` is the transport-side handle -/// created by `litep2p::protocol::libp2p::bitswap::Config::new`; the corresponding `Config` -/// must be installed into the litep2p backend by the caller. pub fn start( client: Arc + Send + Sync>, sync: &S, @@ -576,9 +578,9 @@ impl BitswapService { loop { tokio::select! { event = self.handle.next_event() => match event { - Some(BitswapEvent::Request { peer, cids }) => + Some(TransportEvent::Request { peer, cids }) => self.on_inbound_request(peer, cids), - Some(BitswapEvent::Response { peer, responses }) => + Some(TransportEvent::Response { peer, responses }) => self.on_inbound_response(peer, responses).await, None => { log::debug!(target: LOG_TARGET, "litep2p bitswap stream ended; shutting down"); @@ -591,9 +593,6 @@ impl BitswapService { Some(BitswapCommand::RequestStream { cids, sink }) => self.on_request_stream(cids, sink).await, None => { - // All user handles were dropped. Keep running: the node may still - // serve inbound wantlists, and already-admitted waiters still - // resolve normally. log::debug!( target: LOG_TARGET, "all bitswap handles dropped; serving inbound requests only", @@ -615,8 +614,6 @@ impl BitswapService { Some((peer, responses, permit)) = self.inbound_lookup_rx.recv() => { self.handle.send_response(peer, responses).await; - // The response is forwarded: free the worker slot and pull in the next - // queued batch. drop(permit); self.dispatch_inbound_lookups(); }, @@ -648,9 +645,6 @@ impl BitswapService { self.top_up_in_flight(cids).await; } - /// Dispatch WANT-BLOCK requests for the given CIDs, then promote queued CIDs into any - /// remaining dispatch-window capacity. CIDs assigned to the same peer are bundled into - /// wantlist messages of up to [`MAX_WANTED_BLOCKS`] entries. async fn top_up_in_flight(&mut self, cids: impl IntoIterator) { let now = Instant::now(); let by_peer = { @@ -685,31 +679,28 @@ impl BitswapService { } } - async fn on_inbound_response(&mut self, peer: litep2p::PeerId, responses: Vec) { + async fn on_inbound_response( + &mut self, + peer: litep2p::PeerId, + responses: Vec, + ) { let mut cids_to_top_up: HashSet = HashSet::new(); for response in responses { match response { - ResponseType::Block { cid: claimed_cid, block } => { - self.wants.mark_peer_done_for_cid(peer, claimed_cid); + TransportResponse::VerifiedBlock { cid, bytes } => { + self.wants.mark_peer_done_for_cid(peer, cid); - if recompute_cid(&claimed_cid, &block) != Some(claimed_cid) { - self.metrics.record_outbound(outbound_events::VERIFICATION_FAILED, 1); - log::debug!( - target: LOG_TARGET, - "{peer:?} sent block for {claimed_cid} that failed CID verification", - ); - cids_to_top_up.insert(claimed_cid); - } else if self.wants.contains(&claimed_cid) { - self.deliver_block(claimed_cid, block); + if self.wants.contains(&cid) { + self.deliver_block(cid, bytes); } else { log::debug!( target: LOG_TARGET, - "{peer:?} sent unsolicited or unwanted block for {claimed_cid}", + "{peer:?} sent unsolicited or unwanted block for {cid}", ); } }, - ResponseType::Presence { cid, presence } => { + TransportResponse::Presence { cid, presence } => { match presence { BlockPresenceType::DontHave => { log::trace!(target: LOG_TARGET, "{peer:?} DONT_HAVE {cid}"); @@ -757,7 +748,7 @@ impl BitswapService { } async fn on_peer_connected(&mut self, peer: litep2p::PeerId, roles: Roles) { - // Light clients do not hold the indexed-transaction data served over bitswap. + // Light clients do not store indexed transactions. if roles.is_light() { return; } @@ -772,9 +763,8 @@ impl BitswapService { self.top_up_in_flight(cids_to_top_up).await; } - /// Periodic housekeeping: drop waiters whose receiver was dropped (the caller gave up - /// or applied its own timeout), expire per-peer request timeouts, and start new rounds - /// for CIDs whose retry delay has passed. + /// Periodic housekeeping: drop waiters whose receiver was dropped, expire per-peer request + /// timeouts, and start new rounds for CIDs whose retry delay has passed. async fn on_sweep(&mut self) { let abandoned: Vec = self .waiters @@ -795,14 +785,10 @@ impl BitswapService { cids.extend(restarted); self.top_up_in_flight(cids).await; - // Backstop for the inbound queue: a lookup worker that died without publishing a - // result frees its slot without waking the dispatch path. + // Recover capacity after a lookup task exits without a result. self.dispatch_inbound_lookups(); } - /// Queue an inbound wantlist for serving. Wantlists of any size are accepted and - /// served in [`MAX_WANTED_BLOCKS`]-entry batches; only entries beyond the per-peer - /// queue cap are dropped (see [`InboundQueue`] for why dropping beats `DONT_HAVE`). fn on_inbound_request(&mut self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { let dropped = self.inbound_queue.enqueue(peer, cids); if dropped > 0 { @@ -815,7 +801,6 @@ impl BitswapService { self.dispatch_inbound_lookups(); } - /// Move queued inbound batches onto free lookup workers, round-robin across peers. fn dispatch_inbound_lookups(&mut self) { while let Some(permit) = self.inbound_lookup_pool.try_acquire_worker() { let Some((peer, batch)) = self.inbound_queue.next_batch() else { break }; @@ -824,8 +809,7 @@ impl BitswapService { } fn update_metrics(&self) { - self.metrics - .set_state(self.wants.live, self.wants.queued_count(), self.waiters.len()); + self.metrics.set_state(self.wants.live, self.wants.queued, self.waiters.len()); } fn shutdown_waiters(&mut self) { @@ -837,24 +821,6 @@ impl BitswapService { } } -/// Rebuild the CID for `data` using the hashing and codec of `reference_cid`. Returns `None` -/// for unsupported multihash codes. -fn recompute_cid(reference_cid: &Cid, data: &[u8]) -> Option { - let code = reference_cid.hash().code(); - let digest = hash_for_multihash_code(code, data)?; - let mh = CidMultihash::<64>::wrap(code, &digest).ok()?; - Some(Cid::new_v1(reference_cid.codec(), mh)) -} - -fn hash_for_multihash_code(multihash_code: u64, data: &[u8]) -> Option<[u8; 32]> { - match multihash_code { - BLAKE2B_256_MULTIHASH_CODE => Some(sp_crypto_hashing::blake2_256(data)), - SHA2_256_MULTIHASH_CODE => Some(sp_crypto_hashing::sha2_256(data)), - KECCAK_256_MULTIHASH_CODE => Some(sp_crypto_hashing::keccak_256(data)), - _ => None, - } -} - fn serve_inbound( client: &(dyn BlockBackend + Send + Sync), cids: Vec<(Cid, WantType)>, @@ -894,7 +860,10 @@ fn serve_inbound( #[cfg(test)] mod tests { use super::*; - use crate::RAW_CODEC; + use crate::{ + BLAKE2B_256_MULTIHASH_CODE, KECCAK_256_MULTIHASH_CODE, RAW_CODEC, SHA2_256_MULTIHASH_CODE, + }; + use cid::multihash::Multihash as CidMultihash; use rand::{rngs::StdRng, SeedableRng}; use rstest::rstest; use sc_block_builder::BlockBuilderBuilder; @@ -910,14 +879,14 @@ mod tests { }; struct MockTransport { - inbound: AsyncMutex>, + inbound: AsyncMutex>, outbound_req_tx: mpsc::Sender<(litep2p::PeerId, Vec<(Cid, WantType)>)>, outbound_resp_tx: mpsc::Sender<(litep2p::PeerId, Vec)>, } #[async_trait] impl BitswapTransport for MockTransport { - async fn next_event(&mut self) -> Option { + async fn next_event(&mut self) -> Option { self.inbound.get_mut().recv().await } @@ -933,7 +902,7 @@ mod tests { struct TestRig { user_handle: BitswapHandle, sync_event_tx: mpsc::Sender, - inbound_tx: mpsc::Sender, + inbound_tx: mpsc::Sender, outbound_req_rx: mpsc::Receiver<(litep2p::PeerId, Vec<(Cid, WantType)>)>, outbound_resp_rx: mpsc::Receiver<(litep2p::PeerId, Vec)>, _handle: tokio::task::JoinHandle<()>, @@ -1012,7 +981,12 @@ mod tests { } fn cid_for_data(mh_code: u64, data: &[u8]) -> Cid { - let digest = hash_for_multihash_code(mh_code, data).expect("supported"); + let digest = match mh_code { + BLAKE2B_256_MULTIHASH_CODE => sp_crypto_hashing::blake2_256(data), + SHA2_256_MULTIHASH_CODE => sp_crypto_hashing::sha2_256(data), + KECCAK_256_MULTIHASH_CODE => sp_crypto_hashing::keccak_256(data), + _ => panic!("unsupported multihash code"), + }; let mh = CidMultihash::<64>::wrap(mh_code, &digest).unwrap(); Cid::new_v1(RAW_CODEC, mh) } @@ -1026,10 +1000,8 @@ mod tests { timeout(Duration::from_secs(2), rx.recv()).await.ok().flatten() } - /// [`SyncEvent`] carries [`sc_network_types::PeerId`]; convert from the byte-compatible - /// [`litep2p::PeerId`] used on the transport side. fn to_types_peer(peer: litep2p::PeerId) -> TypesPeerId { - TypesPeerId::from_bytes(&peer.to_bytes()).expect("valid peer-id bytes; qed") + TypesPeerId::from_bytes(&peer.to_bytes()).expect("peer ID bytes are valid") } fn sync_connected(peer: litep2p::PeerId) -> SyncEvent { @@ -1049,13 +1021,19 @@ mod tests { self.sync_event_tx.send(sync_connected(peer)).await.unwrap(); } - async fn send_response(&self, peer: litep2p::PeerId, responses: Vec) { - self.inbound_tx.send(BitswapEvent::Response { peer, responses }).await.unwrap(); + async fn send_response(&self, peer: litep2p::PeerId, responses: Vec) { + self.inbound_tx + .send(TransportEvent::Response { peer, responses }) + .await + .unwrap(); } async fn send_block(&self, peer: litep2p::PeerId, cid: Cid, data: &[u8]) { - self.send_response(peer, vec![ResponseType::Block { cid, block: data.to_vec() }]) - .await; + self.send_response( + peer, + vec![TransportResponse::VerifiedBlock { cid, bytes: data.to_vec() }], + ) + .await; } async fn send_presence( @@ -1064,15 +1042,15 @@ mod tests { cid: Cid, presence: BlockPresenceType, ) { - self.send_response(peer, vec![ResponseType::Presence { cid, presence }]).await; + self.send_response(peer, vec![TransportResponse::Presence { cid, presence }]) + .await; } async fn send_wantlist(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { - self.inbound_tx.send(BitswapEvent::Request { peer, cids }).await.unwrap(); + self.inbound_tx.send(TransportEvent::Request { peer, cids }).await.unwrap(); } } - /// Expect the next stream item to deliver the block for `cid` with payload `data`. async fn expect_block(rx: &mut mpsc::Receiver, cid: Cid, data: &[u8]) { match drain_next(rx).await.expect("stream item") { Ok((got_cid, bytes)) => { @@ -1128,11 +1106,9 @@ mod tests { wants.add_waiter(cid_b, waiter_id); assert_eq!(wants.next_peer_to_request(cid_a, &peers, Instant::now(), &mut rng), Some(peer)); - // Window (size 1) is full: `cid_b` must queue instead of dispatching. assert_eq!(wants.next_peer_to_request(cid_b, &peers, Instant::now(), &mut rng), None); assert!(!wants.has_window_capacity()); - // Delivering `cid_a` frees the slot; `cid_b` is promoted. wants.take_waiters_for_delivered_cid(cid_a); assert!(wants.has_window_capacity()); assert_eq!(wants.pop_pending(), Some(cid_b)); @@ -1169,7 +1145,6 @@ mod tests { let entry = |i: u8| (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32]), WantType::Block); let mut queue = InboundQueue::new(MAX_QUEUED_INBOUND_ENTRIES_PER_PEER); - // A backlogs two batches, B one entry: B is served between A's batches. assert_eq!( queue.enqueue(peer_a, (0..(MAX_WANTED_BLOCKS + 4) as u8).map(entry).collect()), 0 @@ -1192,7 +1167,6 @@ mod tests { let mut queue = InboundQueue::new(4); assert_eq!(queue.enqueue(peer, (0..3).map(entry).collect()), 0); - // Only one slot left: two of the three new entries are dropped. assert_eq!(queue.enqueue(peer, (3..6).map(entry).collect()), 2); let (_, batch) = queue.next_batch().unwrap(); @@ -1240,7 +1214,6 @@ mod tests { rig.send_presence(peer, cid, BlockPresenceType::Have).await; - // A HAVE keeps the peer eligible: it is re-asked for the block. let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("re-ask"); assert_eq!(out_peer, peer); assert_eq!(out_cids, vec![(cid, WantType::Block)]); @@ -1264,12 +1237,10 @@ mod tests { let (first, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); let other = if first == peer_a { peer_b } else { peer_a }; - // First HAVE earns the peer a re-ask. rig.send_presence(first, cid, BlockPresenceType::Have).await; let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("re-ask"); assert_eq!(out_peer, first); - // Second HAVE without the block: the peer counts as tried, the want moves on. rig.send_presence(first, cid, BlockPresenceType::Have).await; let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); @@ -1280,9 +1251,6 @@ mod tests { expect_block(&mut rx, cid, &data).await; } - /// The only peer counts as tried (via DONT_HAVE, or an unanswered request hitting the - /// per-peer timeout); after [`ROUND_RETRY_DELAY`] the round restarts and the same peer - /// is asked again. #[rstest] #[case::after_dont_have(true)] #[case::after_timeout(false)] @@ -1299,7 +1267,6 @@ mod tests { if answer_dont_have { rig.send_presence(peer, cid, BlockPresenceType::DontHave).await; - // The only peer is tried: nothing is dispatched before the round delay passes. let no_req = timeout(ROUND_RETRY_DELAY - Duration::from_secs(1), rig.outbound_req_rx.recv()) .await; @@ -1332,8 +1299,6 @@ mod tests { rig.send_presence(peer_a, cid, BlockPresenceType::DontHave).await; - // The CID is parked awaiting a new round; a fresh peer is dispatched immediately, - // without waiting for the round delay. rig.connect(peer_b).await; let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("WANT to new peer"); assert_eq!(out_peer, peer_b); @@ -1341,7 +1306,6 @@ mod tests { rig.send_block(peer_b, cid, &data).await; expect_block(&mut rx, cid, &data).await; - // The dispatch cancelled the scheduled round: no stray re-ask later. tokio::time::advance(ROUND_RETRY_DELAY * 2).await; assert!(rig.outbound_req_rx.try_recv().is_err()); } @@ -1356,10 +1320,8 @@ mod tests { rig.sync_event_tx.send(sync_connected_light(light_peer)).await.unwrap(); let _rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - // The only connected peer is a light client: no WANT must be dispatched. assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); - // Once a full peer connects, the pending want goes out to it. rig.connect(full_peer).await; let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); @@ -1367,13 +1329,8 @@ mod tests { assert_eq!(out_cids, vec![(cid, WantType::Block)]); } - /// The only peer fails the request (DONT_HAVE, or a block failing CID verification): - /// nothing is delivered and the stream stays open until the caller gives up. - #[rstest] - #[case::dont_have(None)] - #[case::corrupted_block(Some(b"NOT-the-real-payload".as_slice()))] #[tokio::test(start_paused = true)] - async fn failure_from_only_peer_leaves_stream_open(#[case] block: Option<&[u8]>) { + async fn dont_have_from_only_peer_leaves_stream_open() { let mut rig = empty_rig(); let peer = litep2p::PeerId::random(); let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, b"the-real-payload"); @@ -1383,10 +1340,7 @@ mod tests { let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); - match block { - Some(corrupted) => rig.send_block(peer, cid, corrupted).await, - None => rig.send_presence(peer, cid, BlockPresenceType::DontHave).await, - } + rig.send_presence(peer, cid, BlockPresenceType::DontHave).await; tokio::time::advance(Duration::from_secs(60)).await; assert!(matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty))); @@ -1395,7 +1349,6 @@ mod tests { enum FailoverTrigger { DontHave, Timeout, - CorruptedBlock, Disconnect, } @@ -1405,7 +1358,6 @@ mod tests { #[rstest] #[case::dont_have(FailoverTrigger::DontHave)] #[case::timeout(FailoverTrigger::Timeout)] - #[case::corrupted_block(FailoverTrigger::CorruptedBlock)] #[case::disconnect(FailoverTrigger::Disconnect)] #[tokio::test(start_paused = true)] async fn first_peer_failure_triggers_failover(#[case] trigger: FailoverTrigger) { @@ -1427,7 +1379,6 @@ mod tests { FailoverTrigger::Timeout => { tokio::time::advance(PER_PEER_TIMEOUT + Duration::from_secs(2)).await }, - FailoverTrigger::CorruptedBlock => rig.send_block(first_peer, cid, b"corrupted").await, FailoverTrigger::Disconnect => { rig.sync_event_tx.send(sync_disconnected(first_peer)).await.unwrap() }, @@ -1799,7 +1750,7 @@ mod tests { sleep(Duration::from_millis(1)).await; rig.inbound_tx - .send(BitswapEvent::Request { peer, cids: vec![(cid, WantType::Block)] }) + .send(TransportEvent::Request { peer, cids: vec![(cid, WantType::Block)] }) .await .unwrap(); @@ -1924,13 +1875,13 @@ mod tests { #[cfg(test)] mod proptests { use super::*; - use crate::RAW_CODEC; + use crate::{BLAKE2B_256_MULTIHASH_CODE, RAW_CODEC}; + use cid::multihash::Multihash as CidMultihash; use proptest::prelude::*; use rand::{rngs::StdRng, SeedableRng}; const NUM_CIDS: u8 = 8; const NUM_PEERS: usize = 4; - /// Small dispatch window so op sequences regularly hit the queueing path. const SMALL_WINDOW: usize = 3; #[derive(Debug, Clone)] @@ -1960,8 +1911,6 @@ mod proptests { struct Harness { wants: WantSet, - /// Active waiters and the CID each waits on. One CID per waiter: multi-CID - /// requests add nothing at the `WantSet` level. waiters: SlotMap, peers: Vec, cids: Vec, @@ -2001,7 +1950,7 @@ mod proptests { let Some(id) = self.waiters.keys().nth(seed % self.waiters.len().max(1)) else { return; }; - let cid = self.waiters.remove(id).expect("key just listed; qed"); + let cid = self.waiters.remove(id).expect("listed waiter exists"); self.wants.remove_waiter(cid, id); }, Op::Deliver(c) => { @@ -2034,8 +1983,6 @@ mod proptests { } } - /// Mirror `BitswapService::top_up_in_flight`: after every event the service - /// re-dispatches everything dispatchable. fn top_up(&mut self) { for cid in self.wants.all_cids() { let _ = @@ -2049,38 +1996,44 @@ mod proptests { } fn check_invariants(&self) { - let live = self.wants.inner.values().filter(|s| !s.in_flight_peers.is_empty()).count(); + let live = self + .wants + .inner + .values() + .filter(|state| matches!(state.phase, CidPhase::InFlight { .. })) + .count(); assert_eq!(self.wants.live, live, "live counter drifted"); assert!(self.wants.live <= self.wants.max_live, "dispatch window overrun"); - let queued = self.wants.inner.values().filter(|s| s.pending).count(); + let queued = self.wants.inner.values().filter(|state| state.is_queued()).count(); assert_eq!(self.wants.queued, queued, "queued counter drifted"); for (cid, state) in &self.wants.inner { - assert!(state.in_flight_peers.len() <= PEER_FANOUT_CAP, "fanout cap exceeded"); assert!(!state.is_idle(), "idle entry retained for {cid}"); - assert!( - state.in_flight_peers.keys().all(|p| !state.tried_peers.contains(p)), - "peer both tried and in flight for {cid}", - ); - if state.pending { + if let CidPhase::InFlight { peer, .. } = state.phase { + assert!(!state.tried_peers.contains(&peer), "peer both tried and in flight"); + } + if state.is_queued() { assert!( self.wants.pending.contains(cid), - "pending flag without queue entry for {cid}", + "queued CID without queue entry for {cid}", ); } - // A parked round is never overdue: the sweep restarts due rounds, and a - // dispatch re-parks strictly into the future. An overdue round that - // stays parked is the "peer blacklisted forever" bug. - if let Some(at) = state.next_round_at { + let retry_at = match state.phase { + CidPhase::Queued { retry_at } => retry_at, + CidPhase::RetryAt(at) => Some(at), + CidPhase::Ready | CidPhase::InFlight { .. } => None, + }; + if let Some(at) = retry_at { assert!(at > self.now, "overdue round never restarted for {cid}"); } - // Liveness after a top-up pass: a waited-on CID must be somewhere on the - // path to resolution — in flight, queued for a window slot, or parked - // awaiting a new round. Anything else is the stranded-forever bug. if state.has_waiters() && !self.connected.is_empty() { assert!( - !state.in_flight_peers.is_empty() || - state.pending || state.next_round_at.is_some(), + matches!( + state.phase, + CidPhase::Queued { .. } | + CidPhase::InFlight { .. } | + CidPhase::RetryAt(_) + ), "stranded CID {cid}", ); } diff --git a/substrate/client/service/src/builder.rs b/substrate/client/service/src/builder.rs index c1c7f29f95cc..6fb6ee992f1a 100644 --- a/substrate/client/service/src/builder.rs +++ b/substrate/client/service/src/builder.rs @@ -1178,16 +1178,14 @@ where } struct BitswapInitialization { - handler: Option + Send>>>, - handle: Option, - config: Option, + handler: Pin + Send>>, + handle: sc_network_bitswap::BitswapHandle, + config: IpfsConfig, } -/// Build the network service, the network status sinks and an RPC sender, this is a lower-level -/// version of [`build_network`] for those needing more control. +/// Builds the lower-level network service. /// -/// The final tuple element is the Bitswap user handle; `Some` when the IPFS server is -/// enabled in the network configuration, `None` otherwise. +/// The final tuple element contains the Bitswap handle when IPFS is enabled. pub fn build_network_advanced( params: BuildNetworkAdvancedParams, ) -> Result< @@ -1248,7 +1246,6 @@ where // install request handlers to `FullNetworkConfiguration` net_config.add_request_response_protocol(light_client_request_protocol_config); - // Initialize the IPFS server. let bitswap = if net_config.network_config.ipfs_server { if !Net::SUPPORTS_IPFS { return Err(Error::Other( @@ -1263,27 +1260,27 @@ where BlocksPruning::Some(num) => std::cmp::min(num, IPFS_MAX_BLOCKS), }; - // The bitswap service owns the transport handle; the config is installed by - // the litep2p backend during `Net::new`. let (ipfs_config, litep2p_bitswap_handle) = IpfsConfig::new( Box::new(IpfsIndexedTransactions::new(client.clone(), ipfs_num_blocks)), net_config.network_config.ipfs_bootnodes.clone(), ); - let (handler, bitswap_user_handle) = sc_network_bitswap::start::( + let (handler, handle) = sc_network_bitswap::start::( client.clone(), &*sync_service, litep2p_bitswap_handle, metrics_registry, ); - BitswapInitialization { - handler: Some(handler), - handle: Some(bitswap_user_handle), - config: Some(ipfs_config), - } + Some(BitswapInitialization { handler, handle, config: ipfs_config }) } else { - BitswapInitialization { handler: None, handle: None, config: None } + None + }; + let (bitswap_handler, bitswap_handle, ipfs_config) = match bitswap { + Some(BitswapInitialization { handler, handle, config }) => { + (Some(handler), Some(handle), Some(config)) + }, + None => (None, None, None), }; // Create transactions protocol and add it to the list of supported protocols of @@ -1315,7 +1312,7 @@ where fork_id: fork_id.map(ToOwned::to_owned), metrics_registry: metrics_registry.cloned(), block_announce_config, - ipfs_config: bitswap.config, + ipfs_config, notification_metrics: metrics, }; @@ -1323,11 +1320,7 @@ where let network_mut = Net::new(network_params)?; let network = network_mut.network_service().clone(); - // Spawn the bitswap actor only after `Net::new` succeeded so a network construction - // failure does not leave an orphan task running. Essential: on storage chains block - // import depends on the actor for indexed-transaction fetches, so if it dies the node - // must shut down instead of stalling sync silently. - if let Some(handler) = bitswap.handler { + if let Some(handler) = bitswap_handler { spawn_essential_handle.spawn("bitswap-service", Some("networking"), handler); } @@ -1387,7 +1380,7 @@ where // the service will shut down. spawn_essential_handle.spawn_blocking("network-worker", Some("networking"), future); - Ok((network, system_rpc_tx, tx_handler_controller, sync_service.clone(), bitswap.handle)) + Ok((network, system_rpc_tx, tx_handler_controller, sync_service.clone(), bitswap_handle)) } /// Configuration for [`build_default_syncing_engine`]. diff --git a/substrate/client/storage-chain-sync/src/fetcher.rs b/substrate/client/storage-chain-sync/src/fetcher.rs index 5f23d85b46f2..bd3e9fdec263 100644 --- a/substrate/client/storage-chain-sync/src/fetcher.rs +++ b/substrate/client/storage-chain-sync/src/fetcher.rs @@ -16,26 +16,23 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! Fetches indexed-transaction blobs through the shared Bitswap service. +//! Indexed-transaction fetching over Bitswap. use crate::RenewWant; use cid::{multihash::Multihash, Cid}; -use sc_network_bitswap::{BitswapError, BitswapRequest}; +use sc_network_bitswap::{BitswapError, BitswapHandle, FetchItem}; use sp_transaction_storage_proof::ContentHash; use std::{ collections::HashMap, sync::{Arc, OnceLock}, time::Duration, }; +use tokio::sync::mpsc; const LOG_TARGET: &str = "storage-chain-fetcher"; -/// Base time budget for a single [`IndexedTransactionFetcher::fetch_many`] call. const FETCH_TIMEOUT_BASE: Duration = Duration::from_secs(30); -/// Additional budget per requested CID: large wantlists queue behind the bitswap -/// service's dispatch window and need proportionally more time. const FETCH_TIMEOUT_PER_CID: Duration = Duration::from_millis(100); -/// Hard cap so a hopeless fetch cannot stall block import for too long. const FETCH_TIMEOUT_MAX: Duration = Duration::from_secs(600); fn fetch_timeout(cid_count: usize) -> Duration { @@ -43,43 +40,48 @@ fn fetch_timeout(cid_count: usize) -> Duration { FETCH_TIMEOUT_BASE.saturating_add(per_cid).min(FETCH_TIMEOUT_MAX) } -/// Late-bound slot for the bitswap handle. -/// -/// Allows constructing a fetcher before the handle exists; fetches fail with -/// [`FetchError::BitswapUnavailable`] until the slot is populated. +/// Source of Bitswap response streams. +pub trait BitswapRequest: Send + Sync { + /// Requests blocks by CID. + fn request_stream(&self, cids: Vec) -> Result, BitswapError>; +} + +impl BitswapRequest for BitswapHandle { + fn request_stream(&self, cids: Vec) -> Result, BitswapError> { + BitswapHandle::request_stream(self, cids) + } +} + +/// Late-bound Bitswap request source. pub type BitswapHandleSlot = Arc>>; -/// Infrastructure-level fetch failure surfaced to [`crate::StorageChainBlockImport`]. +/// Indexed-transaction fetch errors. #[derive(Debug, thiserror::Error)] pub enum FetchError { - /// No bitswap handle is available: bitswap is disabled on this node, or the network - /// has not been initialized yet. + /// Bitswap is unavailable. #[error("bitswap unavailable: disabled on this node, or network not yet initialized")] BitswapUnavailable, - /// CID construction failed for the given (hashing, hash) pair. + /// CID construction failed. #[error("failed to construct multihash for CID: {0}")] Multihash(String), - /// The bitswap service rejected the request at admission, or shut down mid-stream. + /// The Bitswap request failed. #[error("bitswap service error: {0}")] Bitswap(#[from] BitswapError), } -/// Fetcher that resolves indexed-transaction hashes via bitswap. +/// Fetches indexed transactions through Bitswap. #[derive(Clone)] pub struct IndexedTransactionFetcher { bitswap: BitswapHandleSlot, } impl IndexedTransactionFetcher { - /// Build a new fetcher backed by the given late-bound bitswap handle slot. + /// Creates a fetcher. pub fn new(bitswap: BitswapHandleSlot) -> Self { Self { bitswap } } - /// Resolve a batch of indexed-transaction hashes via bitswap. Each want carries the - /// runtime-declared `cid_codec` so the request CID matches what the producing runtime - /// announced. Returns only successfully fetched entries; entries unresolved when the - /// time budget expires are simply absent. + /// Fetches a batch of indexed transactions. pub(crate) async fn fetch_many( &self, wants: &[RenewWant], @@ -101,8 +103,6 @@ impl IndexedTransactionFetcher { let mut rx = match handle.request_stream(cids) { Ok(rx) => rx, - // Transient congestion: degrade to an empty partial result instead of failing - // the import. Err(BitswapError::Overloaded) => { log::debug!(target: LOG_TARGET, "bitswap service overloaded, deferring fetch"); return Ok(HashMap::new()); @@ -139,9 +139,7 @@ impl IndexedTransactionFetcher { return Ok(acquired); }, Ok(Some(Err(other))) => return Err(FetchError::Bitswap(other)), - // The stream closed: every CID was delivered. Ok(None) => break, - // Time budget expired. Dropping the receiver cancels the remaining wants. Err(_) => { log::debug!( target: LOG_TARGET, diff --git a/substrate/client/storage-chain-sync/src/lib.rs b/substrate/client/storage-chain-sync/src/lib.rs index d40503f8873d..468476957b77 100644 --- a/substrate/client/storage-chain-sync/src/lib.rs +++ b/substrate/client/storage-chain-sync/src/lib.rs @@ -34,7 +34,7 @@ mod fetcher; pub(crate) use fetcher::FetchError; -pub use fetcher::{BitswapHandleSlot, IndexedTransactionFetcher}; +pub use fetcher::{BitswapHandleSlot, BitswapRequest, IndexedTransactionFetcher}; use codec::Encode; use sc_client_api::{BlockBackend, PrefetchedIndexedTransactions}; diff --git a/substrate/client/storage-chain-sync/tests/it.rs b/substrate/client/storage-chain-sync/tests/it.rs index 7fd742c4716c..b838039a288b 100644 --- a/substrate/client/storage-chain-sync/tests/it.rs +++ b/substrate/client/storage-chain-sync/tests/it.rs @@ -525,16 +525,14 @@ mod mock { use async_trait::async_trait; use codec::{Decode, Encode}; use sc_storage_chain_sync::{ - BitswapHandleSlot, IndexedTransactionFetcher, StorageChainBlockImport, + BitswapHandleSlot, BitswapRequest, IndexedTransactionFetcher, StorageChainBlockImport, }; use sc_consensus::{ BlockCheckParams, BlockImport, BlockImportParams, ImportResult, ImportedAux, StateAction, StorageChanges as ConsensusStorageChanges, }; - use sc_network_bitswap::{ - BitswapError, BitswapRequest, Cid as BitswapCid, FetchItem, RAW_CODEC, - }; + use sc_network_bitswap::{BitswapError, Cid as BitswapCid, FetchItem, RAW_CODEC}; use sp_api::{ApiError, ConstructRuntimeApi}; use sp_consensus::{BlockOrigin, Error as ConsensusError}; use sp_core::H256; @@ -806,12 +804,6 @@ mod mock { } } - /// In-memory mock of [`BitswapRequest`] for the integration tests. - /// - /// Stores a `ContentHash -> Vec` map. On `request_stream`, returns a receiver - /// pre-loaded with the bytes of every known CID; unknown CIDs produce nothing, and the - /// closed channel signals end-of-request (the fetcher treats absent entries as - /// missing). Records every observed CID and counts every call for assertions. #[derive(Default)] pub(super) struct MockBitswap { responses: Mutex>>, @@ -855,7 +847,7 @@ mod mock { fn populated_bitswap_slot(mock: Arc) -> BitswapHandleSlot { let slot: BitswapHandleSlot = Arc::new(OnceLock::new()); - let _ = slot.set(mock as Arc); + let _ = slot.set(mock); slot } From 415027547d7b7d60f86f2a6197e51750df051475 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Fri, 17 Jul 2026 23:36:30 +0200 Subject: [PATCH 21/32] Remove bitswap init struct. --- .../client/network/bitswap/src/service.rs | 13 +++++++-- substrate/client/service/src/builder.rs | 27 ++++++------------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 1b84cecd127d..af216a0d9a95 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -66,8 +66,17 @@ enum TransportEvent { } enum TransportResponse { - VerifiedBlock { cid: Cid, bytes: Vec }, - Presence { cid: Cid, presence: BlockPresenceType }, + /// A block whose CID is guaranteed by the transport to match `bytes`. litep2p derives + /// the CID from the received bytes (the wire format only carries a CID prefix), so + /// the pairing needs no further verification; any other transport must uphold this. + VerifiedBlock { + cid: Cid, + bytes: Vec, + }, + Presence { + cid: Cid, + presence: BlockPresenceType, + }, } #[async_trait] diff --git a/substrate/client/service/src/builder.rs b/substrate/client/service/src/builder.rs index 6fb6ee992f1a..0f36fb88dd26 100644 --- a/substrate/client/service/src/builder.rs +++ b/substrate/client/service/src/builder.rs @@ -95,8 +95,6 @@ use sp_keystore::KeystorePtr; use sp_runtime::traits::{Block as BlockT, BlockIdTo, NumberFor, Zero}; use sp_storage::{ChildInfo, ChildType, PrefixedStorageKey}; use std::{ - future::Future, - pin::Pin, str::FromStr, sync::Arc, time::{Duration, SystemTime}, @@ -1177,12 +1175,6 @@ where pub blocks_pruning: BlocksPruning, } -struct BitswapInitialization { - handler: Pin + Send>>, - handle: sc_network_bitswap::BitswapHandle, - config: IpfsConfig, -} - /// Builds the lower-level network service. /// /// The final tuple element contains the Bitswap handle when IPFS is enabled. @@ -1246,7 +1238,7 @@ where // install request handlers to `FullNetworkConfiguration` net_config.add_request_response_protocol(light_client_request_protocol_config); - let bitswap = if net_config.network_config.ipfs_server { + let (ipfs_config, bitswap) = if net_config.network_config.ipfs_server { if !Net::SUPPORTS_IPFS { return Err(Error::Other( "the selected network backend does not support Bitswap; \ @@ -1272,15 +1264,9 @@ where metrics_registry, ); - Some(BitswapInitialization { handler, handle, config: ipfs_config }) + (Some(ipfs_config), Some((handler, handle))) } else { - None - }; - let (bitswap_handler, bitswap_handle, ipfs_config) = match bitswap { - Some(BitswapInitialization { handler, handle, config }) => { - (Some(handler), Some(handle), Some(config)) - }, - None => (None, None, None), + (None, None) }; // Create transactions protocol and add it to the list of supported protocols of @@ -1320,9 +1306,12 @@ where let network_mut = Net::new(network_params)?; let network = network_mut.network_service().clone(); - if let Some(handler) = bitswap_handler { + // Essential: on storage chains block import depends on the bitswap actor, so its + // death must shut the node down instead of stalling sync silently. + let bitswap_handle = bitswap.map(|(handler, handle)| { spawn_essential_handle.spawn("bitswap-service", Some("networking"), handler); - } + handle + }); let (tx_handler, tx_handler_controller) = transactions_handler_proto.build( network.clone(), From 2f861f6bcc3277aa6fcd1454cd49851efa247768 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Sat, 18 Jul 2026 20:52:29 +0200 Subject: [PATCH 22/32] Make Cid conversion more elegant --- .../client/storage-chain-sync/src/fetcher.rs | 9 ++------ .../client/storage-chain-sync/src/lib.rs | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/substrate/client/storage-chain-sync/src/fetcher.rs b/substrate/client/storage-chain-sync/src/fetcher.rs index bd3e9fdec263..8e037c594235 100644 --- a/substrate/client/storage-chain-sync/src/fetcher.rs +++ b/substrate/client/storage-chain-sync/src/fetcher.rs @@ -19,7 +19,7 @@ //! Indexed-transaction fetching over Bitswap. use crate::RenewWant; -use cid::{multihash::Multihash, Cid}; +use cid::Cid; use sc_network_bitswap::{BitswapError, BitswapHandle, FetchItem}; use sp_transaction_storage_proof::ContentHash; use std::{ @@ -61,9 +61,6 @@ pub enum FetchError { /// Bitswap is unavailable. #[error("bitswap unavailable: disabled on this node, or network not yet initialized")] BitswapUnavailable, - /// CID construction failed. - #[error("failed to construct multihash for CID: {0}")] - Multihash(String), /// The Bitswap request failed. #[error("bitswap service error: {0}")] Bitswap(#[from] BitswapError), @@ -94,9 +91,7 @@ impl IndexedTransactionFetcher { let mut by_cid: HashMap = HashMap::with_capacity(wants.len()); let mut cids: Vec = Vec::with_capacity(wants.len()); for want in wants { - let mh = Multihash::<64>::wrap(want.hashing.multihash_code(), &want.hash) - .map_err(|e| FetchError::Multihash(e.to_string()))?; - let cid = Cid::new_v1(want.cid_codec, mh); + let cid = Cid::from(*want); by_cid.insert(cid, want.hash); cids.push(cid); } diff --git a/substrate/client/storage-chain-sync/src/lib.rs b/substrate/client/storage-chain-sync/src/lib.rs index 468476957b77..3e426e4b8948 100644 --- a/substrate/client/storage-chain-sync/src/lib.rs +++ b/substrate/client/storage-chain-sync/src/lib.rs @@ -36,6 +36,7 @@ mod fetcher; pub(crate) use fetcher::FetchError; pub use fetcher::{BitswapHandleSlot, BitswapRequest, IndexedTransactionFetcher}; +use cid::{multihash::Multihash, Cid}; use codec::Encode; use sc_client_api::{BlockBackend, PrefetchedIndexedTransactions}; use sc_consensus::{ @@ -113,6 +114,14 @@ pub(crate) struct RenewWant { pub cid_codec: u64, } +impl From for Cid { + fn from(want: RenewWant) -> Self { + let multihash = Multihash::<64>::wrap(want.hashing.multihash_code(), &want.hash) + .expect("a 32-byte content hash always fits into a 64-byte multihash"); + Cid::new_v1(want.cid_codec, multihash) + } +} + /// Block-import wrapper that bitswap-fetches missing TRANSACTION-column entries /// for tip-sync blocks before delegating to the inner block import. pub struct StorageChainBlockImport { @@ -599,6 +608,18 @@ mod tests { OpaqueExtrinsic::from_blob(bytes.to_vec()) } + #[test] + fn renew_want_converts_to_cid() { + let want = + RenewWant { hash: [0xAB; 32], hashing: HashingAlgorithm::Keccak256, cid_codec: 0x70 }; + + let cid: Cid = want.into(); + + assert_eq!(cid.codec(), 0x70); + assert_eq!(cid.hash().code(), HashingAlgorithm::Keccak256.multihash_code()); + assert_eq!(cid.hash().digest(), &[0xAB; 32]); + } + fn body_info( ext: &OpaqueExtrinsic, extrinsic_index: u32, From b0c7697ede4f45b6ffc7ffb721c2351283d29fc4 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Sat, 18 Jul 2026 20:56:39 +0200 Subject: [PATCH 23/32] Simplify fetch timeout --- substrate/client/storage-chain-sync/src/fetcher.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/substrate/client/storage-chain-sync/src/fetcher.rs b/substrate/client/storage-chain-sync/src/fetcher.rs index 8e037c594235..240ea2c3e701 100644 --- a/substrate/client/storage-chain-sync/src/fetcher.rs +++ b/substrate/client/storage-chain-sync/src/fetcher.rs @@ -31,14 +31,7 @@ use tokio::sync::mpsc; const LOG_TARGET: &str = "storage-chain-fetcher"; -const FETCH_TIMEOUT_BASE: Duration = Duration::from_secs(30); -const FETCH_TIMEOUT_PER_CID: Duration = Duration::from_millis(100); -const FETCH_TIMEOUT_MAX: Duration = Duration::from_secs(600); - -fn fetch_timeout(cid_count: usize) -> Duration { - let per_cid = FETCH_TIMEOUT_PER_CID.saturating_mul(cid_count.min(u32::MAX as usize) as u32); - FETCH_TIMEOUT_BASE.saturating_add(per_cid).min(FETCH_TIMEOUT_MAX) -} +const FETCH_TIMEOUT: Duration = Duration::from_secs(30); /// Source of Bitswap response streams. pub trait BitswapRequest: Send + Sync { @@ -105,7 +98,7 @@ impl IndexedTransactionFetcher { Err(other) => return Err(FetchError::Bitswap(other)), }; - let deadline = tokio::time::Instant::now() + fetch_timeout(wants.len()); + let deadline = tokio::time::Instant::now() + FETCH_TIMEOUT; let mut acquired: HashMap> = HashMap::with_capacity(wants.len()); loop { match tokio::time::timeout_at(deadline, rx.recv()).await { From bebf8a5f583b14b98f43a7658a2863b03fb6d570 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Sat, 18 Jul 2026 21:10:42 +0200 Subject: [PATCH 24/32] Remove by_cid hashmap --- .../client/storage-chain-sync/src/fetcher.rs | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/substrate/client/storage-chain-sync/src/fetcher.rs b/substrate/client/storage-chain-sync/src/fetcher.rs index 240ea2c3e701..b253d34d5bf0 100644 --- a/substrate/client/storage-chain-sync/src/fetcher.rs +++ b/substrate/client/storage-chain-sync/src/fetcher.rs @@ -35,7 +35,7 @@ const FETCH_TIMEOUT: Duration = Duration::from_secs(30); /// Source of Bitswap response streams. pub trait BitswapRequest: Send + Sync { - /// Requests blocks by CID. + /// Requests blocks by CID. Successful stream items contain only requested CIDs. fn request_stream(&self, cids: Vec) -> Result, BitswapError>; } @@ -81,13 +81,7 @@ impl IndexedTransactionFetcher { } let handle = self.bitswap.get().ok_or(FetchError::BitswapUnavailable)?; - let mut by_cid: HashMap = HashMap::with_capacity(wants.len()); - let mut cids: Vec = Vec::with_capacity(wants.len()); - for want in wants { - let cid = Cid::from(*want); - by_cid.insert(cid, want.hash); - cids.push(cid); - } + let cids: Vec = wants.iter().copied().map(Cid::from).collect(); let mut rx = match handle.request_stream(cids) { Ok(rx) => rx, @@ -103,14 +97,17 @@ impl IndexedTransactionFetcher { loop { match tokio::time::timeout_at(deadline, rx.recv()).await { Ok(Some(Ok((cid, bytes)))) => { - if let Some(hash) = by_cid.get(&cid) { - log::debug!( - target: LOG_TARGET, - "bitswap fetched {} bytes for {hash:?}", - bytes.len(), - ); - acquired.insert(*hash, bytes); - } + let hash: ContentHash = cid + .hash() + .digest() + .try_into() + .map_err(|_| BitswapError::InvalidCid { cid })?; + log::debug!( + target: LOG_TARGET, + "bitswap fetched {} bytes for {hash:?}", + bytes.len(), + ); + acquired.insert(hash, bytes); }, Ok(Some(Err(BitswapError::ServiceClosed))) => { log::warn!( From be483cedc7a8322f87645fc5bc3066ce9b17f269 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Sat, 18 Jul 2026 21:47:16 +0200 Subject: [PATCH 25/32] Remove expects --- .../client/network/bitswap/src/service.rs | 86 +++++++++++++------ 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index af216a0d9a95..3449981e1e64 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -42,7 +42,7 @@ use smallvec::SmallVec; use sp_core::H256; use sp_runtime::traits::Block as BlockT; use std::{ - collections::{HashMap, HashSet, VecDeque}, + collections::{hash_map::Entry, HashMap, HashSet, VecDeque}, future::Future, pin::Pin, sync::Arc, @@ -186,9 +186,9 @@ impl CidState { match &mut self.phase { CidPhase::Ready => self.phase = CidPhase::RetryAt(at), CidPhase::Queued { retry_at: slot @ None } => *slot = Some(at), - CidPhase::Queued { retry_at: Some(_) } | - CidPhase::InFlight { .. } | - CidPhase::RetryAt(_) => return false, + CidPhase::Queued { retry_at: Some(_) } + | CidPhase::InFlight { .. } + | CidPhase::RetryAt(_) => return false, } true } @@ -279,6 +279,7 @@ impl WantSet { None } + /// Selects an eligible peer for `cid`, queueing it when the dispatch window is full. fn next_peer_to_request( &mut self, cid: Cid, @@ -286,13 +287,14 @@ impl WantSet { now: Instant, rng: &mut R, ) -> Option { - let state = self.inner.get(&cid)?; + let has_window_capacity = self.has_window_capacity(); + let state = self.inner.get_mut(&cid)?; if !state.has_waiters() || matches!(state.phase, CidPhase::InFlight { .. }) { return None; } - if !self.has_window_capacity() { - let state = self.inner.get_mut(&cid).expect("CID exists"); + if !has_window_capacity { + // Preserve the CID for promotion when a dispatch slot opens. if state.queue() { self.pending.push_back(cid); self.queued += 1; @@ -300,6 +302,7 @@ impl WantSet { return None; } + // Prefer peers that advertised HAVE before falling back to any untried peer. let eligible = |peer: &litep2p::PeerId| !state.tried_peers.contains(peer); let Some(peer) = state .have_peers @@ -310,7 +313,7 @@ impl WantSet { .copied() else { if !connected_peers.is_empty() { - let state = self.inner.get_mut(&cid).expect("CID exists"); + // Retry later once every connected peer has been tried. if state.schedule_retry(now + ROUND_RETRY_DELAY) { log::trace!( target: LOG_TARGET, @@ -321,7 +324,6 @@ impl WantSet { return None; }; - let state = self.inner.get_mut(&cid).expect("CID exists"); if state.is_queued() { self.queued -= 1; } @@ -411,11 +413,13 @@ impl WantSet { } fn remove_if_idle(&mut self, cid: Cid) { - if self.inner.get(&cid).is_some_and(CidState::is_idle) { - let state = self.inner.remove(&cid).expect("CID exists"); - if state.is_queued() { - self.queued -= 1; - } + let Entry::Occupied(entry) = self.inner.entry(cid) else { return }; + if !entry.get().is_idle() { + return; + } + let state = entry.remove(); + if state.is_queued() { + self.queued -= 1; } } } @@ -508,15 +512,26 @@ impl InboundQueue { /// Take the next batch of up to [`MAX_WANTED_BLOCKS`] entries, rotating the serviced /// peer to the back of the queue. fn next_batch(&mut self) -> Option<(litep2p::PeerId, Vec<(Cid, WantType)>)> { - let peer = self.rotation.pop_front()?; - let queue = self.per_peer.get_mut(&peer).expect("queued peer has entries"); - let batch: Vec<_> = queue.drain(..queue.len().min(MAX_WANTED_BLOCKS)).collect(); - if queue.is_empty() { - self.per_peer.remove(&peer); - } else { - self.rotation.push_back(peer); + while let Some(peer) = self.rotation.pop_front() { + let Some(queue) = self.per_peer.get_mut(&peer) else { + log::error!(target: LOG_TARGET, "stale peer in inbound queue rotation: {peer}"); + continue; + }; + if queue.is_empty() { + log::error!(target: LOG_TARGET, "empty peer queue in inbound rotation: {peer}"); + self.per_peer.remove(&peer); + continue; + } + + let batch: Vec<_> = queue.drain(..queue.len().min(MAX_WANTED_BLOCKS)).collect(); + if queue.is_empty() { + self.per_peer.remove(&peer); + } else { + self.rotation.push_back(peer); + } + return Some((peer, batch)); } - Some((peer, batch)) + None } } @@ -654,6 +669,8 @@ impl BitswapService { self.top_up_in_flight(cids).await; } + /// Schedules the supplied CIDs, then promotes queued CIDs until the dispatch window is full. + /// Eligible wants are grouped by peer and sent in protocol-sized batches. async fn top_up_in_flight(&mut self, cids: impl IntoIterator) { let now = Instant::now(); let by_peer = { @@ -1169,6 +1186,25 @@ mod tests { assert!(queue.next_batch().is_none()); } + #[test] + fn inbound_queue_skips_inconsistent_rotation_entries() { + let stale_peer = litep2p::PeerId::random(); + let empty_peer = litep2p::PeerId::random(); + let ready_peer = litep2p::PeerId::random(); + let entry = (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xaa; 32]), WantType::Block); + let mut queue = InboundQueue::new(MAX_QUEUED_INBOUND_ENTRIES_PER_PEER); + + queue.rotation.extend([stale_peer, empty_peer]); + queue.per_peer.insert(empty_peer, VecDeque::new()); + queue.enqueue(ready_peer, vec![entry]); + + let (peer, batch) = queue.next_batch().expect("ready peer remains serviceable"); + assert_eq!(peer, ready_peer); + assert_eq!(batch, vec![entry]); + assert!(!queue.per_peer.contains_key(&empty_peer)); + assert!(queue.next_batch().is_none()); + } + #[test] fn inbound_queue_drops_overflow_and_counts_it() { let peer = litep2p::PeerId::random(); @@ -2039,9 +2075,9 @@ mod proptests { assert!( matches!( state.phase, - CidPhase::Queued { .. } | - CidPhase::InFlight { .. } | - CidPhase::RetryAt(_) + CidPhase::Queued { .. } + | CidPhase::InFlight { .. } + | CidPhase::RetryAt(_) ), "stranded CID {cid}", ); From e21505f3378ade0d8890db75eee63662344ff882 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Sat, 18 Jul 2026 22:14:18 +0200 Subject: [PATCH 26/32] Improve comments --- .../client/network/bitswap/src/service.rs | 67 +++++++++++++++++-- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 3449981e1e64..3edc1ab95432 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -53,6 +53,8 @@ use tokio::{ time::Instant, }; +/// Transport boundary used by the Bitswap actor. +/// Implementations must verify block bytes before emitting `VerifiedBlock`. #[async_trait] trait BitswapTransport: Send { async fn next_event(&mut self) -> Option; @@ -157,6 +159,8 @@ impl CidState { matches!(self.phase, CidPhase::Queued { .. }) } + /// Moves a ready CID into the pending queue while preserving its retry deadline. + /// Returns `false` when the CID is already queued or in flight. fn queue(&mut self) -> bool { let retry_at = match self.phase { CidPhase::Ready => None, @@ -167,12 +171,16 @@ impl CidState { true } + /// Removes a CID from the pending queue and restores its prior scheduling phase. + /// Returns `false` when the state was not queued. fn dequeue(&mut self) -> bool { let CidPhase::Queued { retry_at } = self.phase else { return false }; self.phase = retry_at.map_or(CidPhase::Ready, CidPhase::RetryAt); true } + /// Finishes the active request only when `peer` currently owns it. + /// The return value indicates whether an in-flight slot was released. fn finish_peer(&mut self, peer: litep2p::PeerId) -> bool { let CidPhase::InFlight { peer: active, .. } = self.phase else { return false }; if active != peer { @@ -182,6 +190,8 @@ impl CidState { true } + /// Parks an exhausted CID until `at`, including while it waits in the queue. + /// Returns `false` when a retry is already scheduled or work remains in flight. fn schedule_retry(&mut self, at: Instant) -> bool { match &mut self.phase { CidPhase::Ready => self.phase = CidPhase::RetryAt(at), @@ -193,6 +203,8 @@ impl CidState { true } + /// Starts a new peer-selection round once the retry deadline has elapsed. + /// Peer history is cleared so every connected peer becomes eligible again. fn restart_round(&mut self, now: Instant) -> bool { self.phase = match self.phase { CidPhase::RetryAt(at) if at <= now => CidPhase::Ready, @@ -211,11 +223,11 @@ struct WantSet { inner: HashMap, /// FIFO of CIDs waiting for a free dispatch-window slot. May contain stale entries /// (resolved, abandoned or already-dispatched CIDs); those are skipped on pop, guarded - /// by [`CidState::pending`]. + /// by [`CidState::is_queued`]. pending: VecDeque, /// Number of CIDs with at least one in-flight peer request. live: usize, - /// Number of CIDs with [`CidState::pending`] set, maintained incrementally: the + /// Number of queued [`CidState`] entries, maintained incrementally: the /// metrics path reads it after every actor event, so recomputing it by scanning /// `inner` would make large requests quadratic. queued: usize, @@ -240,6 +252,8 @@ impl WantSet { self.inner.entry(cid).or_default().waiters.push(waiter); } + /// Detaches a user request from `cid` and removes newly idle state. + /// In-flight state remains until its response, timeout, or disconnect arrives. fn remove_waiter(&mut self, cid: Cid, waiter: WaiterId) { if let Some(state) = self.inner.get_mut(&cid) { state.waiters.retain(|w| *w != waiter); @@ -251,6 +265,8 @@ impl WantSet { self.inner.keys().copied().collect() } + /// Removes a delivered CID and returns every waiter that should receive it. + /// Scheduler counters are released according to the CID's previous phase. fn take_waiters_for_delivered_cid(&mut self, cid: Cid) -> Option> { self.inner.remove(&cid).map(|state| { if matches!(state.phase, CidPhase::InFlight { .. }) { @@ -267,6 +283,8 @@ impl WantSet { self.live < self.max_live } + /// Pops the next still-valid queued CID and updates its queue accounting. + /// Stale FIFO entries are skipped until a queued state is found. fn pop_pending(&mut self) -> Option { while let Some(cid) = self.pending.pop_front() { if let Some(state) = self.inner.get_mut(&cid) { @@ -280,6 +298,7 @@ impl WantSet { } /// Selects an eligible peer for `cid`, queueing it when the dispatch window is full. + /// A selected peer owns one live slot until completion, timeout, or disconnect. fn next_peer_to_request( &mut self, cid: Cid, @@ -333,6 +352,8 @@ impl WantSet { Some(peer) } + /// Records a terminal response from `peer` and releases its active slot. + /// Late responses cannot finish a newer request owned by a different peer. fn mark_peer_done_for_cid(&mut self, peer: litep2p::PeerId, cid: Cid) { if let Some(state) = self.inner.get_mut(&cid) { if state.finish_peer(peer) { @@ -343,6 +364,8 @@ impl WantSet { self.remove_if_idle(cid); } + /// Records that `peer` has a CID and releases its active request. + /// A repeated `HAVE` marks that peer tried so another peer is selected next. fn note_peer_have_for_cid(&mut self, peer: litep2p::PeerId, cid: Cid) { if let Some(state) = self.inner.get_mut(&cid) { if state.have_peers.contains(&peer) { @@ -357,6 +380,8 @@ impl WantSet { self.remove_if_idle(cid); } + /// Releases every active request owned by a disconnected peer. + /// Returned CIDs are still wanted and ready for immediate failover. fn remove_in_flight_peer(&mut self, peer: litep2p::PeerId) -> Vec { let mut affected = Vec::new(); for (cid, state) in self.inner.iter_mut() { @@ -388,6 +413,8 @@ impl WantSet { (self.remove_idle_and_filter_existing(timed_out), timed_out_count) } + /// Restarts every exhausted CID whose retry delay has elapsed. + /// Returned CIDs should be reconsidered for immediate dispatch. fn restart_exhausted_rounds(&mut self, now: Instant) -> Vec { let mut cids = Vec::new(); for (cid, state) in self.inner.iter_mut() { @@ -405,6 +432,8 @@ impl WantSet { self.queued = 0; } + /// Removes idle states from `cids` and returns those still tracked. + /// The filtered result is safe to feed back into peer selection. fn remove_idle_and_filter_existing(&mut self, cids: Vec) -> Vec { for cid in &cids { self.remove_if_idle(*cid); @@ -454,13 +483,14 @@ impl InboundLookupPool { ) } - /// Reserve a worker slot, or `None` if all workers are busy. Dropping the permit - /// unused returns the slot. + /// Reserves a worker slot, or returns `None` if all workers are busy. + /// Dropping an unused permit immediately returns the slot. fn try_acquire_worker(&self) -> Option { self.semaphore.clone().try_acquire_owned().ok() } - /// Serve the wantlist on a blocking worker occupying `permit`'s slot. + /// Serves a wantlist on a blocking worker occupying `permit`'s slot. + /// The permit travels with the result so backpressure retains the slot. fn submit( &self, permit: OwnedSemaphorePermit, @@ -499,6 +529,7 @@ impl InboundQueue { let free = self.max_entries_per_peer.saturating_sub(queue.len()); let dropped = entries.len().saturating_sub(free); entries.truncate(free); + // Enter the rotation only on the empty-to-nonempty transition. if queue.is_empty() && !entries.is_empty() { self.rotation.push_back(peer); } @@ -592,6 +623,8 @@ where } impl BitswapService { + /// Runs the service actor until a required input stream closes. + /// Each event is handled serially before metrics are refreshed. async fn run(mut self) { log::debug!(target: LOG_TARGET, "BitswapService starting"); let mut sweep_ticker = tokio::time::interval(SWEEP_INTERVAL); @@ -650,6 +683,8 @@ impl BitswapService { } } + /// Admits a user wantlist and attaches one waiter to all requested CIDs. + /// Requests exceeding the per-CID waiter limit are rejected as overloaded. async fn on_request_stream(&mut self, cids: Vec, sink: mpsc::Sender) { for cid in &cids { if self.wants.waiter_count(cid) >= MAX_WAITERS_PER_CID { @@ -685,6 +720,7 @@ impl BitswapService { } } + // Fill remaining dispatch slots from the pending FIFO. while self.wants.has_window_capacity() { let Some(cid) = self.wants.pop_pending() else { break }; if let Some(peer) = @@ -705,6 +741,8 @@ impl BitswapService { } } + /// Applies verified blocks and presence updates received from a peer. + /// Unresolved presence responses are immediately reconsidered for dispatch. async fn on_inbound_response( &mut self, peer: litep2p::PeerId, @@ -715,6 +753,7 @@ impl BitswapService { for response in responses { match response { TransportResponse::VerifiedBlock { cid, bytes } => { + // A late verified block may satisfy a want reassigned to another peer. self.wants.mark_peer_done_for_cid(peer, cid); if self.wants.contains(&cid) { @@ -745,6 +784,8 @@ impl BitswapService { self.top_up_in_flight(cids_to_top_up).await; } + /// Delivers a resolved block to every waiter sharing its CID. + /// The CID is removed once, releasing any replacement in-flight request. fn deliver_block(&mut self, cid: Cid, bytes: Vec) { let Some(waiter_ids) = self.wants.take_waiters_for_delivered_cid(cid) else { return }; self.metrics.record_outbound(outbound_events::DELIVERED, 1); @@ -765,6 +806,8 @@ impl BitswapService { } } + /// Removes a waiter and detaches it from every unresolved CID. + /// CID state is retained only while another waiter or request needs it. fn drop_waiter(&mut self, id: WaiterId) { let Some(waiter) = self.waiters.remove(id) else { return }; @@ -773,8 +816,9 @@ impl BitswapService { } } + /// Adds a connected non-light peer and reconsiders every outstanding CID. + /// Light peers are ignored because they do not store indexed transactions. async fn on_peer_connected(&mut self, peer: litep2p::PeerId, roles: Roles) { - // Light clients do not store indexed transactions. if roles.is_light() { return; } @@ -783,6 +827,8 @@ impl BitswapService { self.top_up_in_flight(cids).await; } + /// Removes a peer and immediately fails over requests it owned. + /// Late responses remain safe because peer ownership is checked on completion. async fn on_peer_disconnected(&mut self, peer: litep2p::PeerId) { self.connected_peers.remove(&peer); let cids_to_top_up = self.wants.remove_in_flight_peer(peer); @@ -815,6 +861,8 @@ impl BitswapService { self.dispatch_inbound_lookups(); } + /// Queues an inbound peer wantlist and records entries dropped by backpressure. + /// Available lookup workers are dispatched immediately after admission. fn on_inbound_request(&mut self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { let dropped = self.inbound_queue.enqueue(peer, cids); if dropped > 0 { @@ -827,6 +875,8 @@ impl BitswapService { self.dispatch_inbound_lookups(); } + /// Starts queued inbound lookups while worker permits remain available. + /// Work stays queued when either the pool is full or no batch is ready. fn dispatch_inbound_lookups(&mut self) { while let Some(permit) = self.inbound_lookup_pool.try_acquire_worker() { let Some((peer, batch)) = self.inbound_queue.next_batch() else { break }; @@ -838,6 +888,8 @@ impl BitswapService { self.metrics.set_state(self.wants.live, self.wants.queued, self.waiters.len()); } + /// Notifies every waiter that the service closed and clears scheduler state. + /// Send failures are ignored because the corresponding receiver already vanished. fn shutdown_waiters(&mut self) { for (_, waiter) in self.waiters.drain() { let _ = waiter.sink.try_send(Err(BitswapError::ServiceClosed)); @@ -847,6 +899,8 @@ impl BitswapService { } } +/// Resolves an inbound wantlist against locally indexed transactions. +/// Unsupported CIDs and missing transactions become `DONT_HAVE` responses. fn serve_inbound( client: &(dyn BlockBackend + Send + Sync), cids: Vec<(Cid, WantType)>, @@ -860,6 +914,7 @@ fn serve_inbound( metrics.record_entry(metric_outcomes::UNSUPPORTED_CID); return ResponseType::Presence { cid, presence: BlockPresenceType::DontHave }; } + // Supported CIDs always carry a 32-byte digest. let hash = H256::from_slice(&cid.hash().digest()[0..32]); let transaction = match client.indexed_transaction(hash) { Ok(t) => t, From 520d4136b0dac28971d77d5299a5c34d0bb6dad8 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Sat, 18 Jul 2026 22:16:46 +0200 Subject: [PATCH 27/32] fmt --- substrate/client/network/bitswap/src/service.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 3edc1ab95432..94ccf85ab9f1 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -196,9 +196,9 @@ impl CidState { match &mut self.phase { CidPhase::Ready => self.phase = CidPhase::RetryAt(at), CidPhase::Queued { retry_at: slot @ None } => *slot = Some(at), - CidPhase::Queued { retry_at: Some(_) } - | CidPhase::InFlight { .. } - | CidPhase::RetryAt(_) => return false, + CidPhase::Queued { retry_at: Some(_) } | + CidPhase::InFlight { .. } | + CidPhase::RetryAt(_) => return false, } true } @@ -2130,9 +2130,9 @@ mod proptests { assert!( matches!( state.phase, - CidPhase::Queued { .. } - | CidPhase::InFlight { .. } - | CidPhase::RetryAt(_) + CidPhase::Queued { .. } | + CidPhase::InFlight { .. } | + CidPhase::RetryAt(_) ), "stranded CID {cid}", ); From dbddd152092f4cde89cc6decd463987f2c769485 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Sat, 18 Jul 2026 22:26:54 +0200 Subject: [PATCH 28/32] Split tests out into separate file --- .../client/network/bitswap/src/service.rs | 1215 +---------------- .../network/bitswap/src/service/tests.rs | 1204 ++++++++++++++++ 2 files changed, 1205 insertions(+), 1214 deletions(-) create mode 100644 substrate/client/network/bitswap/src/service/tests.rs diff --git a/substrate/client/network/bitswap/src/service.rs b/substrate/client/network/bitswap/src/service.rs index 94ccf85ab9f1..4680326028ac 100644 --- a/substrate/client/network/bitswap/src/service.rs +++ b/substrate/client/network/bitswap/src/service.rs @@ -939,1217 +939,4 @@ fn serve_inbound( } #[cfg(test)] -mod tests { - use super::*; - use crate::{ - BLAKE2B_256_MULTIHASH_CODE, KECCAK_256_MULTIHASH_CODE, RAW_CODEC, SHA2_256_MULTIHASH_CODE, - }; - use cid::multihash::Multihash as CidMultihash; - use rand::{rngs::StdRng, SeedableRng}; - use rstest::rstest; - use sc_block_builder::BlockBuilderBuilder; - use sc_network_sync::SyncEvent; - use sc_network_types::PeerId as TypesPeerId; - use sp_consensus::BlockOrigin; - use sp_runtime::codec::Encode; - use substrate_test_runtime::ExtrinsicBuilder; - use substrate_test_runtime_client::{prelude::*, TestClientBuilder}; - use tokio::{ - sync::Mutex as AsyncMutex, - time::{sleep, timeout}, - }; - - struct MockTransport { - inbound: AsyncMutex>, - outbound_req_tx: mpsc::Sender<(litep2p::PeerId, Vec<(Cid, WantType)>)>, - outbound_resp_tx: mpsc::Sender<(litep2p::PeerId, Vec)>, - } - - #[async_trait] - impl BitswapTransport for MockTransport { - async fn next_event(&mut self) -> Option { - self.inbound.get_mut().recv().await - } - - async fn send_request(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { - let _ = self.outbound_req_tx.send((peer, cids)).await; - } - - async fn send_response(&self, peer: litep2p::PeerId, responses: Vec) { - let _ = self.outbound_resp_tx.send((peer, responses)).await; - } - } - - struct TestRig { - user_handle: BitswapHandle, - sync_event_tx: mpsc::Sender, - inbound_tx: mpsc::Sender, - outbound_req_rx: mpsc::Receiver<(litep2p::PeerId, Vec<(Cid, WantType)>)>, - outbound_resp_rx: mpsc::Receiver<(litep2p::PeerId, Vec)>, - _handle: tokio::task::JoinHandle<()>, - } - - fn build_rig_with( - client: Arc + Send + Sync>, - max_live_cids: usize, - ) -> TestRig { - build_rig_with_inbound_limits( - client, - max_live_cids, - MAX_CONCURRENT_INBOUND_LOOKUPS, - MAX_QUEUED_INBOUND_ENTRIES_PER_PEER, - ) - } - - fn build_rig_with_inbound_limits( - client: Arc + Send + Sync>, - max_live_cids: usize, - max_lookups: usize, - queued_entries_per_peer: usize, - ) -> TestRig { - let (inbound_tx, inbound_rx) = mpsc::channel(64); - let (outbound_req_tx, outbound_req_rx) = mpsc::channel(64); - let (outbound_resp_tx, outbound_resp_rx) = mpsc::channel(64); - let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); - let (sync_event_tx, sync_event_rx) = mpsc::channel::(64); - let metrics = BitswapMetrics::default(); - let (inbound_lookup_pool, inbound_lookup_rx) = - InboundLookupPool::new(client, max_lookups, metrics.clone()); - - let transport = MockTransport { - inbound: AsyncMutex::new(inbound_rx), - outbound_req_tx, - outbound_resp_tx, - }; - - let sync_event_stream: Pin + Send>> = - Box::pin(tokio_stream::wrappers::ReceiverStream::new(sync_event_rx)); - - let service: BitswapService = BitswapService { - handle: Box::new(transport), - cmd_rx, - cmd_channel_closed: false, - sync_event_stream, - inbound_lookup_pool, - inbound_lookup_rx, - inbound_queue: InboundQueue::new(queued_entries_per_peer), - connected_peers: HashSet::new(), - wants: WantSet::new(max_live_cids), - waiters: SlotMap::with_key(), - metrics, - }; - - let user_handle = BitswapHandle::new(cmd_tx); - let _handle = tokio::spawn(async move { service.run().await }); - - TestRig { - user_handle, - sync_event_tx, - inbound_tx, - outbound_req_rx, - outbound_resp_rx, - _handle, - } - } - - fn empty_rig() -> TestRig { - small_window_rig(MAX_LIVE_CIDS) - } - - fn small_window_rig(max_live_cids: usize) -> TestRig { - let client = Arc::new(substrate_test_runtime_client::new()); - build_rig_with(client, max_live_cids) - } - - fn cid_for_data(mh_code: u64, data: &[u8]) -> Cid { - let digest = match mh_code { - BLAKE2B_256_MULTIHASH_CODE => sp_crypto_hashing::blake2_256(data), - SHA2_256_MULTIHASH_CODE => sp_crypto_hashing::sha2_256(data), - KECCAK_256_MULTIHASH_CODE => sp_crypto_hashing::keccak_256(data), - _ => panic!("unsupported multihash code"), - }; - let mh = CidMultihash::<64>::wrap(mh_code, &digest).unwrap(); - Cid::new_v1(RAW_CODEC, mh) - } - - fn cid_for_digest(mh_code: u64, digest: [u8; 32]) -> Cid { - let mh = CidMultihash::<64>::wrap(mh_code, &digest).unwrap(); - Cid::new_v1(RAW_CODEC, mh) - } - - async fn drain_next(rx: &mut mpsc::Receiver) -> Option { - timeout(Duration::from_secs(2), rx.recv()).await.ok().flatten() - } - - fn to_types_peer(peer: litep2p::PeerId) -> TypesPeerId { - TypesPeerId::from_bytes(&peer.to_bytes()).expect("peer ID bytes are valid") - } - - fn sync_connected(peer: litep2p::PeerId) -> SyncEvent { - SyncEvent::PeerConnected { peer_id: to_types_peer(peer), roles: Roles::FULL } - } - - fn sync_connected_light(peer: litep2p::PeerId) -> SyncEvent { - SyncEvent::PeerConnected { peer_id: to_types_peer(peer), roles: Roles::LIGHT } - } - - fn sync_disconnected(peer: litep2p::PeerId) -> SyncEvent { - SyncEvent::PeerDisconnected(to_types_peer(peer)) - } - - impl TestRig { - async fn connect(&self, peer: litep2p::PeerId) { - self.sync_event_tx.send(sync_connected(peer)).await.unwrap(); - } - - async fn send_response(&self, peer: litep2p::PeerId, responses: Vec) { - self.inbound_tx - .send(TransportEvent::Response { peer, responses }) - .await - .unwrap(); - } - - async fn send_block(&self, peer: litep2p::PeerId, cid: Cid, data: &[u8]) { - self.send_response( - peer, - vec![TransportResponse::VerifiedBlock { cid, bytes: data.to_vec() }], - ) - .await; - } - - async fn send_presence( - &self, - peer: litep2p::PeerId, - cid: Cid, - presence: BlockPresenceType, - ) { - self.send_response(peer, vec![TransportResponse::Presence { cid, presence }]) - .await; - } - - async fn send_wantlist(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { - self.inbound_tx.send(TransportEvent::Request { peer, cids }).await.unwrap(); - } - } - - async fn expect_block(rx: &mut mpsc::Receiver, cid: Cid, data: &[u8]) { - match drain_next(rx).await.expect("stream item") { - Ok((got_cid, bytes)) => { - assert_eq!(got_cid, cid); - assert_eq!(bytes, data); - }, - other => panic!("expected block, got {other:?}"), - } - } - - fn assert_single_dont_have(responses: &[ResponseType], cid: Cid) { - assert!(matches!( - responses, - [ResponseType::Presence { cid: got, presence: BlockPresenceType::DontHave }] - if *got == cid - )); - } - - #[test] - fn want_set_removes_cid_after_last_waiter_and_peer_complete() { - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xaa; 32]); - let peer = litep2p::PeerId::random(); - let mut waiter_ids = SlotMap::with_key(); - let waiter_id = waiter_ids.insert(()); - let mut wants = WantSet::new(MAX_LIVE_CIDS); - let mut rng = StdRng::seed_from_u64(0); - - wants.add_waiter(cid, waiter_id); - let selected = wants - .next_peer_to_request(cid, &HashSet::from([peer]), Instant::now(), &mut rng) - .unwrap(); - assert_eq!(selected, peer); - - wants.remove_waiter(cid, waiter_id); - assert!(wants.contains(&cid)); - - wants.mark_peer_done_for_cid(peer, cid); - assert!(!wants.contains(&cid)); - } - - #[test] - fn want_set_window_queues_and_promotes() { - let cid_a = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x01; 32]); - let cid_b = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x02; 32]); - let peer = litep2p::PeerId::random(); - let peers = HashSet::from([peer]); - let mut waiter_ids = SlotMap::with_key(); - let waiter_id = waiter_ids.insert(()); - let mut wants = WantSet::new(1); - let mut rng = StdRng::seed_from_u64(0); - - wants.add_waiter(cid_a, waiter_id); - wants.add_waiter(cid_b, waiter_id); - - assert_eq!(wants.next_peer_to_request(cid_a, &peers, Instant::now(), &mut rng), Some(peer)); - assert_eq!(wants.next_peer_to_request(cid_b, &peers, Instant::now(), &mut rng), None); - assert!(!wants.has_window_capacity()); - - wants.take_waiters_for_delivered_cid(cid_a); - assert!(wants.has_window_capacity()); - assert_eq!(wants.pop_pending(), Some(cid_b)); - assert_eq!(wants.next_peer_to_request(cid_b, &peers, Instant::now(), &mut rng), Some(peer)); - assert_eq!(wants.pop_pending(), None); - } - - #[test] - fn peer_selection_varies_across_cids() { - let peers: HashSet<_> = (0..3).map(|_| litep2p::PeerId::random()).collect(); - let mut waiter_ids = SlotMap::with_key(); - let mut wants = WantSet::new(32); - let mut rng = StdRng::seed_from_u64(0); - let mut selected = HashSet::new(); - - for byte in 0..32 { - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [byte; 32]); - let waiter = waiter_ids.insert(()); - wants.add_waiter(cid, waiter); - selected.insert( - wants - .next_peer_to_request(cid, &peers, Instant::now(), &mut rng) - .expect("a connected peer is eligible"), - ); - } - - assert!(selected.len() > 1, "fresh CIDs should not all select the same peer"); - } - - #[test] - fn inbound_queue_rotates_peers_between_batches() { - let peer_a = litep2p::PeerId::random(); - let peer_b = litep2p::PeerId::random(); - let entry = |i: u8| (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32]), WantType::Block); - let mut queue = InboundQueue::new(MAX_QUEUED_INBOUND_ENTRIES_PER_PEER); - - assert_eq!( - queue.enqueue(peer_a, (0..(MAX_WANTED_BLOCKS + 4) as u8).map(entry).collect()), - 0 - ); - assert_eq!(queue.enqueue(peer_b, vec![entry(0xff)]), 0); - - let (peer, batch) = queue.next_batch().unwrap(); - assert_eq!((peer, batch.len()), (peer_a, MAX_WANTED_BLOCKS)); - let (peer, batch) = queue.next_batch().unwrap(); - assert_eq!((peer, batch.len()), (peer_b, 1)); - let (peer, batch) = queue.next_batch().unwrap(); - assert_eq!((peer, batch.len()), (peer_a, 4)); - assert!(queue.next_batch().is_none()); - } - - #[test] - fn inbound_queue_skips_inconsistent_rotation_entries() { - let stale_peer = litep2p::PeerId::random(); - let empty_peer = litep2p::PeerId::random(); - let ready_peer = litep2p::PeerId::random(); - let entry = (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xaa; 32]), WantType::Block); - let mut queue = InboundQueue::new(MAX_QUEUED_INBOUND_ENTRIES_PER_PEER); - - queue.rotation.extend([stale_peer, empty_peer]); - queue.per_peer.insert(empty_peer, VecDeque::new()); - queue.enqueue(ready_peer, vec![entry]); - - let (peer, batch) = queue.next_batch().expect("ready peer remains serviceable"); - assert_eq!(peer, ready_peer); - assert_eq!(batch, vec![entry]); - assert!(!queue.per_peer.contains_key(&empty_peer)); - assert!(queue.next_batch().is_none()); - } - - #[test] - fn inbound_queue_drops_overflow_and_counts_it() { - let peer = litep2p::PeerId::random(); - let entry = |i: u8| (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32]), WantType::Block); - let mut queue = InboundQueue::new(4); - - assert_eq!(queue.enqueue(peer, (0..3).map(entry).collect()), 0); - assert_eq!(queue.enqueue(peer, (3..6).map(entry).collect()), 2); - - let (_, batch) = queue.next_batch().unwrap(); - let kept: Vec = batch.into_iter().map(|(cid, _)| cid).collect(); - assert_eq!( - kept, - (0..4) - .map(|i| cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32])) - .collect::>() - ); - assert!(queue.next_batch().is_none()); - } - - #[tokio::test(start_paused = true)] - async fn single_cid_single_peer_block_response() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let data = b"payload-a".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.connect(peer).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - - let (out_peer, out_cids) = - drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); - assert_eq!(out_peer, peer); - assert_eq!(out_cids, vec![(cid, WantType::Block)]); - - rig.send_block(peer, cid, &data).await; - expect_block(&mut rx, cid, &data).await; - } - - #[tokio::test(start_paused = true)] - async fn have_response_reasks_same_peer_then_delivers() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let data = b"payload-have".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.connect(peer).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - - let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - assert_eq!(out_peer, peer); - - rig.send_presence(peer, cid, BlockPresenceType::Have).await; - - let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("re-ask"); - assert_eq!(out_peer, peer); - assert_eq!(out_cids, vec![(cid, WantType::Block)]); - - rig.send_block(peer, cid, &data).await; - expect_block(&mut rx, cid, &data).await; - } - - #[tokio::test(start_paused = true)] - async fn second_have_without_block_moves_to_other_peer() { - let mut rig = empty_rig(); - let peer_a = litep2p::PeerId::random(); - let peer_b = litep2p::PeerId::random(); - let data = b"payload-have-2".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.connect(peer_a).await; - rig.connect(peer_b).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - - let (first, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - let other = if first == peer_a { peer_b } else { peer_a }; - - rig.send_presence(first, cid, BlockPresenceType::Have).await; - let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("re-ask"); - assert_eq!(out_peer, first); - - rig.send_presence(first, cid, BlockPresenceType::Have).await; - let (out_peer, out_cids) = - drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); - assert_eq!(out_peer, other); - assert_eq!(out_cids, vec![(cid, WantType::Block)]); - - rig.send_block(other, cid, &data).await; - expect_block(&mut rx, cid, &data).await; - } - - #[rstest] - #[case::after_dont_have(true)] - #[case::after_timeout(false)] - #[tokio::test(start_paused = true)] - async fn exhausted_round_reasks_peer_after_delay(#[case] answer_dont_have: bool) { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let data = b"payload-round".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.connect(peer).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - let _ = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - - if answer_dont_have { - rig.send_presence(peer, cid, BlockPresenceType::DontHave).await; - let no_req = - timeout(ROUND_RETRY_DELAY - Duration::from_secs(1), rig.outbound_req_rx.recv()) - .await; - assert!(no_req.is_err()); - } - - let (out_peer, out_cids) = - timeout(PER_PEER_TIMEOUT + ROUND_RETRY_DELAY * 2, rig.outbound_req_rx.recv()) - .await - .expect("new-round WANT") - .expect("transport channel open"); - assert_eq!(out_peer, peer); - assert_eq!(out_cids, vec![(cid, WantType::Block)]); - - rig.send_block(peer, cid, &data).await; - expect_block(&mut rx, cid, &data).await; - } - - #[tokio::test(start_paused = true)] - async fn new_peer_is_asked_immediately_while_round_is_parked() { - let mut rig = empty_rig(); - let peer_a = litep2p::PeerId::random(); - let peer_b = litep2p::PeerId::random(); - let data = b"payload-round-3".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.connect(peer_a).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - let _ = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - - rig.send_presence(peer_a, cid, BlockPresenceType::DontHave).await; - - rig.connect(peer_b).await; - let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("WANT to new peer"); - assert_eq!(out_peer, peer_b); - - rig.send_block(peer_b, cid, &data).await; - expect_block(&mut rx, cid, &data).await; - - tokio::time::advance(ROUND_RETRY_DELAY * 2).await; - assert!(rig.outbound_req_rx.try_recv().is_err()); - } - - #[tokio::test(start_paused = true)] - async fn light_client_peers_are_not_tracked() { - let mut rig = empty_rig(); - let light_peer = litep2p::PeerId::random(); - let full_peer = litep2p::PeerId::random(); - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [9u8; 32]); - - rig.sync_event_tx.send(sync_connected_light(light_peer)).await.unwrap(); - let _rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - - assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); - - rig.connect(full_peer).await; - let (out_peer, out_cids) = - drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); - assert_eq!(out_peer, full_peer); - assert_eq!(out_cids, vec![(cid, WantType::Block)]); - } - - #[tokio::test(start_paused = true)] - async fn dont_have_from_only_peer_leaves_stream_open() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, b"the-real-payload"); - - rig.connect(peer).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - - let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); - - rig.send_presence(peer, cid, BlockPresenceType::DontHave).await; - - tokio::time::advance(Duration::from_secs(60)).await; - assert!(matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty))); - } - - enum FailoverTrigger { - DontHave, - Timeout, - Disconnect, - } - - /// However the first peer fails the request — DONT_HAVE, an unanswered request timing - /// out, a block failing CID verification, or disconnecting — the want fails over to - /// the other connected peer. - #[rstest] - #[case::dont_have(FailoverTrigger::DontHave)] - #[case::timeout(FailoverTrigger::Timeout)] - #[case::disconnect(FailoverTrigger::Disconnect)] - #[tokio::test(start_paused = true)] - async fn first_peer_failure_triggers_failover(#[case] trigger: FailoverTrigger) { - let mut rig = empty_rig(); - let peer_a = litep2p::PeerId::random(); - let peer_b = litep2p::PeerId::random(); - let data = b"failover-payload".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.connect(peer_a).await; - rig.connect(peer_b).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); - - match trigger { - FailoverTrigger::DontHave => { - rig.send_presence(first_peer, cid, BlockPresenceType::DontHave).await - }, - FailoverTrigger::Timeout => { - tokio::time::advance(PER_PEER_TIMEOUT + Duration::from_secs(2)).await - }, - FailoverTrigger::Disconnect => { - rig.sync_event_tx.send(sync_disconnected(first_peer)).await.unwrap() - }, - } - - let (second_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); - assert_ne!(first_peer, second_peer); - - rig.send_block(second_peer, cid, &data).await; - expect_block(&mut rx, cid, &data).await; - } - - #[tokio::test(start_paused = true)] - async fn receiver_drop_cancels_wants() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [1u8; 32]); - - let rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - - // No peers connected: nothing is dispatched, the want just sits there. - assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); - - // The caller gives up; the sweep drops the abandoned waiter. - drop(rx); - tokio::time::advance(Duration::from_secs(2)).await; - - // A peer connecting afterwards must not trigger a WANT for the cancelled request. - rig.connect(peer).await; - assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); - } - - #[tokio::test(start_paused = true)] - async fn two_waiters_overlapping_cid_both_get_block() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let data = b"shared".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.connect(peer).await; - - let mut rx_a = rig.user_handle.request_stream(vec![cid]).unwrap(); - let mut rx_b = rig.user_handle.request_stream(vec![cid]).unwrap(); - - let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); - - rig.send_block(peer, cid, &data).await; - expect_block(&mut rx_a, cid, &data).await; - expect_block(&mut rx_b, cid, &data).await; - } - - #[tokio::test(start_paused = true)] - async fn waiter_drop_does_not_break_other_waiter() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let data = b"survivor".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.connect(peer).await; - - let rx_a = rig.user_handle.request_stream(vec![cid]).unwrap(); - let mut rx_b = rig.user_handle.request_stream(vec![cid]).unwrap(); - - drop(rx_a); - sleep(Duration::from_millis(1)).await; - - let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); - - rig.send_block(peer, cid, &data).await; - expect_block(&mut rx_b, cid, &data).await; - } - - #[tokio::test(start_paused = true)] - async fn dispatch_window_queues_excess_cids_and_promotes_on_delivery() { - let mut rig = small_window_rig(4); - let peer = litep2p::PeerId::random(); - rig.connect(peer).await; - - let payloads: Vec> = (0..5u8).map(|i| vec![i; 8]).collect(); - let cids: Vec = - payloads.iter().map(|p| cid_for_data(BLAKE2B_256_MULTIHASH_CODE, p)).collect(); - - let mut rx = rig.user_handle.request_stream(cids.clone()).unwrap(); - - // Only the window (4 CIDs) is dispatched; the fifth is queued. - let (_, entries) = drain_next(&mut rig.outbound_req_rx).await.expect("first bundle"); - assert_eq!(entries.len(), 4); - let dispatched: HashSet = entries.iter().map(|(cid, _)| *cid).collect(); - let queued_idx = - cids.iter().position(|cid| !dispatched.contains(cid)).expect("one CID queued"); - - // Answering one dispatched CID frees a slot and promotes the queued CID. - let answered_idx = cids.iter().position(|cid| dispatched.contains(cid)).unwrap(); - rig.send_block(peer, cids[answered_idx], &payloads[answered_idx]).await; - expect_block(&mut rx, cids[answered_idx], &payloads[answered_idx]).await; - - let (_, promoted) = drain_next(&mut rig.outbound_req_rx).await.expect("promoted WANT"); - assert_eq!(promoted, vec![(cids[queued_idx], WantType::Block)]); - } - - #[tokio::test(start_paused = true)] - async fn inbound_request_with_known_block_serves_it() { - let client = TestClientBuilder::with_tx_storage(u32::MAX).build(); - let mut block_builder = BlockBuilderBuilder::new(&client) - .on_parent_block(client.chain_info().genesis_hash) - .with_parent_block_number(0) - .build() - .unwrap(); - - let ext = ExtrinsicBuilder::new_indexed_call(vec![0x42, 0x42, 0x42, 0x42]).build(); - let pattern_index = ext.encoded_size() - 4; - let data_hash = sp_crypto_hashing::blake2_256(&ext.encode()[pattern_index..]); - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, data_hash); - - block_builder.push(ext.clone()).unwrap(); - let block = block_builder.build().unwrap().block; - client.import(BlockOrigin::File, block).await.unwrap(); - - let mut rig = build_rig_with(Arc::new(client), MAX_LIVE_CIDS); - - let peer = litep2p::PeerId::random(); - rig.send_wantlist(peer, vec![(cid, WantType::Block)]).await; - - let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("response"); - assert_eq!(resp_peer, peer); - assert_eq!(responses.len(), 1); - match &responses[0] { - ResponseType::Block { cid: got_cid, block } => { - assert_eq!(*got_cid, cid); - assert_eq!(*block, vec![0x42, 0x42, 0x42, 0x42]); - }, - other => panic!("expected Block, got {other:?}"), - } - } - - type BlockchainResult = sc_client_api::blockchain::Result; - - /// A backend whose `indexed_transaction` parks until the paired sender fires, keeping - /// an inbound lookup worker occupied for as long as the test needs. - struct GatedBackend { - gate: std::sync::Mutex>, - } - - impl BlockBackend for GatedBackend { - fn block_body( - &self, - _hash: H256, - ) -> BlockchainResult>> { - unimplemented!() - } - - fn block_indexed_body(&self, _hash: H256) -> BlockchainResult>>> { - unimplemented!() - } - - fn block_indexed_hashes(&self, _hash: H256) -> BlockchainResult>> { - unimplemented!() - } - - fn block( - &self, - _hash: H256, - ) -> BlockchainResult>> - { - unimplemented!() - } - - fn block_status(&self, _hash: H256) -> BlockchainResult { - unimplemented!() - } - - fn justifications( - &self, - _hash: H256, - ) -> BlockchainResult> { - unimplemented!() - } - - fn block_hash(&self, _number: u64) -> BlockchainResult> { - unimplemented!() - } - - fn indexed_transaction(&self, _hash: H256) -> BlockchainResult>> { - let _ = self.gate.lock().unwrap().recv(); - Ok(None) - } - - fn requires_full_sync(&self) -> bool { - false - } - } - - #[tokio::test] - async fn busy_pool_queues_wantlists_until_worker_frees() { - let (gate_tx, gate_rx) = std::sync::mpsc::channel(); - let backend = Arc::new(GatedBackend { gate: std::sync::Mutex::new(gate_rx) }); - let mut rig = build_rig_with_inbound_limits( - backend, - MAX_LIVE_CIDS, - 1, - MAX_QUEUED_INBOUND_ENTRIES_PER_PEER, - ); - - let peer = litep2p::PeerId::random(); - let first_cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x0a; 32]); - let second_cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x0b; 32]); - - // The first wantlist occupies the only lookup worker (parked on the gate); the - // second waits in the peer's queue instead of being refused. - for cid in [first_cid, second_cid] { - rig.send_wantlist(peer, vec![(cid, WantType::Block)]).await; - } - let no_response = timeout(Duration::from_millis(300), rig.outbound_resp_rx.recv()).await; - assert!(no_response.is_err(), "nothing must be answered while the worker is parked"); - - // Releasing the worker serves both wantlists, in arrival order. - gate_tx.send(()).unwrap(); - let (resp_peer, responses) = - drain_next(&mut rig.outbound_resp_rx).await.expect("first response"); - assert_eq!(resp_peer, peer); - assert_single_dont_have(&responses, first_cid); - - gate_tx.send(()).unwrap(); - let (_, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("second response"); - assert_single_dont_have(&responses, second_cid); - } - - #[tokio::test] - async fn inbound_queue_round_robins_between_peers() { - let (gate_tx, gate_rx) = std::sync::mpsc::channel(); - let backend = Arc::new(GatedBackend { gate: std::sync::Mutex::new(gate_rx) }); - let mut rig = build_rig_with_inbound_limits( - backend, - MAX_LIVE_CIDS, - 1, - MAX_QUEUED_INBOUND_ENTRIES_PER_PEER, - ); - - let peer_a = litep2p::PeerId::random(); - let peer_b = litep2p::PeerId::random(); - let wantlist = |tag: u8, len: usize| -> Vec<(Cid, WantType)> { - (0..len as u8) - .map(|i| { - let mut digest = [tag; 32]; - digest[1] = i; - (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, digest), WantType::Block) - }) - .collect() - }; - - // Peer A backlogs three batches: the first is dispatched immediately, two queue. - rig.send_wantlist(peer_a, wantlist(0xaa, 3 * MAX_WANTED_BLOCKS)).await; - // Peer B queues one batch behind A's backlog. - rig.send_wantlist(peer_b, wantlist(0xbb, MAX_WANTED_BLOCKS)).await; - - // Release one batch worth of lookups at a time and record who gets answered. - let mut served_order = Vec::new(); - for _ in 0..4 { - for _ in 0..MAX_WANTED_BLOCKS { - gate_tx.send(()).unwrap(); - } - let (resp_peer, responses) = - drain_next(&mut rig.outbound_resp_rx).await.expect("batch response"); - assert_eq!(responses.len(), MAX_WANTED_BLOCKS); - served_order.push(resp_peer); - } - - // Round-robin: B's batch is interleaved into A's backlog instead of waiting for - // all of it. (A goes twice first: its second batch re-entered the rotation - // before B arrived.) - assert_eq!(served_order, vec![peer_a, peer_a, peer_b, peer_a]); - } - - #[tokio::test] - async fn per_peer_queue_overflow_drops_newest_entries() { - let (gate_tx, gate_rx) = std::sync::mpsc::channel(); - let backend = Arc::new(GatedBackend { gate: std::sync::Mutex::new(gate_rx) }); - // Single gated worker, queue capped at 4 entries per peer. - let mut rig = build_rig_with_inbound_limits(backend, MAX_LIVE_CIDS, 1, 4); - - let peer = litep2p::PeerId::random(); - let cid = |i: u8| cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32]); - - // The first wantlist occupies the worker; the next four fill the queue; the - // sixth overflows the per-peer cap and is dropped silently. - for i in 0..6u8 { - rig.send_wantlist(peer, vec![(cid(i), WantType::Block)]).await; - } - - // Serve everything: the five accepted entries are answered, the sixth never is. - for _ in 0..5 { - gate_tx.send(()).unwrap(); - } - let mut answered = HashSet::new(); - while answered.len() < 5 { - let (_, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("response"); - for response in responses { - match response { - ResponseType::Presence { cid, presence: BlockPresenceType::DontHave } => { - assert!(answered.insert(cid), "duplicate reply for {cid}"); - }, - other => panic!("expected DONT_HAVE, got {other:?}"), - } - } - } - assert_eq!(answered, (0..5u8).map(cid).collect()); - let no_more = timeout(Duration::from_millis(300), rig.outbound_resp_rx.recv()).await; - assert!(no_more.is_err(), "the overflowed entry must not be answered"); - } - - #[tokio::test] - async fn large_wantlist_is_served_in_batches_covering_every_entry() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - - let cids: Vec<(Cid, WantType)> = (0..(MAX_WANTED_BLOCKS + 2) as u8) - .map(|i| { - let mut digest = [0u8; 32]; - digest[0] = i; - (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, digest), WantType::Block) - }) - .collect(); - - rig.send_wantlist(peer, cids.clone()).await; - - // A wantlist larger than `MAX_WANTED_BLOCKS` is accepted whole and served in - // batches. Every entry gets a reply (DONT_HAVE: the test client holds no - // indexed data). - let mut answered = HashSet::new(); - while answered.len() < cids.len() { - let (resp_peer, responses) = - drain_next(&mut rig.outbound_resp_rx).await.expect("reply for every entry"); - assert_eq!(resp_peer, peer); - for response in responses { - match response { - ResponseType::Presence { cid, presence: BlockPresenceType::DontHave } => { - assert!(answered.insert(cid), "duplicate reply for {cid}"); - }, - other => panic!("expected DONT_HAVE, got {other:?}"), - } - } - } - assert_eq!(answered, cids.into_iter().map(|(cid, _)| cid).collect()); - } - - #[tokio::test] - async fn unsupported_cid_in_wantlist_gets_dont_have() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let unsupported = Cid::new_v1( - RAW_CODEC, - CidMultihash::<64>::wrap(0x99 /* unsupported */, &[0u8; 32]).unwrap(), - ); - - rig.send_wantlist(peer, vec![(unsupported, WantType::Block)]).await; - - let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("reply"); - assert_eq!(resp_peer, peer); - assert_single_dont_have(&responses, unsupported); - } - - #[tokio::test(start_paused = true)] - async fn inbound_serving_continues_after_all_handles_dropped() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [3u8; 32]); - - drop(rig.user_handle); - sleep(Duration::from_millis(1)).await; - - rig.inbound_tx - .send(TransportEvent::Request { peer, cids: vec![(cid, WantType::Block)] }) - .await - .unwrap(); - - let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx) - .await - .expect("service must keep serving inbound after all handles are dropped"); - assert_eq!(resp_peer, peer); - assert_single_dont_have(&responses, cid); - } - - #[tokio::test(start_paused = true)] - async fn outbound_wants_bundled_and_split_at_message_cap() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - rig.connect(peer).await; - - let cids: Vec = (0..=MAX_WANTED_BLOCKS as u8) - .map(|i| { - let mut d = [0u8; 32]; - d[0] = i; - cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, d) - }) - .collect(); - - let _rx = rig.user_handle.request_stream(cids.clone()).unwrap(); - - let (peer_a, first) = drain_next(&mut rig.outbound_req_rx).await.expect("first bundle"); - let (peer_b, second) = drain_next(&mut rig.outbound_req_rx).await.expect("second bundle"); - assert_eq!(peer_a, peer); - assert_eq!(peer_b, peer); - - let mut sizes = [first.len(), second.len()]; - sizes.sort(); - assert_eq!(sizes, [1, MAX_WANTED_BLOCKS]); - - let sent: HashSet = first.into_iter().chain(second).map(|(cid, _)| cid).collect(); - assert_eq!(sent, cids.into_iter().collect::>()); - } - - #[tokio::test] - async fn admission_invalid_cid_rejected() { - let rig = empty_rig(); - let bad = Cid::new_v1( - RAW_CODEC, - CidMultihash::<64>::wrap(0x99 /* unsupported */, &[0u8; 32]).unwrap(), - ); - - let err = rig.user_handle.request_stream(vec![bad]).err().expect("err"); - assert!(matches!(err, BitswapError::InvalidCid { .. })); - } - - #[tokio::test] - async fn admission_empty_returns_closed_receiver() { - let rig = empty_rig(); - let mut rx = rig.user_handle.request_stream(vec![]).unwrap(); - assert!(rx.recv().await.is_none()); - } - - #[tokio::test(start_paused = true)] - async fn service_shutdown_emits_service_closed() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xee; 32]); - - rig.connect(peer).await; - let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - let _ = drain_next(&mut rig.outbound_req_rx).await; - - drop(rig.inbound_tx); - - let item = drain_next(&mut rx).await.expect("expect Err"); - assert!(matches!(item, Err(BitswapError::ServiceClosed))); - } - - #[tokio::test(start_paused = true)] - async fn late_response_after_receiver_drop_is_ignored_and_cid_refetchable() { - let mut rig = empty_rig(); - let peer = litep2p::PeerId::random(); - let data = b"too-late".to_vec(); - let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); - - rig.connect(peer).await; - let rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); - - // Caller gives up; the sweep drops the waiter while the peer request is still - // in flight. - drop(rx); - tokio::time::advance(Duration::from_secs(2)).await; - - // The late response hits no waiter and is dropped. - rig.send_block(peer, cid, &data).await; - // Let the actor process the late response before the fresh request goes in. - sleep(Duration::from_millis(1)).await; - - // A fresh request for the same CID starts from a clean slate: the peer is asked - // again and the block is delivered. - let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); - let _ = drain_next(&mut rig.outbound_req_rx).await.expect("fresh WANT"); - rig.send_block(peer, cid, &data).await; - expect_block(&mut rx, cid, &data).await; - } - - #[tokio::test(start_paused = true)] - async fn too_many_waiters_per_cid_yields_overloaded() { - let rig = empty_rig(); - let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x77; 32]); - - let mut receivers = Vec::new(); - for _ in 0..MAX_WAITERS_PER_CID { - receivers.push(rig.user_handle.request_stream(vec![cid]).unwrap()); - } - // Give the actor a chance to admit all waiters before the one-too-many request. - sleep(Duration::from_millis(10)).await; - - let mut rejected = rig.user_handle.request_stream(vec![cid]).unwrap(); - let item = drain_next(&mut rejected).await.expect("item"); - assert!(matches!(item, Err(BitswapError::Overloaded))); - } -} - -#[cfg(test)] -mod proptests { - use super::*; - use crate::{BLAKE2B_256_MULTIHASH_CODE, RAW_CODEC}; - use cid::multihash::Multihash as CidMultihash; - use proptest::prelude::*; - use rand::{rngs::StdRng, SeedableRng}; - - const NUM_CIDS: u8 = 8; - const NUM_PEERS: usize = 4; - const SMALL_WINDOW: usize = 3; - - #[derive(Debug, Clone)] - enum Op { - AddWaiter(u8), - RemoveWaiter(usize), - Deliver(u8), - MarkPeerDone(usize, u8), - NoteHave(usize, u8), - Connect(usize), - Disconnect(usize), - Sweep(u64), - } - - fn op_strategy() -> impl Strategy { - prop_oneof![ - (0..NUM_CIDS).prop_map(Op::AddWaiter), - any::().prop_map(Op::RemoveWaiter), - (0..NUM_CIDS).prop_map(Op::Deliver), - (0..NUM_PEERS, 0..NUM_CIDS).prop_map(|(p, c)| Op::MarkPeerDone(p, c)), - (0..NUM_PEERS, 0..NUM_CIDS).prop_map(|(p, c)| Op::NoteHave(p, c)), - (0..NUM_PEERS).prop_map(Op::Connect), - (0..NUM_PEERS).prop_map(Op::Disconnect), - (1u64..8).prop_map(Op::Sweep), - ] - } - - struct Harness { - wants: WantSet, - waiters: SlotMap, - peers: Vec, - cids: Vec, - connected: HashSet, - now: Instant, - rng: StdRng, - } - - impl Harness { - fn new() -> Self { - let cids = (0..NUM_CIDS) - .map(|i| { - let mh = - CidMultihash::<64>::wrap(BLAKE2B_256_MULTIHASH_CODE, &[i; 32]).unwrap(); - Cid::new_v1(RAW_CODEC, mh) - }) - .collect(); - Self { - wants: WantSet::new(SMALL_WINDOW), - waiters: SlotMap::with_key(), - peers: (0..NUM_PEERS).map(|_| litep2p::PeerId::random()).collect(), - cids, - connected: HashSet::new(), - now: Instant::now(), - rng: StdRng::seed_from_u64(0), - } - } - - fn apply(&mut self, op: Op) { - match op { - Op::AddWaiter(c) => { - let cid = self.cids[c as usize]; - let id = self.waiters.insert(cid); - self.wants.add_waiter(cid, id); - }, - Op::RemoveWaiter(seed) => { - let Some(id) = self.waiters.keys().nth(seed % self.waiters.len().max(1)) else { - return; - }; - let cid = self.waiters.remove(id).expect("listed waiter exists"); - self.wants.remove_waiter(cid, id); - }, - Op::Deliver(c) => { - let waiter_ids = self - .wants - .take_waiters_for_delivered_cid(self.cids[c as usize]) - .unwrap_or_default(); - for id in waiter_ids { - self.waiters.remove(id); - } - }, - Op::MarkPeerDone(p, c) => { - self.wants.mark_peer_done_for_cid(self.peers[p], self.cids[c as usize]) - }, - Op::NoteHave(p, c) => { - self.wants.note_peer_have_for_cid(self.peers[p], self.cids[c as usize]) - }, - Op::Connect(p) => { - self.connected.insert(self.peers[p]); - }, - Op::Disconnect(p) => { - self.connected.remove(&self.peers[p]); - let _ = self.wants.remove_in_flight_peer(self.peers[p]); - }, - Op::Sweep(secs) => { - self.now += Duration::from_secs(secs); - let _ = self.wants.expire_peer_timeouts(self.now); - let _ = self.wants.restart_exhausted_rounds(self.now); - }, - } - } - - fn top_up(&mut self) { - for cid in self.wants.all_cids() { - let _ = - self.wants.next_peer_to_request(cid, &self.connected, self.now, &mut self.rng); - } - while self.wants.has_window_capacity() { - let Some(cid) = self.wants.pop_pending() else { break }; - let _ = - self.wants.next_peer_to_request(cid, &self.connected, self.now, &mut self.rng); - } - } - - fn check_invariants(&self) { - let live = self - .wants - .inner - .values() - .filter(|state| matches!(state.phase, CidPhase::InFlight { .. })) - .count(); - assert_eq!(self.wants.live, live, "live counter drifted"); - assert!(self.wants.live <= self.wants.max_live, "dispatch window overrun"); - let queued = self.wants.inner.values().filter(|state| state.is_queued()).count(); - assert_eq!(self.wants.queued, queued, "queued counter drifted"); - - for (cid, state) in &self.wants.inner { - assert!(!state.is_idle(), "idle entry retained for {cid}"); - if let CidPhase::InFlight { peer, .. } = state.phase { - assert!(!state.tried_peers.contains(&peer), "peer both tried and in flight"); - } - if state.is_queued() { - assert!( - self.wants.pending.contains(cid), - "queued CID without queue entry for {cid}", - ); - } - let retry_at = match state.phase { - CidPhase::Queued { retry_at } => retry_at, - CidPhase::RetryAt(at) => Some(at), - CidPhase::Ready | CidPhase::InFlight { .. } => None, - }; - if let Some(at) = retry_at { - assert!(at > self.now, "overdue round never restarted for {cid}"); - } - if state.has_waiters() && !self.connected.is_empty() { - assert!( - matches!( - state.phase, - CidPhase::Queued { .. } | - CidPhase::InFlight { .. } | - CidPhase::RetryAt(_) - ), - "stranded CID {cid}", - ); - } - } - } - } - - proptest! { - #[test] - fn want_set_invariants_hold(ops in prop::collection::vec(op_strategy(), 1..256)) { - let mut harness = Harness::new(); - for op in ops { - harness.apply(op); - harness.top_up(); - harness.check_invariants(); - } - } - } -} +mod tests; diff --git a/substrate/client/network/bitswap/src/service/tests.rs b/substrate/client/network/bitswap/src/service/tests.rs new file mode 100644 index 000000000000..395e1cc3bc4f --- /dev/null +++ b/substrate/client/network/bitswap/src/service/tests.rs @@ -0,0 +1,1204 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// This file is part of Substrate. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// Substrate is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Substrate is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Substrate. If not, see . + +//! Bitswap service tests. + +use super::*; +use crate::{ + BLAKE2B_256_MULTIHASH_CODE, KECCAK_256_MULTIHASH_CODE, RAW_CODEC, SHA2_256_MULTIHASH_CODE, +}; +use cid::multihash::Multihash as CidMultihash; +use rand::{rngs::StdRng, SeedableRng}; +use rstest::rstest; +use sc_block_builder::BlockBuilderBuilder; +use sc_network_sync::SyncEvent; +use sc_network_types::PeerId as TypesPeerId; +use sp_consensus::BlockOrigin; +use sp_runtime::codec::Encode; +use substrate_test_runtime::ExtrinsicBuilder; +use substrate_test_runtime_client::{prelude::*, TestClientBuilder}; +use tokio::{ + sync::Mutex as AsyncMutex, + time::{sleep, timeout}, +}; + +struct MockTransport { + inbound: AsyncMutex>, + outbound_req_tx: mpsc::Sender<(litep2p::PeerId, Vec<(Cid, WantType)>)>, + outbound_resp_tx: mpsc::Sender<(litep2p::PeerId, Vec)>, +} + +#[async_trait] +impl BitswapTransport for MockTransport { + async fn next_event(&mut self) -> Option { + self.inbound.get_mut().recv().await + } + + async fn send_request(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { + let _ = self.outbound_req_tx.send((peer, cids)).await; + } + + async fn send_response(&self, peer: litep2p::PeerId, responses: Vec) { + let _ = self.outbound_resp_tx.send((peer, responses)).await; + } +} + +struct TestRig { + user_handle: BitswapHandle, + sync_event_tx: mpsc::Sender, + inbound_tx: mpsc::Sender, + outbound_req_rx: mpsc::Receiver<(litep2p::PeerId, Vec<(Cid, WantType)>)>, + outbound_resp_rx: mpsc::Receiver<(litep2p::PeerId, Vec)>, + _handle: tokio::task::JoinHandle<()>, +} + +fn build_rig_with( + client: Arc + Send + Sync>, + max_live_cids: usize, +) -> TestRig { + build_rig_with_inbound_limits( + client, + max_live_cids, + MAX_CONCURRENT_INBOUND_LOOKUPS, + MAX_QUEUED_INBOUND_ENTRIES_PER_PEER, + ) +} + +fn build_rig_with_inbound_limits( + client: Arc + Send + Sync>, + max_live_cids: usize, + max_lookups: usize, + queued_entries_per_peer: usize, +) -> TestRig { + let (inbound_tx, inbound_rx) = mpsc::channel(64); + let (outbound_req_tx, outbound_req_rx) = mpsc::channel(64); + let (outbound_resp_tx, outbound_resp_rx) = mpsc::channel(64); + let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); + let (sync_event_tx, sync_event_rx) = mpsc::channel::(64); + let metrics = BitswapMetrics::default(); + let (inbound_lookup_pool, inbound_lookup_rx) = + InboundLookupPool::new(client, max_lookups, metrics.clone()); + + let transport = + MockTransport { inbound: AsyncMutex::new(inbound_rx), outbound_req_tx, outbound_resp_tx }; + + let sync_event_stream: Pin + Send>> = + Box::pin(tokio_stream::wrappers::ReceiverStream::new(sync_event_rx)); + + let service: BitswapService = BitswapService { + handle: Box::new(transport), + cmd_rx, + cmd_channel_closed: false, + sync_event_stream, + inbound_lookup_pool, + inbound_lookup_rx, + inbound_queue: InboundQueue::new(queued_entries_per_peer), + connected_peers: HashSet::new(), + wants: WantSet::new(max_live_cids), + waiters: SlotMap::with_key(), + metrics, + }; + + let user_handle = BitswapHandle::new(cmd_tx); + let _handle = tokio::spawn(async move { service.run().await }); + + TestRig { user_handle, sync_event_tx, inbound_tx, outbound_req_rx, outbound_resp_rx, _handle } +} + +fn empty_rig() -> TestRig { + small_window_rig(MAX_LIVE_CIDS) +} + +fn small_window_rig(max_live_cids: usize) -> TestRig { + let client = Arc::new(substrate_test_runtime_client::new()); + build_rig_with(client, max_live_cids) +} + +fn cid_for_data(mh_code: u64, data: &[u8]) -> Cid { + let digest = match mh_code { + BLAKE2B_256_MULTIHASH_CODE => sp_crypto_hashing::blake2_256(data), + SHA2_256_MULTIHASH_CODE => sp_crypto_hashing::sha2_256(data), + KECCAK_256_MULTIHASH_CODE => sp_crypto_hashing::keccak_256(data), + _ => panic!("unsupported multihash code"), + }; + let mh = CidMultihash::<64>::wrap(mh_code, &digest).unwrap(); + Cid::new_v1(RAW_CODEC, mh) +} + +fn cid_for_digest(mh_code: u64, digest: [u8; 32]) -> Cid { + let mh = CidMultihash::<64>::wrap(mh_code, &digest).unwrap(); + Cid::new_v1(RAW_CODEC, mh) +} + +async fn drain_next(rx: &mut mpsc::Receiver) -> Option { + timeout(Duration::from_secs(2), rx.recv()).await.ok().flatten() +} + +fn to_types_peer(peer: litep2p::PeerId) -> TypesPeerId { + TypesPeerId::from_bytes(&peer.to_bytes()).expect("peer ID bytes are valid") +} + +fn sync_connected(peer: litep2p::PeerId) -> SyncEvent { + SyncEvent::PeerConnected { peer_id: to_types_peer(peer), roles: Roles::FULL } +} + +fn sync_connected_light(peer: litep2p::PeerId) -> SyncEvent { + SyncEvent::PeerConnected { peer_id: to_types_peer(peer), roles: Roles::LIGHT } +} + +fn sync_disconnected(peer: litep2p::PeerId) -> SyncEvent { + SyncEvent::PeerDisconnected(to_types_peer(peer)) +} + +impl TestRig { + async fn connect(&self, peer: litep2p::PeerId) { + self.sync_event_tx.send(sync_connected(peer)).await.unwrap(); + } + + async fn send_response(&self, peer: litep2p::PeerId, responses: Vec) { + self.inbound_tx + .send(TransportEvent::Response { peer, responses }) + .await + .unwrap(); + } + + async fn send_block(&self, peer: litep2p::PeerId, cid: Cid, data: &[u8]) { + self.send_response( + peer, + vec![TransportResponse::VerifiedBlock { cid, bytes: data.to_vec() }], + ) + .await; + } + + async fn send_presence(&self, peer: litep2p::PeerId, cid: Cid, presence: BlockPresenceType) { + self.send_response(peer, vec![TransportResponse::Presence { cid, presence }]) + .await; + } + + async fn send_wantlist(&self, peer: litep2p::PeerId, cids: Vec<(Cid, WantType)>) { + self.inbound_tx.send(TransportEvent::Request { peer, cids }).await.unwrap(); + } +} + +async fn expect_block(rx: &mut mpsc::Receiver, cid: Cid, data: &[u8]) { + match drain_next(rx).await.expect("stream item") { + Ok((got_cid, bytes)) => { + assert_eq!(got_cid, cid); + assert_eq!(bytes, data); + }, + other => panic!("expected block, got {other:?}"), + } +} + +fn assert_single_dont_have(responses: &[ResponseType], cid: Cid) { + assert!(matches!( + responses, + [ResponseType::Presence { cid: got, presence: BlockPresenceType::DontHave }] + if *got == cid + )); +} + +#[test] +fn want_set_removes_cid_after_last_waiter_and_peer_complete() { + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xaa; 32]); + let peer = litep2p::PeerId::random(); + let mut waiter_ids = SlotMap::with_key(); + let waiter_id = waiter_ids.insert(()); + let mut wants = WantSet::new(MAX_LIVE_CIDS); + let mut rng = StdRng::seed_from_u64(0); + + wants.add_waiter(cid, waiter_id); + let selected = wants + .next_peer_to_request(cid, &HashSet::from([peer]), Instant::now(), &mut rng) + .unwrap(); + assert_eq!(selected, peer); + + wants.remove_waiter(cid, waiter_id); + assert!(wants.contains(&cid)); + + wants.mark_peer_done_for_cid(peer, cid); + assert!(!wants.contains(&cid)); +} + +#[test] +fn want_set_window_queues_and_promotes() { + let cid_a = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x01; 32]); + let cid_b = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x02; 32]); + let peer = litep2p::PeerId::random(); + let peers = HashSet::from([peer]); + let mut waiter_ids = SlotMap::with_key(); + let waiter_id = waiter_ids.insert(()); + let mut wants = WantSet::new(1); + let mut rng = StdRng::seed_from_u64(0); + + wants.add_waiter(cid_a, waiter_id); + wants.add_waiter(cid_b, waiter_id); + + assert_eq!(wants.next_peer_to_request(cid_a, &peers, Instant::now(), &mut rng), Some(peer)); + assert_eq!(wants.next_peer_to_request(cid_b, &peers, Instant::now(), &mut rng), None); + assert!(!wants.has_window_capacity()); + + wants.take_waiters_for_delivered_cid(cid_a); + assert!(wants.has_window_capacity()); + assert_eq!(wants.pop_pending(), Some(cid_b)); + assert_eq!(wants.next_peer_to_request(cid_b, &peers, Instant::now(), &mut rng), Some(peer)); + assert_eq!(wants.pop_pending(), None); +} + +#[test] +fn peer_selection_varies_across_cids() { + let peers: HashSet<_> = (0..3).map(|_| litep2p::PeerId::random()).collect(); + let mut waiter_ids = SlotMap::with_key(); + let mut wants = WantSet::new(32); + let mut rng = StdRng::seed_from_u64(0); + let mut selected = HashSet::new(); + + for byte in 0..32 { + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [byte; 32]); + let waiter = waiter_ids.insert(()); + wants.add_waiter(cid, waiter); + selected.insert( + wants + .next_peer_to_request(cid, &peers, Instant::now(), &mut rng) + .expect("a connected peer is eligible"), + ); + } + + assert!(selected.len() > 1, "fresh CIDs should not all select the same peer"); +} + +#[test] +fn inbound_queue_rotates_peers_between_batches() { + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let entry = |i: u8| (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32]), WantType::Block); + let mut queue = InboundQueue::new(MAX_QUEUED_INBOUND_ENTRIES_PER_PEER); + + assert_eq!(queue.enqueue(peer_a, (0..(MAX_WANTED_BLOCKS + 4) as u8).map(entry).collect()), 0); + assert_eq!(queue.enqueue(peer_b, vec![entry(0xff)]), 0); + + let (peer, batch) = queue.next_batch().unwrap(); + assert_eq!((peer, batch.len()), (peer_a, MAX_WANTED_BLOCKS)); + let (peer, batch) = queue.next_batch().unwrap(); + assert_eq!((peer, batch.len()), (peer_b, 1)); + let (peer, batch) = queue.next_batch().unwrap(); + assert_eq!((peer, batch.len()), (peer_a, 4)); + assert!(queue.next_batch().is_none()); +} + +#[test] +fn inbound_queue_skips_inconsistent_rotation_entries() { + let stale_peer = litep2p::PeerId::random(); + let empty_peer = litep2p::PeerId::random(); + let ready_peer = litep2p::PeerId::random(); + let entry = (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xaa; 32]), WantType::Block); + let mut queue = InboundQueue::new(MAX_QUEUED_INBOUND_ENTRIES_PER_PEER); + + queue.rotation.extend([stale_peer, empty_peer]); + queue.per_peer.insert(empty_peer, VecDeque::new()); + queue.enqueue(ready_peer, vec![entry]); + + let (peer, batch) = queue.next_batch().expect("ready peer remains serviceable"); + assert_eq!(peer, ready_peer); + assert_eq!(batch, vec![entry]); + assert!(!queue.per_peer.contains_key(&empty_peer)); + assert!(queue.next_batch().is_none()); +} + +#[test] +fn inbound_queue_drops_overflow_and_counts_it() { + let peer = litep2p::PeerId::random(); + let entry = |i: u8| (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32]), WantType::Block); + let mut queue = InboundQueue::new(4); + + assert_eq!(queue.enqueue(peer, (0..3).map(entry).collect()), 0); + assert_eq!(queue.enqueue(peer, (3..6).map(entry).collect()), 2); + + let (_, batch) = queue.next_batch().unwrap(); + let kept: Vec = batch.into_iter().map(|(cid, _)| cid).collect(); + assert_eq!( + kept, + (0..4) + .map(|i| cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32])) + .collect::>() + ); + assert!(queue.next_batch().is_none()); +} + +#[tokio::test(start_paused = true)] +async fn single_cid_single_peer_block_response() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let data = b"payload-a".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.connect(peer).await; + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); + + let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); + assert_eq!(out_peer, peer); + assert_eq!(out_cids, vec![(cid, WantType::Block)]); + + rig.send_block(peer, cid, &data).await; + expect_block(&mut rx, cid, &data).await; +} + +#[tokio::test(start_paused = true)] +async fn have_response_reasks_same_peer_then_delivers() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let data = b"payload-have".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.connect(peer).await; + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); + + let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + assert_eq!(out_peer, peer); + + rig.send_presence(peer, cid, BlockPresenceType::Have).await; + + let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("re-ask"); + assert_eq!(out_peer, peer); + assert_eq!(out_cids, vec![(cid, WantType::Block)]); + + rig.send_block(peer, cid, &data).await; + expect_block(&mut rx, cid, &data).await; +} + +#[tokio::test(start_paused = true)] +async fn second_have_without_block_moves_to_other_peer() { + let mut rig = empty_rig(); + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let data = b"payload-have-2".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.connect(peer_a).await; + rig.connect(peer_b).await; + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); + + let (first, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + let other = if first == peer_a { peer_b } else { peer_a }; + + rig.send_presence(first, cid, BlockPresenceType::Have).await; + let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("re-ask"); + assert_eq!(out_peer, first); + + rig.send_presence(first, cid, BlockPresenceType::Have).await; + let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); + assert_eq!(out_peer, other); + assert_eq!(out_cids, vec![(cid, WantType::Block)]); + + rig.send_block(other, cid, &data).await; + expect_block(&mut rx, cid, &data).await; +} + +#[rstest] +#[case::after_dont_have(true)] +#[case::after_timeout(false)] +#[tokio::test(start_paused = true)] +async fn exhausted_round_reasks_peer_after_delay(#[case] answer_dont_have: bool) { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let data = b"payload-round".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.connect(peer).await; + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + + if answer_dont_have { + rig.send_presence(peer, cid, BlockPresenceType::DontHave).await; + let no_req = + timeout(ROUND_RETRY_DELAY - Duration::from_secs(1), rig.outbound_req_rx.recv()).await; + assert!(no_req.is_err()); + } + + let (out_peer, out_cids) = + timeout(PER_PEER_TIMEOUT + ROUND_RETRY_DELAY * 2, rig.outbound_req_rx.recv()) + .await + .expect("new-round WANT") + .expect("transport channel open"); + assert_eq!(out_peer, peer); + assert_eq!(out_cids, vec![(cid, WantType::Block)]); + + rig.send_block(peer, cid, &data).await; + expect_block(&mut rx, cid, &data).await; +} + +#[tokio::test(start_paused = true)] +async fn new_peer_is_asked_immediately_while_round_is_parked() { + let mut rig = empty_rig(); + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let data = b"payload-round-3".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.connect(peer_a).await; + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + + rig.send_presence(peer_a, cid, BlockPresenceType::DontHave).await; + + rig.connect(peer_b).await; + let (out_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("WANT to new peer"); + assert_eq!(out_peer, peer_b); + + rig.send_block(peer_b, cid, &data).await; + expect_block(&mut rx, cid, &data).await; + + tokio::time::advance(ROUND_RETRY_DELAY * 2).await; + assert!(rig.outbound_req_rx.try_recv().is_err()); +} + +#[tokio::test(start_paused = true)] +async fn light_client_peers_are_not_tracked() { + let mut rig = empty_rig(); + let light_peer = litep2p::PeerId::random(); + let full_peer = litep2p::PeerId::random(); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [9u8; 32]); + + rig.sync_event_tx.send(sync_connected_light(light_peer)).await.unwrap(); + let _rx = rig.user_handle.request_stream(vec![cid]).unwrap(); + + assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); + + rig.connect(full_peer).await; + let (out_peer, out_cids) = drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); + assert_eq!(out_peer, full_peer); + assert_eq!(out_cids, vec![(cid, WantType::Block)]); +} + +#[tokio::test(start_paused = true)] +async fn dont_have_from_only_peer_leaves_stream_open() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, b"the-real-payload"); + + rig.connect(peer).await; + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); + + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound WANT"); + + rig.send_presence(peer, cid, BlockPresenceType::DontHave).await; + + tokio::time::advance(Duration::from_secs(60)).await; + assert!(matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty))); +} + +enum FailoverTrigger { + DontHave, + Timeout, + Disconnect, +} + +/// However the first peer fails the request — DONT_HAVE, an unanswered request timing +/// out, a block failing CID verification, or disconnecting — the want fails over to +/// the other connected peer. +#[rstest] +#[case::dont_have(FailoverTrigger::DontHave)] +#[case::timeout(FailoverTrigger::Timeout)] +#[case::disconnect(FailoverTrigger::Disconnect)] +#[tokio::test(start_paused = true)] +async fn first_peer_failure_triggers_failover(#[case] trigger: FailoverTrigger) { + let mut rig = empty_rig(); + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let data = b"failover-payload".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.connect(peer_a).await; + rig.connect(peer_b).await; + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); + let (first_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("first WANT"); + + match trigger { + FailoverTrigger::DontHave => { + rig.send_presence(first_peer, cid, BlockPresenceType::DontHave).await + }, + FailoverTrigger::Timeout => { + tokio::time::advance(PER_PEER_TIMEOUT + Duration::from_secs(2)).await + }, + FailoverTrigger::Disconnect => { + rig.sync_event_tx.send(sync_disconnected(first_peer)).await.unwrap() + }, + } + + let (second_peer, _) = drain_next(&mut rig.outbound_req_rx).await.expect("failover WANT"); + assert_ne!(first_peer, second_peer); + + rig.send_block(second_peer, cid, &data).await; + expect_block(&mut rx, cid, &data).await; +} + +#[tokio::test(start_paused = true)] +async fn receiver_drop_cancels_wants() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [1u8; 32]); + + let rx = rig.user_handle.request_stream(vec![cid]).unwrap(); + + // No peers connected: nothing is dispatched, the want just sits there. + assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); + + // The caller gives up; the sweep drops the abandoned waiter. + drop(rx); + tokio::time::advance(Duration::from_secs(2)).await; + + // A peer connecting afterwards must not trigger a WANT for the cancelled request. + rig.connect(peer).await; + assert!(drain_next(&mut rig.outbound_req_rx).await.is_none()); +} + +#[tokio::test(start_paused = true)] +async fn two_waiters_overlapping_cid_both_get_block() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let data = b"shared".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.connect(peer).await; + + let mut rx_a = rig.user_handle.request_stream(vec![cid]).unwrap(); + let mut rx_b = rig.user_handle.request_stream(vec![cid]).unwrap(); + + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); + + rig.send_block(peer, cid, &data).await; + expect_block(&mut rx_a, cid, &data).await; + expect_block(&mut rx_b, cid, &data).await; +} + +#[tokio::test(start_paused = true)] +async fn waiter_drop_does_not_break_other_waiter() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let data = b"survivor".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.connect(peer).await; + + let rx_a = rig.user_handle.request_stream(vec![cid]).unwrap(); + let mut rx_b = rig.user_handle.request_stream(vec![cid]).unwrap(); + + drop(rx_a); + sleep(Duration::from_millis(1)).await; + + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); + + rig.send_block(peer, cid, &data).await; + expect_block(&mut rx_b, cid, &data).await; +} + +#[tokio::test(start_paused = true)] +async fn dispatch_window_queues_excess_cids_and_promotes_on_delivery() { + let mut rig = small_window_rig(4); + let peer = litep2p::PeerId::random(); + rig.connect(peer).await; + + let payloads: Vec> = (0..5u8).map(|i| vec![i; 8]).collect(); + let cids: Vec = + payloads.iter().map(|p| cid_for_data(BLAKE2B_256_MULTIHASH_CODE, p)).collect(); + + let mut rx = rig.user_handle.request_stream(cids.clone()).unwrap(); + + // Only the window (4 CIDs) is dispatched; the fifth is queued. + let (_, entries) = drain_next(&mut rig.outbound_req_rx).await.expect("first bundle"); + assert_eq!(entries.len(), 4); + let dispatched: HashSet = entries.iter().map(|(cid, _)| *cid).collect(); + let queued_idx = cids.iter().position(|cid| !dispatched.contains(cid)).expect("one CID queued"); + + // Answering one dispatched CID frees a slot and promotes the queued CID. + let answered_idx = cids.iter().position(|cid| dispatched.contains(cid)).unwrap(); + rig.send_block(peer, cids[answered_idx], &payloads[answered_idx]).await; + expect_block(&mut rx, cids[answered_idx], &payloads[answered_idx]).await; + + let (_, promoted) = drain_next(&mut rig.outbound_req_rx).await.expect("promoted WANT"); + assert_eq!(promoted, vec![(cids[queued_idx], WantType::Block)]); +} + +#[tokio::test(start_paused = true)] +async fn inbound_request_with_known_block_serves_it() { + let client = TestClientBuilder::with_tx_storage(u32::MAX).build(); + let mut block_builder = BlockBuilderBuilder::new(&client) + .on_parent_block(client.chain_info().genesis_hash) + .with_parent_block_number(0) + .build() + .unwrap(); + + let ext = ExtrinsicBuilder::new_indexed_call(vec![0x42, 0x42, 0x42, 0x42]).build(); + let pattern_index = ext.encoded_size() - 4; + let data_hash = sp_crypto_hashing::blake2_256(&ext.encode()[pattern_index..]); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, data_hash); + + block_builder.push(ext.clone()).unwrap(); + let block = block_builder.build().unwrap().block; + client.import(BlockOrigin::File, block).await.unwrap(); + + let mut rig = build_rig_with(Arc::new(client), MAX_LIVE_CIDS); + + let peer = litep2p::PeerId::random(); + rig.send_wantlist(peer, vec![(cid, WantType::Block)]).await; + + let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("response"); + assert_eq!(resp_peer, peer); + assert_eq!(responses.len(), 1); + match &responses[0] { + ResponseType::Block { cid: got_cid, block } => { + assert_eq!(*got_cid, cid); + assert_eq!(*block, vec![0x42, 0x42, 0x42, 0x42]); + }, + other => panic!("expected Block, got {other:?}"), + } +} + +type BlockchainResult = sc_client_api::blockchain::Result; + +/// A backend whose `indexed_transaction` parks until the paired sender fires, keeping +/// an inbound lookup worker occupied for as long as the test needs. +struct GatedBackend { + gate: std::sync::Mutex>, +} + +impl BlockBackend for GatedBackend { + fn block_body( + &self, + _hash: H256, + ) -> BlockchainResult>> { + unimplemented!() + } + + fn block_indexed_body(&self, _hash: H256) -> BlockchainResult>>> { + unimplemented!() + } + + fn block_indexed_hashes(&self, _hash: H256) -> BlockchainResult>> { + unimplemented!() + } + + fn block( + &self, + _hash: H256, + ) -> BlockchainResult>> + { + unimplemented!() + } + + fn block_status(&self, _hash: H256) -> BlockchainResult { + unimplemented!() + } + + fn justifications(&self, _hash: H256) -> BlockchainResult> { + unimplemented!() + } + + fn block_hash(&self, _number: u64) -> BlockchainResult> { + unimplemented!() + } + + fn indexed_transaction(&self, _hash: H256) -> BlockchainResult>> { + let _ = self.gate.lock().unwrap().recv(); + Ok(None) + } + + fn requires_full_sync(&self) -> bool { + false + } +} + +#[tokio::test] +async fn busy_pool_queues_wantlists_until_worker_frees() { + let (gate_tx, gate_rx) = std::sync::mpsc::channel(); + let backend = Arc::new(GatedBackend { gate: std::sync::Mutex::new(gate_rx) }); + let mut rig = build_rig_with_inbound_limits( + backend, + MAX_LIVE_CIDS, + 1, + MAX_QUEUED_INBOUND_ENTRIES_PER_PEER, + ); + + let peer = litep2p::PeerId::random(); + let first_cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x0a; 32]); + let second_cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x0b; 32]); + + // The first wantlist occupies the only lookup worker (parked on the gate); the + // second waits in the peer's queue instead of being refused. + for cid in [first_cid, second_cid] { + rig.send_wantlist(peer, vec![(cid, WantType::Block)]).await; + } + let no_response = timeout(Duration::from_millis(300), rig.outbound_resp_rx.recv()).await; + assert!(no_response.is_err(), "nothing must be answered while the worker is parked"); + + // Releasing the worker serves both wantlists, in arrival order. + gate_tx.send(()).unwrap(); + let (resp_peer, responses) = + drain_next(&mut rig.outbound_resp_rx).await.expect("first response"); + assert_eq!(resp_peer, peer); + assert_single_dont_have(&responses, first_cid); + + gate_tx.send(()).unwrap(); + let (_, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("second response"); + assert_single_dont_have(&responses, second_cid); +} + +#[tokio::test] +async fn inbound_queue_round_robins_between_peers() { + let (gate_tx, gate_rx) = std::sync::mpsc::channel(); + let backend = Arc::new(GatedBackend { gate: std::sync::Mutex::new(gate_rx) }); + let mut rig = build_rig_with_inbound_limits( + backend, + MAX_LIVE_CIDS, + 1, + MAX_QUEUED_INBOUND_ENTRIES_PER_PEER, + ); + + let peer_a = litep2p::PeerId::random(); + let peer_b = litep2p::PeerId::random(); + let wantlist = |tag: u8, len: usize| -> Vec<(Cid, WantType)> { + (0..len as u8) + .map(|i| { + let mut digest = [tag; 32]; + digest[1] = i; + (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, digest), WantType::Block) + }) + .collect() + }; + + // Peer A backlogs three batches: the first is dispatched immediately, two queue. + rig.send_wantlist(peer_a, wantlist(0xaa, 3 * MAX_WANTED_BLOCKS)).await; + // Peer B queues one batch behind A's backlog. + rig.send_wantlist(peer_b, wantlist(0xbb, MAX_WANTED_BLOCKS)).await; + + // Release one batch worth of lookups at a time and record who gets answered. + let mut served_order = Vec::new(); + for _ in 0..4 { + for _ in 0..MAX_WANTED_BLOCKS { + gate_tx.send(()).unwrap(); + } + let (resp_peer, responses) = + drain_next(&mut rig.outbound_resp_rx).await.expect("batch response"); + assert_eq!(responses.len(), MAX_WANTED_BLOCKS); + served_order.push(resp_peer); + } + + // Round-robin: B's batch is interleaved into A's backlog instead of waiting for + // all of it. (A goes twice first: its second batch re-entered the rotation + // before B arrived.) + assert_eq!(served_order, vec![peer_a, peer_a, peer_b, peer_a]); +} + +#[tokio::test] +async fn per_peer_queue_overflow_drops_newest_entries() { + let (gate_tx, gate_rx) = std::sync::mpsc::channel(); + let backend = Arc::new(GatedBackend { gate: std::sync::Mutex::new(gate_rx) }); + // Single gated worker, queue capped at 4 entries per peer. + let mut rig = build_rig_with_inbound_limits(backend, MAX_LIVE_CIDS, 1, 4); + + let peer = litep2p::PeerId::random(); + let cid = |i: u8| cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [i; 32]); + + // The first wantlist occupies the worker; the next four fill the queue; the + // sixth overflows the per-peer cap and is dropped silently. + for i in 0..6u8 { + rig.send_wantlist(peer, vec![(cid(i), WantType::Block)]).await; + } + + // Serve everything: the five accepted entries are answered, the sixth never is. + for _ in 0..5 { + gate_tx.send(()).unwrap(); + } + let mut answered = HashSet::new(); + while answered.len() < 5 { + let (_, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("response"); + for response in responses { + match response { + ResponseType::Presence { cid, presence: BlockPresenceType::DontHave } => { + assert!(answered.insert(cid), "duplicate reply for {cid}"); + }, + other => panic!("expected DONT_HAVE, got {other:?}"), + } + } + } + assert_eq!(answered, (0..5u8).map(cid).collect()); + let no_more = timeout(Duration::from_millis(300), rig.outbound_resp_rx.recv()).await; + assert!(no_more.is_err(), "the overflowed entry must not be answered"); +} + +#[tokio::test] +async fn large_wantlist_is_served_in_batches_covering_every_entry() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + + let cids: Vec<(Cid, WantType)> = (0..(MAX_WANTED_BLOCKS + 2) as u8) + .map(|i| { + let mut digest = [0u8; 32]; + digest[0] = i; + (cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, digest), WantType::Block) + }) + .collect(); + + rig.send_wantlist(peer, cids.clone()).await; + + // A wantlist larger than `MAX_WANTED_BLOCKS` is accepted whole and served in + // batches. Every entry gets a reply (DONT_HAVE: the test client holds no + // indexed data). + let mut answered = HashSet::new(); + while answered.len() < cids.len() { + let (resp_peer, responses) = + drain_next(&mut rig.outbound_resp_rx).await.expect("reply for every entry"); + assert_eq!(resp_peer, peer); + for response in responses { + match response { + ResponseType::Presence { cid, presence: BlockPresenceType::DontHave } => { + assert!(answered.insert(cid), "duplicate reply for {cid}"); + }, + other => panic!("expected DONT_HAVE, got {other:?}"), + } + } + } + assert_eq!(answered, cids.into_iter().map(|(cid, _)| cid).collect()); +} + +#[tokio::test] +async fn unsupported_cid_in_wantlist_gets_dont_have() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let unsupported = Cid::new_v1( + RAW_CODEC, + CidMultihash::<64>::wrap(0x99 /* unsupported */, &[0u8; 32]).unwrap(), + ); + + rig.send_wantlist(peer, vec![(unsupported, WantType::Block)]).await; + + let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx).await.expect("reply"); + assert_eq!(resp_peer, peer); + assert_single_dont_have(&responses, unsupported); +} + +#[tokio::test(start_paused = true)] +async fn inbound_serving_continues_after_all_handles_dropped() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [3u8; 32]); + + drop(rig.user_handle); + sleep(Duration::from_millis(1)).await; + + rig.inbound_tx + .send(TransportEvent::Request { peer, cids: vec![(cid, WantType::Block)] }) + .await + .unwrap(); + + let (resp_peer, responses) = drain_next(&mut rig.outbound_resp_rx) + .await + .expect("service must keep serving inbound after all handles are dropped"); + assert_eq!(resp_peer, peer); + assert_single_dont_have(&responses, cid); +} + +#[tokio::test(start_paused = true)] +async fn outbound_wants_bundled_and_split_at_message_cap() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + rig.connect(peer).await; + + let cids: Vec = (0..=MAX_WANTED_BLOCKS as u8) + .map(|i| { + let mut d = [0u8; 32]; + d[0] = i; + cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, d) + }) + .collect(); + + let _rx = rig.user_handle.request_stream(cids.clone()).unwrap(); + + let (peer_a, first) = drain_next(&mut rig.outbound_req_rx).await.expect("first bundle"); + let (peer_b, second) = drain_next(&mut rig.outbound_req_rx).await.expect("second bundle"); + assert_eq!(peer_a, peer); + assert_eq!(peer_b, peer); + + let mut sizes = [first.len(), second.len()]; + sizes.sort(); + assert_eq!(sizes, [1, MAX_WANTED_BLOCKS]); + + let sent: HashSet = first.into_iter().chain(second).map(|(cid, _)| cid).collect(); + assert_eq!(sent, cids.into_iter().collect::>()); +} + +#[tokio::test] +async fn admission_invalid_cid_rejected() { + let rig = empty_rig(); + let bad = Cid::new_v1( + RAW_CODEC, + CidMultihash::<64>::wrap(0x99 /* unsupported */, &[0u8; 32]).unwrap(), + ); + + let err = rig.user_handle.request_stream(vec![bad]).err().expect("err"); + assert!(matches!(err, BitswapError::InvalidCid { .. })); +} + +#[tokio::test] +async fn admission_empty_returns_closed_receiver() { + let rig = empty_rig(); + let mut rx = rig.user_handle.request_stream(vec![]).unwrap(); + assert!(rx.recv().await.is_none()); +} + +#[tokio::test(start_paused = true)] +async fn service_shutdown_emits_service_closed() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0xee; 32]); + + rig.connect(peer).await; + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await; + + drop(rig.inbound_tx); + + let item = drain_next(&mut rx).await.expect("expect Err"); + assert!(matches!(item, Err(BitswapError::ServiceClosed))); +} + +#[tokio::test(start_paused = true)] +async fn late_response_after_receiver_drop_is_ignored_and_cid_refetchable() { + let mut rig = empty_rig(); + let peer = litep2p::PeerId::random(); + let data = b"too-late".to_vec(); + let cid = cid_for_data(BLAKE2B_256_MULTIHASH_CODE, &data); + + rig.connect(peer).await; + let rx = rig.user_handle.request_stream(vec![cid]).unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("outbound"); + + // Caller gives up; the sweep drops the waiter while the peer request is still + // in flight. + drop(rx); + tokio::time::advance(Duration::from_secs(2)).await; + + // The late response hits no waiter and is dropped. + rig.send_block(peer, cid, &data).await; + // Let the actor process the late response before the fresh request goes in. + sleep(Duration::from_millis(1)).await; + + // A fresh request for the same CID starts from a clean slate: the peer is asked + // again and the block is delivered. + let mut rx = rig.user_handle.request_stream(vec![cid]).unwrap(); + let _ = drain_next(&mut rig.outbound_req_rx).await.expect("fresh WANT"); + rig.send_block(peer, cid, &data).await; + expect_block(&mut rx, cid, &data).await; +} + +#[tokio::test(start_paused = true)] +async fn too_many_waiters_per_cid_yields_overloaded() { + let rig = empty_rig(); + let cid = cid_for_digest(BLAKE2B_256_MULTIHASH_CODE, [0x77; 32]); + + let mut receivers = Vec::new(); + for _ in 0..MAX_WAITERS_PER_CID { + receivers.push(rig.user_handle.request_stream(vec![cid]).unwrap()); + } + // Give the actor a chance to admit all waiters before the one-too-many request. + sleep(Duration::from_millis(10)).await; + + let mut rejected = rig.user_handle.request_stream(vec![cid]).unwrap(); + let item = drain_next(&mut rejected).await.expect("item"); + assert!(matches!(item, Err(BitswapError::Overloaded))); +} + +mod proptests { + use super::*; + use crate::{BLAKE2B_256_MULTIHASH_CODE, RAW_CODEC}; + use cid::multihash::Multihash as CidMultihash; + use proptest::prelude::*; + use rand::{rngs::StdRng, SeedableRng}; + + const NUM_CIDS: u8 = 8; + const NUM_PEERS: usize = 4; + const SMALL_WINDOW: usize = 3; + + #[derive(Debug, Clone)] + enum Op { + AddWaiter(u8), + RemoveWaiter(usize), + Deliver(u8), + MarkPeerDone(usize, u8), + NoteHave(usize, u8), + Connect(usize), + Disconnect(usize), + Sweep(u64), + } + + fn op_strategy() -> impl Strategy { + prop_oneof![ + (0..NUM_CIDS).prop_map(Op::AddWaiter), + any::().prop_map(Op::RemoveWaiter), + (0..NUM_CIDS).prop_map(Op::Deliver), + (0..NUM_PEERS, 0..NUM_CIDS).prop_map(|(p, c)| Op::MarkPeerDone(p, c)), + (0..NUM_PEERS, 0..NUM_CIDS).prop_map(|(p, c)| Op::NoteHave(p, c)), + (0..NUM_PEERS).prop_map(Op::Connect), + (0..NUM_PEERS).prop_map(Op::Disconnect), + (1u64..8).prop_map(Op::Sweep), + ] + } + + struct Harness { + wants: WantSet, + waiters: SlotMap, + peers: Vec, + cids: Vec, + connected: HashSet, + now: Instant, + rng: StdRng, + } + + impl Harness { + fn new() -> Self { + let cids = (0..NUM_CIDS) + .map(|i| { + let mh = + CidMultihash::<64>::wrap(BLAKE2B_256_MULTIHASH_CODE, &[i; 32]).unwrap(); + Cid::new_v1(RAW_CODEC, mh) + }) + .collect(); + Self { + wants: WantSet::new(SMALL_WINDOW), + waiters: SlotMap::with_key(), + peers: (0..NUM_PEERS).map(|_| litep2p::PeerId::random()).collect(), + cids, + connected: HashSet::new(), + now: Instant::now(), + rng: StdRng::seed_from_u64(0), + } + } + + fn apply(&mut self, op: Op) { + match op { + Op::AddWaiter(c) => { + let cid = self.cids[c as usize]; + let id = self.waiters.insert(cid); + self.wants.add_waiter(cid, id); + }, + Op::RemoveWaiter(seed) => { + let Some(id) = self.waiters.keys().nth(seed % self.waiters.len().max(1)) else { + return; + }; + let cid = self.waiters.remove(id).expect("listed waiter exists"); + self.wants.remove_waiter(cid, id); + }, + Op::Deliver(c) => { + let waiter_ids = self + .wants + .take_waiters_for_delivered_cid(self.cids[c as usize]) + .unwrap_or_default(); + for id in waiter_ids { + self.waiters.remove(id); + } + }, + Op::MarkPeerDone(p, c) => { + self.wants.mark_peer_done_for_cid(self.peers[p], self.cids[c as usize]) + }, + Op::NoteHave(p, c) => { + self.wants.note_peer_have_for_cid(self.peers[p], self.cids[c as usize]) + }, + Op::Connect(p) => { + self.connected.insert(self.peers[p]); + }, + Op::Disconnect(p) => { + self.connected.remove(&self.peers[p]); + let _ = self.wants.remove_in_flight_peer(self.peers[p]); + }, + Op::Sweep(secs) => { + self.now += Duration::from_secs(secs); + let _ = self.wants.expire_peer_timeouts(self.now); + let _ = self.wants.restart_exhausted_rounds(self.now); + }, + } + } + + fn top_up(&mut self) { + for cid in self.wants.all_cids() { + let _ = + self.wants.next_peer_to_request(cid, &self.connected, self.now, &mut self.rng); + } + while self.wants.has_window_capacity() { + let Some(cid) = self.wants.pop_pending() else { break }; + let _ = + self.wants.next_peer_to_request(cid, &self.connected, self.now, &mut self.rng); + } + } + + fn check_invariants(&self) { + let live = self + .wants + .inner + .values() + .filter(|state| matches!(state.phase, CidPhase::InFlight { .. })) + .count(); + assert_eq!(self.wants.live, live, "live counter drifted"); + assert!(self.wants.live <= self.wants.max_live, "dispatch window overrun"); + let queued = self.wants.inner.values().filter(|state| state.is_queued()).count(); + assert_eq!(self.wants.queued, queued, "queued counter drifted"); + + for (cid, state) in &self.wants.inner { + assert!(!state.is_idle(), "idle entry retained for {cid}"); + if let CidPhase::InFlight { peer, .. } = state.phase { + assert!(!state.tried_peers.contains(&peer), "peer both tried and in flight"); + } + if state.is_queued() { + assert!( + self.wants.pending.contains(cid), + "queued CID without queue entry for {cid}", + ); + } + let retry_at = match state.phase { + CidPhase::Queued { retry_at } => retry_at, + CidPhase::RetryAt(at) => Some(at), + CidPhase::Ready | CidPhase::InFlight { .. } => None, + }; + if let Some(at) = retry_at { + assert!(at > self.now, "overdue round never restarted for {cid}"); + } + if state.has_waiters() && !self.connected.is_empty() { + assert!( + matches!( + state.phase, + CidPhase::Queued { .. } | + CidPhase::InFlight { .. } | + CidPhase::RetryAt(_) + ), + "stranded CID {cid}", + ); + } + } + } + } + + proptest! { + #[test] + fn want_set_invariants_hold(ops in prop::collection::vec(op_strategy(), 1..256)) { + let mut harness = Harness::new(); + for op in ops { + harness.apply(op); + harness.top_up(); + harness.check_invariants(); + } + } + } +} From 183166e80a742b238689c7b91193aebaa7db3320 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Sat, 18 Jul 2026 22:43:15 +0200 Subject: [PATCH 29/32] Remove wrong prdoc --- prdoc/pr_12052.prdoc | 39 --------------------------------------- 1 file changed, 39 deletions(-) delete mode 100644 prdoc/pr_12052.prdoc diff --git a/prdoc/pr_12052.prdoc b/prdoc/pr_12052.prdoc deleted file mode 100644 index 16463bb5e8f9..000000000000 --- a/prdoc/pr_12052.prdoc +++ /dev/null @@ -1,39 +0,0 @@ -title: "Network: rework the Bitswap client into a long-lived service in `sc-network-bitswap`" - -doc: - - audience: Node Dev - description: | - Replaces `request_bitswap_blocks` with `BitswapHandle` in the new - `sc-network-bitswap` crate. `BitswapHandle::request_stream` streams verified - blocks for requested CIDs. - - `build_network` and `build_network_advanced` now return an optional - `BitswapHandle` when `--ipfs-server` is enabled. - - The libp2p Bitswap implementation is removed; Bitswap now requires the litep2p - network backend. Enabling `--ipfs-server` together with - `--network-backend libp2p` fails at startup with a clear error. The - `NetworkBackend::bitswap_server` trait method and the `BitswapConfig` - associated type are removed. - - `sc-storage-chain-sync` now uses the streaming API and replaces - `BitswapPeerSource`, `NetworkHandle` and `SyncingHandle` with - `BitswapHandleSlot`. - - Closes https://github.com/paritytech/polkadot-sdk/issues/12052. - -crates: - - name: sc-network - bump: major - - name: sc-network-bitswap - bump: major - - name: sc-service - bump: major - - name: sc-storage-chain-sync - bump: major - - name: cumulus-client-service - bump: major - - name: polkadot-omni-node-lib - bump: major - - name: polkadot-service - bump: patch From 230fb0f806580c94cd71f3c3553a5ee9c92d62f4 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Sat, 18 Jul 2026 22:51:17 +0200 Subject: [PATCH 30/32] prdoc --- prdoc/pr_12686.prdoc | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 prdoc/pr_12686.prdoc diff --git a/prdoc/pr_12686.prdoc b/prdoc/pr_12686.prdoc new file mode 100644 index 000000000000..5794163650ca --- /dev/null +++ b/prdoc/pr_12686.prdoc @@ -0,0 +1,23 @@ +title: Extract and refactor the Bitswap service +doc: +- audience: Node Dev + description: |- + Moves the Bitswap client and server from `sc-network` into the new + `sc-network-bitswap` crate. The new actor exposes a streaming `BitswapHandle` and + centralizes CID deduplication, peer selection, retries, response handling, and + backpressure. Storage-chain sync and node service wiring now use this handle. + + The backend-specific Bitswap shims and local protobuf plumbing are removed. Bitswap + is now supported through Litep2p only; starting a node with `--ipfs-server` and a + different network backend returns an error. +crates: +- name: sc-network-bitswap + bump: minor +- name: sc-network + bump: major +- name: sc-service + bump: major +- name: sc-storage-chain-sync + bump: major +- name: cumulus-client-service + bump: major From 51763d337ab10c64cfedfd96297df7f2dbde615c Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Sun, 19 Jul 2026 13:20:39 +0200 Subject: [PATCH 31/32] Fix umbrella crate --- Cargo.lock | 1 + umbrella/Cargo.toml | 6 ++++++ umbrella/src/lib.rs | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index bc47eab9e224..056cabc75078 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17551,6 +17551,7 @@ dependencies = [ "sc-keystore", "sc-mixnet", "sc-network 0.34.0", + "sc-network-bitswap", "sc-network-common 0.33.0", "sc-network-gossip", "sc-network-light", diff --git a/umbrella/Cargo.toml b/umbrella/Cargo.toml index a5b2ac1be8d1..37a0a0191c01 100644 --- a/umbrella/Cargo.toml +++ b/umbrella/Cargo.toml @@ -997,6 +997,7 @@ node = [ "sc-keystore", "sc-mixnet", "sc-network", + "sc-network-bitswap", "sc-network-common", "sc-network-gossip", "sc-network-light", @@ -2796,6 +2797,11 @@ default-features = false optional = true path = "../substrate/client/network" +[dependencies.sc-network-bitswap] +default-features = false +optional = true +path = "../substrate/client/network/bitswap" + [dependencies.sc-network-common] default-features = false optional = true diff --git a/umbrella/src/lib.rs b/umbrella/src/lib.rs index 6bb57891042f..8ef22e08e0c0 100644 --- a/umbrella/src/lib.rs +++ b/umbrella/src/lib.rs @@ -1184,6 +1184,10 @@ pub use sc_mixnet; #[cfg(feature = "sc-network")] pub use sc_network; +/// Substrate Bitswap client/server service. +#[cfg(feature = "sc-network-bitswap")] +pub use sc_network_bitswap; + /// Substrate network common. #[cfg(feature = "sc-network-common")] pub use sc_network_common; From b39eedde79d13bcc7a023a0ff6e9389b879c8ca5 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Sun, 19 Jul 2026 23:24:25 +0200 Subject: [PATCH 32/32] Fix prdoc --- prdoc/pr_12686.prdoc | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/prdoc/pr_12686.prdoc b/prdoc/pr_12686.prdoc index 5794163650ca..a69f5fb4dc7b 100644 --- a/prdoc/pr_12686.prdoc +++ b/prdoc/pr_12686.prdoc @@ -12,12 +12,20 @@ doc: different network backend returns an error. crates: - name: sc-network-bitswap - bump: minor + bump: patch - name: sc-network bump: major - name: sc-service bump: major + validate: false - name: sc-storage-chain-sync bump: major - name: cumulus-client-service bump: major + validate: false +- name: polkadot-service + bump: patch +- name: polkadot-omni-node-lib + bump: major +- name: polkadot-sdk + bump: minor