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 /// The liveness tracker and registry this arm retires a completed dispatch
49 /// from, when the dispatcher above it tracks its dispatches.
50 ///
51 /// The liminal delivery is the ONLY seam that can do this. Unlike gRPC —
52 /// where the frame is queued, `Delivered` is returned, and the result comes
53 /// back later on the worker's stream for the session loop to untrack — this
54 /// arm waits for the reply inline and routes the completion itself. There is
55 /// no later moment and no other holder: if this does not retire the entry,
56 /// nothing does, and the dispatch stays counted against the worker's
57 /// capacity for the life of the registration.
58 ///
59 /// `None` where the dispatcher above tracks nothing, which is every
60 /// in-process façade and any test whose subject is not capacity.
61 completion_tracking: Option<CompletionTracking>,
62}
63
64/// What the liminal arm needs to retire a completed dispatch from the liveness
65/// tracker: the tracker itself and the registry whose capacity count it feeds.
66#[derive(Clone)]
67struct CompletionTracking {
68 heartbeat_tracker: crate::worker::heartbeat::HeartbeatTracker,
69 registry: crate::worker::ConnectedWorkerRegistry,
70}
71
72impl std::fmt::Debug for LiminalTaskDelivery {
73 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 formatter
75 .debug_struct("LiminalTaskDelivery")
76 .field("attempt_owners", &self.attempt_owners.is_some())
77 .finish_non_exhaustive()
78 }
79}
80
81impl LiminalTaskDelivery {
82 /// Build the liminal delivery arm over the completion sink both transports
83 /// share.
84 #[must_use]
85 pub fn new(completion: Arc<LiminalCompletionSource>) -> Self {
86 Self {
87 completion,
88 attempt_owners: None,
89 completion_tracking: None,
90 }
91 }
92
93 /// Retire a completed dispatch from the liveness tracker when the reply
94 /// lands, so this transport's completion removes its tracked entry exactly
95 /// as the gRPC session loop's result arm does.
96 ///
97 /// Required wherever the dispatcher above this arm tracks its dispatches
98 /// (`ActivityDispatcher::with_heartbeat_tracker`). Without it the entry made
99 /// before the push is never removed on this transport, and the worker's
100 /// advertised capacity is consumed permanently, one slot per delivery.
101 #[must_use]
102 pub fn with_completion_tracking(
103 mut self,
104 heartbeat_tracker: crate::worker::heartbeat::HeartbeatTracker,
105 registry: crate::worker::ConnectedWorkerRegistry,
106 ) -> Self {
107 self.completion_tracking = Some(CompletionTracking {
108 heartbeat_tracker,
109 registry,
110 });
111 self
112 }
113
114 /// Install the NOI-6 attempt-owner back-index, so a dispatched attempt binds
115 /// its owning worker before the push and the intervention router can resolve
116 /// the current owner of a live attempt.
117 #[must_use]
118 pub fn with_attempt_owners(mut self, attempt_owners: AttemptOwnerIndex) -> Self {
119 self.attempt_owners = Some(attempt_owners);
120 self
121 }
122}
123
124/// Untracks a liminal dispatch if this delivery is ABANDONED before its
125/// completion is routed.
126///
127/// The liminal arm waits for the worker's reply inline, so its future can be
128/// cancelled mid-wait — a caller that withdraws, or a dispatcher whose runtime is
129/// dropped out from under it during a failover. A cancelled future does not
130/// return, so neither the delivery's own completion path nor the dispatcher's
131/// non-`Delivered` arm ever runs, and the entry tracked before the push would
132/// simply stay: a slot the worker never gets back, on a registry that in a
133/// multi-server deployment can outlive the dispatcher that made the entry.
134///
135/// Disarmed the moment the completion has retired the entry itself, so the
136/// normal path does not untrack twice (it would be idempotent by key anyway —
137/// this only avoids the pointless second call).
138struct AbandonedDispatchGuard<'a> {
139 tracking: &'a CompletionTracking,
140 worker_id: crate::worker::WorkerId,
141 workflow_id: &'a WorkflowId,
142 activity_id: &'a ActivityId,
143 armed: bool,
144}
145
146impl AbandonedDispatchGuard<'_> {
147 /// The completion retired the entry; this guard has nothing left to do.
148 fn disarm(&mut self) {
149 self.armed = false;
150 }
151}
152
153impl Drop for AbandonedDispatchGuard<'_> {
154 fn drop(&mut self) {
155 if !self.armed {
156 return;
157 }
158 let retired = crate::worker::bridge::clear_completed_task_tracking(
159 &self.tracking.heartbeat_tracker,
160 &self.tracking.registry,
161 self.worker_id,
162 self.workflow_id,
163 self.activity_id,
164 );
165 if retired {
166 tracing::warn!(
167 worker_id = ?self.worker_id,
168 workflow_id = %self.workflow_id,
169 activity_id = %self.activity_id,
170 "liminal delivery was abandoned before the worker replied; its tracked slot has \
171 been returned and the row re-drives"
172 );
173 }
174 }
175}
176
177/// The identity a delivery needs, resolved from the wire task **before** any
178/// owner binding is taken.
179///
180/// 🔴 This type exists to make the ordering structural rather than positional.
181/// The owner binding is keyed on the run, so a binding taken before the run is
182/// resolved would let an intervention aimed at one continue-as-new generation
183/// resolve another generation's worker.
184///
185/// # Exactly how strong this is, measured rather than asserted
186///
187/// The binding is taken by [`Resolved::bind_owner`], a method **on the resolved
188/// identity**, so it cannot be called before that identity exists: moving the
189/// call above the resolution does not compile. That is the mutation this guards
190/// against, and it has been run.
191///
192/// It is **not** proof against a deliberate bypass. [`AttemptKey::new`] is a
193/// public constructor over four plain components, so an edit that calls
194/// [`AttemptOwnerGuard::bind`] directly with a placeholder run compiles and
195/// binds. An earlier version of this comment claimed the key "can only be built
196/// from a `Resolved`", which was false — the mutation that proved it survived
197/// both this structure and the test below.
198///
199/// The bypass is also, for the same reason, invisible at runtime: the guard
200/// releases on drop, so a binding taken before a refusal is gone again before
201/// any caller can look. What defends the property is that the natural edit does
202/// not compile and the deliberate one is a different API call, which review can
203/// see.
204struct Resolved {
205 workflow_id: WorkflowId,
206 run_id: RunId,
207 activity_id: ActivityId,
208 attempt: u32,
209}
210
211impl Resolved {
212 /// Resolve the task's identity, refusing a task with no run.
213 ///
214 /// The refusal is the pre-existing one — an activity without a run cannot be
215 /// dispatched at all, because an unfenced external effect has no generation
216 /// to belong to.
217 fn from_task(task: &ProtoActivityTask) -> Result<Self, &'static str> {
218 let workflow_id = task
219 .workflow_id
220 .clone()
221 .ok_or("task carries no workflow id")?
222 .try_into()
223 .map_err(|_| "task carries a malformed workflow id")?;
224 let run_id: RunId = task
225 .run_id
226 .clone()
227 .ok_or("activity run id is missing; refusing unfenced external effect")?
228 .try_into()
229 .map_err(|_| "task carries a malformed run id")?;
230 // Infallible: `ProtoActivityId` is the sequence position, and
231 // `ActivityId` is a newtype over it. Only its ABSENCE can be refused.
232 let activity_id: ActivityId = task
233 .activity_id
234 .ok_or("task carries no activity id")?
235 .into();
236 Ok(Self {
237 workflow_id,
238 run_id,
239 activity_id,
240 attempt: task.attempt,
241 })
242 }
243
244 /// The intervention key for this attempt, carrying the **resolved** run.
245 fn attempt_key(&self) -> AttemptKey {
246 AttemptKey::new(
247 self.workflow_id.clone(),
248 self.run_id.clone(),
249 self.activity_id.clone(),
250 self.attempt,
251 )
252 }
253
254 /// Bind this attempt's owner, returning the guard that releases it.
255 ///
256 /// 🔴 A method on the **resolved** identity on purpose. The binding is keyed
257 /// on the run, so taking it before the run is resolved is the defect; making
258 /// it a method on `Resolved` means the call cannot be moved above the
259 /// resolution — there is no receiver for it there, and the lift does not
260 /// compile. See this type's docs for what that does and does not prove.
261 fn bind_owner(
262 &self,
263 owners: Option<&AttemptOwnerIndex>,
264 worker: super::registry::WorkerId,
265 ) -> Option<AttemptOwnerGuard> {
266 owners.map(|owners| AttemptOwnerGuard::bind(owners.clone(), self.attempt_key(), worker))
267 }
268
269 /// The outbox ordinal this activity id was derived from.
270 ///
271 /// [`ActivityId`] is a newtype over the scheduling sequence position, so
272 /// `from_sequence_position` is exactly invertible and the ordinal the wire
273 /// needs round-trips without the row being present.
274 fn ordinal(&self) -> u64 {
275 self.activity_id.sequence_position()
276 }
277}
278
279/// Build the liminal wire request from the already-built task.
280///
281/// Every field is carried from the task except `heartbeat_window_ms`, which the
282/// task does not have — see [`LivenessTracking`] for why that assignment is a named
283/// value rather than a bare zero.
284/// The [`TaskDelivery`] a typed liminal push or reply failure becomes, BY
285/// CLASS. An unservable frame (proved larger than the connection's whole
286/// outbound buffer) is the terminal [`Undeliverable::Unservable`]; every other
287/// failure stays the alive-worker [`Undeliverable::DeliveryFailed`] this arm
288/// has always reported. The class must survive this boundary: the candidate
289/// walk and the outbox dispatcher read the TYPE, and a refusal flattened to a
290/// string here was re-pushed once per attempt in the budget, recording one
291/// lease per push for a step that could never run.
292fn undelivered_by(error: &crate::error::ServerError) -> TaskDelivery {
293 if error.is_worker_dispatch_unservable() {
294 TaskDelivery::unservable(format!("liminal dispatch is unservable: {error}"))
295 } else {
296 TaskDelivery::failed(format!("liminal dispatch failed: {error}"))
297 }
298}
299
300fn request_for_task(task: &ProtoActivityTask, resolved: &Resolved) -> DispatchRequest {
301 DispatchRequest {
302 activity_type: task.activity_type.clone(),
303 workflow_id: resolved.workflow_id.clone(),
304 ordinal: resolved.ordinal(),
305 run_id: Some(resolved.run_id.clone()),
306 completion_token: task.completion_token.clone(),
307 idempotency_key: task.idempotency_key.clone(),
308 input: task
309 .input
310 .as_ref()
311 .map(|payload| payload.bytes.clone())
312 .unwrap_or_default(),
313 attempt: resolved.attempt,
314 labels: task
315 .labels
316 .iter()
317 .map(|(k, v)| (k.clone(), v.clone()))
318 .collect(),
319 heartbeat_window_ms: LivenessTracking::NotTrackedPerTask.heartbeat_window_ms(),
320 }
321}
322
323#[async_trait]
324impl WorkerTaskDelivery for LiminalTaskDelivery {
325 async fn deliver(
326 &self,
327 worker: &WorkerHandle,
328 task: &ProtoActivityTask,
329 intent: &SharedDeliveryIntent,
330 accepted: &dyn DeliveryAccepted,
331 ) -> TaskDelivery {
332 // Identity is resolved FIRST — before the transport is even consulted —
333 // and the binding below is a method on what this produces, so it cannot
334 // be moved above this line.
335 //
336 // Ahead of the transport check deliberately. A task with no run is
337 // malformed **regardless of which worker it was aimed at**, so refusing
338 // it with "not delivered over liminal" would name the wrong defect and
339 // send an operator after the wrong remedy.
340 let resolved = match Resolved::from_task(task) {
341 Ok(resolved) => resolved,
342 Err(reason) => return TaskDelivery::failed(reason),
343 };
344
345 let delivery = match worker.delivery() {
346 WorkerDelivery::Liminal(delivery) => delivery.clone(),
347 WorkerDelivery::Grpc(_) => {
348 // A caller chose the wrong transport for the worker it selected.
349 // The worker is alive and correctly registered, so its
350 // registration stands — this is #52's defect refused at its
351 // mirror site.
352 return TaskDelivery::failed(
353 "selected worker is not delivered over liminal; the liminal transport cannot \
354 reach it",
355 );
356 }
357 };
358
359 // NOI-6: bind this attempt's owner BEFORE the push, so an intervention
360 // that races the dispatch resolves the worker. The guard releases on
361 // every exit path (reply, error, panic) so the index never keeps a
362 // finished attempt.
363 let _owner_guard = resolved.bind_owner(self.attempt_owners.as_ref(), worker.id());
364
365 let request = request_for_task(task, &resolved);
366 // The push and the wait are two steps on purpose: the moment the push
367 // is acknowledged the worker HOLDS the attempt, and the lease is
368 // recorded THERE — the reply this arm then waits for IS the completion,
369 // so a lease recorded after the wait would land after the terminal it
370 // exists to precede (WA-010 R3). Both steps are blocking, thread-based
371 // liminal calls; each runs off the async runtime so a long-running
372 // activity cannot starve a runtime worker. The caller's intent is
373 // re-asked at every poll boundary of the wait, which is why it arrives
374 // behind an `Arc`.
375 // ARMED BEFORE THE PUSH. The tracker entry already exists (the caller
376 // tracked before handing the task here), so its cleanup obligation
377 // exists from this line — and the push itself is the FIRST await this
378 // future can be cancelled at. A cancelled future runs no arm of any
379 // match below it, so a guard armed after the push left exactly one
380 // uncovered window: a cancellation while the push was in flight
381 // leaked the tracked entry. The push-failure returns disarm explicitly:
382 // nothing was delivered, and the caller's non-`Delivered` arm untracks.
383 // The reply-wait `None`/`Err`, the join error, and the completion-record
384 // failure do NOT disarm — on those the guard fires on drop AND the
385 // caller untracks, and that double untrack is safe only because
386 // `TaskTracker::complete_task` frees a slot solely for the removal
387 // that found the entry (`was_tracked`); the second finds nothing and
388 // decrements nothing. That idempotence is load-bearing here: an
389 // unconditional decrement in `complete_task` would re-open an
390 // over-push through this path.
391 let mut abandoned =
392 self.completion_tracking
393 .as_ref()
394 .map(|tracking| AbandonedDispatchGuard {
395 tracking,
396 worker_id: worker.id(),
397 workflow_id: &resolved.workflow_id,
398 activity_id: &resolved.activity_id,
399 armed: true,
400 });
401 let push_delivery = delivery.clone();
402 let pushed =
403 tokio::task::spawn_blocking(move || push_delivery.push_dispatch(&request)).await;
404 let awaiter = match pushed {
405 Ok(Ok(awaiter)) => awaiter,
406 Ok(Err(error)) => {
407 if let Some(guard) = abandoned.as_mut() {
408 guard.disarm();
409 }
410 return undelivered_by(&error);
411 }
412 Err(error) => {
413 if let Some(guard) = abandoned.as_mut() {
414 guard.disarm();
415 }
416 return TaskDelivery::failed(format!("dispatch task join failed: {error}"));
417 }
418 };
419 accepted.accepted().await;
420 let waiting_intent = Arc::clone(intent);
421 let dispatched = tokio::task::spawn_blocking(move || {
422 super::liminal_transport::receive_bridge_reply(&awaiter, || {
423 waiting_intent.still_wanted()
424 })
425 })
426 .await;
427
428 let response = match dispatched {
429 Ok(Ok(Some(response))) => response,
430 Ok(Ok(None)) => {
431 // The caller withdrew while the delivery waited. The worker is
432 // alive and correctly registered; only this pass gave up, and a
433 // late reply is discarded by the fences.
434 return TaskDelivery::failed("delivery wait abandoned before worker reply");
435 }
436 Ok(Err(error)) => return undelivered_by(&error),
437 Err(error) => {
438 return TaskDelivery::failed(format!("dispatch task join failed: {error}"));
439 }
440 };
441
442 // Re-enter the worker's result through the SAME completion path the gRPC
443 // transport uses; terminal dedup applies unchanged.
444 //
445 // 🔴 A failure HERE is the one outcome whose name reads narrower than its
446 // meaning: the worker took the task, executed it, and replied — and the
447 // reply did not record. `Delivered` would be wrong (the caller would
448 // settle the work without its result) and `WorkerUnreachable` would be
449 // wrong twice over (the worker is demonstrably alive, and deregistering
450 // it would destroy a healthy registration). `DeliveryFailed` carries the
451 // right obligations — registration stands, the caller withdraws its
452 // token — which is what the type encodes and what the outbox arm has
453 // always done here.
454 if let Err(error) = self.completion.deliver(&response) {
455 return TaskDelivery::failed(format!(
456 "worker replied but the completion could not be recorded: {error}"
457 ));
458 }
459 // THE COMPLETION RETIRES THE TRACKED ENTRY, here and nowhere else.
460 //
461 // This transport routes the completion itself, inline, before it returns
462 // — so unlike gRPC there is no later stream frame for a session loop to
463 // untrack on. The dispatcher above tracks before the push (the bridge's
464 // `track_then_send` ordering); this is the matching half. Skipping it
465 // leaves a phantom entry that nothing nominates, nothing logs, and
466 // nothing removes: the worker's capacity shrinks by one per delivery and
467 // `in_flight_count` never returns to zero.
468 //
469 // Idempotent by key — `clear_completed_task_tracking` reports whether it
470 // actually retired anything — so if some other holder ever sees the same
471 // completion this is a no-op rather than a second decrement.
472 if let Some(tracking) = &self.completion_tracking {
473 let _ = crate::worker::bridge::clear_completed_task_tracking(
474 &tracking.heartbeat_tracker,
475 &tracking.registry,
476 worker.id(),
477 &resolved.workflow_id,
478 &resolved.activity_id,
479 );
480 }
481 if let Some(guard) = abandoned.as_mut() {
482 guard.disarm();
483 }
484 TaskDelivery::Delivered
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use crate::worker::registry::RegistrationOptions;
491
492 /// A delivery that records whether its accept point was reached, so a
493 /// refusal can be pinned as never having handed the attempt over.
494 #[derive(Default)]
495 struct NeverAccepted {
496 reached: std::sync::atomic::AtomicBool,
497 }
498
499 #[async_trait::async_trait]
500 impl super::DeliveryAccepted for NeverAccepted {
501 async fn accepted(&self) {
502 self.reached
503 .store(true, std::sync::atomic::Ordering::SeqCst);
504 }
505 }
506 use std::sync::Arc;
507
508 use aion_core::{ActivityId, InterventionCapabilities, RunId, WorkflowId};
509 use aion_proto::{ProtoActivityId, ProtoActivityTask, ProtoWorkflowId};
510 use uuid::Uuid;
511
512 use crate::error::ServerError;
513 use crate::worker::bridge::OutboxDeliveryCallback;
514 use crate::worker::delivery_intent::{AlwaysWanted, SharedDeliveryIntent};
515 use crate::worker::intervention::AttemptOwnerIndex;
516 use crate::worker::liminal_transport::LiminalCompletionSource;
517 use crate::worker::registry::{ConnectedWorkerRegistry, WorkerDelivery};
518 use crate::worker::task_delivery::{TaskDelivery, WorkerTaskDelivery};
519
520 use super::{LiminalTaskDelivery, Resolved};
521
522 /// The completion sink is never reached by these tests — every one of them
523 /// refuses before a worker is pushed to — so both methods report "no live
524 /// run" if they are ever called, rather than pretending to succeed.
525 struct NoopCallback;
526
527 impl OutboxDeliveryCallback for NoopCallback {
528 fn deliver_completion(
529 &self,
530 _workflow_id: &WorkflowId,
531 _activity_id: &ActivityId,
532 _run_id: Option<&RunId>,
533 _result: String,
534 ) -> Result<bool, ServerError> {
535 Ok(false)
536 }
537 fn deliver_failure(
538 &self,
539 _workflow_id: &WorkflowId,
540 _activity_id: &ActivityId,
541 _run_id: Option<&RunId>,
542 _reason: String,
543 ) -> Result<bool, ServerError> {
544 Ok(false)
545 }
546 }
547
548 const WORKFLOW: u128 = 0x51;
549
550 /// A task carrying everything a delivery needs EXCEPT a run id.
551 fn task_without_a_run() -> ProtoActivityTask {
552 ProtoActivityTask {
553 workflow_id: Some(ProtoWorkflowId::from(WorkflowId::new(Uuid::from_u128(
554 WORKFLOW,
555 )))),
556 activity_id: Some(ProtoActivityId::from(ActivityId::from_sequence_position(3))),
557 activity_type: String::from("agent"),
558 input: None,
559 attempt: 1,
560 labels: std::collections::HashMap::new(),
561 // The whole point of this fixture.
562 run_id: None,
563 completion_token: String::from("token"),
564 idempotency_key: String::from("key"),
565 }
566 }
567
568 fn liminal_delivery(owners: &AttemptOwnerIndex) -> LiminalTaskDelivery {
569 LiminalTaskDelivery::new(Arc::new(LiminalCompletionSource::new(Arc::new(
570 NoopCallback,
571 ))))
572 .with_attempt_owners(owners.clone())
573 }
574
575 /// 🔴 NAMED PROPERTY: the run is resolved BEFORE any attempt owner is bound.
576 ///
577 /// # What breaks without it
578 ///
579 /// The owner binding is keyed on the run. A binding taken before the run is
580 /// resolved would let an intervention aimed at one continue-as-new
581 /// generation resolve a DIFFERENT generation's worker — an operator
582 /// stopping run A reaching the worker executing run B.
583 ///
584 /// # 🔴 What this test does and does NOT witness — measured by mutation
585 ///
586 /// It witnesses the **refusal**: a run-less task is rejected, is diagnosed
587 /// by its missing run rather than by the transport, does not deregister the
588 /// worker, and leaves no owner binding behind.
589 ///
590 /// It does **not** witness the ordering, and an earlier version of this
591 /// comment claimed it did. The mutation — lifting the bind above the
592 /// resolution with a placeholder run — was run, and **this test stayed
593 /// green**, because [`AttemptOwnerGuard`] releases on drop: a binding taken
594 /// before a refusal is already gone by the time any caller can look. There
595 /// is no observation window on this path, and there cannot be one.
596 ///
597 /// The ordering is defended structurally instead: the bind is a method on
598 /// [`Resolved`], so the natural lift does not compile. That claim was also
599 /// checked by mutation rather than asserted — see [`Resolved`] for exactly
600 /// how far it goes, including the bypass it does not stop.
601 #[tokio::test]
602 async fn run_resolution_precedes_attempt_owner_binding()
603 -> Result<(), Box<dyn std::error::Error>> {
604 let owners = AttemptOwnerIndex::new();
605 let delivery = liminal_delivery(&owners);
606
607 // A real registration yields a real WorkerId; no id here is fabricated.
608 let registry = ConnectedWorkerRegistry::default();
609 let (sender, _receiver) = tokio::sync::mpsc::channel(1);
610 let types = [String::from("agent")];
611 let registration = registry.register_delivery(
612 [String::from("default")],
613 String::from("default"),
614 None,
615 types.iter(),
616 WorkerDelivery::Grpc(sender),
617 RegistrationOptions::identified(
618 "agent-1",
619 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
620 )
621 .with_intervention_capabilities(InterventionCapabilities::none()),
622 )?;
623 let worker_id = registration
624 .worker_id()
625 .ok_or("a registration must assign a worker id")?;
626 let worker = registry
627 .worker_by_id(worker_id)?
628 .ok_or("the worker just registered must be readable")?;
629
630 let intent: SharedDeliveryIntent = Arc::new(AlwaysWanted);
631 let accepted = NeverAccepted::default();
632 let outcome = delivery
633 .deliver(&worker, &task_without_a_run(), &intent, &accepted)
634 .await;
635 assert!(
636 !accepted.reached.load(std::sync::atomic::Ordering::SeqCst),
637 "a task refused by its missing run must never reach the accept point"
638 );
639
640 match outcome {
641 TaskDelivery::Delivered => {
642 return Err("a task with no run must never be delivered".into());
643 }
644 TaskDelivery::Undeliverable(undeliverable) => {
645 // The refusal names the RUN, not the transport: identity is
646 // resolved before the transport is consulted, so a run-less task
647 // is diagnosed as run-less whichever worker it was aimed at.
648 assert!(
649 undeliverable.reason().contains("run id is missing"),
650 "a run-less task must be refused BY ITS MISSING RUN, not by the transport; \
651 got: {}",
652 undeliverable.reason()
653 );
654 assert!(
655 !undeliverable.deregisters_worker(),
656 "a malformed task is not evidence that the worker is gone"
657 );
658 }
659 }
660
661 // 🔴 THE PROPERTY, as far as this test can witness it: nothing is bound
662 // when the run refuses. Note what this does NOT say — an earlier
663 // version of this comment claimed nothing COULD be bound because an
664 // `AttemptKey` is only constructible from a resolved identity, and that
665 // was false. `AttemptKey::new` is public over four plain components.
666 // See this test's docs and [`Resolved`] for what actually holds.
667 let bound = owners.attempts_for_workflow(&WorkflowId::new(Uuid::from_u128(WORKFLOW)));
668 assert!(
669 bound.is_empty(),
670 "the attempt-owner index must be untouched when the run refused; a binding here \
671 means the bind was lifted above the run resolution, and an intervention could \
672 resolve the wrong generation's worker. Found: {bound:?}"
673 );
674 Ok(())
675 }
676
677 /// A task carrying everything a delivery needs, run included.
678 fn task_with_a_run() -> ProtoActivityTask {
679 ProtoActivityTask {
680 run_id: Some(aion_proto::ProtoRunId::from(RunId::new(Uuid::from_u128(
681 0x52,
682 )))),
683 ..task_without_a_run()
684 }
685 }
686
687 /// 🔴 NAMED PROPERTY: the abandonment guard is armed BEFORE the push, so a
688 /// delivery cancelled while its push is still in flight returns the slot
689 /// the caller tracked for it.
690 ///
691 /// # What breaks without it
692 ///
693 /// A guard constructed after the push leaves one uncovered window — the
694 /// push's own `await`, the FIRST point this future can be cancelled at. A
695 /// dispatcher that withdraws there (a shutdown, a caller that stops
696 /// wanting the work) runs no arm below the push, so nothing untracks the
697 /// entry made before it: the worker's advertised capacity is down one slot
698 /// permanently, on a registry that in a multi-server deployment outlives
699 /// the dispatcher that made the entry.
700 ///
701 /// # How the window is held open, deterministically
702 ///
703 /// The push runs on `spawn_blocking`. The runtime here has a blocking pool
704 /// of exactly ONE thread, and that thread is occupied by a closure parked
705 /// on a gate the test holds shut — so the push's blocking task is provably
706 /// still queued, not merely likely to be, when the delivery future is
707 /// polled once and dropped. The worker is a liminal handle over a
708 /// supervisor with no connection behind it: had the push ever run it
709 /// would have FAILED, and the failure arm disarms the guard and leaves the
710 /// slot to the caller — which is exactly why the push must not be allowed
711 /// to run for this test to say anything. The gate is opened afterwards so
712 /// the runtime shuts down cleanly.
713 ///
714 /// Measured by mutation: moving the guard's construction below the push
715 /// (a full revert of the fix) leaves the slot held and this test red.
716 #[test]
717 fn a_delivery_abandoned_during_its_push_returns_the_tracked_slot()
718 -> Result<(), Box<dyn std::error::Error>> {
719 use std::time::{Duration, Instant};
720
721 use crate::worker::envelope::CompletionToken;
722 use crate::worker::heartbeat::{HeartbeatTracker, InFlightActivity};
723 use crate::worker::liminal_transport::LiminalWorkerDelivery;
724
725 let runtime = tokio::runtime::Builder::new_current_thread()
726 .enable_all()
727 .max_blocking_threads(1)
728 .build()?;
729 runtime.block_on(async {
730 // Occupy the only blocking thread until the gate opens.
731 let (open_gate, gate) = std::sync::mpsc::channel::<()>();
732 let occupant = tokio::task::spawn_blocking(move || gate.recv());
733
734 let registry = ConnectedWorkerRegistry::default();
735 let tracker = HeartbeatTracker::new(Duration::from_secs(30));
736 let supervisor = liminal_server::server::connection::ConnectionSupervisor::new()?;
737 let types = [String::from("agent")];
738 let registration = registry.register_delivery(
739 [String::from("default")],
740 String::from("default"),
741 None,
742 types.iter(),
743 WorkerDelivery::Liminal(LiminalWorkerDelivery::new(supervisor, 7)),
744 RegistrationOptions::identified("agent-1", 1)
745 .with_intervention_capabilities(InterventionCapabilities::none()),
746 )?;
747 let worker_id = registration
748 .worker_id()
749 .ok_or("a registration must assign a worker id")?;
750 let worker = registry
751 .worker_by_id(worker_id)?
752 .ok_or("the worker just registered must be readable")?;
753
754 // The caller's half of the contract: tracked BEFORE the delivery is
755 // handed the task, holding the worker's one slot.
756 let workflow_id = WorkflowId::new(Uuid::from_u128(WORKFLOW));
757 let activity_id = ActivityId::from_sequence_position(3);
758 tracker.track_task(
759 worker_id,
760 InFlightActivity {
761 workflow_id: workflow_id.clone(),
762 activity_id: activity_id.clone(),
763 attempt: 1,
764 completion_token: CompletionToken::for_test(),
765 },
766 Instant::now(),
767 ®istry,
768 None,
769 )?;
770 assert_eq!(
771 registry.in_flight_for_worker(worker_id)?,
772 1,
773 "precondition: the tracked dispatch holds the worker's slot"
774 );
775
776 let owners = AttemptOwnerIndex::new();
777 let delivery = liminal_delivery(&owners)
778 .with_completion_tracking(tracker.clone(), registry.clone());
779 let intent: SharedDeliveryIntent = Arc::new(AlwaysWanted);
780 let accepted = NeverAccepted::default();
781 let task = task_with_a_run();
782 {
783 // Owned on the heap so the `drop` below is the real one: a
784 // stack-pinned future is only released at the end of its scope.
785 let mut future = Box::pin(delivery.deliver(&worker, &task, &intent, &accepted));
786 let first_poll = futures::poll!(future.as_mut());
787 assert!(
788 first_poll.is_pending(),
789 "the push is queued behind the occupied blocking thread, so the first poll \
790 must suspend AT the push; a ready outcome means the window this test \
791 holds open was not held"
792 );
793 // The dispatcher withdraws while the push is in flight.
794 drop(future);
795 }
796 assert!(
797 !accepted.reached.load(std::sync::atomic::Ordering::SeqCst),
798 "a delivery abandoned during its push never reached the accept point"
799 );
800
801 // 🔴 THE PROPERTY: the slot is back, and the entry is gone.
802 assert_eq!(
803 registry.in_flight_for_worker(worker_id)?,
804 0,
805 "a delivery abandoned during its push must return the tracked slot; a slot \
806 still held means the guard was armed AFTER the push and the cancellation \
807 window is open again"
808 );
809 assert!(
810 !tracker.complete_task(worker_id, &workflow_id, &activity_id, ®istry)?,
811 "the guard retired the entry itself; the caller's later untrack must find \
812 nothing left to retire"
813 );
814
815 // Let the queued push run to its (failing) end and the occupant exit,
816 // so runtime shutdown does not wait on a parked blocking thread.
817 open_gate.send(())?;
818 occupant.await??;
819 Ok::<(), Box<dyn std::error::Error>>(())
820 })
821 }
822
823 /// The property's SECOND witness, and the one that outlives a refactor of
824 /// the call site: identity resolution refuses a run-less task, so no
825 /// `Resolved` is produced — and [`Resolved::bind_owner`], the only binding
826 /// path the delivery takes, is a method on it.
827 ///
828 /// 🔴 That is a claim about the CALL SITE, not about the key type. An
829 /// earlier version said an `AttemptKey` "cannot be built without" a
830 /// `Resolved`; it can, and the mutation that proved it survived. What this
831 /// witnesses is that resolution refuses first.
832 ///
833 /// Kept beside the behavioural test deliberately. The refusal and the
834 /// structure are different claims, and a change that removes one should
835 /// still meet the other.
836 #[test]
837 fn identity_resolution_refuses_a_task_with_no_run() -> Result<(), Box<dyn std::error::Error>> {
838 let Err(refusal) = Resolved::from_task(&task_without_a_run()) else {
839 return Err("a task with no run must not resolve".into());
840 };
841 assert!(
842 refusal.contains("run id is missing"),
843 "the refusal must name the run so an operator is sent to the right remedy: {refusal}"
844 );
845 Ok(())
846 }
847}