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}