use std::borrow::Cow;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
pub const IDLE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
pub const WORK_COMPONENT: &str = "work_config";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkState {
Active,
Idle(Cow<'static, str>),
}
impl WorkState {
#[must_use]
pub fn idle(reason: impl Into<Cow<'static, str>>) -> Self {
Self::Idle(reason.into())
}
#[must_use]
pub fn idle_if(empty: bool, reason: impl Into<Cow<'static, str>>) -> Self {
if empty {
Self::idle(reason)
} else {
Self::Active
}
}
#[must_use]
pub fn is_idle(&self) -> bool {
matches!(self, Self::Idle(_))
}
#[must_use]
pub fn reason(&self) -> Option<&str> {
match self {
Self::Active => None,
Self::Idle(reason) => Some(reason),
}
}
}
#[cfg(feature = "health")]
#[must_use]
fn work_health(idle: bool) -> crate::health::HealthStatus {
if idle {
crate::health::HealthStatus::Degraded
} else {
crate::health::HealthStatus::Healthy
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateWake {
ConfigChanged,
ShuttingDown,
}
pub struct IdleGate {
idle: Arc<AtomicBool>,
registered: bool,
}
impl Default for IdleGate {
fn default() -> Self {
Self::new()
}
}
impl IdleGate {
#[must_use]
pub fn new() -> Self {
Self {
idle: Arc::new(AtomicBool::new(false)),
registered: false,
}
}
#[must_use]
pub fn is_idle(&self) -> bool {
self.idle.load(Ordering::Relaxed)
}
pub fn enter_idle(&mut self, reason: &str) {
if self.idle.swap(true, Ordering::Relaxed) {
return;
}
set_idle_gauge(true);
if !self.registered {
self.registered = true;
#[cfg(feature = "health")]
{
let idle = Arc::clone(&self.idle);
crate::health::HealthRegistry::register(WORK_COMPONENT, move || {
work_health(idle.load(Ordering::Relaxed))
});
}
}
tracing::warn!(
reason,
"no work configured -- idling until the configuration gives this service work"
);
}
pub fn leave_idle(&mut self) {
if !self.idle.swap(false, Ordering::Relaxed) {
return;
}
set_idle_gauge(false);
tracing::info!("configuration now names work -- starting the service");
}
#[cfg(feature = "health")]
#[must_use]
pub fn health_status(&self) -> crate::health::HealthStatus {
work_health(self.is_idle())
}
}
fn set_idle_gauge(idle: bool) {
#[cfg(feature = "metrics")]
metrics::gauge!("pipeline_idle").set(if idle { 1.0 } else { 0.0 });
#[cfg(not(feature = "metrics"))]
let _ = idle;
}
pub async fn wait_for_config_change(config_path: Option<&std::path::Path>) -> GateWake {
use crate::config::watch::ConfigWatch;
let mut watch = ConfigWatch::new(
config_path.map(std::path::Path::to_path_buf),
IDLE_POLL_INTERVAL,
)
.with_sighup(true);
watch.prime().await;
#[cfg(feature = "shutdown")]
{
let token = crate::shutdown::token();
tokio::select! {
_ = watch.next_trigger() => GateWake::ConfigChanged,
() = token.cancelled() => GateWake::ShuttingDown,
}
}
#[cfg(not(feature = "shutdown"))]
{
watch.next_trigger().await;
GateWake::ConfigChanged
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn work_state_helpers() {
assert_eq!(WorkState::idle_if(false, "unused"), WorkState::Active);
let idle = WorkState::idle_if(true, "no enabled sources");
assert!(idle.is_idle());
assert_eq!(idle.reason(), Some("no enabled sources"));
assert_eq!(WorkState::Active.reason(), None);
}
#[test]
fn gate_transitions_are_idempotent() {
let mut gate = IdleGate::new();
assert!(!gate.is_idle());
gate.enter_idle("no sources");
gate.enter_idle("no sources");
assert!(gate.is_idle());
gate.leave_idle();
gate.leave_idle();
assert!(!gate.is_idle());
}
#[cfg(feature = "health")]
#[test]
fn idle_is_ready_but_not_healthy() {
use crate::health::HealthStatus;
let mut gate = IdleGate::new();
assert_eq!(gate.health_status(), HealthStatus::Healthy);
gate.enter_idle("no sources");
assert_eq!(
gate.health_status(),
HealthStatus::Degraded,
"idle must be Ready (Degraded), never Unhealthy -- a readiness gate fails the deploy"
);
gate.leave_idle();
assert_eq!(gate.health_status(), HealthStatus::Healthy);
}
}