Skip to main content

aion_server/worker/
activity_cancel.rs

1//! Stopping a run's in-flight activities, wherever they are executing (#233).
2//!
3//! Cancelling a workflow records `WorkflowCancelled`, kills the workflow's own
4//! VM process, and settles its outbox rows so nothing more is dispatched. None
5//! of that reaches an activity a remote worker is ALREADY executing: that is a
6//! separate OS process, on a separate machine in the general case, and the only
7//! thing that can interrupt it is a message down the stream its dispatch came
8//! from. This module sends that message.
9//!
10//! **A cancel is a request, not a guarantee.** Delivering it establishes one
11//! fact — that the server asked. Whether the work stops depends on the worker
12//! still holding the activity and on the action being interruptible, and
13//! neither is observable from here. Every outcome this module reports is
14//! therefore phrased as what was asked or what stopped the asking, never as a
15//! stop it did not witness. Silently assuming it stopped is the same defect one
16//! layer up from the one this exists to fix.
17//!
18//! Routing comes from [`HeartbeatTracker::in_flight_for_workflow`] — the live
19//! registry of which worker holds which activity — rather than from the durable
20//! outbox row, which records no worker at all.
21//!
22//! # Not every in-flight activity has a worker
23//!
24//! An action whose contract declares a body is executed BY THIS SERVER
25//! ([`super::declared_body`]): no worker holds it, no heartbeat tracks it, and
26//! the tracker read above therefore cannot see it. Stopping those is
27//! [`super::declared_body_cancel`]'s job, and it happens HERE, in the same call,
28//! because "stop this run's in-flight work" is one question with two execution
29//! paths — and a caller that had to remember the second one would eventually
30//! forget it, which is the same silence #233 closed for the first.
31
32use aion_core::{ActivityId, WorkflowId};
33use aion_proto::{ProtoActivityId, ProtoCancelActivity, ProtoWorkflowId};
34use tracing::{info, warn};
35
36use crate::error::ServerError;
37use crate::worker::declared_body_cancel::DeclaredCommandAttempts;
38use crate::worker::heartbeat::HeartbeatTracker;
39use crate::worker::intervention::AttemptKey;
40use crate::worker::registry::{ConnectedWorkerRegistry, WorkerId, WorkerMessage};
41
42/// What happened when the server tried to ask one worker to stop one activity.
43///
44/// Every variant names an OBSERVATION about the asking. None of them claims the
45/// work stopped, because delivering a cancel does not establish that.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum CancelDelivery {
48    /// The cancel was queued on the worker's dispatch stream. The server asked;
49    /// nothing more than that is known from here.
50    Requested,
51    /// The worker was tracked as holding the activity but is no longer
52    /// registered — it disconnected between the tracker read and the send.
53    /// Its work is already orphaned by the disconnect, not by this.
54    WorkerGone,
55    /// The worker's transport carries no control messages, so a cancel cannot
56    /// be expressed to it at all (the liminal transport, which has no drain
57    /// channel either). Named rather than skipped: an operator who cancelled a
58    /// run must be able to find out that the ask never left the building.
59    TransportCannotCarry,
60    /// The worker is registered but its dispatch channel would not accept the
61    /// message — full or closed. The transport is failing, and the same channel
62    /// carries this worker's dispatches.
63    ChannelUnavailable,
64}
65
66impl CancelDelivery {
67    /// Whether the cancel actually reached the worker's stream.
68    ///
69    /// Deliberately NOT named `succeeded`: a queued cancel is a delivered
70    /// question, not an answered one.
71    #[must_use]
72    pub const fn was_requested(self) -> bool {
73        matches!(self, Self::Requested)
74    }
75}
76
77/// One activity the server asked a worker to stop, and how that asking went.
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct CancelRequest {
80    /// Worker that was holding the activity when the tracker was read.
81    pub worker_id: WorkerId,
82    /// Workflow the activity belongs to.
83    pub workflow_id: WorkflowId,
84    /// Activity within that workflow.
85    pub activity_id: ActivityId,
86    /// Delivery attempt the tracked dispatch carried, for the record.
87    pub attempt: u32,
88    /// What was observed when the ask was attempted.
89    pub delivery: CancelDelivery,
90}
91
92/// Everything this node did to stop one run's in-flight work.
93///
94/// Two lists because there are two execution paths and they are stopped by
95/// different means — asking a worker over its stream, and signalling a process
96/// this server owns. Kept apart rather than merged into one vocabulary: a
97/// worker ask is a question whose answer this node never sees, and a declared
98/// body's stop is one the attempt itself witnesses, and calling both "cancel
99/// requests" would lose that difference exactly where an operator needs it.
100#[derive(Clone, Debug, Default, Eq, PartialEq)]
101pub struct InFlightCancellation {
102    /// One record per activity a WORKER was tracked as holding, including the
103    /// ones that could not be asked.
104    pub worker_requests: Vec<CancelRequest>,
105    /// The declared bodies THIS SERVER was executing, now signalled. Each named
106    /// attempt has had its cancellation raised; the attempt reports back only
107    /// once its process group has been proven gone.
108    pub declared_attempts: Vec<AttemptKey>,
109}
110
111impl InFlightCancellation {
112    /// Whether this node stopped nothing at all — the common case for a run with
113    /// no activity in flight, and not a failure.
114    #[must_use]
115    pub fn stopped_nothing(&self) -> bool {
116        self.worker_requests.is_empty() && self.declared_attempts.is_empty()
117    }
118}
119
120/// Stop every in-flight activity of `workflow_id`, by whichever path is
121/// executing it (#233).
122///
123/// Two paths, one call. Every activity a WORKER holds produces a
124/// [`CancelRequest`], in the order the tracker yielded them, INCLUDING the ones
125/// that could not be asked. Every declared body THIS SERVER is executing is
126/// signalled directly, which reaches the command's process group rather than
127/// merely a worker's inbox. An empty result on both means nothing was in flight
128/// for this workflow — the common case, and not a failure.
129///
130/// Call this AFTER the terminal is durably recorded, never before: a cancel
131/// pushed ahead of the record could stop work for a cancellation that then
132/// fails to persist.
133///
134/// # Errors
135///
136/// Returns [`ServerError::LockPoisoned`] when the tracker's, the registry's, or
137/// the declared-attempt registry's state cannot be trusted. A poisoned lock is
138/// not survivable here: it would mean answering "nothing was in flight" from
139/// state that cannot be read, and an operator would take that silence for a
140/// completed cancellation.
141pub fn cancel_in_flight_activities(
142    tracker: &HeartbeatTracker,
143    registry: &ConnectedWorkerRegistry,
144    declared: &DeclaredCommandAttempts,
145    workflow_id: &WorkflowId,
146) -> Result<InFlightCancellation, ServerError> {
147    let in_flight = tracker.in_flight_for_workflow(workflow_id)?;
148    let mut worker_requests = Vec::with_capacity(in_flight.len());
149    for liveness in in_flight {
150        let delivery = ask_worker_to_stop(
151            registry,
152            liveness.worker_id,
153            &liveness.workflow_id,
154            &liveness.activity_id,
155        )?;
156        worker_requests.push(CancelRequest {
157            worker_id: liveness.worker_id,
158            workflow_id: liveness.workflow_id,
159            activity_id: liveness.activity_id,
160            attempt: liveness.attempt,
161            delivery,
162        });
163    }
164    // The server's own execution path, in the SAME call: an activity is either
165    // held by a worker or run here, and a cancel that only covered the first
166    // left the second running under a console reporting `Cancelled`.
167    let declared_attempts = declared.cancel_workflow(workflow_id)?;
168    let cancellation = InFlightCancellation {
169        worker_requests,
170        declared_attempts,
171    };
172    report(workflow_id, &cancellation);
173    Ok(cancellation)
174}
175
176/// Push one cancel down one worker's dispatch stream.
177///
178/// `pub(crate)` for the bridge's superseded-attempt release (aion#195): when a
179/// higher attempt takes over an execution site, the worker still executing the
180/// superseded attempt is asked to stop through the same message a workflow
181/// cancel uses — one asking path, two callers.
182pub(crate) fn ask_worker_to_stop(
183    registry: &ConnectedWorkerRegistry,
184    worker_id: WorkerId,
185    workflow_id: &WorkflowId,
186    activity_id: &ActivityId,
187) -> Result<CancelDelivery, ServerError> {
188    let Some(worker) = registry.worker_by_id(worker_id)? else {
189        return Ok(CancelDelivery::WorkerGone);
190    };
191    let Some(sender) = worker.sender() else {
192        return Ok(CancelDelivery::TransportCannotCarry);
193    };
194    let message = WorkerMessage::CancelActivity(ProtoCancelActivity {
195        workflow_id: Some(ProtoWorkflowId {
196            uuid: workflow_id.to_string(),
197        }),
198        activity_id: Some(ProtoActivityId {
199            sequence_position: activity_id.sequence_position(),
200        }),
201    });
202    if sender.try_send(message).is_ok() {
203        Ok(CancelDelivery::Requested)
204    } else {
205        Ok(CancelDelivery::ChannelUnavailable)
206    }
207}
208
209/// Log what was stopped, what was only asked, and what stopped an ask.
210///
211/// Anything that is not a plain `Requested` is logged at WARN with the worker
212/// and activity named, because it means a cancelled run may still be holding a
213/// machine and nothing else in the system will say so.
214fn report(workflow_id: &WorkflowId, cancellation: &InFlightCancellation) {
215    if cancellation.stopped_nothing() {
216        return;
217    }
218    let requests = &cancellation.worker_requests;
219    let requested = requests
220        .iter()
221        .filter(|request| request.delivery.was_requested())
222        .count();
223    info!(
224        workflow_id = %workflow_id,
225        in_flight = requests.len(),
226        requested,
227        declared_attempts = cancellation.declared_attempts.len(),
228        "stopped the cancelled run's in-flight activities: asked their workers, and \
229         signalled the declared bodies this server was executing"
230    );
231    for request in requests
232        .iter()
233        .filter(|request| !request.delivery.was_requested())
234    {
235        warn!(
236            workflow_id = %workflow_id,
237            worker_id = request.worker_id.value(),
238            activity_id = request.activity_id.sequence_position(),
239            attempt = request.attempt,
240            outcome = ?request.delivery,
241            "could not ask a worker to stop a cancelled run's activity; \
242             the work may still be running"
243        );
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::{
250        CancelDelivery, CancelRequest, DeclaredCommandAttempts, cancel_in_flight_activities,
251    };
252    use crate::error::ServerError;
253    use crate::worker::heartbeat::{HeartbeatTracker, InFlightActivity};
254    use crate::worker::registry::RegistrationOptions;
255    use crate::worker::registry::{
256        ConnectedWorkerRegistry, WorkerId, WorkerMessage, WorkerRegistration,
257    };
258    use aion_core::{ActivityId, WorkflowId};
259    use std::time::{Duration, Instant};
260    use tokio::sync::mpsc::{self, Receiver};
261
262    type TestResult = Result<(), Box<dyn std::error::Error>>;
263
264    const WINDOW: Duration = Duration::from_secs(5);
265
266    /// A registered worker whose registration guard is HELD: dropping a
267    /// [`WorkerRegistration`] deregisters the worker (the disconnect path), so
268    /// the guard must live as long as the test wants the worker present.
269    /// Returning only the id — the first draft of these tests — deregistered
270    /// every worker before the test body ran, and one test then passed green
271    /// for the wrong reason: its explicit deregister had already been done for
272    /// it by the drop.
273    struct RegisteredWorker {
274        registry: ConnectedWorkerRegistry,
275        registration: WorkerRegistration,
276        worker_id: WorkerId,
277        received: Receiver<WorkerMessage>,
278    }
279
280    fn registry_with_worker(capacity: usize) -> Result<RegisteredWorker, ServerError> {
281        let registry = ConnectedWorkerRegistry::default();
282        let (sender, received) = mpsc::channel(capacity);
283        let activity_types = [String::from("work")];
284        let registration = registry.register(
285            "tenant-a",
286            activity_types.iter(),
287            sender,
288            crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
289        )?;
290        let worker_id = registration
291            .worker_id()
292            .ok_or_else(|| ServerError::lock_poisoned("test worker registration"))?;
293        Ok(RegisteredWorker {
294            registry,
295            registration,
296            worker_id,
297            received,
298        })
299    }
300
301    fn track(
302        tracker: &HeartbeatTracker,
303        registry: &ConnectedWorkerRegistry,
304        worker_id: WorkerId,
305        workflow_id: &WorkflowId,
306        position: u64,
307    ) -> Result<(), ServerError> {
308        tracker.track_task(
309            worker_id,
310            InFlightActivity {
311                workflow_id: workflow_id.clone(),
312                activity_id: ActivityId::from_sequence_position(position),
313                attempt: 1,
314                completion_token: crate::worker::CompletionToken::for_test(),
315            },
316            Instant::now(),
317            registry,
318            None,
319        )
320    }
321
322    /// A tracked activity produces a cancel on the worker's OWN stream — the
323    /// same channel its dispatch travelled.
324    ///
325    /// `try_recv`, never `recv().await`: the fan-out is synchronous, so once it
326    /// returns the cancel is either already queued or was never sent. A wait
327    /// here could only hang forever on a missing message instead of failing.
328    #[tokio::test]
329    async fn a_tracked_activity_is_asked_to_stop_on_its_workers_stream() -> TestResult {
330        let mut worker = registry_with_worker(4)?;
331        let tracker = HeartbeatTracker::new(WINDOW);
332        let workflow_id = WorkflowId::new_v4();
333        track(
334            &tracker,
335            &worker.registry,
336            worker.worker_id,
337            &workflow_id,
338            7,
339        )?;
340
341        let declared = DeclaredCommandAttempts::new(crate::shutdown::DrainState::default());
342        let requests =
343            cancel_in_flight_activities(&tracker, &worker.registry, &declared, &workflow_id)?
344                .worker_requests;
345
346        assert_eq!(requests.len(), 1, "one tracked activity, one ask");
347        assert_eq!(requests[0].delivery, CancelDelivery::Requested);
348        assert_eq!(
349            requests[0].activity_id,
350            ActivityId::from_sequence_position(7)
351        );
352        let Ok(WorkerMessage::CancelActivity(cancel)) = worker.received.try_recv() else {
353            return Err("the worker's stream did not carry a cancel".into());
354        };
355        assert_eq!(
356            cancel.workflow_id.map(|id| id.uuid),
357            Some(workflow_id.to_string()),
358            "the cancel must name the workflow it is cancelling"
359        );
360        assert_eq!(
361            cancel.activity_id.map(|id| id.sequence_position),
362            Some(7),
363            "the cancel must name the activity it is cancelling"
364        );
365        Ok(())
366    }
367
368    /// A workflow with nothing in flight asks nothing — the common case, and
369    /// not a failure.
370    #[tokio::test]
371    async fn a_workflow_with_nothing_in_flight_asks_nobody() -> TestResult {
372        let worker = registry_with_worker(4)?;
373        let tracker = HeartbeatTracker::new(WINDOW);
374
375        let declared = DeclaredCommandAttempts::new(crate::shutdown::DrainState::default());
376        let requests = cancel_in_flight_activities(
377            &tracker,
378            &worker.registry,
379            &declared,
380            &WorkflowId::new_v4(),
381        )?
382        .worker_requests;
383
384        assert!(
385            requests.is_empty(),
386            "an untracked workflow must produce no asks, not a synthesized one"
387        );
388        Ok(())
389    }
390
391    /// Only the named workflow's activities are asked to stop. A cancel must
392    /// never reach a bystander sharing the same worker.
393    #[tokio::test]
394    async fn another_workflows_activity_is_never_asked_to_stop() -> TestResult {
395        let mut worker = registry_with_worker(4)?;
396        let tracker = HeartbeatTracker::new(WINDOW);
397        let cancelled = WorkflowId::new_v4();
398        let bystander = WorkflowId::new_v4();
399        track(&tracker, &worker.registry, worker.worker_id, &cancelled, 1)?;
400        track(&tracker, &worker.registry, worker.worker_id, &bystander, 1)?;
401
402        let declared = DeclaredCommandAttempts::new(crate::shutdown::DrainState::default());
403        let requests =
404            cancel_in_flight_activities(&tracker, &worker.registry, &declared, &cancelled)?
405                .worker_requests;
406
407        assert_eq!(requests.len(), 1, "only the cancelled run's activity");
408        let Ok(WorkerMessage::CancelActivity(cancel)) = worker.received.try_recv() else {
409            return Err("the worker's stream did not carry a cancel".into());
410        };
411        assert_eq!(
412            cancel.workflow_id.map(|id| id.uuid),
413            Some(cancelled.to_string()),
414            "the bystander workflow must not be named in any cancel"
415        );
416        assert!(
417            worker.received.try_recv().is_err(),
418            "exactly one cancel; the bystander's activity was asked to stop too"
419        );
420        Ok(())
421    }
422
423    /// A worker that has gone since the tracker entry was written is REPORTED,
424    /// not silently skipped.
425    #[tokio::test]
426    async fn a_departed_worker_is_reported_rather_than_skipped() -> TestResult {
427        let worker = registry_with_worker(4)?;
428        let tracker = HeartbeatTracker::new(WINDOW);
429        let workflow_id = WorkflowId::new_v4();
430        track(
431            &tracker,
432            &worker.registry,
433            worker.worker_id,
434            &workflow_id,
435            1,
436        )?;
437        worker.registration.deregister()?;
438
439        let declared = DeclaredCommandAttempts::new(crate::shutdown::DrainState::default());
440        let requests: Vec<CancelRequest> =
441            cancel_in_flight_activities(&tracker, &worker.registry, &declared, &workflow_id)?
442                .worker_requests;
443
444        assert_eq!(requests.len(), 1, "the entry is reported, not dropped");
445        assert_eq!(
446            requests[0].delivery,
447            CancelDelivery::WorkerGone,
448            "an unaskable worker must be named, never counted as asked"
449        );
450        assert!(
451            !requests[0].delivery.was_requested(),
452            "a departed worker was never asked"
453        );
454        Ok(())
455    }
456
457    /// A worker on a transport that carries no control messages is reported as
458    /// exactly that — never as gone (it is still registered) and never as
459    /// asked (the ask cannot be expressed to it).
460    ///
461    /// The supervisor is real but deliberately connected to nothing: what is
462    /// under test is the registry's answer for a liminal delivery, not liminal
463    /// itself.
464    #[cfg(feature = "liminal-transport")]
465    #[tokio::test]
466    async fn a_liminal_worker_is_reported_as_transport_cannot_carry() -> TestResult {
467        use crate::worker::liminal_transport::LiminalWorkerDelivery;
468        use crate::worker::registry::WorkerDelivery;
469
470        let registry = ConnectedWorkerRegistry::default();
471        let supervisor = liminal_server::server::connection::ConnectionSupervisor::new()?;
472        let activity_types = [String::from("work")];
473        // The guard must outlive the fan-out call: dropping it deregisters.
474        let registration = registry.register_delivery(
475            [String::from("tenant-a")],
476            String::from("default"),
477            None,
478            activity_types.iter(),
479            WorkerDelivery::Liminal(LiminalWorkerDelivery::new(supervisor, 7)),
480            RegistrationOptions::identified(
481                String::from("activity-cancel-worker"),
482                crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
483            ),
484        )?;
485        let worker_id = registration
486            .worker_id()
487            .ok_or_else(|| ServerError::lock_poisoned("test worker registration"))?;
488        let tracker = HeartbeatTracker::new(WINDOW);
489        let workflow_id = WorkflowId::new_v4();
490        track(&tracker, &registry, worker_id, &workflow_id, 1)?;
491
492        let declared = DeclaredCommandAttempts::new(crate::shutdown::DrainState::default());
493        let requests = cancel_in_flight_activities(&tracker, &registry, &declared, &workflow_id)?
494            .worker_requests;
495
496        assert_eq!(requests.len(), 1, "the entry is reported, not dropped");
497        assert_eq!(
498            requests[0].delivery,
499            CancelDelivery::TransportCannotCarry,
500            "a control-less transport must be named as such, not as gone or asked"
501        );
502        drop(registration);
503        Ok(())
504    }
505
506    /// A closed dispatch channel is reported as unavailable, not as an ask.
507    #[tokio::test]
508    async fn a_closed_dispatch_channel_is_reported_as_unavailable() -> TestResult {
509        let worker = registry_with_worker(1)?;
510        let tracker = HeartbeatTracker::new(WINDOW);
511        let workflow_id = WorkflowId::new_v4();
512        track(
513            &tracker,
514            &worker.registry,
515            worker.worker_id,
516            &workflow_id,
517            1,
518        )?;
519        drop(worker.received);
520
521        let declared = DeclaredCommandAttempts::new(crate::shutdown::DrainState::default());
522        let requests =
523            cancel_in_flight_activities(&tracker, &worker.registry, &declared, &workflow_id)?
524                .worker_requests;
525
526        assert_eq!(
527            requests[0].delivery,
528            CancelDelivery::ChannelUnavailable,
529            "a dead channel must not read as a delivered cancel"
530        );
531        Ok(())
532    }
533}