Skip to main content

monoloop_loop/transaction/
callback_service.rs

1//! Runtime-owned completion-callback service (D-021 / D-029).
2//!
3//! Callbacks run on owned child tasks under a bounded concurrency permit so
4//! host panics/timeouts cannot kill actors, and outstanding work can be drained
5//! at shutdown independent of actor liveness. Capacity is reserved at admission
6//! (try_reserve) and retained through callback terminal state.
7
8use super::executor_spawn::try_spawn;
9use monoloop_contracts::{CompletionCallback, TransactionEnd};
10use std::sync::atomic::{AtomicUsize, Ordering};
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::runtime::Handle;
14use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore};
15use tokio::task::{AbortHandle, JoinHandle};
16
17type CallbackJoin = (AbortHandle, JoinHandle<()>);
18type CallbackJoinSet = Arc<Mutex<Vec<CallbackJoin>>>;
19
20/// Bounded, runtime-owned completion callback executor.
21#[derive(Clone)]
22pub struct CallbackService {
23    permits: Arc<Semaphore>,
24    /// Admission reservations + scheduled/running callbacks (D-029).
25    reserved: Arc<AtomicUsize>,
26    inflight: Arc<AtomicUsize>,
27    default_deadline: Duration,
28    /// Injected Tokio handle for owned callback tasks (D-032).
29    executor: Handle,
30    /// Owned callback joins for shutdown abort+join (D-029).
31    joins: CallbackJoinSet,
32}
33
34/// Admission-time callback capacity reservation (D-029).
35pub struct CallbackReservation {
36    permits: Arc<Semaphore>,
37    reserved: Arc<AtomicUsize>,
38    permit: Option<OwnedSemaphorePermit>,
39}
40
41impl CallbackReservation {
42    /// Release without scheduling (admission rollback).
43    pub fn release(mut self) {
44        let _ = self.permit.take();
45        self.reserved.fetch_sub(1, Ordering::SeqCst);
46    }
47
48    fn into_parts(mut self) -> (Arc<Semaphore>, Arc<AtomicUsize>, OwnedSemaphorePermit) {
49        let permit = self
50            .permit
51            .take()
52            .expect("CallbackReservation permit present");
53        self.reserved.fetch_sub(1, Ordering::SeqCst);
54        (
55            Arc::clone(&self.permits),
56            Arc::clone(&self.reserved),
57            permit,
58        )
59    }
60}
61
62impl Drop for CallbackReservation {
63    fn drop(&mut self) {
64        if self.permit.take().is_some() {
65            self.reserved.fetch_sub(1, Ordering::SeqCst);
66        }
67    }
68}
69
70impl CallbackService {
71    /// Create with a maximum number of concurrent callback tasks.
72    pub fn new(max_concurrent: usize, default_deadline: Duration, executor: Handle) -> Self {
73        Self {
74            permits: Arc::new(Semaphore::new(max_concurrent.max(1))),
75            reserved: Arc::new(AtomicUsize::new(0)),
76            inflight: Arc::new(AtomicUsize::new(0)),
77            default_deadline: if default_deadline.is_zero() {
78                Duration::from_millis(50)
79            } else {
80                default_deadline
81            },
82            executor,
83            joins: Arc::new(Mutex::new(Vec::new())),
84        }
85    }
86
87    /// Number of callbacks currently executing.
88    pub fn inflight(&self) -> usize {
89        self.inflight.load(Ordering::SeqCst)
90    }
91
92    /// Reserved + inflight callback slots (admission bound).
93    pub fn reserved(&self) -> usize {
94        self.reserved.load(Ordering::SeqCst)
95    }
96
97    /// Reserve one callback slot at admission (fail closed when full).
98    pub fn try_reserve(&self) -> Option<CallbackReservation> {
99        let permit = self.permits.clone().try_acquire_owned().ok()?;
100        self.reserved.fetch_add(1, Ordering::SeqCst);
101        Some(CallbackReservation {
102            permits: Arc::clone(&self.permits),
103            reserved: Arc::clone(&self.reserved),
104            permit: Some(permit),
105        })
106    }
107
108    /// Schedule a host callback using an admission reservation (D-029).
109    ///
110    /// If the executor rejects the spawn, the reservation permit is dropped and
111    /// inflight is not left elevated (D-032).
112    pub fn schedule_reserved(
113        &self,
114        reservation: CallbackReservation,
115        callback: Box<dyn CompletionCallback>,
116        end: TransactionEnd,
117        deadline: Option<Duration>,
118    ) {
119        let (_permits, _reserved_counter, permit) = reservation.into_parts();
120        let inflight = Arc::clone(&self.inflight);
121        let joins = Arc::clone(&self.joins);
122        let executor = self.executor.clone();
123        let budget = deadline.unwrap_or(self.default_deadline);
124        inflight.fetch_add(1, Ordering::SeqCst);
125        let inflight_child = Arc::clone(&inflight);
126        let handle = match try_spawn(&self.executor, async move {
127            let call =
128                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback.call(end)));
129            if let Ok(fut) = call {
130                let handle = match try_spawn(&executor, fut) {
131                    Ok(h) => h,
132                    Err(()) => {
133                        drop(permit);
134                        inflight_child.fetch_sub(1, Ordering::SeqCst);
135                        return;
136                    }
137                };
138                let abort = handle.abort_handle();
139                match tokio::time::timeout(budget, handle).await {
140                    Ok(Ok(_)) | Ok(Err(_)) => {}
141                    Err(_) => {
142                        abort.abort();
143                    }
144                }
145            }
146            drop(permit);
147            inflight_child.fetch_sub(1, Ordering::SeqCst);
148        }) {
149            Ok(h) => h,
150            Err(()) => {
151                // Future (and permit) dropped with failed spawn; clear inflight only.
152                inflight.fetch_sub(1, Ordering::SeqCst);
153                return;
154            }
155        };
156        let abort = handle.abort_handle();
157        // Best-effort register for shutdown join; ignore contention.
158        if let Ok(mut g) = joins.try_lock() {
159            g.push((abort, handle));
160        };
161    }
162
163    /// Schedule a host callback on an owned task (does not block the caller).
164    ///
165    /// Prefer [`schedule_reserved`] after admission. This path still acquires a
166    /// permit before work; if none are available the callback is dropped fail-closed.
167    pub fn schedule(
168        &self,
169        callback: Box<dyn CompletionCallback>,
170        end: TransactionEnd,
171        deadline: Option<Duration>,
172    ) {
173        let Some(reservation) = self.try_reserve() else {
174            return;
175        };
176        self.schedule_reserved(reservation, callback, end, deadline);
177    }
178
179    /// Wait until no callbacks are inflight, or until `deadline` elapses.
180    /// On expiry, abort owned callback tasks and join briefly (D-029).
181    pub async fn drain(&self, deadline: Duration) {
182        let start = tokio::time::Instant::now();
183        while self.inflight() > 0 {
184            if start.elapsed() >= deadline {
185                break;
186            }
187            tokio::time::sleep(Duration::from_millis(5)).await;
188        }
189        if self.inflight() == 0 {
190            return;
191        }
192        let remaining = deadline.saturating_sub(start.elapsed());
193        let mut handles = {
194            let mut g = self.joins.lock().await;
195            std::mem::take(&mut *g)
196        };
197        for (abort, join) in handles.drain(..) {
198            if remaining.is_zero() {
199                abort.abort();
200                let _ = join.await;
201            } else {
202                abort.abort();
203                let _ = tokio::time::timeout(remaining, join).await;
204            }
205        }
206    }
207}