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