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}