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