Skip to main content

liminal/pressure/
capacity.rs

1pub use state::{CapacityError, CapacityTracker, ConsumerCapacity};
2
3mod state {
4    use crate::pressure::signal::PressureSignal;
5
6    /// Consumer-declared capacity limits for pressure-aware delivery.
7    ///
8    /// `Copy` because it is two `usize`s: it is passed by value through the
9    /// admission path and the depth-cap clamp, and cloning a pair of integers
10    /// to read them is noise. Additive.
11    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
12    pub struct ConsumerCapacity {
13        /// Maximum messages this consumer can process concurrently.
14        pub max_in_flight: usize,
15        /// Maximum messages that may wait for this consumer's capacity to free.
16        pub max_buffer_depth: usize,
17    }
18
19    impl ConsumerCapacity {
20        /// Creates a capacity declaration after verifying both limits are positive.
21        ///
22        /// # Errors
23        ///
24        /// Returns [`CapacityError::InvalidCapacity`] when either declared limit is zero.
25        pub const fn new(
26            max_in_flight: usize,
27            max_buffer_depth: usize,
28        ) -> Result<Self, CapacityError> {
29            if max_in_flight == 0 || max_buffer_depth == 0 {
30                Err(CapacityError::InvalidCapacity {
31                    max_in_flight,
32                    max_buffer_depth,
33                })
34            } else {
35                Ok(Self {
36                    max_in_flight,
37                    max_buffer_depth,
38                })
39            }
40        }
41
42        /// Verifies that the declared capacity contains positive limits.
43        ///
44        /// # Errors
45        ///
46        /// Returns [`CapacityError::InvalidCapacity`] when either declared limit is zero.
47        pub const fn validate(&self) -> Result<(), CapacityError> {
48            if self.max_in_flight == 0 || self.max_buffer_depth == 0 {
49                Err(CapacityError::InvalidCapacity {
50                    max_in_flight: self.max_in_flight,
51                    max_buffer_depth: self.max_buffer_depth,
52                })
53            } else {
54                Ok(())
55            }
56        }
57    }
58
59    /// Capacity tracking failures that keep counters from entering invalid states.
60    #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
61    pub enum CapacityError {
62        /// A capacity declaration used zero for at least one required positive limit.
63        #[error("consumer capacity limits must be positive")]
64        InvalidCapacity {
65            /// Declared maximum in-flight messages.
66            max_in_flight: usize,
67            /// Declared maximum buffered messages.
68            max_buffer_depth: usize,
69        },
70        /// Processing completion was recorded while no message was in flight.
71        #[error("cannot decrement in-flight count below zero")]
72        InFlightUnderflow,
73        /// Buffer removal was recorded while no message was buffered.
74        #[error("cannot decrement buffer depth below zero")]
75        BufferUnderflow,
76    }
77
78    /// Per-consumer tracker for current in-flight and buffered message counts.
79    #[derive(Clone, Debug, PartialEq, Eq)]
80    pub struct CapacityTracker {
81        capacity: ConsumerCapacity,
82        current_in_flight: usize,
83        current_buffer_depth: usize,
84    }
85
86    impl CapacityTracker {
87        /// Creates a tracker for an explicitly declared consumer capacity.
88        #[must_use]
89        pub const fn new(capacity: ConsumerCapacity) -> Self {
90            Self {
91                capacity,
92                current_in_flight: 0,
93                current_buffer_depth: 0,
94            }
95        }
96
97        /// Builds a tracker whose two bands are DERIVED from `queued` — the
98        /// authoritative depth of the queue the messages actually sit in —
99        /// rather than accumulated by independent `record_*` mutations
100        /// (A1 §0.1/§2, `docs/design/A1-DEFER-SEMANTICS.md`).
101        ///
102        /// The in-flight band is `min(queued, max_in_flight)` and the buffered
103        /// band is `queued.saturating_sub(max_in_flight)`, so the occupancy
104        /// invariant `queued == current_in_flight + current_buffer_depth`
105        /// holds for EVERY `queued`, and neither band can be decremented below
106        /// zero because neither band is ever decremented at all. That is what
107        /// retires the [`CapacityError::InFlightUnderflow`] /
108        /// [`CapacityError::BufferUnderflow`] drift class by construction: the
109        /// hot path calls this constructor and reads
110        /// [`Self::pressure_signal`], and never touches a `record_*` mutator.
111        ///
112        /// The mutators stay for the explicit-credit (v2) accounting the
113        /// design specifies but does not ship, and for the standalone unit
114        /// tests already written against them.
115        #[must_use]
116        pub const fn derived(capacity: ConsumerCapacity, queued: usize) -> Self {
117            let max_in_flight = capacity.max_in_flight;
118            let current_in_flight = if queued < max_in_flight {
119                queued
120            } else {
121                max_in_flight
122            };
123            Self {
124                capacity,
125                current_in_flight,
126                current_buffer_depth: queued.saturating_sub(max_in_flight),
127            }
128        }
129
130        /// Returns the consumer capacity declaration this tracker follows.
131        #[must_use]
132        pub const fn capacity(&self) -> &ConsumerCapacity {
133            &self.capacity
134        }
135
136        /// Returns the number of messages currently being processed by the consumer.
137        #[must_use]
138        pub const fn current_in_flight(&self) -> usize {
139            self.current_in_flight
140        }
141
142        /// Returns the number of messages currently buffered for the consumer.
143        #[must_use]
144        pub const fn current_buffer_depth(&self) -> usize {
145            self.current_buffer_depth
146        }
147
148        /// Records that a message was delivered and processing began.
149        pub const fn record_delivery(&mut self) {
150            if self.current_in_flight < usize::MAX {
151                self.current_in_flight += 1;
152            }
153        }
154
155        /// Records that processing completed for one in-flight message.
156        ///
157        /// # Errors
158        ///
159        /// Returns [`CapacityError::InFlightUnderflow`] if no message is currently in flight.
160        pub const fn record_completion(&mut self) -> Result<(), CapacityError> {
161            if self.current_in_flight == 0 {
162                Err(CapacityError::InFlightUnderflow)
163            } else {
164                self.current_in_flight -= 1;
165                Ok(())
166            }
167        }
168
169        /// Records that a message was buffered pending consumer capacity.
170        pub const fn record_buffered(&mut self) {
171            if self.current_buffer_depth < usize::MAX {
172                self.current_buffer_depth += 1;
173            }
174        }
175
176        /// Records that one buffered message left the buffer.
177        ///
178        /// # Errors
179        ///
180        /// Returns [`CapacityError::BufferUnderflow`] if no message is currently buffered.
181        pub const fn record_buffer_drained(&mut self) -> Result<(), CapacityError> {
182            if self.current_buffer_depth == 0 {
183                Err(CapacityError::BufferUnderflow)
184            } else {
185                self.current_buffer_depth -= 1;
186                Ok(())
187            }
188        }
189
190        /// Determines the pressure signal for the next message without mutating counters.
191        #[must_use]
192        pub const fn pressure_signal(&self) -> PressureSignal {
193            if self.current_in_flight < self.capacity.max_in_flight {
194                PressureSignal::accept(self.current_in_flight, self.capacity.max_in_flight)
195            } else if self.current_buffer_depth < self.capacity.max_buffer_depth {
196                PressureSignal::defer(
197                    self.current_in_flight,
198                    self.capacity.max_in_flight,
199                    self.current_buffer_depth,
200                    self.capacity.max_buffer_depth,
201                )
202            } else {
203                PressureSignal::reject(
204                    self.current_in_flight,
205                    self.capacity.max_in_flight,
206                    self.current_buffer_depth,
207                    self.capacity.max_buffer_depth,
208                )
209            }
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::{CapacityError, CapacityTracker, ConsumerCapacity};
217    use crate::pressure::PressureSignal;
218
219    const fn capacity(max_in_flight: usize, max_buffer_depth: usize) -> ConsumerCapacity {
220        ConsumerCapacity {
221            max_in_flight,
222            max_buffer_depth,
223        }
224    }
225
226    /// A1 §0.1/§2 PIN — **derived counters cannot underflow by construction.**
227    ///
228    /// The occupancy invariant `queued == in_flight + buffered` is asserted
229    /// across every band boundary, and the whole point is that it is a
230    /// *property of the constructor*, not of a call sequence: there is no
231    /// ordering of pops and pushes that can make it false, because neither band
232    /// is ever decremented. The two underflow errors this retires
233    /// ([`CapacityError::InFlightUnderflow`] / [`CapacityError::BufferUnderflow`])
234    /// are reachable ONLY through the `record_*` mutators, which the admission
235    /// hot path never calls.
236    #[test]
237    fn derived_counters_satisfy_the_occupancy_invariant_at_every_depth() {
238        let declared = capacity(4, 8);
239        let bound = declared.max_in_flight + declared.max_buffer_depth;
240        // Past the bound too: a queue can legitimately hold more than the bound
241        // for one observation (a fairness/budget-free inbox admitted before a
242        // capacity install), and the bands must still add up rather than wrap.
243        for queued in 0..=(bound + 5) {
244            let tracker = CapacityTracker::derived(declared, queued);
245            assert_eq!(
246                tracker.current_in_flight() + tracker.current_buffer_depth(),
247                queued,
248                "occupancy invariant must hold at depth {queued}"
249            );
250            assert!(
251                tracker.current_in_flight() <= declared.max_in_flight,
252                "the in-flight band never exceeds the declared window at depth {queued}"
253            );
254        }
255    }
256
257    /// The band boundaries the A1 decision turns on, read through the SAME
258    /// `pressure_signal()` rule the unwired decision model already had.
259    #[test]
260    fn derived_counters_reproduce_the_accept_defer_reject_bands() {
261        let declared = capacity(4, 8);
262        assert_eq!(
263            CapacityTracker::derived(declared, 0).pressure_signal(),
264            PressureSignal::accept(0, 4)
265        );
266        assert_eq!(
267            CapacityTracker::derived(declared, 3).pressure_signal(),
268            PressureSignal::accept(3, 4),
269            "the last slot in the in-flight window still Accepts"
270        );
271        assert_eq!(
272            CapacityTracker::derived(declared, 4).pressure_signal(),
273            PressureSignal::defer(4, 4, 0, 8),
274            "a full window with an empty buffer band Defers"
275        );
276        assert_eq!(
277            CapacityTracker::derived(declared, 11).pressure_signal(),
278            PressureSignal::defer(4, 4, 7, 8),
279            "the last slot in the buffer band still Defers"
280        );
281        assert_eq!(
282            CapacityTracker::derived(declared, 12).pressure_signal(),
283            PressureSignal::reject(4, 4, 8, 8),
284            "a full buffer band Rejects"
285        );
286        // De-escalation is the same function read backwards: nothing is stored,
287        // so a drained queue reports the band it is actually in.
288        assert_eq!(
289            CapacityTracker::derived(declared, 4).pressure_signal(),
290            PressureSignal::defer(4, 4, 0, 8),
291            "a resuming consumer de-escalates through Defer with no state to reset"
292        );
293    }
294
295    #[test]
296    fn consumer_capacity_constructs_with_public_fields_and_validates_positive_limits() {
297        let declaration = ConsumerCapacity {
298            max_in_flight: 10,
299            max_buffer_depth: 50,
300        };
301
302        assert_eq!(declaration.max_in_flight, 10);
303        assert_eq!(declaration.max_buffer_depth, 50);
304        assert_eq!(declaration.validate(), Ok(()));
305        assert_eq!(ConsumerCapacity::new(10, 50), Ok(declaration));
306        assert_eq!(
307            ConsumerCapacity::new(0, 50),
308            Err(CapacityError::InvalidCapacity {
309                max_in_flight: 0,
310                max_buffer_depth: 50,
311            })
312        );
313    }
314
315    #[test]
316    fn capacity_tracker_starts_empty_and_records_counts() {
317        let mut tracker = CapacityTracker::new(capacity(10, 50));
318
319        assert_eq!(tracker.current_in_flight(), 0);
320        assert_eq!(tracker.current_buffer_depth(), 0);
321        assert_eq!(tracker.capacity(), &capacity(10, 50));
322
323        tracker.record_delivery();
324        assert_eq!(tracker.current_in_flight(), 1);
325
326        assert_eq!(tracker.record_completion(), Ok(()));
327        assert_eq!(tracker.current_in_flight(), 0);
328
329        tracker.record_buffered();
330        assert_eq!(tracker.current_buffer_depth(), 1);
331
332        assert_eq!(tracker.record_buffer_drained(), Ok(()));
333        assert_eq!(tracker.current_buffer_depth(), 0);
334    }
335
336    #[test]
337    fn capacity_tracker_reports_underflow_errors_without_negative_counts() {
338        let mut tracker = CapacityTracker::new(capacity(10, 50));
339
340        assert_eq!(
341            tracker.record_completion(),
342            Err(CapacityError::InFlightUnderflow)
343        );
344        assert_eq!(tracker.current_in_flight(), 0);
345
346        assert_eq!(
347            tracker.record_buffer_drained(),
348            Err(CapacityError::BufferUnderflow)
349        );
350        assert_eq!(tracker.current_buffer_depth(), 0);
351    }
352
353    #[test]
354    fn pressure_signal_accepts_when_in_flight_capacity_is_available() {
355        let mut tracker = CapacityTracker::new(capacity(2, 5));
356        tracker.record_delivery();
357
358        assert_eq!(tracker.pressure_signal(), PressureSignal::accept(1, 2));
359        assert_eq!(tracker.current_in_flight(), 1);
360        assert_eq!(tracker.current_buffer_depth(), 0);
361    }
362
363    #[test]
364    fn pressure_signal_defers_when_in_flight_full_and_buffer_has_capacity() {
365        let mut tracker = CapacityTracker::new(capacity(2, 5));
366        tracker.record_delivery();
367        tracker.record_delivery();
368        tracker.record_buffered();
369        tracker.record_buffered();
370        tracker.record_buffered();
371
372        assert_eq!(tracker.pressure_signal(), PressureSignal::defer(2, 2, 3, 5));
373        assert_eq!(tracker.current_in_flight(), 2);
374        assert_eq!(tracker.current_buffer_depth(), 3);
375    }
376
377    #[test]
378    fn pressure_signal_rejects_when_in_flight_and_buffer_limits_are_reached() {
379        let mut tracker = CapacityTracker::new(capacity(2, 5));
380        tracker.record_delivery();
381        tracker.record_delivery();
382        tracker.record_buffered();
383        tracker.record_buffered();
384        tracker.record_buffered();
385        tracker.record_buffered();
386        tracker.record_buffered();
387
388        assert_eq!(
389            tracker.pressure_signal(),
390            PressureSignal::reject(2, 2, 5, 5)
391        );
392        assert_eq!(tracker.current_in_flight(), 2);
393        assert_eq!(tracker.current_buffer_depth(), 5);
394    }
395
396    #[test]
397    fn pressure_signal_accepts_available_in_flight_regardless_of_buffer_state() {
398        let mut tracker = CapacityTracker::new(capacity(1, 1));
399        tracker.record_buffered();
400
401        assert_eq!(tracker.pressure_signal(), PressureSignal::accept(0, 1));
402        assert_eq!(tracker.current_in_flight(), 0);
403        assert_eq!(tracker.current_buffer_depth(), 1);
404    }
405
406    #[test]
407    fn pressure_root_re_exports_capacity_types() {
408        use crate::pressure::{
409            CapacityError as RootCapacityError, CapacityTracker as RootCapacityTracker,
410            ConsumerCapacity as RootConsumerCapacity,
411        };
412
413        let mut tracker = RootCapacityTracker::new(RootConsumerCapacity {
414            max_in_flight: 1,
415            max_buffer_depth: 1,
416        });
417
418        assert_eq!(
419            tracker.record_completion(),
420            Err(RootCapacityError::InFlightUnderflow)
421        );
422    }
423}