Skip to main content

aion_server/worker/queue_service/
delivery.rs

1//! Hand-off of a dispatched task to a live compatible worker, bounded by the
2//! schedule-to-start clock.
3//!
4//! This is where [`QueueServiceReason::Saturated`](super::QueueServiceReason::Saturated)
5//! is really emitted, and the only place it can be: a saturated queue is by
6//! definition one with compatible pollers connected, so selection SUCCEEDS and
7//! the unservability shows up at intake. Without a configured
8//! schedule-to-start timeout the behaviour is exactly what it was — one
9//! `try_send`, and a full or closed intake fails the dispatch immediately —
10//! because inventing a wait would be inventing a timeout.
11
12use std::time::{Duration, Instant};
13
14use tokio::sync::mpsc::error::TrySendError;
15
16use crate::worker::registry::{WorkerMessage, WorkerTaskSender};
17
18/// Cadence at which a saturated intake is re-offered the task.
19///
20/// This is the seam's PRE-EXISTING park cadence (the runtime-less branch of the
21/// selection wait has slept exactly this long between re-selections since the
22/// bridge was written), named once and reused rather than a second, newly
23/// invented interval. Each sleep is clamped to the remaining schedule-to-start
24/// budget, so the clock — not the cadence — decides when the refusal lands.
25pub const PARK_POLL_INTERVAL: Duration = Duration::from_millis(500);
26
27/// Why a task could not be handed to the selected worker.
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub enum DeliveryRefusal {
30    /// A compatible worker was live and its intake stayed full for the whole
31    /// schedule-to-start budget: `SATURATED`.
32    Saturated {
33        /// How long the hand-off was attempted before giving up.
34        waited: Duration,
35    },
36    /// The intake was full and no schedule-to-start clock is configured, so the
37    /// dispatch fails at once exactly as it always has.
38    Full,
39    /// The worker's transport is gone.
40    Closed,
41    /// The server stopped accepting work (drain) while the hand-off retried.
42    NotAccepting {
43        /// The drain gate's own refusal reason, passed through untouched.
44        reason: String,
45    },
46}
47
48/// Offer `message` to `sender` until it is accepted or the schedule-to-start
49/// clock expires.
50///
51/// `accepting` is re-consulted before every re-offer so a drain beginning
52/// mid-hand-off still fails fast with the drain's own reason — the existing
53/// drain contract is not weakened by the retry window.
54///
55/// # Errors
56///
57/// Returns the typed [`DeliveryRefusal`] describing why the hand-off failed.
58pub fn deliver_within_schedule_to_start(
59    sender: &WorkerTaskSender,
60    message: WorkerMessage,
61    schedule_to_start: Option<Duration>,
62    accepting: &mut dyn FnMut() -> Result<(), String>,
63) -> Result<(), DeliveryRefusal> {
64    let started_at = Instant::now();
65    let mut pending = message;
66    loop {
67        match sender.try_send(pending) {
68            Ok(()) => return Ok(()),
69            Err(TrySendError::Closed(_)) => return Err(DeliveryRefusal::Closed),
70            Err(TrySendError::Full(returned)) => {
71                let Some(budget) = schedule_to_start else {
72                    return Err(DeliveryRefusal::Full);
73                };
74                let waited = started_at.elapsed();
75                let Some(remaining) = budget.checked_sub(waited) else {
76                    return Err(DeliveryRefusal::Saturated { waited });
77                };
78                if remaining.is_zero() {
79                    return Err(DeliveryRefusal::Saturated { waited });
80                }
81                if let Err(reason) = accepting() {
82                    return Err(DeliveryRefusal::NotAccepting { reason });
83                }
84                std::thread::sleep(remaining.min(PARK_POLL_INTERVAL));
85                pending = returned;
86            }
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn task() -> WorkerMessage {
96        WorkerMessage::ActivityTask(Box::default())
97    }
98
99    fn always_accepting() -> impl FnMut() -> Result<(), String> {
100        || Ok(())
101    }
102
103    #[test]
104    fn a_free_intake_accepts_the_task_immediately() -> Result<(), String> {
105        let (sender, mut receiver) = tokio::sync::mpsc::channel(1);
106        let mut accepting = always_accepting();
107        deliver_within_schedule_to_start(&sender, task(), None, &mut accepting)
108            .map_err(|refusal| format!("unexpected refusal: {refusal:?}"))?;
109        receiver
110            .try_recv()
111            .map_err(|error| format!("task never reached the worker: {error}"))?;
112        Ok(())
113    }
114
115    #[test]
116    fn a_full_intake_with_no_clock_fails_at_once() {
117        let (sender, _receiver) = tokio::sync::mpsc::channel(1);
118        let mut accepting = always_accepting();
119        assert!(
120            sender.try_send(task()).is_ok(),
121            "the fixture must fill the single intake slot"
122        );
123        let started = Instant::now();
124        let refusal = deliver_within_schedule_to_start(&sender, task(), None, &mut accepting);
125        assert_eq!(refusal, Err(DeliveryRefusal::Full));
126        assert!(
127            started.elapsed() < PARK_POLL_INTERVAL,
128            "the unclocked path must not wait"
129        );
130    }
131
132    #[test]
133    fn a_closed_intake_is_never_reported_as_saturation() {
134        let (sender, receiver) = tokio::sync::mpsc::channel(1);
135        drop(receiver);
136        let mut accepting = always_accepting();
137        let refusal = deliver_within_schedule_to_start(
138            &sender,
139            task(),
140            Some(Duration::from_secs(30)),
141            &mut accepting,
142        );
143        assert_eq!(refusal, Err(DeliveryRefusal::Closed));
144    }
145
146    /// SATURATED really fires: a live, compatible, connected worker whose
147    /// intake refuses the task for the whole schedule-to-start budget.
148    #[test]
149    fn an_intake_that_stays_full_past_the_clock_is_saturated() {
150        let (sender, _receiver) = tokio::sync::mpsc::channel(1);
151        let mut accepting = always_accepting();
152        assert!(sender.try_send(task()).is_ok(), "fixture must fill intake");
153        let budget = Duration::from_millis(300);
154        let started = Instant::now();
155        let refusal =
156            deliver_within_schedule_to_start(&sender, task(), Some(budget), &mut accepting);
157        let elapsed = started.elapsed();
158        assert!(
159            matches!(refusal, Err(DeliveryRefusal::Saturated { waited }) if waited >= budget),
160            "expected saturation after at least {budget:?}, got {refusal:?}"
161        );
162        assert!(
163            elapsed < Duration::from_secs(5),
164            "saturation must be bounded by the clock, took {elapsed:?}"
165        );
166    }
167
168    /// An intake that drains within the budget is served, not refused: the
169    /// clock exists to bound the wait, not to shorten a recoverable one.
170    #[test]
171    fn an_intake_that_frees_up_within_the_clock_is_served() -> Result<(), String> {
172        let (sender, mut receiver) = tokio::sync::mpsc::channel(1);
173        let mut accepting = always_accepting();
174        sender
175            .try_send(task())
176            .map_err(|error| format!("fixture fill failed: {error}"))?;
177        let drainer = std::thread::spawn(move || {
178            std::thread::sleep(Duration::from_millis(100));
179            let taken = receiver.try_recv().is_ok();
180            (receiver, taken)
181        });
182        let outcome = deliver_within_schedule_to_start(
183            &sender,
184            task(),
185            Some(Duration::from_secs(5)),
186            &mut accepting,
187        );
188        let (_receiver, taken) = drainer.join().map_err(|_| "drainer thread panicked")?;
189        assert!(taken, "the fixture drainer never freed the intake slot");
190        assert_eq!(outcome, Ok(()));
191        Ok(())
192    }
193
194    /// A drain starting mid-hand-off still wins, with the drain's own reason.
195    #[test]
196    fn a_drain_during_the_retry_window_fails_with_the_drain_reason() {
197        let (sender, _receiver) = tokio::sync::mpsc::channel(1);
198        assert!(sender.try_send(task()).is_ok(), "fixture must fill intake");
199        let mut accepting = || Err("server draining".to_owned());
200        let refusal = deliver_within_schedule_to_start(
201            &sender,
202            task(),
203            Some(Duration::from_secs(30)),
204            &mut accepting,
205        );
206        assert_eq!(
207            refusal,
208            Err(DeliveryRefusal::NotAccepting {
209                reason: "server draining".to_owned()
210            })
211        );
212    }
213}