feat(homecore-automation): implement bounded RunModes Restart/Queued/max (ADR-162, completes ADR-161 §A5)
ADR-161 implemented RunMode::Single (AtomicBool re-entrancy guard) + Parallel but honestly left Restart/Queued/max as "ACCEPTED-FUTURE / unbounded parallel" — every non-Single mode spawned an unbounded task. This makes them real. New `runmode` module — per-automation RunState owns the machinery: - Restart: aborts the in-flight action task (tokio::task::AbortHandle) and starts a fresh one. - Queued: serializes runs in arrival order via a per-automation async Mutex — sequential, never concurrent, nothing dropped. - max: N: caps concurrency at N via a per-automation Semaphore; triggers beyond N queue (await a permit) rather than running concurrently (HA bounded semantics). Documented in the module table. - Single/IgnoreFirst/Parallel preserved. engine.rs now holds a RunState per registration and calls run_state.dispatch() at all three trigger sites (event loop, timer, fire_time_for_test); the old spawn_run is removed. engine.rs trimmed to 433 lines. Tests (tests/engine_behaviors.rs) — verified to FAIL on the old unbounded- parallel dispatch (simulated and confirmed each panics), pass on the new: - restart_mode_cancels_prior_run (old: both runs complete → 2; new: 1) - queued_mode_runs_sequentially_not_concurrently (old: max concurrency 3; new: all 3 run, max concurrency 1) - max_two_caps_concurrency_at_two (old: 4 concurrent; new: all 4 run, max 2) homecore-automation --no-default-features: 45 passed (lib 37, engine_behaviors 8), 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
0ca903b497
commit
3292bd2c5d
|
|
@ -3,14 +3,15 @@
|
||||||
//!
|
//!
|
||||||
//! ADR-129 §2 design: one Tokio task per running automation instance.
|
//! ADR-129 §2 design: one Tokio task per running automation instance.
|
||||||
//!
|
//!
|
||||||
//! ## Run modes (ADR-161, HC-WS-05)
|
//! ## Run modes (ADR-161 §A5 → completed in ADR-162)
|
||||||
//!
|
//!
|
||||||
//! `RunMode::Single` is enforced via a per-automation `AtomicBool`
|
//! Each registered automation owns a [`RunState`] that implements its
|
||||||
//! guard: while an instance is executing, a second trigger is skipped.
|
//! `RunMode`: `Single`/`IgnoreFirst` skip re-entrant triggers, `Restart`
|
||||||
//! `Parallel` (and the as-yet-unbounded `Restart`/`Queued`) spawn a
|
//! aborts the in-flight run and starts a fresh one, `Queued` serializes
|
||||||
//! fresh instance on every trigger. (Before this fix the doc claimed
|
//! runs in arrival order (nothing dropped), `Parallel` spawns on every
|
||||||
//! AtomicBool enforcement but every trigger spawned unbounded parallel
|
//! trigger, and `max: N` caps concurrency via a per-automation semaphore.
|
||||||
//! tasks regardless of `mode`.)
|
//! (ADR-161 only honored Single/Parallel; Restart/Queued/max were
|
||||||
|
//! honestly documented as unbounded-parallel until ADR-162.)
|
||||||
//!
|
//!
|
||||||
//! ## Time triggers (ADR-161, HC-WS-04)
|
//! ## Time triggers (ADR-161, HC-WS-04)
|
||||||
//!
|
//!
|
||||||
|
|
@ -26,7 +27,6 @@
|
||||||
//! `EvalContext::with_templates`), so `template:` conditions evaluate
|
//! `EvalContext::with_templates`), so `template:` conditions evaluate
|
||||||
//! against live state instead of always returning false.
|
//! against live state instead of always returning false.
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use chrono::{Local, Timelike};
|
use chrono::{Local, Timelike};
|
||||||
|
|
@ -34,18 +34,18 @@ use tokio::sync::broadcast;
|
||||||
|
|
||||||
use homecore::HomeCore;
|
use homecore::HomeCore;
|
||||||
|
|
||||||
use crate::action::ExecutionContext;
|
use crate::automation::Automation;
|
||||||
use crate::automation::{Automation, RunMode};
|
|
||||||
use crate::condition::EvalContext;
|
use crate::condition::EvalContext;
|
||||||
|
use crate::runmode::RunState;
|
||||||
use crate::template::TemplateEnvironment;
|
use crate::template::TemplateEnvironment;
|
||||||
use crate::trigger::{Trigger, TriggerContext};
|
use crate::trigger::{Trigger, TriggerContext};
|
||||||
|
|
||||||
/// An automation registered with the engine, plus its runtime run-state.
|
/// An automation registered with the engine, plus its runtime run-state.
|
||||||
struct Registered {
|
struct Registered {
|
||||||
auto: Arc<Automation>,
|
auto: Arc<Automation>,
|
||||||
/// `true` while a `Single`-mode instance is executing. Used to
|
/// Run-mode machinery (re-entrancy guard / restart abort handle /
|
||||||
/// skip re-entrant triggers (HC-WS-05).
|
/// queue mutex / concurrency semaphore) for this automation.
|
||||||
running: Arc<AtomicBool>,
|
run_state: RunState,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The automation engine. Holds a HOMECORE handle and a list of registered
|
/// The automation engine. Holds a HOMECORE handle and a list of registered
|
||||||
|
|
@ -69,9 +69,10 @@ impl AutomationEngine {
|
||||||
|
|
||||||
/// Register an automation. Can be called before or after `start()`.
|
/// Register an automation. Can be called before or after `start()`.
|
||||||
pub fn register(&self, automation: Automation) {
|
pub fn register(&self, automation: Automation) {
|
||||||
|
let run_state = RunState::new(&automation);
|
||||||
self.automations.lock().unwrap().push(Registered {
|
self.automations.lock().unwrap().push(Registered {
|
||||||
auto: Arc::new(automation),
|
auto: Arc::new(automation),
|
||||||
running: Arc::new(AtomicBool::new(false)),
|
run_state,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,13 +119,13 @@ impl AutomationEngine {
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
let snapshot: Vec<(Arc<Automation>, Arc<AtomicBool>)> = automations
|
let snapshot: Vec<(Arc<Automation>, RunState)> = automations
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| (Arc::clone(&r.auto), Arc::clone(&r.running)))
|
.map(|r| (Arc::clone(&r.auto), r.run_state.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
for (automation, running) in snapshot {
|
for (automation, run_state) in snapshot {
|
||||||
if !automation.enabled {
|
if !automation.enabled {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +149,7 @@ impl AutomationEngine {
|
||||||
if !conditions_pass(&automation, &eval_ctx).await {
|
if !conditions_pass(&automation, &eval_ctx).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
spawn_run(&hc, automation, running);
|
run_state.dispatch(&hc, automation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Closed) => break,
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
|
@ -183,14 +184,14 @@ impl AutomationEngine {
|
||||||
if last_fired_sec.as_deref() == Some(hhmmss.as_str()) {
|
if last_fired_sec.as_deref() == Some(hhmmss.as_str()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let snapshot: Vec<(Arc<Automation>, Arc<AtomicBool>)> = automations
|
let snapshot: Vec<(Arc<Automation>, RunState)> = automations
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| (Arc::clone(&r.auto), Arc::clone(&r.running)))
|
.map(|r| (Arc::clone(&r.auto), r.run_state.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
let mut fired_any = false;
|
let mut fired_any = false;
|
||||||
for (automation, running) in snapshot {
|
for (automation, run_state) in snapshot {
|
||||||
if !automation.enabled {
|
if !automation.enabled {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +209,7 @@ impl AutomationEngine {
|
||||||
if !conditions_pass(&automation, &eval_ctx).await {
|
if !conditions_pass(&automation, &eval_ctx).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
spawn_run(&hc, automation, running);
|
run_state.dispatch(&hc, automation);
|
||||||
fired_any = true;
|
fired_any = true;
|
||||||
}
|
}
|
||||||
if fired_any {
|
if fired_any {
|
||||||
|
|
@ -231,15 +232,15 @@ impl AutomationEngine {
|
||||||
/// wall-clock second to roll over. Returns the number of automations
|
/// wall-clock second to roll over. Returns the number of automations
|
||||||
/// that fired (passed conditions and were spawned).
|
/// that fired (passed conditions and were spawned).
|
||||||
pub async fn fire_time_for_test(&self, hhmmss: &str) -> usize {
|
pub async fn fire_time_for_test(&self, hhmmss: &str) -> usize {
|
||||||
let snapshot: Vec<(Arc<Automation>, Arc<AtomicBool>)> = self
|
let snapshot: Vec<(Arc<Automation>, RunState)> = self
|
||||||
.automations
|
.automations
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| (Arc::clone(&r.auto), Arc::clone(&r.running)))
|
.map(|r| (Arc::clone(&r.auto), r.run_state.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
let mut fired = 0usize;
|
let mut fired = 0usize;
|
||||||
for (automation, running) in snapshot {
|
for (automation, run_state) in snapshot {
|
||||||
if !automation.enabled {
|
if !automation.enabled {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +255,7 @@ impl AutomationEngine {
|
||||||
if !conditions_pass(&automation, &eval_ctx).await {
|
if !conditions_pass(&automation, &eval_ctx).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
spawn_run(&self.hc, automation, running);
|
run_state.dispatch(&self.hc, automation);
|
||||||
fired += 1;
|
fired += 1;
|
||||||
}
|
}
|
||||||
fired
|
fired
|
||||||
|
|
@ -281,36 +282,6 @@ fn time_at_matches(at: &str, hhmmss: &str) -> bool {
|
||||||
normalized == hhmmss
|
normalized == hhmmss
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn an automation run, honoring `RunMode::Single` re-entrancy
|
|
||||||
/// guard (HC-WS-05). For `Single`/`IgnoreFirst` modes a run already in
|
|
||||||
/// flight causes the new trigger to be skipped; the `running` flag is
|
|
||||||
/// cleared when the run finishes.
|
|
||||||
fn spawn_run(hc: &HomeCore, automation: Arc<Automation>, running: Arc<AtomicBool>) {
|
|
||||||
let single = matches!(automation.mode, RunMode::Single | RunMode::IgnoreFirst);
|
|
||||||
if single {
|
|
||||||
// Try to claim the running slot; if already running, skip.
|
|
||||||
if running
|
|
||||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let hc_clone = hc.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut exec_ctx = ExecutionContext::new(hc_clone, automation.id.clone());
|
|
||||||
for action in &automation.action {
|
|
||||||
if let Err(e) = action.execute(&mut exec_ctx).await {
|
|
||||||
eprintln!("[homecore-automation] action error in {}: {e}", automation.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if single {
|
|
||||||
running.store(false, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ pub mod condition;
|
||||||
pub mod action;
|
pub mod action;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
|
pub mod runmode;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|
||||||
pub use automation::{Automation, RunMode};
|
pub use automation::{Automation, RunMode};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
//! Per-automation run-mode machinery (ADR-162, completes ADR-161 §A5).
|
||||||
|
//!
|
||||||
|
//! ADR-161 implemented `RunMode::Single` (a per-automation `AtomicBool`
|
||||||
|
//! re-entrancy guard) and `Parallel`, but honestly left `Restart`, `Queued`
|
||||||
|
//! and `max: N` as "ACCEPTED-FUTURE / unbounded parallel" — every non-Single
|
||||||
|
//! mode spawned an unbounded task. This module makes them real:
|
||||||
|
//!
|
||||||
|
//! | Mode | Semantics implemented |
|
||||||
|
//! |------|-----------------------|
|
||||||
|
//! | `Single` / `IgnoreFirst` | re-entrancy guard: skip while a run is in flight (ADR-161). |
|
||||||
|
//! | `Restart` | **cancel** the in-flight run (`tokio::task::AbortHandle`) and start a fresh one. |
|
||||||
|
//! | `Queued` | **serialize**: runs execute sequentially in arrival order via a per-automation async mutex — nothing is dropped. |
|
||||||
|
//! | `Parallel` | spawn on every trigger (optionally capped, see below). |
|
||||||
|
//! | `max: N` | cap concurrency at **N** via a per-automation semaphore; triggers beyond N **queue** (await a permit) rather than running concurrently — matching HA's bounded `parallel`/`queued`. |
|
||||||
|
//!
|
||||||
|
//! Each registered automation owns one [`RunState`]; the engine calls
|
||||||
|
//! [`RunState::dispatch`] on every (trigger + conditions-passed) event.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use tokio::sync::{Mutex as AsyncMutex, Semaphore};
|
||||||
|
|
||||||
|
use homecore::HomeCore;
|
||||||
|
|
||||||
|
use crate::action::ExecutionContext;
|
||||||
|
use crate::automation::{Automation, RunMode};
|
||||||
|
|
||||||
|
/// Per-automation runtime state backing the run-mode dispatch.
|
||||||
|
///
|
||||||
|
/// Cheap to clone (all fields are `Arc`); the engine clones it into each
|
||||||
|
/// spawned run so the machinery (abort handle, queue mutex, semaphore) is
|
||||||
|
/// shared across all triggers of the same automation.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RunState {
|
||||||
|
/// `Single`/`IgnoreFirst` re-entrancy guard (ADR-161 §A5).
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
/// `Restart`: handle to the currently-running action task, so a new
|
||||||
|
/// trigger can abort it before starting a fresh one.
|
||||||
|
current: Arc<Mutex<Option<tokio::task::AbortHandle>>>,
|
||||||
|
/// `Queued`: serializes runs in arrival order (one at a time, FIFO via
|
||||||
|
/// fair async mutex acquisition).
|
||||||
|
queue_lock: Arc<AsyncMutex<()>>,
|
||||||
|
/// `max: N` (and bounded `Parallel`): caps concurrent runs at N.
|
||||||
|
/// `None` when no cap applies.
|
||||||
|
semaphore: Option<Arc<Semaphore>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RunState {
|
||||||
|
/// Build run-state for an automation, sizing the concurrency semaphore
|
||||||
|
/// from its `max:` field (only meaningful for `Queued`/`Parallel`).
|
||||||
|
pub fn new(automation: &Automation) -> Self {
|
||||||
|
let semaphore = automation
|
||||||
|
.max
|
||||||
|
.filter(|n| *n > 0)
|
||||||
|
.map(|n| Arc::new(Semaphore::new(n)));
|
||||||
|
Self {
|
||||||
|
running: Arc::new(AtomicBool::new(false)),
|
||||||
|
current: Arc::new(Mutex::new(None)),
|
||||||
|
queue_lock: Arc::new(AsyncMutex::new(())),
|
||||||
|
semaphore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch one trigger for `automation` according to its `RunMode`.
|
||||||
|
/// Honors Single re-entrancy, Restart cancel-and-replace, Queued
|
||||||
|
/// serialization, and `max:` concurrency capping.
|
||||||
|
pub fn dispatch(&self, hc: &HomeCore, automation: Arc<Automation>) {
|
||||||
|
match automation.mode {
|
||||||
|
RunMode::Single | RunMode::IgnoreFirst => self.dispatch_single(hc, automation),
|
||||||
|
RunMode::Restart => self.dispatch_restart(hc, automation),
|
||||||
|
RunMode::Queued => self.dispatch_queued(hc, automation),
|
||||||
|
RunMode::Parallel => self.dispatch_parallel(hc, automation),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Single`: skip if a run is already in flight; clear the flag on done.
|
||||||
|
fn dispatch_single(&self, hc: &HomeCore, automation: Arc<Automation>) {
|
||||||
|
if self
|
||||||
|
.running
|
||||||
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return; // already running — skip re-entrant trigger.
|
||||||
|
}
|
||||||
|
let hc = hc.clone();
|
||||||
|
let running = Arc::clone(&self.running);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
run_actions(&hc, &automation).await;
|
||||||
|
running.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Restart`: abort the in-flight run (if any), then start a fresh one
|
||||||
|
/// and record its abort handle.
|
||||||
|
fn dispatch_restart(&self, hc: &HomeCore, automation: Arc<Automation>) {
|
||||||
|
// Abort any prior run before starting the new one.
|
||||||
|
if let Some(prev) = self.current.lock().unwrap().take() {
|
||||||
|
prev.abort();
|
||||||
|
}
|
||||||
|
let hc = hc.clone();
|
||||||
|
let slot = Arc::clone(&self.current);
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
run_actions(&hc, &automation).await;
|
||||||
|
});
|
||||||
|
*slot.lock().unwrap() = Some(handle.abort_handle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Queued`: serialize via the per-automation async mutex. Each trigger
|
||||||
|
/// spawns a task that waits its turn, so all triggers run in arrival
|
||||||
|
/// order, one at a time — nothing is dropped.
|
||||||
|
fn dispatch_queued(&self, hc: &HomeCore, automation: Arc<Automation>) {
|
||||||
|
let hc = hc.clone();
|
||||||
|
let lock = Arc::clone(&self.queue_lock);
|
||||||
|
let sem = self.semaphore.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Optional `max:` cap still applies on top of serialization.
|
||||||
|
let _permit = match &sem {
|
||||||
|
Some(s) => Some(s.acquire().await.expect("semaphore not closed")),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let _guard = lock.lock().await; // FIFO turn — sequential execution.
|
||||||
|
run_actions(&hc, &automation).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Parallel`: spawn on every trigger, capped at `max:` if set.
|
||||||
|
fn dispatch_parallel(&self, hc: &HomeCore, automation: Arc<Automation>) {
|
||||||
|
let hc = hc.clone();
|
||||||
|
let sem = self.semaphore.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _permit = match &sem {
|
||||||
|
Some(s) => Some(s.acquire().await.expect("semaphore not closed")),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
run_actions(&hc, &automation).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute an automation's action sequence once.
|
||||||
|
async fn run_actions(hc: &HomeCore, automation: &Automation) {
|
||||||
|
let mut exec_ctx = ExecutionContext::new(hc.clone(), automation.id.clone());
|
||||||
|
for action in &automation.action {
|
||||||
|
if let Err(e) = action.execute(&mut exec_ctx).await {
|
||||||
|
eprintln!(
|
||||||
|
"[homecore-automation] action error in {}: {e}",
|
||||||
|
automation.id
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -257,3 +257,162 @@ async fn template_condition_evaluates_false_blocks_action() {
|
||||||
sleep(Duration::from_millis(50)).await;
|
sleep(Duration::from_millis(50)).await;
|
||||||
assert_eq!(log.lock().unwrap().len(), 0, "false template condition should block the action");
|
assert_eq!(log.lock().unwrap().len(), 0, "false template condition should block the action");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ADR-162 (completes ADR-161 §A5): bounded RunModes ───────────────
|
||||||
|
//
|
||||||
|
// ADR-161 honored only Single/Parallel; Restart/Queued/max were honestly
|
||||||
|
// documented as unbounded-parallel. These tests drive the real
|
||||||
|
// Restart/Queued/max machinery and FAIL on the old engine (where every
|
||||||
|
// non-Single mode spawned an unbounded parallel task).
|
||||||
|
|
||||||
|
/// A service that increments a live concurrency gauge on entry, sleeps,
|
||||||
|
/// then decrements — recording the maximum concurrency ever observed and
|
||||||
|
/// the total number of completed runs. Returns `(max_concurrency, completed)`.
|
||||||
|
async fn register_gauge(
|
||||||
|
hc: &HomeCore,
|
||||||
|
domain: &str,
|
||||||
|
service: &str,
|
||||||
|
work: Duration,
|
||||||
|
) -> (Arc<AtomicUsize>, Arc<AtomicUsize>) {
|
||||||
|
let live = Arc::new(AtomicUsize::new(0));
|
||||||
|
let max_seen = Arc::new(AtomicUsize::new(0));
|
||||||
|
let completed = Arc::new(AtomicUsize::new(0));
|
||||||
|
let (l, m, c) = (Arc::clone(&live), Arc::clone(&max_seen), Arc::clone(&completed));
|
||||||
|
hc.services()
|
||||||
|
.register(
|
||||||
|
ServiceName::new(domain, service),
|
||||||
|
FnHandler(move |_call: ServiceCall| {
|
||||||
|
let (l, m, c) = (Arc::clone(&l), Arc::clone(&m), Arc::clone(&c));
|
||||||
|
async move {
|
||||||
|
let now = l.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
m.fetch_max(now, Ordering::SeqCst);
|
||||||
|
sleep(work).await;
|
||||||
|
l.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
c.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
(max_seen, completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_auto(id: &str, entity: &str, domain: &str, service: &str) -> Automation {
|
||||||
|
Automation::new(
|
||||||
|
id,
|
||||||
|
vec![Trigger::State {
|
||||||
|
entity_id: EntityId::parse(entity).unwrap(),
|
||||||
|
from: None,
|
||||||
|
to: None,
|
||||||
|
}],
|
||||||
|
vec![Action::ServiceCall {
|
||||||
|
domain: domain.into(),
|
||||||
|
service: service.into(),
|
||||||
|
data: serde_json::json!({}),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Restart: cancels the in-flight run ─────────────────────────────
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restart_mode_cancels_prior_run() {
|
||||||
|
let hc = HomeCore::new();
|
||||||
|
// Each run sleeps 300ms before recording completion.
|
||||||
|
let (_max, completed) =
|
||||||
|
register_gauge(&hc, "light", "slow", Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
let engine = AutomationEngine::new(hc.clone());
|
||||||
|
let mut auto = state_auto("restart_auto", "switch.r", "light", "slow");
|
||||||
|
auto.mode = RunMode::Restart;
|
||||||
|
engine.register(auto);
|
||||||
|
let _handle = engine.start();
|
||||||
|
|
||||||
|
// Trigger 1 starts the slow run.
|
||||||
|
hc.states().set(EntityId::parse("switch.r").unwrap(), "a", serde_json::json!({}), Context::new());
|
||||||
|
sleep(Duration::from_millis(80)).await;
|
||||||
|
// Trigger 2 arrives mid-run → must ABORT run 1 and start run 2.
|
||||||
|
hc.states().set(EntityId::parse("switch.r").unwrap(), "b", serde_json::json!({}), Context::new());
|
||||||
|
|
||||||
|
// Wait long enough for run 2 (started ~80ms in) to finish, but run 1
|
||||||
|
// (aborted at ~80ms, would have finished at ~300ms) must NOT complete.
|
||||||
|
sleep(Duration::from_millis(400)).await;
|
||||||
|
assert_eq!(
|
||||||
|
completed.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"Restart must cancel the in-flight run: exactly the restarted run completes (not both). \
|
||||||
|
On the old engine both ran to completion → 2."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Queued: serialize N rapid triggers, all run, never concurrent ──
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queued_mode_runs_sequentially_not_concurrently() {
|
||||||
|
let hc = HomeCore::new();
|
||||||
|
let (max_seen, completed) =
|
||||||
|
register_gauge(&hc, "light", "slow", Duration::from_millis(120)).await;
|
||||||
|
|
||||||
|
let engine = AutomationEngine::new(hc.clone());
|
||||||
|
let mut auto = state_auto("queued_auto", "switch.q", "light", "slow");
|
||||||
|
auto.mode = RunMode::Queued;
|
||||||
|
engine.register(auto);
|
||||||
|
let _handle = engine.start();
|
||||||
|
|
||||||
|
// Three rapid triggers.
|
||||||
|
for v in ["a", "b", "c"] {
|
||||||
|
hc.states().set(EntityId::parse("switch.q").unwrap(), v, serde_json::json!({}), Context::new());
|
||||||
|
sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 runs × 120ms serialized ≈ 360ms; wait generously.
|
||||||
|
sleep(Duration::from_millis(600)).await;
|
||||||
|
assert_eq!(
|
||||||
|
completed.load(Ordering::SeqCst),
|
||||||
|
3,
|
||||||
|
"Queued must run every trigger (nothing dropped)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
max_seen.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"Queued must never run two instances concurrently. On the old engine all 3 ran in \
|
||||||
|
parallel → max concurrency 3."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── max: 2 → never more than 2 concurrent ──────────────────────────
|
||||||
|
#[tokio::test]
|
||||||
|
async fn max_two_caps_concurrency_at_two() {
|
||||||
|
let hc = HomeCore::new();
|
||||||
|
let (max_seen, completed) =
|
||||||
|
register_gauge(&hc, "light", "slow", Duration::from_millis(150)).await;
|
||||||
|
|
||||||
|
let engine = AutomationEngine::new(hc.clone());
|
||||||
|
let mut auto = state_auto("max_auto", "switch.m", "light", "slow");
|
||||||
|
auto.mode = RunMode::Parallel;
|
||||||
|
auto.max = Some(2);
|
||||||
|
engine.register(auto);
|
||||||
|
let _handle = engine.start();
|
||||||
|
|
||||||
|
// Four rapid triggers — without the cap all 4 would run at once.
|
||||||
|
for v in ["a", "b", "c", "d"] {
|
||||||
|
hc.states().set(EntityId::parse("switch.m").unwrap(), v, serde_json::json!({}), Context::new());
|
||||||
|
sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(Duration::from_millis(600)).await;
|
||||||
|
assert_eq!(
|
||||||
|
completed.load(Ordering::SeqCst),
|
||||||
|
4,
|
||||||
|
"max:2 must still run all 4 triggers (queued beyond the cap, not dropped)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
max_seen.load(Ordering::SeqCst) <= 2,
|
||||||
|
"max:2 must never exceed 2 concurrent runs (observed {}). On the old engine all 4 ran \
|
||||||
|
concurrently → 4.",
|
||||||
|
max_seen.load(Ordering::SeqCst)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
max_seen.load(Ordering::SeqCst) >= 2,
|
||||||
|
"max:2 should reach the cap of 2 with 4 rapid triggers (observed {})",
|
||||||
|
max_seen.load(Ordering::SeqCst)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue