Skip to main content

relay_knowledge/net/
qos.rs

1//! QoS admission policy for inbound and outbound network work.
2//!
3//! The policy keeps HTTP clients, HTTP servers, and background network tasks
4//! inside explicit resource budgets before they can allocate unbounded work.
5
6use std::{
7    error::Error,
8    fmt,
9    sync::{Arc, Mutex},
10};
11
12use crate::env::NetworkEnvOverrides;
13
14pub const DEFAULT_MAX_CONNECTIONS: usize = 1024;
15pub const DEFAULT_MAX_IN_FLIGHT_REQUESTS: usize = 256;
16pub const DEFAULT_MAX_QUEUE_DEPTH: usize = 512;
17
18/// Bounded resource policy for network admission decisions.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct QosPolicy {
21    pub max_connections: usize,
22    pub max_in_flight_requests: usize,
23    pub max_queue_depth: usize,
24}
25
26impl QosPolicy {
27    /// Creates a policy and rejects zero-sized budgets.
28    pub fn new(
29        max_connections: usize,
30        max_in_flight_requests: usize,
31        max_queue_depth: usize,
32    ) -> Result<Self, QosPolicyError> {
33        ensure_positive("max_connections", max_connections)?;
34        ensure_positive("max_in_flight_requests", max_in_flight_requests)?;
35        ensure_positive("max_queue_depth", max_queue_depth)?;
36
37        Ok(Self {
38            max_connections,
39            max_in_flight_requests,
40            max_queue_depth,
41        })
42    }
43
44    /// Applies environment overrides to the default interactive QoS budgets.
45    pub fn from_overrides(overrides: &NetworkEnvOverrides) -> Result<Self, QosPolicyError> {
46        Self::new(
47            overrides
48                .qos_max_connections
49                .unwrap_or(DEFAULT_MAX_CONNECTIONS),
50            overrides
51                .qos_max_in_flight_requests
52                .unwrap_or(DEFAULT_MAX_IN_FLIGHT_REQUESTS),
53            overrides
54                .qos_max_queue_depth
55                .unwrap_or(DEFAULT_MAX_QUEUE_DEPTH),
56        )
57    }
58
59    /// Evaluates current resource usage before admitting network work.
60    pub fn evaluate(&self, snapshot: QosSnapshot) -> AdmissionDecision {
61        if snapshot.connections >= self.max_connections {
62            return AdmissionDecision::Reject(RejectReason::ConnectionBudgetExceeded);
63        }
64
65        if snapshot.in_flight_requests >= self.max_in_flight_requests {
66            return AdmissionDecision::Reject(RejectReason::RequestBudgetExceeded);
67        }
68
69        if snapshot.queued_requests >= self.max_queue_depth {
70            return AdmissionDecision::Reject(RejectReason::QueueBudgetExceeded);
71        }
72
73        AdmissionDecision::Admit
74    }
75}
76
77/// Point-in-time network resource usage for QoS decisions.
78#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
79pub struct QosSnapshot {
80    pub connections: usize,
81    pub in_flight_requests: usize,
82    pub queued_requests: usize,
83}
84
85/// Current QoS usage plus cumulative admission and overload counters.
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
87pub struct QosDiagnosticsSnapshot {
88    pub usage: QosSnapshot,
89    pub admitted_total: u64,
90    pub queued_total: u64,
91    pub rejected_total: u64,
92    pub timed_out_total: u64,
93    pub cancelled_total: u64,
94    pub dropped_total: u64,
95}
96
97/// Result of a QoS admission check.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum AdmissionDecision {
100    Admit,
101    Reject(RejectReason),
102}
103
104/// Reason attached to a rejected network operation.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum RejectReason {
107    ConnectionBudgetExceeded,
108    RequestBudgetExceeded,
109    QueueBudgetExceeded,
110}
111
112impl RejectReason {
113    /// Stable low-cardinality reason label for diagnostics and metrics.
114    pub const fn as_str(self) -> &'static str {
115        match self {
116            Self::ConnectionBudgetExceeded => "connection_budget_exceeded",
117            Self::RequestBudgetExceeded => "request_budget_exceeded",
118            Self::QueueBudgetExceeded => "queue_budget_exceeded",
119        }
120    }
121}
122
123/// Runtime QoS counters used by inbound protocol adapters.
124#[derive(Debug, Clone, Default)]
125pub struct QosRuntime {
126    state: Arc<Mutex<QosState>>,
127}
128
129#[derive(Debug, Clone, Copy, Default)]
130struct QosState {
131    usage: QosSnapshot,
132    counters: QosCounters,
133}
134
135#[derive(Debug, Clone, Copy, Default)]
136struct QosCounters {
137    admitted_total: u64,
138    queued_total: u64,
139    rejected_total: u64,
140    timed_out_total: u64,
141    cancelled_total: u64,
142    dropped_total: u64,
143}
144
145impl QosRuntime {
146    /// Reserves space in the bounded request queue before active admission.
147    pub fn reserve_queue(&self, policy: &QosPolicy) -> Result<QosPermit, RejectReason> {
148        let mut state = self
149            .state
150            .lock()
151            .unwrap_or_else(|poisoned| poisoned.into_inner());
152        if state.usage.queued_requests >= policy.max_queue_depth {
153            state.record_rejection();
154            return Err(RejectReason::QueueBudgetExceeded);
155        }
156
157        state.usage.queued_requests += 1;
158        state.counters.queued_total = state.counters.queued_total.saturating_add(1);
159        Ok(QosPermit {
160            runtime: self.clone(),
161            kind: QosPermitKind::Queue,
162            released: false,
163        })
164    }
165
166    /// Applies queue and active request budgets atomically for immediate admission.
167    pub fn admit_queued_request(&self, policy: &QosPolicy) -> Result<QosPermit, RejectReason> {
168        let mut state = self
169            .state
170            .lock()
171            .unwrap_or_else(|poisoned| poisoned.into_inner());
172        if state.usage.queued_requests >= policy.max_queue_depth {
173            state.record_rejection();
174            return Err(RejectReason::QueueBudgetExceeded);
175        }
176
177        if state.usage.in_flight_requests >= policy.max_in_flight_requests {
178            state.record_rejection();
179            return Err(RejectReason::RequestBudgetExceeded);
180        }
181
182        state.usage.in_flight_requests += 1;
183        state.counters.queued_total = state.counters.queued_total.saturating_add(1);
184        state.counters.admitted_total = state.counters.admitted_total.saturating_add(1);
185        Ok(QosPermit {
186            runtime: self.clone(),
187            kind: QosPermitKind::Request,
188            released: false,
189        })
190    }
191
192    /// Attempts to admit one inbound request and returns a release-on-drop permit.
193    pub fn admit_request(&self, policy: &QosPolicy) -> Result<QosPermit, RejectReason> {
194        let mut state = self
195            .state
196            .lock()
197            .unwrap_or_else(|poisoned| poisoned.into_inner());
198        if state.usage.in_flight_requests >= policy.max_in_flight_requests {
199            state.record_rejection();
200            return Err(RejectReason::RequestBudgetExceeded);
201        }
202
203        state.usage.in_flight_requests += 1;
204        state.counters.admitted_total = state.counters.admitted_total.saturating_add(1);
205        Ok(QosPermit {
206            runtime: self.clone(),
207            kind: QosPermitKind::Request,
208            released: false,
209        })
210    }
211
212    /// Attempts to admit one open network connection.
213    pub fn admit_connection(&self, policy: &QosPolicy) -> Result<QosPermit, RejectReason> {
214        let mut state = self
215            .state
216            .lock()
217            .unwrap_or_else(|poisoned| poisoned.into_inner());
218        if state.usage.connections >= policy.max_connections {
219            state.record_rejection();
220            return Err(RejectReason::ConnectionBudgetExceeded);
221        }
222
223        state.usage.connections += 1;
224        state.counters.admitted_total = state.counters.admitted_total.saturating_add(1);
225        Ok(QosPermit {
226            runtime: self.clone(),
227            kind: QosPermitKind::Connection,
228            released: false,
229        })
230    }
231
232    /// Returns the current request budget snapshot for diagnostics and tests.
233    pub fn snapshot(&self) -> QosSnapshot {
234        self.state
235            .lock()
236            .unwrap_or_else(|poisoned| poisoned.into_inner())
237            .usage
238    }
239
240    /// Returns current usage and cumulative overload counters.
241    pub fn diagnostics_snapshot(&self) -> QosDiagnosticsSnapshot {
242        let state = self
243            .state
244            .lock()
245            .unwrap_or_else(|poisoned| poisoned.into_inner());
246        QosDiagnosticsSnapshot {
247            usage: state.usage,
248            admitted_total: state.counters.admitted_total,
249            queued_total: state.counters.queued_total,
250            rejected_total: state.counters.rejected_total,
251            timed_out_total: state.counters.timed_out_total,
252            cancelled_total: state.counters.cancelled_total,
253            dropped_total: state.counters.dropped_total,
254        }
255    }
256
257    /// Records an operation that exceeded its runtime timeout after admission.
258    pub fn record_timed_out(&self) {
259        let mut state = self
260            .state
261            .lock()
262            .unwrap_or_else(|poisoned| poisoned.into_inner());
263        state.counters.timed_out_total = state.counters.timed_out_total.saturating_add(1);
264    }
265
266    /// Records an admitted operation cancelled by the caller or peer.
267    pub fn record_cancelled(&self) {
268        let mut state = self
269            .state
270            .lock()
271            .unwrap_or_else(|poisoned| poisoned.into_inner());
272        state.counters.cancelled_total = state.counters.cancelled_total.saturating_add(1);
273    }
274
275    /// Records work dropped before application handling could start.
276    pub fn record_dropped(&self) {
277        let mut state = self
278            .state
279            .lock()
280            .unwrap_or_else(|poisoned| poisoned.into_inner());
281        state.counters.dropped_total = state.counters.dropped_total.saturating_add(1);
282    }
283
284    fn release(&self, kind: QosPermitKind) {
285        if matches!(kind, QosPermitKind::Noop) {
286            return;
287        }
288        let mut state = self
289            .state
290            .lock()
291            .unwrap_or_else(|poisoned| poisoned.into_inner());
292        match kind {
293            QosPermitKind::Connection => {
294                state.usage.connections = state.usage.connections.saturating_sub(1);
295            }
296            QosPermitKind::Request => {
297                state.usage.in_flight_requests = state.usage.in_flight_requests.saturating_sub(1);
298            }
299            QosPermitKind::Queue => {
300                state.usage.queued_requests = state.usage.queued_requests.saturating_sub(1);
301            }
302            QosPermitKind::Noop => {}
303        }
304    }
305}
306
307impl QosState {
308    fn record_rejection(&mut self) {
309        self.counters.rejected_total = self.counters.rejected_total.saturating_add(1);
310    }
311}
312
313#[derive(Debug, Clone, Copy)]
314enum QosPermitKind {
315    Connection,
316    Request,
317    Queue,
318    Noop,
319}
320
321/// Admission permit that releases its QoS budget on drop.
322#[derive(Debug)]
323pub struct QosPermit {
324    runtime: QosRuntime,
325    kind: QosPermitKind,
326    released: bool,
327}
328
329impl QosPermit {
330    /// Creates a permit for nested work already charged to an outer QoS boundary.
331    pub(crate) fn already_admitted(runtime: QosRuntime) -> Self {
332        Self {
333            runtime,
334            kind: QosPermitKind::Noop,
335            released: false,
336        }
337    }
338}
339
340impl Drop for QosPermit {
341    fn drop(&mut self) {
342        if !self.released {
343            self.runtime.release(self.kind);
344            self.released = true;
345        }
346    }
347}
348
349/// QoS policy validation error.
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub struct QosPolicyError {
352    pub field: &'static str,
353}
354
355impl fmt::Display for QosPolicyError {
356    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
357        write!(formatter, "{} must be greater than zero", self.field)
358    }
359}
360
361impl Error for QosPolicyError {}
362
363fn ensure_positive(field: &'static str, value: usize) -> Result<(), QosPolicyError> {
364    if value == 0 {
365        return Err(QosPolicyError { field });
366    }
367
368    Ok(())
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn admits_work_inside_all_budgets() {
377        let policy = QosPolicy::new(2, 2, 2).expect("policy should build");
378
379        let decision = policy.evaluate(QosSnapshot {
380            connections: 1,
381            in_flight_requests: 1,
382            queued_requests: 1,
383        });
384
385        assert_eq!(decision, AdmissionDecision::Admit);
386    }
387
388    #[test]
389    fn rejects_when_connection_budget_is_exhausted() {
390        let policy = QosPolicy::new(2, 2, 2).expect("policy should build");
391
392        let decision = policy.evaluate(QosSnapshot {
393            connections: 2,
394            in_flight_requests: 1,
395            queued_requests: 1,
396        });
397
398        assert_eq!(
399            decision,
400            AdmissionDecision::Reject(RejectReason::ConnectionBudgetExceeded)
401        );
402    }
403
404    #[test]
405    fn rejects_when_request_budget_is_exhausted() {
406        let policy = QosPolicy::new(2, 2, 2).expect("policy should build");
407
408        let decision = policy.evaluate(QosSnapshot {
409            connections: 1,
410            in_flight_requests: 2,
411            queued_requests: 1,
412        });
413
414        assert_eq!(
415            decision,
416            AdmissionDecision::Reject(RejectReason::RequestBudgetExceeded)
417        );
418    }
419
420    #[test]
421    fn rejects_when_queue_budget_is_exhausted() {
422        let policy = QosPolicy::new(2, 2, 2).expect("policy should build");
423
424        let decision = policy.evaluate(QosSnapshot {
425            connections: 1,
426            in_flight_requests: 1,
427            queued_requests: 2,
428        });
429
430        assert_eq!(
431            decision,
432            AdmissionDecision::Reject(RejectReason::QueueBudgetExceeded)
433        );
434    }
435
436    #[test]
437    fn rejects_zero_sized_budgets() {
438        let error = QosPolicy::new(0, 1, 1).expect_err("zero budget should fail");
439
440        assert_eq!(
441            error,
442            QosPolicyError {
443                field: "max_connections"
444            }
445        );
446    }
447
448    #[test]
449    fn permit_releases_in_flight_budget_on_drop() {
450        let runtime = QosRuntime::default();
451        let policy = QosPolicy::new(2, 1, 1).expect("policy should build");
452        let permit = runtime
453            .admit_request(&policy)
454            .expect("first request should enter");
455
456        assert_eq!(runtime.snapshot().in_flight_requests, 1);
457        assert_eq!(
458            runtime
459                .admit_request(&policy)
460                .expect_err("second request should fail"),
461            RejectReason::RequestBudgetExceeded
462        );
463
464        drop(permit);
465
466        assert_eq!(runtime.snapshot().in_flight_requests, 0);
467        assert!(runtime.admit_request(&policy).is_ok());
468    }
469
470    #[test]
471    fn connection_and_request_permits_update_independent_budgets() {
472        let runtime = QosRuntime::default();
473        let policy = QosPolicy::new(1, 1, 1).expect("policy should build");
474        let connection = runtime
475            .admit_connection(&policy)
476            .expect("connection should enter");
477
478        assert_eq!(runtime.snapshot().connections, 1);
479        assert_eq!(runtime.snapshot().in_flight_requests, 0);
480        assert_eq!(
481            runtime
482                .admit_connection(&policy)
483                .expect_err("second connection should fail"),
484            RejectReason::ConnectionBudgetExceeded
485        );
486
487        let request = runtime
488            .admit_request(&policy)
489            .expect("request on existing connection should enter");
490
491        assert_eq!(runtime.snapshot().connections, 1);
492        assert_eq!(runtime.snapshot().in_flight_requests, 1);
493
494        drop(request);
495        assert_eq!(runtime.snapshot().connections, 1);
496        assert_eq!(runtime.snapshot().in_flight_requests, 0);
497
498        drop(connection);
499        assert_eq!(runtime.snapshot().connections, 0);
500    }
501
502    #[test]
503    fn queue_permit_tracks_waiting_budget_independently() {
504        let runtime = QosRuntime::default();
505        let policy = QosPolicy::new(1, 1, 1).expect("policy should build");
506        let queued = runtime
507            .reserve_queue(&policy)
508            .expect("first queued request should enter");
509
510        assert_eq!(runtime.snapshot().queued_requests, 1);
511        assert_eq!(
512            runtime
513                .reserve_queue(&policy)
514                .expect_err("second queued request should fail"),
515            RejectReason::QueueBudgetExceeded
516        );
517
518        drop(queued);
519        assert_eq!(runtime.snapshot().queued_requests, 0);
520    }
521
522    #[test]
523    fn queued_request_admission_preserves_budget_invariants() {
524        let runtime = QosRuntime::default();
525        let policy = QosPolicy::new(1, 1, 1).expect("policy should build");
526        let queued = runtime
527            .reserve_queue(&policy)
528            .expect("queued request should reserve budget");
529
530        assert_eq!(
531            runtime
532                .admit_queued_request(&policy)
533                .expect_err("full queue should reject admission"),
534            RejectReason::QueueBudgetExceeded
535        );
536        drop(queued);
537
538        let active = runtime
539            .admit_queued_request(&policy)
540            .expect("request should enter through queued admission");
541
542        assert_eq!(runtime.snapshot().queued_requests, 0);
543        assert_eq!(runtime.snapshot().in_flight_requests, 1);
544        assert_eq!(
545            runtime
546                .admit_queued_request(&policy)
547                .expect_err("active request budget should reject admission"),
548            RejectReason::RequestBudgetExceeded
549        );
550        assert_eq!(runtime.snapshot().queued_requests, 0);
551
552        drop(active);
553        assert_eq!(runtime.snapshot().in_flight_requests, 0);
554    }
555
556    #[test]
557    fn diagnostics_snapshot_records_admission_and_overload_outcomes() {
558        let runtime = QosRuntime::default();
559        let policy = QosPolicy::new(1, 1, 1).expect("policy should build");
560        let permit = runtime
561            .admit_request(&policy)
562            .expect("first request should enter");
563
564        assert_eq!(
565            runtime
566                .admit_request(&policy)
567                .expect_err("second request should exceed active budget"),
568            RejectReason::RequestBudgetExceeded
569        );
570        runtime.record_timed_out();
571        runtime.record_cancelled();
572        runtime.record_dropped();
573
574        let diagnostics = runtime.diagnostics_snapshot();
575        assert_eq!(diagnostics.usage.in_flight_requests, 1);
576        assert_eq!(diagnostics.admitted_total, 1);
577        assert_eq!(diagnostics.rejected_total, 1);
578        assert_eq!(diagnostics.timed_out_total, 1);
579        assert_eq!(diagnostics.cancelled_total, 1);
580        assert_eq!(diagnostics.dropped_total, 1);
581
582        drop(permit);
583        assert_eq!(runtime.diagnostics_snapshot().usage.in_flight_requests, 0);
584    }
585}