use std::io;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Running,
Draining,
Stopped,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShutdownOutcome {
DrainedClean { flushed: usize },
DrainedWithErrors { flushed: usize, errors: usize },
ForcedAfterBudget { flushed: usize },
AlreadyDraining,
AlreadyStopped,
}
impl ShutdownOutcome {
#[must_use]
pub fn is_clean(&self) -> bool {
matches!(self, ShutdownOutcome::DrainedClean { .. })
}
}
pub trait DrainTarget {
fn stop_intake(&mut self);
fn flush_pending(&mut self) -> io::Result<FlushProgress>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FlushProgress {
pub flushed: usize,
pub still_pending: usize,
}
#[derive(Debug)]
pub struct GracefulShutdown {
state: State,
}
impl Default for GracefulShutdown {
fn default() -> Self {
Self {
state: State::Running,
}
}
}
impl GracefulShutdown {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn state(&self) -> State {
self.state
}
#[must_use]
pub fn is_stopped(&self) -> bool {
self.state == State::Stopped
}
pub fn shutdown(
&mut self,
target: &mut dyn DrainTarget,
max_flush_passes: usize,
) -> ShutdownOutcome {
match self.state {
State::Draining => return ShutdownOutcome::AlreadyDraining,
State::Stopped => return ShutdownOutcome::AlreadyStopped,
State::Running => {}
}
self.state = State::Draining;
target.stop_intake();
let budget = max_flush_passes.max(1);
let mut flushed_total = 0usize;
let mut error_total = 0usize;
for _ in 0..budget {
match target.flush_pending() {
Ok(p) => {
flushed_total += p.flushed;
if p.still_pending == 0 {
self.state = State::Stopped;
return if error_total == 0 {
ShutdownOutcome::DrainedClean {
flushed: flushed_total,
}
} else {
ShutdownOutcome::DrainedWithErrors {
flushed: flushed_total,
errors: error_total,
}
};
}
}
Err(_) => {
error_total += 1;
}
}
}
self.state = State::Stopped;
if error_total > 0 && flushed_total == 0 {
ShutdownOutcome::DrainedWithErrors {
flushed: 0,
errors: error_total,
}
} else {
ShutdownOutcome::ForcedAfterBudget {
flushed: flushed_total,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct MockTarget {
log: RefCell<Vec<&'static str>>,
pending: RefCell<usize>,
per_pass: usize,
pass: RefCell<usize>,
err_passes: Vec<usize>,
}
impl MockTarget {
fn new(pending: usize, per_pass: usize) -> Self {
Self {
log: RefCell::new(Vec::new()),
pending: RefCell::new(pending),
per_pass,
pass: RefCell::new(0),
err_passes: Vec::new(),
}
}
fn erroring(mut self, passes: &[usize]) -> Self {
self.err_passes = passes.to_vec();
self
}
}
impl DrainTarget for MockTarget {
fn stop_intake(&mut self) {
self.log.borrow_mut().push("stop_intake");
}
fn flush_pending(&mut self) -> io::Result<FlushProgress> {
self.log.borrow_mut().push("flush");
let idx = *self.pass.borrow();
*self.pass.borrow_mut() += 1;
if self.err_passes.contains(&idx) {
return Err(io::Error::other("flush boom"));
}
let mut p = self.pending.borrow_mut();
let f = self.per_pass.min(*p);
*p -= f;
Ok(FlushProgress {
flushed: f,
still_pending: *p,
})
}
}
#[test]
fn shutdown_stops_intake_strictly_before_any_flush() {
let mut t = MockTarget::new(5, 10);
let mut sm = GracefulShutdown::new();
sm.shutdown(&mut t, 4);
let log = t.log.borrow();
assert_eq!(log[0], "stop_intake", "intake MUST stop before any flush");
assert!(
log.iter().filter(|s| **s == "stop_intake").count() == 1,
"stop_intake exactly once"
);
let first_flush = log.iter().position(|s| *s == "flush").unwrap();
let stop_idx = log.iter().position(|s| *s == "stop_intake").unwrap();
assert!(
stop_idx < first_flush,
"stop_intake strictly precedes flush"
);
}
#[test]
fn shutdown_drains_until_empty_then_clean_stop() {
let mut t = MockTarget::new(10, 4); let mut sm = GracefulShutdown::new();
let out = sm.shutdown(&mut t, 8);
assert_eq!(out, ShutdownOutcome::DrainedClean { flushed: 10 });
assert!(out.is_clean());
assert_eq!(sm.state(), State::Stopped);
}
#[test]
fn shutdown_is_idempotent_double_sigterm() {
let mut t = MockTarget::new(2, 2);
let mut sm = GracefulShutdown::new();
let first = sm.shutdown(&mut t, 4);
assert_eq!(first, ShutdownOutcome::DrainedClean { flushed: 2 });
let second = sm.shutdown(&mut t, 4);
assert_eq!(second, ShutdownOutcome::AlreadyStopped);
assert!(
!second.is_clean(),
"AlreadyStopped is not a fresh clean drain"
);
assert_eq!(
t.log
.borrow()
.iter()
.filter(|s| **s == "stop_intake")
.count(),
1
);
}
#[test]
fn shutdown_bounded_forces_stop_after_budget_never_hangs() {
let mut t = MockTarget::new(100, 1);
let mut sm = GracefulShutdown::new();
let out = sm.shutdown(&mut t, 3);
assert_eq!(out, ShutdownOutcome::ForcedAfterBudget { flushed: 3 });
assert!(
!out.is_clean(),
"a budget-forced stop is honestly NOT clean"
);
assert_eq!(sm.state(), State::Stopped, "termination guaranteed");
assert_eq!(t.log.borrow().iter().filter(|s| **s == "flush").count(), 3);
}
#[test]
fn shutdown_flush_error_still_reaches_stopped() {
let mut t = MockTarget::new(5, 5).erroring(&[0, 1, 2]);
let mut sm = GracefulShutdown::new();
let out = sm.shutdown(&mut t, 3);
assert_eq!(
out,
ShutdownOutcome::DrainedWithErrors {
flushed: 0,
errors: 3
}
);
assert!(!out.is_clean());
assert_eq!(sm.state(), State::Stopped);
}
#[test]
fn shutdown_partial_flush_then_error_reports_durable_count() {
let mut t = MockTarget::new(6, 3).erroring(&[1]);
let mut sm = GracefulShutdown::new();
let out = sm.shutdown(&mut t, 5);
assert_eq!(
out,
ShutdownOutcome::DrainedWithErrors {
flushed: 6,
errors: 1
}
);
assert!(!out.is_clean());
}
#[test]
fn shutdown_zero_budget_is_treated_as_one_attempt() {
let mut t = MockTarget::new(1, 1);
let mut sm = GracefulShutdown::new();
let out = sm.shutdown(&mut t, 0);
assert_eq!(out, ShutdownOutcome::DrainedClean { flushed: 1 });
assert_eq!(t.log.borrow().iter().filter(|s| **s == "flush").count(), 1);
}
#[test]
fn shutdown_stopped_is_terminal() {
let mut t = MockTarget::new(0, 1);
let mut sm = GracefulShutdown::new();
sm.shutdown(&mut t, 1);
assert_eq!(sm.state(), State::Stopped);
assert_eq!(sm.shutdown(&mut t, 1), ShutdownOutcome::AlreadyStopped);
assert_eq!(sm.state(), State::Stopped);
}
#[test]
fn shutdown_empty_pending_is_immediate_clean_stop() {
let mut t = MockTarget::new(0, 4);
let mut sm = GracefulShutdown::new();
let out = sm.shutdown(&mut t, 4);
assert_eq!(out, ShutdownOutcome::DrainedClean { flushed: 0 });
assert_eq!(t.log.borrow()[0], "stop_intake");
assert!(out.is_clean());
}
#[test]
fn shutdown_outcome_is_clean_only_for_drained_clean() {
assert!(ShutdownOutcome::DrainedClean { flushed: 9 }.is_clean());
assert!(
!ShutdownOutcome::DrainedWithErrors {
flushed: 1,
errors: 1
}
.is_clean()
);
assert!(!ShutdownOutcome::ForcedAfterBudget { flushed: 2 }.is_clean());
assert!(!ShutdownOutcome::AlreadyDraining.is_clean());
assert!(!ShutdownOutcome::AlreadyStopped.is_clean());
}
}