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;
42use crate::shutdown::DrainState;
43
44/// The lock resource name carried by a [`ServerError::LockPoisoned`] raised
45/// here, so an operator reading the error knows which state could not be read.
46const RESOURCE: &str = "declared command attempts";
47
48/// The declared-command attempts this server is executing right now.
49///
50/// An entry exists for exactly as long as one attempt's command is running:
51/// [`Self::register`] returns a guard that removes it, so an attempt that
52/// finished, failed, or panicked its way out cannot be signalled afterwards.
53/// Keyed on the full [`AttemptKey`] — including the run — because a
54/// continue-as-new chain reuses one workflow id across generations while
55/// activity ordinals and attempt numbers restart, so a shorter key genuinely
56/// COLLIDES: two generations' attempts would be one entry, and registering the
57/// second would replace the first's handle with nothing left to stop it. The
58/// run axis is what keeps them distinct here and what lets the cancel report
59/// name which generation it stopped. Cancelling is still workflow-scoped, as it
60/// is for workers ([`super::HeartbeatTracker::in_flight_for_workflow`]): a
61/// cancelled workflow's work stops in every generation of it.
62#[derive(Clone, Debug)]
63pub struct DeclaredCommandAttempts {
64    inner: Arc<Mutex<HashMap<AttemptKey, ActivityCancellationHandle>>>,
65    /// The drain latch this registry wakes when an attempt finishes. The
66    /// graceful drain waits for this census as well as the heartbeat tracker's
67    /// (a declared body is in-flight work no worker holds), and a waiter that
68    /// is never woken outlives every command by the full drain window.
69    drain: DrainState,
70}
71
72impl DeclaredCommandAttempts {
73    /// Build an empty registry that wakes `drain`'s activity-drained latch
74    /// whenever an executing attempt finishes.
75    ///
76    /// The latch is required rather than optional for the same reason the
77    /// dispatcher requires the registry itself: a construction path that could
78    /// silently skip the wiring would recreate the drained-over-live-work bug
79    /// the wiring exists to prevent, invisibly.
80    #[must_use]
81    pub fn new(drain: DrainState) -> Self {
82        Self {
83            inner: Arc::default(),
84            drain,
85        }
86    }
87
88    /// Record that this server is executing `key`'s declared command, and hand
89    /// back the guard that keeps the entry alive.
90    ///
91    /// The entry lives exactly as long as the returned
92    /// [`DeclaredAttemptRegistration`]. A second registration of the same key —
93    /// which would mean one attempt executing twice at once, and is a defect
94    /// wherever it came from — is refused rather than allowed to overwrite the
95    /// handle of a command still running, because an overwritten handle is an
96    /// attempt nothing can stop.
97    ///
98    /// Registration is also the drain's door: a draining server is refused
99    /// here, so a declared body — which no worker gate can park — cannot start
100    /// after `aion server stop` was requested. The draining check runs INSIDE
101    /// the registry lock, and the drain gate's census takes the same lock, so
102    /// any registration that passed the check is visible to any census taken
103    /// after the drain began; there is no window in which the gate reads empty
104    /// while a command that beat the latch is about to start.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`ServerError::LockPoisoned`] when the registry cannot be read,
109    /// [`ServerError::DrainingRefusedDeclaredAttempt`] when this server is
110    /// draining, and [`ServerError::DeclaredAttemptCollision`] when `key` is
111    /// already executing.
112    pub fn register(
113        &self,
114        key: AttemptKey,
115        cancellation: ActivityCancellationHandle,
116    ) -> Result<DeclaredAttemptRegistration, ServerError> {
117        let mut attempts = self
118            .inner
119            .lock()
120            .map_err(|_poisoned| ServerError::lock_poisoned(RESOURCE))?;
121        if self.drain.is_draining() {
122            // The refusal wins (a park is the safe answer during a drain), but
123            // a collision is a correctness defect — one attempt executing
124            // twice — and must not vanish into a routine park report.
125            if attempts.contains_key(&key) {
126                tracing::error!(
127                    workflow_id = %key.workflow_id,
128                    activity_id = %key.activity_id,
129                    attempt = key.attempt,
130                    "declared attempt collision detected while draining: this \
131                     attempt is ALREADY executing; the dispatch is parked by the \
132                     drain, but a second dispatch of a running attempt is a \
133                     double-dispatch defect regardless of the drain"
134                );
135            }
136            return Err(ServerError::DrainingRefusedDeclaredAttempt {
137                workflow_id: key.workflow_id.clone(),
138                activity_id: key.activity_id.clone(),
139                attempt: key.attempt,
140            });
141        }
142        if attempts.contains_key(&key) {
143            return Err(ServerError::DeclaredAttemptCollision {
144                workflow_id: key.workflow_id.clone(),
145                activity_id: key.activity_id.clone(),
146                attempt: key.attempt,
147            });
148        }
149        attempts.insert(key.clone(), cancellation);
150        drop(attempts);
151        Ok(DeclaredAttemptRegistration {
152            attempts: self.clone(),
153            key,
154        })
155    }
156
157    /// Signal every declared command this server is executing for `workflow_id`.
158    ///
159    /// Returns the attempts signalled, in activity-then-attempt order, so the
160    /// caller reports a stable list rather than whatever order the map yielded.
161    /// An empty result means this server is executing none of the run's bodies,
162    /// which is the common case and is not a failure.
163    ///
164    /// The entries are NOT removed here. Removing them is the executing
165    /// attempt's own act, on the guard it holds, once its process group is gone
166    /// — and a cancel that deregistered an attempt it had merely signalled would
167    /// make a second cancel a silent no-op against a command still dying.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`ServerError::LockPoisoned`] when the registry cannot be read.
172    /// Not survivable: answering "this server is executing nothing for that run"
173    /// out of state that could not be read is exactly how a cancelled run keeps
174    /// a machine.
175    pub fn cancel_workflow(
176        &self,
177        workflow_id: &WorkflowId,
178    ) -> Result<Vec<AttemptKey>, ServerError> {
179        let attempts = self
180            .inner
181            .lock()
182            .map_err(|_poisoned| ServerError::lock_poisoned(RESOURCE))?;
183        let mut signalled = Vec::new();
184        for (key, cancellation) in attempts.iter() {
185            if key.workflow_id == *workflow_id {
186                cancellation.cancel();
187                signalled.push(key.clone());
188            }
189        }
190        drop(attempts);
191        signalled.sort_by_key(|key| (key.activity_id.sequence_position(), key.attempt));
192        Ok(signalled)
193    }
194
195    /// Every declared command this server is executing right now, in
196    /// activity-then-attempt order.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`ServerError::LockPoisoned`] when the registry cannot be read.
201    pub fn executing(&self) -> Result<Vec<AttemptKey>, ServerError> {
202        let attempts = self
203            .inner
204            .lock()
205            .map_err(|_poisoned| ServerError::lock_poisoned(RESOURCE))?;
206        let mut keys = attempts.keys().cloned().collect::<Vec<_>>();
207        drop(attempts);
208        keys.sort_by_key(|key| (key.activity_id.sequence_position(), key.attempt));
209        Ok(keys)
210    }
211
212    /// Remove one entry, whatever state the lock is in.
213    ///
214    /// The poisoned lock is RECOVERED rather than reported, because this runs
215    /// from a drop guard that has no way to return a failure and because the
216    /// alternative is worse: a retained entry outlives the command it names, and
217    /// a later cancel would then signal a handle whose activity has already
218    /// ended. The poisoning itself is stated once, here, at ERROR.
219    fn release(&self, key: &AttemptKey) {
220        let mut attempts = match self.inner.lock() {
221            Ok(attempts) => attempts,
222            Err(poisoned) => {
223                tracing::error!(
224                    resource = RESOURCE,
225                    "the declared-command attempt registry's lock is poisoned; recovering it \
226                     to release the finished attempt rather than leaving a stale entry a \
227                     later cancel could signal"
228                );
229                PoisonError::into_inner(poisoned)
230            }
231        };
232        attempts.remove(key);
233        drop(attempts);
234        // Wake the graceful drain's census loop: this may have been the last
235        // in-flight declared body it was waiting out. Waking with no waiter is
236        // a no-op, so the ordinary (non-draining) case costs nothing.
237        self.drain.notify_activity_drained();
238    }
239}
240
241/// Keeps one executing declared command visible to the cancel path.
242///
243/// Dropping it deregisters the attempt, so the entry cannot outlive the command
244/// it names — including when the executing thread unwinds.
245#[derive(Debug)]
246pub struct DeclaredAttemptRegistration {
247    attempts: DeclaredCommandAttempts,
248    key: AttemptKey,
249}
250
251impl DeclaredAttemptRegistration {
252    /// The attempt this registration keeps visible.
253    #[must_use]
254    pub const fn key(&self) -> &AttemptKey {
255        &self.key
256    }
257}
258
259impl Drop for DeclaredAttemptRegistration {
260    fn drop(&mut self) {
261        self.attempts.release(&self.key);
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use aion_core::{ActivityId, RunId, WorkflowId};
268    use aion_worker::ActivityContext;
269
270    use super::{AttemptKey, DeclaredCommandAttempts, DrainState};
271
272    /// What a test returns. Every fallible step is carried rather than
273    /// unwrapped, because the workspace denies panicking accessors in test code
274    /// as firmly as in library code.
275    type TestResult = Result<(), Box<dyn std::error::Error>>;
276
277    fn key(workflow_id: &WorkflowId, position: u64, attempt: u32) -> AttemptKey {
278        AttemptKey::new(
279            workflow_id.clone(),
280            RunId::new_v4(),
281            ActivityId::from_sequence_position(position),
282            attempt,
283        )
284    }
285
286    /// One executing attempt is signalled, and the signal reaches the context
287    /// the executor is running under — not a copy of it.
288    #[tokio::test]
289    async fn a_registered_attempt_is_signalled_on_its_own_context() -> TestResult {
290        let attempts = DeclaredCommandAttempts::new(DrainState::default());
291        let workflow_id = WorkflowId::new_v4();
292        let target = key(&workflow_id, 3, 1);
293        let (context, cancellation) = ActivityContext::new(
294            target.workflow_id.clone(),
295            target.run_id.clone(),
296            target.activity_id.clone(),
297            target.attempt,
298        );
299        let registration = attempts.register(target.clone(), cancellation)?;
300
301        let signalled = attempts.cancel_workflow(&workflow_id)?;
302
303        assert_eq!(signalled, vec![target]);
304        assert!(
305            context.is_cancelled(),
306            "the registry must signal the context the command is running under"
307        );
308        drop(registration);
309        Ok(())
310    }
311
312    /// Another run's attempt is never signalled — a cancel must not reach a
313    /// bystander sharing this server.
314    #[tokio::test]
315    async fn another_workflows_attempt_is_never_signalled() -> TestResult {
316        let attempts = DeclaredCommandAttempts::new(DrainState::default());
317        let cancelled = WorkflowId::new_v4();
318        let bystander = WorkflowId::new_v4();
319        let target = key(&cancelled, 1, 1);
320        let spectator = key(&bystander, 1, 1);
321        let (_target_context, target_cancellation) = ActivityContext::new(
322            target.workflow_id.clone(),
323            target.run_id.clone(),
324            target.activity_id.clone(),
325            target.attempt,
326        );
327        let (spectator_context, spectator_cancellation) = ActivityContext::new(
328            spectator.workflow_id.clone(),
329            spectator.run_id.clone(),
330            spectator.activity_id.clone(),
331            spectator.attempt,
332        );
333        let target_registration = attempts.register(target.clone(), target_cancellation)?;
334        let spectator_registration = attempts.register(spectator, spectator_cancellation)?;
335
336        let signalled = attempts.cancel_workflow(&cancelled)?;
337
338        assert_eq!(signalled, vec![target]);
339        assert!(
340            !spectator_context.is_cancelled(),
341            "a cancel must not reach another run's declared body"
342        );
343        drop(target_registration);
344        drop(spectator_registration);
345        Ok(())
346    }
347
348    /// A finished attempt leaves nothing behind: the guard's drop is what makes
349    /// the entry's life exactly the command's life.
350    #[tokio::test]
351    async fn a_finished_attempt_is_no_longer_visible() -> TestResult {
352        let attempts = DeclaredCommandAttempts::new(DrainState::default());
353        let workflow_id = WorkflowId::new_v4();
354        let target = key(&workflow_id, 1, 1);
355        let (_context, cancellation) = ActivityContext::new(
356            target.workflow_id.clone(),
357            target.run_id.clone(),
358            target.activity_id.clone(),
359            target.attempt,
360        );
361
362        let registration = attempts.register(target, cancellation)?;
363        assert_eq!(attempts.executing()?.len(), 1);
364        drop(registration);
365
366        assert!(
367            attempts.executing()?.is_empty(),
368            "a finished attempt must not stay signallable"
369        );
370        assert!(attempts.cancel_workflow(&workflow_id)?.is_empty());
371        Ok(())
372    }
373
374    /// One attempt cannot execute twice at once. The refusal exists because the
375    /// second registration would replace the first command's cancellation
376    /// handle, and a replaced handle is a running command nothing can stop.
377    #[tokio::test]
378    async fn one_attempt_cannot_register_twice() -> TestResult {
379        let attempts = DeclaredCommandAttempts::new(DrainState::default());
380        let target = key(&WorkflowId::new_v4(), 1, 1);
381        let (_first_context, first) = ActivityContext::new(
382            target.workflow_id.clone(),
383            target.run_id.clone(),
384            target.activity_id.clone(),
385            target.attempt,
386        );
387        let (_second_context, second) = ActivityContext::new(
388            target.workflow_id.clone(),
389            target.run_id.clone(),
390            target.activity_id.clone(),
391            target.attempt,
392        );
393        let registration = attempts.register(target.clone(), first)?;
394
395        let Err(refusal) = attempts.register(target, second) else {
396            return Err("a second execution of one attempt must be refused".into());
397        };
398
399        assert!(
400            refusal.to_string().contains("already executing"),
401            "the refusal must name what it refused: {refusal}"
402        );
403        drop(registration);
404        Ok(())
405    }
406
407    /// Registration is the drain's door: once the drain begins, a declared
408    /// command cannot join the cancel path — and therefore cannot start — and
409    /// the registry stays empty so the drain gate's census cannot be held open
410    /// by work admitted after the stop was requested.
411    #[tokio::test]
412    async fn a_draining_server_refuses_new_declared_registrations() -> TestResult {
413        let drain = DrainState::default();
414        let attempts = DeclaredCommandAttempts::new(drain.clone());
415        let target = key(&WorkflowId::new_v4(), 1, 1);
416        let (_context, cancellation) = ActivityContext::new(
417            target.workflow_id.clone(),
418            target.run_id.clone(),
419            target.activity_id.clone(),
420            target.attempt,
421        );
422        assert!(drain.begin(), "the first begin() must flip the latch");
423
424        let Err(refusal) = attempts.register(target, cancellation) else {
425            return Err("a draining server must refuse a new declared registration".into());
426        };
427
428        assert!(
429            matches!(
430                refusal,
431                crate::error::ServerError::DrainingRefusedDeclaredAttempt { .. }
432            ),
433            "the refusal must be the draining variant so the dispatcher can park it: {refusal}"
434        );
435        assert!(
436            attempts.executing()?.is_empty(),
437            "a refused registration must leave no census entry behind"
438        );
439        Ok(())
440    }
441
442    /// The drain must not mask a collision: when the refused key is ALREADY
443    /// executing, the double-dispatch defect is logged at ERROR before the
444    /// park-safe refusal — a correctness defect must not vanish into a
445    /// routine park report. This pins the LOG, not just the refusal: the
446    /// branch that keeps the defect visible must itself be visible to the
447    /// suite.
448    #[tokio::test]
449    async fn a_drain_masked_collision_is_logged_before_the_refusal() -> TestResult {
450        let drain = DrainState::default();
451        let attempts = DeclaredCommandAttempts::new(drain.clone());
452        let target = key(&WorkflowId::new_v4(), 1, 1);
453        let (_context, first_cancellation) = ActivityContext::new(
454            target.workflow_id.clone(),
455            target.run_id.clone(),
456            target.activity_id.clone(),
457            target.attempt,
458        );
459        // The attempt is executing BEFORE the drain begins.
460        let registration = attempts.register(target.clone(), first_cancellation)?;
461        assert!(drain.begin(), "the first begin() must flip the latch");
462
463        let (_second_context, second_cancellation) = ActivityContext::new(
464            target.workflow_id.clone(),
465            target.run_id.clone(),
466            target.activity_id.clone(),
467            target.attempt,
468        );
469        let (captured, refusal) = crate::test_support::CapturedLogs::capture(|| {
470            attempts.register(target.clone(), second_cancellation)
471        });
472        let Err(refusal) = refusal else {
473            return Err("a draining server must refuse the colliding registration".into());
474        };
475        assert!(
476            matches!(
477                refusal,
478                crate::error::ServerError::DrainingRefusedDeclaredAttempt { .. }
479            ),
480            "the park-safe refusal must still win: {refusal}"
481        );
482        let logged = captured.text()?;
483        assert!(
484            logged.contains("declared attempt collision detected while draining"),
485            "the collision must be logged, never masked by the drain: {logged}"
486        );
487        assert!(
488            logged.contains(&target.workflow_id.to_string()),
489            "the log must name the colliding workflow: {logged}"
490        );
491        drop(registration);
492        Ok(())
493    }
494}