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#[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 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 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}