Skip to main content

zakura_network/zakura/block_sync/
state.rs

1use super::{
2    bbr::{rounded_usize, BbrState},
3    config::*,
4    request::*,
5    work_queue::WorkQueue,
6    *,
7};
8use crate::zakura::{
9    chain_frontier_from_parts, Frontier, FrontierUpdate, ServicePeerDirection, ServicePeerSnapshot,
10    ZakuraBlockSyncCandidateState,
11};
12
13/// Hard ceiling on outbound block-range requests kept in flight to one peer.
14///
15/// A safety bound only; the binding per-peer concurrency is the peer's advertised
16/// `max_inflight_requests` (config `max_inflight_requests`, clamped to
17/// [`MAX_BS_INFLIGHT_REQUESTS`]).
18// `MAX_BS_INFLIGHT_REQUESTS` is a `u32`, which fits in `usize` on supported targets.
19pub(super) const EFFECTIVE_BS_OUTBOUND_INFLIGHT_PER_PEER: usize = MAX_BS_INFLIGHT_REQUESTS as usize;
20
21/// Cached chain frontiers used by the block-sync reactor.
22#[derive(Copy, Clone, Debug, Eq, PartialEq)]
23pub struct BlockSyncFrontiers {
24    /// Shared finalized height supplied by state.
25    pub finalized_height: block::Height,
26    /// Highest verified block-body height supplied by state.
27    pub verified_block_tip: block::Height,
28    /// Hash of [`verified_block_tip`](Self::verified_block_tip).
29    pub verified_block_hash: block::Hash,
30}
31
32/// Startup inputs for the dependency-neutral block-sync reactor.
33#[derive(Clone, Debug)]
34pub struct BlockSyncStartup {
35    /// Cached state frontiers at startup.
36    pub frontiers: BlockSyncFrontiers,
37    /// Durable best header tip at startup.
38    pub best_header_tip: (block::Height, block::Hash),
39    /// Header-sync best-tip watch used as the moving body-download target.
40    pub header_tip: Option<watch::Receiver<(block::Height, block::Hash)>>,
41    /// Shared sync exchange frontier stream used as the moving body-download target.
42    pub frontier_updates: Option<watch::Receiver<FrontierUpdate>>,
43    /// Local stream-6 configuration.
44    pub config: ZakuraBlockSyncConfig,
45    /// Shared shutdown signal owned by the embedding endpoint or test harness.
46    pub shutdown: CancellationToken,
47    /// Enables query actions for state-backed metadata.
48    pub state_queries_enabled: bool,
49    /// JSONL trace emitter for block-sync scheduling, download, and commit rows.
50    pub trace: ZakuraTrace,
51}
52
53impl BlockSyncStartup {
54    /// Build block-sync startup config from durable/frontier facts.
55    pub fn new(
56        frontiers: BlockSyncFrontiers,
57        best_header_tip: (block::Height, block::Hash),
58        header_tip: watch::Receiver<(block::Height, block::Hash)>,
59        config: ZakuraBlockSyncConfig,
60    ) -> Self {
61        Self {
62            frontiers,
63            best_header_tip,
64            header_tip: Some(header_tip),
65            frontier_updates: None,
66            config,
67            shutdown: CancellationToken::new(),
68            state_queries_enabled: true,
69            trace: ZakuraTrace::noop(),
70        }
71    }
72
73    /// Build block-sync startup config from shared sync exchange frontiers.
74    pub fn new_with_exchange(
75        frontiers: BlockSyncFrontiers,
76        best_header_tip: (block::Height, block::Hash),
77        frontier_updates: watch::Receiver<FrontierUpdate>,
78        config: ZakuraBlockSyncConfig,
79    ) -> Self {
80        Self {
81            frontiers,
82            best_header_tip,
83            header_tip: None,
84            frontier_updates: Some(frontier_updates),
85            config,
86            shutdown: CancellationToken::new(),
87            state_queries_enabled: true,
88            trace: ZakuraTrace::noop(),
89        }
90    }
91
92    /// Build a latest-value frontier update stream from legacy startup pieces.
93    pub fn frontier_update_from_parts(
94        frontiers: BlockSyncFrontiers,
95        best_header_tip: (block::Height, block::Hash),
96    ) -> FrontierUpdate {
97        FrontierUpdate {
98            frontier: chain_frontier_from_parts(
99                frontiers.finalized_height,
100                Frontier::new(frontiers.verified_block_tip, frontiers.verified_block_hash),
101                Frontier::new(best_header_tip.0, best_header_tip.1),
102            ),
103            change: crate::zakura::FrontierChange::Snapshot,
104        }
105    }
106
107    pub(super) fn inert(config: ZakuraBlockSyncConfig) -> Self {
108        Self {
109            frontiers: BlockSyncFrontiers {
110                finalized_height: block::Height::MIN,
111                verified_block_tip: block::Height::MIN,
112                verified_block_hash: block::Hash([0; 32]),
113            },
114            best_header_tip: (block::Height::MIN, block::Hash([0; 32])),
115            header_tip: None,
116            frontier_updates: None,
117            config,
118            shutdown: CancellationToken::new(),
119            state_queries_enabled: false,
120            trace: ZakuraTrace::noop(),
121        }
122    }
123}
124
125/// Cheap cloneable handle used by services and drivers to inform block sync.
126///
127/// per-peer routines carries the shared per-peer download primitives here too, so
128/// `service::add_peer` (the pipe-routine spawn point) can wire each per-peer
129/// pipe-routine with the same `WorkQueue`/`ByteBudget`/`PeerRegistry`/Sequencer/
130/// action/routine-to-reactor channels the reactor created.
131#[derive(Clone, Debug)]
132pub struct BlockSyncHandle {
133    pub(super) events: mpsc::Sender<BlockSyncEvent>,
134    pub(super) lifecycle: mpsc::UnboundedSender<BlockSyncEvent>,
135    pub(super) peers: watch::Receiver<ServicePeerSnapshot>,
136    pub(super) status: watch::Receiver<BlockSyncStatus>,
137    pub(super) candidates: watch::Receiver<ZakuraBlockSyncCandidateState>,
138    /// Shared primitives every per-peer pipe-routine is wired with at spawn
139    /// (`service::add_peer`). `None` for the inert/handle-less test constructors
140    /// that never spawn routines.
141    pub(super) routine_wiring: Option<RoutineWiring>,
142}
143
144/// The shared download primitives a per-peer pipe-routine is constructed with.
145/// Created once in `spawn_block_sync_reactor` and threaded through the handle to
146/// `service::add_peer`.
147#[derive(Clone, Debug)]
148pub(super) struct RoutineWiring {
149    pub(super) config: ZakuraBlockSyncConfig,
150    pub(super) budget: ByteBudget,
151    pub(super) work: Arc<WorkQueue>,
152    pub(super) registry: Arc<super::peer_registry::PeerRegistry>,
153    pub(super) received_throughput: Arc<std::sync::Mutex<ThroughputMeter>>,
154    pub(super) sequencer_input: mpsc::Sender<super::sequencer_task::SequencedBody>,
155    pub(super) sequencer_input_bytes: Arc<std::sync::atomic::AtomicU64>,
156    pub(super) sequencer_input_decoded_attributed_memory_bytes: Arc<std::sync::atomic::AtomicU64>,
157    pub(super) actions: mpsc::Sender<BlockSyncAction>,
158    pub(super) routine_to_reactor: mpsc::Sender<super::events::RoutineToReactor>,
159    pub(super) view: watch::Receiver<super::sequencer_task::SequencerView>,
160    pub(super) trace: ZakuraTrace,
161}
162
163impl BlockSyncHandle {
164    /// Send a fact/event to the block-sync reactor.
165    pub async fn send(
166        &self,
167        event: BlockSyncEvent,
168    ) -> Result<(), mpsc::error::SendError<BlockSyncEvent>> {
169        self.events.send(event).await
170    }
171
172    /// Try to send a fact/event without awaiting.
173    pub fn try_send(
174        &self,
175        event: BlockSyncEvent,
176    ) -> Result<(), mpsc::error::TrySendError<BlockSyncEvent>> {
177        self.events.try_send(event)
178    }
179
180    /// Send a control-plane event without sharing the bounded wire-event queue.
181    pub fn send_control(
182        &self,
183        event: BlockSyncEvent,
184    ) -> Result<(), mpsc::error::SendError<BlockSyncEvent>> {
185        self.lifecycle
186            .send(event)
187            .map_err(|error| mpsc::error::SendError(error.0))
188    }
189
190    /// Send a peer lifecycle event without sharing the bounded wire-event queue.
191    pub fn send_lifecycle(
192        &self,
193        event: BlockSyncEvent,
194    ) -> Result<(), mpsc::error::SendError<BlockSyncEvent>> {
195        self.send_control(event)
196    }
197
198    /// Return the currently cached peer slot snapshot.
199    pub fn peer_snapshot(&self) -> ServicePeerSnapshot {
200        *self.peers.borrow()
201    }
202
203    /// Subscribe to local block-sync status advertisements.
204    pub fn subscribe_status(&self) -> watch::Receiver<BlockSyncStatus> {
205        self.status.clone()
206    }
207
208    /// Return the currently cached local status advertisement.
209    pub fn local_status(&self) -> BlockSyncStatus {
210        *self.status.borrow()
211    }
212
213    /// Subscribe to block-sync candidate-selection hints.
214    pub fn subscribe_candidate_state(&self) -> watch::Receiver<ZakuraBlockSyncCandidateState> {
215        self.candidates.clone()
216    }
217
218    /// Return the currently cached block-sync candidate-selection hints.
219    pub fn candidate_state(&self) -> ZakuraBlockSyncCandidateState {
220        self.candidates.borrow().clone()
221    }
222}
223
224#[derive(Debug)]
225pub(super) struct BlockSyncState {
226    pub(super) finalized_height: block::Height,
227    pub(super) verified_block_hash: block::Hash,
228    pub(super) servable_high: block::Height,
229    pub(super) servable_hash: block::Hash,
230    pub(super) best_header_tip: block::Height,
231    pub(super) best_header_hash: block::Hash,
232    /// Thin per-peer handles the reactor keeps for demux/serving/admission. The
233    /// per-peer *download* state moved into the spawned [`PeerRoutine`](super::peer_routine)
234    /// (per-peer routines); the cross-peer facts the reactor/producer need live in the
235    /// [`PeerRegistry`](super::peer_registry).
236    pub(super) peers: HashMap<ZakuraPeerId, PeerBlockState>,
237    pub(super) parked_peers: HashSet<ZakuraPeerId>,
238    /// Sorted set of needed download heights. Replaces the central
239    /// `BlockRangeScheduler`: the per-peer issuance path pulls work in its own
240    /// servable range, dedup/covered are `in_flight`, and the floor is GC only.
241    /// `Arc` so the state stays cheaply `Clone` and the queue is shared with the
242    /// Sequencer task and the per-peer routines.
243    pub(super) work_queue: Arc<WorkQueue>,
244    pub(super) budget: ByteBudget,
245    pub(super) needed_heights: Vec<block::Height>,
246    pub(super) status_refresh: RateMeter,
247    pub(super) pending_status_refresh: bool,
248    pub(super) last_advertised_status: BlockSyncStatus,
249    /// Throughput of bodies received off the wire (the download rate). Shared
250    /// with the per-peer routines (they `record` on receipt); the reactor samples
251    /// it each trace tick. Compared against the Sequencer task's committed
252    /// throughput it separates a download-limited sync from a commit-limited one.
253    pub(super) received_throughput: Arc<std::sync::Mutex<ThroughputMeter>>,
254}
255
256impl BlockSyncState {
257    pub(super) fn new(startup: &BlockSyncStartup) -> Self {
258        let last_advertised_status = BlockSyncStatus {
259            servable_low: block::Height::MIN,
260            servable_high: startup.frontiers.verified_block_tip,
261            tip_hash: startup.frontiers.verified_block_hash,
262            max_blocks_per_response: startup.config.advertised_max_blocks_per_response(),
263            max_inflight_requests: startup.config.advertised_max_inflight_requests(),
264            max_response_bytes: startup.config.advertised_max_response_bytes(),
265        };
266
267        Self {
268            finalized_height: startup.frontiers.finalized_height,
269            verified_block_hash: startup.frontiers.verified_block_hash,
270            servable_high: startup.frontiers.verified_block_tip,
271            servable_hash: startup.frontiers.verified_block_hash,
272            best_header_tip: startup.best_header_tip.0,
273            best_header_hash: startup.best_header_tip.1,
274            peers: HashMap::new(),
275            parked_peers: HashSet::new(),
276            work_queue: Arc::new(WorkQueue::new(startup.frontiers.verified_block_tip)),
277            budget: ByteBudget::new(startup.config.max_inflight_block_bytes),
278            needed_heights: Vec::new(),
279            status_refresh: RateMeter::new(startup.config.status_refresh_interval),
280            pending_status_refresh: false,
281            last_advertised_status,
282            received_throughput: Arc::new(std::sync::Mutex::new(ThroughputMeter::new(
283                Instant::now(),
284            ))),
285        }
286    }
287
288    pub(super) fn peer_snapshot(&self, limits: ServicePeerLimits) -> ServicePeerSnapshot {
289        let inbound = self
290            .peers
291            .values()
292            .filter(|peer| peer.direction == ServicePeerDirection::Inbound)
293            .count();
294        let outbound = self
295            .peers
296            .values()
297            .filter(|peer| peer.direction == ServicePeerDirection::Outbound)
298            .count();
299        ServicePeerSnapshot::new(inbound, outbound, limits)
300    }
301}
302
303/// Carved out of `PeerBlockState` so the window math stays unit-testable
304/// while the per-peer download state moves into the spawned
305/// [`PeerRoutine`](super::peer_routine) (per-peer routines). The routine embeds one of these.
306#[derive(Clone, Debug)]
307pub(super) struct DownloadWindow {
308    pub(super) max_inflight_requests: u32,
309    pub(super) outstanding: Vec<OutstandingBlockRange>,
310    /// Per-peer BBR-lite estimators + cwnd — the sole congestion controller. Under
311    /// [`CwndUnit::Bytes`] the cwnd is itself a byte budget sourced from header size
312    /// hints (no fixed per-request byte weight), so there is no `nominal_request_bytes`.
313    bbr: BbrState,
314    /// Whether the cwnd budgets outstanding work in request slots or reserved bytes.
315    cwnd_unit: CwndUnit,
316    /// Request-count cap used while byte-cwnd has no fresh BDP sample.
317    pub(super) startup_request_cap: usize,
318    /// Deadline by which an active peer must send another accepted full block.
319    pub(super) block_liveness_deadline: Option<Instant>,
320    /// Last time this peer was sent a block-body request.
321    pub(super) last_request_at: Option<Instant>,
322    /// Last time this peer sent an accepted full block body.
323    pub(super) last_block_at: Option<Instant>,
324    /// Consecutive `GetBlocks` requests sent since the last accepted full block body.
325    pub(super) requests_without_block_progress: u32,
326    /// Maximum no-progress requests this peer may receive in its current proof state.
327    max_requests_without_block_progress: u32,
328    /// Maximum no-progress requests this peer may receive before its first accepted body.
329    initial_block_probe_requests: u32,
330}
331
332#[derive(Copy, Clone, Debug, Eq, PartialEq)]
333pub(super) enum LivenessOutcome {
334    Ok,
335    Disarm,
336    Disconnect,
337}
338
339impl DownloadWindow {
340    pub(super) fn new(config: &ZakuraBlockSyncConfig) -> Self {
341        Self {
342            max_inflight_requests: config.advertised_max_inflight_requests(),
343            outstanding: Vec::new(),
344            bbr: BbrState::new(config),
345            cwnd_unit: config.bbr_cwnd_unit,
346            startup_request_cap: usize::try_from(config.initial_inflight_requests)
347                .unwrap_or(usize::MAX)
348                .max(1),
349            block_liveness_deadline: None,
350            last_request_at: None,
351            last_block_at: None,
352            requests_without_block_progress: 0,
353            max_requests_without_block_progress: config.max_requests_without_block_progress,
354            initial_block_probe_requests: config.initial_block_probe_requests,
355        }
356    }
357
358    pub(super) fn delivery_snapshot(&self, now: Instant) -> DeliverySnapshot {
359        self.bbr.delivery_snapshot(now)
360    }
361
362    /// Record a completed request into the BBR estimators (RTprop / BtlBw / delivered)
363    /// and advance the ProbeRtt phase machine. `delivered_bytes` is the request's total
364    /// delivered body bytes — under the single-block-per-request invariant
365    /// (`DEFAULT_BS_BLOCKS_PER_RESPONSE = 1`) this is the completing body's
366    /// `serialized_bytes`. Call after removing the completed request from `outstanding`,
367    /// so the in-flight measure reflects the post-completion queue depth.
368    pub(super) fn record_delivery(
369        &mut self,
370        now: Instant,
371        elapsed: Duration,
372        blocks: u32,
373        delivered_bytes: u64,
374        snapshot: DeliverySnapshot,
375    ) {
376        // The ProbeRtt drain check compares this against `min_cwnd`, so the in-flight
377        // measure MUST be in the cwnd's unit: request count under `Blocks`, reserved
378        // body bytes under `Bytes`. Passing the raw request count under `Bytes` made the
379        // drain check (`count <= min_cwnd_bytes`) trivially true, so the hold timer
380        // started before the byte queue had actually drained and the RTprop sample could
381        // still be contended.
382        let inflight = match self.cwnd_unit {
383            // `outstanding.len()` (a `usize` request count) widens to `u64` losslessly.
384            CwndUnit::Blocks => self.outstanding.len() as u64,
385            CwndUnit::Bytes => self.outstanding_reserved_bytes(),
386        };
387        self.bbr
388            .record_delivery(now, elapsed, blocks, delivered_bytes, inflight, snapshot);
389    }
390
391    /// The effective BBR cwnd as a **request count**, for diagnostics that compare
392    /// against the request-count hard cap (the periodic slot trace, cross-peer floor
393    /// bias). Under `Blocks` this is the cwnd directly; under `Bytes` it is the byte
394    /// cwnd divided by a representative body size, so it reads as "requests this peer's
395    /// byte window admits". The byte cwnd itself is available via
396    /// [`bbr_effective_cwnd_bytes`](Self::bbr_effective_cwnd_bytes).
397    pub(super) fn bbr_effective_cwnd(&self) -> usize {
398        match self.cwnd_unit {
399            CwndUnit::Blocks => self.bbr.effective_cwnd(),
400            CwndUnit::Bytes => {
401                let cwnd_bytes = self.bbr.effective_cwnd() as u64;
402                let rep = self.representative_body_bytes();
403                usize::try_from((cwnd_bytes / rep.max(1)).max(1)).unwrap_or(usize::MAX)
404            }
405        }
406    }
407
408    /// The effective byte cwnd under `Bytes` (`None` under `Blocks`), for tracing.
409    pub(super) fn bbr_effective_cwnd_bytes(&self) -> Option<u64> {
410        matches!(self.cwnd_unit, CwndUnit::Bytes).then(|| self.bbr.effective_cwnd() as u64)
411    }
412
413    /// A representative body size in bytes for converting a byte cwnd into a request
414    /// count: the mean reserved bytes across in-flight requests, falling back to the
415    /// per-block worst case when nothing is outstanding. Used only for diagnostics and
416    /// the floor-bypass byte bonus, never for admission.
417    fn representative_body_bytes(&self) -> u64 {
418        let outstanding = self.outstanding.len() as u64;
419        if outstanding == 0 {
420            return block::MAX_BLOCK_BYTES;
421        }
422        (self.outstanding_reserved_bytes() / outstanding).max(1)
423    }
424
425    /// Current RTprop estimate in ms (windowed min as of `now`), for tracing and
426    /// floor-server preference. Filtering by `now` reports `None` (worst floor server)
427    /// once a deteriorating peer's only fast samples age past the horizon, rather than a
428    /// stale-low RTprop.
429    pub(super) fn bbr_rtprop_ms(&self, now: Instant) -> Option<u64> {
430        self.bbr.rtprop_ms(now)
431    }
432
433    /// The current BtlBw estimate in milli-blocks/sec (blocks/sec × 1000), for tracing.
434    /// `None` under `Bytes`, where [`bbr_btlbw_bytes_per_sec`](Self::bbr_btlbw_bytes_per_sec)
435    /// is the meaningful rate.
436    pub(super) fn bbr_btlbw_milliblocks(&self, now: Instant) -> Option<u64> {
437        matches!(self.cwnd_unit, CwndUnit::Blocks)
438            .then(|| self.bbr.btlbw_milliblocks_per_sec(now))
439            .flatten()
440    }
441
442    /// Current BtlBw estimate in bytes/sec under `Bytes` (`None` under `Blocks`), as of
443    /// `now`. Filtering by `now` keeps a stale-high rate from tightening the above-floor
444    /// request deadline after the peer has stopped delivering.
445    pub(super) fn bbr_btlbw_bytes_per_sec(&self, now: Instant) -> Option<u64> {
446        if !matches!(self.cwnd_unit, CwndUnit::Bytes) {
447            return None;
448        }
449        self.bbr
450            .btlbw_units_per_sec(now)
451            // A non-negative finite bytes/sec rate rounds into u64 for any real link.
452            .map(|rate| rate.round() as u64)
453    }
454
455    /// Bytes reserved across this peer's in-flight requests, for tracing the byte window
456    /// occupancy.
457    pub(super) fn bbr_inflight_bytes(&self) -> u64 {
458        self.outstanding_reserved_bytes()
459    }
460
461    /// Total delivered through this peer's completed requests, for tracing — blocks
462    /// under `Blocks`, bytes under `Bytes`.
463    pub(super) fn bbr_delivered(&self) -> u64 {
464        self.bbr.delivered()
465    }
466
467    /// The current BBR phase as a numeric code (0 = ProbeBw, 1 = ProbeRtt), for tracing.
468    pub(super) fn bbr_phase_code(&self) -> u64 {
469        self.bbr.phase_code()
470    }
471
472    /// The smoothed request round-trip in milliseconds the delay-gradient tracks.
473    pub(super) fn bbr_smoothed_elapsed_ms(&self) -> Option<u64> {
474        self.bbr.smoothed_elapsed_ms()
475    }
476
477    /// The delay-gradient cwnd ceiling in blocks once it binds (`None` while unbounded).
478    pub(super) fn bbr_delay_cap(&self) -> Option<u64> {
479        self.bbr
480            .delay_cap()
481            .map(|cap| u64::try_from(cap).unwrap_or(u64::MAX))
482    }
483
484    /// This peer's reliability estimate (goodput fraction) in per-mille (0–1000), for
485    /// tracing the cwnd discount applied to a request-dropping carrier.
486    pub(super) fn bbr_reliability_permille(&self) -> u64 {
487        self.bbr.reliability_permille()
488    }
489
490    pub(super) fn available_slots(&self) -> usize {
491        self.available_slots_at(Instant::now())
492    }
493
494    pub(super) fn available_slots_at(&self, now: Instant) -> usize {
495        self.available_slots_with_bonus_at(0, now)
496    }
497
498    /// Available headroom allowing `bonus` extra in-flight requests beyond the BBR cwnd,
499    /// still clamped to the peer's advertised hard cap. `bonus == 0` is the normal
500    /// (above-floor) capacity used by [`Self::available_slots`]; a small positive `bonus` is
501    /// the floor bypass — it lets the lowest missing height be fetched even when the
502    /// peer is saturated at its cwnd, without ever exceeding the advertised inflight.
503    ///
504    /// The return value is non-zero exactly when there is room for at least one more
505    /// request; callers use it as a gate, not an absolute count. Under
506    /// [`CwndUnit::Bytes`] the cwnd is itself a byte budget (`BtlBw_bytes × RTprop ×
507    /// gain`, from header size hints) compared against reserved body bytes, so a peer
508    /// serving large bodies holds fewer in flight and a peer serving small bodies holds
509    /// many — the in-flight *request* count falls out of `cwnd_bytes / body_size`. The
510    /// controller is unit-agnostic; only this comparison differs — the seam that makes
511    /// switching units a small change.
512    pub(super) fn available_slots_with_bonus_at(&self, bonus: usize, now: Instant) -> usize {
513        // BBR-lite is the sole congestion controller: cap in-flight at the BDP-derived
514        // cwnd so a peer's queue stays at ~one BDP and head-of-line latency tracks
515        // RTprop. The floor bypass adds `bonus` on top.
516        let hard_cap = self.hard_outbound_capacity();
517        match self.cwnd_unit {
518            CwndUnit::Blocks => {
519                let cwnd_slots = self
520                    .bbr
521                    .effective_cwnd()
522                    .saturating_add(bonus)
523                    .min(hard_cap);
524                cwnd_slots.saturating_sub(self.outstanding.len())
525            }
526            CwndUnit::Bytes => {
527                // The peer's advertised request-count cap still binds in byte mode: a peer
528                // serving tiny bodies must never be issued more in-flight *requests* than it
529                // advertised it will service, however much byte headroom the cwnd still
530                // shows. Once the request count reaches the hard cap there is no slot,
531                // regardless of bytes — mirroring the blocks-unit ceiling (review fix F2).
532                let outstanding = self.outstanding.len();
533                if outstanding >= hard_cap {
534                    return 0;
535                }
536                if !self.bbr.has_fresh_bdp(now) {
537                    let startup_cap = self.startup_request_cap.saturating_add(bonus).min(hard_cap);
538                    if outstanding >= startup_cap {
539                        return 0;
540                    }
541                }
542                // The cwnd is already a byte budget. The floor bypass grants `bonus`
543                // *representative* bodies of extra byte headroom — sized to the recent
544                // per-request reservation, NOT the 2 MB worst case — so a starved floor
545                // can still be fetched when the byte window is full without ballooning
546                // the in-flight bytes far past the cwnd (which would defeat the byte
547                // denomination's head-of-line bound). The take is still count-capped to
548                // one block and passes the real `ByteBudget` reservation.
549                let reserved = self.outstanding_reserved_bytes();
550                let representative = self.representative_body_bytes();
551                let bonus_bytes = (bonus as u64).saturating_mul(representative);
552                let cwnd_bytes = (self.bbr.effective_cwnd() as u64).saturating_add(bonus_bytes);
553                usize::try_from(cwnd_bytes.saturating_sub(reserved)).unwrap_or(usize::MAX)
554            }
555        }
556    }
557
558    /// Remaining cwnd **byte** headroom for a take under [`CwndUnit::Bytes`]: byte window
559    /// (plus `bonus` representative bodies of floor-bypass headroom) less bytes already
560    /// reserved in-flight. `None` under [`CwndUnit::Blocks`], where the window is a request
561    /// count (via `available_slots_with_bonus` + per-request cap), not a byte ceiling.
562    ///
563    /// Used as the byte cap of the work-queue take, this makes the byte cwnd a real
564    /// admission limit (outstanding reserved bytes ≤ window) rather than a nonzero gate, so
565    /// a small window cannot issue a large multi-body request. The take always admits its
566    /// first item for floor progress, so the only permitted overshoot is that single body.
567    pub(super) fn cwnd_byte_headroom_at(&self, bonus: usize, now: Instant) -> Option<u64> {
568        match self.cwnd_unit {
569            CwndUnit::Blocks => None,
570            // `available_slots_with_bonus` already returns the remaining byte headroom
571            // (cwnd bytes + bonus bodies − reserved) under `Bytes`.
572            CwndUnit::Bytes => Some(self.available_slots_with_bonus_at(bonus, now) as u64),
573        }
574    }
575
576    #[cfg(test)]
577    pub(super) fn available_slots_with_bonus(&self, bonus: usize) -> usize {
578        self.available_slots_with_bonus_at(bonus, Instant::now())
579    }
580
581    #[cfg(test)]
582    pub(super) fn cwnd_byte_headroom(&self, bonus: usize) -> Option<u64> {
583        self.cwnd_byte_headroom_at(bonus, Instant::now())
584    }
585
586    /// Scale a base floor-bypass slot count by the peer's reliability discount, so the
587    /// above-window bypass (slots granted *beyond* the cwnd to keep the lowest missing
588    /// height moving through a saturated carrier) shrinks with the same signal that shrinks
589    /// the window. A healthy peer (factor ≈ 1) keeps the full bypass; a sealed peer
590    /// (factor → 0) gets none, so a wedged peer receives no requests of any kind.
591    pub(super) fn scaled_floor_bonus(&self, base: usize) -> usize {
592        // Shares the finite/non-negative rounding policy with `effective_cwnd` via
593        // `rounded_usize`: the fallback `0` seals the bypass on a non-finite factor rather
594        // than opening it (base ≥ 0 and factor ∈ [0, 1], so the fallback is defensive only).
595        rounded_usize(base as f64 * self.bbr.reliability_factor(), 0)
596    }
597
598    #[cfg(test)]
599    pub(super) fn reliability_factor(&self) -> f64 {
600        self.bbr.reliability_factor()
601    }
602
603    /// Bytes reserved across this peer's in-flight requests (the per-request size
604    /// estimates of heights not yet received). Recomputed on demand — the byte unit is
605    /// experimental; a hot path would maintain a running counter instead.
606    fn outstanding_reserved_bytes(&self) -> u64 {
607        self.outstanding.iter().fold(0u64, |acc, range| {
608            acc.saturating_add(range.reserved_bytes())
609        })
610    }
611
612    /// Record `timed_out` requests that expired without a body. Applies the BBR cwnd dip
613    /// once (one multiplicative, min-cwnd-bounded dip per batch) and ages the reliability
614    /// EWMA once per timed-out request, so a chronically dropping peer keeps a suppressed
615    /// cwnd (a smaller share of the work) rather than fully recovering on its next success.
616    pub(super) fn record_timeout(&mut self, timed_out: usize) {
617        self.bbr.dip_on_timeout();
618        self.bbr.penalize_reliability(timed_out);
619    }
620
621    /// Age the reliability EWMA by **one** goodput failure for a short response (a
622    /// `BlocksDone` terminator or `RangeUnavailable`) that left `missing > 0` heights
623    /// unreceived. One failure *per request*, matching the per-request timeout charge
624    /// ([`record_timeout`](Self::record_timeout)) and the per-request delivery credit
625    /// ([`credit_late_delivery`](Self::credit_late_delivery)): the EWMA is a per-request
626    /// goodput fraction, so charging one-per-missing-height would near-seal a peer for a
627    /// single protocol-legal short answer once `max_blocks_per_response > 1` (at the shipped
628    /// default of 1 the two denominations coincide). Unlike a timeout this does *not* dip the
629    /// cwnd — a short response is a goodput, not a latency/congestion, signal — but it must
630    /// still count so a peer cannot deliver one body per request to keep its
631    /// liveness/no-progress accounting reset while dropping the rest.
632    pub(super) fn penalize_short_response(&mut self, missing: usize) {
633        if missing > 0 {
634            self.bbr.penalize_reliability(1);
635        }
636    }
637
638    /// Credit the reliability EWMA for a body that arrived *late* — after its request had
639    /// timed out and been charged as a failure. Offsets that charge: a suddenly-slower peer
640    /// whose fast-window backlog drains past the per-request deadline stays "weaker but
641    /// kept" instead of being sealed like a dropping/wedged peer (which sends no late body).
642    pub(super) fn credit_late_delivery(&mut self) {
643        self.bbr.credit_late_success();
644    }
645
646    pub(super) fn has_block_progress(&self) -> bool {
647        self.last_block_at.is_some()
648    }
649
650    pub(super) fn no_progress_request_cap(&self) -> u32 {
651        if self.has_block_progress() {
652            self.max_requests_without_block_progress
653        } else {
654            self.initial_block_probe_requests
655        }
656    }
657
658    pub(super) fn arm_liveness(&mut self, now: Instant, timeout: Duration) {
659        self.last_request_at = Some(now);
660        self.requests_without_block_progress =
661            self.requests_without_block_progress.saturating_add(1);
662        if self.block_liveness_deadline.is_none() {
663            self.block_liveness_deadline = Some(now + timeout);
664        }
665    }
666
667    pub(super) fn note_block_progress(&mut self, now: Instant, timeout: Duration) {
668        self.last_block_at = Some(now);
669        self.requests_without_block_progress = 0;
670        self.block_liveness_deadline = if self.outstanding.is_empty() {
671            None
672        } else {
673            Some(now + timeout)
674        };
675    }
676
677    pub(super) fn disarm_liveness_after_progress_if_idle(&mut self) {
678        if self.outstanding.is_empty()
679            && matches!(
680                (self.last_request_at, self.last_block_at),
681                (Some(request_at), Some(block_at)) if block_at >= request_at
682            )
683        {
684            self.block_liveness_deadline = None;
685        }
686    }
687
688    pub(super) fn clear_liveness_if_idle(&mut self) {
689        if self.outstanding.is_empty() {
690            self.block_liveness_deadline = None;
691        }
692    }
693
694    /// Reset per-view no-progress accounting after a destructive view reset. The reset
695    /// returned this peer's outstanding to the queue on *our* initiative (a reorg/rollback,
696    /// not the peer's fault), so the in-flight probe streak must not stay charged against
697    /// it: clearing `requests_without_block_progress` lets an unproven peer probe again
698    /// instead of wedging at its one-probe cap forever (the reset also cleared its liveness
699    /// deadline, so nothing would disconnect it). Proof state (`last_block_at`) is preserved.
700    pub(super) fn note_view_reset(&mut self) {
701        self.requests_without_block_progress = 0;
702        self.clear_liveness_if_idle();
703    }
704
705    /// Push the block-liveness deadline out by `timeout` when a would-be disconnect is
706    /// attributable to *local* outbound backpressure, not the peer: while our outbound queue
707    /// is full the routine stops draining inbound, so a useful body may be sitting unread.
708    /// Avoids punishing the peer for our own write-side congestion.
709    pub(super) fn extend_liveness_deadline(&mut self, now: Instant, timeout: Duration) {
710        self.block_liveness_deadline = Some(now + timeout);
711    }
712
713    pub(super) fn check_liveness(&self, now: Instant) -> LivenessOutcome {
714        match self.block_liveness_deadline {
715            None => LivenessOutcome::Ok,
716            // Defensive: a deadline that exists with no recorded request was never armed by
717            // `arm_liveness` (which always sets `last_request_at`), so it was not actually
718            // earned by an outstanding request. Unreachable in production — every deadline
719            // setter runs after a request is sent — so disarm it rather than disconnect the
720            // peer over a deadline it never earned. Reached only by tests that set the
721            // deadline directly.
722            Some(deadline) if self.last_request_at.is_none() && now >= deadline => {
723                LivenessOutcome::Disarm
724            }
725            Some(deadline) if now < deadline => LivenessOutcome::Ok,
726            Some(_) => LivenessOutcome::Disconnect,
727        }
728    }
729
730    pub(super) fn hard_outbound_capacity(&self) -> usize {
731        usize::try_from(self.max_inflight_requests)
732            .expect("u32 max inflight requests fits in usize on supported targets")
733            .min(EFFECTIVE_BS_OUTBOUND_INFLIGHT_PER_PEER)
734    }
735
736    pub(super) fn outstanding_index_for_height(&self, height: block::Height) -> Option<usize> {
737        self.outstanding
738            .iter()
739            .position(|outstanding| outstanding.request.contains(height))
740    }
741
742    pub(super) fn outstanding_index_for_start(&self, start_height: block::Height) -> Option<usize> {
743        self.outstanding
744            .iter()
745            .position(|outstanding| outstanding.request.start_height == start_height)
746    }
747}
748
749/// Thin per-peer handle the reactor keeps to serve inbound
750/// `GetBlocks` (the session clone + serving meters), advertise our `Status`, count
751/// admission, and tear down. The per-peer *download* state + inbound decode live
752/// in the per-peer pipe-routine ([`PeerRoutine`](super::peer_routine)); servable/
753/// caps live in the [`PeerRegistry`](super::peer_registry). There is no reactor→
754/// routine channel (inverted data flow): the routine owns its own `FramedRecv`.
755#[derive(Debug)]
756pub(super) struct PeerBlockState {
757    pub(super) session: BlockSyncPeerSession,
758    pub(super) direction: ServicePeerDirection,
759    /// Per-peer rate meter for the reactor's `Status` *advertisement* refresh
760    /// (serving-tip change broadcast + retry to peers that have not acknowledged
761    /// our Status). The inbound-status *reply* half lives on the routine's
762    /// `status_reply_meter`; this half stays reactor-side because the reactor owns
763    /// serving-tip advertisement.
764    pub(super) refresh_meter: RateMeter,
765    pub(super) served_blocks_inflight: u32,
766    pub(super) served_block_requests: VecDeque<(block::Height, Instant)>,
767}
768
769impl PeerBlockState {
770    pub(super) fn new(session: BlockSyncPeerSession, config: &ZakuraBlockSyncConfig) -> Self {
771        Self {
772            direction: session.direction(),
773            session,
774            refresh_meter: RateMeter::new(config.status_refresh_interval),
775            served_blocks_inflight: 0,
776            served_block_requests: VecDeque::new(),
777        }
778    }
779
780    pub(super) fn try_start_serving_blocks(
781        &mut self,
782        local_inflight_cap: u32,
783        start_height: block::Height,
784    ) -> bool {
785        if self.served_blocks_inflight >= local_inflight_cap {
786            return false;
787        }
788        self.served_blocks_inflight = self.served_blocks_inflight.saturating_add(1);
789        self.served_block_requests
790            .push_back((start_height, Instant::now()));
791        true
792    }
793
794    pub(super) fn serving_blocks_elapsed(&self, start_height: block::Height) -> Option<Duration> {
795        self.served_block_requests
796            .iter()
797            .find_map(|(start, started)| (*start == start_height).then(|| started.elapsed()))
798    }
799
800    pub(super) fn finish_serving_blocks(
801        &mut self,
802        start_height: block::Height,
803    ) -> Option<Duration> {
804        self.served_blocks_inflight = self.served_blocks_inflight.saturating_sub(1);
805        self.served_block_requests
806            .iter()
807            .position(|(start, _)| *start == start_height)
808            .and_then(|index| self.served_block_requests.remove(index))
809            .map(|(_, started)| started.elapsed())
810    }
811}
812
813#[derive(Clone, Debug)]
814pub(super) struct OutstandingBlockRange {
815    pub(super) request: BlockRangeRequest,
816    pub(super) queued_at: Instant,
817    pub(super) deadline: Instant,
818    pub(super) delivery_snapshot: DeliverySnapshot,
819    pub(super) delivered_bytes: u64,
820    pub(super) received: ReceivedBlockTracker,
821}
822
823#[derive(Copy, Clone, Debug)]
824pub(super) struct DeliverySnapshot {
825    pub(super) delivered: u64,
826    pub(super) delivered_at: Instant,
827}
828
829impl OutstandingBlockRange {
830    /// Size estimates still reserved for unreceived heights.
831    pub(super) fn reserved_bytes(&self) -> u64 {
832        self.request
833            .expected_blocks
834            .iter()
835            .filter(|expected| !self.has_received(expected.height))
836            .fold(0u64, |acc, expected| {
837                acc.saturating_add(expected.estimated_bytes)
838            })
839    }
840
841    pub(super) fn estimated_bytes_for_height(&self, height: block::Height) -> Option<u64> {
842        self.request.estimated_bytes_for_height(height)
843    }
844
845    pub(super) fn has_received(&self, height: block::Height) -> bool {
846        self.request
847            .offset_for_height(height)
848            .is_some_and(|offset| self.received.contains_offset(offset))
849    }
850
851    pub(super) fn mark_received(&mut self, height: block::Height) {
852        if let Some(offset) = self.request.offset_for_height(height) {
853            self.received.insert_offset(offset);
854        }
855    }
856
857    pub(super) fn record_body_bytes(&mut self, bytes: u64) {
858        self.delivered_bytes = self.delivered_bytes.saturating_add(bytes);
859    }
860
861    /// Mark every requested height at or below `tip` as received and return the
862    /// sum of the per-height size estimates those newly-received heights still
863    /// held, so the caller releases exactly the reservation those heights held.
864    pub(super) fn mark_received_through(&mut self, tip: block::Height) -> u64 {
865        self.request
866            .expected_blocks
867            .iter()
868            .filter(|expected| {
869                expected.height <= tip
870                    && self
871                        .request
872                        .offset_for_height(expected.height)
873                        .is_some_and(|offset| self.received.insert_offset(offset))
874            })
875            .fold(0u64, |acc, expected| {
876                acc.saturating_add(expected.estimated_bytes)
877            })
878    }
879
880    pub(super) fn is_complete(&self) -> bool {
881        self.received.len() == self.request.expected_blocks.len()
882    }
883}
884
885/// Per-height request reservation: `Reserved(estimate) -> Released`.
886#[derive(Copy, Clone, Debug, Eq, PartialEq)]
887pub(super) enum BlockBudgetLedger {
888    Reserved(u64),
889    Released,
890}
891
892impl BlockBudgetLedger {
893    pub(super) fn reserved(estimate: u64) -> Self {
894        Self::Reserved(estimate)
895    }
896
897    pub(super) fn reserved_charge(self) -> u64 {
898        match self {
899            Self::Reserved(bytes) => bytes,
900            Self::Released => 0,
901        }
902    }
903
904    pub(super) fn is_reserved(self) -> bool {
905        matches!(self, Self::Reserved(_))
906    }
907
908    /// Release the reserved estimate exactly once, returning the released bytes.
909    pub(super) fn release_reserved(&mut self) -> u64 {
910        let released = self.reserved_charge();
911        *self = Self::Released;
912        released
913    }
914}
915
916/// Number of distinct request offsets the [`ReceivedBlockTracker`] bitset can hold —
917/// one per bit of its `u128`.
918const RECEIVED_TRACKER_OFFSET_CAPACITY: u32 = u128::BITS;
919
920// A request range carries one received-offset bit per requested height (offsets
921// `0..count`). If the advertised block-count cap ever exceeded the bitset width,
922// `bit_for_offset` would return `None` for the overflowing heights, so they could
923// never be marked received, `is_complete()` would be unreachable, and the range would
924// wedge (its reservation never released). Couple the two so a future cap bump that
925// outgrows the bitset fails to compile instead of silently wedging.
926const _: () = assert!(MAX_BS_BLOCKS_PER_REQUEST <= RECEIVED_TRACKER_OFFSET_CAPACITY);
927
928#[derive(Clone, Debug, Default)]
929pub(super) struct ReceivedBlockTracker {
930    bits: u128,
931    count: usize,
932}
933
934impl ReceivedBlockTracker {
935    pub(super) fn len(&self) -> usize {
936        self.count
937    }
938
939    fn contains_offset(&self, offset: u32) -> bool {
940        Self::bit_for_offset(offset).is_some_and(|bit| self.bits & bit != 0)
941    }
942
943    fn insert_offset(&mut self, offset: u32) -> bool {
944        let Some(bit) = Self::bit_for_offset(offset) else {
945            return false;
946        };
947        if self.bits & bit != 0 {
948            return false;
949        }
950        self.bits |= bit;
951        self.count = self.count.saturating_add(1);
952        true
953    }
954
955    fn bit_for_offset(offset: u32) -> Option<u128> {
956        1u128.checked_shl(offset)
957    }
958}
959
960#[derive(Clone, Debug)]
961pub(super) struct RateMeter {
962    pub(super) next_allowed: Instant,
963    pub(super) interval: Duration,
964}
965
966impl RateMeter {
967    pub(super) fn new(interval: Duration) -> Self {
968        Self {
969            next_allowed: Instant::now(),
970            interval,
971        }
972    }
973
974    pub(super) fn try_take(&mut self, now: Instant) -> bool {
975        if now < self.next_allowed {
976            return false;
977        }
978        self.next_allowed = now + self.interval;
979        true
980    }
981
982    pub(super) fn is_ready(&self, now: Instant) -> bool {
983        now >= self.next_allowed
984    }
985
986    pub(super) fn mark_taken(&mut self, now: Instant) {
987        self.next_allowed = now + self.interval;
988    }
989}
990
991/// Tracks block-body throughput (bytes and block counts) over the interval
992/// between samples, so the trace snapshot can report download/commit rates while
993/// driving toward the 1–2 Gbps target. `record` accumulates; `sample` snapshots
994/// the per-second rate since the last sample and resets the window. The last
995/// computed rate is cached so it can be read from the immutable trace path. Cost
996/// is two saturating adds per body and one division per sample tick.
997#[derive(Clone, Debug)]
998pub(super) struct ThroughputMeter {
999    bytes: u64,
1000    blocks: u64,
1001    window_start: Instant,
1002    last_bytes_per_sec: u64,
1003    last_blocks_per_sec: u64,
1004}
1005
1006impl ThroughputMeter {
1007    pub(super) fn new(now: Instant) -> Self {
1008        Self {
1009            bytes: 0,
1010            blocks: 0,
1011            window_start: now,
1012            last_bytes_per_sec: 0,
1013            last_blocks_per_sec: 0,
1014        }
1015    }
1016
1017    pub(super) fn record(&mut self, bytes: u64) {
1018        self.bytes = self.bytes.saturating_add(bytes);
1019        self.blocks = self.blocks.saturating_add(1);
1020    }
1021
1022    /// Recompute the cached per-second rates from the bytes/blocks accumulated
1023    /// since the last sample, then reset the window. A non-positive interval
1024    /// (clock not advanced between samples) leaves the cached rates untouched.
1025    pub(super) fn sample(&mut self, now: Instant) {
1026        let elapsed = now
1027            .saturating_duration_since(self.window_start)
1028            .as_secs_f64();
1029        if elapsed <= 0.0 {
1030            return;
1031        }
1032        // `as u64` truncates a finite, non-negative rate; both numerator and
1033        // denominator are non-negative so the cast cannot wrap or go negative.
1034        self.last_bytes_per_sec = (self.bytes as f64 / elapsed) as u64;
1035        self.last_blocks_per_sec = (self.blocks as f64 / elapsed) as u64;
1036        self.bytes = 0;
1037        self.blocks = 0;
1038        self.window_start = now;
1039    }
1040
1041    pub(super) fn bytes_per_sec(&self) -> u64 {
1042        self.last_bytes_per_sec
1043    }
1044
1045    pub(super) fn blocks_per_sec(&self) -> u64 {
1046        self.last_blocks_per_sec
1047    }
1048}
1049
1050// `ByteBudget` was promoted to `transport/guard.rs` so byte-rate protection is
1051// reusable across services. Re-exported here so existing block_sync call sites
1052// (`reorder.rs`, `scheduler.rs`, `tests.rs`, and the field on this module's
1053// state) keep resolving unchanged.
1054pub(crate) use crate::zakura::transport::ByteBudget;
1055
1056pub(super) fn next_height(height: block::Height) -> Option<block::Height> {
1057    height.0.checked_add(1).map(block::Height)
1058}
1059
1060pub(super) fn previous_height(height: block::Height) -> Option<block::Height> {
1061    height.0.checked_sub(1).map(block::Height)
1062}
1063
1064pub(super) fn height_after_count(start: block::Height, count: u32) -> Option<block::Height> {
1065    start.0.checked_add(count).map(block::Height)
1066}