Skip to main content

aion_server/worker/
liminal_task_delivery.rs

1//! The liminal arm of the delivery seam: push the dispatch out on the worker's
2//! existing connection and block for its correlated reply.
3//!
4//! # Why this arm is the one that needed a seam
5//!
6//! Before #52 there was no way to reach it from the worker dispatcher at all: a
7//! worker with no gRPC stream sender was deregistered on the premise that its
8//! presence could only be a leak. This module is the same delivery sequence the
9//! outbox arm has always run, lifted so both callers reach one implementation.
10//!
11//! # What it does NOT own any more
12//!
13//! Two responsibilities deliberately stayed with the caller, and moving them
14//! here would break the invariants they exist to keep:
15//!
16//! - **The completion token.** One per pass, minted before the candidate walk
17//!   and revoked once when the pass places nothing. Minting per delivery would
18//!   put a second authorization beside the one a worker may still be holding for
19//!   the same attempt.
20//! - **The abandonment condition.** See
21//!   [`DeliveryIntent`](super::delivery_intent::DeliveryIntent): one of its terms
22//!   is unanswerable from inside a transport.
23
24use std::sync::Arc;
25
26use async_trait::async_trait;
27
28use aion_core::{ActivityId, RunId, WorkflowId};
29use aion_proto::ProtoActivityTask;
30
31use super::delivery_intent::SharedDeliveryIntent;
32use super::intervention::{AttemptKey, AttemptOwnerIndex};
33use super::liminal_transport::{AttemptOwnerGuard, DispatchRequest, LiminalCompletionSource};
34use super::registry::{WorkerDelivery, WorkerHandle};
35use super::task_delivery::{DeliveryAccepted, LivenessTracking, TaskDelivery, WorkerTaskDelivery};
36
37/// Delivers by pushing the dispatch out on the worker's liminal connection and
38/// blocking for the correlated reply.
39///
40/// The reply **is** the activity's completion, so this arm re-enters it through
41/// the same completion path the gRPC transport's out-of-band completion uses.
42pub struct LiminalTaskDelivery {
43    completion: Arc<LiminalCompletionSource>,
44    /// NOI-6 `attempt -> owning-worker` back-index. `None` (every non-agent
45    /// deployment) skips the binding, exactly as the outbox arm does —
46    /// intervention is then simply never offered.
47    attempt_owners: Option<AttemptOwnerIndex>,
48}
49
50impl std::fmt::Debug for LiminalTaskDelivery {
51    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        formatter
53            .debug_struct("LiminalTaskDelivery")
54            .field("attempt_owners", &self.attempt_owners.is_some())
55            .finish_non_exhaustive()
56    }
57}
58
59impl LiminalTaskDelivery {
60    /// Build the liminal delivery arm over the completion sink both transports
61    /// share.
62    #[must_use]
63    pub fn new(completion: Arc<LiminalCompletionSource>) -> Self {
64        Self {
65            completion,
66            attempt_owners: None,
67        }
68    }
69
70    /// Install the NOI-6 attempt-owner back-index, so a dispatched attempt binds
71    /// its owning worker before the push and the intervention router can resolve
72    /// the current owner of a live attempt.
73    #[must_use]
74    pub fn with_attempt_owners(mut self, attempt_owners: AttemptOwnerIndex) -> Self {
75        self.attempt_owners = Some(attempt_owners);
76        self
77    }
78}
79
80/// The identity a delivery needs, resolved from the wire task **before** any
81/// owner binding is taken.
82///
83/// 🔴 This type exists to make the ordering structural rather than positional.
84/// The owner binding is keyed on the run, so a binding taken before the run is
85/// resolved would let an intervention aimed at one continue-as-new generation
86/// resolve another generation's worker.
87///
88/// # Exactly how strong this is, measured rather than asserted
89///
90/// The binding is taken by [`Resolved::bind_owner`], a method **on the resolved
91/// identity**, so it cannot be called before that identity exists: moving the
92/// call above the resolution does not compile. That is the mutation this guards
93/// against, and it has been run.
94///
95/// It is **not** proof against a deliberate bypass. [`AttemptKey::new`] is a
96/// public constructor over four plain components, so an edit that calls
97/// [`AttemptOwnerGuard::bind`] directly with a placeholder run compiles and
98/// binds. An earlier version of this comment claimed the key "can only be built
99/// from a `Resolved`", which was false — the mutation that proved it survived
100/// both this structure and the test below.
101///
102/// The bypass is also, for the same reason, invisible at runtime: the guard
103/// releases on drop, so a binding taken before a refusal is gone again before
104/// any caller can look. What defends the property is that the natural edit does
105/// not compile and the deliberate one is a different API call, which review can
106/// see.
107struct Resolved {
108    workflow_id: WorkflowId,
109    run_id: RunId,
110    activity_id: ActivityId,
111    attempt: u32,
112}
113
114impl Resolved {
115    /// Resolve the task's identity, refusing a task with no run.
116    ///
117    /// The refusal is the pre-existing one — an activity without a run cannot be
118    /// dispatched at all, because an unfenced external effect has no generation
119    /// to belong to.
120    fn from_task(task: &ProtoActivityTask) -> Result<Self, &'static str> {
121        let workflow_id = task
122            .workflow_id
123            .clone()
124            .ok_or("task carries no workflow id")?
125            .try_into()
126            .map_err(|_| "task carries a malformed workflow id")?;
127        let run_id: RunId = task
128            .run_id
129            .clone()
130            .ok_or("activity run id is missing; refusing unfenced external effect")?
131            .try_into()
132            .map_err(|_| "task carries a malformed run id")?;
133        // Infallible: `ProtoActivityId` is the sequence position, and
134        // `ActivityId` is a newtype over it. Only its ABSENCE can be refused.
135        let activity_id: ActivityId = task
136            .activity_id
137            .ok_or("task carries no activity id")?
138            .into();
139        Ok(Self {
140            workflow_id,
141            run_id,
142            activity_id,
143            attempt: task.attempt,
144        })
145    }
146
147    /// The intervention key for this attempt, carrying the **resolved** run.
148    fn attempt_key(&self) -> AttemptKey {
149        AttemptKey::new(
150            self.workflow_id.clone(),
151            self.run_id.clone(),
152            self.activity_id.clone(),
153            self.attempt,
154        )
155    }
156
157    /// Bind this attempt's owner, returning the guard that releases it.
158    ///
159    /// 🔴 A method on the **resolved** identity on purpose. The binding is keyed
160    /// on the run, so taking it before the run is resolved is the defect; making
161    /// it a method on `Resolved` means the call cannot be moved above the
162    /// resolution — there is no receiver for it there, and the lift does not
163    /// compile. See this type's docs for what that does and does not prove.
164    fn bind_owner(
165        &self,
166        owners: Option<&AttemptOwnerIndex>,
167        worker: super::registry::WorkerId,
168    ) -> Option<AttemptOwnerGuard> {
169        owners.map(|owners| AttemptOwnerGuard::bind(owners.clone(), self.attempt_key(), worker))
170    }
171
172    /// The outbox ordinal this activity id was derived from.
173    ///
174    /// [`ActivityId`] is a newtype over the scheduling sequence position, so
175    /// `from_sequence_position` is exactly invertible and the ordinal the wire
176    /// needs round-trips without the row being present.
177    fn ordinal(&self) -> u64 {
178        self.activity_id.sequence_position()
179    }
180}
181
182/// Build the liminal wire request from the already-built task.
183///
184/// Every field is carried from the task except `heartbeat_window_ms`, which the
185/// task does not have — see [`LivenessTracking`] for why that assignment is a named
186/// value rather than a bare zero.
187fn request_for_task(task: &ProtoActivityTask, resolved: &Resolved) -> DispatchRequest {
188    DispatchRequest {
189        activity_type: task.activity_type.clone(),
190        workflow_id: resolved.workflow_id.clone(),
191        ordinal: resolved.ordinal(),
192        run_id: Some(resolved.run_id.clone()),
193        completion_token: task.completion_token.clone(),
194        idempotency_key: task.idempotency_key.clone(),
195        input: task
196            .input
197            .as_ref()
198            .map(|payload| payload.bytes.clone())
199            .unwrap_or_default(),
200        attempt: resolved.attempt,
201        labels: task
202            .labels
203            .iter()
204            .map(|(k, v)| (k.clone(), v.clone()))
205            .collect(),
206        heartbeat_window_ms: LivenessTracking::NotTrackedPerTask.heartbeat_window_ms(),
207    }
208}
209
210#[async_trait]
211impl WorkerTaskDelivery for LiminalTaskDelivery {
212    async fn deliver(
213        &self,
214        worker: &WorkerHandle,
215        task: &ProtoActivityTask,
216        intent: &SharedDeliveryIntent,
217        accepted: &dyn DeliveryAccepted,
218    ) -> TaskDelivery {
219        // Identity is resolved FIRST — before the transport is even consulted —
220        // and the binding below is a method on what this produces, so it cannot
221        // be moved above this line.
222        //
223        // Ahead of the transport check deliberately. A task with no run is
224        // malformed **regardless of which worker it was aimed at**, so refusing
225        // it with "not delivered over liminal" would name the wrong defect and
226        // send an operator after the wrong remedy.
227        let resolved = match Resolved::from_task(task) {
228            Ok(resolved) => resolved,
229            Err(reason) => return TaskDelivery::failed(reason),
230        };
231
232        let delivery = match worker.delivery() {
233            WorkerDelivery::Liminal(delivery) => delivery.clone(),
234            WorkerDelivery::Grpc(_) => {
235                // A caller chose the wrong transport for the worker it selected.
236                // The worker is alive and correctly registered, so its
237                // registration stands — this is #52's defect refused at its
238                // mirror site.
239                return TaskDelivery::failed(
240                    "selected worker is not delivered over liminal; the liminal transport cannot \
241                     reach it",
242                );
243            }
244        };
245
246        // NOI-6: bind this attempt's owner BEFORE the push, so an intervention
247        // that races the dispatch resolves the worker. The guard releases on
248        // every exit path (reply, error, panic) so the index never keeps a
249        // finished attempt.
250        let _owner_guard = resolved.bind_owner(self.attempt_owners.as_ref(), worker.id());
251
252        let request = request_for_task(task, &resolved);
253        // The push and the wait are two steps on purpose: the moment the push
254        // is acknowledged the worker HOLDS the attempt, and the lease is
255        // recorded THERE — the reply this arm then waits for IS the completion,
256        // so a lease recorded after the wait would land after the terminal it
257        // exists to precede (WA-010 R3). Both steps are blocking, thread-based
258        // liminal calls; each runs off the async runtime so a long-running
259        // activity cannot starve a runtime worker. The caller's intent is
260        // re-asked at every poll boundary of the wait, which is why it arrives
261        // behind an `Arc`.
262        let push_delivery = delivery.clone();
263        let pushed =
264            tokio::task::spawn_blocking(move || push_delivery.push_dispatch(&request)).await;
265        let awaiter = match pushed {
266            Ok(Ok(awaiter)) => awaiter,
267            Ok(Err(error)) => {
268                return TaskDelivery::failed(format!("liminal dispatch failed: {error}"));
269            }
270            Err(error) => {
271                return TaskDelivery::failed(format!("dispatch task join failed: {error}"));
272            }
273        };
274        accepted.accepted().await;
275        let waiting_intent = Arc::clone(intent);
276        let dispatched = tokio::task::spawn_blocking(move || {
277            super::liminal_transport::receive_bridge_reply(&awaiter, || {
278                waiting_intent.still_wanted()
279            })
280        })
281        .await;
282
283        let response = match dispatched {
284            Ok(Ok(Some(response))) => response,
285            Ok(Ok(None)) => {
286                // The caller withdrew while the delivery waited. The worker is
287                // alive and correctly registered; only this pass gave up, and a
288                // late reply is discarded by the fences.
289                return TaskDelivery::failed("delivery wait abandoned before worker reply");
290            }
291            Ok(Err(error)) => {
292                return TaskDelivery::failed(format!("liminal dispatch failed: {error}"));
293            }
294            Err(error) => {
295                return TaskDelivery::failed(format!("dispatch task join failed: {error}"));
296            }
297        };
298
299        // Re-enter the worker's result through the SAME completion path the gRPC
300        // transport uses; terminal dedup applies unchanged.
301        //
302        // 🔴 A failure HERE is the one outcome whose name reads narrower than its
303        // meaning: the worker took the task, executed it, and replied — and the
304        // reply did not record. `Delivered` would be wrong (the caller would
305        // settle the work without its result) and `WorkerUnreachable` would be
306        // wrong twice over (the worker is demonstrably alive, and deregistering
307        // it would destroy a healthy registration). `DeliveryFailed` carries the
308        // right obligations — registration stands, the caller withdraws its
309        // token — which is what the type encodes and what the outbox arm has
310        // always done here.
311        if let Err(error) = self.completion.deliver(&response) {
312            return TaskDelivery::failed(format!(
313                "worker replied but the completion could not be recorded: {error}"
314            ));
315        }
316        TaskDelivery::Delivered
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use crate::worker::registry::RegistrationOptions;
323
324    /// A delivery that records whether its accept point was reached, so a
325    /// refusal can be pinned as never having handed the attempt over.
326    #[derive(Default)]
327    struct NeverAccepted {
328        reached: std::sync::atomic::AtomicBool,
329    }
330
331    #[async_trait::async_trait]
332    impl super::DeliveryAccepted for NeverAccepted {
333        async fn accepted(&self) {
334            self.reached
335                .store(true, std::sync::atomic::Ordering::SeqCst);
336        }
337    }
338    use std::sync::Arc;
339
340    use aion_core::{ActivityId, InterventionCapabilities, RunId, WorkflowId};
341    use aion_proto::{ProtoActivityId, ProtoActivityTask, ProtoWorkflowId};
342    use uuid::Uuid;
343
344    use crate::error::ServerError;
345    use crate::worker::bridge::OutboxDeliveryCallback;
346    use crate::worker::delivery_intent::{AlwaysWanted, SharedDeliveryIntent};
347    use crate::worker::intervention::AttemptOwnerIndex;
348    use crate::worker::liminal_transport::LiminalCompletionSource;
349    use crate::worker::registry::{ConnectedWorkerRegistry, WorkerDelivery};
350    use crate::worker::task_delivery::{TaskDelivery, WorkerTaskDelivery};
351
352    use super::{LiminalTaskDelivery, Resolved};
353
354    /// The completion sink is never reached by these tests — every one of them
355    /// refuses before a worker is pushed to — so both methods report "no live
356    /// run" if they are ever called, rather than pretending to succeed.
357    struct NoopCallback;
358
359    impl OutboxDeliveryCallback for NoopCallback {
360        fn deliver_completion(
361            &self,
362            _workflow_id: &WorkflowId,
363            _activity_id: &ActivityId,
364            _run_id: Option<&RunId>,
365            _result: String,
366        ) -> Result<bool, ServerError> {
367            Ok(false)
368        }
369        fn deliver_failure(
370            &self,
371            _workflow_id: &WorkflowId,
372            _activity_id: &ActivityId,
373            _run_id: Option<&RunId>,
374            _reason: String,
375        ) -> Result<bool, ServerError> {
376            Ok(false)
377        }
378    }
379
380    const WORKFLOW: u128 = 0x51;
381
382    /// A task carrying everything a delivery needs EXCEPT a run id.
383    fn task_without_a_run() -> ProtoActivityTask {
384        ProtoActivityTask {
385            workflow_id: Some(ProtoWorkflowId::from(WorkflowId::new(Uuid::from_u128(
386                WORKFLOW,
387            )))),
388            activity_id: Some(ProtoActivityId::from(ActivityId::from_sequence_position(3))),
389            activity_type: String::from("agent"),
390            input: None,
391            attempt: 1,
392            labels: std::collections::HashMap::new(),
393            // The whole point of this fixture.
394            run_id: None,
395            completion_token: String::from("token"),
396            idempotency_key: String::from("key"),
397        }
398    }
399
400    fn liminal_delivery(owners: &AttemptOwnerIndex) -> LiminalTaskDelivery {
401        LiminalTaskDelivery::new(Arc::new(LiminalCompletionSource::new(Arc::new(
402            NoopCallback,
403        ))))
404        .with_attempt_owners(owners.clone())
405    }
406
407    /// 🔴 NAMED PROPERTY: the run is resolved BEFORE any attempt owner is bound.
408    ///
409    /// # What breaks without it
410    ///
411    /// The owner binding is keyed on the run. A binding taken before the run is
412    /// resolved would let an intervention aimed at one continue-as-new
413    /// generation resolve a DIFFERENT generation's worker — an operator
414    /// stopping run A reaching the worker executing run B.
415    ///
416    /// # 🔴 What this test does and does NOT witness — measured by mutation
417    ///
418    /// It witnesses the **refusal**: a run-less task is rejected, is diagnosed
419    /// by its missing run rather than by the transport, does not deregister the
420    /// worker, and leaves no owner binding behind.
421    ///
422    /// It does **not** witness the ordering, and an earlier version of this
423    /// comment claimed it did. The mutation — lifting the bind above the
424    /// resolution with a placeholder run — was run, and **this test stayed
425    /// green**, because [`AttemptOwnerGuard`] releases on drop: a binding taken
426    /// before a refusal is already gone by the time any caller can look. There
427    /// is no observation window on this path, and there cannot be one.
428    ///
429    /// The ordering is defended structurally instead: the bind is a method on
430    /// [`Resolved`], so the natural lift does not compile. That claim was also
431    /// checked by mutation rather than asserted — see [`Resolved`] for exactly
432    /// how far it goes, including the bypass it does not stop.
433    #[tokio::test]
434    async fn run_resolution_precedes_attempt_owner_binding()
435    -> Result<(), Box<dyn std::error::Error>> {
436        let owners = AttemptOwnerIndex::new();
437        let delivery = liminal_delivery(&owners);
438
439        // A real registration yields a real WorkerId; no id here is fabricated.
440        let registry = ConnectedWorkerRegistry::default();
441        let (sender, _receiver) = tokio::sync::mpsc::channel(1);
442        let types = [String::from("agent")];
443        let registration = registry.register_delivery(
444            [String::from("default")],
445            String::from("default"),
446            None,
447            types.iter(),
448            WorkerDelivery::Grpc(sender),
449            RegistrationOptions::identified("agent-1")
450                .with_intervention_capabilities(InterventionCapabilities::none()),
451        )?;
452        let worker_id = registration
453            .worker_id()
454            .ok_or("a registration must assign a worker id")?;
455        let worker = registry
456            .worker_by_id(worker_id)?
457            .ok_or("the worker just registered must be readable")?;
458
459        let intent: SharedDeliveryIntent = Arc::new(AlwaysWanted);
460        let accepted = NeverAccepted::default();
461        let outcome = delivery
462            .deliver(&worker, &task_without_a_run(), &intent, &accepted)
463            .await;
464        assert!(
465            !accepted.reached.load(std::sync::atomic::Ordering::SeqCst),
466            "a task refused by its missing run must never reach the accept point"
467        );
468
469        match outcome {
470            TaskDelivery::Delivered => {
471                return Err("a task with no run must never be delivered".into());
472            }
473            TaskDelivery::Undeliverable(undeliverable) => {
474                // The refusal names the RUN, not the transport: identity is
475                // resolved before the transport is consulted, so a run-less task
476                // is diagnosed as run-less whichever worker it was aimed at.
477                assert!(
478                    undeliverable.reason().contains("run id is missing"),
479                    "a run-less task must be refused BY ITS MISSING RUN, not by the transport; \
480                     got: {}",
481                    undeliverable.reason()
482                );
483                assert!(
484                    !undeliverable.deregisters_worker(),
485                    "a malformed task is not evidence that the worker is gone"
486                );
487            }
488        }
489
490        // 🔴 THE PROPERTY, as far as this test can witness it: nothing is bound
491        // when the run refuses. Note what this does NOT say — an earlier
492        // version of this comment claimed nothing COULD be bound because an
493        // `AttemptKey` is only constructible from a resolved identity, and that
494        // was false. `AttemptKey::new` is public over four plain components.
495        // See this test's docs and [`Resolved`] for what actually holds.
496        let bound = owners.attempts_for_workflow(&WorkflowId::new(Uuid::from_u128(WORKFLOW)));
497        assert!(
498            bound.is_empty(),
499            "the attempt-owner index must be untouched when the run refused; a binding here \
500             means the bind was lifted above the run resolution, and an intervention could \
501             resolve the wrong generation's worker. Found: {bound:?}"
502        );
503        Ok(())
504    }
505
506    /// The property's SECOND witness, and the one that outlives a refactor of
507    /// the call site: identity resolution refuses a run-less task, so no
508    /// `Resolved` is produced — and [`Resolved::bind_owner`], the only binding
509    /// path the delivery takes, is a method on it.
510    ///
511    /// 🔴 That is a claim about the CALL SITE, not about the key type. An
512    /// earlier version said an `AttemptKey` "cannot be built without" a
513    /// `Resolved`; it can, and the mutation that proved it survived. What this
514    /// witnesses is that resolution refuses first.
515    ///
516    /// Kept beside the behavioural test deliberately. The refusal and the
517    /// structure are different claims, and a change that removes one should
518    /// still meet the other.
519    #[test]
520    fn identity_resolution_refuses_a_task_with_no_run() -> Result<(), Box<dyn std::error::Error>> {
521        let Err(refusal) = Resolved::from_task(&task_without_a_run()) else {
522            return Err("a task with no run must not resolve".into());
523        };
524        assert!(
525            refusal.contains("run id is missing"),
526            "the refusal must name the run so an operator is sent to the right remedy: {refusal}"
527        );
528        Ok(())
529    }
530}