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}