use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use acton_reactive::prelude::*;
use acton_test::prelude::*;
const PATIENCE: Duration = Duration::from_secs(5);
#[acton_actor]
struct Parent;
#[acton_actor]
struct Worker;
#[acton_message]
struct HireWorker;
#[acton_message]
struct Ping;
type Registrations = (
tokio::sync::mpsc::UnboundedSender<Result<SupervisedChild, SupervisionError>>,
tokio::sync::mpsc::UnboundedReceiver<Result<SupervisedChild, SupervisionError>>,
);
const fn brisk_limiter(max_restarts: u32) -> RestartLimiterConfig {
RestartLimiterConfig {
enabled: true,
max_restarts,
window_secs: 60,
initial_backoff_ms: 10,
max_backoff_ms: 50,
backoff_multiplier: 1.0,
}
}
fn counting_blueprint(
builds: &Arc<AtomicUsize>,
) -> impl Fn(&mut ManagedActor<Idle, Worker>) + Clone + Send + Sync + 'static {
let builds = Arc::clone(builds);
move |actor: &mut ManagedActor<Idle, Worker>| {
builds.fetch_add(1, Ordering::SeqCst);
actor.mutate_on::<Ping>(|_actor, _ctx| Reply::ready());
}
}
fn supervising_parent(
runtime: &mut ActorRuntime,
registered: tokio::sync::mpsc::UnboundedSender<Result<SupervisedChild, SupervisionError>>,
blueprint: impl Fn(&mut ManagedActor<Idle, Worker>) + Clone + Send + Sync + 'static,
limiter: Option<RestartLimiterConfig>,
) -> ManagedActor<Idle, Parent> {
let mut parent = runtime.new_actor::<Parent>();
parent.mutate_on::<HireWorker>(move |actor, _ctx| {
let mut config = ActorConfig::for_supervised_child("worker", actor.handle().clone(), None)
.expect("a name plus a live parent is a valid child configuration")
.with_restart_policy(RestartPolicy::Permanent);
if let Some(limiter) = limiter.clone() {
config = config.with_restart_limiter(limiter);
}
let _ = registered.send(actor.supervise_deferred(config, blueprint.clone()));
Reply::ready()
});
parent.mutate_on::<Ping>(|_actor, _ctx| Reply::ready());
parent
}
async fn hire(
parent: &ActorHandle,
registrations: &mut tokio::sync::mpsc::UnboundedReceiver<
Result<SupervisedChild, SupervisionError>,
>,
) -> SupervisedChild {
parent.send(HireWorker).await;
tokio::time::timeout(PATIENCE, registrations.recv())
.await
.expect("the supervisor must answer a hire")
.expect("the channel is open")
.expect("the first child of a name is accepted")
}
fn channel() -> Registrations {
tokio::sync::mpsc::unbounded_channel()
}
#[acton_test]
async fn a_permanent_child_that_dies_is_brought_back_by_the_framework() -> anyhow::Result<()> {
let mut runtime = ActonApp::launch_async().await;
let (registered, mut registrations) = channel();
let builds = Arc::new(AtomicUsize::new(0));
let parent = supervising_parent(
&mut runtime,
registered,
counting_blueprint(&builds),
Some(brisk_limiter(5)),
)
.start()
.await;
let mut child = hire(&parent, &mut registrations).await;
let first = tokio::time::timeout(PATIENCE, child.wait_running())
.await
.expect("the first start must land")?;
assert_eq!(builds.load(Ordering::SeqCst), 1);
first.stop().await?;
let second = tokio::time::timeout(PATIENCE, child.wait_generation(RestartGeneration::FIRST.next()))
.await
.expect("the framework must bring a Permanent child back")?;
assert_eq!(
builds.load(Ordering::SeqCst),
2,
"the blueprint ran again, so this is a new incarnation and not the old one"
);
assert_eq!(
second.id(),
first.id(),
"a restart keeps the child's identity"
);
assert_eq!(child.status().generation(), RestartGeneration::FIRST.next());
assert_eq!(child.status().state(), SupervisionState::Running);
tokio::time::timeout(PATIENCE, second.send(Ping))
.await
.expect("the new incarnation takes messages");
runtime.shutdown_all().await?;
Ok(())
}
#[acton_test]
async fn a_supervisor_keeps_taking_messages_while_a_child_is_backing_off() -> anyhow::Result<()> {
let mut runtime = ActonApp::launch_async().await;
let (registered, mut registrations) = channel();
let builds = Arc::new(AtomicUsize::new(0));
let parent = supervising_parent(
&mut runtime,
registered,
counting_blueprint(&builds),
Some(RestartLimiterConfig {
initial_backoff_ms: 1_500,
max_backoff_ms: 1_500,
..brisk_limiter(5)
}),
)
.start()
.await;
let mut child = hire(&parent, &mut registrations).await;
let first = tokio::time::timeout(PATIENCE, child.wait_running())
.await
.expect("the first start must land")?;
first.stop().await?;
tokio::time::timeout(
PATIENCE,
child.wait_for(|s| s.state() == SupervisionState::RestartPending),
)
.await
.expect("the child must reach its backoff")?;
tokio::time::timeout(Duration::from_millis(500), parent.send(Ping))
.await
.expect("a supervisor waiting out a backoff must still take messages");
assert_eq!(
child.status().state(),
SupervisionState::RestartPending,
"and it answered during the backoff rather than after the restart"
);
tokio::time::timeout(PATIENCE, child.wait_generation(RestartGeneration::FIRST.next()))
.await
.expect("the armed timer must still fire")?;
runtime.shutdown_all().await?;
Ok(())
}
#[acton_test]
async fn a_child_that_exhausts_its_allowance_is_escalated_rather_than_left_pending()
-> anyhow::Result<()> {
let mut runtime = ActonApp::launch_async().await;
let (registered, mut registrations) = channel();
let builds = Arc::new(AtomicUsize::new(0));
let parent = supervising_parent(
&mut runtime,
registered,
counting_blueprint(&builds),
Some(brisk_limiter(2)),
)
.start()
.await;
let mut child = hire(&parent, &mut registrations).await;
let mut generation = RestartGeneration::FIRST;
let mut handle = tokio::time::timeout(PATIENCE, child.wait_running())
.await
.expect("the first start must land")?;
for _ in 0..2 {
handle.stop().await?;
generation = generation.next();
handle = tokio::time::timeout(PATIENCE, child.wait_generation(generation))
.await
.expect("a restart within the allowance must happen")?;
}
handle.stop().await?;
let status = tokio::time::timeout(PATIENCE, child.wait_for(|s| s.state().is_terminal()))
.await
.expect("an exhausted child must reach a terminal state, not sit pending")?;
assert_eq!(status.state(), SupervisionState::Escalated);
assert!(
matches!(
status.failure(),
Some(SupervisionError::RestartLimit { .. })
),
"the caller learns why it gave up: {:?}",
status.failure()
);
assert_eq!(
builds.load(Ordering::SeqCst),
3,
"one first start plus the two restarts the allowance covered"
);
let error = tokio::time::timeout(PATIENCE, child.wait_running())
.await
.expect("a terminal state must end the wait")
.expect_err("the child is not coming back");
assert!(
matches!(error, SupervisionError::RestartLimit { .. }),
"unexpected error: {error}"
);
runtime.shutdown_all().await?;
Ok(())
}
#[acton_test]
async fn a_child_whose_policy_forbids_a_restart_is_recorded_down() -> anyhow::Result<()> {
let mut runtime = ActonApp::launch_async().await;
let (registered, mut registrations) = channel();
let builds = Arc::new(AtomicUsize::new(0));
let blueprint = counting_blueprint(&builds);
let mut parent = runtime.new_actor::<Parent>();
parent.mutate_on::<HireWorker>(move |actor, _ctx| {
let config = ActorConfig::for_supervised_child("worker", actor.handle().clone(), None)
.expect("a name plus a live parent is a valid child configuration")
.with_restart_policy(RestartPolicy::Temporary);
let _ = registered.send(actor.supervise_deferred(config, blueprint.clone()));
Reply::ready()
});
let parent = parent.start().await;
let mut child = hire(&parent, &mut registrations).await;
let handle = tokio::time::timeout(PATIENCE, child.wait_running())
.await
.expect("the first start must land")?;
handle.stop().await?;
let status = tokio::time::timeout(PATIENCE, child.wait_for(|s| s.state().is_terminal()))
.await
.expect("a child that will not be restarted must say so")?;
assert_eq!(status.state(), SupervisionState::Down);
assert_eq!(status.generation(), RestartGeneration::FIRST);
assert_eq!(
builds.load(Ordering::SeqCst),
1,
"nothing was rebuilt for a Temporary child"
);
runtime.shutdown_all().await?;
Ok(())
}
#[acton_test]
async fn a_user_handler_for_child_terminated_still_runs() -> anyhow::Result<()> {
let mut runtime = ActonApp::launch_async().await;
let (registered, mut registrations) = channel();
let builds = Arc::new(AtomicUsize::new(0));
let blueprint = counting_blueprint(&builds);
let noticed = Arc::new(AtomicUsize::new(0));
let mut parent = runtime.new_actor::<Parent>();
parent.mutate_on::<HireWorker>(move |actor, _ctx| {
let config = ActorConfig::for_supervised_child("worker", actor.handle().clone(), None)
.expect("a name plus a live parent is a valid child configuration")
.with_restart_policy(RestartPolicy::Permanent)
.with_restart_limiter(brisk_limiter(5));
let _ = registered.send(actor.supervise_deferred(config, blueprint.clone()));
Reply::ready()
});
let counter = Arc::clone(¬iced);
parent.mutate_on::<ChildTerminated>(move |_actor, _ctx| {
counter.fetch_add(1, Ordering::SeqCst);
Reply::ready()
});
let parent = parent.start().await;
let mut child = hire(&parent, &mut registrations).await;
let first = tokio::time::timeout(PATIENCE, child.wait_running())
.await
.expect("the first start must land")?;
first.stop().await?;
tokio::time::timeout(PATIENCE, child.wait_generation(RestartGeneration::FIRST.next()))
.await
.expect("the framework must still restart the child")?;
assert_eq!(
noticed.load(Ordering::SeqCst),
1,
"the engine's bookkeeping must not swallow a public message"
);
runtime.shutdown_all().await?;
Ok(())
}
#[acton_test]
async fn a_supervisor_stopping_mid_backoff_settles_the_child_it_will_not_restart()
-> anyhow::Result<()> {
let mut runtime = ActonApp::launch_async().await;
let (registered, mut registrations) = channel();
let builds = Arc::new(AtomicUsize::new(0));
let parent = supervising_parent(
&mut runtime,
registered,
counting_blueprint(&builds),
Some(RestartLimiterConfig {
initial_backoff_ms: 2_000,
max_backoff_ms: 2_000,
..brisk_limiter(5)
}),
)
.start()
.await;
let mut child = hire(&parent, &mut registrations).await;
let first = tokio::time::timeout(PATIENCE, child.wait_running())
.await
.expect("the first start must land")?;
first.stop().await?;
tokio::time::timeout(PATIENCE, child.wait_for(|s| {
s.state() == SupervisionState::RestartPending
}))
.await
.expect("the child must be waiting out its backoff")?;
parent.stop().await?;
let error = tokio::time::timeout(PATIENCE, child.wait_running())
.await
.expect("a supervisor stopping mid-backoff must end the wait")
.expect_err("the restart it was waiting for cannot happen now");
assert!(
matches!(error, SupervisionError::SupervisorStopped { .. }),
"unexpected error: {error}"
);
runtime.shutdown_all().await?;
Ok(())
}
#[acton_test]
async fn a_child_adopted_through_the_legacy_path_is_never_restarted() -> anyhow::Result<()> {
let mut runtime = ActonApp::launch_async().await;
let parent = runtime.new_actor::<Parent>().start().await;
let config = ActorConfig::for_supervised_child("legacy", parent.clone(), None)
.expect("a name plus a live parent is a valid child configuration")
.with_restart_policy(RestartPolicy::Permanent);
let child_id = config.id();
let builds = Arc::new(AtomicUsize::new(0));
let mut child = runtime.new_actor_with_config::<Worker>(config);
{
let builds = Arc::clone(&builds);
child.before_start(move |_actor| {
let builds = Arc::clone(&builds);
async move {
builds.fetch_add(1, Ordering::SeqCst);
}
});
}
child.mutate_on::<Ping>(|_actor, _ctx| Reply::ready());
let child_handle = parent.supervise(child).await?;
assert_eq!(builds.load(Ordering::SeqCst), 1, "the child came up once");
assert_eq!(child_handle.id(), child_id);
child_handle.stop().await?;
tokio::time::sleep(Duration::from_millis(400)).await;
assert_eq!(
builds.load(Ordering::SeqCst),
1,
"nothing rebuilt a child the supervisor has no blueprint for"
);
tokio::time::timeout(PATIENCE, parent.send(Ping))
.await
.expect("the supervisor is still taking messages");
runtime.shutdown_all().await?;
Ok(())
}