1use std::collections::{BTreeSet, HashMap, VecDeque};
2use std::fmt;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex, RwLock};
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8const INSPECT_SQLITE_SIDECAR_SUFFIXES: &[&str] = &["-wal", "-shm", "-journal"];
9
10use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
11
12use crate::cache_freshness::{FileFreshness, FreshnessVerdict};
13use crate::config::Config;
14use crate::jsonc::strip_jsonc;
15
16use super::job::{
17 contribution_with_type_ref_names, type_ref_names_from_contribution, FileContribution,
18 InspectCategory, JobKey,
19};
20
21#[derive(Debug, Default)]
22pub(crate) struct Tier2ContributionUpdates {
23 pub upserts: Vec<FileContribution>,
24 pub deletes: Vec<PathBuf>,
25 pub metadata_updates: Vec<(PathBuf, FileFreshness)>,
26}
27
28#[derive(Debug, Default, Clone, Copy)]
29pub(crate) struct InspectDbTimings {
30 pub lock_wait: Duration,
31 pub transaction: Duration,
32}
33
34#[derive(Debug)]
35pub enum InspectCacheError {
36 Io(std::io::Error),
37 Sql(rusqlite::Error),
38 Json(serde_json::Error),
39 LockPoisoned(&'static str),
40 InvalidHash(String),
41}
42
43impl fmt::Display for InspectCacheError {
44 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45 match self {
46 InspectCacheError::Io(error) => write!(formatter, "inspect cache io error: {error}"),
47 InspectCacheError::Sql(error) => {
48 write!(formatter, "inspect cache sqlite error: {error}")
49 }
50 InspectCacheError::Json(error) => {
51 write!(formatter, "inspect cache json error: {error}")
52 }
53 InspectCacheError::LockPoisoned(name) => {
54 write!(formatter, "inspect cache lock poisoned: {name}")
55 }
56 InspectCacheError::InvalidHash(hash) => {
57 write!(formatter, "inspect cache invalid blake3 hash: {hash}")
58 }
59 }
60 }
61}
62
63impl std::error::Error for InspectCacheError {}
64
65impl From<std::io::Error> for InspectCacheError {
66 fn from(error: std::io::Error) -> Self {
67 Self::Io(error)
68 }
69}
70
71impl From<rusqlite::Error> for InspectCacheError {
72 fn from(error: rusqlite::Error) -> Self {
73 Self::Sql(error)
74 }
75}
76
77impl From<serde_json::Error> for InspectCacheError {
78 fn from(error: serde_json::Error) -> Self {
79 Self::Json(error)
80 }
81}
82
83pub(crate) const TIER2_CONTRIBUTION_CACHE_VERSION: u32 = 29;
148
149#[derive(Debug, Clone)]
150pub struct ContributionRecord {
151 pub category: InspectCategory,
152 pub file_path: PathBuf,
153 pub freshness: FileFreshness,
154 pub contribution: serde_json::Value,
155 pub type_ref_names: BTreeSet<String>,
156}
157
158#[derive(Debug, Clone)]
159struct MemoryAggregate {
160 payload: serde_json::Value,
161 generated_at: i64,
162 contribution_set_hash: Option<String>,
163}
164
165const TIER1_FILE_MEMO_MAX_ENTRIES: usize = 4_096;
166
167#[derive(Debug, Clone)]
168struct Tier1MemoEntry<T> {
169 freshness: FileFreshness,
170 value: T,
171 generation: u64,
172}
173
174#[derive(Debug, Clone)]
175struct LruNode {
176 path: PathBuf,
177 generation: u64,
178}
179
180#[derive(Debug)]
181struct Tier1MemoState<T> {
182 entries: HashMap<PathBuf, Tier1MemoEntry<T>>,
183 lru: VecDeque<LruNode>,
184 next_generation: u64,
185}
186
187impl<T> Default for Tier1MemoState<T> {
188 fn default() -> Self {
189 Self {
190 entries: HashMap::new(),
191 lru: VecDeque::new(),
192 next_generation: 0,
193 }
194 }
195}
196
197impl<T> Tier1MemoState<T> {
198 fn insert(&mut self, path: PathBuf, mut entry: Tier1MemoEntry<T>) {
199 let generation = self.allocate_generation();
200 entry.generation = generation;
201 self.entries.insert(path.clone(), entry);
202 self.lru.push_back(LruNode { path, generation });
203 self.compact_lru_if_needed();
204 self.evict_lru();
205 }
206
207 fn remove(&mut self, path: &Path) {
208 self.entries.remove(path);
209 self.compact_lru_if_needed();
210 }
211
212 fn touch(&mut self, path: &Path) {
213 if !self.entries.contains_key(path) {
214 return;
215 }
216
217 let generation = self.allocate_generation();
218 if let Some(entry) = self.entries.get_mut(path) {
219 entry.generation = generation;
220 self.lru.push_back(LruNode {
221 path: path.to_path_buf(),
222 generation,
223 });
224 }
225 self.compact_lru_if_needed();
226 }
227
228 fn allocate_generation(&mut self) -> u64 {
229 if self.next_generation == u64::MAX {
230 self.rebuild_lru();
231 }
232 let generation = self.next_generation;
233 self.next_generation += 1;
234 generation
235 }
236
237 fn compact_lru_if_needed(&mut self) {
238 let max_lru_nodes = TIER1_FILE_MEMO_MAX_ENTRIES
239 .saturating_mul(2)
240 .max(self.entries.len());
241 if self.lru.len() > max_lru_nodes {
242 self.rebuild_lru();
243 }
244 }
245
246 fn rebuild_lru(&mut self) {
247 let mut live_nodes = self
248 .entries
249 .iter()
250 .map(|(path, entry)| (entry.generation, path.clone()))
251 .collect::<Vec<_>>();
252 live_nodes.sort_by_key(|(generation, _)| *generation);
253
254 self.lru.clear();
255 for (generation, (_, path)) in live_nodes.into_iter().enumerate() {
256 let generation = generation as u64;
257 if let Some(entry) = self.entries.get_mut(&path) {
258 entry.generation = generation;
259 }
260 self.lru.push_back(LruNode { path, generation });
261 }
262 self.next_generation = self.lru.len() as u64;
263 }
264
265 fn evict_lru(&mut self) {
266 while self.entries.len() > TIER1_FILE_MEMO_MAX_ENTRIES {
267 let Some(node) = self.lru.pop_front() else {
268 break;
269 };
270 if self
271 .entries
272 .get(&node.path)
273 .is_some_and(|entry| entry.generation == node.generation)
274 {
275 self.entries.remove(&node.path);
276 }
277 }
278 self.compact_lru_if_needed();
279 }
280}
281
282#[derive(Debug)]
283pub(crate) struct Tier1FileMemo<T> {
284 state: Mutex<Tier1MemoState<T>>,
285}
286
287impl<T> Default for Tier1FileMemo<T> {
288 fn default() -> Self {
289 Self {
290 state: Mutex::new(Tier1MemoState::default()),
291 }
292 }
293}
294
295impl<T: Clone> Tier1FileMemo<T> {
296 pub(crate) fn get_or_insert_with<F>(&self, path: &Path, scan: F) -> T
297 where
298 F: FnOnce(&Path) -> (Option<FileFreshness>, T),
299 {
300 if let Some(cached) = self.cached_value(path) {
301 return cached;
302 }
303
304 let (freshness, value) = scan(path);
305 if let Ok(mut state) = self.state.lock() {
306 if let Some(freshness) = freshness {
307 state.insert(
308 path.to_path_buf(),
309 Tier1MemoEntry {
310 freshness,
311 value: value.clone(),
312 generation: 0,
313 },
314 );
315 } else {
316 state.remove(path);
317 }
318 }
319 value
320 }
321
322 fn cached_value(&self, path: &Path) -> Option<T> {
323 let mut cached = self
324 .state
325 .lock()
326 .ok()
327 .and_then(|state| state.entries.get(path).cloned())?;
328
329 match crate::cache_freshness::verify_file(path, &cached.freshness) {
330 FreshnessVerdict::HotFresh => {
331 if let Ok(mut state) = self.state.lock() {
332 state.touch(path);
333 }
334 Some(cached.value)
335 }
336 FreshnessVerdict::ContentFresh {
337 new_mtime,
338 new_size,
339 } => {
340 cached.freshness.mtime = new_mtime;
341 cached.freshness.size = new_size;
342 let value = cached.value.clone();
343 if let Ok(mut state) = self.state.lock() {
344 state.insert(path.to_path_buf(), cached);
345 }
346 Some(value)
347 }
348 FreshnessVerdict::Stale => None,
349 FreshnessVerdict::Deleted => {
350 if let Ok(mut state) = self.state.lock() {
351 state.remove(path);
352 }
353 None
354 }
355 }
356 }
357}
358
359#[derive(Debug)]
360pub struct InspectCache {
361 project_root: PathBuf,
362 project_key: String,
363 sqlite_path: PathBuf,
364 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
365 read_marker: Option<crate::root_cache::ReadMarker>,
366 conn: Mutex<Connection>,
367 memory: RwLock<HashMap<JobKey, MemoryAggregate>>,
368}
369
370#[derive(Debug)]
371pub struct ReadonlyInspectCache {
372 inner: InspectCache,
373}
374
375impl InspectCache {
376 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
379 let memory = match self.memory.try_read() {
380 Ok(memory) => memory,
381 Err(_) => return crate::memory::MemoryEstimate::busy(),
382 };
383 if memory.is_empty() {
384 return crate::memory::MemoryEstimate::partial(0)
385 .count("memory_aggregates", 0)
386 .count("open_generation_handles", 1);
387 }
388 let aggregate_bytes = memory.iter().fold(0u64, |bytes, (key, aggregate)| {
389 bytes
390 .saturating_add(std::mem::size_of::<JobKey>() as u64)
391 .saturating_add(
392 key.scope_hash
393 .as_ref()
394 .map(|hash| crate::memory::usize_to_u64(hash.len()))
395 .unwrap_or(0),
396 )
397 .saturating_add(std::mem::size_of::<MemoryAggregate>() as u64)
398 .saturating_add(crate::memory::estimated_json_bytes(&aggregate.payload))
399 .saturating_add(
400 aggregate
401 .contribution_set_hash
402 .as_ref()
403 .map(|hash| crate::memory::usize_to_u64(hash.len()))
404 .unwrap_or(0),
405 )
406 });
407 let metadata_bytes = crate::memory::path_bytes(&self.project_root)
408 .saturating_add(crate::memory::usize_to_u64(self.project_key.len()))
409 .saturating_add(crate::memory::path_bytes(&self.sqlite_path));
410 crate::memory::MemoryEstimate::partial(aggregate_bytes.saturating_add(metadata_bytes))
411 .count("memory_aggregates", memory.len())
412 .count("open_generation_handles", 1)
413 }
414}
415
416pub trait InspectCacheRead {
417 fn get_aggregated_for_config(
418 &self,
419 key: &JobKey,
420 config: &Config,
421 ) -> Result<Option<serde_json::Value>, InspectCacheError>;
422 fn latest_aggregate_any_hash(
423 &self,
424 category: InspectCategory,
425 ) -> Result<Option<serde_json::Value>, InspectCacheError>;
426 fn contribution_freshness(
427 &self,
428 category: InspectCategory,
429 ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError>;
430 fn load_tier2_contributions(
431 &self,
432 category: InspectCategory,
433 ) -> Result<Vec<ContributionRecord>, InspectCacheError>;
434 fn contribution_set_hash_for_config(
435 &self,
436 category: InspectCategory,
437 config: &Config,
438 ) -> Result<String, InspectCacheError>;
439 fn last_full_run(&self, category: InspectCategory) -> Result<Option<i64>, InspectCacheError>;
440}
441
442impl InspectCache {
443 pub fn open(inspect_dir: PathBuf, project_root: PathBuf) -> Result<Self, InspectCacheError> {
444 let project_key = crate::path_identity::project_scope_key(&project_root);
445 let project_inspect_dir = project_inspect_dir(inspect_dir.clone(), &project_key);
446 let Some(writer_lease) =
447 acquire_writer_lease(&project_inspect_dir, &project_key, &project_root)?
448 else {
449 return match Self::open_readonly(inspect_dir, project_root.clone())? {
450 Some(cache) => Ok(cache.into_inner()),
451 None => Self::borrow_only_empty(project_inspect_dir, project_root, project_key),
452 };
453 };
454 let inspect_dir = project_inspect_dir;
455 if !writer_lease.verify().map_err(InspectCacheError::from)? {
456 return Err(InspectCacheError::Io(std::io::Error::other(
457 "inspect writer lease epoch changed before opening cache",
458 )));
459 }
460 std::fs::create_dir_all(&inspect_dir)?;
461 let (sqlite_path, generation, needs_publish) =
462 resolve_or_create_inspect_target(&inspect_dir, &project_key);
463 let conn = Connection::open(&sqlite_path)?;
464 configure_connection(&conn)?;
465 if !writer_lease.verify().map_err(InspectCacheError::from)? {
466 return Err(InspectCacheError::Io(std::io::Error::other(
467 "inspect writer lease epoch changed before schema initialization",
468 )));
469 }
470 initialize_schema(&conn)?;
471 if needs_publish {
472 if !writer_lease.verify().map_err(InspectCacheError::from)? {
473 return Err(InspectCacheError::Io(std::io::Error::other(
474 "inspect writer lease epoch changed before pointer publish",
475 )));
476 }
477 publish_inspect_pointer(
478 &inspect_dir,
479 &project_key,
480 generation.as_deref().unwrap_or_default(),
481 )?;
482 }
483 Ok(Self::from_connection(
484 project_root,
485 project_key,
486 sqlite_path,
487 Some(writer_lease),
488 None,
489 conn,
490 ))
491 }
492
493 pub fn open_readonly(
494 inspect_dir: PathBuf,
495 project_root: PathBuf,
496 ) -> Result<Option<ReadonlyInspectCache>, InspectCacheError> {
497 let project_key = crate::path_identity::project_scope_key(&project_root);
498 let inspect_dir = project_inspect_dir(inspect_dir, &project_key);
499 let Some((sqlite_path, generation)) = resolve_inspect_target(&inspect_dir, &project_key)
500 else {
501 return Ok(None);
502 };
503 let conn = open_readonly_connection(&sqlite_path)?;
504 let marker_label = generation.as_deref().unwrap_or("legacy");
505 let read_marker = crate::root_cache::ReadMarker::create(&inspect_dir, marker_label)?;
506 Ok(Some(ReadonlyInspectCache::from_inner(
507 Self::from_connection(
508 project_root,
509 project_key,
510 sqlite_path,
511 None,
512 Some(read_marker),
513 conn,
514 ),
515 )))
516 }
517
518 fn borrow_only_empty(
519 inspect_dir: PathBuf,
520 project_root: PathBuf,
521 project_key: String,
522 ) -> Result<Self, InspectCacheError> {
523 let conn = Connection::open_in_memory()?;
524 initialize_schema(&conn)?;
525 conn.pragma_update(None, "query_only", true)?;
526 Ok(Self::from_connection(
527 project_root,
528 project_key.clone(),
529 inspect_dir.join(format!("{project_key}.borrow-only")),
530 None,
531 None,
532 conn,
533 ))
534 }
535
536 fn from_connection(
537 project_root: PathBuf,
538 project_key: String,
539 sqlite_path: PathBuf,
540 writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
541 read_marker: Option<crate::root_cache::ReadMarker>,
542 conn: Connection,
543 ) -> Self {
544 Self {
545 project_root,
546 project_key,
547 sqlite_path,
548 writer_lease,
549 read_marker,
550 conn: Mutex::new(conn),
551 memory: RwLock::new(HashMap::new()),
552 }
553 }
554
555 pub fn project_root(&self) -> &Path {
556 &self.project_root
557 }
558
559 pub fn project_key(&self) -> &str {
560 &self.project_key
561 }
562
563 pub fn sqlite_path(&self) -> &Path {
564 &self.sqlite_path
565 }
566
567 pub fn writer_epoch_for_test(&self) -> Option<&str> {
568 self.writer_lease.as_ref().map(|lease| lease.epoch())
569 }
570
571 fn verify_writer_lease(&self) -> Result<(), InspectCacheError> {
572 let Some(lease) = self.writer_lease.as_ref() else {
573 return Err(InspectCacheError::Io(std::io::Error::other(
574 "inspect cache opened read-only; write API is unavailable",
575 )));
576 };
577 if lease.verify()? {
578 Ok(())
579 } else {
580 Err(InspectCacheError::Io(std::io::Error::other(format!(
581 "inspect writer lease for key {} lost epoch {}; aborting write",
582 lease.key(),
583 lease.epoch()
584 ))))
585 }
586 }
587
588 fn refresh_read_marker(&self) -> Result<(), InspectCacheError> {
589 if let Some(marker) = self.read_marker.as_ref() {
590 marker.touch_if_due()?;
591 }
592 Ok(())
593 }
594
595 pub fn store_aggregated(
596 &self,
597 key: JobKey,
598 payload: serde_json::Value,
599 ) -> Result<(), InspectCacheError> {
600 self.verify_writer_lease()?;
601 self.store_memory_aggregate(key, payload, None)
602 }
603
604 fn store_memory_aggregate(
605 &self,
606 key: JobKey,
607 payload: serde_json::Value,
608 contribution_set_hash: Option<String>,
609 ) -> Result<(), InspectCacheError> {
610 self.memory
611 .write()
612 .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
613 .insert(
614 key,
615 MemoryAggregate {
616 payload,
617 generated_at: unix_seconds_now(),
618 contribution_set_hash,
619 },
620 );
621 Ok(())
622 }
623
624 pub fn get_aggregated(
625 &self,
626 key: &JobKey,
627 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
628 self.get_aggregated_with_config(key, None)
629 }
630
631 pub fn get_aggregated_for_config(
632 &self,
633 key: &JobKey,
634 config: &Config,
635 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
636 self.get_aggregated_with_config(key, Some(config))
637 }
638
639 fn get_aggregated_with_config(
640 &self,
641 key: &JobKey,
642 config: Option<&Config>,
643 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
644 self.refresh_read_marker()?;
645 if !key.category.is_tier2() {
646 return Ok(self
647 .memory
648 .read()
649 .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
650 .get(key)
651 .map(|entry| entry.payload.clone()));
652 }
653
654 let current_hash = {
655 let conn = self
656 .conn
657 .lock()
658 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
659 contribution_set_hash_with_conn(
660 &conn,
661 key.category,
662 &self.project_key,
663 &self.project_root,
664 config,
665 )?
666 };
667
668 let memory_entry = {
669 self.memory
670 .read()
671 .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
672 .get(key)
673 .cloned()
674 };
675 if let Some(entry) = memory_entry {
676 if entry.contribution_set_hash.as_deref() == Some(current_hash.as_str()) {
677 return Ok(Some(entry.payload));
678 }
679 self.memory
680 .write()
681 .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
682 .remove(key);
683 }
684
685 let payload = {
686 let conn = self
687 .conn
688 .lock()
689 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
690 conn.query_row(
691 "SELECT aggregate FROM tier2_aggregates \
692 WHERE category = ?1 AND project_key = ?2 AND contribution_set_hash = ?3",
693 params![key.category.as_str(), self.project_key, current_hash],
694 |row| row.get::<_, Vec<u8>>(0),
695 )
696 .optional()?
697 };
698
699 match payload {
700 Some(bytes) => {
701 let value = serde_json::from_slice::<serde_json::Value>(&bytes)?;
702 self.store_memory_aggregate(key.clone(), value.clone(), Some(current_hash))?;
703 Ok(Some(value))
704 }
705 None => Ok(None),
706 }
707 }
708
709 pub fn store_tier2_result(
710 &self,
711 key: JobKey,
712 scanned_files: &[PathBuf],
713 contributions: &[FileContribution],
714 aggregate: serde_json::Value,
715 ) -> Result<(), InspectCacheError> {
716 self.store_tier2_result_with_config(key, scanned_files, contributions, aggregate, None)
717 }
718
719 pub fn store_tier2_result_for_config(
720 &self,
721 key: JobKey,
722 scanned_files: &[PathBuf],
723 contributions: &[FileContribution],
724 aggregate: serde_json::Value,
725 config: &Config,
726 ) -> Result<(), InspectCacheError> {
727 self.store_tier2_result_with_config(
728 key,
729 scanned_files,
730 contributions,
731 aggregate,
732 Some(config),
733 )
734 }
735
736 fn store_tier2_result_with_config(
737 &self,
738 key: JobKey,
739 scanned_files: &[PathBuf],
740 contributions: &[FileContribution],
741 aggregate: serde_json::Value,
742 config: Option<&Config>,
743 ) -> Result<(), InspectCacheError> {
744 if !key.category.is_tier2() {
745 self.store_aggregated(key, aggregate)?;
746 return Ok(());
747 }
748
749 self.verify_writer_lease()?;
750 let now = unix_seconds_now();
751 let mut conn = self
752 .conn
753 .lock()
754 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
755 let tx = conn.transaction()?;
756
757 let scanned_relative = scanned_files
758 .iter()
759 .map(|path| relative_string(&self.project_root, path))
760 .collect::<BTreeSet<_>>();
761 let existing = existing_contribution_paths(&tx, key.category, &self.project_key)?;
762 for file_path in existing {
763 if !scanned_relative.contains(&file_path) {
764 tx.execute(
765 "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
766 params![key.category.as_str(), self.project_key, file_path],
767 )?;
768 }
769 }
770
771 for contribution in contributions {
772 let file_path = relative_string(&self.project_root, &contribution.file_path);
773 let blob = serde_json::to_vec(&contribution_with_type_ref_names(
774 contribution.contribution.clone(),
775 &contribution.type_ref_names,
776 ))?;
777 tx.execute(
778 "INSERT INTO tier2_contributions \
779 (category, project_key, file_path, file_mtime_ns, file_size, file_hash, contribution, generated_at) \
780 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
781 ON CONFLICT(category, project_key, file_path) DO UPDATE SET \
782 file_mtime_ns = excluded.file_mtime_ns, \
783 file_size = excluded.file_size, \
784 file_hash = excluded.file_hash, \
785 contribution = excluded.contribution, \
786 generated_at = excluded.generated_at",
787 params![
788 contribution.category.as_str(),
789 self.project_key,
790 file_path,
791 system_time_to_ns(contribution.freshness.mtime),
792 contribution.freshness.size as i64,
793 hash_to_hex(contribution.freshness.content_hash),
794 blob,
795 now,
796 ],
797 )?;
798 }
799
800 let contribution_set_hash = contribution_set_hash_with_conn(
801 &tx,
802 key.category,
803 &self.project_key,
804 &self.project_root,
805 config,
806 )?;
807 let aggregate_blob = serde_json::to_vec(&aggregate)?;
808 tx.execute(
809 "INSERT INTO tier2_aggregates \
810 (category, project_key, contribution_set_hash, aggregate, generated_at) \
811 VALUES (?1, ?2, ?3, ?4, ?5) \
812 ON CONFLICT(category, project_key) DO UPDATE SET \
813 contribution_set_hash = excluded.contribution_set_hash, \
814 aggregate = excluded.aggregate, \
815 generated_at = excluded.generated_at",
816 params![
817 key.category.as_str(),
818 self.project_key,
819 contribution_set_hash,
820 aggregate_blob,
821 now,
822 ],
823 )?;
824 tx.execute(
825 "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3) \
826 ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
827 params![key.category.as_str(), self.project_key, now],
828 )?;
829 tx.commit()?;
830
831 self.store_memory_aggregate(key, aggregate, Some(contribution_set_hash))
832 }
833
834 pub(crate) fn apply_contribution_updates_for_config(
835 &self,
836 category: InspectCategory,
837 updates: Tier2ContributionUpdates,
838 config: &Config,
839 ) -> Result<(String, InspectDbTimings), InspectCacheError> {
840 self.apply_contribution_updates_with_config(category, updates, Some(config))
841 }
842
843 fn apply_contribution_updates_with_config(
844 &self,
845 category: InspectCategory,
846 updates: Tier2ContributionUpdates,
847 config: Option<&Config>,
848 ) -> Result<(String, InspectDbTimings), InspectCacheError> {
849 self.verify_writer_lease()?;
850 let now = unix_seconds_now();
851 let lock_started = Instant::now();
852 let mut conn = self
853 .conn
854 .lock()
855 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
856 let mut timings = InspectDbTimings {
857 lock_wait: lock_started.elapsed(),
858 ..InspectDbTimings::default()
859 };
860 let transaction_started = Instant::now();
861 let tx = conn.transaction()?;
862
863 for relative_file in updates.deletes {
864 tx.execute(
865 "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
866 params![
867 category.as_str(),
868 self.project_key,
869 relative_file.to_string_lossy().to_string()
870 ],
871 )?;
872 }
873
874 for (relative_file, freshness) in updates.metadata_updates {
875 tx.execute(
876 "UPDATE tier2_contributions \
877 SET file_mtime_ns = ?4, file_size = ?5, file_hash = ?6 \
878 WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
879 params![
880 category.as_str(),
881 self.project_key,
882 relative_file.to_string_lossy().to_string(),
883 system_time_to_ns(freshness.mtime),
884 freshness.size as i64,
885 hash_to_hex(freshness.content_hash),
886 ],
887 )?;
888 }
889
890 for contribution in updates.upserts {
891 let file_path = relative_string(&self.project_root, &contribution.file_path);
892 let blob = serde_json::to_vec(&contribution_with_type_ref_names(
893 contribution.contribution.clone(),
894 &contribution.type_ref_names,
895 ))?;
896 tx.execute(
897 "INSERT INTO tier2_contributions \
898 (category, project_key, file_path, file_mtime_ns, file_size, file_hash, contribution, generated_at) \
899 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
900 ON CONFLICT(category, project_key, file_path) DO UPDATE SET \
901 file_mtime_ns = excluded.file_mtime_ns, \
902 file_size = excluded.file_size, \
903 file_hash = excluded.file_hash, \
904 contribution = excluded.contribution, \
905 generated_at = excluded.generated_at",
906 params![
907 contribution.category.as_str(),
908 self.project_key,
909 file_path,
910 system_time_to_ns(contribution.freshness.mtime),
911 contribution.freshness.size as i64,
912 hash_to_hex(contribution.freshness.content_hash),
913 blob,
914 now,
915 ],
916 )?;
917 }
918
919 let contribution_set_hash = contribution_set_hash_with_conn(
920 &tx,
921 category,
922 &self.project_key,
923 &self.project_root,
924 config,
925 )?;
926 tx.commit()?;
927 timings.transaction = transaction_started.elapsed();
928
929 self.memory
930 .write()
931 .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
932 .remove(&JobKey::for_project_category(category));
933
934 Ok((contribution_set_hash, timings))
935 }
936
937 pub(crate) fn load_aggregate_if_hash_matches(
938 &self,
939 category: InspectCategory,
940 contribution_set_hash: &str,
941 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
942 self.refresh_read_marker()?;
943 let payload = {
944 let conn = self
945 .conn
946 .lock()
947 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
948 conn.query_row(
949 "SELECT aggregate FROM tier2_aggregates \
950 WHERE category = ?1 AND project_key = ?2 AND contribution_set_hash = ?3",
951 params![category.as_str(), self.project_key, contribution_set_hash],
952 |row| row.get::<_, Vec<u8>>(0),
953 )
954 .optional()?
955 };
956
957 match payload {
958 Some(bytes) => {
959 let value = serde_json::from_slice::<serde_json::Value>(&bytes)?;
960 self.store_memory_aggregate(
961 JobKey::for_project_category(category),
962 value.clone(),
963 Some(contribution_set_hash.to_string()),
964 )?;
965 Ok(Some(value))
966 }
967 None => Ok(None),
968 }
969 }
970
971 pub(crate) fn latest_aggregate_any_hash(
972 &self,
973 category: InspectCategory,
974 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
975 self.refresh_read_marker()?;
976 let payload = {
977 let conn = self
978 .conn
979 .lock()
980 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
981 conn.query_row(
982 "SELECT aggregate FROM tier2_aggregates \
983 WHERE category = ?1 AND project_key = ?2 \
984 ORDER BY generated_at DESC LIMIT 1",
985 params![category.as_str(), self.project_key],
986 |row| row.get::<_, Vec<u8>>(0),
987 )
988 .optional()?
989 };
990
991 match payload {
992 Some(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)
993 .map(Some)
994 .map_err(InspectCacheError::from),
995 None => Ok(None),
996 }
997 }
998
999 pub(crate) fn touch_tier2_last_full_run(
1000 &self,
1001 category: InspectCategory,
1002 ) -> Result<i64, InspectCacheError> {
1003 self.verify_writer_lease()?;
1004 let mut conn = self
1005 .conn
1006 .lock()
1007 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1008 let tx = conn.transaction()?;
1009 let previous = tx
1010 .query_row(
1011 "SELECT last_full_run FROM tier2_meta WHERE category = ?1 AND project_key = ?2",
1012 params![category.as_str(), self.project_key],
1013 |row| row.get::<_, i64>(0),
1014 )
1015 .optional()?;
1016 let now = unix_seconds_now();
1017 let last_full_run = previous.map_or(now, |previous| now.max(previous.saturating_add(1)));
1018 tx.execute(
1019 "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3) ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
1020 params![category.as_str(), self.project_key, last_full_run],
1021 )?;
1022 tx.commit()?;
1023 Ok(last_full_run)
1024 }
1025
1026 pub(crate) fn store_tier2_aggregate(
1027 &self,
1028 key: JobKey,
1029 contribution_set_hash: &str,
1030 aggregate: serde_json::Value,
1031 ) -> Result<(), InspectCacheError> {
1032 if !key.category.is_tier2() {
1033 self.store_aggregated(key, aggregate)?;
1034 return Ok(());
1035 }
1036
1037 self.verify_writer_lease()?;
1038 let now = unix_seconds_now();
1039 let aggregate_blob = serde_json::to_vec(&aggregate)?;
1040 let mut conn = self
1041 .conn
1042 .lock()
1043 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1044 let tx = conn.transaction()?;
1045 tx.execute(
1046 "INSERT INTO tier2_aggregates \
1047 (category, project_key, contribution_set_hash, aggregate, generated_at) \
1048 VALUES (?1, ?2, ?3, ?4, ?5) \
1049 ON CONFLICT(category, project_key) DO UPDATE SET \
1050 contribution_set_hash = excluded.contribution_set_hash, \
1051 aggregate = excluded.aggregate, \
1052 generated_at = excluded.generated_at",
1053 params![
1054 key.category.as_str(),
1055 self.project_key,
1056 contribution_set_hash,
1057 aggregate_blob,
1058 now,
1059 ],
1060 )?;
1061 tx.execute(
1062 "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3) \
1063 ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
1064 params![key.category.as_str(), self.project_key, now],
1065 )?;
1066 tx.commit()?;
1067
1068 self.store_memory_aggregate(key, aggregate, Some(contribution_set_hash.to_string()))
1069 }
1070
1071 pub fn load_tier2_contributions(
1072 &self,
1073 category: InspectCategory,
1074 ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1075 self.refresh_read_marker()?;
1076 let conn = self
1077 .conn
1078 .lock()
1079 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1080 let mut stmt = conn.prepare(
1081 "SELECT file_path, file_mtime_ns, file_size, file_hash, contribution \
1082 FROM tier2_contributions \
1083 WHERE category = ?1 AND project_key = ?2 \
1084 ORDER BY file_path ASC",
1085 )?;
1086 let rows = stmt.query_map(params![category.as_str(), self.project_key], |row| {
1087 let file_path: String = row.get(0)?;
1088 let mtime_ns: i64 = row.get(1)?;
1089 let file_size: i64 = row.get(2)?;
1090 let file_hash: String = row.get(3)?;
1091 let contribution: Vec<u8> = row.get(4)?;
1092 Ok((file_path, mtime_ns, file_size, file_hash, contribution))
1093 })?;
1094
1095 let mut records = Vec::new();
1096 for row in rows {
1097 let (file_path, mtime_ns, file_size, file_hash, contribution) = row?;
1098 let contribution: serde_json::Value = serde_json::from_slice(&contribution)?;
1099 let type_ref_names = type_ref_names_from_contribution(&contribution);
1100 records.push(ContributionRecord {
1101 category,
1102 file_path: PathBuf::from(file_path),
1103 freshness: FileFreshness {
1104 mtime: ns_to_system_time(mtime_ns),
1105 size: file_size.max(0) as u64,
1106 content_hash: hash_from_hex(&file_hash)?,
1107 },
1108 contribution,
1109 type_ref_names,
1110 });
1111 }
1112 Ok(records)
1113 }
1114
1115 pub fn delete_tier2_contribution(
1116 &self,
1117 category: InspectCategory,
1118 relative_file: &Path,
1119 ) -> Result<(), InspectCacheError> {
1120 self.verify_writer_lease()?;
1121 let conn = self
1122 .conn
1123 .lock()
1124 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1125 conn.execute(
1126 "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
1127 params![
1128 category.as_str(),
1129 self.project_key,
1130 relative_file.to_string_lossy().to_string()
1131 ],
1132 )?;
1133 Ok(())
1134 }
1135
1136 pub fn update_content_fresh_metadata(
1137 &self,
1138 category: InspectCategory,
1139 relative_file: &Path,
1140 freshness: &FileFreshness,
1141 ) -> Result<(), InspectCacheError> {
1142 self.verify_writer_lease()?;
1143 let conn = self
1144 .conn
1145 .lock()
1146 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1147 conn.execute(
1148 "UPDATE tier2_contributions \
1149 SET file_mtime_ns = ?4, file_size = ?5, file_hash = ?6 \
1150 WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
1151 params![
1152 category.as_str(),
1153 self.project_key,
1154 relative_file.to_string_lossy().to_string(),
1155 system_time_to_ns(freshness.mtime),
1156 freshness.size as i64,
1157 hash_to_hex(freshness.content_hash),
1158 ],
1159 )?;
1160 Ok(())
1161 }
1162
1163 pub(crate) fn contribution_freshness(
1164 &self,
1165 category: InspectCategory,
1166 ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1167 self.refresh_read_marker()?;
1168 let conn = self
1169 .conn
1170 .lock()
1171 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1172 let mut stmt = conn.prepare(
1173 "SELECT file_path, file_mtime_ns, file_size, file_hash \
1174 FROM tier2_contributions \
1175 WHERE category = ?1 AND project_key = ?2 \
1176 ORDER BY file_path ASC",
1177 )?;
1178 let rows = stmt.query_map(params![category.as_str(), self.project_key], |row| {
1179 Ok((
1180 row.get::<_, String>(0)?,
1181 row.get::<_, i64>(1)?,
1182 row.get::<_, i64>(2)?,
1183 row.get::<_, String>(3)?,
1184 ))
1185 })?;
1186
1187 let mut records = Vec::new();
1188 for row in rows {
1189 let (file_path, mtime_ns, file_size, file_hash) = row?;
1190 records.push((
1191 PathBuf::from(file_path),
1192 FileFreshness {
1193 mtime: ns_to_system_time(mtime_ns),
1194 size: file_size.max(0) as u64,
1195 content_hash: hash_from_hex(&file_hash)?,
1196 },
1197 ));
1198 }
1199 Ok(records)
1200 }
1201
1202 pub fn contribution_set_hash(
1203 &self,
1204 category: InspectCategory,
1205 ) -> Result<String, InspectCacheError> {
1206 self.contribution_set_hash_with_config(category, None)
1207 }
1208
1209 pub fn contribution_set_hash_for_config(
1210 &self,
1211 category: InspectCategory,
1212 config: &Config,
1213 ) -> Result<String, InspectCacheError> {
1214 self.contribution_set_hash_with_config(category, Some(config))
1215 }
1216
1217 fn contribution_set_hash_with_config(
1218 &self,
1219 category: InspectCategory,
1220 config: Option<&Config>,
1221 ) -> Result<String, InspectCacheError> {
1222 self.refresh_read_marker()?;
1223 let conn = self
1224 .conn
1225 .lock()
1226 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1227 contribution_set_hash_with_conn(
1228 &conn,
1229 category,
1230 &self.project_key,
1231 &self.project_root,
1232 config,
1233 )
1234 }
1235
1236 pub fn last_full_run(
1237 &self,
1238 category: InspectCategory,
1239 ) -> Result<Option<i64>, InspectCacheError> {
1240 self.refresh_read_marker()?;
1241 let conn = self
1242 .conn
1243 .lock()
1244 .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1245 conn.query_row(
1246 "SELECT last_full_run FROM tier2_meta WHERE category = ?1 AND project_key = ?2",
1247 params![category.as_str(), self.project_key],
1248 |row| row.get::<_, i64>(0),
1249 )
1250 .optional()
1251 .map_err(InspectCacheError::from)
1252 }
1253
1254 pub fn memory_generated_at(&self, key: &JobKey) -> Result<Option<i64>, InspectCacheError> {
1255 self.refresh_read_marker()?;
1256 Ok(self
1257 .memory
1258 .read()
1259 .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
1260 .get(key)
1261 .map(|entry| entry.generated_at))
1262 }
1263}
1264
1265impl ReadonlyInspectCache {
1266 fn from_inner(inner: InspectCache) -> Self {
1267 Self { inner }
1268 }
1269
1270 fn into_inner(self) -> InspectCache {
1271 self.inner
1272 }
1273
1274 pub fn project_root(&self) -> &Path {
1275 self.inner.project_root()
1276 }
1277
1278 pub fn project_key(&self) -> &str {
1279 self.inner.project_key()
1280 }
1281
1282 pub fn sqlite_path(&self) -> &Path {
1283 self.inner.sqlite_path()
1284 }
1285
1286 pub fn get_aggregated_for_config(
1287 &self,
1288 key: &JobKey,
1289 config: &Config,
1290 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1291 self.inner.get_aggregated_for_config(key, config)
1292 }
1293
1294 pub fn latest_aggregate_any_hash(
1295 &self,
1296 category: InspectCategory,
1297 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1298 self.inner.latest_aggregate_any_hash(category)
1299 }
1300
1301 pub fn contribution_freshness(
1302 &self,
1303 category: InspectCategory,
1304 ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1305 self.inner.contribution_freshness(category)
1306 }
1307
1308 pub fn load_tier2_contributions(
1309 &self,
1310 category: InspectCategory,
1311 ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1312 self.inner.load_tier2_contributions(category)
1313 }
1314
1315 pub fn contribution_set_hash_for_config(
1316 &self,
1317 category: InspectCategory,
1318 config: &Config,
1319 ) -> Result<String, InspectCacheError> {
1320 self.inner
1321 .contribution_set_hash_for_config(category, config)
1322 }
1323
1324 pub fn last_full_run(
1325 &self,
1326 category: InspectCategory,
1327 ) -> Result<Option<i64>, InspectCacheError> {
1328 self.inner.last_full_run(category)
1329 }
1330}
1331
1332impl InspectCacheRead for InspectCache {
1333 fn get_aggregated_for_config(
1334 &self,
1335 key: &JobKey,
1336 config: &Config,
1337 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1338 InspectCache::get_aggregated_for_config(self, key, config)
1339 }
1340 fn latest_aggregate_any_hash(
1341 &self,
1342 category: InspectCategory,
1343 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1344 InspectCache::latest_aggregate_any_hash(self, category)
1345 }
1346 fn contribution_freshness(
1347 &self,
1348 category: InspectCategory,
1349 ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1350 InspectCache::contribution_freshness(self, category)
1351 }
1352 fn load_tier2_contributions(
1353 &self,
1354 category: InspectCategory,
1355 ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1356 InspectCache::load_tier2_contributions(self, category)
1357 }
1358 fn contribution_set_hash_for_config(
1359 &self,
1360 category: InspectCategory,
1361 config: &Config,
1362 ) -> Result<String, InspectCacheError> {
1363 InspectCache::contribution_set_hash_for_config(self, category, config)
1364 }
1365 fn last_full_run(&self, category: InspectCategory) -> Result<Option<i64>, InspectCacheError> {
1366 InspectCache::last_full_run(self, category)
1367 }
1368}
1369
1370impl<T: InspectCacheRead + ?Sized> InspectCacheRead for Arc<T> {
1371 fn get_aggregated_for_config(
1372 &self,
1373 key: &JobKey,
1374 config: &Config,
1375 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1376 (**self).get_aggregated_for_config(key, config)
1377 }
1378 fn latest_aggregate_any_hash(
1379 &self,
1380 category: InspectCategory,
1381 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1382 (**self).latest_aggregate_any_hash(category)
1383 }
1384 fn contribution_freshness(
1385 &self,
1386 category: InspectCategory,
1387 ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1388 (**self).contribution_freshness(category)
1389 }
1390 fn load_tier2_contributions(
1391 &self,
1392 category: InspectCategory,
1393 ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1394 (**self).load_tier2_contributions(category)
1395 }
1396 fn contribution_set_hash_for_config(
1397 &self,
1398 category: InspectCategory,
1399 config: &Config,
1400 ) -> Result<String, InspectCacheError> {
1401 (**self).contribution_set_hash_for_config(category, config)
1402 }
1403 fn last_full_run(&self, category: InspectCategory) -> Result<Option<i64>, InspectCacheError> {
1404 (**self).last_full_run(category)
1405 }
1406}
1407
1408impl InspectCacheRead for ReadonlyInspectCache {
1409 fn get_aggregated_for_config(
1410 &self,
1411 key: &JobKey,
1412 config: &Config,
1413 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1414 self.get_aggregated_for_config(key, config)
1415 }
1416 fn latest_aggregate_any_hash(
1417 &self,
1418 category: InspectCategory,
1419 ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1420 self.latest_aggregate_any_hash(category)
1421 }
1422 fn contribution_freshness(
1423 &self,
1424 category: InspectCategory,
1425 ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1426 self.contribution_freshness(category)
1427 }
1428 fn load_tier2_contributions(
1429 &self,
1430 category: InspectCategory,
1431 ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1432 self.load_tier2_contributions(category)
1433 }
1434 fn contribution_set_hash_for_config(
1435 &self,
1436 category: InspectCategory,
1437 config: &Config,
1438 ) -> Result<String, InspectCacheError> {
1439 self.contribution_set_hash_for_config(category, config)
1440 }
1441 fn last_full_run(&self, category: InspectCategory) -> Result<Option<i64>, InspectCacheError> {
1442 self.last_full_run(category)
1443 }
1444}
1445
1446fn project_inspect_dir(inspect_dir: PathBuf, project_key: &str) -> PathBuf {
1447 if inspect_dir
1448 .file_name()
1449 .and_then(|name| name.to_str())
1450 .is_some_and(|name| name == project_key)
1451 {
1452 inspect_dir
1453 } else {
1454 inspect_dir.join(project_key)
1455 }
1456}
1457
1458fn resolve_or_create_inspect_target(
1459 inspect_dir: &Path,
1460 project_key: &str,
1461) -> (PathBuf, Option<String>, bool) {
1462 if let Some((path, generation)) = resolve_inspect_target(inspect_dir, project_key) {
1463 return (path, generation, false);
1464 }
1465 let generation = inspect_generation_file_name(project_key);
1466 (inspect_dir.join(&generation), Some(generation), true)
1467}
1468
1469fn resolve_inspect_target(
1470 inspect_dir: &Path,
1471 project_key: &str,
1472) -> Option<(PathBuf, Option<String>)> {
1473 for _ in 0..5 {
1474 if let Some(generation) = read_inspect_pointer(inspect_dir, project_key) {
1475 let path = inspect_dir.join(&generation);
1476 if path.is_file() {
1477 return Some((path, Some(generation)));
1478 }
1479 std::thread::sleep(Duration::from_millis(5));
1480 continue;
1481 }
1482 let legacy = inspect_legacy_sqlite_path(inspect_dir, project_key);
1483 return legacy.is_file().then_some((legacy, None));
1484 }
1485 None
1486}
1487
1488fn inspect_generation_file_name(project_key: &str) -> String {
1489 format!(
1490 "{project_key}.g{}.{}.sqlite",
1491 now_nanos(),
1492 std::process::id()
1493 )
1494}
1495
1496fn inspect_pointer_path(inspect_dir: &Path, project_key: &str) -> PathBuf {
1497 inspect_dir.join(format!("{project_key}.current"))
1498}
1499
1500fn inspect_legacy_sqlite_path(inspect_dir: &Path, project_key: &str) -> PathBuf {
1501 inspect_dir.join(format!("{project_key}.sqlite"))
1502}
1503
1504fn read_inspect_pointer(inspect_dir: &Path, project_key: &str) -> Option<String> {
1505 let text = std::fs::read_to_string(inspect_pointer_path(inspect_dir, project_key)).ok()?;
1506 let name = text.trim();
1507 (!name.is_empty()).then(|| name.to_string())
1508}
1509
1510fn publish_inspect_pointer(
1511 inspect_dir: &Path,
1512 project_key: &str,
1513 generation: &str,
1514) -> Result<(), InspectCacheError> {
1515 let pointer = inspect_pointer_path(inspect_dir, project_key);
1516 let tmp = inspect_dir.join(format!(
1517 "{project_key}.current.tmp.{}.{}",
1518 std::process::id(),
1519 now_nanos()
1520 ));
1521 {
1522 use std::io::Write as _;
1523 let mut file = std::fs::File::create(&tmp)?;
1524 file.write_all(generation.as_bytes())?;
1525 file.write_all(b"\n")?;
1526 file.sync_all()?;
1527 }
1528 if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
1529 let _ = std::fs::remove_file(&tmp);
1530 return Err(error.into());
1531 }
1532 crate::fs_lock::sync_parent(&pointer);
1533 gc_old_inspect_generations(inspect_dir, project_key, generation);
1534 Ok(())
1535}
1536
1537fn gc_old_inspect_generations(inspect_dir: &Path, project_key: &str, current: &str) {
1538 let Ok(entries) = std::fs::read_dir(inspect_dir) else {
1539 return;
1540 };
1541 let prefix = format!("{project_key}.g");
1542 for entry in entries.flatten() {
1543 let name = entry.file_name().to_string_lossy().to_string();
1544 if name == current || !name.starts_with(&prefix) || !name.ends_with(".sqlite") {
1545 continue;
1546 }
1547 let path = entry.path();
1548 let _ = std::fs::remove_file(&path);
1549 for suffix in INSPECT_SQLITE_SIDECAR_SUFFIXES {
1550 let _ = std::fs::remove_file(PathBuf::from(format!("{}{suffix}", path.display())));
1551 }
1552 }
1553}
1554
1555fn acquire_writer_lease(
1556 inspect_dir: &Path,
1557 project_key: &str,
1558 project_root: &Path,
1559) -> Result<Option<Arc<crate::root_cache::WriterLease>>, InspectCacheError> {
1560 crate::root_cache::WriterLease::acquire_shared(
1561 crate::root_cache::RootCacheDomain::Inspect,
1562 inspect_dir,
1563 project_key,
1564 project_root,
1565 )
1566 .map_err(|error| InspectCacheError::Io(std::io::Error::other(error.to_string())))
1567}
1568
1569fn open_readonly_connection(path: &Path) -> Result<Connection, InspectCacheError> {
1570 let uri = sqlite_readonly_uri(path);
1571 let conn = Connection::open_with_flags(
1572 &uri,
1573 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
1574 )?;
1575 conn.busy_timeout(reader_busy_timeout())?;
1576 conn.execute_batch("PRAGMA query_only=ON;")?;
1577 Ok(conn)
1578}
1579
1580fn reader_busy_timeout() -> Duration {
1581 let jitter = (now_nanos() % 500) as u64;
1582 Duration::from_millis(250 + jitter)
1583}
1584
1585fn sqlite_readonly_uri(path: &Path) -> String {
1586 let raw = path.to_string_lossy().replace('\\', "/");
1587 let encoded = percent_encode_sqlite_uri_path(&raw);
1588 if raw.starts_with('/') {
1589 format!("file://{encoded}?mode=ro")
1590 } else if raw.as_bytes().get(1) == Some(&b':') {
1591 format!("file:///{encoded}?mode=ro")
1592 } else {
1593 format!("file:{encoded}?mode=ro")
1594 }
1595}
1596
1597fn percent_encode_sqlite_uri_path(path: &str) -> String {
1598 let mut encoded = String::with_capacity(path.len());
1599 for byte in path.bytes() {
1600 match byte {
1601 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
1602 encoded.push(byte as char)
1603 }
1604 _ => encoded.push_str(&format!("%{byte:02X}")),
1605 }
1606 }
1607 encoded
1608}
1609
1610fn configure_connection(conn: &Connection) -> Result<(), InspectCacheError> {
1611 conn.pragma_update(None, "journal_mode", "WAL")?;
1612 conn.pragma_update(None, "synchronous", "NORMAL")?;
1615 conn.pragma_update(None, "busy_timeout", 5_000)?;
1616 Ok(())
1617}
1618
1619fn initialize_schema(conn: &Connection) -> Result<(), InspectCacheError> {
1620 conn.execute_batch(
1621 "CREATE TABLE IF NOT EXISTS tier2_contributions (
1622 category TEXT NOT NULL,
1623 project_key TEXT NOT NULL,
1624 file_path TEXT NOT NULL,
1625 file_mtime_ns INTEGER NOT NULL,
1626 file_size INTEGER NOT NULL,
1627 file_hash TEXT NOT NULL,
1628 contribution BLOB NOT NULL,
1629 generated_at INTEGER NOT NULL,
1630 PRIMARY KEY (category, project_key, file_path)
1631 );
1632
1633 CREATE TABLE IF NOT EXISTS tier2_aggregates (
1634 category TEXT NOT NULL,
1635 project_key TEXT NOT NULL,
1636 contribution_set_hash TEXT NOT NULL,
1637 aggregate BLOB NOT NULL,
1638 generated_at INTEGER NOT NULL,
1639 PRIMARY KEY (category, project_key)
1640 );
1641
1642 CREATE TABLE IF NOT EXISTS tier2_meta (
1643 category TEXT NOT NULL,
1644 project_key TEXT NOT NULL,
1645 last_full_run INTEGER NOT NULL,
1646 PRIMARY KEY (category, project_key)
1647 );",
1648 )?;
1649 Ok(())
1650}
1651
1652fn existing_contribution_paths(
1653 conn: &Connection,
1654 category: InspectCategory,
1655 project_key: &str,
1656) -> Result<Vec<String>, InspectCacheError> {
1657 let mut stmt = conn.prepare(
1658 "SELECT file_path FROM tier2_contributions WHERE category = ?1 AND project_key = ?2",
1659 )?;
1660 let rows = stmt.query_map(params![category.as_str(), project_key], |row| {
1661 row.get::<_, String>(0)
1662 })?;
1663 rows.collect::<Result<Vec<_>, _>>()
1664 .map_err(InspectCacheError::from)
1665}
1666
1667fn contribution_set_hash_with_conn(
1668 conn: &Connection,
1669 category: InspectCategory,
1670 project_key: &str,
1671 project_root: &Path,
1672 config: Option<&Config>,
1673) -> Result<String, InspectCacheError> {
1674 let mut stmt = conn.prepare(
1675 "SELECT file_path, file_hash FROM tier2_contributions \
1676 WHERE category = ?1 AND project_key = ?2 ORDER BY file_path ASC",
1677 )?;
1678 let rows = stmt.query_map(params![category.as_str(), project_key], |row| {
1679 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1680 })?;
1681
1682 let mut hasher = blake3::Hasher::new();
1683 hasher.update(b"tier2-contributions\0");
1684 hasher.update(&TIER2_CONTRIBUTION_CACHE_VERSION.to_le_bytes());
1685 hasher.update(b"\0");
1686 for row in rows {
1687 let (file_path, file_hash) = row?;
1688 hasher.update(file_path.as_bytes());
1689 hasher.update(b"\0");
1690 hasher.update(file_hash.as_bytes());
1691 hasher.update(b"\0");
1692 }
1693 update_manifest_fingerprint_hash(&mut hasher, project_root)?;
1694 if matches!(
1695 category,
1696 InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
1697 ) {
1698 update_resolver_config_fingerprint_hash(&mut hasher, project_root)?;
1699 }
1700 update_inspect_config_fingerprint_hash(&mut hasher, category, config);
1701 Ok(hasher.finalize().to_hex().to_string())
1702}
1703
1704fn update_inspect_config_fingerprint_hash(
1705 hasher: &mut blake3::Hasher,
1706 category: InspectCategory,
1707 config: Option<&Config>,
1708) {
1709 if category != InspectCategory::Duplicates {
1710 return;
1711 }
1712
1713 hasher.update(b"inspect.duplicates.expected_mirrors\0");
1714 let Some(config) = config else {
1715 return;
1716 };
1717 for pair in &config.inspect.duplicates.expected_mirrors {
1718 hasher.update(pair[0].as_bytes());
1719 hasher.update(b"\0");
1720 hasher.update(pair[1].as_bytes());
1721 hasher.update(b"\0");
1722 }
1723}
1724
1725fn update_resolver_config_fingerprint_hash(
1726 hasher: &mut blake3::Hasher,
1727 project_root: &Path,
1728) -> Result<(), InspectCacheError> {
1729 let manifest_root =
1730 fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1731 hasher.update(b"ts-js-resolver-configs\0");
1732 for config in collect_resolver_config_dependency_files(project_root) {
1733 let relative_path = config
1734 .strip_prefix(&manifest_root)
1735 .unwrap_or(config.as_path())
1736 .to_string_lossy()
1737 .replace('\\', "/");
1738 let content_hash = blake3::hash(&fs::read(&config)?);
1739 hasher.update(relative_path.as_bytes());
1740 hasher.update(b"\0");
1741 hasher.update(content_hash.as_bytes());
1742 hasher.update(b"\0");
1743 }
1744 Ok(())
1745}
1746
1747struct ResolverConfigDependency {
1748 path: PathBuf,
1749 follow_extends: bool,
1750}
1751
1752impl ResolverConfigDependency {
1753 fn resolver_config(path: PathBuf) -> Self {
1754 Self {
1755 path,
1756 follow_extends: true,
1757 }
1758 }
1759
1760 fn hashed_file(path: PathBuf) -> Self {
1761 Self {
1762 path,
1763 follow_extends: false,
1764 }
1765 }
1766}
1767
1768fn collect_resolver_config_dependency_files(project_root: &Path) -> BTreeSet<PathBuf> {
1769 let mut configs = walk_resolver_config_files(project_root);
1770 let mut pending = configs.iter().cloned().collect::<Vec<_>>();
1771 let mut queued = configs.clone();
1772 while let Some(config) = pending.pop() {
1773 for dependency in resolver_config_extends_targets(&config, project_root) {
1774 let ResolverConfigDependency {
1775 path,
1776 follow_extends,
1777 } = dependency;
1778 configs.insert(path.clone());
1779 if follow_extends && queued.insert(path.clone()) {
1780 pending.push(path);
1781 }
1782 }
1783 }
1784 configs
1785}
1786
1787fn walk_resolver_config_files(project_root: &Path) -> BTreeSet<PathBuf> {
1788 let walker = ignore::WalkBuilder::new(project_root)
1789 .hidden(true)
1790 .git_ignore(true)
1791 .git_global(true)
1792 .git_exclude(true)
1793 .add_custom_ignore_filename(".aftignore")
1794 .filter_entry(|entry| {
1795 let name = entry.file_name().to_string_lossy();
1796 if entry
1797 .file_type()
1798 .is_some_and(|file_type| file_type.is_dir())
1799 {
1800 return !matches!(
1801 name.as_ref(),
1802 "node_modules"
1803 | "target"
1804 | "venv"
1805 | ".venv"
1806 | ".git"
1807 | "__pycache__"
1808 | ".tox"
1809 | "dist"
1810 | "build"
1811 );
1812 }
1813 true
1814 })
1815 .build();
1816
1817 walker
1818 .filter_map(Result::ok)
1819 .filter(|entry| {
1820 entry
1821 .file_type()
1822 .is_some_and(|file_type| file_type.is_file())
1823 })
1824 .map(|entry| entry.into_path())
1825 .filter(|path| {
1826 path.file_name()
1827 .and_then(|name| name.to_str())
1828 .is_some_and(is_resolver_config_file_name)
1829 })
1830 .filter_map(canonical_file_path)
1831 .collect()
1832}
1833
1834fn is_resolver_config_file_name(name: &str) -> bool {
1835 name == "tsconfig.json"
1836 || name == "jsconfig.json"
1837 || ((name.starts_with("tsconfig.") || name.starts_with("jsconfig."))
1838 && name.ends_with(".json"))
1839}
1840
1841fn resolver_config_extends_targets(
1842 config: &Path,
1843 project_root: &Path,
1844) -> Vec<ResolverConfigDependency> {
1845 let Ok(source) = fs::read_to_string(config) else {
1846 return Vec::new();
1847 };
1848 let Ok(value) = parse_resolver_config_json(&source) else {
1849 return Vec::new();
1850 };
1851
1852 let mut specs = Vec::new();
1853 collect_extends_specs(value.get("extends"), &mut specs);
1854 specs
1855 .into_iter()
1856 .flat_map(|spec| resolve_resolver_config_extends(config, project_root, spec))
1857 .collect()
1858}
1859
1860fn parse_resolver_config_json(source: &str) -> Result<serde_json::Value, serde_json::Error> {
1861 serde_json::from_str(source).or_else(|_| serde_json::from_str(&strip_jsonc(source)))
1862}
1863
1864fn collect_extends_specs<'a>(value: Option<&'a serde_json::Value>, specs: &mut Vec<&'a str>) {
1865 match value {
1866 Some(serde_json::Value::String(spec)) => specs.push(spec),
1867 Some(serde_json::Value::Array(values)) => {
1868 for value in values {
1869 collect_extends_specs(Some(value), specs);
1870 }
1871 }
1872 _ => {}
1873 }
1874}
1875
1876fn resolve_resolver_config_extends(
1877 config: &Path,
1878 project_root: &Path,
1879 spec: &str,
1880) -> Vec<ResolverConfigDependency> {
1881 let config_dir = config.parent().unwrap_or(project_root);
1882 let spec_path = Path::new(spec);
1883 if spec_path.is_absolute() || spec.starts_with('.') {
1884 return resolver_config_extends_target(&config_dir.join(spec_path))
1885 .map(ResolverConfigDependency::resolver_config)
1886 .into_iter()
1887 .collect();
1888 }
1889
1890 node_modules_resolver_config_dependencies(config_dir, project_root, spec)
1891}
1892
1893fn node_modules_resolver_config_dependencies(
1894 config_dir: &Path,
1895 project_root: &Path,
1896 spec: &str,
1897) -> Vec<ResolverConfigDependency> {
1898 let boundary = fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1899 let config_dir = fs::canonicalize(config_dir).unwrap_or_else(|_| config_dir.to_path_buf());
1900 let enforce_project_boundary = config_dir.starts_with(&boundary);
1901 let is_bare_package = is_bare_package_extends_spec(spec);
1902 let mut dependencies = Vec::new();
1903 for ancestor in config_dir.ancestors() {
1904 let ancestor = fs::canonicalize(ancestor).unwrap_or_else(|_| ancestor.to_path_buf());
1905 if enforce_project_boundary && !ancestor.starts_with(&boundary) {
1906 break;
1907 }
1908 let package_dir = ancestor.join("node_modules").join(spec);
1909 let mut ancestor_dependencies = Vec::new();
1910 if is_bare_package {
1911 if let Some(mut package_dependencies) =
1912 package_json_resolver_config_dependencies(&package_dir)
1913 {
1914 let has_resolver_config = package_dependencies
1915 .iter()
1916 .any(|dependency| dependency.follow_extends);
1917 ancestor_dependencies.append(&mut package_dependencies);
1918 if has_resolver_config {
1919 dependencies.extend(ancestor_dependencies);
1920 return dependencies;
1921 }
1922 }
1923 }
1924 if let Some(target) = resolver_config_extends_target(&package_dir) {
1925 ancestor_dependencies.push(ResolverConfigDependency::resolver_config(target));
1926 dependencies.extend(ancestor_dependencies);
1927 return dependencies;
1928 }
1929 dependencies.extend(ancestor_dependencies);
1930 }
1931 dependencies
1932}
1933
1934fn package_json_resolver_config_dependencies(
1935 package_dir: &Path,
1936) -> Option<Vec<ResolverConfigDependency>> {
1937 let package_json = canonical_file_path(package_dir.join("package.json"))?;
1938 let package_root = package_json
1939 .parent()
1940 .map(Path::to_path_buf)
1941 .unwrap_or_else(|| package_dir.to_path_buf());
1942 let mut dependencies = vec![ResolverConfigDependency::hashed_file(package_json.clone())];
1943
1944 let Ok(source) = fs::read_to_string(&package_json) else {
1945 return Some(dependencies);
1946 };
1947 let Ok(value) = parse_resolver_config_json(&source) else {
1948 return Some(dependencies);
1949 };
1950 let selected_config = value
1951 .get("tsconfig")
1952 .and_then(serde_json::Value::as_str)
1953 .map(str::trim)
1954 .filter(|value| !value.is_empty())
1955 .unwrap_or("tsconfig.json");
1956 if let Some(target) = resolver_config_extends_target(&package_root.join(selected_config)) {
1957 dependencies.push(ResolverConfigDependency::resolver_config(target));
1958 }
1959
1960 Some(dependencies)
1961}
1962
1963fn is_bare_package_extends_spec(spec: &str) -> bool {
1964 let mut parts = spec.split('/').filter(|part| !part.is_empty());
1965 let Some(first) = parts.next() else {
1966 return false;
1967 };
1968 if first.starts_with('@') {
1969 parts.next().is_some() && parts.next().is_none()
1970 } else {
1971 parts.next().is_none()
1972 }
1973}
1974
1975fn resolver_config_extends_target(base: &Path) -> Option<PathBuf> {
1976 resolver_config_extends_candidates(base)
1977 .into_iter()
1978 .find_map(canonical_file_path)
1979}
1980
1981fn resolver_config_extends_candidates(base: &Path) -> Vec<PathBuf> {
1982 let mut candidates = vec![base.to_path_buf()];
1983 if base.extension().is_none() {
1984 candidates.push(base.with_extension("json"));
1985 candidates.push(base.join("tsconfig.json"));
1986 }
1987 candidates
1988}
1989
1990fn canonical_file_path(path: PathBuf) -> Option<PathBuf> {
1991 if !path.is_file() {
1992 return None;
1993 }
1994 Some(fs::canonicalize(&path).unwrap_or(path))
1995}
1996
1997fn update_manifest_fingerprint_hash(
1998 hasher: &mut blake3::Hasher,
1999 project_root: &Path,
2000) -> Result<(), InspectCacheError> {
2001 let manifest_root =
2002 fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
2003 hasher.update(b"entry-point-manifests\0");
2004 for manifest in super::entry_points::collect_entry_point_manifests(project_root) {
2005 let relative_path = manifest
2006 .strip_prefix(&manifest_root)
2007 .unwrap_or(manifest.as_path())
2008 .to_string_lossy()
2009 .replace('\\', "/");
2010 let content_hash = blake3::hash(&fs::read(&manifest)?);
2011 hasher.update(relative_path.as_bytes());
2012 hasher.update(b"\0");
2013 hasher.update(content_hash.as_bytes());
2014 hasher.update(b"\0");
2015 }
2016 Ok(())
2017}
2018
2019fn relative_string(project_root: &Path, path: &Path) -> String {
2020 if let Ok(relative) = path.strip_prefix(project_root) {
2021 return relative.to_string_lossy().to_string();
2022 }
2023
2024 if let (Ok(canonical_root), Ok(canonical_path)) =
2025 (fs::canonicalize(project_root), fs::canonicalize(path))
2026 {
2027 if let Ok(relative) = canonical_path.strip_prefix(canonical_root) {
2028 return relative.to_string_lossy().to_string();
2029 }
2030 }
2031
2032 path.to_string_lossy().to_string()
2033}
2034
2035fn system_time_to_ns(time: SystemTime) -> i64 {
2036 let nanos = time
2037 .duration_since(UNIX_EPOCH)
2038 .unwrap_or_else(|_| Duration::from_secs(0))
2039 .as_nanos();
2040 nanos.min(i64::MAX as u128) as i64
2041}
2042
2043fn ns_to_system_time(value: i64) -> SystemTime {
2044 UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
2045}
2046
2047fn hash_to_hex(hash: blake3::Hash) -> String {
2048 hash.to_hex().to_string()
2049}
2050
2051fn hash_from_hex(value: &str) -> Result<blake3::Hash, InspectCacheError> {
2052 if value.len() != 64 {
2053 return Err(InspectCacheError::InvalidHash(value.to_string()));
2054 }
2055 let mut bytes = [0u8; 32];
2056 for (index, chunk) in value.as_bytes().chunks(2).enumerate() {
2057 let hex = std::str::from_utf8(chunk)
2058 .map_err(|_| InspectCacheError::InvalidHash(value.to_string()))?;
2059 bytes[index] = u8::from_str_radix(hex, 16)
2060 .map_err(|_| InspectCacheError::InvalidHash(value.to_string()))?;
2061 }
2062 Ok(blake3::Hash::from_bytes(bytes))
2063}
2064
2065fn unix_seconds_now() -> i64 {
2066 SystemTime::now()
2067 .duration_since(UNIX_EPOCH)
2068 .unwrap_or_else(|_| Duration::from_secs(0))
2069 .as_secs()
2070 .min(i64::MAX as u64) as i64
2071}
2072
2073fn now_nanos() -> u128 {
2074 SystemTime::now()
2075 .duration_since(UNIX_EPOCH)
2076 .unwrap_or(Duration::ZERO)
2077 .as_nanos()
2078}
2079
2080#[cfg(test)]
2081mod tests {
2082 use super::*;
2083 use std::cell::Cell;
2084 use std::fs;
2085 use std::path::{Path, PathBuf};
2086
2087 fn collect_freshness(path: &Path) -> FileFreshness {
2088 crate::cache_freshness::collect(path).unwrap()
2089 }
2090
2091 #[test]
2092 fn sqlite_readonly_uri_percent_encodes_windows_paths() {
2093 assert_eq!(
2094 sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
2095 "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
2096 );
2097 }
2098
2099 #[test]
2100 fn inspect_cache_writer_uses_normal_synchronous_mode() {
2101 let temp = tempfile::tempdir().unwrap();
2102 let project_root = temp.path().join("checkout");
2103 fs::create_dir_all(&project_root).unwrap();
2104 let cache = InspectCache::open(temp.path().join("inspect"), project_root).unwrap();
2105 let conn = cache.conn.lock().unwrap();
2106 let synchronous: i64 = conn
2107 .query_row("PRAGMA synchronous", [], |row| row.get(0))
2108 .unwrap();
2109 assert_eq!(synchronous, 1, "SQLite NORMAL mode is numeric value 1");
2110 }
2111
2112 #[test]
2113 fn inspect_cache_publishes_pointer_generation_and_reopens_after_crash_redo() {
2114 let temp = tempfile::tempdir().unwrap();
2115 let project_root = temp.path().join("checkout");
2116 fs::create_dir_all(&project_root).unwrap();
2117 let inspect_dir = temp.path().join("inspect");
2118 let project_key = crate::path_identity::project_scope_key(&project_root);
2119
2120 fs::create_dir_all(inspect_dir.join("leftover-nonempty-dir")).unwrap();
2121 let cache = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
2122 assert!(cache.sqlite_path().is_file());
2123 assert_ne!(
2124 cache.sqlite_path(),
2125 inspect_dir
2126 .join(&project_key)
2127 .join(format!("{project_key}.sqlite"))
2128 );
2129 let pointer = inspect_dir
2130 .join(&project_key)
2131 .join(format!("{project_key}.current"));
2132 let generation = fs::read_to_string(&pointer).unwrap();
2133 assert_eq!(
2134 inspect_dir.join(&project_key).join(generation.trim()),
2135 cache.sqlite_path()
2136 );
2137 drop(cache);
2138
2139 let reopened = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
2140 assert_eq!(
2141 inspect_dir.join(&project_key).join(generation.trim()),
2142 reopened.sqlite_path()
2143 );
2144 let readonly = InspectCache::open_readonly(inspect_dir, project_root)
2145 .unwrap()
2146 .expect("pointer-published inspect cache should reopen read-only");
2147 assert_eq!(readonly.inner.sqlite_path(), reopened.sqlite_path());
2148 }
2149
2150 #[test]
2151 fn tier1_file_memo_evicts_lru_and_keeps_recent_hits() {
2152 let temp = tempfile::tempdir().unwrap();
2153 let memo = Tier1FileMemo::<usize>::default();
2154 let mut paths = Vec::with_capacity(TIER1_FILE_MEMO_MAX_ENTRIES);
2155
2156 for index in 0..TIER1_FILE_MEMO_MAX_ENTRIES {
2157 let path = temp.path().join(format!("file-{index}.txt"));
2158 fs::write(&path, index.to_string()).unwrap();
2159 let value =
2160 memo.get_or_insert_with(&path, |path| (Some(collect_freshness(path)), index));
2161 assert_eq!(value, index);
2162 paths.push(path);
2163 }
2164
2165 let recent_path = paths[0].clone();
2166 let recent_value = memo.get_or_insert_with(&recent_path, |_| {
2167 panic!("recently inserted entry should hit before eviction")
2168 });
2169 assert_eq!(recent_value, 0);
2170
2171 let evicting_path = temp.path().join("new-file.txt");
2172 fs::write(&evicting_path, "new").unwrap();
2173 let evicting_value = memo.get_or_insert_with(&evicting_path, |path| {
2174 (Some(collect_freshness(path)), TIER1_FILE_MEMO_MAX_ENTRIES)
2175 });
2176 assert_eq!(evicting_value, TIER1_FILE_MEMO_MAX_ENTRIES);
2177
2178 let state = memo.state.lock().unwrap();
2179 assert_eq!(state.entries.len(), TIER1_FILE_MEMO_MAX_ENTRIES);
2180 assert!(state.entries.contains_key(&recent_path));
2181 assert!(state.entries.contains_key(&evicting_path));
2182 assert!(!state.entries.contains_key(&paths[1]));
2183 drop(state);
2184
2185 let recent_value = memo.get_or_insert_with(&recent_path, |_| {
2186 panic!("recently used entry should survive eviction")
2187 });
2188 assert_eq!(recent_value, 0);
2189 }
2190
2191 #[test]
2192 fn tier1_file_memo_repeated_touches_keep_lazy_lru_bounded() {
2193 let temp = tempfile::tempdir().unwrap();
2194 let memo = Tier1FileMemo::<usize>::default();
2195 let mut paths = Vec::with_capacity(TIER1_FILE_MEMO_MAX_ENTRIES);
2196
2197 for index in 0..TIER1_FILE_MEMO_MAX_ENTRIES {
2198 let path = temp.path().join(format!("file-{index}.txt"));
2199 fs::write(&path, index.to_string()).unwrap();
2200 memo.get_or_insert_with(&path, |path| (Some(collect_freshness(path)), index));
2201 paths.push(path);
2202 }
2203
2204 for _ in 0..(TIER1_FILE_MEMO_MAX_ENTRIES * 3) {
2205 let value = memo.get_or_insert_with(&paths[0], |_| {
2206 panic!("hot entry should stay cached while it is repeatedly touched")
2207 });
2208 assert_eq!(value, 0);
2209 }
2210
2211 let evicting_path = temp.path().join("new-file.txt");
2212 fs::write(&evicting_path, "new").unwrap();
2213 memo.get_or_insert_with(&evicting_path, |path| {
2214 (Some(collect_freshness(path)), TIER1_FILE_MEMO_MAX_ENTRIES)
2215 });
2216
2217 let state = memo.state.lock().unwrap();
2218 assert_eq!(state.entries.len(), TIER1_FILE_MEMO_MAX_ENTRIES);
2219 assert!(state.entries.contains_key(&paths[0]));
2220 assert!(state.entries.contains_key(&evicting_path));
2221 assert!(!state.entries.contains_key(&paths[1]));
2222 assert!(
2223 state.lru.len() <= TIER1_FILE_MEMO_MAX_ENTRIES * 2,
2224 "lazy LRU queue should be compacted instead of growing without bound"
2225 );
2226 }
2227
2228 #[test]
2229 fn tier1_file_memo_reuses_fresh_entries_and_rescans_stale_files() {
2230 let temp = tempfile::tempdir().unwrap();
2231 let path = temp.path().join("memo.txt");
2232 fs::write(&path, "first").unwrap();
2233
2234 let memo = Tier1FileMemo::<String>::default();
2235 let scans = Cell::new(0);
2236
2237 let first = memo.get_or_insert_with(&path, |path| {
2238 scans.set(scans.get() + 1);
2239 (Some(collect_freshness(path)), "first scan".to_string())
2240 });
2241 assert_eq!(first, "first scan");
2242 assert_eq!(scans.get(), 1);
2243
2244 let unchanged =
2245 memo.get_or_insert_with(&path, |_| panic!("unchanged file should reuse Tier-1 memo"));
2246 assert_eq!(unchanged, "first scan");
2247 assert_eq!(scans.get(), 1);
2248
2249 fs::write(&path, "changed file contents").unwrap();
2250 let changed = memo.get_or_insert_with(&path, |path| {
2251 scans.set(scans.get() + 1);
2252 (Some(collect_freshness(path)), "second scan".to_string())
2253 });
2254 assert_eq!(changed, "second scan");
2255 assert_eq!(scans.get(), 2);
2256
2257 let fresh_after_rescan = memo.get_or_insert_with(&path, |_| {
2258 panic!("rescanned file should reuse refreshed Tier-1 memo")
2259 });
2260 assert_eq!(fresh_after_rescan, "second scan");
2261 assert_eq!(scans.get(), 2);
2262 }
2263
2264 #[derive(serde::Deserialize, serde::Serialize)]
2265 struct RoundTripContributionRecord {
2266 category: String,
2267 file_path: PathBuf,
2268 contribution: serde_json::Value,
2269 type_ref_names: BTreeSet<String>,
2270 }
2271
2272 impl From<&ContributionRecord> for RoundTripContributionRecord {
2273 fn from(record: &ContributionRecord) -> Self {
2274 Self {
2275 category: record.category.as_str().to_string(),
2276 file_path: record.file_path.clone(),
2277 contribution: record.contribution.clone(),
2278 type_ref_names: record.type_ref_names.clone(),
2279 }
2280 }
2281 }
2282
2283 #[test]
2284 fn contribution_record_round_trip_preserves_dead_code_liveness_metadata() {
2285 let temp = tempfile::tempdir().unwrap();
2286 let project_root = temp.path().join("project");
2287 let inspect_dir = temp.path().join("inspect");
2288 let source = project_root.join("src/lib.ts");
2289 fs::create_dir_all(source.parent().unwrap()).unwrap();
2290 fs::write(&source, "export interface Widget { id: string }\n").unwrap();
2291
2292 let cache = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
2293 let contribution = FileContribution::new(
2294 InspectCategory::DeadCode,
2295 source.clone(),
2296 collect_freshness(&source),
2297 serde_json::json!({
2298 "file": "src/lib.ts",
2299 "exports": [{
2300 "symbol": "Widget",
2301 "kind": "interface",
2302 "line": 1,
2303 "is_type_like": true,
2304 "is_entry_point": false,
2305 }],
2306 "internal_calls": [],
2307 "liveness_roots": [],
2308 "dispatched_method_names": ["render"],
2309 "macro_token_refs": [{
2310 "caller_symbol": "render",
2311 "line": 1,
2312 "name": "Widget",
2313 "shape": "struct"
2314 }],
2315 "type_ref_names": ["Widget"],
2316 }),
2317 )
2318 .with_type_ref_names(["Widget".to_string()]);
2319 cache
2320 .store_tier2_result(
2321 JobKey::for_project_category(InspectCategory::DeadCode),
2322 std::slice::from_ref(&source),
2323 &[contribution],
2324 serde_json::json!({ "count": 0, "items": [] }),
2325 )
2326 .unwrap();
2327 drop(cache);
2328
2329 let cache = InspectCache::open(inspect_dir, project_root).unwrap();
2330 let records = cache
2331 .load_tier2_contributions(InspectCategory::DeadCode)
2332 .unwrap();
2333 assert_eq!(records.len(), 1);
2334
2335 let serialized =
2336 serde_json::to_vec(&RoundTripContributionRecord::from(&records[0])).unwrap();
2337 let decoded: RoundTripContributionRecord = serde_json::from_slice(&serialized).unwrap();
2338 assert_eq!(decoded.category, InspectCategory::DeadCode.as_str());
2339 assert_eq!(decoded.contribution["dispatched_method_names"][0], "render");
2340 assert_eq!(decoded.contribution["type_ref_names"][0], "Widget");
2341 assert_eq!(
2342 decoded.contribution["macro_token_refs"][0]["shape"],
2343 "struct"
2344 );
2345 assert!(decoded.type_ref_names.contains("Widget"));
2346 assert_eq!(
2347 decoded.contribution["exports"][0]["is_type_like"].as_bool(),
2348 Some(true)
2349 );
2350 assert_eq!(TIER2_CONTRIBUTION_CACHE_VERSION, 29);
2351 }
2352
2353 #[test]
2354 fn duplicate_expected_mirrors_participate_in_aggregate_cache_hash() {
2355 let temp = tempfile::tempdir().unwrap();
2356 let project_root = temp.path().join("project");
2357 fs::create_dir_all(&project_root).unwrap();
2358 let left = project_root.join("plugin/a.ts");
2359 let right = project_root.join("pi-plugin/a.ts");
2360 fs::create_dir_all(left.parent().unwrap()).unwrap();
2361 fs::create_dir_all(right.parent().unwrap()).unwrap();
2362 fs::write(&left, "export const value = 1;\n").unwrap();
2363 fs::write(&right, "export const value = 1;\n").unwrap();
2364
2365 let cache = InspectCache::open(temp.path().join("inspect"), project_root.clone()).unwrap();
2366 let contributions = vec![
2367 FileContribution::new(
2368 InspectCategory::Duplicates,
2369 left.clone(),
2370 collect_freshness(&left),
2371 serde_json::json!({ "file": "plugin/a.ts", "line_count": 1, "fragments": [] }),
2372 ),
2373 FileContribution::new(
2374 InspectCategory::Duplicates,
2375 right.clone(),
2376 collect_freshness(&right),
2377 serde_json::json!({ "file": "pi-plugin/a.ts", "line_count": 1, "fragments": [] }),
2378 ),
2379 ];
2380 let config = Config::default();
2381 cache
2382 .store_tier2_result_for_config(
2383 JobKey::for_project_category(InspectCategory::Duplicates),
2384 &[left.clone(), right.clone()],
2385 &contributions,
2386 serde_json::json!({ "count": 0, "items": [] }),
2387 &config,
2388 )
2389 .unwrap();
2390
2391 let without_mirrors = cache
2392 .contribution_set_hash_for_config(InspectCategory::Duplicates, &config)
2393 .unwrap();
2394 let mut mirror_config = Config::default();
2395 mirror_config.inspect.duplicates.expected_mirrors =
2396 vec![["plugin/**".to_string(), "pi-plugin/**".to_string()]];
2397 let with_mirrors = cache
2398 .contribution_set_hash_for_config(InspectCategory::Duplicates, &mirror_config)
2399 .unwrap();
2400
2401 assert_ne!(without_mirrors, with_mirrors);
2402 }
2403}
2404
2405#[cfg(test)]
2406mod memory_estimate_tests {
2407 use super::*;
2408
2409 #[test]
2410 fn inspect_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
2411 let root = tempfile::tempdir().expect("project root");
2412 let project_root = std::fs::canonicalize(root.path()).expect("canonical project root");
2413 let storage = tempfile::tempdir().expect("inspect storage");
2414 let cache = InspectCache::open(storage.path().to_path_buf(), project_root)
2415 .expect("open inspect cache");
2416 assert_eq!(cache.estimated_memory().estimated_bytes, Some(0));
2417
2418 cache
2419 .store_aggregated(
2420 JobKey::for_project_category(InspectCategory::Todos),
2421 serde_json::json!({"count": 1, "items": [{"text": "resident todo"}]}),
2422 )
2423 .expect("store memory aggregate");
2424 let estimate = cache.estimated_memory();
2425 assert!(estimate.estimated_bytes.unwrap() > 0);
2426 assert_eq!(estimate.counts["memory_aggregates"], 1);
2427 assert_eq!(estimate.counts["open_generation_handles"], 1);
2428 }
2429}