1use super::db_healthcheck::DbHealthChecker;
2use super::env_pool::SharedEnv;
3use crate::error::Error;
4use crate::lmdb::{DbHealth, LmdbStore, is_map_full};
5use heed::types::{Bytes, SerdeBincode};
6use heed::{Database, Env};
7use serde::{Deserialize, Serialize};
8use std::collections::VecDeque;
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12const MAX_HISTORY_ENTRIES: usize = 128;
13
14#[derive(Debug, Serialize, Deserialize, Clone)]
16pub struct QueryMatchEntry {
17 pub file_path: PathBuf, pub open_count: u32, pub last_opened: u64, }
21
22#[derive(Debug, Serialize, Deserialize, Clone)]
24struct HistoryEntry {
25 query: String,
26 timestamp: u64,
27}
28
29#[derive(Debug)]
30pub struct QueryTracker {
31 env: SharedEnv,
32 query_file_db: Database<Bytes, SerdeBincode<QueryMatchEntry>>,
34 query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
36 grep_query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
38 health: DbHealth,
39}
40
41impl DbHealthChecker for QueryTracker {
42 fn get_env(&self) -> &Env<heed::WithoutTls> {
43 &self.env
44 }
45
46 fn is_healthy(&self) -> bool {
47 self.health.is_healthy()
48 }
49
50 fn count_entries(&self) -> Result<Vec<(&'static str, u64)>, Error> {
51 let rtxn = self
52 .env
53 .read_txn()
54 .map_err(|source| Error::DbStartReadTxn {
55 db: Self::LABEL,
56 source,
57 })?;
58
59 let count_queries = self
60 .query_file_db
61 .len(&rtxn)
62 .map_err(|source| Error::DbRead {
63 db: Self::LABEL,
64 source,
65 })?;
66 let count_histories = self
67 .query_history_db
68 .len(&rtxn)
69 .map_err(|source| Error::DbRead {
70 db: Self::LABEL,
71 source,
72 })?;
73 let count_grep_histories =
74 self.grep_query_history_db
75 .len(&rtxn)
76 .map_err(|source| Error::DbRead {
77 db: Self::LABEL,
78 source,
79 })?;
80
81 Ok(vec![
82 ("query_file_entries", count_queries),
83 ("query_history_entries", count_histories),
84 ("grep_query_history_entries", count_grep_histories),
85 ])
86 }
87}
88
89impl LmdbStore for QueryTracker {
90 const LABEL: &'static str = "query";
91 const MAP_SIZE: usize = 10 * 1024 * 1024;
93 const MAX_DBS: u32 = 16;
94 const SIZE_CAP_BYTES: u64 = 8 * 1024 * 1024;
95
96 fn shared_env(&self) -> &SharedEnv {
97 &self.env
98 }
99
100 fn health(&self) -> &DbHealth {
101 &self.health
102 }
103}
104
105impl QueryTracker {
106 pub fn db_path(&self) -> &Path {
108 self.env.path()
109 }
110
111 pub fn open(db_path: impl AsRef<Path>) -> Result<Self, Error> {
112 let db_path = db_path.as_ref();
113 let (env, health) = Self::open_env(db_path)?;
114
115 let query_file_db = Self::open_database_safe(&env, Some("query_file_associations"))?;
116 let query_history_db = Self::open_database_safe(&env, Some("query_history"))?;
117 let grep_query_history_db = Self::open_database_safe(&env, Some("grep_query_history"))?;
118
119 Ok(QueryTracker {
120 env,
121 query_file_db,
122 query_history_db,
123 grep_query_history_db,
124 health,
125 })
126 }
127
128 #[deprecated(
129 since = "0.7.0",
130 note = "LMDB unsafe no-lock mode is no longer supported; use `QueryTracker::open` instead. \
131 The `_use_unsafe_no_lock` argument is ignored."
132 )]
133 pub fn new(db_path: impl AsRef<Path>, _use_unsafe_no_lock: bool) -> Result<Self, Error> {
134 Self::open(db_path)
135 }
136
137 fn get_now(&self) -> u64 {
138 SystemTime::now()
139 .duration_since(UNIX_EPOCH)
140 .unwrap()
141 .as_secs()
142 }
143
144 fn create_query_key(project_path: &Path, query: &str) -> Result<[u8; 32], Error> {
145 let project_str = project_path
146 .to_str()
147 .ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?;
148
149 let mut hasher = blake3::Hasher::default();
150 hasher.update(project_str.as_bytes());
151 hasher.update(b"::");
152 hasher.update(query.as_bytes());
153
154 Ok(*hasher.finalize().as_bytes())
155 }
156
157 fn create_project_key(project_path: &Path) -> Result<[u8; 32], Error> {
158 let project_str = project_path
159 .to_str()
160 .ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?;
161
162 Ok(*blake3::hash(project_str.as_bytes()).as_bytes())
163 }
164
165 fn append_to_history(
167 db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
168 wtxn: &mut heed::RwTxn,
169 project_key: &[u8; 32],
170 query: &str,
171 now: u64,
172 ) -> Result<(), Error> {
173 let mut history = db
174 .get(wtxn, project_key)
175 .map_err(|source| Error::DbRead {
176 db: Self::LABEL,
177 source,
178 })?
179 .unwrap_or_default();
180
181 history.push_back(HistoryEntry {
182 query: query.to_string(),
183 timestamp: now,
184 });
185 while history.len() > MAX_HISTORY_ENTRIES {
186 history.pop_front();
187 }
188
189 db.put(wtxn, project_key, &history)
190 .map_err(|source| Error::DbWrite {
191 db: Self::LABEL,
192 source,
193 })?;
194 Ok(())
195 }
196
197 fn read_history_at_offset(
200 db: &Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
201 env: &Env<heed::WithoutTls>,
202 project_key: &[u8; 32],
203 offset: usize,
204 ) -> Result<Option<String>, Error> {
205 let rtxn = env.read_txn().map_err(|source| Error::DbStartReadTxn {
206 db: Self::LABEL,
207 source,
208 })?;
209
210 let mut history = db
211 .get(&rtxn, project_key)
212 .map_err(|source| Error::DbRead {
213 db: Self::LABEL,
214 source,
215 })?
216 .unwrap_or_default();
217
218 if history.len() > offset {
220 let index = history.len() - 1 - offset;
221 let record = history.remove(index);
222 Ok(record.map(|r| r.query))
223 } else {
224 Ok(None)
225 }
226 }
227
228 pub fn track_query_completion(
229 &mut self,
230 query: &str,
231 project_path: &Path,
232 file_path: &Path,
233 ) -> Result<(), Error> {
234 let now = self.get_now();
235 let file_path_buf = file_path.to_path_buf();
236
237 let query_key = Self::create_query_key(project_path, query)?;
238 let mut wtxn = self
239 .env
240 .write_txn()
241 .map_err(|source| Error::DbStartWriteTxn {
242 db: Self::LABEL,
243 source,
244 })?;
245
246 let mut entry = self
247 .query_file_db
248 .get(&wtxn, &query_key)
249 .map_err(|source| Error::DbRead {
250 db: Self::LABEL,
251 source,
252 })?
253 .unwrap_or_else(|| QueryMatchEntry {
254 file_path: file_path_buf.clone(),
255 open_count: 0,
256 last_opened: now,
257 });
258
259 if entry.file_path == file_path_buf {
260 tracing::debug!(
261 ?query,
262 ?file_path,
263 "Query completed for same file as last time"
264 );
265
266 entry.open_count += 1;
268 } else {
269 tracing::debug!(
270 ?query,
271 ?file_path,
272 "Query completed for different file than last time"
273 );
274
275 entry.file_path = file_path_buf;
277 entry.open_count = 1;
278 }
279
280 entry.last_opened = now;
281
282 if let Err(e) = self.query_file_db.put(&mut wtxn, &query_key, &entry) {
283 if is_map_full(&e) {
284 self.health.mark_unhealthy("MDB_MAP_FULL on put");
285 tracing::error!(
286 ?query,
287 "Query tracker DB hit MDB_MAP_FULL; dropping write — db will \
288 be erased on next open"
289 );
290 return Ok(());
291 }
292 return Err(Error::DbWrite {
293 db: Self::LABEL,
294 source: e,
295 });
296 }
297
298 let project_key = Self::create_project_key(project_path)?;
300 if let Err(e) =
301 Self::append_to_history(&self.query_history_db, &mut wtxn, &project_key, query, now)
302 {
303 if let Error::DbWrite {
304 source: ref inner, ..
305 } = e
306 && is_map_full(inner)
307 {
308 self.health.mark_unhealthy("MDB_MAP_FULL on history append");
309 tracing::error!(?query, "Query tracker DB map full while appending history");
310 return Ok(());
311 }
312 return Err(e);
313 }
314
315 if let Err(e) = wtxn.commit() {
316 if is_map_full(&e) {
317 self.health.mark_unhealthy("MDB_MAP_FULL on commit");
318 tracing::error!(?query, "Query tracker DB map full on commit");
319 return Ok(());
320 }
321 return Err(Error::DbCommit {
322 db: Self::LABEL,
323 source: e,
324 });
325 }
326
327 tracing::debug!(?query, ?file_path, "Tracked query completion");
328 Ok(())
329 }
330
331 pub fn get_last_query_entry(
332 &self,
333 query: &str,
334 project_path: &Path,
335 min_combo_count: u32,
336 ) -> Result<Option<QueryMatchEntry>, Error> {
337 let query_key = Self::create_query_key(project_path, query)?;
338 let rtxn = self
339 .env
340 .read_txn()
341 .map_err(|source| Error::DbStartReadTxn {
342 db: Self::LABEL,
343 source,
344 })?;
345
346 let last_match = self
347 .query_file_db
348 .get(&rtxn, &query_key)
349 .map_err(|source| Error::DbRead {
350 db: Self::LABEL,
351 source,
352 })?;
353
354 Ok(last_match.filter(|entry| entry.open_count >= min_combo_count))
355 }
356
357 pub fn get_last_query_path(
358 &self,
359 query: &str,
360 project_path: &Path,
361 file_path: &Path,
362 combo_boost: i32,
363 ) -> Result<i32, Error> {
364 let query_key = Self::create_query_key(project_path, query)?;
365 tracing::debug!(?query_key, "HASH");
366 let rtxn = self
367 .env
368 .read_txn()
369 .map_err(|source| Error::DbStartReadTxn {
370 db: Self::LABEL,
371 source,
372 })?;
373
374 match self
375 .query_file_db
376 .get(&rtxn, &query_key)
377 .map_err(|source| Error::DbRead {
378 db: Self::LABEL,
379 source,
380 })? {
381 Some(entry) => {
382 if entry.file_path == file_path && entry.open_count >= 2 {
384 Ok(combo_boost)
385 } else {
386 Ok(0)
387 }
388 }
389 None => Ok(0), }
391 }
392
393 pub fn get_historical_query(
396 &self,
397 project_path: &Path,
398 offset: usize,
399 ) -> Result<Option<String>, Error> {
400 let project_key = Self::create_project_key(project_path)?;
401 Self::read_history_at_offset(&self.query_history_db, &self.env, &project_key, offset)
402 }
403
404 pub fn track_grep_query(&mut self, query: &str, project_path: &Path) -> Result<(), Error> {
407 let now = self.get_now();
408 let project_key = Self::create_project_key(project_path)?;
409 let mut wtxn = self
410 .env
411 .write_txn()
412 .map_err(|source| Error::DbStartWriteTxn {
413 db: Self::LABEL,
414 source,
415 })?;
416
417 if let Err(e) = Self::append_to_history(
418 &self.grep_query_history_db,
419 &mut wtxn,
420 &project_key,
421 query,
422 now,
423 ) {
424 if let Error::DbWrite {
425 source: ref inner, ..
426 } = e
427 && is_map_full(inner)
428 {
429 self.health
430 .mark_unhealthy("MDB_MAP_FULL on grep history append");
431 tracing::error!(?query, "Grep query history DB map full; dropping write");
432 return Ok(());
433 }
434 return Err(e);
435 }
436
437 if let Err(e) = wtxn.commit() {
438 if is_map_full(&e) {
439 self.health.mark_unhealthy("MDB_MAP_FULL on commit");
440 tracing::error!(?query, "Grep query history DB map full on commit");
441 return Ok(());
442 }
443 return Err(Error::DbCommit {
444 db: Self::LABEL,
445 source: e,
446 });
447 }
448
449 tracing::debug!(?query, "Tracked grep query");
450 Ok(())
451 }
452
453 pub fn get_historical_grep_query(
456 &self,
457 project_path: &Path,
458 offset: usize,
459 ) -> Result<Option<String>, Error> {
460 let project_key = Self::create_project_key(project_path)?;
461 Self::read_history_at_offset(&self.grep_query_history_db, &self.env, &project_key, offset)
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use std::env;
469
470 #[test]
471 fn test_query_tracking() {
472 let temp_dir = env::temp_dir().join("fff_test_query_tracking_new");
473 let _ = std::fs::remove_dir_all(&temp_dir);
474
475 let mut tracker = QueryTracker::open(temp_dir.to_str().unwrap()).unwrap();
476
477 let project_path = PathBuf::from("/test/project");
478 let file_path = PathBuf::from("/test/project/src/main.rs");
479
480 tracker
482 .track_query_completion("main", &project_path, &file_path)
483 .unwrap();
484 let boost = tracker
485 .get_last_query_path("main", &project_path, &file_path, 10000)
486 .unwrap();
487 assert_eq!(boost, 0, "First completion should not boost");
488
489 tracker
491 .track_query_completion("main", &project_path, &file_path)
492 .unwrap();
493 let boost = tracker
494 .get_last_query_path("main", &project_path, &file_path, 10000)
495 .unwrap();
496 assert_eq!(boost, 10000, "Second completion should boost");
497
498 let other_file = PathBuf::from("/test/project/src/lib.rs");
500 tracker
501 .track_query_completion("main", &project_path, &other_file)
502 .unwrap();
503 let boost = tracker
504 .get_last_query_path("main", &project_path, &other_file, 10000)
505 .unwrap();
506 assert_eq!(boost, 0, "Different file should reset boost");
507
508 let boost = tracker
510 .get_last_query_path("main", &project_path, &file_path, 10000)
511 .unwrap();
512 assert_eq!(boost, 0, "Original file should not boost after replacement");
513
514 let _ = std::fs::remove_dir_all(&temp_dir);
515 }
516
517 #[test]
518 fn test_hashing_functions() {
519 let project_path = PathBuf::from("/test/project");
520
521 let key1 = QueryTracker::create_project_key(&project_path).unwrap();
523 let key2 = QueryTracker::create_project_key(&project_path).unwrap();
524 assert_eq!(key1, key2, "Same project should hash to same key");
525
526 let query_key1 = QueryTracker::create_query_key(&project_path, "test").unwrap();
528 let query_key2 = QueryTracker::create_query_key(&project_path, "test").unwrap();
529 assert_eq!(
530 query_key1, query_key2,
531 "Same project+query should hash to same key"
532 );
533
534 let query_key3 = QueryTracker::create_query_key(&project_path, "different").unwrap();
536 assert_ne!(
537 query_key1, query_key3,
538 "Different queries should hash to different keys"
539 );
540
541 let other_project = PathBuf::from("/other/project");
543 let query_key4 = QueryTracker::create_query_key(&other_project, "test").unwrap();
544 assert_ne!(
545 query_key1, query_key4,
546 "Different projects should hash to different keys"
547 );
548 }
549}