aion_server/worker/bridge.rs
1//! NIF bridge dispatcher that routes `run_activity` calls to connected workers.
2//!
3//! `WorkerActivityDispatcher` implements `aion::ActivityDispatcher` so the
4//! engine's activity NIFs can synchronously dispatch to a remote worker and
5//! block until the result comes back.
6//!
7//! # Threading contract
8//!
9//! The engine invokes [`aion::ActivityDispatcher::dispatch`] from two kinds of
10//! threads: beamr scheduler threads (concurrency combinators) and spawned
11//! tokio tasks (the two-phase `dispatch_activity` completion task). The task
12//! send uses `try_send()` (non-blocking channel push) and the response wait
13//! blocks on `std::sync::mpsc::Receiver::recv`.
14//!
15//! Blocking is harmless on a beamr thread, but on a tokio runtime worker it
16//! must be wrapped in `tokio::task::block_in_place`: the `try_send` wakes the
17//! per-worker gRPC stream forwarder task, and tokio schedules a task woken
18//! from task context into the *current* worker's LIFO slot, which no other
19//! runtime worker can steal. Without the `block_in_place` core handoff the
20//! forwarder sits trapped in that slot while this thread blocks, so the queued
21//! `ActivityTask` is never flushed to the worker even though the worker is
22//! healthy. `block_in_place` moves the worker's scheduler core (LIFO slot
23//! included) to another thread before the wait begins, so dispatch-to-delivery
24//! stays in the millisecond range and the runtime keeps full parallelism.
25//!
26//! # Wait termination
27//!
28//! The engine imposes no activity timeout of its own: agent-style activities
29//! legitimately run for over an hour, so the completion wait is unbounded.
30//! The blocking `recv` terminates on exactly one of:
31//!
32//! - **Completion** — the worker reports a result and the stream handler
33//! delivers it through [`ActivityCompletionSink::complete_activity`].
34//! - **Worker loss** — the worker's gRPC stream ends (process death,
35//! disconnect, expired token); the stream teardown sweeps the worker's
36//! in-flight tasks through the same sink as every other TRANSPORT loss
37//! ([`HeartbeatTracker::fail_disconnected_worker`]). A liminal-delivered
38//! worker's loss is observed by its reply router thread instead (the
39//! correlated-reply awaiter wakes the moment the connection closes) and
40//! resolves the dispatch with the same transport-loss class. Both are
41//! classified by [`transport_loss`](crate::worker::transport_loss): `lost:`
42//! while the transport still has budget to deliver the activity (the engine
43//! re-dispatches the SAME attempt, recording nothing), `transport-exhausted:`
44//! once that budget is spent. Neither ever wears the action's retry
45//! vocabulary — the activity never ran.
46//! - **Graceful-drain park (#207)** — during a drain, a worker stream ending
47//! (or the drain-timeout backstop) PARKS the worker's in-flight tasks
48//! through [`ActivityCompletionSink::park_activity`]: the waiter resolves
49//! with the ephemeral parked sentinel
50//! ([`aion::PARKED_ACTIVITY_REASON`]), nothing is recorded or delivered,
51//! and restart recovery re-dispatches the dangling ordinal — kill -9
52//! convergence.
53//! - **Drain timeout at shutdown** — the shutdown coordinator parks all
54//! remaining in-flight tasks through the sink
55//! (`HeartbeatTracker::park_all_in_flight_workers`).
56//! - **Channel teardown** — every sender for the pending entry is dropped
57//! (a cleanup path removed the entry without completing it); surfaced as
58//! a channel-closed dispatch error, never a hang.
59//!
60//! An activity's duration is bounded only by the workflow's own
61//! `timeout_seconds` and by worker liveness — never by an engine constant.
62
63use std::collections::BTreeMap;
64use std::sync::{Arc, OnceLock};
65use std::time::{Duration, Instant};
66
67use aion::{ActivityDispatch, ActivityDispatcher, LeaseSignal};
68use aion_core::{ActivityId, RunId, WorkflowId};
69use aion_proto::{ProtoActivityId, ProtoActivityTask, ProtoPayload, ProtoWorkflowId};
70use dashmap::DashMap;
71use dashmap::mapref::entry::Entry;
72
73use super::dispatch::{ActivityCompletion, ActivityCompletionSink};
74use super::envelope::{CompletionFences, CompletionToken, idempotency_key};
75use super::heartbeat::{HeartbeatTracker, InFlightActivity};
76use super::queue_service::{
77 DeliveryRefusal, ExpiredClock, PARK_POLL_INTERVAL, PoolCensus, QueueDeclarationSource,
78 QueueServiceConfig, QueueServiceReason, QueueServiceState, SelectionRefusal, ServiceAddress,
79 ServiceWait, WorkerUnavailable, deliver_within_schedule_to_start, select_worker_or_refuse,
80};
81use super::registry::{
82 ConnectedWorkerRegistry, DispatchReservation, WorkerArrival, WorkerDelivery, WorkerHandle,
83 WorkerId, WorkerMessage,
84};
85use crate::error::ServerError;
86use crate::shutdown::DrainState;
87
88mod completion_outcome;
89use tracing::info_span;
90
91type SyncSender = std::sync::mpsc::SyncSender<Result<String, String>>;
92
93/// One live dispatch's waiter slot: the channel that unblocks its
94/// `await_activity_result`, and the attempt it was dispatched at.
95///
96/// The attempt travels with the sender so resolution paths owned by ONE
97/// dispatch (`deliver_result`, `cleanup_activity`) remove the entry only while
98/// it is still that dispatch's own — after a higher attempt takes the site
99/// over (aion#195), the superseded dispatch's teardown must not evict the live
100/// waiter that replaced it. Whole-site resolutions (a fence-accepted
101/// completion, a park) stay unconditional: they retire the site, whoever
102/// holds it.
103struct PendingWaiter {
104 sender: SyncSender,
105 attempt: u32,
106}
107
108/// The reason a superseded waiter is resolved with when a higher attempt takes
109/// over its execution site (aion#195).
110///
111/// Worn by the DROPPED dispatch future of an attempt whose per-attempt bound
112/// already failed it, so it normally reaches nobody. It still wears the
113/// `retryable:` prefix deliberately: if a race ever surfaces it to the retry
114/// loop, a retry is the harmless reading (the fence refuses duplicates), where
115/// a terminal reading would repeat the exact defect this exists to close.
116fn superseded_site_reason(new_attempt: u32) -> String {
117 format!(
118 "retryable: superseded by attempt {new_attempt} at this execution site — the prior \
119 attempt reached its bound, its completion token is fence-refused, and its worker \
120 has been asked to stop"
121 )
122}
123type SyncReceiver = std::sync::mpsc::Receiver<Result<String, String>>;
124
125/// Execution-scoped key for an in-flight activity dispatch.
126///
127/// The engine seam ([`ActivityDispatch`]) carries the *real* workflow id and
128/// the *real* per-workflow activity ordinal recorded in history, so this pair
129/// uniquely and stably identifies one execution. Keying by bare [`ActivityId`]
130/// would be unsafe across server restarts — a stale result re-reported from a
131/// worker's previous session could complete a *different* post-restart
132/// dispatch reusing the same ordinal — but pairing it with the real workflow
133/// id closes that race: two different workflow executions never share a
134/// workflow id, so a stale `(workflow_id, activity_id)` from a previous server
135/// life can only ever match the exact execution it belongs to.
136///
137/// The wire (`ActivityResult`) carries both ids, plus an attempt discriminator
138/// (`ActivityTask.attempt`). The pending key stays attempt-free for now: a
139/// retry re-dispatches under the same `(workflow_id, activity_id)` and the
140/// outstanding entry is the one awaiting completion. Redelivery bookkeeping
141/// can widen this key with the wire attempt later — no protocol change needed.
142type PendingActivityKey = (WorkflowId, ActivityId);
143
144/// Routes an unmatched durable-outbox completion into the live workflow.
145///
146/// When the outbox is ON a worker completion can arrive at the sink with no
147/// pending oneshot (the dispatch was non-blocking fan-out, or the original
148/// waiter was lost). Rather than dropping it, [`PendingActivities::complete`]
149/// hands it to this callback, which resolves the workflow to its live engine
150/// process and delivers the terminal into its mailbox. The callback is only
151/// installed when the outbox is enabled, so flag-off the unmatched branch
152/// stays a silent drop.
153pub trait OutboxDeliveryCallback: Send + Sync {
154 /// Deliver a successful completion to the live workflow.
155 ///
156 /// Returns `Ok(true)` when delivered to a live workflow and `Ok(false)`
157 /// when no run is currently live (the expected stale-completion case that
158 /// recovery re-arms).
159 ///
160 /// # Errors
161 ///
162 /// Returns [`ServerError`] when the engine rejects the delivery.
163 fn deliver_completion(
164 &self,
165 workflow_id: &WorkflowId,
166 activity_id: &ActivityId,
167 run_id: Option<&RunId>,
168 result: String,
169 ) -> Result<bool, ServerError>;
170
171 /// Deliver a failure to the live workflow. Same `bool`/error contract as
172 /// [`Self::deliver_completion`].
173 ///
174 /// # Errors
175 ///
176 /// Returns [`ServerError`] when the engine rejects the delivery.
177 fn deliver_failure(
178 &self,
179 workflow_id: &WorkflowId,
180 activity_id: &ActivityId,
181 run_id: Option<&RunId>,
182 reason: String,
183 ) -> Result<bool, ServerError>;
184}
185
186/// Tracks in-flight activity dispatches waiting for worker results.
187///
188/// When the server's worker stream handler receives an `ActivityResult`, it
189/// calls [`complete_activity`](ActivityCompletionSink::complete_activity) to
190/// deliver the result to the blocked NIF thread. Entries are keyed by
191/// [`PendingActivityKey`] so a stale result from a previous server life can
192/// never be matched to a different execution (#59).
193///
194/// Clones share both the pending map and the outbox-delivery callback through
195/// `Arc`, so [`set_outbox_delivery`](Self::set_outbox_delivery) called once on
196/// any clone after construction is visible to the clone the dispatcher holds.
197#[derive(Clone)]
198pub struct PendingActivities {
199 pending: Arc<DashMap<PendingActivityKey, PendingWaiter>>,
200 completion_fences: CompletionFences,
201 outbox_delivery: Arc<OnceLock<Arc<dyn OutboxDeliveryCallback>>>,
202 /// Where an accepted delivery's lease is recorded (WA-010 R3), shared by
203 /// the bridge and every outbox dispatcher built over these seams so both
204 /// paths attribute through one recorder and count on one ledger.
205 lease_recorder: super::lease_record::LeaseRecorderSeam,
206 /// The transport's OWN re-dispatch budget for activities whose worker died
207 /// before reporting (see [`transport_loss`](crate::worker::transport_loss)).
208 /// Shared across clones, so every loss for one execution site lands in one
209 /// budget. There is no default: the window is required at construction, so
210 /// a zero budget cannot be inherited by omission.
211 transport_losses: super::transport_loss::TransportLossLedger,
212}
213
214impl std::fmt::Debug for PendingActivities {
215 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 formatter
217 .debug_struct("PendingActivities")
218 .field("pending", &self.pending.len())
219 .field("completion_fences", &self.completion_fences)
220 .field(
221 "outbox_delivery_installed",
222 &self.outbox_delivery.get().is_some(),
223 )
224 .field("lease_recorder", &self.lease_recorder)
225 .field("transport_losses", &self.transport_losses)
226 .finish()
227 }
228}
229
230impl PendingActivities {
231 /// Register the waiter for one dispatch, taking over the execution site
232 /// from a superseded lower attempt when one still holds it (aion#195).
233 ///
234 /// The third return names the attempt that was superseded, so the caller
235 /// can walk the release ladder for it (ask its worker to stop, untrack its
236 /// heartbeat task). The fence issue above already superseded the holder's
237 /// completion tokens — a takeover changes who may still answer, never what
238 /// would have been accepted.
239 fn insert(
240 &self,
241 workflow_id: WorkflowId,
242 run_id: &RunId,
243 activity_id: ActivityId,
244 attempt: u32,
245 ) -> Result<(CompletionToken, SyncReceiver, Option<u32>), ServerError> {
246 let completion_token =
247 self.completion_fences
248 .issue(&workflow_id, run_id, &activity_id, attempt)?;
249 let (tx, rx) = std::sync::mpsc::sync_channel(1);
250 let key = (workflow_id, activity_id);
251 match self.pending.entry(key) {
252 Entry::Vacant(entry) => {
253 entry.insert(PendingWaiter {
254 sender: tx,
255 attempt,
256 });
257 Ok((completion_token, rx, None))
258 }
259 Entry::Occupied(mut entry) => {
260 let held_attempt = entry.get().attempt;
261 if attempt > held_attempt {
262 // The holder is a corpse: issuing this attempt's token
263 // superseded its generation, so nothing it answers can be
264 // accepted. Waking it with the superseded sentinel lets
265 // its blocked dispatcher thread finish; its attempt-aware
266 // teardown cannot evict this replacement.
267 let superseded = entry.insert(PendingWaiter {
268 sender: tx,
269 attempt,
270 });
271 let _ = superseded.sender.send(Err(superseded_site_reason(attempt)));
272 Ok((completion_token, rx, Some(held_attempt)))
273 } else {
274 let (workflow_id, activity_id) = entry.key();
275 Err(ServerError::PendingActivityCollision {
276 workflow_id: workflow_id.clone(),
277 activity_id: activity_id.clone(),
278 held_attempt,
279 incoming_attempt: attempt,
280 })
281 }
282 }
283 }
284 }
285
286 /// Share the generation registry with non-blocking outbox dispatch.
287 #[must_use]
288 pub fn completion_fences(&self) -> CompletionFences {
289 self.completion_fences.clone()
290 }
291
292 /// Test seam: register a pending waiter exactly as a live dispatch does,
293 /// so crate-internal tests outside this module (the stream-teardown
294 /// park-vs-fail pins) can observe how a sweep resolves it.
295 #[cfg(test)]
296 pub(crate) fn insert_for_test(
297 &self,
298 workflow_id: WorkflowId,
299 run_id: &RunId,
300 activity_id: ActivityId,
301 attempt: u32,
302 ) -> Result<(CompletionToken, SyncReceiver, Option<u32>), ServerError> {
303 self.insert(workflow_id, run_id, activity_id, attempt)
304 }
305
306 /// Install the unmatched-completion delivery callback (idempotent).
307 ///
308 /// Set once, after construction, when the durable outbox is enabled. A
309 /// second set is ignored and logged: the callback is process-wide and must
310 /// not silently change identity.
311 pub fn set_outbox_delivery(&self, callback: Arc<dyn OutboxDeliveryCallback>) {
312 if self.outbox_delivery.set(callback).is_err() {
313 tracing::warn!("outbox delivery callback already installed; ignoring duplicate set");
314 }
315 }
316
317 /// Complete a pending dispatch, or route an unmatched completion to the
318 /// outbox delivery callback when one is installed.
319 ///
320 /// A matched entry delivers to its waiting oneshot exactly as before. An
321 /// unmatched completion is dropped silently when no callback is installed
322 /// (outbox OFF — byte-identical to the prior behaviour); with a callback
323 /// installed (outbox ON) it is routed into the live workflow's mailbox.
324 fn complete(
325 &self,
326 workflow_id: &WorkflowId,
327 activity_id: &ActivityId,
328 run_id: Option<&RunId>,
329 result: Result<String, String>,
330 ) -> bool {
331 // Take and drop the DashMap guard before any callback runs: the engine
332 // delivery the callback invokes must never execute under a shard lock.
333 let matched = self
334 .pending
335 .remove(&(workflow_id.clone(), activity_id.clone()));
336 if let Some((_, waiter)) = matched {
337 return waiter.sender.send(result).is_ok();
338 }
339 let Some(callback) = self.outbox_delivery.get() else {
340 // Outbox OFF: silent drop, byte-identical to the prior behaviour.
341 return false;
342 };
343 let outcome = match result {
344 Ok(payload) => callback.deliver_completion(workflow_id, activity_id, run_id, payload),
345 Err(reason) => callback.deliver_failure(workflow_id, activity_id, run_id, reason),
346 };
347 match outcome {
348 Ok(true) => true,
349 Ok(false) => {
350 // Not live: the expected stale-completion case recovery re-arms.
351 tracing::debug!(
352 workflow_id = %workflow_id,
353 activity_id = %activity_id,
354 "unmatched outbox completion for a workflow that is not currently live; \
355 recovery will re-arm it"
356 );
357 false
358 }
359 Err(error) => {
360 tracing::warn!(
361 workflow_id = %workflow_id,
362 activity_id = %activity_id,
363 %error,
364 "failed to deliver unmatched outbox completion to the live workflow"
365 );
366 false
367 }
368 }
369 }
370
371 fn complete_fenced_after_accept(
372 &self,
373 workflow_id: &WorkflowId,
374 activity_id: &ActivityId,
375 run_id: Option<&RunId>,
376 completion_token: &CompletionToken,
377 result: Result<String, String>,
378 after_accept: impl FnOnce() -> Result<bool, ServerError>,
379 ) -> Result<bool, ServerError> {
380 // The acceptance hands back the generation it consumed — every token
381 // outstanding for this attempt, not just the one submitted — so the two
382 // restore arms below put back exactly what was taken rather than
383 // re-deriving it and inventing a narrower generation.
384 let accepted = self
385 .completion_fences
386 .accept(workflow_id, activity_id, completion_token)
387 .inspect_err(|error| {
388 tracing::warn!(
389 workflow_id = %workflow_id,
390 activity_id = %activity_id,
391 %error,
392 "activity completion rejected by execution-generation fence"
393 );
394 })?;
395 let should_publish = match after_accept() {
396 Ok(should_publish) => should_publish,
397 Err(error) => {
398 if let Err(restore_error) =
399 self.completion_fences
400 .restore_if_absent(workflow_id, activity_id, &accepted)
401 {
402 tracing::error!(%restore_error, "failed to restore completion generation after settlement failure");
403 }
404 return Err(error);
405 }
406 };
407 if !should_publish {
408 // A non-publishing settlement means ANOTHER path owns this
409 // dispatch's resolution (the lost-worker sweep, typically). That
410 // path will present the same token to `accept` — so the
411 // generation this call consumed must be restored, exactly as on
412 // the failure arm, or the true resolver is refused with
413 // `NoCurrentGeneration` and the waiter is never resolved.
414 if let Err(restore_error) =
415 self.completion_fences
416 .restore_if_absent(workflow_id, activity_id, &accepted)
417 {
418 tracing::error!(%restore_error, "failed to restore completion generation after a non-publishing settlement");
419 }
420 return Ok(false);
421 }
422 // A resolution OUTSIDE the transport domain — a real result, or a real
423 // action failure — retires this execution site's transport-loss budget,
424 // so a run that survived one blip does not carry it forward. A
425 // transport-domain resolution deliberately does not: the running budget
426 // is exactly what bounds a flapping link.
427 let transport_domain = result
428 .as_ref()
429 .err()
430 .is_some_and(|reason| super::transport_loss::is_transport_domain_reason(reason));
431 if !transport_domain
432 && let Err(error) = self.transport_losses.clear(workflow_id, activity_id)
433 {
434 tracing::warn!(
435 workflow_id = %workflow_id,
436 activity_id = %activity_id,
437 %error,
438 "failed to retire the transport-loss budget for a resolved activity"
439 );
440 }
441 Ok(self.complete(workflow_id, activity_id, run_id, result))
442 }
443
444 /// Build a sink whose transport-loss ledger derives its budget from the
445 /// operator's heartbeat window (see
446 /// [`transport_loss`](crate::worker::transport_loss)).
447 ///
448 /// The window is a CONSTRUCTOR parameter rather than a builder step, and
449 /// that is the point: there is no way to obtain a `PendingActivities`
450 /// without naming the window, so a wiring cannot supply everything else and
451 /// silently inherit a zero budget. The previous shape —
452 /// `default().with_heartbeat_window(w)` — was two calls where the second was
453 /// omissible, and nothing in the test suite could tell the wired world from
454 /// the unwired one.
455 #[must_use]
456 pub fn new(heartbeat_window: std::time::Duration) -> Self {
457 Self {
458 pending: Arc::default(),
459 completion_fences: CompletionFences::default(),
460 outbox_delivery: Arc::default(),
461 lease_recorder: super::lease_record::LeaseRecorderSeam::default(),
462 transport_losses: super::transport_loss::TransportLossLedger::new(heartbeat_window),
463 }
464 }
465
466 /// The shared lease-record seam, for an outbox dispatcher built over the
467 /// same pending set to record through.
468 #[must_use]
469 pub fn lease_recorder(&self) -> super::lease_record::LeaseRecorderSeam {
470 self.lease_recorder.clone()
471 }
472
473 /// Install the lease recorder once the engine exists; a duplicate install
474 /// keeps the first and is logged by the seam.
475 pub fn set_lease_recorder(
476 &self,
477 recorder: Arc<dyn super::lease_record::ActivityLeaseRecorder>,
478 metrics: Option<crate::observability::Metrics>,
479 ) {
480 self.lease_recorder.install(recorder, metrics);
481 }
482}
483
484impl ActivityCompletionSink for PendingActivities {
485 /// Park one in-flight dispatch for restart recovery (#207): resolve the
486 /// matched waiter with the ephemeral parked sentinel, and nothing else.
487 ///
488 /// This resolution is MANDATORY, not an optimization: the default
489 /// `ActivityDispatcher::dispatch_async` runs the dispatcher's blocking
490 /// `std::sync::mpsc::recv()` on tokio's blocking pool, and tokio `Runtime`
491 /// drop joins blocking threads — an unresolved waiter would wedge process
492 /// exit indefinitely. An unmatched dispatch (already resolved by another
493 /// path) is a no-op: a park is NEVER routed to the outbox delivery
494 /// callback, because it is not a failure and must never reach a workflow.
495 fn park_activity(
496 &self,
497 workflow_id: &WorkflowId,
498 activity_id: &ActivityId,
499 ) -> Result<(), ServerError> {
500 // A park retires the execution site, so it takes the whole current
501 // generation — including a redelivery's sibling token, whose worker is
502 // being parked for the same restart.
503 self.completion_fences
504 .revoke_current(workflow_id, activity_id)?;
505 let matched = self
506 .pending
507 .remove(&(workflow_id.clone(), activity_id.clone()));
508 if let Some((_, waiter)) = matched {
509 // A send failure means the waiter side already dropped its
510 // receiver (the dispatch is being cleaned up concurrently) —
511 // benign: there is no thread left to unblock.
512 let _ = waiter
513 .sender
514 .send(Err(aion::PARKED_ACTIVITY_REASON.to_owned()));
515 }
516 Ok(())
517 }
518
519 fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
520 self.complete_activity_after_accept(completion, || Ok(()))
521 }
522}
523
524/// Dispatcher that routes `run_activity` NIF calls to connected workers.
525///
526/// Synchronous interface — uses `try_send` for the task channel and
527/// `std::sync::mpsc::Receiver::recv` for the response. Callers on a
528/// multi-thread tokio runtime are detected and moved into
529/// `tokio::task::block_in_place` so the blocking wait never starves the
530/// runtime tasks that flush the worker stream (see the module docs).
531pub struct WorkerActivityDispatcher {
532 registry: ConnectedWorkerRegistry,
533 namespace: String,
534 pending: PendingActivities,
535 heartbeat_tracker: HeartbeatTracker,
536 drain_state: DrainState,
537 tokio_handle: Option<tokio::runtime::Handle>,
538 /// NOI-6 attempt→owner back-index. When installed, each liminal-delivered
539 /// dispatch binds its `(workflow, activity, attempt)` to the owning worker
540 /// for the dispatch's lifetime, so the intervention router (and the ops
541 /// console's live-attempts enumeration) can see and target it. `None`
542 /// (isolated tests) binds nothing.
543 attempt_owners: Option<super::intervention::AttemptOwnerIndex>,
544 /// R1 service policies and the two service clocks. Default: `strict` with
545 /// no clocks — refuse the structurally unservable, wait (loudly) for
546 /// everything else, and invent no deadline the operator never wrote.
547 queue_service: QueueServiceConfig,
548 /// Deployed queue declarations, installed once the engine exists. Answers
549 /// `Unknown` until then, and `Unknown` never refuses anything.
550 queue_declarations: QueueDeclarationSource,
551 /// Live unserved-queue state a parked dispatch publishes itself into.
552 queue_state: QueueServiceState,
553 /// Cluster-event publisher an unbounded park announces itself on (#266
554 /// T4). `None` (isolated tests) loses only the pushed echo; the WARN and
555 /// the queryable state above cannot be switched off by wiring.
556 cluster_publisher: Option<crate::cluster_publisher::ClusterEventPublisher>,
557}
558
559impl std::fmt::Debug for WorkerActivityDispatcher {
560 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
561 f.debug_struct("WorkerActivityDispatcher")
562 .field("namespace", &self.namespace)
563 .finish_non_exhaustive()
564 }
565}
566
567impl WorkerActivityDispatcher {
568 /// Build a dispatcher for the given namespace, worker registry, and
569 /// liveness tracker.
570 ///
571 /// The tracker must be the same instance the worker stream handler and
572 /// shutdown coordinator share: the unbounded completion wait relies on
573 /// stream teardown sweeping this tracker's in-flight entries to fail
574 /// dispatches whose worker was lost.
575 #[must_use]
576 pub fn new(
577 registry: ConnectedWorkerRegistry,
578 namespace: impl Into<String>,
579 heartbeat_tracker: HeartbeatTracker,
580 ) -> Self {
581 Self {
582 registry,
583 namespace: namespace.into(),
584 // Derived from the tracker this constructor is already handed,
585 // which already carries the operator's window: the dispatcher
586 // cannot be given a heartbeat window and a mismatched budget.
587 pending: PendingActivities::new(heartbeat_tracker.heartbeat_window()),
588 heartbeat_tracker,
589 drain_state: DrainState::default(),
590 tokio_handle: None,
591 attempt_owners: None,
592 queue_service: QueueServiceConfig::default(),
593 queue_declarations: QueueDeclarationSource::default(),
594 queue_state: QueueServiceState::default(),
595 cluster_publisher: None,
596 }
597 }
598
599 /// Share the deployment-global cluster-event publisher so a dispatch
600 /// parked with no availability deadline is announced on the operator's
601 /// real-time channel, not only in the log (#266 T4).
602 #[must_use]
603 pub fn with_cluster_publisher(
604 mut self,
605 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
606 ) -> Self {
607 self.cluster_publisher = Some(cluster_publisher);
608 self
609 }
610
611 /// Share the operator's R1 queue-service settings: the default policy, the
612 /// written per-queue `durable_pending` opt-ins, and the two clocks.
613 #[must_use]
614 pub fn with_queue_service(mut self, queue_service: QueueServiceConfig) -> Self {
615 self.queue_service = queue_service;
616 self
617 }
618
619 /// Share the queue-declaration source the boot path fills in once the
620 /// engine exists (the handle is cloneable; installing on any clone is
621 /// visible here).
622 #[must_use]
623 pub fn with_queue_declarations(mut self, queue_declarations: QueueDeclarationSource) -> Self {
624 self.queue_declarations = queue_declarations;
625 self
626 }
627
628 /// Share the queue-service state so the server can read which addresses are
629 /// unserved and which runs are parked on them.
630 #[must_use]
631 pub fn with_queue_state(mut self, queue_state: QueueServiceState) -> Self {
632 self.queue_state = queue_state;
633 self
634 }
635
636 /// Share the server's NOI-6 attempt→owner back-index so liminal-delivered
637 /// dispatches are visible (and targetable) to the intervention router for
638 /// exactly as long as they are in flight. The production boot passes
639 /// `ServerState`'s index — the SAME instance `intervenable_attempts` and
640 /// `intervene` read — or the console's live-attempts list stays empty for
641 /// every bridge-dispatched agent step.
642 #[must_use]
643 pub fn with_attempt_owners(
644 mut self,
645 attempt_owners: super::intervention::AttemptOwnerIndex,
646 ) -> Self {
647 self.attempt_owners = Some(attempt_owners);
648 self
649 }
650
651 /// The completion sink this dispatcher classifies worker deaths through.
652 ///
653 /// Exposed so the transport-loss budget a wiring actually ends up with is
654 /// observable, rather than being a property only prose asserts.
655 #[must_use]
656 pub const fn pending(&self) -> &PendingActivities {
657 &self.pending
658 }
659
660 /// Share a caller-supplied pending-activities tracker.
661 #[must_use]
662 pub fn with_pending(mut self, pending: PendingActivities) -> Self {
663 self.pending = pending;
664 self
665 }
666
667 /// Share the server drain gate.
668 #[must_use]
669 pub fn with_drain_state(mut self, drain_state: DrainState) -> Self {
670 self.drain_state = drain_state;
671 self
672 }
673
674 /// Share the server runtime handle for sync history writes from dirty NIF threads.
675 #[must_use]
676 pub fn with_tokio_handle(mut self, tokio_handle: tokio::runtime::Handle) -> Self {
677 self.tokio_handle = Some(tokio_handle);
678 self
679 }
680}
681
682impl WorkerActivityDispatcher {
683 /// The drain gate for one dispatch, and the single place a draining server's
684 /// refusal is turned into a reason the engine acts on.
685 ///
686 /// The reason handed back is the #207 parked sentinel
687 /// ([`aion::PARKED_ACTIVITY_REASON`]), NOT the gate's own message. A
688 /// dispatch the drain refused never reached a worker and never ran, which is
689 /// precisely the state that sentinel was minted for: record nothing, deliver
690 /// nothing, and let post-restart replay re-dispatch the dangling ordinal —
691 /// the kill -9 convergence `shutdown::ShutdownOutcome::Parked` documents.
692 ///
693 /// Both alternatives are wrong here, and neither is a style preference:
694 ///
695 /// - the *unprefixed* gate message this used to return is classified as a
696 /// terminal action failure, so a routine deploy records `ActivityFailed`
697 /// (and, with no authored retry, `WorkflowFailed`) for work nobody
698 /// attempted — the synthesized failure #207 exists to abolish;
699 /// - the TRANSPORT-loss class (`lost:`) is attempt-neutral but re-dispatches
700 /// the SAME attempt live and in-process, which on a draining server means
701 /// refuse → re-dispatch → refuse, spinning against the very gate that
702 /// refused it while the process is trying to exit.
703 ///
704 /// The operator-facing detail is not lost: the gate's own message is what is
705 /// logged here. Only the engine-facing classification is the sentinel.
706 fn ensure_accepting(
707 &self,
708 namespace: &str,
709 activity_type: &str,
710 workflow_id: &WorkflowId,
711 activity_id: &ActivityId,
712 worker_id: Option<WorkerId>,
713 ) -> Result<(), String> {
714 self.drain_state
715 .ensure_accepting(namespace, activity_type)
716 .map_err(|error| {
717 log_worker_error(
718 "WorkerDispatch",
719 namespace,
720 activity_type,
721 workflow_id,
722 activity_id,
723 worker_id,
724 &error.to_string(),
725 );
726 aion::PARKED_ACTIVITY_REASON.to_owned()
727 })
728 }
729
730 /// Select a worker for the address, or refuse with a typed R1 reason.
731 ///
732 /// The wait itself lives in [`super::queue_service::wait`]: every selection
733 /// miss is classified against the deployed contract records and the live
734 /// poller census, published to the queue-service state, and stated at WARN.
735 /// This method owns only the two things the seam cannot delegate — the
736 /// drain gate and the runtime plumbing of one bounded park.
737 ///
738 /// The [`DispatchReservation`] returned beside the worker is the chosen
739 /// worker's capacity slot, already claimed. The caller must hold it until
740 /// the heartbeat tracker has taken the count over; dropping it early
741 /// re-opens the window in which a simultaneous dispatch re-picks a worker
742 /// this one has already filled.
743 fn select_worker_or_wait(
744 &self,
745 address: &ServiceAddress,
746 workflow_id: &WorkflowId,
747 activity_id: &ActivityId,
748 ) -> Result<(WorkerHandle, DispatchReservation), String> {
749 let wait = ServiceWait {
750 registry: &self.registry,
751 declarations: &self.queue_declarations,
752 config: &self.queue_service,
753 state: &self.queue_state,
754 address,
755 workflow_id,
756 activity_id,
757 publisher: self.cluster_publisher.as_ref(),
758 };
759 let mut accepting = || {
760 self.ensure_accepting(
761 &address.namespace,
762 &address.activity_type,
763 workflow_id,
764 activity_id,
765 None,
766 )
767 };
768 let mut park = |arrival: WorkerArrival, budget: Option<Duration>| {
769 self.park_for_worker(arrival, budget);
770 };
771 select_worker_or_refuse(&wait, &mut accepting, &mut park).map_err(|refusal| {
772 let reason = refusal.reason_string();
773 // The drain gate already logged its own refusal at its own site;
774 // logging it twice would double-count the incident.
775 if !matches!(refusal, SelectionRefusal::NotAccepting { .. }) {
776 let error_type = match refusal {
777 SelectionRefusal::Unavailable(_) => "WorkerUnavailable",
778 _ => "WorkerRegistry",
779 };
780 log_worker_error(
781 error_type,
782 &address.namespace,
783 &address.activity_type,
784 workflow_id,
785 activity_id,
786 None,
787 &reason,
788 );
789 }
790 reason
791 })
792 }
793
794 /// Wait once on the `arrival` subscription — bounded by `budget` when one is
795 /// given, and bounded in BOTH arms by the server's own shutdown.
796 ///
797 /// `arrival` was taken by [`select_worker_or_refuse`] BEFORE the selection
798 /// that missed, which is why this method is handed one rather than taking
799 /// its own. Subscribing here would be too late: the registry's wake sources
800 /// store no permit, so a worker that registered during that selection would
801 /// already have fired into an empty waiter list and this park would sleep
802 /// through it.
803 ///
804 /// The arrival wait races the drain latch, and that race is the whole point.
805 /// Without it a `None` budget — the default, because no schedule-to-start
806 /// deadline is configured unless an operator writes one — parked this
807 /// dispatch on a `spawn_blocking` thread with nothing left that could ever
808 /// wake it: the drain gate is consulted once per selection iteration, and
809 /// the iteration was stuck inside this wait. Tokio's runtime `Drop` joins
810 /// the blocking pool, so `aion server` could not exit even after `main`
811 /// returned an exit code — it kept the store's writer lock and blocked the
812 /// next deploy into the same data directory. The captured stack of a real
813 /// one is `docs/evidence/samples/aion-server-hang-after-drain-15202.sample`.
814 /// The `Some(budget)` arm carried the same defect at the scale of the
815 /// budget: exit was delayed by up to the whole availability deadline.
816 ///
817 /// Racing the latch is the fix rather than capping the wait, because the
818 /// wait is legitimately unbounded while the server runs: a queue no worker
819 /// serves yet is a fleet condition an operator resolves by starting a
820 /// worker, and a deadline invented here would refuse work nobody asked to
821 /// have refused.
822 ///
823 /// Waking on the latch decides nothing by itself. The selection loop
824 /// re-selects, misses, and consults the SAME drain gate it always
825 /// consulted — [`Self::ensure_accepting`] — which is where the refusal and
826 /// its classification are produced. This seam gains no second opinion about
827 /// draining; it only stops being unwakeable.
828 fn park_for_worker(&self, arrival: WorkerArrival, budget: Option<Duration>) {
829 let handle = self
830 .tokio_handle
831 .clone()
832 .or_else(|| tokio::runtime::Handle::try_current().ok());
833 let Some(handle) = handle else {
834 // No runtime in reach (a plain OS thread in an isolated test):
835 // sleep the seam's park interval, clamped to the remaining budget.
836 // Bounded by construction, so the loop re-consults the drain gate
837 // within one interval and needs no signal to race.
838 //
839 // `arrival` is DROPPED unawaited here, deliberately: there is no
840 // executor to await it on. That is safe only because this arm is
841 // bounded by construction — the loop re-selects within one interval
842 // whether or not any wake ever fires — and it is the one place the
843 // `park` contract permits discarding the subscription. Its `Drop`
844 // unlinks the waiter it enabled.
845 std::thread::sleep(
846 budget.map_or(PARK_POLL_INTERVAL, |budget| budget.min(PARK_POLL_INTERVAL)),
847 );
848 return;
849 };
850 handle.block_on(async {
851 let raced = async {
852 tokio::select! {
853 () = arrival => {}
854 () = self.drain_state.wait_for_drain() => {}
855 }
856 };
857 match budget {
858 None => raced.await,
859 Some(budget) => {
860 // Timeout is not failure here: the caller re-classifies and
861 // decides whether the clock has run out.
862 drop(tokio::time::timeout(budget, raced).await);
863 }
864 }
865 });
866 }
867
868 fn track_worker_task(
869 &self,
870 worker_id: WorkerId,
871 activity_type: &str,
872 workflow_id: &WorkflowId,
873 activity_id: &ActivityId,
874 attempt: u32,
875 // The token this dispatch is fenced by and the capacity slot it holds,
876 // together because they are handed over together: the tracker commits
877 // the slot in the same call that records the token.
878 lease: (CompletionToken, DispatchReservation),
879 ) -> Result<(), String> {
880 let (completion_token, reservation) = lease;
881 self.heartbeat_tracker
882 .track_task(
883 worker_id,
884 InFlightActivity {
885 workflow_id: workflow_id.clone(),
886 activity_id: activity_id.clone(),
887 // The engine-provided attempt this delivery carries — the
888 // same one stamped on the wire task and bound into the
889 // NOI-6 attempt→owner index, so the tracker, the wire, and
890 // the owner index all name one attempt.
891 attempt,
892 completion_token,
893 },
894 Instant::now(),
895 &self.registry,
896 // HANDED OVER in one act: the tracker commits this slot and
897 // skips its own increment, so the count never reads higher than
898 // the work this worker holds. Two acts — track, then release the
899 // reservation — left a window in which a concurrent selection
900 // was refused a slot that was free.
901 Some(reservation),
902 )
903 .map_err(|error| {
904 let reason = error.to_string();
905 log_worker_error(
906 "WorkerHeartbeatTracker",
907 &self.namespace,
908 activity_type,
909 workflow_id,
910 activity_id,
911 Some(worker_id),
912 &reason,
913 );
914 reason
915 })
916 }
917
918 fn cleanup_activity(
919 &self,
920 worker_id: WorkerId,
921 workflow_id: &WorkflowId,
922 activity_id: &ActivityId,
923 attempt: u32,
924 completion_token: &CompletionToken,
925 ) {
926 // Own-attempt-only, like the token revocation below: a dispatch may
927 // tear down only the waiter slot it installed. After a higher attempt
928 // takes the site over (aion#195) this entry is no longer this
929 // dispatch's to remove.
930 self.pending
931 .pending
932 .remove_if(&(workflow_id.clone(), activity_id.clone()), |_, waiter| {
933 waiter.attempt == attempt
934 });
935 // Own-token-only: an undelivered task withdraws the authorization it
936 // was given and never the sibling a concurrently redelivered dispatch
937 // handed to a worker that is still executing.
938 if let Err(error) =
939 self.pending
940 .completion_fences
941 .revoke(workflow_id, activity_id, completion_token)
942 {
943 tracing::error!(
944 workflow_id = %workflow_id,
945 activity_id = %activity_id,
946 %error,
947 "failed to revoke undelivered activity generation"
948 );
949 }
950 let _ = self.heartbeat_tracker.complete_task(
951 worker_id,
952 workflow_id,
953 activity_id,
954 &self.registry,
955 );
956 self.drain_state.notify_activity_drained();
957 }
958
959 /// Deliver one dispatched task to the selected worker over ITS transport.
960 ///
961 /// The bridge is transport-agnostic at this seam: selection
962 /// ([`Self::select_worker_or_wait`]) already treats every registry member
963 /// identically, and this match delivers on whichever [`WorkerDelivery`] leg
964 /// the worker registered with — the gRPC stream `mpsc` push, or the liminal
965 /// server-push on the worker's existing connection. Both legs resolve
966 /// through the SAME pending map, so `await_activity_result` is oblivious to
967 /// the transport.
968 fn send_activity_task(
969 &self,
970 worker: &WorkerHandle,
971 task: ProtoActivityTask,
972 address: &ServiceAddress,
973 handoff: &super::lease_record::LeaseHandoff,
974 ) -> Result<(), String> {
975 let workflow_id = &handoff.key().workflow_id;
976 let activity_id = &handoff.key().activity_id;
977 let completion_token = handoff.token();
978 match worker.delivery() {
979 WorkerDelivery::Grpc(sender) => {
980 let worker_id = worker.id();
981 let mut accepting = || {
982 self.ensure_accepting(
983 &address.namespace,
984 &address.activity_type,
985 workflow_id,
986 activity_id,
987 Some(worker_id),
988 )
989 };
990 let handed_over = deliver_within_schedule_to_start(
991 sender,
992 WorkerMessage::ActivityTask(Box::new(task)),
993 self.queue_service.schedule_to_start_timeout,
994 &mut accepting,
995 );
996 let Err(refusal) = handed_over else {
997 // The stream took the frame: the worker holds the attempt
998 // from here, and the lease is recorded before this call
999 // returns to wait on the result (WA-010 R3).
1000 handoff.accepted_blocking(self.tokio_handle.as_ref());
1001 return Ok(());
1002 };
1003 self.cleanup_activity(
1004 worker_id,
1005 workflow_id,
1006 activity_id,
1007 handoff.key().attempt,
1008 completion_token,
1009 );
1010 let (error_type, reason) = self.hand_off_failure(&refusal, address);
1011 if !matches!(refusal, DeliveryRefusal::NotAccepting { .. }) {
1012 log_worker_error(
1013 error_type,
1014 &address.namespace,
1015 &address.activity_type,
1016 workflow_id,
1017 activity_id,
1018 Some(worker_id),
1019 &reason,
1020 );
1021 }
1022 Err(reason)
1023 }
1024 // The liminal leg has no bounded intake queue to saturate: the push
1025 // either enqueues on the worker's live connection or fails
1026 // synchronously because that connection is already gone. There is
1027 // therefore no schedule-to-start window to apply here, and reporting
1028 // `SATURATED` for a push failure would name a condition that did not
1029 // happen.
1030 #[cfg(feature = "liminal-transport")]
1031 WorkerDelivery::Liminal(delivery) => self.send_liminal_activity_task(
1032 worker.id(),
1033 delivery,
1034 task,
1035 &address.activity_type,
1036 handoff,
1037 ),
1038 }
1039 }
1040
1041 /// Render one hand-off refusal into its log class and failure reason.
1042 ///
1043 /// `SATURATED` is the only one that becomes a typed [`WorkerUnavailable`]:
1044 /// a compatible worker WAS live and would not take the task inside the
1045 /// schedule-to-start clock. A full intake with no clock configured, and a
1046 /// closed transport, keep the failure they always had.
1047 fn hand_off_failure(
1048 &self,
1049 refusal: &DeliveryRefusal,
1050 address: &ServiceAddress,
1051 ) -> (&'static str, String) {
1052 match refusal {
1053 DeliveryRefusal::Saturated { waited } => {
1054 // A census failure must not swallow the refusal: report the
1055 // saturation with an empty census and say why it is empty.
1056 let census = self
1057 .registry
1058 .pool_census(
1059 &address.namespace,
1060 &address.task_queue,
1061 &address.activity_type,
1062 address.node.as_deref(),
1063 )
1064 .unwrap_or_else(|error| {
1065 tracing::error!(
1066 namespace = %address.namespace,
1067 task_queue = %address.task_queue,
1068 activity_type = %address.activity_type,
1069 %error,
1070 "poller census failed while reporting a saturated queue; \
1071 the refusal carries an empty census"
1072 );
1073 PoolCensus::default()
1074 });
1075 let unavailable = WorkerUnavailable {
1076 reason: QueueServiceReason::Saturated,
1077 clock: Some(ExpiredClock::ScheduleToStart),
1078 waited: *waited,
1079 address: address.clone(),
1080 census,
1081 };
1082 ("WorkerUnavailable", unavailable.reason_string())
1083 }
1084 DeliveryRefusal::Full => (
1085 "WorkerChannelClosed",
1086 "worker task channel full or closed: no available capacity".to_owned(),
1087 ),
1088 DeliveryRefusal::Closed => (
1089 "WorkerChannelClosed",
1090 "worker task channel full or closed: channel closed".to_owned(),
1091 ),
1092 DeliveryRefusal::NotAccepting { reason } => ("WorkerDispatch", reason.clone()),
1093 }
1094 }
1095
1096 /// Deliver one dispatched task to a liminal-connected worker: push the SAME
1097 /// wire frame the outbox liminal path pushes (a
1098 /// [`DispatchRequest`](super::liminal_transport::DispatchRequest) — the
1099 /// worker's serve loop cannot tell a bridge dispatch from an outbox row) and
1100 /// hand the correlated-reply awaiter to a dedicated router thread that
1101 /// resolves this dispatch's pending entry exactly like a gRPC completion.
1102 ///
1103 /// The wire carries the SAME engine-provided `attempt` and `labels` the gRPC
1104 /// arm's `ActivityTask` carries (a retry over liminal executes with the real
1105 /// attempt, not a re-stamped first delivery), plus the server's heartbeat
1106 /// window so the worker's automatic liveness pump keeps this TRACKED
1107 /// dispatch alive under the #176 expiry sweeper. It carries the engine's
1108 /// concrete run identity, generation proof, and stable effect key exactly
1109 /// like the gRPC bridge task.
1110 ///
1111 /// A successful push also binds the attempt into the NOI-6 attempt→owner
1112 /// back-index (when installed) with the SAME `(workflow, activity, attempt)`
1113 /// key the worker stamps its intervention session with, exactly as the
1114 /// outbox liminal arm binds each row dispatch — so the ops console can
1115 /// enumerate this live attempt and route interventions to its worker. The
1116 /// binding is released when the reply router exits (reply, abandonment, or
1117 /// disconnect — every path). The gRPC arm carries no bind because the agent
1118 /// harness seam exists only on the liminal worker transport.
1119 #[cfg(feature = "liminal-transport")]
1120 fn send_liminal_activity_task(
1121 &self,
1122 worker_id: WorkerId,
1123 delivery: &super::liminal_transport::LiminalWorkerDelivery,
1124 task: ProtoActivityTask,
1125 activity_type: &str,
1126 handoff: &super::lease_record::LeaseHandoff,
1127 ) -> Result<(), String> {
1128 let workflow_id = &handoff.key().workflow_id;
1129 let activity_id = &handoff.key().activity_id;
1130 let completion_token =
1131 CompletionToken::from_wire(workflow_id, activity_id, task.completion_token.clone())
1132 .map_err(|error| error.to_string())?;
1133 let heartbeat_window_ms =
1134 u64::try_from(self.heartbeat_tracker.heartbeat_window().as_millis())
1135 .unwrap_or(u64::MAX);
1136 let attempt = task.attempt;
1137 // The run is resolved BEFORE the dispatch is built, because everything
1138 // downstream is keyed on it: the worker refuses a run-less dispatch
1139 // envelope outright, the attempt-owner binding below needs it to name a
1140 // generation, and the attempt's transcript is written under it. Refusing
1141 // here reports the fault against the dispatch that has it, rather than
1142 // shipping a frame whose only possible outcome is the worker's rejection.
1143 let run_id = task
1144 .run_id
1145 .map(RunId::try_from)
1146 .transpose()
1147 .map_err(|error| error.to_string())?
1148 .ok_or_else(|| {
1149 "activity task run id is missing; refusing to dispatch an unidentified run"
1150 .to_owned()
1151 })?;
1152 let request = super::liminal_transport::DispatchRequest {
1153 activity_type: activity_type.to_owned(),
1154 workflow_id: workflow_id.clone(),
1155 ordinal: activity_id.sequence_position(),
1156 run_id: Some(run_id.clone()),
1157 attempt,
1158 completion_token: task.completion_token,
1159 idempotency_key: task.idempotency_key,
1160 labels: task.labels.into_iter().collect(),
1161 heartbeat_window_ms,
1162 input: task.input.map(|payload| payload.bytes).unwrap_or_default(),
1163 };
1164 // A push-enqueue failure means the worker's connection was already gone
1165 // at push time — the same synchronous-failure contract as a closed gRPC
1166 // stream channel above.
1167 let awaiter = match delivery.push_dispatch(&request) {
1168 Ok(awaiter) => awaiter,
1169 Err(error) => {
1170 let reason = format!("worker liminal push failed: {error}");
1171 self.cleanup_activity(
1172 worker_id,
1173 workflow_id,
1174 activity_id,
1175 attempt,
1176 &completion_token,
1177 );
1178 log_worker_error(
1179 "WorkerChannelClosed",
1180 &self.namespace,
1181 activity_type,
1182 workflow_id,
1183 activity_id,
1184 Some(worker_id),
1185 &reason,
1186 );
1187 return Err(reason);
1188 }
1189 };
1190 // NOI-6: the attempt is live on `worker_id` from this push until the
1191 // router resolves it — bind it for exactly that window (the guard is
1192 // dropped when the router thread exits).
1193 // The push was acknowledged: the worker holds the attempt, and the
1194 // lease is recorded before the reply router is even spawned (WA-010
1195 // R3) — the reply IS the completion, so nothing may wait for it first.
1196 handoff.accepted_blocking(self.tokio_handle.as_ref());
1197 let owner_binding = self.attempt_owners.as_ref().map(|owners| {
1198 super::liminal_transport::AttemptOwnerGuard::bind(
1199 owners.clone(),
1200 super::intervention::AttemptKey::new(
1201 workflow_id.clone(),
1202 run_id.clone(),
1203 activity_id.clone(),
1204 attempt,
1205 ),
1206 worker_id,
1207 )
1208 });
1209 self.spawn_liminal_reply_router(
1210 worker_id,
1211 awaiter,
1212 workflow_id,
1213 activity_id,
1214 &completion_token,
1215 owner_binding,
1216 );
1217 Ok(())
1218 }
1219
1220 /// Waits (on a dedicated router thread, bounded by the dispatch's own
1221 /// lifetime) for the worker's correlated
1222 /// [`DispatchResponse`](super::liminal_transport::DispatchResponse) and
1223 /// re-enters it through the SAME completion bookkeeping the gRPC inbound
1224 /// stream applies (`process_inbound` in `worker_grpc.rs`): clear the
1225 /// in-flight liveness entry, wake any drain waiter, then resolve the
1226 /// bridge's pending map — result, failure, and retryable classification
1227 /// identical (the worker encodes the `retryable:`/`terminal:` reason
1228 /// vocabulary on the wire). An unmatched (already-resolved) REAL reply
1229 /// routes through the outbox delivery callback exactly like a late gRPC
1230 /// result.
1231 ///
1232 /// The #176 heartbeat sweeper covers this dispatch exactly as it covers a
1233 /// gRPC one: the dispatch is tracked in the shared [`HeartbeatTracker`] and
1234 /// the worker's runtime pumps automatic liveness beats over the reserved
1235 /// liminal channel (`WORKER_LIVENESS_CHANNEL`), so a healthy worker running
1236 /// an over-window activity is never falsely expired while a wedged one
1237 /// still is. Prompt worker-DEATH detection additionally rides the
1238 /// connection itself — the awaiter wakes with the typed Disconnected error
1239 /// the moment the connection closes, resolving the SAME retryable
1240 /// lost-worker failure the gRPC stream-teardown sweep reports.
1241 ///
1242 /// Two structural guards mirror the gRPC arm's tracker gating:
1243 ///
1244 /// - A SYNTHESIZED failure (disconnect / receive fault) is delivered only
1245 /// when this router's own `complete_task` actually retired the tracked
1246 /// entry — the same "fail only still-tracked tasks" gate
1247 /// `remove_worker_tasks` gives the gRPC sweeps — so a dispatch already
1248 /// resolved elsewhere (expiry sweep, shutdown drain, deregistered
1249 /// fast path) never has a spurious failure injected for an ordinal whose
1250 /// retry may be live on another worker.
1251 /// - The wait itself ends one reply-poll after the tracked entry
1252 /// disappears, so an abandoned dispatch never parks this thread for the
1253 /// remaining life of the worker's connection. A real reply arriving
1254 /// AFTER that exit is dropped (the resolving path owns the ordinal — its
1255 /// retry re-executes); this is the one deliberate divergence from the
1256 /// gRPC arm, whose shared stream task routes any late result to the
1257 /// outbox callback, and it is the safer half of the trade because a
1258 /// stale attempt's result can never resolve a newer attempt's entry.
1259 #[cfg(feature = "liminal-transport")]
1260 fn spawn_liminal_reply_router(
1261 &self,
1262 worker_id: WorkerId,
1263 awaiter: liminal_server::server::connection::PushReplyAwaiter,
1264 workflow_id: &WorkflowId,
1265 activity_id: &ActivityId,
1266 completion_token: &CompletionToken,
1267 owner_binding: Option<super::liminal_transport::AttemptOwnerGuard>,
1268 ) {
1269 let pending = self.pending.clone();
1270 let heartbeat_tracker = self.heartbeat_tracker.clone();
1271 let registry = self.registry.clone();
1272 let drain_state = self.drain_state.clone();
1273 let workflow_id = workflow_id.clone();
1274 let activity_id = activity_id.clone();
1275 let completion_token = completion_token.clone();
1276 std::thread::spawn(move || {
1277 // Owns the NOI-6 attempt binding for the dispatch's lifetime: it
1278 // drops (releasing the back-index entry) when this router exits,
1279 // on every path — reply, abandonment, disconnect, or panic.
1280 let _owner_binding = owner_binding;
1281 route_liminal_reply(
1282 &pending,
1283 &heartbeat_tracker,
1284 ®istry,
1285 &drain_state,
1286 &awaiter,
1287 (worker_id, &workflow_id, &activity_id, &completion_token),
1288 );
1289 });
1290 }
1291
1292 /// Block until the dispatch terminates (see the module docs for the
1293 /// exhaustive termination list). The wait is deliberately unbounded:
1294 /// the engine imposes no activity timeout of its own.
1295 fn await_activity_result(
1296 &self,
1297 context: &ActivityDispatchContext<'_>,
1298 rx: &SyncReceiver,
1299 ) -> Result<String, String> {
1300 // Close the dispatch/disconnect race before blocking. A worker whose
1301 // stream tore down *before* this dispatch tracked its task was swept
1302 // without this entry, so nothing would ever deliver through `rx`.
1303 // `fail_lost_worker` deregisters before it collects tasks, and this
1304 // dispatch tracked its task before sending, so: if the worker is
1305 // still registered here, any later sweep is guaranteed to include
1306 // this task and unblock the `recv` below.
1307 match self.registry.is_registered(context.worker_id) {
1308 Ok(true) => {}
1309 Ok(false) => {
1310 // A sweep that did include this task may have delivered
1311 // already; prefer its verdict (or a genuine result that
1312 // raced the disconnect) over fabricating one.
1313 if let Ok(result) = rx.try_recv() {
1314 return self.deliver_result(context, result);
1315 }
1316 self.cleanup_activity(
1317 context.worker_id,
1318 context.workflow_id,
1319 context.activity_id,
1320 context.attempt,
1321 &context.completion_token,
1322 );
1323 // TRANSPORT domain, classified through the shared ledger: the
1324 // activity never executed, so the failure never wears the
1325 // action's retry vocabulary and is re-dispatched
1326 // attempt-neutrally until the transport's own budget is spent.
1327 let reason = self.pending.classify_worker_loss(
1328 context.workflow_id,
1329 context.activity_id,
1330 context.worker_id,
1331 );
1332 log_worker_error(
1333 "WorkerLost",
1334 &self.namespace,
1335 context.activity_type,
1336 context.workflow_id,
1337 context.activity_id,
1338 Some(context.worker_id),
1339 &reason,
1340 );
1341 return Err(reason);
1342 }
1343 Err(error) => {
1344 self.cleanup_activity(
1345 context.worker_id,
1346 context.workflow_id,
1347 context.activity_id,
1348 context.attempt,
1349 &context.completion_token,
1350 );
1351 let reason = format!("worker registry inspection failed: {error}");
1352 log_worker_error(
1353 "WorkerRegistry",
1354 &self.namespace,
1355 context.activity_type,
1356 context.workflow_id,
1357 context.activity_id,
1358 Some(context.worker_id),
1359 &reason,
1360 );
1361 return Err(reason);
1362 }
1363 }
1364 if let Ok(result) = rx.recv() {
1365 return self.deliver_result(context, result);
1366 }
1367 // Every sender was dropped without completing: a cleanup path
1368 // removed the pending entry. Surface it instead of hanging.
1369 self.cleanup_activity(
1370 context.worker_id,
1371 context.workflow_id,
1372 context.activity_id,
1373 context.attempt,
1374 &context.completion_token,
1375 );
1376 let reason = "activity response channel dropped".to_owned();
1377 log_worker_error(
1378 "WorkerChannelClosed",
1379 &self.namespace,
1380 context.activity_type,
1381 context.workflow_id,
1382 context.activity_id,
1383 Some(context.worker_id),
1384 &reason,
1385 );
1386 Err(reason)
1387 }
1388
1389 fn deliver_result(
1390 &self,
1391 context: &ActivityDispatchContext<'_>,
1392 result: Result<String, String>,
1393 ) -> Result<String, String> {
1394 // Own-attempt-only (aion#195): after a higher attempt takes this site
1395 // over, the superseded dispatch resolving here must not evict the live
1396 // waiter that replaced it.
1397 self.pending.pending.remove_if(
1398 &(context.workflow_id.clone(), context.activity_id.clone()),
1399 |_, waiter| waiter.attempt == context.attempt,
1400 );
1401 // A parked dispatch (#207) is not a failure: the server is draining and
1402 // restart recovery re-dispatches the ordinal. Info, never error — a
1403 // routine deploy must not emit an ActivityFailed log per in-flight
1404 // dispatch (the incident's alarm noise).
1405 if let Err(reason) = &result
1406 && aion::is_parked_reason(reason)
1407 {
1408 tracing::info!(
1409 operation = "activity_dispatch",
1410 namespace = %self.namespace,
1411 workflow_id = %context.workflow_id,
1412 activity_id = %context.activity_id,
1413 activity_type = context.activity_type,
1414 worker_id = ?context.worker_id,
1415 "activity parked for restart recovery"
1416 );
1417 return result;
1418 }
1419 log_activity_completion(context, result.is_ok());
1420 result.inspect_err(|reason| {
1421 log_worker_error(
1422 "ActivityFailed",
1423 &self.namespace,
1424 context.activity_type,
1425 context.workflow_id,
1426 context.activity_id,
1427 Some(context.worker_id),
1428 reason,
1429 );
1430 })
1431 }
1432}
1433
1434impl ActivityDispatcher for WorkerActivityDispatcher {
1435 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
1436 match tokio::runtime::Handle::try_current() {
1437 Ok(handle) => match handle.runtime_flavor() {
1438 tokio::runtime::RuntimeFlavor::MultiThread => {
1439 // We are inside a tokio runtime (the engine spawns the
1440 // sync dispatch onto its handle). Hand this worker's
1441 // scheduler core to another thread before blocking so the
1442 // stream forwarder woken by our `try_send` can actually
1443 // run — otherwise it is trapped in this worker's
1444 // non-stealable LIFO slot for as long as we block.
1445 tokio::task::block_in_place(|| {
1446 self.dispatch_blocking(request, &LeaseSignal::none())
1447 })
1448 }
1449 flavor => Err(format!(
1450 "activity dispatch blocks the calling thread until the worker responds; \
1451 a {flavor:?} tokio runtime cannot host that wait because the worker \
1452 stream forwarder shares its only executor thread and the task could \
1453 never be delivered — run the engine on a multi-thread tokio runtime"
1454 )),
1455 },
1456 // No tokio context: a beamr scheduler thread or other plain OS
1457 // thread. Blocking here is the designed contract and cannot starve
1458 // the server runtime.
1459 Err(_) => self.dispatch_blocking(request, &LeaseSignal::none()),
1460 }
1461 }
1462
1463 /// The awaited entry, overridden so the LEASE reaches the engine's
1464 /// per-attempt timer.
1465 ///
1466 /// The default runs the synchronous entry above and fires the signal
1467 /// immediately, which is right for a dispatcher that has no lease step.
1468 /// THIS dispatcher has one, and it is separated from the call by an
1469 /// unbounded park: `dispatch_blocking` waits for a worker to exist before
1470 /// anything can be handed over. Firing at the call would put that park
1471 /// inside a bound written to describe execution — the defect — so the
1472 /// signal travels down to the handover instead and fires at the same
1473 /// instant the durable `ActivityLeased` record is made.
1474 ///
1475 /// `spawn_blocking` rather than the default's `dispatch` hop: the blocking
1476 /// pool is exactly the "no scheduler core to give up" condition the
1477 /// `block_in_place` branch above exists to manufacture, so going through it
1478 /// again would be a hop that arranges what is already true.
1479 fn dispatch_async(
1480 self: std::sync::Arc<Self>,
1481 request: ActivityDispatch,
1482 lease: LeaseSignal,
1483 ) -> futures::future::BoxFuture<'static, Result<String, String>> {
1484 Box::pin(async move {
1485 let blocking =
1486 tokio::task::spawn_blocking(move || self.dispatch_blocking(request, &lease));
1487 match blocking.await {
1488 Ok(result) => result,
1489 Err(join_error) => Err(format!("activity dispatch task failed: {join_error}")),
1490 }
1491 })
1492 }
1493}
1494
1495impl WorkerActivityDispatcher {
1496 /// Dispatch the activity and block the calling thread until the worker
1497 /// responds, the worker is declared lost, or the server drains (see the
1498 /// module docs for the exhaustive termination list).
1499 ///
1500 /// The request carries the *real* workflow and activity ids the engine
1501 /// recorded in history, so the worker logs, the pending-completion key,
1502 /// and the heartbeat tracker all correlate directly against the event
1503 /// store. `config` is forwarded by the engine seam but not yet consumed
1504 /// here (the retry executor that reads it is unbuilt).
1505 ///
1506 /// Must never run while the calling thread still owns a tokio scheduler
1507 /// core: the response can only arrive after the runtime's stream
1508 /// forwarder flushes the queued [`WorkerMessage::ActivityTask`] to the
1509 /// worker, so the thread blocking here must not be the one responsible
1510 /// for polling that forwarder. [`ActivityDispatcher::dispatch`] enforces
1511 /// this with `tokio::task::block_in_place`.
1512 fn dispatch_blocking(
1513 &self,
1514 request: ActivityDispatch,
1515 lease: &LeaseSignal,
1516 ) -> Result<String, String> {
1517 let ActivityDispatch {
1518 namespace,
1519 task_queue,
1520 // OPTIONAL within-pool node affinity (NODE-4): `Some(n)` pins this
1521 // dispatch to workers advertising node `n` (require semantics);
1522 // `None` is unpinned and reaches any worker in the pool.
1523 node,
1524 workflow_id,
1525 run_id,
1526 activity_id,
1527 name,
1528 input,
1529 config: _,
1530 attempt,
1531 labels,
1532 // R5 advisory class: engine-side only. The wire carries no such
1533 // field and a worker's behaviour is identical either way — the
1534 // class governs how the ENGINE treats the failure, never how the
1535 // work is done.
1536 advisory: _,
1537 } = request;
1538 let started_at = Instant::now();
1539 self.ensure_accepting(&namespace, &name, &workflow_id, &activity_id, None)?;
1540 let address = ServiceAddress {
1541 namespace: namespace.clone(),
1542 task_queue: task_queue.clone(),
1543 activity_type: name.clone(),
1544 node: node.clone(),
1545 };
1546 // `reservation` holds this worker's capacity slot from the moment it was
1547 // chosen. It is dropped only after `track_then_send` below has handed
1548 // the count to the heartbeat tracker; every `?` between here and there
1549 // releases it on the way out, which is why none of those arms mention
1550 // capacity.
1551 let (worker, reservation) =
1552 self.select_worker_or_wait(&address, &workflow_id, &activity_id)?;
1553 let worker_id = worker.id();
1554 let span = info_span!(
1555 "activity_dispatch",
1556 operation = "activity_dispatch",
1557 namespace = %namespace,
1558 task_queue = %task_queue,
1559 node = node.as_deref(),
1560 workflow_id = %workflow_id,
1561 activity_id = %activity_id,
1562 activity_type = %name,
1563 worker_id = ?worker_id,
1564 );
1565 let _span_guard = span.enter();
1566 self.ensure_accepting(
1567 &namespace,
1568 &name,
1569 &workflow_id,
1570 &activity_id,
1571 Some(worker_id),
1572 )?;
1573
1574 let (completion_token, rx, superseded_attempt) = self
1575 .pending
1576 .insert(workflow_id.clone(), &run_id, activity_id.clone(), attempt)
1577 .map_err(|error| error.to_string())?;
1578 if let Some(held_attempt) = superseded_attempt {
1579 // aion#195: this retry took the site over from a timed-out
1580 // attempt whose responder is still live. Walk the release ladder
1581 // for the corpse BEFORE this attempt's work begins: ask its
1582 // worker to stop, and untrack its heartbeat task so the expiry
1583 // sweep never presents its stale token mid-sweep.
1584 self.release_superseded_attempt(&workflow_id, &activity_id, held_attempt, attempt);
1585 }
1586 // The lease this delivery's acceptance records (WA-010 R3), armed on
1587 // the fences before the push; settled by the handoff whether the push
1588 // lands or not, so a completion never waits on a lease that will not
1589 // come.
1590 let handoff = super::lease_record::LeaseHandoff::arm(
1591 self.pending.lease_recorder(),
1592 super::lease_record::LeaseKey {
1593 workflow_id: workflow_id.clone(),
1594 run_id: run_id.clone(),
1595 activity_id: activity_id.clone(),
1596 attempt,
1597 },
1598 super::lease_record::attribution_for(&worker),
1599 self.pending.completion_fences(),
1600 completion_token.clone(),
1601 // The engine's per-attempt clock starts where the durable lease
1602 // record is made, not where this call began. Both are this
1603 // handoff's job, so it holds both — one anchor by construction,
1604 // rather than two sites that agree only while somebody keeps them
1605 // agreeing.
1606 lease.clone(),
1607 )
1608 .map_err(|error| error.to_string())?;
1609 let task = activity_task(
1610 &name,
1611 &input,
1612 (&workflow_id, &run_id, &activity_id),
1613 attempt,
1614 labels,
1615 &completion_token,
1616 );
1617 // The reservation goes INTO the tracking call: one act, no window in
1618 // which the count reads higher than the truth.
1619 self.track_then_send(
1620 (worker_id, &worker),
1621 task,
1622 &address,
1623 (&name, &workflow_id, &activity_id, attempt),
1624 (&completion_token, &handoff),
1625 reservation,
1626 )?;
1627 let context = ActivityDispatchContext {
1628 namespace: &namespace,
1629 activity_type: &name,
1630 worker_id,
1631 workflow_id: &workflow_id,
1632 activity_id: &activity_id,
1633 attempt,
1634 completion_token,
1635 started_at,
1636 };
1637 self.await_activity_result(&context, &rx)
1638 }
1639
1640 /// Track this dispatch's liveness entry, then push it to its worker.
1641 ///
1642 /// The two are one step because the ORDER between them is load-bearing and
1643 /// the failure path between them is too: the task is tracked BEFORE the
1644 /// push, so a sweep that fires while the push is in flight can always see
1645 /// the dispatch it has to fail; and a tracking failure must undo the
1646 /// arming this dispatch has already done rather than leave a generation
1647 /// armed for work that was never sent.
1648 fn track_then_send(
1649 &self,
1650 worker: (WorkerId, &WorkerHandle),
1651 task: ProtoActivityTask,
1652 address: &ServiceAddress,
1653 site: (&str, &WorkflowId, &ActivityId, u32),
1654 lease: (&CompletionToken, &super::lease_record::LeaseHandoff),
1655 reservation: DispatchReservation,
1656 ) -> Result<(), String> {
1657 let (worker_id, handle) = worker;
1658 let (name, workflow_id, activity_id, attempt) = site;
1659 let (completion_token, handoff) = lease;
1660 if let Err(error) = self.track_worker_task(
1661 worker_id,
1662 name,
1663 workflow_id,
1664 activity_id,
1665 attempt,
1666 (completion_token.clone(), reservation),
1667 ) {
1668 self.cleanup_activity(
1669 worker_id,
1670 workflow_id,
1671 activity_id,
1672 attempt,
1673 completion_token,
1674 );
1675 return Err(error);
1676 }
1677 self.send_activity_task(handle, task, address, handoff)
1678 }
1679
1680 /// Release the superseded attempt a takeover displaced (aion#195).
1681 ///
1682 /// Two acts, both best-effort and both reported: ask the worker still
1683 /// executing the corpse to stop (a request, not a guarantee — see
1684 /// [`super::activity_cancel`]), and untrack its heartbeat task so the
1685 /// expiry sweep never aborts mid-loop on the corpse's fence-refused stale
1686 /// token. Bookkeeping about the corpse must never fail the live retry, so
1687 /// every failure here lands in the log and nowhere else.
1688 fn release_superseded_attempt(
1689 &self,
1690 workflow_id: &WorkflowId,
1691 activity_id: &ActivityId,
1692 held_attempt: u32,
1693 new_attempt: u32,
1694 ) {
1695 let in_flight = match self.heartbeat_tracker.in_flight_for_workflow(workflow_id) {
1696 Ok(in_flight) => in_flight,
1697 Err(error) => {
1698 tracing::error!(
1699 workflow_id = %workflow_id,
1700 activity_id = %activity_id,
1701 held_attempt,
1702 new_attempt,
1703 %error,
1704 "superseded-attempt release could not read the in-flight tracker; \
1705 the corpse's worker was not asked to stop"
1706 );
1707 return;
1708 }
1709 };
1710 let holders = in_flight
1711 .into_iter()
1712 .filter(|task| &task.activity_id == activity_id && task.attempt == held_attempt);
1713 let mut asked_anyone = false;
1714 for task in holders {
1715 asked_anyone = true;
1716 let delivery = match super::activity_cancel::ask_worker_to_stop(
1717 &self.registry,
1718 task.worker_id,
1719 workflow_id,
1720 activity_id,
1721 ) {
1722 Ok(delivery) => Some(delivery),
1723 Err(error) => {
1724 // ASKED AND FAILED IS STILL SUPERSEDED. This used to
1725 // `continue`, which skipped the `complete_task` below and
1726 // leaked the corpse's capacity slot permanently — the
1727 // registry count only comes down on completion or
1728 // deregistration, so a worker whose stop request failed
1729 // silently shrank by one slot for the life of its
1730 // registration, and the expiry sweep kept a dispatch nobody
1731 // would ever answer.
1732 //
1733 // The stop request is best-effort by construction (it is a
1734 // REQUEST, not a guarantee — see `activity_cancel`). Whether
1735 // it reached the worker changes nothing about the fact this
1736 // attempt has been superseded, so the bookkeeping proceeds
1737 // either way and the failed request is reported on its own.
1738 //
1739 // NOT COVERED BY A TEST, and worth saying why rather than
1740 // leaving it to be discovered: `ask_worker_to_stop` returns
1741 // `Ok` for every reachable outcome — worker gone, transport
1742 // cannot carry, channel full — and reaches `Err` only when
1743 // the registry lock is POISONED. Constructing that state
1744 // means panicking inside the registry's mutex, which would
1745 // poison it for the rest of the process. The leak this arm
1746 // fixes was therefore real but confined to a poisoned-lock
1747 // path; the correction is that the bookkeeping no longer
1748 // depends on the request having succeeded.
1749 tracing::warn!(
1750 workflow_id = %workflow_id,
1751 activity_id = %activity_id,
1752 held_attempt,
1753 worker_id = task.worker_id.value(),
1754 %error,
1755 "superseded-attempt release could not ask the holding worker to stop; \
1756 untracking the corpse anyway so its capacity slot is not leaked"
1757 );
1758 None
1759 }
1760 };
1761 if let Err(error) = self.heartbeat_tracker.complete_task(
1762 task.worker_id,
1763 workflow_id,
1764 activity_id,
1765 &self.registry,
1766 ) {
1767 tracing::warn!(
1768 workflow_id = %workflow_id,
1769 activity_id = %activity_id,
1770 held_attempt,
1771 worker_id = task.worker_id.value(),
1772 %error,
1773 "superseded-attempt release could not untrack the corpse's heartbeat task"
1774 );
1775 }
1776 let Some(delivery) = delivery else {
1777 // The stop request itself failed and was reported above. The
1778 // corpse is untracked, its slot is back, and there is no
1779 // delivery outcome to describe.
1780 continue;
1781 };
1782 if delivery.was_requested() {
1783 tracing::info!(
1784 workflow_id = %workflow_id,
1785 activity_id = %activity_id,
1786 held_attempt,
1787 new_attempt,
1788 worker_id = task.worker_id.value(),
1789 "attempt takeover: asked the worker still executing the superseded \
1790 attempt to stop"
1791 );
1792 } else {
1793 tracing::warn!(
1794 workflow_id = %workflow_id,
1795 activity_id = %activity_id,
1796 held_attempt,
1797 new_attempt,
1798 worker_id = task.worker_id.value(),
1799 outcome = ?delivery,
1800 "attempt takeover: the superseded attempt's worker could NOT be asked \
1801 to stop — its work runs on unwitnessed until it next reports, and its \
1802 completion will be refused as a stale generation"
1803 );
1804 }
1805 }
1806 if !asked_anyone {
1807 tracing::info!(
1808 workflow_id = %workflow_id,
1809 activity_id = %activity_id,
1810 held_attempt,
1811 new_attempt,
1812 "attempt takeover: no tracked worker holds the superseded attempt \
1813 (already untracked, or its transport records no heartbeat task); \
1814 its completion token is fence-refused regardless"
1815 );
1816 }
1817 }
1818}
1819
1820/// One failure the reply router FABRICATED (a disconnect, an unservable
1821/// frame, or a receive fault) in the shape [`route_liminal_reply`] settles:
1822/// no run id, the token THIS push dispatched, the reason, and the
1823/// `synthesized` flag set so delivery is gated on the in-flight tracker.
1824#[cfg(feature = "liminal-transport")]
1825fn synthesized_failure(
1826 current_token: &CompletionToken,
1827 reason: String,
1828) -> (Option<RunId>, CompletionToken, Result<String, String>, bool) {
1829 (None, current_token.clone(), Err(reason), true)
1830}
1831
1832/// Body of one liminal reply-router thread (see
1833/// [`WorkerActivityDispatcher::spawn_liminal_reply_router`] for the contract).
1834///
1835/// Resolves by the key THIS push dispatched (the awaiter is already
1836/// correlation-scoped to it), never by the reply's echoed ids: a buggy echo
1837/// must not cross executions.
1838#[cfg(feature = "liminal-transport")]
1839fn route_liminal_reply(
1840 pending: &PendingActivities,
1841 heartbeat_tracker: &HeartbeatTracker,
1842 registry: &crate::worker::ConnectedWorkerRegistry,
1843 drain_state: &DrainState,
1844 awaiter: &liminal_server::server::connection::PushReplyAwaiter,
1845 execution: (WorkerId, &WorkflowId, &ActivityId, &CompletionToken),
1846) {
1847 let (worker_id, workflow_id, activity_id, current_token) = execution;
1848 // The wait re-arms only while this dispatch is still tracked in-flight, so
1849 // a dispatch resolved elsewhere (expiry sweep, shutdown drain, cleanup)
1850 // releases this thread within one reply poll instead of parking it for the
1851 // remaining life of the worker's connection.
1852 let waited = super::liminal_transport::receive_bridge_reply(awaiter, || {
1853 heartbeat_tracker
1854 .is_tracked(worker_id, workflow_id, activity_id)
1855 .unwrap_or(false)
1856 });
1857 // `synthesized` marks a failure this router FABRICATED (disconnect or
1858 // receive fault) as opposed to a real worker reply: only fabricated
1859 // failures are gated on the tracker below.
1860 let (run_id, submitted_token, outcome, synthesized) = match waited {
1861 Ok(Some(response)) => {
1862 let submitted_token = match CompletionToken::from_wire(
1863 workflow_id,
1864 activity_id,
1865 response.completion_token,
1866 ) {
1867 Ok(token) => token,
1868 Err(error) => {
1869 tracing::warn!(
1870 worker_id = ?worker_id,
1871 workflow_id = %workflow_id,
1872 activity_id = %activity_id,
1873 %error,
1874 "liminal activity completion omitted its generation proof"
1875 );
1876 return;
1877 }
1878 };
1879 (response.run_id, submitted_token, response.outcome, false)
1880 }
1881 Ok(None) => {
1882 tracing::debug!(
1883 worker_id = ?worker_id,
1884 workflow_id = %workflow_id,
1885 activity_id = %activity_id,
1886 "liminal dispatch resolved by another path; abandoning reply wait"
1887 );
1888 return;
1889 }
1890 // UNSERVABLE, before the lost arm: liminal proved the frame larger than
1891 // the connection's whole outbound buffer. The lost arm below would
1892 // classify it through the transport-loss ledger and re-dispatch it
1893 // attempt-neutrally, forever — the 846-lease fault. The engine reads any
1894 // non-`retryable:` prefix as settled (`nif_activity_retry::is_retryable_reason`),
1895 // so this outcome records one `ActivityFailed` and stops.
1896 Err(error) if error.is_worker_dispatch_unservable() => {
1897 synthesized_failure(current_token, format!("terminal:{error}"))
1898 }
1899 // TRANSPORT domain (the worker's connection closed before it replied):
1900 // classified through the shared ledger, never as an action failure.
1901 Err(error) if error.is_worker_connection_lost() => synthesized_failure(
1902 current_token,
1903 pending.classify_worker_loss(workflow_id, activity_id, worker_id),
1904 ),
1905 Err(error) => synthesized_failure(
1906 current_token,
1907 format!("retryable:worker liminal reply failed: {error}"),
1908 ),
1909 };
1910 // The gRPC sweeps fail only still-tracked tasks (`remove_worker_tasks`);
1911 // this is the same structural gate: `complete_task` reports whether THIS
1912 // call retired the tracked entry. A poisoned tracker fails open (deliver)
1913 // so the blocked dispatch thread is never left hanging on a broken lock.
1914 let after_accept = || {
1915 if synthesized {
1916 let was_tracked = clear_completed_task_tracking(
1917 heartbeat_tracker,
1918 registry,
1919 worker_id,
1920 workflow_id,
1921 activity_id,
1922 );
1923 if !was_tracked {
1924 // Another path already resolved this dispatch (and notified drain):
1925 // injecting the fabricated lost-worker failure now could reach a retry.
1926 tracing::debug!(
1927 worker_id = ?worker_id,
1928 workflow_id = %workflow_id,
1929 activity_id = %activity_id,
1930 "liminal dispatch already resolved; dropping synthesized lost-worker failure"
1931 );
1932 return Ok(false);
1933 }
1934 } else {
1935 // A real worker result: the tracking clear is bookkeeping, never
1936 // a gate. Fail open — withholding a received result over a
1937 // poisoned tracker would leave the dispatch waiter parked on an
1938 // unbounded receive with no recovery path, because the lost-worker
1939 // sweep reads the same poisoned lock.
1940 let _ = clear_completed_task_tracking(
1941 heartbeat_tracker,
1942 registry,
1943 worker_id,
1944 workflow_id,
1945 activity_id,
1946 );
1947 }
1948 drain_state.notify_activity_drained();
1949 Ok(true)
1950 };
1951 if let Err(error) = pending.complete_fenced_after_accept(
1952 workflow_id,
1953 activity_id,
1954 run_id.as_ref(),
1955 &submitted_token,
1956 outcome,
1957 after_accept,
1958 ) {
1959 tracing::warn!(
1960 worker_id = ?worker_id,
1961 workflow_id = %workflow_id,
1962 activity_id = %activity_id,
1963 %error,
1964 "liminal activity completion handoff rejected"
1965 );
1966 }
1967}
1968
1969/// Clears a completed activity's in-flight tracking entry, reporting whether
1970/// THIS call retired it. Fails open on a poisoned tracker — logged loudly and
1971/// treated as "was tracked" — because the clear is bookkeeping, never a gate:
1972/// withholding a completion over the poisoned lock would strand the dispatch
1973/// waiter on an unbounded receive, and the lost-worker sweep cannot recover it
1974/// (it reads the same lock).
1975pub(crate) fn clear_completed_task_tracking(
1976 heartbeat_tracker: &HeartbeatTracker,
1977 registry: &crate::worker::ConnectedWorkerRegistry,
1978 worker_id: WorkerId,
1979 workflow_id: &WorkflowId,
1980 activity_id: &ActivityId,
1981) -> bool {
1982 heartbeat_tracker
1983 .complete_task(worker_id, workflow_id, activity_id, registry)
1984 .unwrap_or_else(|error| {
1985 tracing::error!(
1986 worker_id = ?worker_id,
1987 workflow_id = %workflow_id,
1988 activity_id = %activity_id,
1989 %error,
1990 "failed to clear in-flight tracking for a completed activity; delivering anyway — a poisoned tracker must not withhold a result"
1991 );
1992 true
1993 })
1994}
1995
1996struct ActivityDispatchContext<'a> {
1997 namespace: &'a str,
1998 activity_type: &'a str,
1999 worker_id: WorkerId,
2000 workflow_id: &'a WorkflowId,
2001 activity_id: &'a ActivityId,
2002 /// Attempt this dispatch was delivered at — the identity its own-attempt
2003 /// teardown paths remove the waiter slot by (aion#195).
2004 attempt: u32,
2005 completion_token: CompletionToken,
2006 started_at: Instant,
2007}
2008
2009fn activity_task(
2010 activity_type: &str,
2011 input: &str,
2012 execution: (&WorkflowId, &RunId, &ActivityId),
2013 attempt: u32,
2014 labels: BTreeMap<String, String>,
2015 completion_token: &CompletionToken,
2016) -> ProtoActivityTask {
2017 let (workflow_id, run_id, activity_id) = execution;
2018 ProtoActivityTask {
2019 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
2020 activity_id: Some(ProtoActivityId::from(activity_id.clone())),
2021 activity_type: activity_type.to_owned(),
2022 input: Some(ProtoPayload {
2023 content_type: String::from("application/json"),
2024 bytes: input.as_bytes().to_vec(),
2025 }),
2026 attempt,
2027 labels: labels.into_iter().collect(),
2028 run_id: Some(run_id.clone().into()),
2029 completion_token: completion_token.as_str().to_owned(),
2030 idempotency_key: idempotency_key(workflow_id, run_id, activity_id),
2031 }
2032}
2033
2034fn log_activity_completion(context: &ActivityDispatchContext<'_>, succeeded: bool) {
2035 let duration_ms = duration_ms(context.started_at.elapsed());
2036 tracing::info!(
2037 operation = "activity_complete",
2038 namespace = context.namespace,
2039 workflow_id = %context.workflow_id,
2040 activity_id = %context.activity_id,
2041 activity_type = context.activity_type,
2042 worker_id = ?context.worker_id,
2043 duration_ms,
2044 outcome = if succeeded { "succeeded" } else { "failed" },
2045 "activity completed"
2046 );
2047}
2048
2049fn duration_ms(duration: Duration) -> u64 {
2050 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
2051}
2052
2053fn log_worker_error(
2054 error_type: &'static str,
2055 namespace: &str,
2056 activity_type: &str,
2057 workflow_id: &WorkflowId,
2058 activity_id: &ActivityId,
2059 worker_id: Option<super::registry::WorkerId>,
2060 reason: &str,
2061) {
2062 tracing::error!(
2063 operation = "activity_dispatch",
2064 namespace,
2065 workflow_id = %workflow_id,
2066 activity_id = %activity_id,
2067 activity_type,
2068 worker_id = ?worker_id,
2069 error_type,
2070 reason,
2071 "worker interaction failed"
2072 );
2073}
2074
2075#[cfg(test)]
2076mod tests {
2077 use std::sync::{
2078 Mutex,
2079 atomic::{AtomicBool, Ordering},
2080 };
2081
2082 use aion_core::{ActivityError, ActivityErrorKind, ContentType, Payload};
2083
2084 use crate::worker::dispatch::ActivityCompletionOutcome;
2085
2086 use super::*;
2087
2088 fn activity_id(pos: u64) -> ActivityId {
2089 ActivityId::from_sequence_position(pos)
2090 }
2091
2092 /// One delivery of attempt 1 for `(workflow_id, id)` in its own run — what
2093 /// every single-dispatch unit test below is. The run and the attempt are the
2094 /// two discriminators the fence separates a REDELIVERY from a retry and from
2095 /// a new execution generation with, so they are stated here rather than
2096 /// implied.
2097 fn insert_attempt_one(
2098 pending: &PendingActivities,
2099 workflow_id: &WorkflowId,
2100 id: &ActivityId,
2101 ) -> Result<(CompletionToken, SyncReceiver), ServerError> {
2102 let (token, rx, superseded) =
2103 pending.insert(workflow_id.clone(), &RunId::new_v4(), id.clone(), 1)?;
2104 assert!(
2105 superseded.is_none(),
2106 "a first insert must never report a superseded holder"
2107 );
2108 Ok((token, rx))
2109 }
2110
2111 #[test]
2112 fn pending_insert_and_complete_delivers_result() -> Result<(), ServerError> {
2113 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2114 let workflow_id = WorkflowId::new_v4();
2115 let id = activity_id(1);
2116 let rx = insert_attempt_one(&pending, &workflow_id, &id)?.1;
2117
2118 assert!(pending.complete(&workflow_id, &id, None, Ok("done".to_owned())));
2119 assert_eq!(
2120 rx.recv_timeout(Duration::from_millis(50)),
2121 Ok(Ok("done".to_owned()))
2122 );
2123 Ok(())
2124 }
2125
2126 /// #246 / #254 red: retrying an activity while the same worker still holds
2127 /// the prior attempt must never replace its responder silently. Today's
2128 /// attempt-free `DashMap::insert` drops the first sender, producing the
2129 /// terminal "activity response channel dropped" specimen.
2130 #[test]
2131 fn same_execution_collision_is_a_named_typed_refusal() -> Result<(), ServerError> {
2132 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2133 let workflow_id = WorkflowId::new_v4();
2134 let id = activity_id(2);
2135 // The SAME execution redelivered: same run, same attempt. The fence
2136 // treats that as a sibling authorization; the pending map still refuses
2137 // to replace a live responder.
2138 let run_id = RunId::new_v4();
2139 let _first = pending.insert(workflow_id.clone(), &run_id, id.clone(), 1)?;
2140
2141 let error = pending
2142 .insert(workflow_id, &run_id, id, 1)
2143 .err()
2144 .ok_or_else(|| {
2145 ServerError::worker_dispatch(
2146 "test",
2147 "same-worker-collision",
2148 "a live responder collision was silently replaced",
2149 )
2150 })?;
2151
2152 assert!(
2153 error.to_string().contains("pending activity collision"),
2154 "{error}"
2155 );
2156 Ok(())
2157 }
2158
2159 /// aion#195 THE PIN: a HIGHER attempt arriving at a held execution site is
2160 /// a retry whose predecessor timed out — it takes the site over instead of
2161 /// dying on it. Before the fix, this insert returned the collision error
2162 /// and the workflow FAILED while the healthy responder ran on (workflow
2163 /// b38ecf80: attempt 2 collided with attempt 1's live responder one second
2164 /// after its backoff and the whole run was lost).
2165 #[test]
2166 fn a_higher_attempt_takes_over_a_stale_execution_site() -> Result<(), ServerError> {
2167 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2168 let workflow_id = WorkflowId::new_v4();
2169 let id = activity_id(3);
2170 let run_id = RunId::new_v4();
2171 let (_stale_token, stale_rx, none) =
2172 pending.insert(workflow_id.clone(), &run_id, id.clone(), 1)?;
2173 assert!(none.is_none());
2174
2175 let (fresh_token, fresh_rx, superseded) =
2176 pending.insert(workflow_id.clone(), &run_id, id.clone(), 2)?;
2177 assert_eq!(
2178 superseded,
2179 Some(1),
2180 "the takeover must name the attempt it displaced"
2181 );
2182
2183 // The superseded waiter is woken with the retryable sentinel, so its
2184 // blocked dispatcher thread finishes instead of hanging forever.
2185 let stale_resolution = stale_rx
2186 .recv_timeout(Duration::from_millis(50))
2187 .map_err(|_| {
2188 ServerError::worker_dispatch(
2189 "test",
2190 "takeover",
2191 "the superseded waiter was never woken",
2192 )
2193 })?;
2194 let reason = match stale_resolution {
2195 Ok(delivered) => {
2196 return Err(ServerError::worker_dispatch(
2197 "test",
2198 "takeover",
2199 format!("the superseded waiter resolved Ok({delivered}) instead of an error"),
2200 ));
2201 }
2202 Err(reason) => reason,
2203 };
2204 assert!(
2205 reason.starts_with("retryable:") && reason.contains("superseded by attempt 2"),
2206 "{reason}"
2207 );
2208
2209 // The site now belongs to attempt 2: a completion presenting attempt
2210 // 2's token delivers to attempt 2's waiter.
2211 pending.complete_activity(ActivityCompletion {
2212 workflow_id: workflow_id.clone(),
2213 activity_id: id.clone(),
2214 run_id: None,
2215 completion_token: fresh_token,
2216 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
2217 ContentType::Json,
2218 br#""second attempt wins""#.to_vec(),
2219 )),
2220 })?;
2221 assert_eq!(
2222 fresh_rx.recv_timeout(Duration::from_millis(50)),
2223 Ok(Ok(r#""second attempt wins""#.to_owned()))
2224 );
2225 Ok(())
2226 }
2227
2228 /// aion#195: a LOWER attempt arriving at a held site is a stale
2229 /// re-dispatch, not a retry — it must still be refused, and the refusal
2230 /// must name both attempts.
2231 #[test]
2232 fn a_lower_attempt_is_still_refused_by_name() -> Result<(), ServerError> {
2233 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2234 let workflow_id = WorkflowId::new_v4();
2235 let id = activity_id(4);
2236 let run_id = RunId::new_v4();
2237 let _held = pending.insert(workflow_id.clone(), &run_id, id.clone(), 3)?;
2238 let error = pending
2239 .insert(workflow_id, &run_id, id, 2)
2240 .err()
2241 .ok_or_else(|| {
2242 ServerError::worker_dispatch(
2243 "test",
2244 "stale-redispatch",
2245 "a lower attempt silently replaced a live responder",
2246 )
2247 })?;
2248 let text = error.to_string();
2249 assert!(
2250 text.contains("attempt 2 arrived while attempt 3 still holds"),
2251 "{text}"
2252 );
2253 Ok(())
2254 }
2255
2256 #[test]
2257 fn pending_complete_unknown_returns_false() {
2258 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2259 assert!(!pending.complete(
2260 &WorkflowId::new_v4(),
2261 &activity_id(99),
2262 None,
2263 Ok("orphan".to_owned())
2264 ));
2265 }
2266
2267 #[derive(Default)]
2268 struct RecordingOutboxCallback {
2269 completions: Mutex<Vec<(WorkflowId, ActivityId, String)>>,
2270 failures: Mutex<Vec<(WorkflowId, ActivityId, String)>>,
2271 live: bool,
2272 }
2273
2274 impl OutboxDeliveryCallback for RecordingOutboxCallback {
2275 fn deliver_completion(
2276 &self,
2277 workflow_id: &WorkflowId,
2278 activity_id: &ActivityId,
2279 run_id: Option<&RunId>,
2280 result: String,
2281 ) -> Result<bool, ServerError> {
2282 let _ = run_id;
2283 self.completions
2284 .lock()
2285 .map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
2286 .push((workflow_id.clone(), activity_id.clone(), result));
2287 Ok(self.live)
2288 }
2289
2290 fn deliver_failure(
2291 &self,
2292 workflow_id: &WorkflowId,
2293 activity_id: &ActivityId,
2294 run_id: Option<&RunId>,
2295 reason: String,
2296 ) -> Result<bool, ServerError> {
2297 let _ = run_id;
2298 self.failures
2299 .lock()
2300 .map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
2301 .push((workflow_id.clone(), activity_id.clone(), reason));
2302 Ok(self.live)
2303 }
2304 }
2305
2306 #[test]
2307 fn unmatched_completion_routes_to_outbox_callback_when_installed() -> Result<(), ServerError> {
2308 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2309 let callback = Arc::new(RecordingOutboxCallback {
2310 live: true,
2311 ..RecordingOutboxCallback::default()
2312 });
2313 // Install on one clone; the wiring must be visible to every clone.
2314 pending.clone().set_outbox_delivery(callback.clone());
2315
2316 let workflow_id = WorkflowId::new_v4();
2317 let id = activity_id(7);
2318
2319 // No pending entry: the completion is unmatched and must route to the
2320 // callback rather than being dropped. A live workflow reports true.
2321 assert!(pending.complete(&workflow_id, &id, None, Ok("done".to_owned())));
2322 let completions = callback
2323 .completions
2324 .lock()
2325 .map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?;
2326 assert_eq!(completions.len(), 1);
2327 assert_eq!(completions[0].0, workflow_id);
2328 assert_eq!(completions[0].1, id);
2329 assert_eq!(completions[0].2, "done");
2330 Ok(())
2331 }
2332
2333 #[test]
2334 fn unmatched_failure_routes_to_outbox_callback_and_not_live_reports_false()
2335 -> Result<(), ServerError> {
2336 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2337 // live = false models the expected stale-completion case.
2338 let callback = Arc::new(RecordingOutboxCallback::default());
2339 pending.set_outbox_delivery(callback.clone());
2340
2341 let workflow_id = WorkflowId::new_v4();
2342 let id = activity_id(8);
2343
2344 assert!(!pending.complete(&workflow_id, &id, None, Err("retryable:boom".to_owned())));
2345 let failures = callback
2346 .failures
2347 .lock()
2348 .map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?;
2349 assert_eq!(failures.len(), 1);
2350 assert_eq!(failures[0].2, "retryable:boom");
2351 Ok(())
2352 }
2353
2354 #[test]
2355 fn unmatched_completion_is_silent_drop_when_no_callback_installed() {
2356 // Flag-off byte-identical behaviour: no callback, unmatched returns
2357 // false (silent drop) exactly as before.
2358 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2359 assert!(!pending.complete(
2360 &WorkflowId::new_v4(),
2361 &activity_id(9),
2362 None,
2363 Ok("x".to_owned())
2364 ));
2365 }
2366
2367 #[test]
2368 fn matched_completion_never_reaches_outbox_callback() -> Result<(), ServerError> {
2369 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2370 let callback = Arc::new(RecordingOutboxCallback {
2371 live: true,
2372 ..RecordingOutboxCallback::default()
2373 });
2374 pending.set_outbox_delivery(callback.clone());
2375
2376 let workflow_id = WorkflowId::new_v4();
2377 let id = activity_id(10);
2378 let rx = insert_attempt_one(&pending, &workflow_id, &id)?.1;
2379
2380 assert!(pending.complete(&workflow_id, &id, None, Ok("matched".to_owned())));
2381 assert_eq!(
2382 rx.recv_timeout(Duration::from_millis(50)),
2383 Ok(Ok("matched".to_owned()))
2384 );
2385 assert!(
2386 callback
2387 .completions
2388 .lock()
2389 .map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
2390 .is_empty(),
2391 "a matched completion must deliver to its waiter, not the outbox callback"
2392 );
2393 Ok(())
2394 }
2395
2396 /// #207: parking resolves the matched waiter with the ephemeral parked
2397 /// sentinel — the exact string the engine's retry loop classifies as
2398 /// `Parked` — and nothing else.
2399 #[test]
2400 fn park_activity_resolves_matched_waiter_with_the_parked_sentinel() -> Result<(), ServerError> {
2401 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2402 let workflow_id = WorkflowId::new_v4();
2403 let id = activity_id(11);
2404 let rx = insert_attempt_one(&pending, &workflow_id, &id)?.1;
2405
2406 pending.park_activity(&workflow_id, &id)?;
2407 let result = rx
2408 .recv_timeout(Duration::from_millis(50))
2409 .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
2410 assert_eq!(result, Err(aion::PARKED_ACTIVITY_REASON.to_owned()));
2411 Ok(())
2412 }
2413
2414 /// #207: an unmatched park is a no-op and is NEVER routed to the outbox
2415 /// delivery callback — a park is not a failure and must never reach a
2416 /// workflow.
2417 #[test]
2418 fn unmatched_park_is_a_noop_and_never_reaches_the_outbox_callback() -> Result<(), ServerError> {
2419 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2420 let callback = Arc::new(RecordingOutboxCallback {
2421 live: true,
2422 ..RecordingOutboxCallback::default()
2423 });
2424 pending.set_outbox_delivery(callback.clone());
2425
2426 pending.park_activity(&WorkflowId::new_v4(), &activity_id(12))?;
2427
2428 assert!(
2429 callback
2430 .failures
2431 .lock()
2432 .map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
2433 .is_empty(),
2434 "a park must never be delivered as an outbox failure"
2435 );
2436 assert!(
2437 callback
2438 .completions
2439 .lock()
2440 .map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
2441 .is_empty(),
2442 "a park must never be delivered as an outbox completion"
2443 );
2444 Ok(())
2445 }
2446
2447 #[test]
2448 fn completion_sink_routes_success() -> Result<(), ServerError> {
2449 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2450 let workflow_id = WorkflowId::new_v4();
2451 let id = activity_id(2);
2452 let (completion_token, rx) = insert_attempt_one(&pending, &workflow_id, &id)?;
2453 let payload = Payload::new(ContentType::Json, br#"{"greeting":"hi"}"#.to_vec());
2454
2455 pending.complete_activity(ActivityCompletion {
2456 workflow_id,
2457 activity_id: id,
2458 run_id: None,
2459 completion_token,
2460 outcome: ActivityCompletionOutcome::Succeeded(payload),
2461 })?;
2462
2463 let result = rx
2464 .recv_timeout(Duration::from_millis(50))
2465 .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
2466 assert_eq!(result, Ok(r#"{"greeting":"hi"}"#.to_owned()));
2467 Ok(())
2468 }
2469
2470 #[test]
2471 fn accepted_hook_runs_before_the_result_is_observable() -> Result<(), ServerError> {
2472 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2473 let workflow_id = WorkflowId::new_v4();
2474 let id = activity_id(13);
2475 let (completion_token, rx) = insert_attempt_one(&pending, &workflow_id, &id)?;
2476 let accepted = AtomicBool::new(false);
2477
2478 pending.complete_activity_after_accept(
2479 ActivityCompletion {
2480 workflow_id,
2481 activity_id: id,
2482 run_id: None,
2483 completion_token,
2484 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
2485 ContentType::Json,
2486 br#""ordered""#.to_vec(),
2487 )),
2488 },
2489 || {
2490 accepted.store(true, Ordering::SeqCst);
2491 Ok(())
2492 },
2493 )?;
2494
2495 let result = rx
2496 .recv_timeout(Duration::from_millis(50))
2497 .map_err(|error| ServerError::worker_dispatch("", "", format!("channel: {error}")))?;
2498 assert_eq!(result, Ok(r#""ordered""#.to_owned()));
2499 assert!(
2500 accepted.load(Ordering::SeqCst),
2501 "the accepted-path cleanup hook must finish before the waiter can observe the result"
2502 );
2503 Ok(())
2504 }
2505
2506 #[test]
2507 fn a_non_publishing_settlement_restores_the_generation_for_the_true_resolver()
2508 -> Result<(), ServerError> {
2509 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2510 let workflow_id = WorkflowId::new_v4();
2511 let id = activity_id(16);
2512 let (completion_token, rx) = insert_attempt_one(&pending, &workflow_id, &id)?;
2513
2514 // The synthesized-failure path finding its dispatch already resolved
2515 // by another path: settlement reports "do not publish". The generation
2516 // it consumed on accept must come back, because the OTHER path (the
2517 // lost-worker sweep) presents the same token next.
2518 let settled = pending.complete_fenced_after_accept(
2519 &workflow_id,
2520 &id,
2521 None,
2522 &completion_token,
2523 Err("retryable:worker lost".to_owned()),
2524 || Ok(false),
2525 )?;
2526 assert!(!settled, "a non-publishing settlement must report false");
2527 assert!(
2528 rx.try_recv().is_err(),
2529 "a non-publishing settlement must not publish"
2530 );
2531
2532 // The true resolver arrives with the same token: it must be accepted,
2533 // not refused with NoCurrentGeneration.
2534 let published = pending.complete_fenced_after_accept(
2535 &workflow_id,
2536 &id,
2537 None,
2538 &completion_token,
2539 Err("retryable:worker lost".to_owned()),
2540 || Ok(true),
2541 )?;
2542 assert!(
2543 published,
2544 "the true resolver must be accepted after a non-publishing settlement"
2545 );
2546 assert_eq!(
2547 rx.recv_timeout(Duration::from_millis(50))
2548 .map_err(|error| ServerError::worker_dispatch(
2549 "",
2550 "",
2551 format!("channel: {error}")
2552 ))?,
2553 Err("retryable:worker lost".to_owned())
2554 );
2555 Ok(())
2556 }
2557
2558 #[test]
2559 fn a_poisoned_tracker_fails_open_and_never_withholds_a_completion() {
2560 let tracker = HeartbeatTracker::new(TEST_HEARTBEAT_WINDOW);
2561 tracker.poison_for_tests();
2562 let workflow_id = WorkflowId::new_v4();
2563 let id = activity_id(17);
2564 // An empty registry: the worker was never registered here, so the
2565 // selection-side count has nothing to correct. This test is about the
2566 // TRACKER's poison policy, and the registry is present only because
2567 // the two counts are written by one call.
2568 let registry = ConnectedWorkerRegistry::default();
2569 let was_tracked = clear_completed_task_tracking(
2570 &tracker,
2571 ®istry,
2572 WorkerId::from_value(1),
2573 &workflow_id,
2574 &id,
2575 );
2576 assert!(
2577 was_tracked,
2578 "a poisoned tracker must fail open (deliver), never withhold"
2579 );
2580 }
2581
2582 #[test]
2583 fn accepted_hook_failure_blocks_publication_and_restores_generation() -> Result<(), ServerError>
2584 {
2585 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2586 let workflow_id = WorkflowId::new_v4();
2587 let id = activity_id(15);
2588 let (completion_token, rx) = insert_attempt_one(&pending, &workflow_id, &id)?;
2589
2590 let rejected = pending.complete_activity_after_accept(
2591 ActivityCompletion {
2592 workflow_id: workflow_id.clone(),
2593 activity_id: id.clone(),
2594 run_id: None,
2595 completion_token: completion_token.clone(),
2596 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
2597 ContentType::Json,
2598 br#""ordered""#.to_vec(),
2599 )),
2600 },
2601 || Err(ServerError::lock_poisoned("accepted-path settlement")),
2602 );
2603
2604 assert!(matches!(rejected, Err(ServerError::LockPoisoned { .. })));
2605 assert!(
2606 rx.try_recv().is_err(),
2607 "failed accepted-path settlement must not publish the completion"
2608 );
2609
2610 pending.complete_activity_after_accept(
2611 ActivityCompletion {
2612 workflow_id,
2613 activity_id: id,
2614 run_id: None,
2615 completion_token,
2616 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
2617 ContentType::Json,
2618 br#""ordered""#.to_vec(),
2619 )),
2620 },
2621 || Ok(()),
2622 )?;
2623 assert_eq!(
2624 rx.recv_timeout(Duration::from_millis(50))
2625 .map_err(|error| ServerError::worker_dispatch(
2626 "",
2627 "",
2628 format!("channel: {error}")
2629 ))?,
2630 Ok(r#""ordered""#.to_owned())
2631 );
2632 Ok(())
2633 }
2634
2635 #[test]
2636 fn refused_generation_never_runs_the_accepted_hook() -> Result<(), ServerError> {
2637 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2638 let workflow_id = WorkflowId::new_v4();
2639 let id = activity_id(14);
2640 let (_completion_token, rx) = insert_attempt_one(&pending, &workflow_id, &id)?;
2641 let accepted = AtomicBool::new(false);
2642
2643 let rejected = pending.complete_activity_after_accept(
2644 ActivityCompletion {
2645 workflow_id,
2646 activity_id: id,
2647 run_id: None,
2648 completion_token: CompletionToken::for_test(),
2649 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
2650 ContentType::Json,
2651 br#""stale""#.to_vec(),
2652 )),
2653 },
2654 || {
2655 accepted.store(true, Ordering::SeqCst);
2656 Ok(())
2657 },
2658 );
2659
2660 assert!(matches!(
2661 rejected,
2662 Err(ServerError::ActivityCompletionRejected { .. })
2663 ));
2664 assert!(
2665 !accepted.load(Ordering::SeqCst),
2666 "a refused generation must preserve liveness by skipping accepted-path cleanup"
2667 );
2668 assert!(
2669 rx.try_recv().is_err(),
2670 "a refused generation must leave the current waiter unresolved"
2671 );
2672 Ok(())
2673 }
2674
2675 #[test]
2676 fn malformed_payload_does_not_consume_the_current_generation() -> Result<(), ServerError> {
2677 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2678 let workflow_id = WorkflowId::new_v4();
2679 let id = activity_id(12);
2680 let (completion_token, rx) = insert_attempt_one(&pending, &workflow_id, &id)?;
2681
2682 let malformed = pending.complete_activity(ActivityCompletion {
2683 workflow_id: workflow_id.clone(),
2684 activity_id: id.clone(),
2685 run_id: None,
2686 completion_token: completion_token.clone(),
2687 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
2688 ContentType::Json,
2689 vec![0xff],
2690 )),
2691 });
2692 assert!(matches!(malformed, Err(ServerError::WorkerDispatch { .. })));
2693 assert!(
2694 rx.try_recv().is_err(),
2695 "an invalid result must leave the waiter unresolved"
2696 );
2697
2698 pending.complete_activity(ActivityCompletion {
2699 workflow_id,
2700 activity_id: id,
2701 run_id: None,
2702 completion_token,
2703 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
2704 ContentType::Json,
2705 br#""valid""#.to_vec(),
2706 )),
2707 })?;
2708 let result = rx
2709 .recv_timeout(Duration::from_millis(50))
2710 .map_err(|error| ServerError::worker_dispatch("", "", format!("channel: {error}")))?;
2711 assert_eq!(result, Ok(r#""valid""#.to_owned()));
2712 Ok(())
2713 }
2714
2715 #[test]
2716 fn completion_sink_routes_retryable_error() -> Result<(), ServerError> {
2717 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2718 let workflow_id = WorkflowId::new_v4();
2719 let id = activity_id(3);
2720 let (completion_token, rx) = insert_attempt_one(&pending, &workflow_id, &id)?;
2721
2722 pending.complete_activity(ActivityCompletion {
2723 workflow_id,
2724 activity_id: id,
2725 run_id: None,
2726 completion_token,
2727 outcome: ActivityCompletionOutcome::Failed(ActivityError {
2728 kind: ActivityErrorKind::Retryable,
2729 message: "temporary".to_owned(),
2730 details: None,
2731 }),
2732 })?;
2733
2734 let result = rx
2735 .recv_timeout(Duration::from_millis(50))
2736 .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
2737 assert_eq!(result, Err("retryable:temporary".to_owned()));
2738 Ok(())
2739 }
2740
2741 #[test]
2742 fn completion_sink_routes_policy_refusal_with_its_own_prefix() -> Result<(), ServerError> {
2743 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2744 let workflow_id = WorkflowId::new_v4();
2745 let id = activity_id(4);
2746 let (completion_token, rx) = insert_attempt_one(&pending, &workflow_id, &id)?;
2747
2748 pending.complete_activity(ActivityCompletion {
2749 workflow_id,
2750 activity_id: id,
2751 run_id: None,
2752 completion_token,
2753 outcome: ActivityCompletionOutcome::Failed(ActivityError {
2754 kind: ActivityErrorKind::PolicyRefused,
2755 message: "provider safety policy".to_owned(),
2756 details: None,
2757 }),
2758 })?;
2759
2760 let result = rx
2761 .recv_timeout(Duration::from_millis(50))
2762 .map_err(|error| ServerError::worker_dispatch("", "", format!("channel: {error}")))?;
2763 assert_eq!(
2764 result,
2765 Err("policy_refused:provider safety policy".to_owned())
2766 );
2767 Ok(())
2768 }
2769
2770 /// Regression test (#59, brief D12): pending tracking must be keyed by
2771 /// the full `(WorkflowId, ActivityId)` pair. The dispatcher fabricates
2772 /// activity ids from a process-local counter that resets on server
2773 /// restart, so a stale result re-reported from a worker's previous
2774 /// session carries the same bare `ActivityId` as a fresh post-restart
2775 /// dispatch. Under bare-`ActivityId` keying the stale result completed
2776 /// the wrong execution; with pair keying it is dropped and the genuine
2777 /// result still completes.
2778 #[test]
2779 fn stale_result_for_other_workflow_does_not_complete_pending_dispatch()
2780 -> Result<(), ServerError> {
2781 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2782 let post_restart_workflow = WorkflowId::new_v4();
2783 let pre_restart_workflow = WorkflowId::new_v4();
2784 // Counter resets to the same sequence position after restart.
2785 let id = activity_id(1);
2786 let (completion_token, rx) = insert_attempt_one(&pending, &post_restart_workflow, &id)?;
2787
2788 // Stale pre-restart result: same activity id, different workflow.
2789 let rejected = pending.complete_activity(ActivityCompletion {
2790 workflow_id: pre_restart_workflow,
2791 activity_id: id.clone(),
2792 run_id: None,
2793 completion_token: CompletionToken::for_test(),
2794 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
2795 ContentType::Json,
2796 br#""stale""#.to_vec(),
2797 )),
2798 });
2799 assert!(matches!(
2800 rejected,
2801 Err(ServerError::ActivityCompletionRejected { .. })
2802 ));
2803 assert!(
2804 rx.try_recv().is_err(),
2805 "stale result for a different workflow must not complete this dispatch"
2806 );
2807
2808 // The genuine result for the pending execution still completes.
2809 pending.complete_activity(ActivityCompletion {
2810 workflow_id: post_restart_workflow,
2811 activity_id: id,
2812 run_id: None,
2813 completion_token,
2814 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
2815 ContentType::Json,
2816 br#""fresh""#.to_vec(),
2817 )),
2818 })?;
2819 let result = rx
2820 .recv_timeout(Duration::from_millis(50))
2821 .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
2822 assert_eq!(result, Ok(r#""fresh""#.to_owned()));
2823 Ok(())
2824 }
2825
2826 /// Liveness tracker for dispatcher unit tests; the window only matters
2827 /// to expiry checks, which nothing in these tests drives.
2828 /// The heartbeat window these unit tests wire, named once so the
2829 /// transport-loss budget they run under is explicit rather than inherited.
2830 /// It is the same window [`test_tracker`] uses, so a sink and a tracker
2831 /// built side by side in a test agree exactly as they do in production.
2832 const TEST_HEARTBEAT_WINDOW: Duration = Duration::from_secs(5);
2833
2834 /// The budget a sink ends up with is DERIVED from the operator's window,
2835 /// and this is the arithmetic — not prose about it.
2836 ///
2837 /// Before #100 the wiring was `default().with_heartbeat_window(w)`: two
2838 /// calls where the second was omissible, and deleting it from all five
2839 /// production sites reddened nothing, because every test ran the zero-budget
2840 /// default. This pin and its sibling below are what make that deletion
2841 /// impossible to perform silently.
2842 #[test]
2843 fn a_sink_derives_its_transport_budget_from_the_operator_window() {
2844 let window = Duration::from_secs(7);
2845 let sink = PendingActivities::new(window);
2846
2847 assert_eq!(
2848 sink.transport_losses().budget(),
2849 window * super::super::transport_loss::TRANSPORT_LOSS_BUDGET_WINDOWS,
2850 "the transport-loss budget must be the operator's heartbeat window times \
2851 TRANSPORT_LOSS_BUDGET_WINDOWS; a sink carrying any other budget is one no \
2852 operator declared"
2853 );
2854 }
2855
2856 /// The wiring itself, which is where the defect actually lived: a dispatcher
2857 /// is handed a `HeartbeatTracker` and must not be able to carry a transport
2858 /// budget that disagrees with it.
2859 ///
2860 /// This asserts on the dispatcher a caller really builds, so it sits on the
2861 /// path a forgetful wiring would take rather than on the constructor in
2862 /// isolation.
2863 #[test]
2864 fn a_dispatcher_carries_the_transport_budget_of_the_tracker_it_was_given() {
2865 let window = Duration::from_secs(11);
2866 let registry = ConnectedWorkerRegistry::default();
2867 let dispatcher =
2868 WorkerActivityDispatcher::new(registry, "default", HeartbeatTracker::new(window));
2869
2870 assert_eq!(
2871 dispatcher.pending().transport_losses().budget(),
2872 window * super::super::transport_loss::TRANSPORT_LOSS_BUDGET_WINDOWS,
2873 "the dispatcher's transport budget must be derived from the heartbeat window it \
2874 was constructed with; a zero or mismatched budget here means a worker loss is \
2875 declared transport-exhausted after ONE re-dispatch instead of after the \
2876 operator's window"
2877 );
2878 assert_ne!(
2879 dispatcher.pending().transport_losses().budget(),
2880 Duration::ZERO,
2881 "a ZERO budget is the specific regression this pin exists for: it grants exactly \
2882 one re-dispatchable loss and then declares the infrastructure flapping"
2883 );
2884 }
2885
2886 fn test_tracker() -> HeartbeatTracker {
2887 HeartbeatTracker::new(TEST_HEARTBEAT_WINDOW)
2888 }
2889
2890 /// A `greet` dispatch request carrying real (test-synthesized) ids, the
2891 /// engine-seam shape `WorkerActivityDispatcher::dispatch` now consumes.
2892 fn greet_request() -> ActivityDispatch {
2893 ActivityDispatch {
2894 namespace: "default".to_owned(),
2895 task_queue: "default".to_owned(),
2896 node: None,
2897 workflow_id: WorkflowId::new_v4(),
2898 run_id: RunId::new_v4(),
2899 activity_id: ActivityId::from_sequence_position(0),
2900 name: "greet".to_owned(),
2901 input: "{}".to_owned(),
2902 config: "{}".to_owned(),
2903 attempt: 1,
2904 labels: std::collections::BTreeMap::new(),
2905 advisory: false,
2906 }
2907 }
2908
2909 #[test]
2910 fn dispatcher_fails_immediately_when_draining_without_workers() {
2911 let registry = ConnectedWorkerRegistry::default();
2912 let drain = DrainState::default();
2913 let dispatcher = WorkerActivityDispatcher::new(registry, "default", test_tracker())
2914 .with_drain_state(drain.clone());
2915
2916 let _ = drain.begin();
2917
2918 let result = dispatcher.dispatch(greet_request());
2919
2920 assert!(result.is_err());
2921 let err = result.err().unwrap_or_default();
2922 assert!(
2923 err.contains("drain"),
2924 "expected drain rejection, got: {err}"
2925 );
2926 }
2927
2928 /// Regression test for the production stall where every remote activity
2929 /// timed out: the engine invoked the sync `dispatch` from inside a
2930 /// spawned tokio task (`futures::future::lazy` polled on a runtime
2931 /// worker), and the woken stream-consumer task landed in that blocked
2932 /// worker's non-stealable LIFO slot, so the queued `ActivityTask` was
2933 /// only delivered when the then-extant 30s dispatch timeout fired (the
2934 /// dispatch wait is unbounded today; the stall would now be a hang).
2935 ///
2936 /// Mirrors the real wiring minus tonic: the real registry channel that
2937 /// the gRPC stream forwarder drains, a worker task awaiting that channel
2938 /// on the same runtime, completion through the production
2939 /// `ActivityCompletionSink`, and the sync dispatch invoked from a
2940 /// runtime worker task — the worst case the `block_in_place` guard in
2941 /// `dispatch` defends against (the engine itself now routes through
2942 /// `dispatch_async`, off the async workers).
2943 /// aion#195 END TO END: attempt 1 is delivered to a worker that never
2944 /// answers (the engine's per-attempt bound has already failed it and
2945 /// dropped its future); attempt 2 of the same execution then arrives. The
2946 /// site is taken over, the worker is asked to STOP the corpse on its own
2947 /// dispatch stream — the cancel frame precedes attempt 2's task — the
2948 /// corpse's heartbeat task is untracked, attempt 2 runs and completes, and
2949 /// attempt 1's blocked dispatcher thread resolves with the retryable
2950 /// superseded sentinel instead of hanging or failing the workflow.
2951 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2952 async fn a_retry_takes_over_a_timed_out_attempt_and_asks_its_worker_to_stop()
2953 -> Result<(), Box<dyn std::error::Error>> {
2954 let registry = ConnectedWorkerRegistry::default();
2955 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
2956 let (worker_tx, mut worker_rx) = tokio::sync::mpsc::channel(32);
2957 let activity_types = [String::from("greet")];
2958 let registration = registry.register(
2959 "default",
2960 activity_types.iter(),
2961 worker_tx,
2962 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
2963 )?;
2964 let worker_id = registration
2965 .worker_id()
2966 .ok_or("test worker registration missing id")?;
2967 let tracker = test_tracker();
2968 let dispatcher = Arc::new(
2969 WorkerActivityDispatcher::new(registry, "default", tracker.clone())
2970 .with_pending(pending.clone()),
2971 );
2972
2973 let request = greet_request();
2974 let workflow_id = request.workflow_id.clone();
2975 let activity_id = request.activity_id.clone();
2976
2977 let first_dispatcher = Arc::clone(&dispatcher);
2978 let first_request = request.clone();
2979 let first_attempt = tokio::spawn(futures::future::lazy(move |_| {
2980 first_dispatcher.dispatch(first_request)
2981 }));
2982 let Some(WorkerMessage::ActivityTask(first_task)) = worker_rx.recv().await else {
2983 return Err("expected attempt 1's task on the worker channel".into());
2984 };
2985 assert_eq!(first_task.attempt, 1);
2986
2987 let mut retry_request = request.clone();
2988 retry_request.attempt = 2;
2989 let retry_dispatcher = Arc::clone(&dispatcher);
2990 let retry_attempt = tokio::spawn(futures::future::lazy(move |_| {
2991 retry_dispatcher.dispatch(retry_request)
2992 }));
2993
2994 // The release ladder runs before attempt 2's task is pushed, so the
2995 // worker sees the STOP for the corpse first, then the new work.
2996 let Some(WorkerMessage::CancelActivity(cancel)) = worker_rx.recv().await else {
2997 return Err(
2998 "expected the superseded attempt's cancel frame before attempt 2's task".into(),
2999 );
3000 };
3001 assert_eq!(
3002 cancel
3003 .activity_id
3004 .map(|id| id.sequence_position)
3005 .unwrap_or_default(),
3006 activity_id.sequence_position()
3007 );
3008 let Some(WorkerMessage::ActivityTask(retry_task)) = worker_rx.recv().await else {
3009 return Err("expected attempt 2's task on the worker channel".into());
3010 };
3011 assert_eq!(retry_task.attempt, 2);
3012
3013 // The corpse's heartbeat task was untracked, so the expiry sweep can
3014 // never present its stale token; only attempt 2 remains in flight.
3015 let in_flight = tracker.in_flight_for_workflow(&workflow_id)?;
3016 assert_eq!(
3017 in_flight
3018 .iter()
3019 .map(|task| task.attempt)
3020 .collect::<Vec<_>>(),
3021 vec![2],
3022 "exactly attempt 2 must remain tracked for worker {worker_id:?}"
3023 );
3024
3025 let retry_token = CompletionToken::from_wire(
3026 &workflow_id,
3027 &activity_id,
3028 retry_task.completion_token.clone(),
3029 )
3030 .map_err(|error| error.to_string())?;
3031 pending.complete_activity(ActivityCompletion {
3032 workflow_id: workflow_id.clone(),
3033 activity_id: activity_id.clone(),
3034 run_id: None,
3035 completion_token: retry_token,
3036 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
3037 ContentType::Json,
3038 br#""attempt 2 delivered""#.to_vec(),
3039 )),
3040 })?;
3041
3042 let retry_result = retry_attempt.await.map_err(|error| error.to_string())?;
3043 assert_eq!(retry_result, Ok(r#""attempt 2 delivered""#.to_owned()));
3044 let first_result = first_attempt.await.map_err(|error| error.to_string())?;
3045 let first_reason = match first_result {
3046 Ok(delivered) => {
3047 return Err(
3048 format!("attempt 1 resolved Ok({delivered}) instead of superseded").into(),
3049 );
3050 }
3051 Err(reason) => reason,
3052 };
3053 assert!(
3054 first_reason.starts_with("retryable:")
3055 && first_reason.contains("superseded by attempt 2"),
3056 "{first_reason}"
3057 );
3058 registration.deregister()?;
3059 Ok(())
3060 }
3061
3062 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3063 async fn dispatch_inside_runtime_task_delivers_promptly_and_round_trips()
3064 -> Result<(), Box<dyn std::error::Error>> {
3065 let registry = ConnectedWorkerRegistry::default();
3066 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
3067 let (worker_tx, mut worker_rx) = tokio::sync::mpsc::channel(32);
3068 let activity_types = [String::from("greet")];
3069 let registration = registry.register(
3070 "default",
3071 activity_types.iter(),
3072 worker_tx,
3073 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3074 )?;
3075
3076 let sink = pending.clone();
3077 let echo_worker = tokio::spawn(async move {
3078 let Some(WorkerMessage::ActivityTask(task)) = worker_rx.recv().await else {
3079 return Err("expected an activity task on the worker channel".to_owned());
3080 };
3081 let workflow_id = task
3082 .workflow_id
3083 .ok_or("task missing workflow id")
3084 .and_then(|id| WorkflowId::try_from(id).map_err(|_| "bad workflow id"))?;
3085 let activity_id = task
3086 .activity_id
3087 .map(ActivityId::from)
3088 .ok_or("task missing activity id")?;
3089 let completion_token =
3090 CompletionToken::from_wire(&workflow_id, &activity_id, task.completion_token)
3091 .map_err(|error| error.to_string())?;
3092 sink.complete_activity(ActivityCompletion {
3093 workflow_id,
3094 activity_id,
3095 run_id: None,
3096 completion_token,
3097 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
3098 ContentType::Json,
3099 br#"{"greeting":"hello"}"#.to_vec(),
3100 )),
3101 })
3102 .map_err(|error| error.to_string())
3103 });
3104
3105 let dispatcher = Arc::new(
3106 WorkerActivityDispatcher::new(registry, "default", test_tracker())
3107 .with_pending(pending),
3108 );
3109 let started = Instant::now();
3110 // Invoke the sync dispatch inside the first poll of a spawned task:
3111 // the worst-case calling context for the `block_in_place` guard.
3112 let dispatch_task = tokio::spawn(futures::future::lazy(move |_| {
3113 dispatcher.dispatch(greet_request())
3114 }));
3115 let result = dispatch_task.await.map_err(|error| error.to_string())?;
3116 let elapsed = started.elapsed();
3117
3118 assert_eq!(result, Ok(r#"{"greeting":"hello"}"#.to_owned()));
3119 assert!(
3120 elapsed < Duration::from_secs(5),
3121 "dispatch round trip took {elapsed:?}; task delivery must not \
3122 depend on the blocked dispatch thread"
3123 );
3124 echo_worker.await.map_err(|error| error.to_string())??;
3125 registration.deregister()?;
3126 Ok(())
3127 }
3128
3129 /// A current-thread runtime cannot host the blocking wait (the stream
3130 /// forwarder would share its only executor thread), so dispatch must
3131 /// fail fast with a precise error instead of blocking forever.
3132 #[tokio::test]
3133 async fn dispatch_on_current_thread_runtime_fails_fast()
3134 -> Result<(), Box<dyn std::error::Error>> {
3135 let registry = ConnectedWorkerRegistry::default();
3136 let (worker_tx, _worker_rx) = tokio::sync::mpsc::channel(32);
3137 let activity_types = [String::from("greet")];
3138 let registration = registry.register(
3139 "default",
3140 activity_types.iter(),
3141 worker_tx,
3142 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3143 )?;
3144 let dispatcher = WorkerActivityDispatcher::new(registry, "default", test_tracker());
3145
3146 let started = Instant::now();
3147 let result = dispatcher.dispatch(greet_request());
3148 let elapsed = started.elapsed();
3149
3150 let err = result.err().ok_or("expected dispatch to fail")?;
3151 assert!(
3152 err.contains("multi-thread tokio runtime"),
3153 "unexpected error: {err}"
3154 );
3155 assert!(
3156 elapsed < Duration::from_secs(5),
3157 "fail-fast path took {elapsed:?}"
3158 );
3159 registration.deregister()?;
3160 Ok(())
3161 }
3162
3163 /// Bridge-level mirror of the e2e node-pin proof: two workers share the
3164 /// `(namespace, task_queue)` pool but advertise different nodes; an
3165 /// `ActivityDispatch` pinned to one node must reach ONLY the worker on that
3166 /// node through the live engine-seam `WorkerActivityDispatcher`. This is the
3167 /// regression guard for the bridge discarding the dispatch's `node`.
3168 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3169 async fn dispatch_pinned_to_node_reaches_only_that_node()
3170 -> Result<(), Box<dyn std::error::Error>> {
3171 let registry = ConnectedWorkerRegistry::default();
3172 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
3173 let activity_types = [String::from("greet")];
3174 let (n1_tx, mut n1_rx) = tokio::sync::mpsc::channel(32);
3175 let (n2_tx, mut n2_rx) = tokio::sync::mpsc::channel(32);
3176 // Register the DECOY (n2) FIRST so it owns the lowest worker id. The
3177 // bridge's `select_worker` orders candidates by worker id and starts at
3178 // the pool's rotation cursor, which is 0 for a pool nothing has selected
3179 // from yet — so the FIRST unfiltered selection here would name n2 (the
3180 // decoy), and a bridge that DISCARDED the node would route to it: the n1
3181 // echo would never fire and the round trip would time out. With the node
3182 // threaded through, selection is filtered to n1 before the cursor is
3183 // consulted, so the rotation cannot rescue a bridge that dropped it.
3184 let on_n2 = registry.register_namespaces(
3185 [String::from("default")],
3186 "default",
3187 Some(String::from("n2")),
3188 activity_types.iter(),
3189 n2_tx,
3190 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3191 )?;
3192 let on_n1 = registry.register_namespaces(
3193 [String::from("default")],
3194 "default",
3195 Some(String::from("n1")),
3196 activity_types.iter(),
3197 n1_tx,
3198 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3199 )?;
3200
3201 // Echo only on the n1 channel: the dispatch can only complete if the
3202 // task was routed to n1. If it leaked to n2, the n1 wait would stall and
3203 // the round trip below would time out instead.
3204 let sink = pending.clone();
3205 let echo_n1 = tokio::spawn(async move {
3206 let Some(WorkerMessage::ActivityTask(task)) = n1_rx.recv().await else {
3207 return Err("expected an activity task on the n1 worker channel".to_owned());
3208 };
3209 let workflow_id = task
3210 .workflow_id
3211 .ok_or("task missing workflow id")
3212 .and_then(|id| WorkflowId::try_from(id).map_err(|_| "bad workflow id"))?;
3213 let activity_id = task
3214 .activity_id
3215 .map(ActivityId::from)
3216 .ok_or("task missing activity id")?;
3217 let completion_token =
3218 CompletionToken::from_wire(&workflow_id, &activity_id, task.completion_token)
3219 .map_err(|error| error.to_string())?;
3220 sink.complete_activity(ActivityCompletion {
3221 workflow_id,
3222 activity_id,
3223 run_id: None,
3224 completion_token,
3225 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
3226 ContentType::Json,
3227 br#"{"greeting":"hello"}"#.to_vec(),
3228 )),
3229 })
3230 .map_err(|error| error.to_string())
3231 });
3232
3233 let dispatcher = Arc::new(
3234 WorkerActivityDispatcher::new(registry.clone(), "default", test_tracker())
3235 .with_pending(pending),
3236 );
3237
3238 let pinned = ActivityDispatch {
3239 node: Some(String::from("n1")),
3240 ..greet_request()
3241 };
3242 let started = Instant::now();
3243 let result = tokio::spawn(futures::future::lazy(move |_| dispatcher.dispatch(pinned)))
3244 .await
3245 .map_err(|error| error.to_string())?;
3246 let elapsed = started.elapsed();
3247
3248 assert_eq!(result, Ok(r#"{"greeting":"hello"}"#.to_owned()));
3249 assert!(
3250 elapsed < Duration::from_secs(5),
3251 "pinned dispatch round trip took {elapsed:?}; the task must route to n1"
3252 );
3253 echo_n1.await.map_err(|error| error.to_string())??;
3254
3255 // The n2 worker (wrong node) must never have been handed the task.
3256 assert!(
3257 n2_rx.try_recv().is_err(),
3258 "node=Some(\"n1\") dispatch must not reach the n2 worker"
3259 );
3260
3261 on_n1.deregister()?;
3262 on_n2.deregister()?;
3263 Ok(())
3264 }
3265
3266 /// An unpinned (`node = None`) dispatch is byte-identical to today: it
3267 /// reaches a worker in the pool regardless of the worker's advertised node.
3268 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3269 async fn unpinned_dispatch_reaches_a_pooled_worker_regardless_of_node()
3270 -> Result<(), Box<dyn std::error::Error>> {
3271 let registry = ConnectedWorkerRegistry::default();
3272 let pending = PendingActivities::new(TEST_HEARTBEAT_WINDOW);
3273 let activity_types = [String::from("greet")];
3274 let (n1_tx, mut n1_rx) = tokio::sync::mpsc::channel(32);
3275 let on_n1 = registry.register_namespaces(
3276 [String::from("default")],
3277 "default",
3278 Some(String::from("n1")),
3279 activity_types.iter(),
3280 n1_tx,
3281 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3282 )?;
3283
3284 let sink = pending.clone();
3285 let echo = tokio::spawn(async move {
3286 let Some(WorkerMessage::ActivityTask(task)) = n1_rx.recv().await else {
3287 return Err("expected an activity task on the worker channel".to_owned());
3288 };
3289 let workflow_id = task
3290 .workflow_id
3291 .ok_or("task missing workflow id")
3292 .and_then(|id| WorkflowId::try_from(id).map_err(|_| "bad workflow id"))?;
3293 let activity_id = task
3294 .activity_id
3295 .map(ActivityId::from)
3296 .ok_or("task missing activity id")?;
3297 let completion_token =
3298 CompletionToken::from_wire(&workflow_id, &activity_id, task.completion_token)
3299 .map_err(|error| error.to_string())?;
3300 sink.complete_activity(ActivityCompletion {
3301 workflow_id,
3302 activity_id,
3303 run_id: None,
3304 completion_token,
3305 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
3306 ContentType::Json,
3307 br#"{"greeting":"hello"}"#.to_vec(),
3308 )),
3309 })
3310 .map_err(|error| error.to_string())
3311 });
3312
3313 let dispatcher = Arc::new(
3314 WorkerActivityDispatcher::new(registry.clone(), "default", test_tracker())
3315 .with_pending(pending),
3316 );
3317
3318 // greet_request() carries node: None — the unpinned path.
3319 let result = tokio::spawn(futures::future::lazy(move |_| {
3320 dispatcher.dispatch(greet_request())
3321 }))
3322 .await
3323 .map_err(|error| error.to_string())?;
3324
3325 assert_eq!(result, Ok(r#"{"greeting":"hello"}"#.to_owned()));
3326 echo.await.map_err(|error| error.to_string())??;
3327 on_n1.deregister()?;
3328 Ok(())
3329 }
3330}