Skip to main content

velesdb_core/guardrails/
context.rs

1//! Query execution context with guard-rail tracking (EPIC-048).
2//!
3//! Tracks per-query resource consumption (time, depth, cardinality, memory)
4//! and enforces the configured limits.
5
6// Reason: Numeric casts in guardrails are intentional:
7// - u128->u64 for millisecond durations: durations fit within u64 (thousands of years)
8// - Used for timeout checking and logging, not precise calculations
9#![allow(clippy::cast_possible_truncation)]
10
11use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
12use std::time::{Duration, Instant};
13
14use super::limits::{GuardRailViolation, QueryLimits};
15
16/// Query execution context with guard-rail tracking (EPIC-048).
17#[derive(Debug)]
18pub struct QueryContext {
19    /// Query limits configuration.
20    pub limits: QueryLimits,
21    /// Query start time.
22    start_time: Instant,
23    /// Current traversal depth.
24    current_depth: AtomicU64,
25    /// Current cardinality (intermediate results count).
26    current_cardinality: AtomicUsize,
27    /// Estimated memory usage in bytes.
28    memory_used: AtomicUsize,
29    /// Graph nodes visited during MATCH traversal (for EXPLAIN ANALYZE).
30    traversal_nodes_visited: AtomicU64,
31    /// Graph edges traversed during MATCH traversal (for EXPLAIN ANALYZE).
32    traversal_edges_traversed: AtomicU64,
33}
34
35impl QueryContext {
36    /// Creates a new query context with the given limits.
37    #[must_use]
38    pub fn new(limits: QueryLimits) -> Self {
39        Self {
40            limits,
41            start_time: Instant::now(),
42            current_depth: AtomicU64::new(0),
43            current_cardinality: AtomicUsize::new(0),
44            memory_used: AtomicUsize::new(0),
45            traversal_nodes_visited: AtomicU64::new(0),
46            traversal_edges_traversed: AtomicU64::new(0),
47        }
48    }
49
50    /// Checks if the query has timed out (US-001).
51    ///
52    /// # Errors
53    ///
54    /// Returns [`GuardRailViolation::Timeout`] when elapsed time exceeds
55    /// the configured timeout.
56    pub fn check_timeout(&self) -> Result<(), GuardRailViolation> {
57        // timeout_ms == 0 means "disabled" — never fire.
58        if self.limits.timeout_ms == 0 {
59            return Ok(());
60        }
61        let elapsed_ms = self.start_time.elapsed().as_millis() as u64;
62        if elapsed_ms >= self.limits.timeout_ms {
63            return Err(GuardRailViolation::Timeout {
64                max_ms: self.limits.timeout_ms,
65                elapsed_ms,
66            });
67        }
68        Ok(())
69    }
70
71    /// Checks and updates traversal depth (US-002).
72    ///
73    /// # Errors
74    ///
75    /// Returns [`GuardRailViolation::DepthExceeded`] when `depth` is greater
76    /// than the configured maximum.
77    pub fn check_depth(&self, depth: u32) -> Result<(), GuardRailViolation> {
78        self.current_depth
79            .store(u64::from(depth), Ordering::Relaxed);
80        if depth > self.limits.max_depth {
81            return Err(GuardRailViolation::DepthExceeded {
82                max: self.limits.max_depth,
83                actual: depth,
84            });
85        }
86        Ok(())
87    }
88
89    /// Checks and updates cardinality (US-003).
90    ///
91    /// # Errors
92    ///
93    /// Returns [`GuardRailViolation::CardinalityExceeded`] when cumulative
94    /// intermediate result count exceeds the configured maximum.
95    ///
96    /// # Known Limitation
97    ///
98    /// This method is called on the final result set (post-filter, post-ORDER BY,
99    /// pre-LIMIT). It does **not** track intermediate over-fetched candidate sets
100    /// (e.g., `candidates_k = execution_limit * 10 * N` during similarity search).
101    /// Those are bounded by `MAX_LIMIT` internally and therefore do not escape.
102    /// Future work: thread `QueryContext` into ANN search to track intermediates.
103    pub fn check_cardinality(&self, count: usize) -> Result<(), GuardRailViolation> {
104        let current = self.current_cardinality.fetch_add(count, Ordering::Relaxed) + count;
105        if current > self.limits.max_cardinality {
106            return Err(GuardRailViolation::CardinalityExceeded {
107                max: self.limits.max_cardinality,
108                actual: current,
109            });
110        }
111        Ok(())
112    }
113
114    /// Checks and updates memory usage (US-004).
115    ///
116    /// # Errors
117    ///
118    /// Returns [`GuardRailViolation::MemoryExceeded`] when cumulative estimated
119    /// memory usage exceeds the configured budget.
120    pub fn check_memory(&self, bytes: usize) -> Result<(), GuardRailViolation> {
121        let current = self.memory_used.fetch_add(bytes, Ordering::Relaxed) + bytes;
122        if current > self.limits.memory_limit_bytes {
123            return Err(GuardRailViolation::MemoryExceeded {
124                max_bytes: self.limits.memory_limit_bytes,
125                used_bytes: current,
126            });
127        }
128        Ok(())
129    }
130
131    /// Returns elapsed time since query start.
132    #[must_use]
133    pub fn elapsed(&self) -> Duration {
134        self.start_time.elapsed()
135    }
136
137    /// Returns current memory usage estimate.
138    #[must_use]
139    pub fn memory_used(&self) -> usize {
140        self.memory_used.load(Ordering::Relaxed)
141    }
142
143    /// Accumulates graph-traversal counters measured during MATCH execution.
144    ///
145    /// Uses `fetch_add` so multiple traversal phases compose: a multi-pattern
146    /// MATCH, and the `GraphFirst` + `VectorFirst` legs of the Parallel
147    /// strategy, each add their own counts. Read back by EXPLAIN ANALYZE.
148    pub fn add_traversal(&self, nodes_visited: u64, edges_traversed: u64) {
149        self.traversal_nodes_visited
150            .fetch_add(nodes_visited, Ordering::Relaxed);
151        self.traversal_edges_traversed
152            .fetch_add(edges_traversed, Ordering::Relaxed);
153    }
154
155    /// Returns graph nodes visited during MATCH traversal (0 if no traversal ran).
156    #[must_use]
157    pub fn traversal_nodes_visited(&self) -> u64 {
158        self.traversal_nodes_visited.load(Ordering::Relaxed)
159    }
160
161    /// Returns graph edges traversed during MATCH traversal (0 if no traversal ran).
162    #[must_use]
163    pub fn traversal_edges_traversed(&self) -> u64 {
164        self.traversal_edges_traversed.load(Ordering::Relaxed)
165    }
166}