use super::lifecycle::LifecycleState::{self, *};
use super::lifecycle::{
InstanceLife, LifecyclePolicy, RestartDecision, RestartPolicy, can_transition,
};
use crate::workload_kind::{
Health, HealthSample, KindRegistry, SampleState, judge_process,
};
const ALL: [LifecycleState; 16] = [
Requested, Placed, Fetching, Verified, Loaded, Running, Unhealthy, Restarting, Stopping,
Stopped, Finished, Failed, Lost, Rescheduled, Revoked, Unloaded,
];
#[test]
fn the_adr_path_is_legal_end_to_end() {
let path = [Requested, Placed, Fetching, Verified, Loaded, Running, Stopping, Unloaded];
for w in path.windows(2) {
assert!(can_transition(w[0], w[1]), "{:?} -> {:?}", w[0], w[1]);
}
}
#[test]
fn health_and_restart_steps_are_legal_and_nonsense_is_not() {
for (a, b) in [
(Running, Unhealthy),
(Unhealthy, Restarting),
(Restarting, Running),
(Restarting, Unhealthy),
(Unhealthy, Running),
(Unhealthy, Failed),
(Running, Finished),
(Stopped, Running),
(Lost, Rescheduled),
(Lost, Running),
(Rescheduled, Unloaded),
] {
assert!(can_transition(a, b), "{a:?} -> {b:?}");
}
for (a, b) in [
(Running, Loaded),
(Running, Placed),
(Stopped, Restarting),
(Finished, Restarting),
(Rescheduled, Running),
(Rescheduled, Lost),
(Rescheduled, Revoked),
(Revoked, Running),
(Revoked, Lost),
(Unloaded, Running),
(Running, Running),
] {
assert!(!can_transition(a, b), "{a:?} -> {b:?} must be refused");
}
}
#[test]
fn a_revocation_or_a_lost_node_can_interrupt_anything_that_exists() {
for s in ALL {
let revocable = !matches!(s, Unloaded | Revoked | Rescheduled);
assert_eq!(can_transition(s, Revoked), revocable, "{s:?} -> Revoked");
let losable = !matches!(s, Unloaded | Rescheduled | Lost | Revoked);
assert_eq!(can_transition(s, Lost), losable, "{s:?} -> Lost");
}
}
#[test]
fn nothing_leaves_unloaded() {
for s in ALL {
assert!(!can_transition(Unloaded, s));
}
}
#[test]
fn an_illegal_step_is_refused_and_changes_nothing() {
let mut l = InstanceLife::new(Running, 5);
let e = l.transition(Loaded, 9).unwrap_err();
assert_eq!((e.from, e.to), (Running, Loaded));
assert_eq!((l.state, l.since_ms), (Running, 5));
assert_eq!(l.transition(Unhealthy, 9).unwrap(), Running);
assert_eq!((l.state, l.since_ms), (Unhealthy, 9));
}
#[test]
fn entering_running_clears_the_miss_count() {
let mut l = InstanceLife::new(Running, 0);
l.misses = 2;
l.transition(Unhealthy, 1).unwrap();
assert_eq!(l.misses, 2);
l.transition(Running, 2).unwrap();
assert_eq!(l.misses, 0);
}
fn policy() -> RestartPolicy {
RestartPolicy {
max_restarts: 3,
window_ms: 10_000,
backoff_base_ms: 100,
backoff_max_ms: 250,
}
}
#[test]
fn restarts_back_off_exponentially_up_to_the_cap() {
let p = policy();
let mut l = InstanceLife::new(Unhealthy, 0);
assert_eq!(l.restart_decision(&p, 0), RestartDecision::Restart { attempt: 1 });
l.record_restart(&p, 0);
assert_eq!(l.restart_decision(&p, 50), RestartDecision::Wait { until_ms: 100 });
assert_eq!(l.restart_decision(&p, 100), RestartDecision::Restart { attempt: 2 });
l.record_restart(&p, 100);
assert_eq!(l.restart_decision(&p, 150), RestartDecision::Wait { until_ms: 300 });
l.record_restart(&p, 300);
assert_eq!(l.restart_decision(&p, 310), RestartDecision::Exhausted);
let p4 = RestartPolicy { max_restarts: 4, ..p };
assert_eq!(l.restart_decision(&p4, 310), RestartDecision::Wait { until_ms: 550 });
}
#[test]
fn restarts_outside_the_window_are_forgotten() {
let p = policy();
let mut l = InstanceLife::new(Unhealthy, 0);
for t in [0, 1_000, 2_000] {
l.record_restart(&p, t);
}
assert_eq!(l.restart_decision(&p, 3_000), RestartDecision::Exhausted);
assert!(matches!(
l.restart_decision(&p, 10_500),
RestartDecision::Restart { attempt: 3 }
));
l.record_restart(&p, 10_500);
assert_eq!(l.restarts.len(), 3, "the first one aged out and the new one was added");
}
#[test]
fn zero_max_restarts_means_never_restart() {
let p = RestartPolicy {
max_restarts: 0,
..policy()
};
assert_eq!(
InstanceLife::new(Unhealthy, 0).restart_decision(&p, 0),
RestartDecision::Exhausted
);
}
fn sample(state: SampleState, exit_code: Option<i32>, continuous: bool) -> HealthSample {
HealthSample {
state,
exit_code,
continuous,
}
}
#[test]
fn process_health_is_judged_by_state_and_exit() {
use SampleState::*;
assert_eq!(judge_process(&sample(Running, None, true)), Health::Healthy);
assert!(matches!(judge_process(&sample(Degraded, None, true)), Health::Unhealthy(_)));
assert!(matches!(judge_process(&sample(Unknown, None, true)), Health::Unhealthy(_)));
assert!(matches!(judge_process(&sample(Exited, Some(0), true)), Health::Unhealthy(_)));
match judge_process(&sample(Exited, Some(3), true)) {
Health::Unhealthy(w) => assert!(w.contains("code 3"), "{w}"),
h => panic!("{h:?}"),
}
assert_eq!(judge_process(&sample(Exited, Some(0), false)), Health::Finished);
assert!(matches!(judge_process(&sample(Exited, Some(1), false)), Health::Unhealthy(_)));
}
#[test]
fn kinds_define_their_own_polling_and_movability() {
let kinds = KindRegistry::builtin();
let cog = kinds.get("cog").unwrap();
let project = kinds.get("project").unwrap();
assert!(cog.migratable());
assert!(!project.migratable(), "a project kernel never moves");
assert_eq!(cog.health().miss_limit, 3);
assert!(project.health().interval_ms < cog.health().interval_ms);
assert_eq!(project.health().miss_limit, 2);
}
#[test]
fn the_default_lifecycle_policy_allows_moving_a_bounded_number_of_times() {
let p = LifecyclePolicy::default();
assert!(p.migratable);
assert!(p.max_reschedules > 0);
}
fn spec_requiring(ids: &[&str]) -> clawft_types::placement::engine::WorkloadSpec {
use clawft_types::placement::engine::{PlacementPolicy, WorkloadRequirements, WorkloadSpec};
use clawft_types::placement::{CapabilityId, Requirement};
WorkloadSpec {
kind: "cog".into(),
name: "x".into(),
requirements: WorkloadRequirements {
common: ids
.iter()
.map(|i| Requirement::exact(CapabilityId::new(*i).unwrap()))
.collect(),
..WorkloadRequirements::default()
},
policy: PlacementPolicy::default(),
}
}
#[test]
fn a_spec_needing_an_attached_sensor_or_device_is_hardware_bound() {
use super::lifecycle::needs_attached_hardware as bound;
assert!(!bound(&spec_requiring(&["cpu.arch.aarch64", "os.linux", "runtime.native", "mem.system"])));
assert!(bound(&spec_requiring(&["os.linux", "sensor.csi.esp32"])));
assert!(bound(&spec_requiring(&["feed.esp32-csi-udp"])));
assert!(bound(&spec_requiring(&["device.gpio"])));
for id in ["accel.npu.hailo", "trust.tier.paired", "store.tier.internal", "model.present", "net.egress"] {
assert!(!bound(&spec_requiring(&[id])), "{id} must not pin");
}
let routes = super::cog_kind::arch_routes("aarch64").unwrap();
let mut s = spec_requiring(&[]);
s.requirements.variants = routes;
assert!(!bound(&s));
}