use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use aion_core::WorkflowId;
use aion_worker::ActivityCancellationHandle;
use super::intervention::AttemptKey;
use crate::error::ServerError;
use crate::shutdown::DrainState;
const RESOURCE: &str = "declared command attempts";
#[derive(Clone, Debug)]
pub struct DeclaredCommandAttempts {
inner: Arc<Mutex<HashMap<AttemptKey, ActivityCancellationHandle>>>,
drain: DrainState,
}
impl DeclaredCommandAttempts {
#[must_use]
pub fn new(drain: DrainState) -> Self {
Self {
inner: Arc::default(),
drain,
}
}
pub fn register(
&self,
key: AttemptKey,
cancellation: ActivityCancellationHandle,
) -> Result<DeclaredAttemptRegistration, ServerError> {
let mut attempts = self
.inner
.lock()
.map_err(|_poisoned| ServerError::lock_poisoned(RESOURCE))?;
if self.drain.is_draining() {
if attempts.contains_key(&key) {
tracing::error!(
workflow_id = %key.workflow_id,
activity_id = %key.activity_id,
attempt = key.attempt,
"declared attempt collision detected while draining: this \
attempt is ALREADY executing; the dispatch is parked by the \
drain, but a second dispatch of a running attempt is a \
double-dispatch defect regardless of the drain"
);
}
return Err(ServerError::DrainingRefusedDeclaredAttempt {
workflow_id: key.workflow_id.clone(),
activity_id: key.activity_id.clone(),
attempt: key.attempt,
});
}
if attempts.contains_key(&key) {
return Err(ServerError::DeclaredAttemptCollision {
workflow_id: key.workflow_id.clone(),
activity_id: key.activity_id.clone(),
attempt: key.attempt,
});
}
attempts.insert(key.clone(), cancellation);
drop(attempts);
Ok(DeclaredAttemptRegistration {
attempts: self.clone(),
key,
})
}
pub fn cancel_workflow(
&self,
workflow_id: &WorkflowId,
) -> Result<Vec<AttemptKey>, ServerError> {
let attempts = self
.inner
.lock()
.map_err(|_poisoned| ServerError::lock_poisoned(RESOURCE))?;
let mut signalled = Vec::new();
for (key, cancellation) in attempts.iter() {
if key.workflow_id == *workflow_id {
cancellation.cancel();
signalled.push(key.clone());
}
}
drop(attempts);
signalled.sort_by_key(|key| (key.activity_id.sequence_position(), key.attempt));
Ok(signalled)
}
pub fn executing(&self) -> Result<Vec<AttemptKey>, ServerError> {
let attempts = self
.inner
.lock()
.map_err(|_poisoned| ServerError::lock_poisoned(RESOURCE))?;
let mut keys = attempts.keys().cloned().collect::<Vec<_>>();
drop(attempts);
keys.sort_by_key(|key| (key.activity_id.sequence_position(), key.attempt));
Ok(keys)
}
fn release(&self, key: &AttemptKey) {
let mut attempts = match self.inner.lock() {
Ok(attempts) => attempts,
Err(poisoned) => {
tracing::error!(
resource = RESOURCE,
"the declared-command attempt registry's lock is poisoned; recovering it \
to release the finished attempt rather than leaving a stale entry a \
later cancel could signal"
);
PoisonError::into_inner(poisoned)
}
};
attempts.remove(key);
drop(attempts);
self.drain.notify_activity_drained();
}
}
#[derive(Debug)]
pub struct DeclaredAttemptRegistration {
attempts: DeclaredCommandAttempts,
key: AttemptKey,
}
impl DeclaredAttemptRegistration {
#[must_use]
pub const fn key(&self) -> &AttemptKey {
&self.key
}
}
impl Drop for DeclaredAttemptRegistration {
fn drop(&mut self) {
self.attempts.release(&self.key);
}
}
#[cfg(test)]
mod tests {
use aion_core::{ActivityId, RunId, WorkflowId};
use aion_worker::ActivityContext;
use super::{AttemptKey, DeclaredCommandAttempts, DrainState};
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn key(workflow_id: &WorkflowId, position: u64, attempt: u32) -> AttemptKey {
AttemptKey::new(
workflow_id.clone(),
RunId::new_v4(),
ActivityId::from_sequence_position(position),
attempt,
)
}
#[tokio::test]
async fn a_registered_attempt_is_signalled_on_its_own_context() -> TestResult {
let attempts = DeclaredCommandAttempts::new(DrainState::default());
let workflow_id = WorkflowId::new_v4();
let target = key(&workflow_id, 3, 1);
let (context, cancellation) = ActivityContext::new(
target.workflow_id.clone(),
target.run_id.clone(),
target.activity_id.clone(),
target.attempt,
);
let registration = attempts.register(target.clone(), cancellation)?;
let signalled = attempts.cancel_workflow(&workflow_id)?;
assert_eq!(signalled, vec![target]);
assert!(
context.is_cancelled(),
"the registry must signal the context the command is running under"
);
drop(registration);
Ok(())
}
#[tokio::test]
async fn another_workflows_attempt_is_never_signalled() -> TestResult {
let attempts = DeclaredCommandAttempts::new(DrainState::default());
let cancelled = WorkflowId::new_v4();
let bystander = WorkflowId::new_v4();
let target = key(&cancelled, 1, 1);
let spectator = key(&bystander, 1, 1);
let (_target_context, target_cancellation) = ActivityContext::new(
target.workflow_id.clone(),
target.run_id.clone(),
target.activity_id.clone(),
target.attempt,
);
let (spectator_context, spectator_cancellation) = ActivityContext::new(
spectator.workflow_id.clone(),
spectator.run_id.clone(),
spectator.activity_id.clone(),
spectator.attempt,
);
let target_registration = attempts.register(target.clone(), target_cancellation)?;
let spectator_registration = attempts.register(spectator, spectator_cancellation)?;
let signalled = attempts.cancel_workflow(&cancelled)?;
assert_eq!(signalled, vec![target]);
assert!(
!spectator_context.is_cancelled(),
"a cancel must not reach another run's declared body"
);
drop(target_registration);
drop(spectator_registration);
Ok(())
}
#[tokio::test]
async fn a_finished_attempt_is_no_longer_visible() -> TestResult {
let attempts = DeclaredCommandAttempts::new(DrainState::default());
let workflow_id = WorkflowId::new_v4();
let target = key(&workflow_id, 1, 1);
let (_context, cancellation) = ActivityContext::new(
target.workflow_id.clone(),
target.run_id.clone(),
target.activity_id.clone(),
target.attempt,
);
let registration = attempts.register(target, cancellation)?;
assert_eq!(attempts.executing()?.len(), 1);
drop(registration);
assert!(
attempts.executing()?.is_empty(),
"a finished attempt must not stay signallable"
);
assert!(attempts.cancel_workflow(&workflow_id)?.is_empty());
Ok(())
}
#[tokio::test]
async fn one_attempt_cannot_register_twice() -> TestResult {
let attempts = DeclaredCommandAttempts::new(DrainState::default());
let target = key(&WorkflowId::new_v4(), 1, 1);
let (_first_context, first) = ActivityContext::new(
target.workflow_id.clone(),
target.run_id.clone(),
target.activity_id.clone(),
target.attempt,
);
let (_second_context, second) = ActivityContext::new(
target.workflow_id.clone(),
target.run_id.clone(),
target.activity_id.clone(),
target.attempt,
);
let registration = attempts.register(target.clone(), first)?;
let Err(refusal) = attempts.register(target, second) else {
return Err("a second execution of one attempt must be refused".into());
};
assert!(
refusal.to_string().contains("already executing"),
"the refusal must name what it refused: {refusal}"
);
drop(registration);
Ok(())
}
#[tokio::test]
async fn a_draining_server_refuses_new_declared_registrations() -> TestResult {
let drain = DrainState::default();
let attempts = DeclaredCommandAttempts::new(drain.clone());
let target = key(&WorkflowId::new_v4(), 1, 1);
let (_context, cancellation) = ActivityContext::new(
target.workflow_id.clone(),
target.run_id.clone(),
target.activity_id.clone(),
target.attempt,
);
assert!(drain.begin(), "the first begin() must flip the latch");
let Err(refusal) = attempts.register(target, cancellation) else {
return Err("a draining server must refuse a new declared registration".into());
};
assert!(
matches!(
refusal,
crate::error::ServerError::DrainingRefusedDeclaredAttempt { .. }
),
"the refusal must be the draining variant so the dispatcher can park it: {refusal}"
);
assert!(
attempts.executing()?.is_empty(),
"a refused registration must leave no census entry behind"
);
Ok(())
}
#[tokio::test]
async fn a_drain_masked_collision_is_logged_before_the_refusal() -> TestResult {
let drain = DrainState::default();
let attempts = DeclaredCommandAttempts::new(drain.clone());
let target = key(&WorkflowId::new_v4(), 1, 1);
let (_context, first_cancellation) = ActivityContext::new(
target.workflow_id.clone(),
target.run_id.clone(),
target.activity_id.clone(),
target.attempt,
);
let registration = attempts.register(target.clone(), first_cancellation)?;
assert!(drain.begin(), "the first begin() must flip the latch");
let (_second_context, second_cancellation) = ActivityContext::new(
target.workflow_id.clone(),
target.run_id.clone(),
target.activity_id.clone(),
target.attempt,
);
let (captured, refusal) = crate::test_support::CapturedLogs::capture(|| {
attempts.register(target.clone(), second_cancellation)
});
let Err(refusal) = refusal else {
return Err("a draining server must refuse the colliding registration".into());
};
assert!(
matches!(
refusal,
crate::error::ServerError::DrainingRefusedDeclaredAttempt { .. }
),
"the park-safe refusal must still win: {refusal}"
);
let logged = captured.text()?;
assert!(
logged.contains("declared attempt collision detected while draining"),
"the collision must be logged, never masked by the drain: {logged}"
);
assert!(
logged.contains(&target.workflow_id.to_string()),
"the log must name the colliding workflow: {logged}"
);
drop(registration);
Ok(())
}
}