Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org).

## [Unreleased]

### Changed

- The Zakura block-sync request budget (`max_inflight_block_bytes`) is now
purely an outstanding-request cap: a body's wire reservation is released
when it is received instead of being held until the block is durable, so the
resident look-ahead budget (`max_reorder_lookahead_bytes`) is the single
bound on retained memory. Operators who used `max_inflight_block_bytes` to
bound node memory should set `max_reorder_lookahead_bytes` instead. The
request budget is also no longer raised to one checkpoint range (~802 MB) at
config load: configured values load unchanged, except values too small to
cover a single block request (~32 MiB with default response limits), which
are clamped to just above that floor with a warning rather than failing to
start. The `budget_reserved` trace field and metric keep their names but now
report outstanding request bytes only (previously they included retained
body bytes).

### Removed

- Removed two Zakura block-sync config fields that never needed operator
Expand Down
16 changes: 9 additions & 7 deletions zebra-network/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1050,15 +1050,17 @@ impl<'de> Deserialize<'de> for Config {
non_zero_config_field.filter(|config_value| config_value > &0).unwrap_or(default_config_value)
});

// Clamp the in-flight byte budget up to the checkpoint-range floor (with a
// warning) rather than rejecting too-small configs, so older configs keep
// starting while checkpoint sync stays deadlock-free.
// Clamp too-small budgets up (with a warning) rather than rejecting the
// config: the resident look-ahead budget up to one checkpoint range, so
// the resident-memory admission gate cannot deadlock checkpoint sync when
// verified_tip is pinned to the previous checkpoint; and the in-flight
// request budget up to just above one floor request, so stored configs
// written when that field also bounded retention keep starting.
let mut zakura = zakura;
zakura.block_sync.clamp_inflight_block_bytes_to_floor();
// Likewise clamp the resident look-ahead budget (and its block cap) up to one
// checkpoint range, so the resident-memory admission gate cannot deadlock checkpoint
// sync when verified_tip is pinned to the previous checkpoint.
zakura.block_sync.clamp_reorder_lookahead_to_floor();
zakura
.block_sync
.clamp_inflight_block_bytes_to_request_floor();
zakura.block_sync.validate().map_err(|error| {
de::Error::custom(format!("invalid zakura.block_sync config: {error}"))
})?;
Expand Down
39 changes: 25 additions & 14 deletions zebra-network/src/zakura/block_sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ while anything anchored to the verified tip is pinned until real progress commit
| Purpose | unblock the contiguous prefix so the committer keeps moving | fill the look-ahead buffer so the bursty committer never starves |
| Candidate selection | first pending in `[servable_low, min(servable_high, floor_high)]`, only if this peer is the preferred floor carrier | first pending in the peer's servable range, only when the floor arm produced nothing |
| cwnd slots | may borrow up to `floor_bypass_slots` (default 2) beyond a saturated cwnd — bypass slots fund the floor **only** | normal cwnd slots only |
| Byte funding | may **block**: `reserve_request_budget`'s floor path can shed an above-floor reorder body to fund the floor (`FundFloorReservation`) — reachable even at zero in-flight budget | non-blocking `try_reserve`; refused if the in-flight budget is spent |
| Byte funding | never refused: `reserve_request_budget`'s floor path overdrafts the in-flight budget by at most one request when `try_reserve` fails — reachable even at zero in-flight budget | non-blocking `try_reserve`; refused if the in-flight budget is spent |
| Request deadline | short fixed leash (`floor_rescue_timeout`, default 2 s); on expiry the height is rescued to a faster carrier, the peer is retry-avoided but **not** disconnected | `request_timeout` (default 8 s) + expected transfer time (`estimated_bytes / measured BtlBw`, rate floored at 256 KiB/s) — patient, since it never gates the floor |

**Floor carrier preference:** the floor rides the fastest servable peer. Before taking
Expand All @@ -64,6 +64,13 @@ for the commit-window exemption, the resident-memory gate, and request sizing
fill loop feeds its grant verbatim to the work queue and may not substitute its own
sizing.

The one deliberate exception is `admission::admit_received_body`, the retention-only
gate for a body that is already downloaded (the unmatched-fallthrough path). A received
body consumes no request budget, so it applies the same commit-window exemption and
resident gate but never consults `budget_available` — a wire budget saturated by
outstanding requests must not force an already-paid-for body to be dropped and
re-downloaded.

