Skip to main content

sqry_db/
dependency.rs

1//! Thread-local dependency recording with RAII cleanup.
2//!
3//! During query execution, every node/edge read records the owning `FileId` in
4//! a thread-local vector. The [`DependencyRecorderGuard`] ensures the vector is
5//! cleared on drop (including on panic unwind) to prevent stale state leaks.
6//!
7//! # Usage
8//!
9//! ```rust,ignore
10//! let guard = DependencyRecorderGuard::new();
11//! // ... query execution that calls record_file_dep() ...
12//! let deps = guard.finish(&input_store);
13//! ```
14
15use std::cell::RefCell;
16
17use smallvec::SmallVec;
18
19use sqry_core::graph::unified::file::id::FileId;
20
21use crate::input::FileInputStore;
22
23/// File dependency pair: `(FileId, revision_at_read_time)`.
24pub type FileDep = (FileId, u64);
25
26thread_local! {
27    /// Thread-local accumulator for file dependencies during query execution.
28    ///
29    /// Cleared by `DependencyRecorderGuard::drop` to prevent cross-query leaks.
30    static FILE_DEPS: RefCell<Vec<FileId>> = const { RefCell::new(Vec::new()) };
31}
32
33/// Records a file dependency from within a query's `execute` method.
34///
35/// Call this whenever a query reads a node or edge belonging to `file_id`.
36/// The guard will collect these at the end and pair them with revision numbers.
37///
38/// # Panics
39///
40/// Panics if called outside a `DependencyRecorderGuard` scope (the thread-local
41/// is empty but functional — the dep simply won't be captured).
42pub fn record_file_dep(file_id: FileId) {
43    if file_id == FileId::INVALID {
44        return;
45    }
46    FILE_DEPS.with(|deps| {
47        deps.borrow_mut().push(file_id);
48    });
49}
50
51/// RAII guard that initializes the thread-local dependency recorder and
52/// clears it on drop (including on panic unwind).
53///
54/// # Design
55///
56/// The guard model ensures no stale file IDs leak between query executions,
57/// even if a query panics mid-execution. Create one guard per `QueryDb::get`
58/// call, before invoking `Q::execute`.
59pub struct DependencyRecorderGuard {
60    /// Marker to prevent `Send` (thread-local is per-thread).
61    _not_send: std::marker::PhantomData<*const ()>,
62}
63
64impl DependencyRecorderGuard {
65    /// Creates a new guard, clearing any stale state in the thread-local.
66    #[must_use]
67    pub fn new() -> Self {
68        FILE_DEPS.with(|deps| deps.borrow_mut().clear());
69        Self {
70            _not_send: std::marker::PhantomData,
71        }
72    }
73
74    /// Consumes the guard, pairing recorded `FileId`s with their current
75    /// revision from the input store. Deduplicates file IDs.
76    ///
77    /// Returns a `SmallVec` with capacity 8, matching the design doc's
78    /// `SmallVec<[(FileId, u64); 8]>` for the common case of queries
79    /// touching 8 or fewer files.
80    #[must_use]
81    pub fn finish(self, inputs: &FileInputStore) -> SmallVec<[FileDep; 8]> {
82        let raw = FILE_DEPS.with(|deps| std::mem::take(&mut *deps.borrow_mut()));
83
84        // Deduplicate: sort + dedup (cheaper than a HashSet for small N)
85        let mut unique: Vec<FileId> = raw;
86        unique.sort_unstable();
87        unique.dedup();
88
89        unique
90            .into_iter()
91            .filter_map(|fid| inputs.revision(fid).map(|rev| (fid, rev)))
92            .collect()
93    }
94}
95
96impl Default for DependencyRecorderGuard {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl Drop for DependencyRecorderGuard {
103    fn drop(&mut self) {
104        FILE_DEPS.with(|deps| deps.borrow_mut().clear());
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn guard_clears_on_drop() {
114        // Record some deps without a guard to verify the thread-local works
115        FILE_DEPS.with(|deps| {
116            deps.borrow_mut().push(FileId::new(1));
117            deps.borrow_mut().push(FileId::new(2));
118        });
119
120        // Guard creation should clear stale state
121        let guard = DependencyRecorderGuard::new();
122        FILE_DEPS.with(|deps| {
123            assert!(deps.borrow().is_empty(), "guard should clear on creation");
124        });
125
126        // Record new deps
127        record_file_dep(FileId::new(10));
128        record_file_dep(FileId::new(20));
129        record_file_dep(FileId::new(10)); // duplicate
130
131        // Drop without finish — should clear
132        drop(guard);
133        FILE_DEPS.with(|deps| {
134            assert!(deps.borrow().is_empty(), "guard should clear on drop");
135        });
136    }
137
138    #[test]
139    fn finish_deduplicates_and_pairs_revisions() {
140        let mut store = FileInputStore::new();
141        store.insert(
142            FileId::new(1),
143            crate::input::FileInput::new(Default::default()),
144        );
145        store.insert(
146            FileId::new(2),
147            crate::input::FileInput::new(Default::default()),
148        );
149
150        let guard = DependencyRecorderGuard::new();
151        record_file_dep(FileId::new(1));
152        record_file_dep(FileId::new(2));
153        record_file_dep(FileId::new(1)); // duplicate
154        record_file_dep(FileId::INVALID); // should be ignored
155
156        let deps = guard.finish(&store);
157        assert_eq!(deps.len(), 2);
158        assert_eq!(deps[0], (FileId::new(1), 1));
159        assert_eq!(deps[1], (FileId::new(2), 1));
160    }
161
162    #[test]
163    fn guard_clears_on_panic_unwind() {
164        // Simulate a panic during query execution
165        let result = std::panic::catch_unwind(|| {
166            let _guard = DependencyRecorderGuard::new();
167            record_file_dep(FileId::new(99));
168            panic!("simulated query panic");
169        });
170        assert!(result.is_err());
171
172        // Thread-local should be clean
173        FILE_DEPS.with(|deps| {
174            assert!(
175                deps.borrow().is_empty(),
176                "guard should clear on panic unwind"
177            );
178        });
179    }
180}