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
215 tokio::select! {
216 result = node.execute(&timeout_ctx) => {
217 result
218 }
219 elapsed = wait_for_run_timeout(policy.run_timeout) => {
220 Err(GraphError::NodeTimedOut {
221 node: node_name,
222 elapsed,
223 })
224 }
225 elapsed = wait_for_idle_timeout(policy.idle_timeout, &progress_handle) => {
226 Err(GraphError::NodeTimedOut {
227 node: node_name,
228 elapsed,
229 })
230 }
231 }
232}
233
234async fn wait_for_run_timeout(run_timeout: Option<Duration>) -> Duration {
237 match run_timeout {
238 Some(duration) => {
239 tokio::time::sleep(duration).await;
240 duration
241 }
242 None => {
243 std::future::pending::<()>().await;
245 unreachable!()
246 }
247 }
248}
249
250async fn wait_for_idle_timeout(
254 idle_timeout: Option<Duration>,
255 progress_handle: &ProgressHandle,
256) -> Duration {
257 match idle_timeout {
258 Some(idle_duration) => {
259 let start_ms = current_time_ms();
260 let idle_ms = idle_duration.as_millis() as u64;
261 let poll_interval = Duration::from_millis(100);
262
263 loop {
264 tokio::time::sleep(poll_interval).await;
265 let now_ms = current_time_ms();
266 let last_progress = progress_handle.last_progress_ms();
267 let idle_elapsed = now_ms.saturating_sub(last_progress);
268
269 if idle_elapsed >= idle_ms {
270 let total_elapsed_ms = now_ms.saturating_sub(start_ms);
271 return Duration::from_millis(total_elapsed_ms);
272 }
273 }
274 }
275 None => {
276 std::future::pending::<()>().await;
278 unreachable!()
279 }
280 }
281}
282
283fn current_time_ms() -> u64 {
285 std::time::SystemTime::now()
286 .duration_since(std::time::UNIX_EPOCH)
287 .unwrap_or_default()
288 .as_millis() as u64
289}
290
291pub fn item_timeout_budget(policy: &TimeoutPolicy, elapsed: Duration) -> Option<Duration> {
298 let remaining_run = policy.run_timeout.map(|limit| limit.saturating_sub(elapsed));
299 match (remaining_run, policy.idle_timeout) {
300 (Some(run), Some(idle)) => Some(run.min(idle)),
301 (Some(run), None) => Some(run),
302 (None, Some(idle)) => Some(idle),
303 (None, None) => None,
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::node::{ExecutionConfig, FunctionNode, NodeContext, NodeOutput};
311 use crate::state::State;
312
313 #[tokio::test]
314 async fn test_no_timeout_executes_normally() {
315 let node = FunctionNode::new("fast", |_ctx| async {
316 Ok(NodeOutput::new().with_update("done", serde_json::json!(true)))
317 });
318
319 let ctx = NodeContext::new(State::new(), ExecutionConfig::default(), 0);
320 let policy = TimeoutPolicy::default();
321
322 let result = execute_with_timeout(&node, &ctx, &policy).await;
323 assert!(result.is_ok());
324 let output = result.unwrap();
325 assert_eq!(output.updates.get("done"), Some(&serde_json::json!(true)));
326 }
327
328 #[tokio::test]
329 async fn test_run_timeout_fires_on_slow_node() {
330 let node = FunctionNode::new("slow", |_ctx| async {
331 tokio::time::sleep(Duration::from_secs(10)).await;
332 Ok(NodeOutput::new())
333 });
334
335 let ctx = NodeContext::new(State::new(), ExecutionConfig::default(), 0);
336 let policy = TimeoutPolicy {
337 run_timeout: Some(Duration::from_millis(100)),
338 idle_timeout: None,
339 on_timeout: OnTimeout::Fail,
340 };
341
342 let result = execute_with_timeout(&node, &ctx, &policy).await;
343 assert!(result.is_err());
344 match result {
345 Err(GraphError::NodeTimedOut { node, .. }) => {
346 assert_eq!(node, "slow");
347 }
348 Err(other) => panic!("expected NodeTimedOut, got: {other:?}"),
349 Ok(_) => panic!("expected error, got Ok"),
350 }
351 }
352
353 #[tokio::test]
354 async fn test_skip_returns_empty_output() {
355 let node = FunctionNode::new("slow", |_ctx| async {
356 tokio::time::sleep(Duration::from_secs(10)).await;
357 Ok(NodeOutput::new().with_update("should_not_appear", serde_json::json!(true)))
358 });
359
360 let ctx = NodeContext::new(State::new(), ExecutionConfig::default(), 0);
361 let policy = TimeoutPolicy {
362 run_timeout: Some(Duration::from_millis(50)),
363 idle_timeout: None,
364 on_timeout: OnTimeout::Skip,
365 };
366
367 let result = execute_with_timeout(&node, &ctx, &policy).await;
368 assert!(result.is_ok());
369 let output = result.unwrap();
370 assert!(output.updates.is_empty());
371 }
372
373 #[tokio::test]
374 async fn test_retry_retries_up_to_max_attempts() {
375 use std::sync::atomic::AtomicUsize;
376
377 let attempt_count = Arc::new(AtomicUsize::new(0));
378 let count_clone = attempt_count.clone();
379
380 let node = FunctionNode::new("flaky", move |_ctx| {
381 let count = count_clone.clone();
382 async move {
383 count.fetch_add(1, Ordering::SeqCst);
384 tokio::time::sleep(Duration::from_secs(10)).await;
385 Ok(NodeOutput::new())
386 }
387 });
388
389 let ctx = NodeContext::new(State::new(), ExecutionConfig::default(), 0);
390 let policy = TimeoutPolicy {
391 run_timeout: Some(Duration::from_millis(50)),
392 idle_timeout: None,
393 on_timeout: OnTimeout::Retry { max_attempts: 3 },
394 };
395
396 let result = execute_with_timeout(&node, &ctx, &policy).await;
397 assert!(result.is_err());
398 assert_eq!(attempt_count.load(Ordering::SeqCst), 3);
399 }
400
401 #[tokio::test]
402 async fn test_fast_node_with_timeout_succeeds() {
403 let node = FunctionNode::new("fast", |_ctx| async {
404 Ok(NodeOutput::new().with_update("value", serde_json::json!(42)))
405 });
406
407 let ctx = NodeContext::new(State::new(), ExecutionConfig::default(), 0);
408 let policy = TimeoutPolicy {
409 run_timeout: Some(Duration::from_secs(5)),
410 idle_timeout: None,
411 on_timeout: OnTimeout::Fail,
412 };
413
414 let result = execute_with_timeout(&node, &ctx, &policy).await;
415 assert!(result.is_ok());
416 let output = result.unwrap();
417 assert_eq!(output.updates.get("value"), Some(&serde_json::json!(42)));
418 }
419
420 #[test]
421 fn test_progress_handle_updates_timestamp() {
422 let handle = ProgressHandle::new();
423 let initial = handle.last_progress_ms();
424
425 std::thread::sleep(Duration::from_millis(10));
427 handle.report_progress();
428
429 let updated = handle.last_progress_ms();
430 assert!(updated >= initial);
431 }
432}