aion_worker/context.rs
1//! `ActivityContext` heartbeat, cancellation, attempt, and identifier support.
2
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use aion_core::{ActivityEvent, ActivityId, Payload, RunId, WorkflowId};
7use tokio::sync::{Notify, mpsc};
8
9use crate::error::WorkerError;
10
11/// Handler-facing context for one activity execution.
12#[derive(Clone, Debug)]
13pub struct ActivityContext {
14 /// The workflow this activity belongs to.
15 workflow_id: WorkflowId,
16 /// The concrete run this activity was dispatched by — the generation axis.
17 ///
18 /// REQUIRED, never optional: [`ActivityContext::run_id`] is the only source
19 /// a handler has for the run axis its transcript events must carry, and
20 /// [`aion_core::ActivityEvent::run_id`] is itself a required field. An
21 /// optional accessor would leave a handler with no honest way to build a
22 /// required field, so the run is supplied at construction or the context is
23 /// never built — the refusal lives at the dispatch boundary, not here.
24 run_id: RunId,
25 activity_id: ActivityId,
26 attempt: u32,
27 idempotency_key: Option<String>,
28 cancellation: Arc<CancellationState>,
29 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
30 /// NOI-5b agent-observability event seam (additive, OPTIONAL). A running
31 /// activity (or the harness adapter driving it) emits neutral
32 /// [`ActivityEvent`]s here; the worker runtime drains them and forwards them
33 /// to the server's transcript sequencer over the same transport activity
34 /// results take. A context created WITHOUT this seam — every isolated unit
35 /// test and every activity that emits nothing — is a no-op, byte-identical to
36 /// today, exactly as the `heartbeat_sender` seam is.
37 event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
38}
39
40/// Internal handle used by the worker runtime to signal cooperative cancellation.
41#[derive(Clone, Debug)]
42pub struct ActivityCancellationHandle {
43 cancellation: Arc<CancellationState>,
44}
45
46/// Heartbeat request emitted by [`ActivityContext::heartbeat`].
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct HeartbeatRequest {
49 /// Workflow owning the activity whose progress is being reported.
50 pub workflow_id: WorkflowId,
51 /// Activity whose progress is being reported.
52 pub activity_id: ActivityId,
53 /// Opaque progress detail supplied by the handler.
54 pub detail: Option<Payload>,
55}
56
57#[derive(Debug)]
58struct CancellationState {
59 cancelled: AtomicBool,
60 notify: Notify,
61}
62
63impl ActivityContext {
64 /// Creates a context and the internal handle that can signal cancellation.
65 ///
66 /// The full dispatch identity is required: an activity execution always
67 /// belongs to one `(workflow, run, activity, attempt)`, and a handler reads
68 /// the workflow and run back to stamp the transcript events it emits.
69 #[must_use]
70 pub fn new(
71 workflow_id: WorkflowId,
72 run_id: RunId,
73 activity_id: ActivityId,
74 attempt: u32,
75 ) -> (Self, ActivityCancellationHandle) {
76 Self::for_workflow(workflow_id, run_id, activity_id, attempt, None)
77 }
78
79 /// Creates a context whose transcript seam is live, for a host that owns
80 /// the receiving end of `events`.
81 ///
82 /// This is the seam [`Self::emit_event`] publishes on: every event a handler
83 /// emits carries this context's `(workflow_id, run_id, activity_id, attempt)`
84 /// identity, which is exactly the key the server's transcript sequencer
85 /// files it under. A host that executes an activity IN PROCESS (the server's
86 /// declared-command path) uses this to hand its own publisher the same
87 /// stream a remote worker's drain would have delivered.
88 #[must_use]
89 pub fn with_transcript(
90 workflow_id: WorkflowId,
91 run_id: RunId,
92 activity_id: ActivityId,
93 attempt: u32,
94 events: mpsc::UnboundedSender<ActivityEvent>,
95 ) -> (Self, ActivityCancellationHandle) {
96 Self::for_workflow_with_events(
97 workflow_id,
98 run_id,
99 activity_id,
100 attempt,
101 None,
102 None,
103 Some(events),
104 )
105 }
106
107 /// Returns this activity's identifier.
108 #[must_use]
109 pub const fn activity_id(&self) -> &ActivityId {
110 &self.activity_id
111 }
112
113 /// Returns the workflow this activity belongs to.
114 #[must_use]
115 pub const fn workflow_id(&self) -> &WorkflowId {
116 &self.workflow_id
117 }
118
119 /// Returns this activity's attempt number.
120 #[must_use]
121 pub const fn attempt(&self) -> u32 {
122 self.attempt
123 }
124
125 /// Returns the concrete run this activity was dispatched by.
126 ///
127 /// A handler that emits transcript events through [`Self::emit_event`]
128 /// stamps this onto every [`ActivityEvent`] it builds: the transcript
129 /// keyspace is keyed on `(workflow, run, activity, attempt)`, and without
130 /// the run two generations of one continue-as-new chain write to the same
131 /// stream. Always present — the run is part of the dispatch identity, so
132 /// the handler is never handed an absence it cannot resolve.
133 #[must_use]
134 pub const fn run_id(&self) -> &RunId {
135 &self.run_id
136 }
137
138 /// Returns the stable external-effect key delivered with this task.
139 ///
140 /// Live worker tasks always return `Some`; manually constructed unit-test
141 /// contexts return `None` because they are not attached to a server task.
142 #[must_use]
143 pub fn idempotency_key(&self) -> Option<&str> {
144 self.idempotency_key.as_deref()
145 }
146
147 /// Emits a cooperative heartbeat request for this activity.
148 ///
149 /// This is the PROGRESS channel: handlers call it to attach a progress
150 /// payload to the activity's liveness record. LIVENESS itself is owned by
151 /// the worker runtime, which automatically heartbeats every in-flight
152 /// activity within the server-assigned heartbeat window — a handler that
153 /// never calls this still stays live for as long as it genuinely runs.
154 /// Contexts created without a live heartbeat sender remain no-op contexts
155 /// for isolated unit tests.
156 ///
157 /// # Errors
158 ///
159 /// Returns [`WorkerError`] when an installed heartbeat seam has been closed.
160 pub fn heartbeat(&self, detail: Option<Payload>) -> Result<(), WorkerError> {
161 if let Some(sender) = &self.heartbeat_sender {
162 sender
163 .send(HeartbeatRequest {
164 workflow_id: self.workflow_id.clone(),
165 activity_id: self.activity_id.clone(),
166 detail,
167 })
168 .map_err(|source| WorkerError::registration(HeartbeatSeamClosed { source }))?;
169 }
170 Ok(())
171 }
172
173 /// Emit a neutral agent-observability [`ActivityEvent`] onto the transcript
174 /// seam (NOI-5b).
175 ///
176 /// Additive and OPTIONAL: on a context created without a live event seam
177 /// (every isolated unit test, and every activity that does not run an
178 /// instrumented agent) this is a no-op returning `Ok(())`, so behaviour is
179 /// byte-identical to today. When a seam is installed the worker runtime drains
180 /// these events and forwards them to the server's transcript sequencer, which
181 /// stamps the commit-allocated `store_seq` — the producer never assigns it.
182 ///
183 /// Harness-neutral: the payload is a pure `aion-core` [`ActivityEvent`]; the
184 /// per-harness mapping lives in the worker-side adapter, never here.
185 ///
186 /// # Errors
187 ///
188 /// Returns [`WorkerError`] when an installed event seam has been closed (the
189 /// runtime drain end was dropped) — a dropped transcript event is surfaced,
190 /// never silently swallowed.
191 pub fn emit_event(&self, event: ActivityEvent) -> Result<(), WorkerError> {
192 if let Some(sender) = &self.event_sender {
193 sender
194 .send(event)
195 .map_err(|source| WorkerError::registration(EventSeamClosed { source }))?;
196 }
197 Ok(())
198 }
199
200 /// Returns true once cooperative cancellation has been signalled.
201 #[must_use]
202 pub fn is_cancelled(&self) -> bool {
203 self.cancellation.cancelled.load(Ordering::Acquire)
204 }
205
206 /// Resolves when cooperative cancellation is signalled.
207 ///
208 /// # Why the waiter exists BEFORE the flag is read
209 ///
210 /// [`Notify::notify_waiters`] reaches the waiters that EXIST at the moment
211 /// it is called and stores nothing for a `Notified` created afterwards —
212 /// both halves measured in this module's
213 /// `a_notification_is_seen_by_a_waiter_that_existed_before_it_and_lost_on_one_that_did_not`.
214 /// A waiter that read the flag first and created its `Notified` second
215 /// therefore has a window: the flag reads false, the whole of
216 /// [`ActivityCancellationHandle::cancel`] runs inside the window, and the
217 /// notification is spent before this waiter exists to receive it. It is
218 /// then lost FOREVER, because cancellation is signalled exactly once — and
219 /// the awaiting side waits on a cancellation that has already happened,
220 /// which is a running command that cannot be killed.
221 ///
222 /// So the order is inverted. The `Notified` future is created and
223 /// `enable()`d — which registers this waiter explicitly, rather than
224 /// leaning on the creation-time capture alone — and only then is the flag
225 /// read. A `cancel` that lands before the read is seen by the read; one
226 /// that lands after it is seen by the waiter already in place. There is no
227 /// third position for it to land in.
228 ///
229 /// The loop is not a spin: `Notified` completes only on a notification, and
230 /// re-entering it creates and registers a new waiter before re-reading, so
231 /// every iteration keeps the same ordering.
232 pub async fn cancelled(&self) {
233 loop {
234 let notified = self.cancellation.notify.notified();
235 let mut notified = std::pin::pin!(notified);
236 // Registers this waiter. Everything after this line is covered by
237 // a notification, including the flag read on the next line.
238 notified.as_mut().enable();
239 if self.is_cancelled() {
240 return;
241 }
242 notified.await;
243 }
244 }
245
246 pub(crate) fn for_workflow(
247 workflow_id: WorkflowId,
248 run_id: RunId,
249 activity_id: ActivityId,
250 attempt: u32,
251 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
252 ) -> (Self, ActivityCancellationHandle) {
253 Self::for_workflow_with_events(
254 workflow_id,
255 run_id,
256 activity_id,
257 attempt,
258 None,
259 heartbeat_sender,
260 None,
261 )
262 }
263
264 pub(crate) fn for_task(
265 workflow_id: WorkflowId,
266 run_id: RunId,
267 activity_id: ActivityId,
268 attempt: u32,
269 idempotency_key: String,
270 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
271 ) -> (Self, ActivityCancellationHandle) {
272 Self::for_workflow_with_events(
273 workflow_id,
274 run_id,
275 activity_id,
276 attempt,
277 Some(idempotency_key),
278 heartbeat_sender,
279 None,
280 )
281 }
282
283 /// Build a context with BOTH the heartbeat seam and the NOI-5b transcript
284 /// event seam installed. The runtime uses this when an activity is driven with
285 /// observability enabled; the pre-existing constructors default the event seam
286 /// to `None` so every current call site is unchanged.
287 pub(crate) fn for_workflow_with_events(
288 workflow_id: WorkflowId,
289 run_id: RunId,
290 activity_id: ActivityId,
291 attempt: u32,
292 idempotency_key: Option<String>,
293 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
294 event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
295 ) -> (Self, ActivityCancellationHandle) {
296 let cancellation = Arc::new(CancellationState {
297 cancelled: AtomicBool::new(false),
298 notify: Notify::new(),
299 });
300 let context = Self {
301 workflow_id,
302 run_id,
303 activity_id,
304 attempt,
305 idempotency_key,
306 cancellation: Arc::clone(&cancellation),
307 heartbeat_sender,
308 event_sender,
309 };
310 let handle = ActivityCancellationHandle { cancellation };
311 (context, handle)
312 }
313}
314
315impl ActivityCancellationHandle {
316 /// Signals cooperative cancellation to the handler-facing context.
317 ///
318 /// The FLAG IS SET FIRST and the waiters woken second, and that order is
319 /// load-bearing in both directions: a waiter woken by this call finds the
320 /// flag already true (so [`ActivityContext::cancelled`] never wakes to a
321 /// flag that has not landed yet), and a waiter that registered before the
322 /// wake is woken by it (so the flag never lands with nobody told). Waking
323 /// first and setting second would let a woken waiter read `false`, loop,
324 /// and park forever on a notification that has already been spent.
325 ///
326 /// `notify_waiters` runs only on the 0→1 transition because cancellation
327 /// is signalled once: a second `cancel` has nothing new to say, and the
328 /// waiters it would wake are the ones already returned by the flag.
329 pub fn cancel(&self) {
330 let was_cancelled = self.cancellation.cancelled.swap(true, Ordering::AcqRel);
331 if !was_cancelled {
332 self.cancellation.notify.notify_waiters();
333 }
334 }
335}
336
337#[derive(Debug, thiserror::Error)]
338#[error("activity heartbeat seam is closed: {source}")]
339struct HeartbeatSeamClosed {
340 source: mpsc::error::SendError<HeartbeatRequest>,
341}
342
343#[derive(Debug, thiserror::Error)]
344#[error("activity transcript event seam is closed: {source}")]
345struct EventSeamClosed {
346 source: mpsc::error::SendError<ActivityEvent>,
347}
348
349#[cfg(test)]
350mod tests {
351 use std::future::Future as _;
352 use std::sync::Arc;
353 use std::sync::atomic::{AtomicBool, Ordering};
354 use std::task::{Context as TaskContext, Poll, Wake, Waker};
355 use std::time::Duration;
356
357 use aion_core::ActivityId;
358
359 use super::ActivityContext;
360
361 /// What a test returns. Every fallible step is carried rather than
362 /// unwrapped, because the workspace denies panicking accessors in test code
363 /// as firmly as in library code.
364 type TestResult = Result<(), Box<dyn std::error::Error>>;
365
366 /// NOI-5b: a context WITHOUT an event seam is a no-op — `emit_event` returns
367 /// `Ok(())` and drops the event, byte-identical to a context that predates the
368 /// seam. This is the additive guarantee: an activity that emits nothing (and
369 /// every isolated unit test) is unaffected.
370 #[tokio::test]
371 async fn emit_event_is_a_no_op_without_an_installed_seam() {
372 use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, RunId, WorkflowId};
373 use chrono::Utc;
374 use uuid::Uuid;
375
376 let workflow_id = WorkflowId::new(Uuid::from_u128(1));
377 let run_id = RunId::new(Uuid::from_u128(0x11));
378 let (context, _cancellation) = ActivityContext::new(
379 workflow_id.clone(),
380 run_id.clone(),
381 ActivityId::from_sequence_position(1),
382 0,
383 );
384 // The dispatch identity is always readable — a handler stamping a
385 // transcript event never has to invent the run axis.
386 assert_eq!(context.run_id(), &run_id);
387 assert_eq!(context.workflow_id(), &workflow_id);
388 let event = ActivityEvent {
389 workflow_id,
390 run_id,
391 activity_id: ActivityId::from_sequence_position(1),
392 attempt: 0,
393 agent_id: Uuid::from_u128(2),
394 agent_role: "orchestrator".to_owned(),
395 emitted_at: Utc::now(),
396 worker_seq: 1,
397 store_seq: None,
398 ephemeral: false,
399 kind: ActivityEventKind::Message {
400 role: MessageRole::Assistant,
401 text: "hello".to_owned(),
402 },
403 };
404 assert!(context.emit_event(event).is_ok());
405 }
406
407 /// With an event seam installed, `emit_event` forwards the neutral event to
408 /// the runtime drain end — the additive worker->server ingestion seam.
409 #[tokio::test]
410 async fn emit_event_forwards_to_installed_seam() -> Result<(), Box<dyn std::error::Error>> {
411 use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, RunId, WorkflowId};
412 use chrono::Utc;
413 use uuid::Uuid;
414
415 let run_id = RunId::new(Uuid::from_u128(0x11));
416 let (sender, mut drain) = super::mpsc::unbounded_channel();
417 let (context, _cancellation) = ActivityContext::for_workflow_with_events(
418 WorkflowId::new(Uuid::from_u128(1)),
419 run_id.clone(),
420 ActivityId::from_sequence_position(1),
421 0,
422 None,
423 None,
424 Some(sender),
425 );
426 // A dispatched context exposes the run its events must be stamped with.
427 assert_eq!(context.run_id(), &run_id);
428 let event = ActivityEvent {
429 workflow_id: WorkflowId::new(Uuid::from_u128(1)),
430 run_id: run_id.clone(),
431 activity_id: ActivityId::from_sequence_position(1),
432 attempt: 0,
433 agent_id: Uuid::from_u128(2),
434 agent_role: "orchestrator".to_owned(),
435 emitted_at: Utc::now(),
436 worker_seq: 7,
437 store_seq: None,
438 ephemeral: false,
439 kind: ActivityEventKind::Message {
440 role: MessageRole::Assistant,
441 text: "steer".to_owned(),
442 },
443 };
444 context.emit_event(event.clone())?;
445 let delivered = drain.recv().await.ok_or("event must be delivered")?;
446 assert_eq!(delivered.worker_seq, 7);
447 assert_eq!(delivered, event);
448 Ok(())
449 }
450
451 #[test]
452 fn live_task_context_exposes_the_server_idempotency_key() {
453 let run_id = aion_core::RunId::new_v4();
454 let (context, cancellation) = ActivityContext::for_task(
455 aion_core::WorkflowId::new_v4(),
456 run_id.clone(),
457 ActivityId::from_sequence_position(7),
458 3,
459 String::from("effect-key"),
460 None,
461 );
462
463 assert_eq!(context.idempotency_key(), Some("effect-key"));
464 assert_eq!(context.attempt(), 3);
465 assert_eq!(context.run_id(), &run_id);
466 drop(cancellation);
467 }
468
469 /// Reads the cancellation flag AT THE MOMENT it is woken, which is the only
470 /// place from which the ordering inside `cancel()` is observable: `wake` is
471 /// called from inside `notify_waiters`, so what the flag says here is what
472 /// it said before the wake was sent.
473 struct CancelWatcher {
474 context: ActivityContext,
475 woken: AtomicBool,
476 flag_at_wake: AtomicBool,
477 }
478
479 impl Wake for CancelWatcher {
480 fn wake(self: Arc<Self>) {
481 self.wake_by_ref();
482 }
483
484 fn wake_by_ref(self: &Arc<Self>) {
485 self.flag_at_wake
486 .store(self.context.is_cancelled(), Ordering::Release);
487 self.woken.store(true, Ordering::Release);
488 }
489 }
490
491 /// THE ORDERING GUARANTEE, both halves, measured rather than argued.
492 ///
493 /// A waiter that has polled once is REGISTERED (it is woken by a later
494 /// `cancel`), and `cancel` sets the flag BEFORE it wakes anyone (the flag
495 /// already reads true from inside the wake). Those two facts are what make
496 /// a cancellation impossible to lose: whichever side moves first, the other
497 /// sees it.
498 #[test]
499 fn cancel_sets_the_flag_before_it_wakes_a_registered_waiter() {
500 let (context, handle) = ActivityContext::new(
501 aion_core::WorkflowId::new_v4(),
502 aion_core::RunId::new_v4(),
503 ActivityId::from_sequence_position(1),
504 1,
505 );
506 let watcher = Arc::new(CancelWatcher {
507 context: context.clone(),
508 woken: AtomicBool::new(false),
509 flag_at_wake: AtomicBool::new(false),
510 });
511 let waker = Waker::from(Arc::clone(&watcher));
512 let mut task = TaskContext::from_waker(&waker);
513 let mut cancelled = std::pin::pin!(context.cancelled());
514
515 assert_eq!(
516 cancelled.as_mut().poll(&mut task),
517 Poll::Pending,
518 "nothing has cancelled yet"
519 );
520 assert!(
521 !watcher.woken.load(Ordering::Acquire),
522 "no wake before a cancel"
523 );
524
525 handle.cancel();
526
527 assert!(
528 watcher.woken.load(Ordering::Acquire),
529 "the waiter registered its interest on its first poll, so `cancel` found it"
530 );
531 assert!(
532 watcher.flag_at_wake.load(Ordering::Acquire),
533 "`cancel` must set the flag BEFORE it notifies: a waiter woken to a flag that has \
534 not landed yet loops and parks forever"
535 );
536 assert_eq!(
537 cancelled.as_mut().poll(&mut task),
538 Poll::Ready(()),
539 "the woken waiter resolves"
540 );
541 }
542
543 /// THE WINDOW THE FIX CLOSES, measured directly on the primitive rather
544 /// than asserted about it.
545 ///
546 /// Three arms, and the middle one is the whole reason the order in
547 /// `cancelled()` is what it is:
548 ///
549 /// * a `Notified` created AFTER a `notify_waiters` never sees it — the
550 /// notification is gone, and cancellation is signalled exactly once, so a
551 /// lost one is lost for good;
552 /// * a `Notified` created BEFORE it does see it, even though it had not yet
553 /// been polled;
554 /// * and one that was additionally `enable`d sees it too.
555 ///
556 /// So the guarantee `cancelled()` needs is that the `Notified` EXISTS
557 /// before the flag is read. It is enabled as well, which registers it
558 /// explicitly at a point this code chooses, so the guarantee does not rest
559 /// on the creation-time capture alone.
560 #[tokio::test]
561 async fn a_notification_is_seen_by_a_waiter_that_existed_before_it_and_lost_on_one_that_did_not()
562 -> TestResult {
563 let notify = tokio::sync::Notify::new();
564 notify.notify_waiters();
565 let mut late = std::pin::pin!(notify.notified());
566 let missed = tokio::time::timeout(Duration::from_millis(200), late.as_mut()).await;
567 assert!(
568 missed.is_err(),
569 "a notification sent before the waiter existed is LOST — this is the window"
570 );
571
572 let notify = tokio::sync::Notify::new();
573 let mut created = std::pin::pin!(notify.notified());
574 notify.notify_waiters();
575 let landed = tokio::time::timeout(Duration::from_millis(200), created.as_mut()).await;
576 assert!(
577 landed.is_ok(),
578 "a waiter that existed when the notification was sent sees it"
579 );
580
581 let notify = tokio::sync::Notify::new();
582 let mut registered = std::pin::pin!(notify.notified());
583 assert!(
584 !registered.as_mut().enable(),
585 "nothing has been notified yet, so enabling only registers"
586 );
587 notify.notify_waiters();
588 let landed = tokio::time::timeout(Duration::from_millis(200), registered.as_mut()).await;
589 assert!(
590 landed.is_ok(),
591 "a waiter that registered before the notification was sent sees it"
592 );
593 Ok(())
594 }
595
596 /// THE RACE, RUN: a cancel landing while the waiter is on its way into the
597 /// wait must never be lost. Bounded — a fixed number of rounds, each with a
598 /// patience wide enough that a healthy round never approaches it — and it
599 /// FAILS rather than hangs, naming the round that lost its cancellation.
600 ///
601 /// The defect this guards was reproducible at roughly one round in tens of
602 /// thousands, so a green run of this test is a guard and not a proof; the
603 /// proof is the ordering test above and the `enable`-before-read structure
604 /// it pins. What this catches is a future change that quietly reintroduces
605 /// the read-then-register order.
606 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
607 async fn a_cancel_racing_the_waiter_into_the_wait_is_never_lost() -> TestResult {
608 const ROUNDS: usize = 20_000;
609 const PATIENCE: Duration = Duration::from_secs(30);
610
611 for round in 0..ROUNDS {
612 let (context, handle) = ActivityContext::new(
613 aion_core::WorkflowId::new_v4(),
614 aion_core::RunId::new_v4(),
615 ActivityId::from_sequence_position(1),
616 1,
617 );
618 let waiter = tokio::spawn(async move { context.cancelled().await });
619 // The canceller runs on a BLOCKING pool thread, which is a real
620 // thread of its own rather than a task queued behind the waiter:
621 // the two sides have to be genuinely parallel for the window to be
622 // reachable at all, and a canceller that can only run after the
623 // waiter has parked would make every round pass vacuously.
624 let canceller = tokio::task::spawn_blocking(move || handle.cancel());
625 match tokio::time::timeout(PATIENCE, waiter).await {
626 Ok(joined) => joined?,
627 Err(elapsed) => {
628 return Err(format!(
629 "round {round}: the waiter never woke ({elapsed}) — a cancellation was \
630 lost, which is the read-then-register window"
631 )
632 .into());
633 }
634 }
635 canceller.await?;
636 }
637 Ok(())
638 }
639
640 #[tokio::test]
641 async fn context_exposes_identity_attempt_and_cancellation_signal() {
642 let activity_id = ActivityId::from_sequence_position(42);
643 let (context, cancellation) = ActivityContext::new(
644 aion_core::WorkflowId::new_v4(),
645 aion_core::RunId::new_v4(),
646 activity_id.clone(),
647 3,
648 );
649
650 assert_eq!(context.activity_id(), &activity_id);
651 assert_eq!(context.attempt(), 3);
652 assert!(!context.is_cancelled());
653
654 cancellation.cancel();
655
656 assert!(context.is_cancelled());
657 let cancelled = tokio::time::timeout(Duration::from_millis(50), context.cancelled()).await;
658 assert!(cancelled.is_ok());
659 }
660}