Skip to main content

bb_runtime/framework/
backpressure_notice.rs

1//! `BackoffNoticePayload` - typed wire payload for the backpressure
2//! protocol per
3//! `docs/internal/superpowers/specs/2026-06-23-backpressure-runtime.md`
4//! §2.
5//!
6//! The framework synthesizes one of these whenever
7//! `BackpressureTracker::observe_overload` returns
8//! [`super::BackpressureDecision::EmitNotice`]. The payload is
9//! serialized with `bincode` (matches the universal `SlotValue` wire
10//! encoding at `bb-ir/src/slot_value.rs:194-196`), stamped with a
11//! stable `type_hash` discriminator, packed into a single
12//! [`crate::envelope::SlotFill`] inside a [`crate::envelope::WireEnvelope`]
13//! addressed to the originating sender, and pushed onto the
14//! receiver's `OutboundQueue`.
15//!
16//! Receivers detect the notice by matching the per-fill `type_hash`
17//! against [`backoff_notice_type_hash`] in their inbound envelope
18//! routing - the framework intercepts BackoffNotice envelopes before
19//! data-plane / control-plane dispatch so user Components never see
20//! them. Sender-side handling updates the sender's
21//! [`crate::framework::BackoffTable`] so the existing
22//! `BackoffGateTx` consultation gates the next outbound send to
23//! that peer.
24
25use serde::{Deserialize, Serialize};
26
27use crate::envelope::{CorrelationKind, SlotFill, WireCorrelation, WireEnvelope};
28use crate::framework::address_book::Address;
29use crate::framework::BackoffCause;
30use crate::ids::PeerId;
31use crate::slot_value::type_hash_of;
32
33/// Domain string the framework uses to namespace the backpressure
34/// protocol per `bb-runtime/src/bus.rs:155` reserved framework
35/// prefix. Surfaced for cross-referencing in docs + tests; the
36/// actual routing key is the per-fill `type_hash` from
37/// [`backoff_notice_type_hash`].
38pub const BACKPRESSURE_DOMAIN: &str = "ai.bytesandbrains.backpressure";
39
40/// Wire-encoded BackoffNotice payload.
41///
42/// One of these rides as the sole `SlotFill.payload` of a
43/// BackoffNotice envelope. Field semantics:
44///
45/// - `min_backoff_ns` - minimum back-off duration the receiver is
46///   asking the sender to observe before its next envelope. The
47///   sender's `BackoffTable` translates this into a `next_retry_ns`
48///   so the existing `BackoffGateTx` already gates outbound sends.
49/// - `cause` - why the receiver is requesting back-off.
50/// - `suggested_next_send_ns` - optional wall-clock hint (engine-ns
51///   since epoch) the receiver believes it will be ready by. `None`
52///   when the receiver has no estimate.
53///
54/// The payload uses `bincode` serialization (the universal
55/// `SlotValue::to_wire_bytes` impl at
56/// `bb-ir/src/slot_value.rs:194-196` already routes through bincode),
57/// and the discriminator [`backoff_notice_type_hash`] is identical
58/// on both sides because it derives from
59/// `std::any::type_name::<BackoffNoticePayload>()` via FNV-1a.
60#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
61pub struct BackoffNoticePayload {
62    /// Minimum back-off duration in nanoseconds.
63    pub min_backoff_ns: u64,
64    /// Why the receiver is requesting back-off.
65    pub cause: BackoffCauseWire,
66    /// Optional wall-clock hint (engine-ns since epoch) the receiver
67    /// expects to be ready by. `0` encodes `None`.
68    pub suggested_next_send_ns: u64,
69}
70
71/// Wire-stable encoding of [`BackoffCause`]. Serialized as a u8 so
72/// the on-wire representation never bit-shifts when the framework
73/// enum evolves. Always derived from the framework enum at the
74/// send site + mapped back at the receive site.
75#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
76#[repr(u8)]
77pub enum BackoffCauseWire {
78    /// `IngressQueue` depth crossed the high-water mark.
79    QueueFull = 0,
80    /// `PhiAccrualState` marked the sender as `Suspect`.
81    PhiAccrual = 1,
82    /// A Component returned a typed reject (e.g. role rate-limit).
83    ExplicitDrop = 2,
84}
85
86impl From<BackoffCause> for BackoffCauseWire {
87    fn from(cause: BackoffCause) -> Self {
88        match cause {
89            BackoffCause::QueueFull => Self::QueueFull,
90            BackoffCause::PhiAccrual => Self::PhiAccrual,
91            BackoffCause::ExplicitDrop => Self::ExplicitDrop,
92        }
93    }
94}
95
96impl From<BackoffCauseWire> for BackoffCause {
97    fn from(cause: BackoffCauseWire) -> Self {
98        match cause {
99            BackoffCauseWire::QueueFull => Self::QueueFull,
100            BackoffCauseWire::PhiAccrual => Self::PhiAccrual,
101            BackoffCauseWire::ExplicitDrop => Self::ExplicitDrop,
102        }
103    }
104}
105
106impl BackoffNoticePayload {
107    /// Construct a payload, encoding `None` as `0` per the field
108    /// docstring.
109    pub fn new(
110        min_backoff_ns: u64,
111        cause: BackoffCause,
112        suggested_next_send_ns: Option<u64>,
113    ) -> Self {
114        Self {
115            min_backoff_ns,
116            cause: cause.into(),
117            suggested_next_send_ns: suggested_next_send_ns.unwrap_or(0),
118        }
119    }
120
121    /// Recover the optional wall-clock hint.
122    pub fn suggested_next_send(&self) -> Option<u64> {
123        if self.suggested_next_send_ns == 0 {
124            None
125        } else {
126            Some(self.suggested_next_send_ns)
127        }
128    }
129
130    /// Recover the framework-side [`BackoffCause`].
131    pub fn cause(&self) -> BackoffCause {
132        self.cause.into()
133    }
134
135    /// Serialize via `bincode`. Matches the universal
136    /// `SlotValue::to_wire_bytes` impl at
137    /// `bb-ir/src/slot_value.rs:194-196`.
138    pub fn encode(&self) -> Vec<u8> {
139        bincode::serialize(self).expect("BackoffNoticePayload bincode serialize is infallible")
140    }
141
142    /// Deserialize via `bincode`. Returns `None` when the payload
143    /// bytes don't round-trip (corrupt envelope / version skew).
144    pub fn decode(bytes: &[u8]) -> Option<Self> {
145        bincode::deserialize::<Self>(bytes).ok()
146    }
147}
148
149/// Stable u64 discriminator for `BackoffNoticePayload`. Matches the
150/// canonical `SlotFill.type_hash` the framework stamps at send time
151/// and consults at receive time per
152/// `bb-ir/src/slot_value.rs:203-210`.
153pub fn backoff_notice_type_hash() -> u64 {
154    type_hash_of::<BackoffNoticePayload>()
155}
156
157/// Build a `BackoffNotice` wire envelope addressed to `sender`.
158///
159/// The envelope's `dest_peer_addresses` carries one `/p2p/<sender>`
160/// entry; its sole `SlotFill` uses a reserved framework dest-suffix
161/// `/p2p/<self_peer>` so the receiver's `route_envelope` can
162/// recognize a notice by type_hash before any data-plane /
163/// control-plane dispatch. The fill's `type_hash` is set to
164/// [`backoff_notice_type_hash`] so the receiver matches by
165/// discriminator instead of payload-content inspection.
166pub fn build_backoff_notice_envelope(
167    self_peer: PeerId,
168    sender: PeerId,
169    payload: BackoffNoticePayload,
170) -> WireEnvelope {
171    let dest_addr = Address::empty().p2p(sender).to_bytes();
172    // The fill's dest_suffix uses a reserved `/p2p/<self_peer>`
173    // suffix that intentionally does NOT match the data-plane /
174    // control-plane suffix shapes. The receiver intercepts on
175    // `type_hash` so the suffix is informational only - it carries
176    // the originating self-peer for diagnostics.
177    let dest_suffix = Address::empty().p2p(self_peer).to_bytes();
178    let bytes = payload.encode();
179    WireEnvelope {
180        dest_peer_addresses: vec![dest_addr],
181        fills: vec![SlotFill {
182            dest_suffix,
183            payload: bytes,
184            trigger_only: false,
185            type_hash: backoff_notice_type_hash(),
186        }],
187        correlation: Some(WireCorrelation {
188            kind: CorrelationKind::None as i32,
189            wire_req_id: 0,
190        }),
191        remaining_deadline_ns: 0,
192        edge_rtt_reports: Vec::new(),
193        ..Default::default()
194    }
195}
196