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}