Skip to main content

hopr_transport/protocol/pipeline/
config.rs

1//! Configuration structures for the HOPR packet processing pipeline.
2
3use validator::{Validate, ValidationError, ValidationErrors};
4
5fn default_ack_buffer_interval() -> std::time::Duration {
6    std::time::Duration::from_millis(200)
7}
8
9fn default_ack_grouping_capacity() -> usize {
10    5
11}
12
13fn default_ticket_ack_buffer_size() -> usize {
14    50_000
15}
16
17fn default_ack_out_buffer_size() -> usize {
18    50_000
19}
20
21/// Configuration for the acknowledgement processing pipeline.
22#[derive(Debug, Copy, Clone, smart_default::SmartDefault, Eq, PartialEq)]
23#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
24pub struct AcknowledgementPipelineConfig {
25    /// Interval for which to wait to buffer acknowledgements before sending them out.
26    ///
27    /// Default is 200 ms.
28    #[default(default_ack_buffer_interval())]
29    #[cfg_attr(
30        feature = "serde",
31        serde(default = "default_ack_buffer_interval", with = "humantime_serde")
32    )]
33    pub ack_buffer_interval: std::time::Duration,
34    /// Initial capacity when grouping outgoing acknowledgements.
35    ///
36    /// If set too low, it causes additional reallocations in the outgoing acknowledgement processing pipeline.
37    /// The value should grow if `ack_buffer_interval` grows.
38    ///
39    /// Default is 5.
40    #[default(default_ack_grouping_capacity())]
41    #[cfg_attr(feature = "serde", serde(default = "default_ack_grouping_capacity"))]
42    pub ack_grouping_capacity: usize,
43    /// Capacity of the `incoming_ack` MPSC channel carrying received acknowledgements
44    /// to the ticket-ack processing pipeline.
45    ///
46    /// The previous hardcoded value of 1_000_000 pre-allocated ~MBs of ring buffer per node even
47    /// though real-world throughput rarely saturates more than a few thousand entries. Let the
48    /// 50 ms sink timeouts (`QUEUE_SEND_TIMEOUT`) propagate backpressure instead.
49    ///
50    /// The default is 50 000.
51    #[default(default_ticket_ack_buffer_size())]
52    #[cfg_attr(feature = "serde", serde(default = "default_ticket_ack_buffer_size"))]
53    pub ticket_ack_buffer_size: usize,
54    /// Capacity of the `outgoing_ack` MPSC channel carrying acknowledgements to be sent back
55    /// to the previous hop.
56    ///
57    /// The default is 50 000. See [`ticket_ack_buffer_size`](Self::ticket_ack_buffer_size) for the
58    /// rationale on why this is smaller than the original hardcoded 1_000_000.
59    #[default(default_ack_out_buffer_size())]
60    #[cfg_attr(feature = "serde", serde(default = "default_ack_out_buffer_size"))]
61    pub ack_out_buffer_size: usize,
62    /// Maximum concurrency when processing incoming (received) acknowledgements.
63    ///
64    /// `None` or `Some(0)` both fall back to a default of 10.
65    pub ack_input_concurrency: Option<usize>,
66    /// Maximum concurrency when processing outgoing (sent-back) acknowledgements.
67    ///
68    /// `None` or `Some(0)` both fall back to a default of 10.
69    pub ack_output_concurrency: Option<usize>,
70}
71
72// Requires manual implementation due to https://github.com/Keats/validator/issues/285
73impl Validate for AcknowledgementPipelineConfig {
74    fn validate(&self) -> Result<(), ValidationErrors> {
75        let mut errors = ValidationErrors::new();
76        if self.ack_grouping_capacity == 0 {
77            errors.add("ack_grouping_capacity", ValidationError::new("must be greater than 0"));
78        }
79        if self.ack_buffer_interval < std::time::Duration::from_millis(10) {
80            errors.add("ack_buffer_interval", ValidationError::new("must be at least 10 ms"));
81        }
82        if self.ticket_ack_buffer_size == 0 {
83            errors.add("ticket_ack_buffer_size", ValidationError::new("must be greater than 0"));
84        }
85        if self.ack_out_buffer_size == 0 {
86            errors.add("ack_out_buffer_size", ValidationError::new("must be greater than 0"));
87        }
88        if errors.is_empty() { Ok(()) } else { Err(errors) }
89    }
90}
91
92fn default_arbitration_enabled() -> bool {
93    true
94}
95fn default_arbitration_occupancy_pct() -> u32 {
96    75
97}
98fn default_arbitration_encode_reserve_pct() -> u32 {
99    50
100}
101
102/// Arbitration of the shared Rayon pool, protecting the encode path (SPHINX wrap + SURB generation)
103/// from decode floods (SPHINX peel — relay forwarding + exit termination).
104///
105/// Asymmetric and occupancy-gated: only decode is ever throttled, and only when the pool is
106/// saturated *and* encode work is present, so pure forwarding and unsaturated nodes are untouched.
107/// Enforced inside `spawn_decode_blocking` (see `hopr_utils::parallelize::cpu::configure_arbitration`).
108#[derive(Clone, Copy, Debug, PartialEq, Eq, smart_default::SmartDefault, Validate)]
109#[cfg_attr(
110    feature = "serde",
111    derive(serde::Serialize, serde::Deserialize),
112    serde(deny_unknown_fields)
113)]
114pub struct PoolArbitrationConfig {
115    /// When `false`, decode is never throttled (the pool is shared first-come-first-served).
116    #[default(default_arbitration_enabled())]
117    #[cfg_attr(feature = "serde", serde(default = "default_arbitration_enabled"))]
118    pub enabled: bool,
119    /// Pool occupancy (percent of threads actually running) at or above which decode admission may
120    /// engage. Below it, decode is never throttled.
121    #[default(default_arbitration_occupancy_pct())]
122    #[validate(range(min = 1, max = 100))]
123    #[cfg_attr(feature = "serde", serde(default = "default_arbitration_occupancy_pct"))]
124    pub occupancy_pct: u32,
125    /// Share of the pool (percent) that decode yields to encode when both contend under saturation.
126    #[default(default_arbitration_encode_reserve_pct())]
127    #[validate(range(min = 1, max = 100))]
128    #[cfg_attr(feature = "serde", serde(default = "default_arbitration_encode_reserve_pct"))]
129    pub encode_reserve_pct: u32,
130}
131
132impl PoolArbitrationConfig {
133    /// Maps this flat (serde-friendly) config onto the pool arbiter's
134    /// [`ArbitrationConfig`](hopr_utils::parallelize::cpu::ArbitrationConfig) enum, where the disabled
135    /// state carries no tuning percentages.
136    pub fn to_arbitration(&self) -> hopr_utils::parallelize::cpu::ArbitrationConfig {
137        use hopr_utils::parallelize::cpu::ArbitrationConfig;
138        if self.enabled {
139            ArbitrationConfig::Enabled {
140                occupancy_pct: self.occupancy_pct,
141                encode_reserve_pct: self.encode_reserve_pct,
142            }
143        } else {
144            ArbitrationConfig::Disabled
145        }
146    }
147}
148
149/// Overall configuration of the input/output packet processing pipeline.
150#[derive(Clone, Copy, Debug, Default, PartialEq, Validate)]
151#[cfg_attr(
152    feature = "serde",
153    derive(serde::Serialize, serde::Deserialize),
154    serde(deny_unknown_fields)
155)]
156pub struct PacketPipelineConfig {
157    /// Maximum concurrency when processing outgoing packets.
158    ///
159    /// `None` or `Some(0)` both fall back to the default (available parallelism * 8).
160    pub output_concurrency: Option<usize>,
161    /// Maximum concurrency when processing incoming packets (SPHINX decode).
162    ///
163    /// `None` or `Some(0)` both fall back to the default (available parallelism * 8), the same as
164    /// `output_concurrency`. Encode is no longer protected by throttling this queue depth below
165    /// output's (which regressed relay forwarding — #8246); protection now lives in the shared-pool
166    /// arbiter (see [`PoolArbitrationConfig`]), which throttles decode *admission* only under a
167    /// genuine flood, leaving pure forwarding at full concurrency.
168    pub input_concurrency: Option<usize>,
169    /// How long routing resolution keeps waiting for a return path's SURBs before giving up on the
170    /// packet.
171    ///
172    /// `None` falls back to the default of 6 s. `Some(0)` disables the wait entirely, dropping a
173    /// return packet the first time its SURBs are missing.
174    ///
175    /// The right value trades two failures against each other. Too short loses data on a session
176    /// whose SURB pool is only momentarily empty, which is why the wait exists at all. Too long
177    /// stalls **every** packet the node originates, not just this one: resolution preserves
178    /// submission order, so an unresolvable packet withholds everything behind it, and a
179    /// counterparty that has gone away never sends another SURB. An unbounded wait here took a
180    /// production exit's entire egress down for 1h44m while it still forwarded and acknowledged
181    /// normally.
182    ///
183    /// **Set this if you know your session's frame timeout.** The default errs long, because a
184    /// library cannot know it; a packet held past that timeout is discarded by the receiver anyway,
185    /// so the wait is pure stall from then on. hoprd, whose sessions time frames out at 3 s,
186    /// configures 1 s.
187    #[cfg_attr(feature = "serde", serde(default, with = "humantime_serde"))]
188    pub surb_resolution_wait: Option<std::time::Duration>,
189    /// Configuration of the packet acknowledgement processing
190    #[validate(nested)]
191    pub ack_config: AcknowledgementPipelineConfig,
192    /// Arbitration of the shared Rayon pool between encode and decode.
193    #[validate(nested)]
194    #[cfg_attr(feature = "serde", serde(default))]
195    pub arbitration: PoolArbitrationConfig,
196}
197
198#[cfg(all(test, feature = "serde"))]
199mod tests {
200    use super::*;
201
202    /// Everything this struct requires in a document, minus the wait under test.
203    ///
204    /// Only `surb_resolution_wait` carries `serde(default)`, so the surrounding fields have to be
205    /// written out; `deny_unknown_fields` means the document must otherwise be exact.
206    const REQUIRED_FIELDS: &str = "output_concurrency: null\ninput_concurrency: null\nack_config:\n  \
207                                   ack_input_concurrency: null\n  ack_output_concurrency: null\n";
208
209    fn parse(wait: Option<&str>) -> PacketPipelineConfig {
210        let doc = match wait {
211            Some(value) => format!("{REQUIRED_FIELDS}surb_resolution_wait: {value}\n"),
212            None => REQUIRED_FIELDS.to_string(),
213        };
214        serde_saphyr::from_str(&doc).unwrap_or_else(|e| panic!("config must parse:\n{doc}\n{e}"))
215    }
216
217    /// The wait has to survive a config file, in the human-readable form the rest of this config
218    /// uses. `Option<Duration>` through `humantime_serde` is easy to get wrong in a way that only
219    /// shows up when someone's YAML is silently ignored.
220    #[test]
221    fn the_surb_resolution_wait_should_round_trip_through_yaml() {
222        assert_eq!(
223            Some(std::time::Duration::from_secs(2)),
224            parse(Some("2s")).surb_resolution_wait,
225            "a duration string must reach the field"
226        );
227        assert_eq!(
228            None,
229            parse(None).surb_resolution_wait,
230            "an omitted wait must stay unset so the default applies"
231        );
232        assert_eq!(
233            Some(std::time::Duration::ZERO),
234            parse(Some("0s")).surb_resolution_wait,
235            "zero must reach the code as zero, not as unset"
236        );
237    }
238
239    /// The `SmartDefault` derive and the serde field defaults share the `default_arbitration_*` fns,
240    /// so they can't diverge — pin the values the arbiter ships with, and that a default config
241    /// validates (the percentages sit inside the `1..=100` range).
242    #[test]
243    fn pool_arbitration_config_defaults_are_enabled_75_50_and_valid() {
244        let cfg = PoolArbitrationConfig::default();
245        assert!(cfg.enabled);
246        assert_eq!(cfg.occupancy_pct, 75);
247        assert_eq!(cfg.encode_reserve_pct, 50);
248        assert!(
249            cfg.validate().is_ok(),
250            "the default arbitration config must pass validation"
251        );
252    }
253
254    /// `to_arbitration` maps the flat config onto the arbiter enum: enabled → `Enabled` with the
255    /// percentages, disabled → `Disabled` (no percentages).
256    #[test]
257    fn pool_arbitration_config_maps_onto_the_arbiter_enum() {
258        use hopr_utils::parallelize::cpu::ArbitrationConfig;
259        let enabled = PoolArbitrationConfig {
260            enabled: true,
261            occupancy_pct: 80,
262            encode_reserve_pct: 40,
263        };
264        assert_eq!(
265            enabled.to_arbitration(),
266            ArbitrationConfig::Enabled {
267                occupancy_pct: 80,
268                encode_reserve_pct: 40
269            }
270        );
271        let disabled = PoolArbitrationConfig {
272            enabled: false,
273            ..PoolArbitrationConfig::default()
274        };
275        assert_eq!(disabled.to_arbitration(), ArbitrationConfig::Disabled);
276    }
277}