### The commit window

Heights in `(verified_tip, verified_tip + 401]` are the **commit window**
Expand Down Expand Up @@ -116,17 +123,20 @@ _eventual_ decoded cost, `wire_bytes × DESERIALIZED_MEM_FACTOR` (= 4):

Two gates, checked together in `lookahead_over_budget`:

- **Byte gate:** `estimated_resident ≥ effective_max_reorder_lookahead_bytes`, where
`effective = min(max_reorder_lookahead_bytes, max_inflight_block_bytes × 4)`.
- **Byte gate:** `estimated_resident ≥ effective_max_reorder_lookahead_bytes`
(= `max_reorder_lookahead_bytes`; the request budget no longer caps it, since
the request cap does not imply retention).
- **Block gate (defense in depth):** reorder + applying + reserved block counts
`≥ LOOKAHEAD_BLOCK_HARD_CAP` (a fixed 262,144 — it binds before the byte gate
only for tiny bodies averaging under ~6.1 KB wire, where per-entry bookkeeping
overhead dominates; never needed operator tuning, so it is a constant, not a
config knob).

This is separate from the **in-flight wire budget** (`max_inflight_block_bytes`,
default 6 GiB, tracked by `ByteBudget`): that bounds bytes concurrently on the wire;
the look-ahead gate bounds bytes _retained_ by the pipeline.
This is separate from the **in-flight request budget** (`max_inflight_block_bytes`,
default 6 GiB, tracked by `ByteBudget`): that bounds outstanding request
reservations — charged at issuance, released at receipt (or timeout/watchdog/
reset/floor GC) — while the look-ahead gate is the single authority over bytes
_retained_ by the pipeline.

### Config clamps

Expand Down Expand Up @@ -158,12 +168,13 @@ retained wire 807.7–807.9 MB → ×4 = 3.231 GB, +0.7% over budget.
look-ahead gates — a pinned checkpoint range can always assemble.
2. **Floor grants never size below one byte**, and `take_in_range_budgeted` always
takes its first item regardless of the byte cap — so the floor block is taken even
when the in-flight budget is exactly full, reaching the floor funding path…
3. **…which can shed to fund.** `reserve_request_budget`'s Floor path awaits
`FundFloorReservation`: an above-floor reorder body is shed (returned to pending)
to free bytes for the floor block. The floor is therefore never starved by
speculative work, and this path is deliberately independent of the in-flight byte
budget.
when the in-flight budget is exactly full, reaching the floor reservation path…
3. **…which overdrafts instead of waiting.** When `try_reserve` fails,
`reserve_request_budget`'s Floor path charges the reservation past the max — a
bounded overshoot of at most one request (the WorkQueue single-owner invariant
permits one floor reservation globally), repaid through the normal release
discipline. The floor is therefore never starved by speculative work, with no
cross-task funding round trip.
4. **In-window floor liveness never depends on budget size** — the clamps only stop
sub-range configs from thrashing the speculative lane.

Expand Down Expand Up @@ -191,8 +202,8 @@ borrowed a bypass slot.

