Skip to main content

aion_server/worker/
declared_body_cancel.rs

1//! Stopping the declared bodies THIS server is executing.
2//!
3//! [`super::activity_cancel`] asks the workers holding a cancelled run's
4//! activities to stop. A declared action body has no worker to ask: it is a
5//! process this server started itself, at the dispatch seam, before any
6//! task-queue routing happened — so it appears in no heartbeat tracker, is held
7//! by no connected worker, and nothing in the cancel path could see it. A
8//! cancelled run's server-run command therefore kept a machine busy while the
9//! console truthfully reported `Cancelled`, which is the same defect #233 fixed
10//! for remote workers, one execution path over.
11//!
12//! This module is the registry that makes those attempts visible while they
13//! run, and the stop the server performs ITSELF rather than asks for.
14//!
15//! # This one is not a request
16//!
17//! A worker cancel establishes only that the server asked. Signalling here
18//! reaches [`aion_worker::ActivityContext`]'s cooperative cancellation, which
19//! the declared-body executor has already handed to
20//! [`aion_worker::run_cancellable_command`]: `SIGTERM` →
21//! [`aion_worker::PROCESS_GROUP_TERMINATION_GRACE`] → `SIGKILL` across the whole
22//! process group, with the `Cancelled` verdict withheld until the group has been
23//! PROVEN gone. What this module returns is still an ASKING — the signal has
24//! been raised, not yet acted on — but the attempt it names cannot report back
25//! until its process tree is gone, so the stop is witnessed rather than assumed,
26//! by the attempt itself.
27//!
28//! # An unregistered attempt is uncancellable
29//!
30//! Registration is therefore not bookkeeping. An attempt that fails to enter
31//! this registry has no cancellation path at all, so the executor refuses the
32//! dispatch instead of running a command nothing could stop.
33
34use std::collections::HashMap;
35use std::sync::{Arc, Mutex, PoisonError};
36
37use aion_core::WorkflowId;
38use aion_worker::ActivityCancellationHandle;
39
40use super::intervention::AttemptKey;
41use crate::error::ServerError;
42
43/// The lock resource name carried by a [`ServerError::LockPoisoned`] raised
44/// here, so an operator reading the error knows which state could not be read.
45const RESOURCE: &str = "declared command attempts";
46
47/// The declared-command attempts this server is executing right now.
48///
49/// An entry exists for exactly as long as one attempt's command is running:
50/// [`Self::register`] returns a guard that removes it, so an attempt that
51/// finished, failed, or panicked its way out cannot be signalled afterwards.
52/// Keyed on the full [`AttemptKey`] — including the run — because a
53/// continue-as-new chain reuses one workflow id across generations while
54/// activity ordinals and attempt numbers restart, so a shorter key genuinely
55/// COLLIDES: two generations' attempts would be one entry, and registering the
56/// second would replace the first's handle with nothing left to stop it. The
57/// run axis is what keeps them distinct here and what lets the cancel report
58/// name which generation it stopped. Cancelling is still workflow-scoped, as it
59/// is for workers ([`super::HeartbeatTracker::in_flight_for_workflow`]): a
60/// cancelled workflow's work stops in every generation of it.
61#[derive(Clone, Debug, Default)]
62pub struct DeclaredCommandAttempts {
63    inner: Arc<Mutex<HashMap<AttemptKey, ActivityCancellationHandle>>>,
64}
65
66impl DeclaredCommandAttempts {
67    /// Build an empty registry.
68    #[must_use]
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Record that this server is executing `key`'s declared command, and hand
74    /// back the guard that keeps the entry alive.
75    ///
76    /// The entry lives exactly as long as the returned
77    /// [`DeclaredAttemptRegistration`]. A second registration of the same key —
78    /// which would mean one attempt executing twice at once, and is a defect
79    /// wherever it came from — is refused rather than allowed to overwrite the
80    /// handle of a command still running, because an overwritten handle is an
81    /// attempt nothing can stop.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`ServerError::LockPoisoned`] when the registry cannot be read,
86    /// and [`ServerError::DeclaredAttemptCollision`] when `key` is already
87    /// executing.
88    pub fn register(
89        &self,
90        key: AttemptKey,
91        cancellation: ActivityCancellationHandle,
92    ) -> Result<DeclaredAttemptRegistration, ServerError> {
93        let mut attempts = self
94            .inner
95            .lock()
96            .map_err(|_poisoned| ServerError::lock_poisoned(RESOURCE))?;
97        if attempts.contains_key(&key) {
98            return Err(ServerError::DeclaredAttemptCollision {
99                workflow_id: key.workflow_id.clone(),
100                activity_id: key.activity_id.clone(),
101                attempt: key.attempt,
102            });
103        }
104        attempts.insert(key.clone(), cancellation);
105        drop(attempts);
106        Ok(DeclaredAttemptRegistration {
107            attempts: self.clone(),
108            key,
109        })
110    }
111
112    /// Signal every declared command this server is executing for `workflow_id`.
113    ///
114    /// Returns the attempts signalled, in activity-then-attempt order, so the
115    /// caller reports a stable list rather than whatever order the map yielded.
116    /// An empty result means this server is executing none of the run's bodies,
117    /// which is the common case and is not a failure.
118    ///
119    /// The entries are NOT removed here. Removing them is the executing
120    /// attempt's own act, on the guard it holds, once its process group is gone
121    /// — and a cancel that deregistered an attempt it had merely signalled would
122    /// make a second cancel a silent no-op against a command still dying.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`ServerError::LockPoisoned`] when the registry cannot be read.
127    /// Not survivable: answering "this server is executing nothing for that run"
128    /// out of state that could not be read is exactly how a cancelled run keeps
129    /// a machine.
130    pub fn cancel_workflow(
131        &self,
132        workflow_id: &WorkflowId,
133    ) -> Result<Vec<AttemptKey>, ServerError> {
134        let attempts = self
135            .inner
136            .lock()
137            .map_err(|_poisoned| ServerError::lock_poisoned(RESOURCE))?;
138        let mut signalled = Vec::new();
139        for (key, cancellation) in attempts.iter() {
140            if key.workflow_id == *workflow_id {
141                cancellation.cancel();
142                signalled.push(key.clone());
143            }
144        }
145        drop(attempts);
146        signalled.sort_by_key(|key| (key.activity_id.sequence_position(), key.attempt));
147        Ok(signalled)
148    }
149
150    /// Every declared command this server is executing right now, in
151    /// activity-then-attempt order.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`ServerError::LockPoisoned`] when the registry cannot be read.
156    pub fn executing(&self) -> Result<Vec<AttemptKey>, ServerError> {
157        let attempts = self
158            .inner
159            .lock()
160            .map_err(|_poisoned| ServerError::lock_poisoned(RESOURCE))?;
161        let mut keys = attempts.keys().cloned().collect::<Vec<_>>();
162        drop(attempts);
163        keys.sort_by_key(|key| (key.activity_id.sequence_position(), key.attempt));
164        Ok(keys)
165    }
166
167    /// Remove one entry, whatever state the lock is in.
168    ///
169    /// The poisoned lock is RECOVERED rather than reported, because this runs
170    /// from a drop guard that has no way to return a failure and because the
171    /// alternative is worse: a retained entry outlives the command it names, and
172    /// a later cancel would then signal a handle whose activity has already
173    /// ended. The poisoning itself is stated once, here, at ERROR.
174    fn release(&self, key: &AttemptKey) {
175        let mut attempts = match self.inner.lock() {
176            Ok(attempts) => attempts,
177            Err(poisoned) => {
178                tracing::error!(
179                    resource = RESOURCE,
180                    "the declared-command attempt registry's lock is poisoned; recovering it \
181                     to release the finished attempt rather than leaving a stale entry a \
182                     later cancel could signal"
183                );
184                PoisonError::into_inner(poisoned)
185            }
186        };
187        attempts.remove(key);
188    }
189}
190
191/// Keeps one executing declared command visible to the cancel path.
192///
193/// Dropping it deregisters the attempt, so the entry cannot outlive the command
194/// it names — including when the executing thread unwinds.
195#[derive(Debug)]
196pub struct DeclaredAttemptRegistration {
197    attempts: DeclaredCommandAttempts,
198    key: AttemptKey,
199}
200
201impl DeclaredAttemptRegistration {
202    /// The attempt this registration keeps visible.
203    #[must_use]
204    pub const fn key(&self) -> &AttemptKey {
205        &self.key
206    }
207}
208
209impl Drop for DeclaredAttemptRegistration {
210    fn drop(&mut self) {
211        self.attempts.release(&self.key);
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use aion_core::{ActivityId, RunId, WorkflowId};
218    use aion_worker::ActivityContext;
219
220    use super::{AttemptKey, DeclaredCommandAttempts};
221
222    /// What a test returns. Every fallible step is carried rather than
223    /// unwrapped, because the workspace denies panicking accessors in test code
224    /// as firmly as in library code.
225    type TestResult = Result<(), Box<dyn std::error::Error>>;
226
227    fn key(workflow_id: &WorkflowId, position: u64, attempt: u32) -> AttemptKey {
228        AttemptKey::new(
229            workflow_id.clone(),
230            RunId::new_v4(),
231            ActivityId::from_sequence_position(position),
232            attempt,
233        )
234    }
235
236    /// One executing attempt is signalled, and the signal reaches the context
237    /// the executor is running under — not a copy of it.
238    #[tokio::test]
239    async fn a_registered_attempt_is_signalled_on_its_own_context() -> TestResult {
240        let attempts = DeclaredCommandAttempts::new();
241        let workflow_id = WorkflowId::new_v4();
242        let target = key(&workflow_id, 3, 1);
243        let (context, cancellation) = ActivityContext::new(
244            target.workflow_id.clone(),
245            target.run_id.clone(),
246            target.activity_id.clone(),
247            target.attempt,
248        );
249        let registration = attempts.register(target.clone(), cancellation)?;
250
251        let signalled = attempts.cancel_workflow(&workflow_id)?;
252
253        assert_eq!(signalled, vec![target]);
254        assert!(
255            context.is_cancelled(),
256            "the registry must signal the context the command is running under"
257        );
258        drop(registration);
259        Ok(())
260    }
261
262    /// Another run's attempt is never signalled — a cancel must not reach a
263    /// bystander sharing this server.
264    #[tokio::test]
265    async fn another_workflows_attempt_is_never_signalled() -> TestResult {
266        let attempts = DeclaredCommandAttempts::new();
267        let cancelled = WorkflowId::new_v4();
268        let bystander = WorkflowId::new_v4();
269        let target = key(&cancelled, 1, 1);
270        let spectator = key(&bystander, 1, 1);
271        let (_target_context, target_cancellation) = ActivityContext::new(
272            target.workflow_id.clone(),
273            target.run_id.clone(),
274            target.activity_id.clone(),
275            target.attempt,
276        );
277        let (spectator_context, spectator_cancellation) = ActivityContext::new(
278            spectator.workflow_id.clone(),
279            spectator.run_id.clone(),
280            spectator.activity_id.clone(),
281            spectator.attempt,
282        );
283        let target_registration = attempts.register(target.clone(), target_cancellation)?;
284        let spectator_registration = attempts.register(spectator, spectator_cancellation)?;
285
286        let signalled = attempts.cancel_workflow(&cancelled)?;
287
288        assert_eq!(signalled, vec![target]);
289        assert!(
290            !spectator_context.is_cancelled(),
291            "a cancel must not reach another run's declared body"
292        );
293        drop(target_registration);
294        drop(spectator_registration);
295        Ok(())
296    }
297
298    /// A finished attempt leaves nothing behind: the guard's drop is what makes
299    /// the entry's life exactly the command's life.
300    #[tokio::test]
301    async fn a_finished_attempt_is_no_longer_visible() -> TestResult {
302        let attempts = DeclaredCommandAttempts::new();
303        let workflow_id = WorkflowId::new_v4();
304        let target = key(&workflow_id, 1, 1);
305        let (_context, cancellation) = ActivityContext::new(
306            target.workflow_id.clone(),
307            target.run_id.clone(),
308            target.activity_id.clone(),
309            target.attempt,
310        );
311
312        let registration = attempts.register(target, cancellation)?;
313        assert_eq!(attempts.executing()?.len(), 1);
314        drop(registration);
315
316        assert!(
317            attempts.executing()?.is_empty(),
318            "a finished attempt must not stay signallable"
319        );
320        assert!(attempts.cancel_workflow(&workflow_id)?.is_empty());
321        Ok(())
322    }
323
324    /// One attempt cannot execute twice at once. The refusal exists because the
325    /// second registration would replace the first command's cancellation
326    /// handle, and a replaced handle is a running command nothing can stop.
327    #[tokio::test]
328    async fn one_attempt_cannot_register_twice() -> TestResult {
329        let attempts = DeclaredCommandAttempts::new();
330        let target = key(&WorkflowId::new_v4(), 1, 1);
331        let (_first_context, first) = ActivityContext::new(
332            target.workflow_id.clone(),
333            target.run_id.clone(),
334            target.activity_id.clone(),
335            target.attempt,
336        );
337        let (_second_context, second) = ActivityContext::new(
338            target.workflow_id.clone(),
339            target.run_id.clone(),
340            target.activity_id.clone(),
341            target.attempt,
342        );
343        let registration = attempts.register(target.clone(), first)?;
344
345        let Err(refusal) = attempts.register(target, second) else {
346            return Err("a second execution of one attempt must be refused".into());
347        };
348
349        assert!(
350            refusal.to_string().contains("already executing"),
351            "the refusal must name what it refused: {refusal}"
352        );
353        drop(registration);
354        Ok(())
355    }
356}