Skip to main content

lash_core/runtime/
queued_work_driver.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use tokio_util::sync::CancellationToken;
5use tokio_util::task::TaskTracker;
6
7use crate::PluginError;
8
9const WAKE_RETRY_INITIAL: Duration = Duration::from_millis(25);
10const WAKE_RETRY_MAX: Duration = Duration::from_secs(1);
11const WAKE_MAX_ATTEMPTS: u32 = 8;
12
13#[derive(Clone, Debug)]
14pub struct QueuedWorkRunRequest {
15    pub session_id: Option<String>,
16    pub reason: String,
17    pub trace_idle: bool,
18}
19
20impl QueuedWorkRunRequest {
21    fn new(session_id: Option<String>, reason: impl Into<String>, trace_idle: bool) -> Self {
22        Self {
23            session_id,
24            reason: reason.into(),
25            trace_idle,
26        }
27    }
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum QueuedWorkRunErrorClass {
32    Transient,
33    Terminal,
34}
35
36#[derive(Clone, Debug, thiserror::Error)]
37#[error("{error}")]
38pub struct QueuedWorkRunError {
39    pub class: QueuedWorkRunErrorClass,
40    pub error: PluginError,
41}
42
43impl QueuedWorkRunError {
44    pub fn transient(error: PluginError) -> Self {
45        Self {
46            class: QueuedWorkRunErrorClass::Transient,
47            error,
48        }
49    }
50
51    pub fn terminal(error: PluginError) -> Self {
52        Self {
53            class: QueuedWorkRunErrorClass::Terminal,
54            error,
55        }
56    }
57}
58
59impl From<PluginError> for QueuedWorkRunError {
60    fn from(error: PluginError) -> Self {
61        Self::terminal(error)
62    }
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum QueuedWorkWakeDisposition {
67    Retrying,
68    Terminal,
69    Exhausted,
70}
71
72/// Operational evidence that a best-effort queued-work wake needs retry.
73///
74/// A wake failure is never an enqueue failure: the input is already durable,
75/// and transient failures re-enter the idempotent pending-work claim path up
76/// to the driver's bounded retry limit.
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct QueuedWorkWakeFailure {
79    pub session_id: Option<String>,
80    pub reason: String,
81    pub attempt: u32,
82    pub retry_after_ms: u64,
83    pub disposition: QueuedWorkWakeDisposition,
84    pub error: String,
85}
86
87#[async_trait::async_trait]
88pub trait QueuedWorkRunHandle: Send + Sync {
89    async fn run_queued_work(
90        &self,
91        request: QueuedWorkRunRequest,
92    ) -> Result<(), QueuedWorkRunError>;
93
94    /// Host-driven single pass: claim and submit ready queued work, optionally
95    /// narrowed to one session. The symmetric counterpart to
96    /// [`ProcessRunHandle::claim_and_run_pending`](super::ProcessRunHandle::claim_and_run_pending).
97    ///
98    /// Idempotency is the store scheduler's job, not a same-process memory
99    /// guard. Hosts call this on an event (enqueue, process wake, turn
100    /// completion) instead of polling.
101    async fn claim_and_run_pending(
102        &self,
103        session_id: Option<&str>,
104        reason: &str,
105    ) -> Result<(), QueuedWorkRunError> {
106        let request =
107            QueuedWorkRunRequest::new(session_id.map(str::to_string), reason.to_string(), false);
108        self.run_queued_work(request).await
109    }
110}
111
112#[derive(Clone)]
113pub struct QueuedWorkDriver {
114    inner: Arc<QueuedWorkDriverInner>,
115}
116
117struct QueuedWorkDriverInner {
118    run_handle: Arc<dyn QueuedWorkRunHandle>,
119    shutdown: CancellationToken,
120    wake_tasks: TaskTracker,
121}
122
123impl Drop for QueuedWorkDriverInner {
124    fn drop(&mut self) {
125        self.shutdown.cancel();
126        self.wake_tasks.close();
127    }
128}
129
130impl QueuedWorkDriver {
131    pub fn new(run_handle: Arc<dyn QueuedWorkRunHandle>) -> Self {
132        Self::with_shutdown_token(run_handle, CancellationToken::new())
133    }
134
135    pub fn with_shutdown_token(
136        run_handle: Arc<dyn QueuedWorkRunHandle>,
137        shutdown: CancellationToken,
138    ) -> Self {
139        Self {
140            inner: Arc::new(QueuedWorkDriverInner {
141                run_handle,
142                shutdown: shutdown.child_token(),
143                wake_tasks: TaskTracker::new(),
144            }),
145        }
146    }
147
148    pub async fn claim_and_run_pending(
149        &self,
150        session_id: Option<&str>,
151        reason: &str,
152    ) -> Result<(), PluginError> {
153        if let Err(err) = self
154            .inner
155            .run_handle
156            .claim_and_run_pending(session_id, reason)
157            .await
158        {
159            tracing::warn!("queued work drive ({reason}) failed: {err}");
160            return Err(err.error);
161        }
162        Ok(())
163    }
164
165    /// Wake pending work without coupling dispatch success to the durable write
166    /// that requested it.
167    ///
168    /// The first claim happens on a spawned task so callers can return their
169    /// durable acceptance receipt immediately. Operational failures are
170    /// recorded as typed telemetry. Only typed transient failures retry, and
171    /// both attempts and task lifetime are bounded; the underlying store
172    /// scheduler remains the idempotency authority.
173    pub fn wake_pending(
174        &self,
175        session_id: Option<&str>,
176        reason: &str,
177    ) -> tokio::task::JoinHandle<()> {
178        let run_handle = Arc::clone(&self.inner.run_handle);
179        let shutdown = self.inner.shutdown.clone();
180        let session_id = session_id.map(str::to_string);
181        let reason = reason.to_string();
182        self.inner.wake_tasks.spawn(async move {
183            let mut attempt = 1_u32;
184            let mut retry_after = WAKE_RETRY_INITIAL;
185            loop {
186                let result = tokio::select! {
187                    () = shutdown.cancelled() => return,
188                    result = run_handle.claim_and_run_pending(session_id.as_deref(), &reason) => result,
189                };
190                match result {
191                    Ok(()) => return,
192                    Err(err) => {
193                        let disposition = match err.class {
194                            QueuedWorkRunErrorClass::Terminal => {
195                                QueuedWorkWakeDisposition::Terminal
196                            }
197                            QueuedWorkRunErrorClass::Transient
198                                if attempt >= WAKE_MAX_ATTEMPTS =>
199                            {
200                                QueuedWorkWakeDisposition::Exhausted
201                            }
202                            QueuedWorkRunErrorClass::Transient => {
203                                QueuedWorkWakeDisposition::Retrying
204                            }
205                        };
206                        let failure = QueuedWorkWakeFailure {
207                            session_id: session_id.clone(),
208                            reason: reason.clone(),
209                            attempt,
210                            retry_after_ms: if matches!(
211                                disposition,
212                                QueuedWorkWakeDisposition::Retrying
213                            ) {
214                                retry_after.as_millis() as u64
215                            } else {
216                                0
217                            },
218                            disposition,
219                            error: err.to_string(),
220                        };
221                        match failure.disposition {
222                            QueuedWorkWakeDisposition::Retrying => tracing::warn!(
223                                session_id = failure.session_id.as_deref(),
224                                reason = %failure.reason,
225                                attempt = failure.attempt,
226                                retry_after_ms = failure.retry_after_ms,
227                                error = %failure.error,
228                                event = "queued_work.wake_retry",
229                                "queued-work wake failed; retrying the pending-work claim"
230                            ),
231                            QueuedWorkWakeDisposition::Terminal => {
232                                tracing::warn!(
233                                    session_id = failure.session_id.as_deref(),
234                                    reason = %failure.reason,
235                                    attempt = failure.attempt,
236                                    error = %failure.error,
237                                    event = "queued_work.wake_terminal",
238                                    "queued-work wake stopped after a terminal failure"
239                                );
240                                return;
241                            }
242                            QueuedWorkWakeDisposition::Exhausted => {
243                                tracing::warn!(
244                                    session_id = failure.session_id.as_deref(),
245                                    reason = %failure.reason,
246                                    attempt = failure.attempt,
247                                    error = %failure.error,
248                                    event = "queued_work.wake_exhausted",
249                                    "queued-work wake exhausted its retry budget"
250                                );
251                                return;
252                            }
253                        }
254                    }
255                }
256                tokio::select! {
257                    () = shutdown.cancelled() => return,
258                    () = tokio::time::sleep(retry_after) => {}
259                }
260                retry_after = retry_after.saturating_mul(2).min(WAKE_RETRY_MAX);
261                attempt = attempt.saturating_add(1);
262            }
263        })
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use std::sync::atomic::{AtomicUsize, Ordering};
270
271    use super::*;
272
273    struct FailOnceRunHandle {
274        attempts: Arc<AtomicUsize>,
275        accepted: tokio::sync::Notify,
276    }
277
278    #[async_trait::async_trait]
279    impl QueuedWorkRunHandle for FailOnceRunHandle {
280        async fn run_queued_work(
281            &self,
282            _request: QueuedWorkRunRequest,
283        ) -> Result<(), QueuedWorkRunError> {
284            if self.attempts.fetch_add(1, Ordering::SeqCst) == 0 {
285                return Err(QueuedWorkRunError::transient(PluginError::Session(
286                    "transient wake failure".to_string(),
287                )));
288            }
289            self.accepted.notify_one();
290            Ok(())
291        }
292    }
293
294    #[tokio::test]
295    async fn best_effort_wake_reenters_pending_claim_without_an_external_event() {
296        let handle = Arc::new(FailOnceRunHandle {
297            attempts: Arc::new(AtomicUsize::new(0)),
298            accepted: tokio::sync::Notify::new(),
299        });
300        let accepted = handle.accepted.notified();
301        let driver = QueuedWorkDriver::new(handle.clone());
302
303        let wake = driver.wake_pending(Some("session-1"), "queued_turn_input");
304
305        tokio::time::timeout(Duration::from_secs(1), accepted)
306            .await
307            .expect("the failed wake must retry without another enqueue");
308        wake.await.expect("wake task");
309        assert_eq!(handle.attempts.load(Ordering::SeqCst), 2);
310    }
311
312    struct AlwaysFailRunHandle {
313        attempts: Arc<AtomicUsize>,
314        class: QueuedWorkRunErrorClass,
315    }
316
317    #[async_trait::async_trait]
318    impl QueuedWorkRunHandle for AlwaysFailRunHandle {
319        async fn run_queued_work(
320            &self,
321            _request: QueuedWorkRunRequest,
322        ) -> Result<(), QueuedWorkRunError> {
323            self.attempts.fetch_add(1, Ordering::SeqCst);
324            let error = PluginError::Session("persistent wake failure".to_string());
325            Err(match self.class {
326                QueuedWorkRunErrorClass::Transient => QueuedWorkRunError::transient(error),
327                QueuedWorkRunErrorClass::Terminal => QueuedWorkRunError::terminal(error),
328            })
329        }
330    }
331
332    #[tokio::test]
333    async fn terminal_wake_error_stops_after_one_attempt() {
334        let attempts = Arc::new(AtomicUsize::new(0));
335        let driver = QueuedWorkDriver::new(Arc::new(AlwaysFailRunHandle {
336            attempts: Arc::clone(&attempts),
337            class: QueuedWorkRunErrorClass::Terminal,
338        }));
339
340        driver
341            .wake_pending(Some("session-terminal"), "queued_turn_input")
342            .await
343            .expect("terminal wake task");
344
345        assert_eq!(attempts.load(Ordering::SeqCst), 1);
346    }
347
348    #[tokio::test]
349    async fn transient_wake_error_stops_at_the_attempt_limit() {
350        let attempts = Arc::new(AtomicUsize::new(0));
351        let driver = QueuedWorkDriver::new(Arc::new(AlwaysFailRunHandle {
352            attempts: Arc::clone(&attempts),
353            class: QueuedWorkRunErrorClass::Transient,
354        }));
355
356        driver
357            .wake_pending(Some("session-exhausted"), "queued_turn_input")
358            .await
359            .expect("exhausted wake task");
360
361        assert_eq!(attempts.load(Ordering::SeqCst), WAKE_MAX_ATTEMPTS as usize);
362    }
363
364    struct BlockingRunHandle {
365        entered: tokio::sync::Notify,
366    }
367
368    #[async_trait::async_trait]
369    impl QueuedWorkRunHandle for BlockingRunHandle {
370        async fn run_queued_work(
371            &self,
372            _request: QueuedWorkRunRequest,
373        ) -> Result<(), QueuedWorkRunError> {
374            self.entered.notify_one();
375            std::future::pending().await
376        }
377    }
378
379    #[tokio::test]
380    async fn dropping_the_driver_cancels_an_inflight_wake() {
381        let handle = Arc::new(BlockingRunHandle {
382            entered: tokio::sync::Notify::new(),
383        });
384        let entered = handle.entered.notified();
385        let driver = QueuedWorkDriver::new(handle.clone());
386        let wake = driver.wake_pending(Some("session-shutdown"), "queued_turn_input");
387        entered.await;
388
389        drop(driver);
390
391        tokio::time::timeout(Duration::from_secs(1), wake)
392            .await
393            .expect("driver drop must cancel the wake")
394            .expect("wake task");
395    }
396}