| Knob | Default | Notes |
| --- | --- | --- |
| `max_reorder_lookahead_bytes` | ~6.4 GB | **resident-denominated** (compared against wire × 4); effective value capped at `max_inflight_block_bytes × 4`; clamped up to ~3.208 GB |
| `max_inflight_block_bytes` | 6 GiB | in-flight wire budget (separate from the resident gate) |
| `max_reorder_lookahead_bytes` | ~6.4 GB | **resident-denominated** (compared against wire × 4); clamped up to ~3.208 GB |
| `max_inflight_block_bytes` | 6 GiB | outstanding-request wire budget, released at receipt (separate from the resident gate) |
| `max_blocks_per_response` | 1 | count cap per request (effective = min of both sides' advertisements, hard max 128) |
| `floor_bypass_slots` | 2 | extra slots past a saturated cwnd, floor lane only |
| `request_timeout` / `floor_rescue_timeout` | 8 s / 2 s | above-floor base deadline / floor rescue leash |
Expand Down
59 changes: 44 additions & 15 deletions zebra-network/src/zakura/block_sync/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ pub(super) enum AdmissionOutcome {
/// rounds to zero.
LookaheadAtCap,
/// The look-ahead gate has headroom but zero bytes are fundable right now
/// (the in-flight byte budget is spent). Never returned for floor-priority
/// (the in-flight request budget is spent). Never returned for floor-priority
/// starts: their byte cap is floored at one so the floor block always
/// reaches the floor-reservation funding path.
/// reaches the bounded-overdraft reservation path.
InflightBudgetEmpty,
}

Expand Down Expand Up @@ -243,6 +243,39 @@ fn lookahead_over_budget(config: &ZakuraBlockSyncConfig, snapshot: &AdmissionSna
|| held_blocks(snapshot) >= LOOKAHEAD_BLOCK_HARD_CAP
}

/// Remaining resident look-ahead headroom, expressed back in wire bytes so one
/// admitted response cannot push resident memory past the budget. The next
/// admitted body will usually become decoded soon, so it is sized as if it
/// costs the decoded multiple.
fn remaining_lookahead_wire_bytes(
config: &ZakuraBlockSyncConfig,
snapshot: &AdmissionSnapshot,
) -> u64 {
config
.effective_max_reorder_lookahead_bytes()
.saturating_sub(estimated_resident_pipeline_bytes(snapshot))
/ DESERIALIZED_MEM_FACTOR
}

/// Retention-only admission for a body that is already downloaded.
///
/// A received body consumes no request budget (its wire reservation is
/// released at receipt), so unlike [`admit`] this never consults
/// `budget_available`: only the commit-window exemption and the resident
/// look-ahead gate — the two rules that bound retention — apply.
pub(super) fn admit_received_body(
config: &ZakuraBlockSyncConfig,
snapshot: &AdmissionSnapshot,
height: block::Height,
serialized_bytes: u64,
) -> bool {
if height <= commit_window_high(snapshot) {
return true;
}
!lookahead_over_budget(config, snapshot)
&& serialized_bytes <= remaining_lookahead_wire_bytes(config, snapshot)
}

/// Plans one contiguous take starting at `start_height`: the single authority for
/// the commit-window exemption, the resident-memory gate, and request sizing.
///
Expand All @@ -265,9 +298,10 @@ fn lookahead_over_budget(config: &ZakuraBlockSyncConfig, snapshot: &AdmissionSna
/// ≈ 3.2 GB; a single in-window response can also exceed the byte gate by up to the
/// response cap × the factor) regardless of how far headers/downloads run ahead.
///
/// Floor-priority requests are never blocked just because the normal byte budget is exactly full.
/// If the lowest missing block is needed to let commit move forward,
/// it can still be requested even when speculative/look-ahead work has filled the byte budget.
/// Floor-priority requests are never blocked just because the request budget is exactly
/// full. If the lowest missing block is needed to let commit move forward, it can still
/// be requested even when speculative work has spent the in-flight budget (the routine's
/// bounded floor overdraft funds it).
pub(super) fn admit(
config: &ZakuraBlockSyncConfig,
snapshot: AdmissionSnapshot,
Expand All @@ -289,13 +323,7 @@ pub(super) fn admit(
if lookahead_over_budget(config, &snapshot) {
return AdmissionOutcome::LookaheadAtCap;
}
// Remaining memory headroom, expressed back in wire bytes for the response cap so a
// single response can't push resident memory past the budget. The next admitted body
// will usually become decoded soon, so it is sized as if it costs the decoded multiple.
let remaining_wire_bytes = config
.effective_max_reorder_lookahead_bytes()
.saturating_sub(estimated_resident_pipeline_bytes(&snapshot))
/ DESERIALIZED_MEM_FACTOR;
let remaining_wire_bytes = remaining_lookahead_wire_bytes(config, &snapshot);
if remaining_wire_bytes == 0 {
return AdmissionOutcome::LookaheadAtCap;
}
Expand Down Expand Up @@ -445,9 +473,10 @@ mod tests {
),
"the final block of a checkpoint range must be admissible under a 1 GiB in-flight budget",
);
// The first height above the window is memory-gated but must still have headroom
// under this budget: (802 MB - 2 MB) * 4 resident < 4 GiB effective. This keeps the
// assertion non-vacuous now that the whole range is window-exempt.
// The first height above the window is memory-gated but must still have headroom:
// the snapshot holds one block less than a full range, so its resident estimate is
// (802 MB - 2 MB) * 4 ~= 3.2 GB, below the default resident budget (~6.4 GB).
// This keeps the assertion non-vacuous now that the whole range is window-exempt.
assert!(
matches!(
admit(
Expand Down
Loading