1use std::process::ExitCode;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::Duration;
7
8use tokio::sync::Notify;
9use tracing::{error, info, warn};
10
11use crate::ServerState;
12use crate::error::ServerError;
13use crate::worker::LostWorkerReport;
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum ShutdownOutcome {
18 Clean,
20 Parked,
27 TimedOut,
30 Forced,
32}
33
34impl ShutdownOutcome {
35 #[must_use]
37 pub fn exit_code(self) -> ExitCode {
38 match self {
39 Self::Clean | Self::Parked => ExitCode::SUCCESS,
40 Self::TimedOut => ExitCode::FAILURE,
41 Self::Forced => ExitCode::from(130),
42 }
43 }
44}
45
46#[derive(Clone, Debug, Default)]
49pub struct DrainState {
50 inner: Arc<DrainStateInner>,
51}
52
53#[derive(Debug, Default)]
54struct DrainStateInner {
55 draining: AtomicBool,
56 empty: Notify,
57}
58
59impl DrainState {
60 #[must_use]
62 pub fn is_draining(&self) -> bool {
63 self.inner.draining.load(Ordering::Acquire)
64 }
65
66 #[must_use]
68 pub fn begin(&self) -> bool {
69 !self.inner.draining.swap(true, Ordering::AcqRel)
70 }
71
72 pub fn ensure_accepting(
78 &self,
79 namespace: &str,
80 activity_type: &str,
81 ) -> Result<(), ServerError> {
82 if self.is_draining() {
83 Err(ServerError::worker_dispatch(
84 namespace.to_owned(),
85 activity_type.to_owned(),
86 "server is draining and not accepting new activity tasks",
87 ))
88 } else {
89 Ok(())
90 }
91 }
92
93 pub fn notify_activity_drained(&self) {
95 self.inner.empty.notify_waiters();
96 }
97
98 async fn wait_for_empty(&self, state: &ServerState) -> Result<(), ServerError> {
99 loop {
100 let in_flight = state.heartbeat_tracker().in_flight_count()?;
101 if in_flight == 0 {
102 return Ok(());
103 }
104 let notified = self.inner.empty.notified();
105 if state.heartbeat_tracker().in_flight_count()? == 0 {
106 return Ok(());
107 }
108 notified.await;
109 }
110 }
111}
112
113pub async fn drain_after_first_signal(
122 state: ServerState,
123 second_signal: impl std::future::Future<Output = ()>,
124) -> Result<ShutdownOutcome, ServerError> {
125 let drain = state.drain_state().clone();
126 let first = drain.begin();
127 if first {
128 info!("shutdown signal received; beginning graceful drain");
129 }
130
131 let delivered_workers = state.worker_registry().broadcast_drain()?;
132 info!(delivered_workers, "sent drain request to connected workers");
133
134 let timeout = state.runtime_config().drain_timeout;
135 tokio::pin!(second_signal);
136
137 let outcome = tokio::select! {
138 () = &mut second_signal => {
139 warn!("second shutdown signal received; forcing immediate exit");
140 ShutdownOutcome::Forced
141 }
142 result = wait_for_drain_or_timeout(&state, &drain, timeout) => result?,
143 };
144
145 if matches!(outcome, ShutdownOutcome::Forced) {
146 return Ok(outcome);
147 }
148
149 state.shutdown()?;
150 Ok(outcome)
151}
152
153async fn wait_for_drain_or_timeout(
154 state: &ServerState,
155 drain: &DrainState,
156 timeout: Duration,
157) -> Result<ShutdownOutcome, ServerError> {
158 match tokio::time::timeout(timeout, drain.wait_for_empty(state)).await {
159 Ok(result) => {
160 result?;
161 info!("activity drain completed cleanly");
162 Ok(ShutdownOutcome::Clean)
163 }
164 Err(_elapsed) => {
165 match state
173 .heartbeat_tracker()
174 .park_all_in_flight_workers(state.worker_registry(), state.pending_activities())
175 {
176 Ok(reports) => {
177 log_parked_workers(&reports);
178 Ok(ShutdownOutcome::Parked)
179 }
180 Err(park_error) => {
181 error!(
182 %park_error,
183 "activity drain timed out and parking the remaining in-flight \
184 activities failed; exiting with the failure drain outcome"
185 );
186 Ok(ShutdownOutcome::TimedOut)
187 }
188 }
189 }
190 }
191}
192
193fn log_parked_workers(reports: &[LostWorkerReport]) {
194 let parked_tasks: usize = reports.iter().map(|report| report.tasks.len()).sum();
195 if parked_tasks == 0 {
196 info!("activity drain timed out with no tracked in-flight activities to park");
197 } else {
198 info!(
199 parked_workers = reports.len(),
200 parked_tasks,
201 "activity drain timed out; remaining activities parked for restart recovery"
202 );
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use std::process::ExitCode;
209
210 use super::{DrainState, ShutdownOutcome};
211
212 #[test]
213 fn begin_is_idempotent_and_sets_draining() {
214 let drain = DrainState::default();
215
216 assert!(!drain.is_draining());
217 assert!(drain.begin());
218 assert!(drain.is_draining());
219 assert!(!drain.begin());
220 }
221
222 #[test]
227 fn exit_codes_map_parked_to_success_and_timed_out_to_failure() {
228 let debug = |code: ExitCode| format!("{code:?}");
229 assert_eq!(
230 debug(ShutdownOutcome::Clean.exit_code()),
231 debug(ExitCode::SUCCESS)
232 );
233 assert_eq!(
234 debug(ShutdownOutcome::Parked.exit_code()),
235 debug(ExitCode::SUCCESS)
236 );
237 assert_eq!(
238 debug(ShutdownOutcome::TimedOut.exit_code()),
239 debug(ExitCode::FAILURE)
240 );
241 assert_eq!(
242 debug(ShutdownOutcome::Forced.exit_code()),
243 debug(ExitCode::from(130))
244 );
245 }
246}