Skip to main content

aion_server/worker/
task_delivery.rs

1//! One awaited call: hand an already-built task to an already-chosen worker,
2//! over whichever transport that worker registered on.
3//!
4//! # Why this seam exists
5//!
6//! Before it, the dispatcher's delivery line assumed gRPC. A worker whose
7//! `sender()` was `None` — which is every liminal-delivered worker — was
8//! **deregistered like a closed stream**, on the premise that its presence could
9//! only be a leak. That premise held while the outbox chose one transport for
10//! the whole server, and stops holding the moment a row is routed by the
11//! worker's own delivery ([aion#52]).
12//!
13//! # The contract, and why the blocking shape wins
14//!
15//! "Enqueued onto the stream" is the weakest truth a transport can offer: it
16//! says a message was accepted by a channel, not that a worker took the work.
17//! The liminal path already offers a stronger one — a correlated reply, with the
18//! run id resolved **before** an owner is bound — and that is the truth the
19//! completion fences actually want. So the seam is a single awaited call
20//! returning a typed outcome, and gRPC is the fast-completing case of it rather
21//! than a different shape. Latency is not the dispatcher's concern; it awaits
22//! either.
23//!
24//! # The outcome carries the deregistration decision as a TYPE
25//!
26//! The distinction that used to live in a comment at the delivery line is now
27//! [`Undeliverable`]'s two variants, so a caller cannot act on it by accident:
28//! a worker the transport says is **gone** is deregistered, and a delivery that
29//! failed while the worker is **alive** leaves the registration standing.
30//!
31//! [aion#52]: https://github.com/ablative-io/aion/issues/52
32
33use async_trait::async_trait;
34
35use aion_proto::ProtoActivityTask;
36
37use super::delivery_intent::SharedDeliveryIntent;
38use super::registry::WorkerHandle;
39
40/// How a delivered task's liveness is tracked, as a value the transport must
41/// interpret rather than a bare number it must remember the meaning of.
42///
43/// Distinct from [`TaskLiveness`](super::heartbeat::TaskLiveness), which is
44/// the tracker's RECORD of an in-flight activity. This type says whether a
45/// task is tracked that way at all.
46///
47/// # Why this is a type and not `0`
48///
49/// [`ProtoActivityTask`] carries no heartbeat window, so a transport that needs
50/// one — liminal's `DispatchRequest` has the field — cannot derive it and must
51/// **assign** it. Today's assignment is `0`, and it is correct only while liminal
52/// dispatches stay outside the server's per-task liveness tracker: the outbox
53/// retry loop is their backstop, so the worker pumps no beats for them.
54///
55/// Written as an exhaustive enum so that the day a liminal dispatch *is* tracked
56/// per task, adding the variant makes every `match` on it non-exhaustive and the
57/// compiler names each site that has to decide again. A literal `0` would simply
58/// keep compiling, silently claiming "no window" for a dispatch that now needs
59/// one.
60#[derive(Debug, Clone, Copy, Eq, PartialEq)]
61pub enum LivenessTracking {
62    /// This transport does not track the task's liveness per task; the caller's
63    /// own retry path is the backstop.
64    NotTrackedPerTask,
65}
66
67impl LivenessTracking {
68    /// The heartbeat window, in milliseconds, to stamp on the wire.
69    ///
70    /// Zero means "pump no beats for this task" — which every consumer already
71    /// reads that way, and which is only ever correct for
72    /// [`LivenessTracking::NotTrackedPerTask`].
73    #[must_use]
74    pub const fn heartbeat_window_ms(self) -> u64 {
75        match self {
76            Self::NotTrackedPerTask => 0,
77        }
78    }
79}
80
81/// Why one delivery attempt did not place its task with a worker.
82///
83/// The two variants are the whole reason this is a type rather than a `bool`:
84/// they carry **opposite obligations** for the worker's registration, and
85/// conflating them either destroys a live worker's registration or keeps a dead
86/// one's forever.
87#[derive(Debug)]
88pub enum Undeliverable {
89    /// The transport reports the worker is **gone** — a closed gRPC stream, a
90    /// liminal connection the supervisor no longer owns.
91    ///
92    /// The caller deregisters it. This is the pre-existing behaviour of the
93    /// closed-stream path, now reached for a reason that is true of both
94    /// transports rather than inferred from a missing sender.
95    WorkerUnreachable {
96        /// Operator-facing reason, named by the transport that observed it.
97        reason: String,
98    },
99    /// The delivery failed while the worker is **alive** — a liminal reply that
100    /// did not arrive in time, a full channel, a drain that closed the gate
101    /// mid-push.
102    ///
103    /// 🔴 The caller **keeps the registration** and withdraws only its own
104    /// token. Deregistering here would destroy a healthy worker because one
105    /// delivery was slow, which is exactly the failure the typed outcome exists
106    /// to prevent.
107    DeliveryFailed {
108        /// Operator-facing reason, named by the transport that observed it.
109        reason: String,
110    },
111    /// The task can NEVER be placed: the transport proved its encoded frame is
112    /// larger than the connection's whole outbound buffer, a bound every
113    /// worker connection shares because one supervisor builds them all from
114    /// one `LimitsConfig`. No other candidate can carry it, no retry can, and
115    /// every attempt writes one more lease for a step that never runs.
116    ///
117    /// The caller **keeps the registration**, withdraws its own token, and
118    /// settles the dispatch TERMINALLY — no next candidate, no park, no
119    /// attempt-neutral re-arm. This is the class the outbox dead-letters on
120    /// first observation and the engine-seam bridge reports as `terminal:`;
121    /// it used to be reported as [`Undeliverable::DeliveryFailed`] here, which
122    /// read as retryable and spent a whole attempt budget on a certainty.
123    Unservable {
124        /// Operator-facing reason naming the frame's size, the bound and the
125        /// key that sets it.
126        reason: String,
127    },
128}
129
130impl Undeliverable {
131    /// Whether this refusal is terminal for the dispatch — see
132    /// [`Undeliverable::Unservable`].
133    #[must_use]
134    pub fn is_unservable(&self) -> bool {
135        matches!(self, Self::Unservable { .. })
136    }
137
138    /// Whether the caller should remove this worker from the registry.
139    ///
140    /// `true` only for [`Undeliverable::WorkerUnreachable`]. Expressed as a
141    /// method so the decision has one definition rather than a `match` repeated
142    /// at every call site, where the two arms could drift apart.
143    #[must_use]
144    pub fn deregisters_worker(&self) -> bool {
145        matches!(self, Self::WorkerUnreachable { .. })
146    }
147
148    /// The operator-facing reason, whichever variant this is.
149    #[must_use]
150    pub fn reason(&self) -> &str {
151        match self {
152            Self::WorkerUnreachable { reason }
153            | Self::DeliveryFailed { reason }
154            | Self::Unservable { reason } => reason,
155        }
156    }
157}
158
159/// The outcome of one delivery attempt to one already-chosen worker.
160#[derive(Debug)]
161pub enum TaskDelivery {
162    /// The worker took the task. For liminal this means a correlated reply
163    /// arrived; for gRPC it means the stream accepted the message.
164    Delivered,
165    /// The task was not placed with this worker, and [`Undeliverable`] says what
166    /// the caller owes the worker's registration.
167    Undeliverable(Undeliverable),
168}
169
170impl TaskDelivery {
171    /// Convenience for the common "gone" refusal.
172    #[must_use]
173    pub fn unreachable(reason: impl Into<String>) -> Self {
174        Self::Undeliverable(Undeliverable::WorkerUnreachable {
175            reason: reason.into(),
176        })
177    }
178
179    /// Convenience for the "alive but this attempt failed" refusal.
180    #[must_use]
181    pub fn failed(reason: impl Into<String>) -> Self {
182        Self::Undeliverable(Undeliverable::DeliveryFailed {
183            reason: reason.into(),
184        })
185    }
186
187    /// Convenience for the terminal "can never be placed" refusal.
188    #[must_use]
189    pub fn unservable(reason: impl Into<String>) -> Self {
190        Self::Undeliverable(Undeliverable::Unservable {
191            reason: reason.into(),
192        })
193    }
194}
195
196/// What a transport arm calls at the instant the worker HOLDS the task — the
197/// gRPC stream accepted the frame; the liminal push was acknowledged — and
198/// before it waits for any reply.
199///
200/// The one implementation in production records the attempt's lease
201/// ([`super::lease_record::LeaseHandoff`]). It is a trait rather than that type
202/// so the transport arms depend on the moment, not on what is done with it,
203/// and so a test can hand in a recorder of its own.
204#[async_trait]
205pub trait DeliveryAccepted: Send + Sync {
206    /// The worker holds the task.
207    async fn accepted(&self);
208}
209
210/// A transport that can place one task with one already-selected worker.
211///
212/// 🔴 **Selection is NOT this trait's job, and that is the point.** The worker
213/// arrives already chosen by the dispatcher's single selection — with its
214/// `Prefer`/`Pinned` tier walk and placement cache already applied — so a
215/// transport cannot select again. Two selections per placement would let the
216/// spill resolve differently from the delivery, which is the defect a composite
217/// dispatcher would have reintroduced.
218#[async_trait]
219pub trait WorkerTaskDelivery: Send + Sync + 'static {
220    /// Deliver `task` to `worker`, awaiting whatever that transport's notion of
221    /// delivery is.
222    ///
223    /// `intent` is the **caller's** abandonment condition, re-asked while a
224    /// blocking transport waits for its reply. It is an argument rather than
225    /// transport state because one of its terms — whether the outbox pass still
226    /// holds the row's claim — is unanswerable from inside a transport, and
227    /// answering it wrongly abandons every dispatcher-originated delivery at its
228    /// first poll. See [`DeliveryIntent`](super::delivery_intent::DeliveryIntent)
229    /// for the fact that decides it.
230    ///
231    /// It arrives behind an [`Arc`](std::sync::Arc) rather than as a bare
232    /// `&dyn` because the blocking transport re-asks it **on a blocking thread**:
233    /// the wait runs under `spawn_blocking`, which requires `'static`, and a
234    /// borrow cannot cross that boundary. This is a mechanical requirement of
235    /// where the predicate is evaluated, not a widening of what it may capture.
236    ///
237    /// `accepted` is called exactly once, at the instant the transport knows
238    /// the worker HOLDS the task and before it waits for any reply — the
239    /// gRPC stream accepted the frame; the liminal push was acknowledged. It is
240    /// never called for an undeliverable outcome. Production records the
241    /// attempt's lease there (WA-010 R3).
242    ///
243    /// Returns [`TaskDelivery::Delivered`] when the worker took the task, and
244    /// [`TaskDelivery::Undeliverable`] otherwise — never an error for an
245    /// ordinary refusal, because the caller's next move differs by variant and
246    /// an error type would flatten that distinction back into a comment.
247    async fn deliver(
248        &self,
249        worker: &WorkerHandle,
250        task: &ProtoActivityTask,
251        intent: &SharedDeliveryIntent,
252        accepted: &dyn DeliveryAccepted,
253    ) -> TaskDelivery;
254}