1use std::sync::Arc;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::time::Duration;
29
30use crate::error::{GraphError, Result};
31use crate::node::{Node, NodeContext, NodeOutput};
32
33#[derive(Debug, Clone, Default)]
35pub enum OnTimeout {
36 #[default]
38 Fail,
39 Retry { max_attempts: usize },
41 Skip,
43}
44
45#[derive(Debug, Clone, Default)]
60pub struct TimeoutPolicy {
61 pub run_timeout: Option<Duration>,
63 pub idle_timeout: Option<Duration>,
65 pub on_timeout: OnTimeout,
67}
68
69#[derive(Debug, Clone)]
75pub struct ProgressHandle {
76 last_progress_ms: Arc<AtomicU64>,
77}
78
79impl ProgressHandle {
80 pub fn new() -> Self {
82 let now_ms = current_time_ms();
83 Self { last_progress_ms: Arc::new(AtomicU64::new(now_ms)) }
84 }
85
86 pub fn report_progress(&self) {
88 let now_ms = current_time_ms();
89 self.last_progress_ms.store(now_ms, Ordering::Release);
90 }
91
92 pub(crate) fn last_progress_ms(&self) -> u64 {
94 self.last_progress_ms.load(Ordering::Acquire)
95 }
96}
97
98impl Default for ProgressHandle {
99 fn default() -> Self {
100 Self::new()
101 }
102}
103
104pub async fn execute_with_timeout(
136 node: &dyn Node,
137 ctx: &NodeContext,
138 policy: &TimeoutPolicy,
139) -> Result<NodeOutput> {
140 if policy.run_timeout.is_none() && policy.idle_timeout.is_none() {
142 return node.execute(ctx).await;
143 }
144
145 let mut attempts = 0;
146
147 loop {
148 attempts += 1;
149 let result = execute_once_with_timeout(node, ctx, policy).await;
150
151 match result {
152 Ok(output) => return Ok(output),
153 Err(GraphError::NodeTimedOut { ref node, ref elapsed }) => {
154 match &policy.on_timeout {
155 OnTimeout::Fail => {
156 tracing::warn!(
157 node = %node,
158 elapsed_ms = elapsed.as_millis(),
159 action = "fail",
160 "node timed out, failing execution"
161 );
162 return result;
163 }
164 OnTimeout::Retry { max_attempts } => {
165 if attempts >= *max_attempts {
166 tracing::warn!(
167 node = %node,
168 elapsed_ms = elapsed.as_millis(),
169 attempts = attempts,
170 action = "fail_after_retries",
171 "node timed out after all retry attempts exhausted"
172 );
173 return result;
174 }
175 tracing::warn!(
176 node = %node,
177 elapsed_ms = elapsed.as_millis(),
178 attempt = attempts,
179 max_attempts = *max_attempts,
180 action = "retry",
181 "node timed out, retrying"
182 );
183 }
185 OnTimeout::Skip => {
186 tracing::warn!(
187 node = %node,
188 elapsed_ms = elapsed.as_millis(),
189 action = "skip",
190 "node timed out, skipping with empty output"
191 );
192 return Ok(NodeOutput::new());
193 }
194 }
195 }
196 Err(other) => return Err(other),
197 }
198 }
199}
200
201async fn execute_once_with_timeout(
203 node: &dyn Node,
204 ctx: &NodeContext,
205 policy: &TimeoutPolicy,
206) -> Result<NodeOutput> {
207 let node_name = node.name().to_string();
208 let progress_handle = ProgressHandle::new();
209
210 let mut timeout_ctx = NodeContext::new(ctx.state.clone(), ctx.config.clone(), ctx.step);
213 timeout_ctx.set_progress_handle(progress_handle.clone());
214 if let Some(run_config) = ctx.run_config() {
215 timeout_ctx.set_run_config(run_config);
216 }
217
218 tokio::select! {
219 result = node.execute(&timeout_ctx) => {
220 result
221 }
222 elapsed = wait_for_run_timeout(policy.run_timeout) => {
223 Err(GraphError::NodeTimedOut {
224 node: node_name,
225 elapsed,
226 })
227 }
228 elapsed = wait_for_idle_timeout(policy.idle_timeout, &progress_handle) => {
229 Err(GraphError::NodeTimedOut {
230 node: node_name,
231 elapsed,
232 })
233 }
234 }
235}
236
237async fn wait_for_run_timeout(run_timeout: Option<Duration>) -> Duration {
240 match run_timeout {
241 Some(duration) => {
242 tokio::time::sleep(duration).await;
243 duration
244 }
245 None => {
246 std::future::pending::<()>().await;
248 unreachable!()
249 }
250 }
251}
252
253async fn wait_for_idle_timeout(
257 idle_timeout: Option<Duration>,
258 progress_handle: &ProgressHandle,
259) -> Duration {
260 match idle_timeout {
261 Some(idle_duration) => {
262 let start_ms = current_time_ms();
263 let idle_ms = idle_duration.as_millis() as u64;
264 let poll_interval = Duration::from_millis(100);
265
266 loop {
267 tokio::time::sleep(poll_interval).await;
268 let now_ms = current_time_ms();
269 let last_progress = progress_handle.last_progress_ms();
270 let idle_elapsed = now_ms.saturating_sub(last_progress);
271
272 if idle_elapsed >= idle_ms {
273 let total_elapsed_ms = now_ms.saturating_sub(start_ms);
274 return Duration::from_millis(total_elapsed_ms);
275 }
276 }
277 }
278 None => {
279 std::future::pending::<()>().await;
281 unreachable!()
282 }
283 }
284}
285
286fn current_time_ms() -> u64 {
288 std::time::SystemTime::now()
289 .duration_since(std::time::UNIX_EPOCH)
290 .unwrap_or_default()
291 .as_millis() as u64
292}
293
294pub fn item_timeout_budget(policy: &TimeoutPolicy, elapsed: Duration) -> Option<Duration> {
301 let remaining_run = policy.run_timeout.map(|limit| limit.saturating_sub(elapsed));
302 match (remaining_run, policy.idle_timeout) {
303 (Some(run), Some(idle)) => Some(run.min(idle)),
304 (Some(run), None) => Some(run),
305 (None, Some(idle)) => Some(idle),
306 (None, None) => None,
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use crate::node::{ExecutionConfig, FunctionNode, NodeContext, NodeOutput};
314 use crate::state::State;
315
316 #[tokio::test]
317 async fn test_no_timeout_executes_normally() {
318 let node = FunctionNode::new("fast", |_ctx| async {
319 Ok(NodeOutput::new().with_update("done", serde_json::json!(true)))
320 });
321
322 let ctx = NodeContext::new(State::new(), ExecutionConfig::default(), 0);
323 let policy = TimeoutPolicy::default();
324
325 let result = execute_with_timeout(&node, &ctx, &policy).await;
326 assert!(result.is_ok());
327 let output = result.unwrap();
328 assert_eq!(output.updates.get("done"), Some(&serde_json::json!(true)));
329 }
330
331 #[tokio::test]
332 async fn test_run_timeout_fires_on_slow_node() {
333 let node = FunctionNode::new("slow", |_ctx| async {
334 tokio::time::sleep(Duration::from_secs(10)).await;
335 Ok(NodeOutput::new())
336 });
337
338 let ctx = NodeContext::new(State::new(), ExecutionConfig::default(), 0);
339 let policy = TimeoutPolicy {
340 run_timeout: Some(Duration::from_millis(100)),
341 idle_timeout: None,
342 on_timeout: OnTimeout::Fail,
343 };
344
345 let result = execute_with_timeout(&node, &ctx, &policy).await;
346 assert!(result.is_err());
347 match result {
348 Err(GraphError::NodeTimedOut { node, .. }) => {
349 assert_eq!(node, "slow");
350 }
351 Err(other) => panic!("expected NodeTimedOut, got: {other:?}"),
352 Ok(_) => panic!("expected error, got Ok"),
353 }
354 }
355
356 #[tokio::test]
357 async fn test_skip_returns_empty_output() {
358 let node = FunctionNode::new("slow", |_ctx| async {
359 tokio::time::sleep(Duration::from_secs(10)).await;
360 Ok(NodeOutput::new().with_update("should_not_appear", serde_json::json!(true)))
361 });
362
363 let ctx = NodeContext::new(State::new(), ExecutionConfig::default(), 0);
364 let policy = TimeoutPolicy {
365 run_timeout: Some(Duration::from_millis(50)),
366 idle_timeout: None,
367 on_timeout: OnTimeout::Skip,
368 };
369
370 let result = execute_with_timeout(&node, &ctx, &policy).await;
371 assert!(result.is_ok());
372 let output = result.unwrap();
373 assert!(output.updates.is_empty());
374 }
375
376 #[tokio::test]
377 async fn test_retry_retries_up_to_max_attempts() {
378 use std::sync::atomic::AtomicUsize;
379
380 let attempt_count = Arc::new(AtomicUsize::new(0));
381 let count_clone = attempt_count.clone();
382
383 let node = FunctionNode::new("flaky", move |_ctx| {
384 let count = count_clone.clone();
385 async move {
386 count.fetch_add(1, Ordering::SeqCst);
387 tokio::time::sleep(Duration::from_secs(10)).await;
388 Ok(NodeOutput::new())
389 }
390 });
391
392 let ctx = NodeContext::new(State::new(), ExecutionConfig::default(), 0);
393 let policy = TimeoutPolicy {
394 run_timeout: Some(Duration::from_millis(50)),
395 idle_timeout: None,
396 on_timeout: OnTimeout::Retry { max_attempts: 3 },
397 };
398
399 let result = execute_with_timeout(&node, &ctx, &policy).await;
400 assert!(result.is_err());
401 assert_eq!(attempt_count.load(Ordering::SeqCst), 3);
402 }
403
404 #[tokio::test]
405 async fn test_fast_node_with_timeout_succeeds() {
406 let node = FunctionNode::new("fast", |_ctx| async {
407 Ok(NodeOutput::new().with_update("value", serde_json::json!(42)))
408 });
409
410 let ctx = NodeContext::new(State::new(), ExecutionConfig::default(), 0);
411 let policy = TimeoutPolicy {
412 run_timeout: Some(Duration::from_secs(5)),
413 idle_timeout: None,
414 on_timeout: OnTimeout::Fail,
415 };
416
417 let result = execute_with_timeout(&node, &ctx, &policy).await;
418 assert!(result.is_ok());
419 let output = result.unwrap();
420 assert_eq!(output.updates.get("value"), Some(&serde_json::json!(42)));
421 }
422
423 #[test]
424 fn test_progress_handle_updates_timestamp() {
425 let handle = ProgressHandle::new();
426 let initial = handle.last_progress_ms();
427
428 std::thread::sleep(Duration::from_millis(10));
430 handle.report_progress();
431
432 let updated = handle.last_progress_ms();
433 assert!(updated >= initial);
434 }
435}