camel_api/in_flight.rs
1//! Context-global in-flight claim (drainclaim), Exchange-carried
2//! extension (claimfamily).
3//!
4//! [`InFlightClaim`] is one accepted-not-completed unit on the
5//! context-global gauge (`CamelContext::total_in_flight()`). The type
6//! lives in camel-api — not camel-component-api — so [`crate::Exchange`]
7//! can carry a claim directly (rc-hllkk, rc-qbigm): stash sites behind
8//! `dyn` traits and raw-`Exchange` channels (resequencer buffers,
9//! embedded aggregator buckets) receive resident exchanges as bare
10//! `Exchange` values with no envelope, and their residency stays counted
11//! only if the claim rides the exchange itself.
12
13use std::fmt;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU64, Ordering};
16
17/// Context-global in-flight gauge (drainclaim): the accepted-not-completed
18/// exchange counter plus a zero-transition notification.
19///
20/// Counting is owned by [`InFlightClaim`] RAII (attach increments, drop
21/// decrements) — `inc`/`dec` are crate-private so no cross-crate code can
22/// skew the count outside the claim lifecycle. Observers read the live
23/// count via [`InFlightGauge::total`] and await quiescence via
24/// [`InFlightGauge::idle`]: the last release (1→0) fires
25/// `notify_waiters()`, waking every waiter registered before the release
26/// (register-before-check: pin the `notified()` future and `enable()` it,
27/// then read state). The sync-callable notify is what makes `Drop` a
28/// completion signal for notification-based settle.
29pub struct InFlightGauge {
30 count: AtomicU64,
31 idle: tokio::sync::Notify,
32}
33
34impl InFlightGauge {
35 /// Create a gauge with a zero count.
36 pub fn new() -> Self {
37 Self {
38 count: AtomicU64::new(0),
39 idle: tokio::sync::Notify::new(),
40 }
41 }
42
43 /// Increment the in-flight count. Crate-private: claims are the only
44 /// counting path.
45 pub(crate) fn inc(&self) {
46 self.count.fetch_add(1, Ordering::AcqRel);
47 }
48
49 /// Decrement the in-flight count; the last release (1→0) wakes every
50 /// registered idle waiter. Crate-private: claims are the only counting
51 /// path.
52 pub(crate) fn dec(&self) {
53 let prev = self.count.fetch_sub(1, Ordering::Release);
54 if prev == 1 {
55 self.idle.notify_waiters();
56 }
57 }
58
59 /// Read the live in-flight count (Acquire load; pairs with the
60 /// `Release` decrement so a woken waiter observes the zero).
61 pub fn total(&self) -> u64 {
62 self.count.load(Ordering::Acquire)
63 }
64
65 /// Notification slot resolved on the last release (1→0). Waiters must
66 /// register before checking the count (pin + `enable()`); notifications
67 /// are not stored.
68 pub fn idle(&self) -> &tokio::sync::Notify {
69 &self.idle
70 }
71}
72
73impl Default for InFlightGauge {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79/// One accepted-not-completed unit on the context-global in-flight
80/// gauge.
81///
82/// Attaching a claim increments the gauge; dropping it decrements the
83/// gauge exactly once (RAII), covering every release path — normal
84/// pipeline completion, dispatch push failure, queued-envelope drop,
85/// pipeline task abort, panic, and readiness failure — with no manual
86/// rollback code. Fanout sites mint one sibling claim per subscriber copy
87/// via [`InFlightClaim::split`], so each copy counts and releases
88/// independently (drainclaim). The type is deliberately NOT `Clone`:
89/// duplicating a claim would double-release on drop; fanout copies mint
90/// siblings via [`InFlightClaim::split`] instead.
91///
92/// Claim lifecycle during a pipeline run (claimfamily): the pipeline
93/// drain site holds the envelope's claim in task scope AND splits a
94/// sibling onto the exchange, so residency inside pipeline-embedded
95/// stash sites (resequencer buffer, aggregator bucket) stays counted
96/// after the pipeline task itself completes. Out-of-band stash emissions
97/// escape with their carried claims; the in-band pipeline result has its
98/// sibling taken back before the reply, so release stays at task end for
99/// exchanges that complete inside the pipeline.
100pub struct InFlightClaim(Arc<InFlightGauge>);
101
102impl InFlightClaim {
103 /// Mint a claim against `gauge`, incrementing it by one.
104 pub fn attach(gauge: &Arc<InFlightGauge>) -> Self {
105 gauge.inc();
106 Self(Arc::clone(gauge))
107 }
108
109 /// Mint a sibling claim for a fanout copy, incrementing the same
110 /// gauge by one. The original claim stays live.
111 pub fn split(&self) -> Self {
112 self.0.inc();
113 Self(Arc::clone(&self.0))
114 }
115}
116
117impl Drop for InFlightClaim {
118 fn drop(&mut self) {
119 self.0.dec();
120 }
121}
122
123impl fmt::Debug for InFlightClaim {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 // The live count is observable through the counter itself
126 // (`total_in_flight()`); the Debug shape deliberately avoids
127 // leaking the Arc pointer.
128 f.write_str("InFlightClaim")
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn claim_attach_increments_and_drop_decrements() {
138 let gauge = Arc::new(InFlightGauge::new());
139 let claim = InFlightClaim::attach(&gauge);
140 assert_eq!(gauge.total(), 1);
141 drop(claim);
142 assert_eq!(gauge.total(), 0);
143 }
144
145 #[test]
146 fn claim_split_adds_one_sibling() {
147 let gauge = Arc::new(InFlightGauge::new());
148 let original = InFlightClaim::attach(&gauge);
149 assert_eq!(gauge.total(), 1);
150 let sibling = original.split();
151 assert_eq!(gauge.total(), 2);
152 drop(sibling);
153 assert_eq!(gauge.total(), 1);
154 drop(original);
155 assert_eq!(gauge.total(), 0);
156 }
157
158 #[test]
159 fn claim_debug_does_not_leak_pointer() {
160 let gauge = Arc::new(InFlightGauge::new());
161 let claim = InFlightClaim::attach(&gauge);
162 assert_eq!(format!("{claim:?}"), "InFlightClaim");
163 }
164
165 #[test]
166 fn gauge_counts_claim_lifecycle() {
167 let gauge = Arc::new(InFlightGauge::new());
168 let first = InFlightClaim::attach(&gauge);
169 let second = InFlightClaim::attach(&gauge);
170 assert_eq!(gauge.total(), 2);
171 drop(first);
172 assert_eq!(gauge.total(), 1);
173 drop(second);
174 assert_eq!(gauge.total(), 0);
175 }
176
177 #[tokio::test]
178 async fn gauge_notifies_on_last_release_only() {
179 let gauge = Arc::new(InFlightGauge::new());
180 // Register-before-check (D4): enable() subscribes the waiter
181 // without awaiting it, so no release can slip past unobserved.
182 let mut idle = std::pin::pin!(gauge.idle().notified());
183 idle.as_mut().enable();
184 let first = InFlightClaim::attach(&gauge);
185 let second = InFlightClaim::attach(&gauge);
186
187 drop(first);
188 let notified_early =
189 tokio::time::timeout(std::time::Duration::from_millis(100), &mut idle).await;
190 assert!(
191 notified_early.is_err(),
192 "a non-final release must not notify idle waiters"
193 );
194
195 drop(second);
196 tokio::time::timeout(std::time::Duration::from_secs(1), &mut idle)
197 .await
198 .expect("the last release must resolve the enabled waiter");
199 }
200
201 #[tokio::test]
202 async fn gauge_notify_wakes_all_enabled_waiters() {
203 let gauge = Arc::new(InFlightGauge::new());
204 let mut first = std::pin::pin!(gauge.idle().notified());
205 first.as_mut().enable();
206 let mut second = std::pin::pin!(gauge.idle().notified());
207 second.as_mut().enable();
208
209 let claim = InFlightClaim::attach(&gauge);
210 drop(claim);
211
212 tokio::time::timeout(std::time::Duration::from_secs(1), &mut first)
213 .await
214 .expect("first enabled waiter must resolve on last release");
215 tokio::time::timeout(std::time::Duration::from_secs(1), &mut second)
216 .await
217 .expect("second enabled waiter must resolve on last release");
218 }
219}