Skip to main content

oxirs_arq/timeout/
mod.rs

1//! SPARQL query timeout enforcement
2//!
3//! Enforces time limits on query execution to prevent runaway queries.
4
5use std::sync::{
6    atomic::{AtomicBool, Ordering},
7    Arc,
8};
9use std::time::{Duration, Instant};
10
11/// Timeout error types
12#[derive(Debug, thiserror::Error)]
13pub enum TimeoutError {
14    #[error("Query exceeded timeout of {duration_ms}ms")]
15    QueryTimeout { duration_ms: u64 },
16    #[error("Query was cancelled")]
17    QueryCancelled,
18}
19
20/// A handle representing an active query timeout.
21///
22/// Created via [`QueryTimeout::new`]. Call [`QueryTimeout::check`] periodically
23/// inside inner query-execution loops to enforce the deadline.
24pub struct QueryTimeout {
25    pub deadline: Instant,
26    pub cancelled: Arc<AtomicBool>,
27    duration: Duration,
28}
29
30impl QueryTimeout {
31    /// Create a new timeout that expires after `duration`.
32    pub fn new(duration: Duration) -> Self {
33        Self {
34            deadline: Instant::now() + duration,
35            cancelled: Arc::new(AtomicBool::new(false)),
36            duration,
37        }
38    }
39
40    /// Returns `true` if the timeout deadline has passed.
41    pub fn is_expired(&self) -> bool {
42        Instant::now() >= self.deadline
43    }
44
45    /// Returns the remaining time until the deadline, or `None` if already expired.
46    pub fn remaining(&self) -> Option<Duration> {
47        self.deadline.checked_duration_since(Instant::now())
48    }
49
50    /// Cancel the query externally. Future calls to [`QueryTimeout::check`] will return
51    /// [`TimeoutError::QueryCancelled`].
52    pub fn cancel(&self) {
53        self.cancelled.store(true, Ordering::Release);
54    }
55
56    /// Check whether the timeout or cancellation has been triggered.
57    ///
58    /// Call this in inner execution loops. Returns `Ok(())` if execution
59    /// should continue, or the appropriate `Err` variant otherwise.
60    pub fn check(&self) -> Result<(), TimeoutError> {
61        if self.cancelled.load(Ordering::Acquire) {
62            return Err(TimeoutError::QueryCancelled);
63        }
64        if self.is_expired() {
65            return Err(TimeoutError::QueryTimeout {
66                duration_ms: self.duration.as_millis() as u64,
67            });
68        }
69        Ok(())
70    }
71
72    /// Obtain a cloneable cancellation handle that shares the same flag.
73    pub fn cancellation_handle(&self) -> Arc<AtomicBool> {
74        Arc::clone(&self.cancelled)
75    }
76}
77
78/// Configuration for timeout behaviour across a SPARQL endpoint.
79pub struct TimeoutConfig {
80    /// Default timeout applied when the client does not specify one.
81    /// `None` means no timeout by default.
82    pub default_timeout: Option<Duration>,
83    /// Hard upper bound that even admin requests cannot exceed
84    /// unless `admin_bypass` is set.
85    pub max_timeout: Duration,
86    /// When `true`, requests that carry admin credentials skip timeout enforcement.
87    pub admin_bypass: bool,
88}
89
90impl Default for TimeoutConfig {
91    fn default() -> Self {
92        Self {
93            default_timeout: Some(Duration::from_secs(30)),
94            max_timeout: Duration::from_secs(300),
95            admin_bypass: true,
96        }
97    }
98}
99
100impl TimeoutConfig {
101    /// Resolve the effective timeout for a request.
102    ///
103    /// * `requested` - the timeout the client asked for (may come from a URL
104    ///   parameter or request header).
105    /// * `is_admin` - whether the requesting user has admin privileges.
106    ///
107    /// Returns `None` when no timeout should be applied.
108    pub fn resolve(&self, requested: Option<Duration>, is_admin: bool) -> Option<Duration> {
109        if is_admin && self.admin_bypass {
110            return None;
111        }
112        let base = requested.or(self.default_timeout)?;
113        Some(base.min(self.max_timeout))
114    }
115}
116
117/// Parse a timeout value supplied as a URL query-parameter or HTTP header value.
118///
119/// Accepted formats:
120/// * `"30s"` - 30 seconds
121/// * `"30000ms"` or `"30000"` (raw integer) - milliseconds
122/// * `"30m"` - 30 minutes
123/// * `"PT30S"` (ISO 8601 duration subset) - 30 seconds
124pub fn parse_timeout_param(value: &str) -> Result<Duration, String> {
125    let trimmed = value.trim();
126
127    if trimmed.is_empty() {
128        return Err("Timeout value is empty".to_string());
129    }
130
131    // ISO 8601 subset: PTnS / PTnM
132    if trimmed.to_ascii_uppercase().starts_with("PT") {
133        let upper = trimmed.to_ascii_uppercase();
134        let inner = &upper[2..]; // strip "PT"
135        if let Some(s_idx) = inner.find('S') {
136            let num_str = &inner[..s_idx];
137            if let Ok(secs) = num_str.parse::<f64>() {
138                if secs < 0.0 {
139                    return Err("Timeout must not be negative".to_string());
140                }
141                return Ok(Duration::from_millis((secs * 1000.0) as u64));
142            }
143        }
144        return Err(format!("Cannot parse ISO 8601 duration: {trimmed}"));
145    }
146
147    // Suffix-based parsing
148    if let Some(num_str) = trimmed.strip_suffix("ms") {
149        let ms: u64 = num_str
150            .trim()
151            .parse()
152            .map_err(|_| format!("Invalid millisecond value: {num_str}"))?;
153        return Ok(Duration::from_millis(ms));
154    }
155    if let Some(num_str) = trimmed.strip_suffix('s') {
156        let secs: f64 = num_str
157            .trim()
158            .parse()
159            .map_err(|_| format!("Invalid second value: {num_str}"))?;
160        if secs < 0.0 {
161            return Err("Timeout must not be negative".to_string());
162        }
163        return Ok(Duration::from_millis((secs * 1000.0) as u64));
164    }
165    if let Some(num_str) = trimmed.strip_suffix('m') {
166        let mins: f64 = num_str
167            .trim()
168            .parse()
169            .map_err(|_| format!("Invalid minute value: {num_str}"))?;
170        if mins < 0.0 {
171            return Err("Timeout must not be negative".to_string());
172        }
173        return Ok(Duration::from_millis((mins * 60.0 * 1000.0) as u64));
174    }
175
176    // Plain integer -> milliseconds
177    if let Ok(ms) = trimmed.parse::<u64>() {
178        return Ok(Duration::from_millis(ms));
179    }
180
181    Err(format!("Unrecognised timeout format: {trimmed}"))
182}
183
184/// An iterator adaptor that enforces a [`QueryTimeout`] on every `N`-th item.
185///
186/// Each call to `next` returns `Some(Ok(item))` while the timeout has not
187/// fired, `Some(Err(TimeoutError))` on the first expiry/cancellation, and
188/// `None` once the inner iterator is exhausted.
189pub struct TimeoutIterator<I: Iterator> {
190    inner: I,
191    timeout: Arc<QueryTimeout>,
192    /// How many items to yield before rechecking the timeout.
193    check_interval: usize,
194    count: usize,
195    /// Set to true after a timeout/cancellation error has been emitted.
196    /// After this, the iterator returns `None` to terminate the iteration.
197    terminated: bool,
198}
199
200impl<I: Iterator> TimeoutIterator<I> {
201    /// Wrap `iter` with a timeout check every `check_interval` items.
202    pub fn new(iter: I, timeout: Arc<QueryTimeout>, check_interval: usize) -> Self {
203        Self {
204            inner: iter,
205            timeout,
206            check_interval: check_interval.max(1),
207            count: 0,
208            terminated: false,
209        }
210    }
211}
212
213impl<I: Iterator> Iterator for TimeoutIterator<I> {
214    type Item = Result<I::Item, TimeoutError>;
215
216    fn next(&mut self) -> Option<Self::Item> {
217        if self.terminated {
218            return None;
219        }
220        self.count += 1;
221        if self.count % self.check_interval == 0 {
222            if let Err(e) = self.timeout.check() {
223                self.terminated = true;
224                return Some(Err(e));
225            }
226        }
227
228        self.inner.next().map(Ok)
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::sync::atomic::Ordering;
236    use std::thread;
237    use std::time::Duration;
238
239    // QueryTimeout basic behaviour
240
241    #[test]
242    fn test_not_expired_immediately() {
243        let t = QueryTimeout::new(Duration::from_secs(60));
244        assert!(!t.is_expired(), "Should not be expired immediately");
245    }
246
247    #[test]
248    fn test_expires_after_deadline() {
249        let t = QueryTimeout::new(Duration::from_millis(10));
250        thread::sleep(Duration::from_millis(50));
251        assert!(t.is_expired(), "Should be expired after deadline");
252    }
253
254    #[test]
255    fn test_remaining_positive_before_expiry() {
256        let t = QueryTimeout::new(Duration::from_secs(60));
257        assert!(
258            t.remaining().is_some(),
259            "Remaining should be Some before expiry"
260        );
261    }
262
263    #[test]
264    fn test_remaining_none_after_expiry() {
265        let t = QueryTimeout::new(Duration::from_millis(1));
266        thread::sleep(Duration::from_millis(30));
267        assert!(
268            t.remaining().is_none(),
269            "Remaining should be None after expiry"
270        );
271    }
272
273    #[test]
274    fn test_remaining_decreases_over_time() {
275        let t = QueryTimeout::new(Duration::from_secs(10));
276        let first = t.remaining().expect("first remaining");
277        thread::sleep(Duration::from_millis(50));
278        let second = t.remaining().expect("second remaining");
279        assert!(second < first, "Remaining should decrease");
280    }
281
282    #[test]
283    fn test_check_ok_before_expiry() {
284        let t = QueryTimeout::new(Duration::from_secs(60));
285        assert!(t.check().is_ok(), "check() should be Ok before expiry");
286    }
287
288    #[test]
289    fn test_check_timeout_after_expiry() {
290        let t = QueryTimeout::new(Duration::from_millis(10));
291        thread::sleep(Duration::from_millis(50));
292        match t.check() {
293            Err(TimeoutError::QueryTimeout { .. }) => {}
294            other => panic!("Expected QueryTimeout, got {other:?}"),
295        }
296    }
297
298    // Cancellation
299
300    #[test]
301    fn test_cancel_not_cancelled_initially() {
302        let t = QueryTimeout::new(Duration::from_secs(60));
303        assert!(!t.cancelled.load(Ordering::Acquire));
304    }
305
306    #[test]
307    fn test_cancel_sets_flag() {
308        let t = QueryTimeout::new(Duration::from_secs(60));
309        t.cancel();
310        assert!(t.cancelled.load(Ordering::Acquire));
311    }
312
313    #[test]
314    fn test_check_returns_cancelled_after_cancel() {
315        let t = QueryTimeout::new(Duration::from_secs(60));
316        t.cancel();
317        match t.check() {
318            Err(TimeoutError::QueryCancelled) => {}
319            other => panic!("Expected QueryCancelled, got {other:?}"),
320        }
321    }
322
323    #[test]
324    fn test_cancel_takes_priority_over_expiry() {
325        let t = QueryTimeout::new(Duration::from_millis(1));
326        t.cancel();
327        thread::sleep(Duration::from_millis(20));
328        // Both cancelled and expired; cancellation flag checked first
329        match t.check() {
330            Err(TimeoutError::QueryCancelled) => {}
331            other => panic!("Expected QueryCancelled, got {other:?}"),
332        }
333    }
334
335    #[test]
336    fn test_cancellation_handle_shares_flag() {
337        let t = QueryTimeout::new(Duration::from_secs(60));
338        let handle = t.cancellation_handle();
339        handle.store(true, Ordering::Release);
340        assert!(t.cancelled.load(Ordering::Acquire));
341    }
342
343    #[test]
344    fn test_cancellation_handle_from_another_thread() {
345        let t = Arc::new(QueryTimeout::new(Duration::from_secs(60)));
346        let handle = t.cancellation_handle();
347        let t2 = Arc::clone(&t);
348
349        let join = thread::spawn(move || {
350            thread::sleep(Duration::from_millis(10));
351            handle.store(true, Ordering::Release);
352        });
353
354        join.join().expect("thread panicked");
355        match t2.check() {
356            Err(TimeoutError::QueryCancelled) => {}
357            other => panic!("Expected QueryCancelled, got {other:?}"),
358        }
359    }
360
361    // TimeoutConfig::resolve
362
363    #[test]
364    fn test_config_default_values() {
365        let cfg = TimeoutConfig::default();
366        assert_eq!(cfg.default_timeout, Some(Duration::from_secs(30)));
367        assert_eq!(cfg.max_timeout, Duration::from_secs(300));
368        assert!(cfg.admin_bypass);
369    }
370
371    #[test]
372    fn test_config_admin_bypass_returns_none() {
373        let cfg = TimeoutConfig::default();
374        let result = cfg.resolve(Some(Duration::from_secs(10)), true);
375        assert!(result.is_none(), "Admin should bypass timeout");
376    }
377
378    #[test]
379    fn test_config_no_admin_bypass_applies_timeout() {
380        let cfg = TimeoutConfig {
381            admin_bypass: false,
382            ..Default::default()
383        };
384        let result = cfg.resolve(Some(Duration::from_secs(10)), true);
385        assert_eq!(result, Some(Duration::from_secs(10)));
386    }
387
388    #[test]
389    fn test_config_clamps_to_max() {
390        let cfg = TimeoutConfig::default();
391        let result = cfg.resolve(Some(Duration::from_secs(9999)), false);
392        assert_eq!(result, Some(Duration::from_secs(300)));
393    }
394
395    #[test]
396    fn test_config_uses_default_when_no_request() {
397        let cfg = TimeoutConfig::default();
398        let result = cfg.resolve(None, false);
399        assert_eq!(result, Some(Duration::from_secs(30)));
400    }
401
402    #[test]
403    fn test_config_none_default_no_request_returns_none() {
404        let cfg = TimeoutConfig {
405            default_timeout: None,
406            ..Default::default()
407        };
408        let result = cfg.resolve(None, false);
409        assert!(result.is_none());
410    }
411
412    #[test]
413    fn test_config_within_max_passes_through() {
414        let cfg = TimeoutConfig::default();
415        let result = cfg.resolve(Some(Duration::from_secs(60)), false);
416        assert_eq!(result, Some(Duration::from_secs(60)));
417    }
418
419    // parse_timeout_param
420
421    #[test]
422    fn test_parse_seconds_suffix() {
423        let d = parse_timeout_param("30s").expect("parse 30s");
424        assert_eq!(d, Duration::from_secs(30));
425    }
426
427    #[test]
428    fn test_parse_milliseconds_suffix() {
429        let d = parse_timeout_param("30000ms").expect("parse 30000ms");
430        assert_eq!(d, Duration::from_millis(30_000));
431    }
432
433    #[test]
434    fn test_parse_plain_integer_milliseconds() {
435        let d = parse_timeout_param("30000").expect("parse 30000");
436        assert_eq!(d, Duration::from_millis(30_000));
437    }
438
439    #[test]
440    fn test_parse_minutes_suffix() {
441        let d = parse_timeout_param("1m").expect("parse 1m");
442        assert_eq!(d, Duration::from_secs(60));
443    }
444
445    #[test]
446    fn test_parse_fractional_seconds() {
447        let d = parse_timeout_param("1.5s").expect("parse 1.5s");
448        assert_eq!(d, Duration::from_millis(1500));
449    }
450
451    #[test]
452    fn test_parse_iso8601_pt30s() {
453        let d = parse_timeout_param("PT30S").expect("parse PT30S");
454        assert_eq!(d, Duration::from_secs(30));
455    }
456
457    #[test]
458    fn test_parse_iso8601_lowercase() {
459        let d = parse_timeout_param("pt30s").expect("parse pt30s");
460        assert_eq!(d, Duration::from_secs(30));
461    }
462
463    #[test]
464    fn test_parse_zero_milliseconds() {
465        let d = parse_timeout_param("0ms").expect("parse 0ms");
466        assert_eq!(d, Duration::from_millis(0));
467    }
468
469    #[test]
470    fn test_parse_zero_seconds() {
471        let d = parse_timeout_param("0s").expect("parse 0s");
472        assert_eq!(d, Duration::ZERO);
473    }
474
475    #[test]
476    fn test_parse_empty_string_errors() {
477        assert!(parse_timeout_param("").is_err());
478    }
479
480    #[test]
481    fn test_parse_invalid_string_errors() {
482        assert!(parse_timeout_param("abc").is_err());
483    }
484
485    #[test]
486    fn test_parse_with_leading_whitespace() {
487        let d = parse_timeout_param("  30s").expect("parse '  30s'");
488        assert_eq!(d, Duration::from_secs(30));
489    }
490
491    #[test]
492    fn test_parse_large_ms_value() {
493        let d = parse_timeout_param("86400000ms").expect("parse 86400000ms");
494        assert_eq!(d, Duration::from_secs(86400));
495    }
496
497    // TimeoutIterator
498
499    #[test]
500    fn test_iterator_yields_all_items_before_timeout() {
501        let t = Arc::new(QueryTimeout::new(Duration::from_secs(60)));
502        let iter = TimeoutIterator::new(0..5, Arc::clone(&t), 100);
503        let results: Vec<_> = iter.collect();
504        assert_eq!(results.len(), 5);
505        for r in &results {
506            assert!(r.is_ok());
507        }
508    }
509
510    #[test]
511    fn test_iterator_stops_on_expiry() {
512        let t = Arc::new(QueryTimeout::new(Duration::from_millis(1)));
513        thread::sleep(Duration::from_millis(30));
514        let iter = TimeoutIterator::new(0..1000, Arc::clone(&t), 1);
515        // First item (count==1, 1%1==0) triggers the check
516        let first = iter.into_iter().next();
517        match first {
518            Some(Err(TimeoutError::QueryTimeout { .. })) => {}
519            other => panic!("Expected QueryTimeout, got {other:?}"),
520        }
521    }
522
523    #[test]
524    fn test_iterator_stops_on_cancellation() {
525        let t = Arc::new(QueryTimeout::new(Duration::from_secs(60)));
526        t.cancel();
527        let iter = TimeoutIterator::new(0..1000, Arc::clone(&t), 1);
528        let first = iter.into_iter().next();
529        match first {
530            Some(Err(TimeoutError::QueryCancelled)) => {}
531            other => panic!("Expected QueryCancelled, got {other:?}"),
532        }
533    }
534
535    #[test]
536    fn test_iterator_checks_at_interval() {
537        // check_interval=3: check on item 3, 6, 9, ...
538        // Timeout fires before iteration (already expired).
539        let t = Arc::new(QueryTimeout::new(Duration::from_millis(1)));
540        thread::sleep(Duration::from_millis(30));
541        let iter = TimeoutIterator::new(0..100, Arc::clone(&t), 3);
542        let results: Vec<_> = iter.collect();
543        // Items 1 & 2 pass (no check at those counts), item 3 triggers timeout
544        assert_eq!(results.len(), 3);
545        assert!(results[0].is_ok());
546        assert!(results[1].is_ok());
547        match &results[2] {
548            Err(TimeoutError::QueryTimeout { .. }) => {}
549            other => panic!("Expected QueryTimeout at index 2, got {other:?}"),
550        }
551    }
552
553    #[test]
554    fn test_iterator_empty_inner() {
555        let t = Arc::new(QueryTimeout::new(Duration::from_secs(60)));
556        let iter = TimeoutIterator::new(std::iter::empty::<i32>(), t, 1);
557        let results: Vec<_> = iter.collect();
558        assert!(results.is_empty());
559    }
560
561    #[test]
562    fn test_iterator_with_string_items() {
563        let t = Arc::new(QueryTimeout::new(Duration::from_secs(60)));
564        let data = vec!["alpha", "beta", "gamma"];
565        let iter = TimeoutIterator::new(data.into_iter(), t, 10);
566        let results: Vec<_> = iter.collect();
567        assert_eq!(results.len(), 3);
568        assert_eq!(*results[0].as_ref().unwrap(), "alpha");
569    }
570
571    // Error display
572
573    #[test]
574    fn test_timeout_error_display_timeout() {
575        let e = TimeoutError::QueryTimeout { duration_ms: 5000 };
576        let msg = format!("{e}");
577        assert!(msg.contains("5000"));
578    }
579
580    #[test]
581    fn test_timeout_error_display_cancelled() {
582        let e = TimeoutError::QueryCancelled;
583        let msg = format!("{e}");
584        assert!(msg.to_lowercase().contains("cancel"));
585    }
586
587    // Combined scenario
588
589    #[test]
590    fn test_full_scenario_config_parse_enforce() {
591        let cfg = TimeoutConfig::default();
592        // A non-admin request asking for 45s -> allowed (less than 300s max)
593        let effective = cfg.resolve(Some(Duration::from_secs(45)), false);
594        assert_eq!(effective, Some(Duration::from_secs(45)));
595
596        // Create the timeout and verify it has not yet fired
597        let t = QueryTimeout::new(effective.unwrap());
598        assert!(t.check().is_ok());
599        assert!(t.remaining().is_some());
600    }
601
602    #[test]
603    fn test_parse_then_new_timeout() {
604        let d = parse_timeout_param("500ms").expect("parse");
605        let t = QueryTimeout::new(d);
606        assert!(t.check().is_ok());
607        thread::sleep(Duration::from_millis(600));
608        assert!(t.is_expired());
609    }
610
611    #[test]
612    fn test_config_non_admin_uses_default_when_none_requested() {
613        let cfg = TimeoutConfig {
614            default_timeout: Some(Duration::from_secs(15)),
615            max_timeout: Duration::from_secs(60),
616            admin_bypass: true,
617        };
618        let result = cfg.resolve(None, false);
619        assert_eq!(result, Some(Duration::from_secs(15)));
620    }
621
622    #[test]
623    fn test_parse_2m() {
624        let d = parse_timeout_param("2m").expect("parse 2m");
625        assert_eq!(d, Duration::from_secs(120));
626    }
627
628    #[test]
629    fn test_parse_pt10s_float() {
630        let d = parse_timeout_param("PT10S").expect("parse PT10S");
631        assert_eq!(d, Duration::from_secs(10));
632    }
633
634    #[test]
635    fn test_iterator_interval_one_checks_every_item() {
636        let t = Arc::new(QueryTimeout::new(Duration::from_secs(60)));
637        let iter = TimeoutIterator::new(0..10, Arc::clone(&t), 1);
638        let results: Vec<_> = iter.collect();
639        assert_eq!(results.len(), 10);
640        for r in &results {
641            assert!(r.is_ok());
642        }
643    }
644
645    #[test]
646    fn test_timeout_duration_stored_correctly() {
647        let d = Duration::from_millis(250);
648        let t = QueryTimeout::new(d);
649        // duration field is private, but we can verify behaviour through check()
650        thread::sleep(Duration::from_millis(300));
651        match t.check() {
652            Err(TimeoutError::QueryTimeout { duration_ms }) => {
653                assert_eq!(duration_ms, 250);
654            }
655            other => panic!("Unexpected: {other:?}"),
656        }
657    }
658
659    #[test]
660    fn test_multiple_cancel_calls_idempotent() {
661        let t = QueryTimeout::new(Duration::from_secs(60));
662        t.cancel();
663        t.cancel();
664        t.cancel();
665        assert!(t.cancelled.load(Ordering::Acquire));
666        assert!(matches!(t.check(), Err(TimeoutError::QueryCancelled)));
667    }
668
669    #[test]
670    fn test_config_max_timeout_boundary() {
671        let cfg = TimeoutConfig {
672            default_timeout: Some(Duration::from_secs(10)),
673            max_timeout: Duration::from_secs(100),
674            admin_bypass: false,
675        };
676        // Exactly at max
677        assert_eq!(
678            cfg.resolve(Some(Duration::from_secs(100)), false),
679            Some(Duration::from_secs(100))
680        );
681        // One ms over max
682        assert_eq!(
683            cfg.resolve(Some(Duration::from_millis(100_001)), false),
684            Some(Duration::from_secs(100))
685        );
686    }
687}