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}
112
113impl Undeliverable {
114 /// Whether the caller should remove this worker from the registry.
115 ///
116 /// `true` only for [`Undeliverable::WorkerUnreachable`]. Expressed as a
117 /// method so the decision has one definition rather than a `match` repeated
118 /// at every call site, where the two arms could drift apart.
119 #[must_use]
120 pub fn deregisters_worker(&self) -> bool {
121 matches!(self, Self::WorkerUnreachable { .. })
122 }
123
124 /// The operator-facing reason, whichever variant this is.
125 #[must_use]
126 pub fn reason(&self) -> &str {
127 match self {
128 Self::WorkerUnreachable { reason } | Self::DeliveryFailed { reason } => reason,
129 }
130 }
131}
132
133/// The outcome of one delivery attempt to one already-chosen worker.
134#[derive(Debug)]
135pub enum TaskDelivery {
136 /// The worker took the task. For liminal this means a correlated reply
137 /// arrived; for gRPC it means the stream accepted the message.
138 Delivered,
139 /// The task was not placed with this worker, and [`Undeliverable`] says what
140 /// the caller owes the worker's registration.
141 Undeliverable(Undeliverable),
142}
143
144impl TaskDelivery {
145 /// Convenience for the common "gone" refusal.
146 #[must_use]
147 pub fn unreachable(reason: impl Into<String>) -> Self {
148 Self::Undeliverable(Undeliverable::WorkerUnreachable {
149 reason: reason.into(),
150 })
151 }
152
153 /// Convenience for the "alive but this attempt failed" refusal.
154 #[must_use]
155 pub fn failed(reason: impl Into<String>) -> Self {
156 Self::Undeliverable(Undeliverable::DeliveryFailed {
157 reason: reason.into(),
158 })
159 }
160}
161
162/// What a transport arm calls at the instant the worker HOLDS the task — the
163/// gRPC stream accepted the frame; the liminal push was acknowledged — and
164/// before it waits for any reply.
165///
166/// The one implementation in production records the attempt's lease
167/// ([`super::lease_record::LeaseHandoff`]). It is a trait rather than that type
168/// so the transport arms depend on the moment, not on what is done with it,
169/// and so a test can hand in a recorder of its own.
170#[async_trait]
171pub trait DeliveryAccepted: Send + Sync {
172 /// The worker holds the task.
173 async fn accepted(&self);
174}
175
176/// A transport that can place one task with one already-selected worker.
177///
178/// 🔴 **Selection is NOT this trait's job, and that is the point.** The worker
179/// arrives already chosen by the dispatcher's single selection — with its
180/// `Prefer`/`Pinned` tier walk and placement cache already applied — so a
181/// transport cannot select again. Two selections per placement would let the
182/// spill resolve differently from the delivery, which is the defect a composite
183/// dispatcher would have reintroduced.
184#[async_trait]
185pub trait WorkerTaskDelivery: Send + Sync + 'static {
186 /// Deliver `task` to `worker`, awaiting whatever that transport's notion of
187 /// delivery is.
188 ///
189 /// `intent` is the **caller's** abandonment condition, re-asked while a
190 /// blocking transport waits for its reply. It is an argument rather than
191 /// transport state because one of its terms — whether the outbox pass still
192 /// holds the row's claim — is unanswerable from inside a transport, and
193 /// answering it wrongly abandons every dispatcher-originated delivery at its
194 /// first poll. See [`DeliveryIntent`](super::delivery_intent::DeliveryIntent)
195 /// for the fact that decides it.
196 ///
197 /// It arrives behind an [`Arc`](std::sync::Arc) rather than as a bare
198 /// `&dyn` because the blocking transport re-asks it **on a blocking thread**:
199 /// the wait runs under `spawn_blocking`, which requires `'static`, and a
200 /// borrow cannot cross that boundary. This is a mechanical requirement of
201 /// where the predicate is evaluated, not a widening of what it may capture.
202 ///
203 /// `accepted` is called exactly once, at the instant the transport knows
204 /// the worker HOLDS the task and before it waits for any reply — the
205 /// gRPC stream accepted the frame; the liminal push was acknowledged. It is
206 /// never called for an undeliverable outcome. Production records the
207 /// attempt's lease there (WA-010 R3).
208 ///
209 /// Returns [`TaskDelivery::Delivered`] when the worker took the task, and
210 /// [`TaskDelivery::Undeliverable`] otherwise — never an error for an
211 /// ordinary refusal, because the caller's next move differs by variant and
212 /// an error type would flatten that distinction back into a comment.
213 async fn deliver(
214 &self,
215 worker: &WorkerHandle,
216 task: &ProtoActivityTask,
217 intent: &SharedDeliveryIntent,
218 accepted: &dyn DeliveryAccepted,
219 ) -> TaskDelivery;
220}