1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum CancelDelivery {
48 Requested,
51 WorkerGone,
55 TransportCannotCarry,
60 ChannelUnavailable,
64}
65
66impl CancelDelivery {
67 #[must_use]
72 pub const fn was_requested(self) -> bool {
73 matches!(self, Self::Requested)
74 }
75}
76
77#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct CancelRequest {
80 pub worker_id: WorkerId,
82 pub workflow_id: WorkflowId,
84 pub activity_id: ActivityId,
86 pub attempt: u32,
88 pub delivery: CancelDelivery,
90}
91
92#[derive(Clone, Debug, Default, Eq, PartialEq)]
101pub struct InFlightCancellation {
102 pub worker_requests: Vec<CancelRequest>,
105 pub declared_attempts: Vec<AttemptKey>,
109}
110
111impl InFlightCancellation {
112 #[must_use]
115 pub fn stopped_nothing(&self) -> bool {
116 self.worker_requests.is_empty() && self.declared_attempts.is_empty()
117 }
118}
119
120pub 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 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
176pub(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
209fn 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 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 #[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 #[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 #[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 #[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 #[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 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, ®istry, worker_id, &workflow_id, 1)?;
491
492 let declared = DeclaredCommandAttempts::new(crate::shutdown::DrainState::default());
493 let requests = cancel_in_flight_activities(&tracker, ®istry, &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 #[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}