1use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::Arc;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14use parking_lot::{Condvar, Mutex};
15
16use crate::config::{Config, IndexKind};
17use crate::db::standing_roots::{self, StandingRootRecord};
18use crate::root_cache::{ArtifactPublishEpoch, WriterLease};
19use crate::scoped_key::{
20 reject_duplicate_artifact_keys, resolve_standing_root, ResolvedStandingRoot,
21};
22
23#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct StandingRootEntry {
28 pub literal_path: String,
29 pub resolved_target: PathBuf,
30 pub resolved_git_toplevel: Option<PathBuf>,
31 pub scoped_relative_path: Option<PathBuf>,
32 pub artifact_key: String,
33 pub indexes: Vec<IndexKind>,
34 pub config_order: usize,
35}
36
37#[derive(Debug)]
38pub enum StandingRootsError {
39 Resolution(crate::scoped_key::ScopedKeyError),
40 Database(standing_roots::StandingRootError),
41 OpenDatabase(crate::db::OpenError),
42 UnknownEntry { literal_path: String },
43}
44
45impl std::fmt::Display for StandingRootsError {
46 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 match self {
48 Self::Resolution(error) => {
49 write!(formatter, "standing root resolution failed: {error}")
50 }
51 Self::Database(error) => write!(formatter, "standing roots database error: {error}"),
52 Self::OpenDatabase(error) => {
53 write!(formatter, "could not open standing roots database: {error}")
54 }
55 Self::UnknownEntry { literal_path } => {
56 write!(
57 formatter,
58 "standing root {literal_path:?} has no active lifecycle"
59 )
60 }
61 }
62 }
63}
64
65impl std::error::Error for StandingRootsError {}
66
67impl From<crate::scoped_key::ScopedKeyError> for StandingRootsError {
68 fn from(error: crate::scoped_key::ScopedKeyError) -> Self {
69 Self::Resolution(error)
70 }
71}
72
73impl From<standing_roots::StandingRootError> for StandingRootsError {
74 fn from(error: standing_roots::StandingRootError) -> Self {
75 Self::Database(error)
76 }
77}
78
79impl From<crate::db::OpenError> for StandingRootsError {
80 fn from(error: crate::db::OpenError) -> Self {
81 Self::OpenDatabase(error)
82 }
83}
84
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct StandingReconcileReport {
87 pub active_entries: Vec<StandingRootEntry>,
88 pub added: Vec<String>,
89 pub removed: Vec<String>,
90 pub replaced: Vec<String>,
91}
92
93#[derive(Clone, Debug, PartialEq, Eq)]
95pub enum StandingRouteError {
96 NoContainingEntry,
97 KindUnavailable {
98 deepest_literal_path: String,
99 deepest_selection: Vec<IndexKind>,
100 },
101 StrictVerificationRequired {
102 literal_path: String,
103 kind: IndexKind,
104 },
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct StandingRoute {
111 pub entry: StandingRootEntry,
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub struct StandingPublicationAdmission {
116 pub admission_epoch: u64,
117 publication_epoch: u64,
118}
119
120pub struct StandingBuildAdmission {
124 lifecycle: Arc<ArtifactLifecycle>,
125 pub publication: StandingPublicationAdmission,
126}
127
128impl StandingBuildAdmission {
129 pub fn checkpoint(&self) -> bool {
132 self.lifecycle.checkpoint()
133 }
134
135 pub fn cancellation_requested(&self) -> bool {
136 self.lifecycle.cancellation_requested()
137 }
138}
139
140impl Drop for StandingBuildAdmission {
141 fn drop(&mut self) {
142 self.lifecycle.finish_build();
143 }
144}
145
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct BindTransition {
148 pub admission_epoch: u64,
149 pub cancelled_builds: usize,
150}
151
152#[derive(Clone, Default)]
156pub struct StandingRoots {
157 inner: Arc<StandingRootsInner>,
158}
159
160#[derive(Default)]
161struct StandingRootsInner {
162 database_path: Mutex<Option<PathBuf>>,
163 state: Mutex<StandingRootsState>,
164}
165
166#[derive(Default)]
167struct StandingRootsState {
168 entries: BTreeMap<String, ManagedEntry>,
169 observed_first_snapshot: bool,
170}
171
172struct ManagedEntry {
173 entry: StandingRootEntry,
174 lifecycle: Arc<ArtifactLifecycle>,
175}
176
177struct ArtifactLifecycle {
178 state: Mutex<ArtifactLifecycleState>,
179 checkpoint_acknowledged: Condvar,
180 admission_epoch: AtomicU64,
181 publication_epoch: ArtifactPublishEpoch,
182}
183
184#[derive(Default)]
185struct ArtifactLifecycleState {
186 standing_admission_open: bool,
187 bind_pending: bool,
188 session_bound: bool,
189 active_builds: usize,
190 cancellation_requested: bool,
191 checkpoint_acknowledged: bool,
192}
193
194impl Default for ArtifactLifecycle {
195 fn default() -> Self {
196 Self {
197 state: Mutex::new(ArtifactLifecycleState {
198 standing_admission_open: true,
199 ..ArtifactLifecycleState::default()
200 }),
201 checkpoint_acknowledged: Condvar::new(),
202 admission_epoch: AtomicU64::new(0),
203 publication_epoch: ArtifactPublishEpoch::default(),
204 }
205 }
206}
207
208impl ArtifactLifecycle {
209 fn admit(self: &Arc<Self>) -> Option<StandingBuildAdmission> {
210 let mut state = self.state.lock();
211 if !state.standing_admission_open || state.bind_pending || state.session_bound {
212 return None;
213 }
214 state.active_builds += 1;
215 Some(StandingBuildAdmission {
216 lifecycle: Arc::clone(self),
217 publication: StandingPublicationAdmission {
218 admission_epoch: self.admission_epoch.load(Ordering::SeqCst),
219 publication_epoch: self.publication_epoch.current(),
220 },
221 })
222 }
223
224 fn revoke_and_mint(&self, bind_pending: bool) -> u64 {
225 let mut state = self.state.lock();
226 state.bind_pending = bind_pending;
227 state.session_bound = bind_pending;
228 state.standing_admission_open = false;
229 state.cancellation_requested = true;
230 state.checkpoint_acknowledged = state.active_builds == 0;
231 let admission_epoch = self
232 .admission_epoch
233 .fetch_add(1, Ordering::SeqCst)
234 .wrapping_add(1);
235 self.publication_epoch.next();
239 if state.checkpoint_acknowledged {
240 self.checkpoint_acknowledged.notify_all();
241 }
242 admission_epoch
243 }
244
245 fn bind_pending(&self) -> bool {
246 self.state.lock().bind_pending
247 }
248
249 fn resume_unbound(&self) {
250 let mut state = self.state.lock();
251 state.bind_pending = false;
252 state.session_bound = false;
253 state.standing_admission_open = true;
254 state.cancellation_requested = false;
255 state.checkpoint_acknowledged = false;
256 }
257
258 fn checkpoint(&self) -> bool {
259 let mut state = self.state.lock();
260 if !state.cancellation_requested {
261 return false;
262 }
263 state.checkpoint_acknowledged = true;
264 self.checkpoint_acknowledged.notify_all();
265 true
266 }
267
268 fn cancellation_requested(&self) -> bool {
269 self.state.lock().cancellation_requested
270 }
271
272 fn finish_build(&self) {
273 let mut state = self.state.lock();
274 state.active_builds = state.active_builds.saturating_sub(1);
275 if state.cancellation_requested && state.active_builds == 0 {
276 state.checkpoint_acknowledged = true;
277 self.checkpoint_acknowledged.notify_all();
278 }
279 }
280
281 fn wait_for_checkpoint_acknowledgement(&self, timeout: Duration) -> bool {
282 let mut state = self.state.lock();
283 if state.checkpoint_acknowledged {
284 return true;
285 }
286 self.checkpoint_acknowledged.wait_for(&mut state, timeout);
287 state.checkpoint_acknowledged
288 }
289}
290
291impl StandingRoots {
292 pub fn reconcile(
296 &self,
297 config: &Config,
298 ) -> Result<StandingReconcileReport, StandingRootsError> {
299 let entries = resolve_entries(config)?;
300 let configured_db_path =
301 crate::bash_background::storage_dir(config.storage_dir.as_deref()).join("aft.db");
302 let db_path = {
306 let mut current = self.inner.database_path.lock();
307 let path = if entries.is_empty() {
308 current.clone().unwrap_or(configured_db_path)
309 } else {
310 configured_db_path
311 };
312 *current = Some(path.clone());
313 path
314 };
315 let mut conn = crate::db::open(&db_path)?;
316
317 let mut state = self.inner.state.lock();
318 let previous_first_snapshot = state.observed_first_snapshot;
319 let previous = std::mem::take(&mut state.entries);
320 let mut next = BTreeMap::new();
321 let mut added = Vec::new();
322 let mut removed = Vec::new();
323 let mut replaced = Vec::new();
324
325 for entry in &entries {
326 let resolved = resolve_standing_root(&entry.literal_path)?;
327 let record = StandingRootRecord {
328 literal_path: entry.literal_path.clone(),
329 resolved_target: resolved.resolved_target,
330 resolved_git_toplevel: resolved.resolved_git_toplevel,
331 scoped_relative_path: resolved.scoped_relative_path,
332 };
333 standing_roots::ensure_standing_root(&mut conn, &record, &entry.indexes)?;
334
335 if let Some(old) = previous.get(&entry.literal_path) {
336 let changed = old.entry.resolved_target != entry.resolved_target
337 || old.entry.resolved_git_toplevel != entry.resolved_git_toplevel
338 || old.entry.scoped_relative_path != entry.scoped_relative_path
339 || old.entry.artifact_key != entry.artifact_key
340 || old.entry.indexes != entry.indexes;
341 if changed {
342 old.lifecycle.revoke_and_mint(false);
343 old.lifecycle.resume_unbound();
344 replaced.push(entry.literal_path.clone());
345 }
346 next.insert(
347 entry.literal_path.clone(),
348 ManagedEntry {
349 entry: entry.clone(),
350 lifecycle: Arc::clone(&old.lifecycle),
351 },
352 );
353 } else {
354 added.push(entry.literal_path.clone());
355 next.insert(
356 entry.literal_path.clone(),
357 ManagedEntry {
358 entry: entry.clone(),
359 lifecycle: Arc::new(ArtifactLifecycle::default()),
360 },
361 );
362 }
363 }
364
365 for (literal_path, old) in &previous {
366 if !next.contains_key(literal_path) {
367 old.lifecycle.revoke_and_mint(false);
368 standing_roots::delete_standing_root(&conn, literal_path)?;
369 removed.push(literal_path.clone());
370 }
371 }
372
373 if !previous_first_snapshot && !entries.is_empty() {
378 for entry in next.values() {
379 mark_kinds_needing_strict_verify(
380 &mut conn,
381 &entry.entry.literal_path,
382 &entry.entry.indexes,
383 )?;
384 }
385 }
386 state.observed_first_snapshot |= !entries.is_empty();
387 state.entries = next;
388
389 Ok(StandingReconcileReport {
390 active_entries: entries,
391 added,
392 removed,
393 replaced,
394 })
395 }
396
397 pub fn entries(&self) -> Vec<StandingRootEntry> {
398 self.inner
399 .state
400 .lock()
401 .entries
402 .values()
403 .map(|entry| entry.entry.clone())
404 .collect()
405 }
406
407 pub fn admit_build(&self, literal_path: &str) -> Option<StandingBuildAdmission> {
410 self.inner
411 .state
412 .lock()
413 .entries
414 .get(literal_path)
415 .and_then(|entry| entry.lifecycle.admit())
416 }
417
418 pub fn begin_case_a_bind(
422 &self,
423 literal_path: &str,
424 ) -> Result<BindTransition, StandingRootsError> {
425 let (lifecycle, indexes) = {
426 let state = self.inner.state.lock();
427 let entry = state.entries.get(literal_path).ok_or_else(|| {
428 StandingRootsError::UnknownEntry {
429 literal_path: literal_path.to_string(),
430 }
431 })?;
432 (Arc::clone(&entry.lifecycle), entry.entry.indexes.clone())
433 };
434 let admission_epoch = lifecycle.revoke_and_mint(true);
435 let mut conn = self.open_database()?;
436 mark_kinds_needing_strict_verify(&mut conn, literal_path, &indexes)?;
437 let cancelled_builds = lifecycle.state.lock().active_builds;
438 Ok(BindTransition {
439 admission_epoch,
440 cancelled_builds,
441 })
442 }
443
444 pub fn wait_for_case_a_checkpoint(
446 &self,
447 literal_path: &str,
448 ) -> Result<bool, StandingRootsError> {
449 let lifecycle = self.lifecycle(literal_path)?;
450 Ok(lifecycle.wait_for_checkpoint_acknowledgement(Duration::from_secs(2)))
451 }
452
453 pub fn resume_after_session(
456 &self,
457 literal_path: &str,
458 session_maintained: &[IndexKind],
459 ) -> Result<(), StandingRootsError> {
460 let (lifecycle, missing) = {
461 let state = self.inner.state.lock();
462 let entry = state.entries.get(literal_path).ok_or_else(|| {
463 StandingRootsError::UnknownEntry {
464 literal_path: literal_path.to_string(),
465 }
466 })?;
467 let missing = entry
468 .entry
469 .indexes
470 .iter()
471 .copied()
472 .filter(|kind| !session_maintained.contains(kind))
473 .collect::<Vec<_>>();
474 (Arc::clone(&entry.lifecycle), missing)
475 };
476 if !lifecycle.bind_pending() {
477 return Ok(());
478 }
479 if !missing.is_empty() {
480 let mut conn = self.open_database()?;
481 mark_kinds_needing_strict_verify(&mut conn, literal_path, &missing)?;
482 }
483 lifecycle.resume_unbound();
484 Ok(())
485 }
486
487 pub fn mark_observation_gap(
491 &self,
492 literal_path: &str,
493 kinds: &[IndexKind],
494 ) -> Result<(), StandingRootsError> {
495 let mut conn = self.open_database()?;
496 mark_kinds_needing_strict_verify(&mut conn, literal_path, kinds)
497 }
498
499 pub fn record_strict_verification(
503 &self,
504 literal_path: &str,
505 kind: IndexKind,
506 ) -> Result<(), StandingRootsError> {
507 let mut conn = self.open_database()?;
508 standing_roots::record_successful_strict_verification(
509 &mut conn,
510 literal_path,
511 kind,
512 now_ms(),
513 )?;
514 Ok(())
515 }
516
517 pub fn route_explicit_path(
521 &self,
522 query_path: &Path,
523 kind: IndexKind,
524 ) -> Result<StandingRoute, StandingRouteError> {
525 let query_path = canonicalize_existing_ancestor(query_path);
526 let mut candidates = self
527 .entries()
528 .into_iter()
529 .filter(|entry| query_path.starts_with(&entry.resolved_target))
530 .collect::<Vec<_>>();
531 candidates.sort_by(|left, right| {
532 path_depth(&right.resolved_target)
533 .cmp(&path_depth(&left.resolved_target))
534 .then_with(|| left.config_order.cmp(&right.config_order))
535 });
536 let Some(deepest) = candidates.first().cloned() else {
537 return Err(StandingRouteError::NoContainingEntry);
538 };
539 let Some(entry) = candidates
540 .into_iter()
541 .find(|entry| entry.indexes.contains(&kind))
542 else {
543 return Err(StandingRouteError::KindUnavailable {
544 deepest_literal_path: deepest.literal_path,
545 deepest_selection: deepest.indexes,
546 });
547 };
548 let conn = self
549 .open_database()
550 .map_err(|_| StandingRouteError::NoContainingEntry)?;
551 if standing_roots::needs_strict_verify(&conn, &entry.literal_path, kind)
552 .ok()
553 .flatten()
554 .unwrap_or(true)
555 {
556 return Err(StandingRouteError::StrictVerificationRequired {
557 literal_path: entry.literal_path,
558 kind,
559 });
560 }
561 Ok(StandingRoute { entry })
562 }
563
564 pub fn publish_if_current<R>(
569 &self,
570 literal_path: &str,
571 admission: StandingPublicationAdmission,
572 writer_lease: &WriterLease,
573 fingerprint_is_current: impl FnOnce() -> bool,
574 generation_is_current: impl FnOnce() -> bool,
575 publish_and_rename: impl FnOnce() -> R,
576 ) -> Result<Option<R>, StandingRootsError> {
577 let lifecycle = self.lifecycle(literal_path)?;
578 Ok(lifecycle
579 .publication_epoch
580 .run_if_current(admission.publication_epoch, || {
581 if lifecycle.admission_epoch.load(Ordering::SeqCst) != admission.admission_epoch
585 || !lifecycle.state.lock().standing_admission_open
586 || !writer_lease.verify().unwrap_or(false)
587 || !fingerprint_is_current()
588 || !generation_is_current()
589 {
590 return None;
591 }
592 Some(publish_and_rename())
593 })
594 .flatten())
595 }
596
597 fn lifecycle(&self, literal_path: &str) -> Result<Arc<ArtifactLifecycle>, StandingRootsError> {
598 self.inner
599 .state
600 .lock()
601 .entries
602 .get(literal_path)
603 .map(|entry| Arc::clone(&entry.lifecycle))
604 .ok_or_else(|| StandingRootsError::UnknownEntry {
605 literal_path: literal_path.to_string(),
606 })
607 }
608
609 fn open_database(&self) -> Result<rusqlite::Connection, StandingRootsError> {
610 let path = self
611 .inner
612 .database_path
613 .lock()
614 .clone()
615 .unwrap_or_else(|| crate::bash_background::storage_dir(None).join("aft.db"));
616 Ok(crate::db::open(&path)?)
617 }
618}
619
620fn resolve_entries(config: &Config) -> Result<Vec<StandingRootEntry>, StandingRootsError> {
621 let resolved = config
622 .index
623 .roots
624 .iter()
625 .map(|root| resolve_standing_root(&root.path))
626 .collect::<Result<Vec<ResolvedStandingRoot>, _>>()?;
627 reject_duplicate_artifact_keys(&resolved)?;
628 Ok(config
629 .index
630 .roots
631 .iter()
632 .zip(resolved)
633 .enumerate()
634 .map(|(config_order, (root, resolved))| StandingRootEntry {
635 literal_path: root.path.clone(),
636 resolved_target: PathBuf::from(resolved.resolved_target),
637 resolved_git_toplevel: resolved.resolved_git_toplevel.map(PathBuf::from),
638 scoped_relative_path: resolved.scoped_relative_path.map(PathBuf::from),
639 artifact_key: resolved.artifact_key,
640 indexes: root.indexes.clone(),
641 config_order,
642 })
643 .collect())
644}
645
646fn mark_kinds_needing_strict_verify(
647 conn: &mut rusqlite::Connection,
648 literal_path: &str,
649 kinds: &[IndexKind],
650) -> Result<(), StandingRootsError> {
651 let tx = conn
652 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
653 .map_err(standing_roots::StandingRootError::from)?;
654 for kind in kinds {
655 let updated = tx
656 .execute(
657 "UPDATE standing_root_freshness
658 SET needs_strict_verify = 1, strict_verified_at = NULL
659 WHERE literal_path = ?1 AND index_kind = ?2",
660 rusqlite::params![literal_path, kind.as_str()],
661 )
662 .map_err(standing_roots::StandingRootError::from)?;
663 if updated != 1 {
664 return Err(StandingRootsError::Database(
665 standing_roots::StandingRootError::MissingFreshnessRow {
666 literal_path: literal_path.to_string(),
667 kind: *kind,
668 },
669 ));
670 }
671 }
672 tx.commit()
673 .map_err(standing_roots::StandingRootError::from)?;
674 Ok(())
675}
676
677fn path_depth(path: &Path) -> usize {
678 path.components().count()
679}
680
681fn canonicalize_existing_ancestor(path: &Path) -> PathBuf {
684 if let Ok(canonical) = std::fs::canonicalize(path) {
685 return canonical;
686 }
687 let mut missing = Vec::new();
688 let mut ancestor = path;
689 while !ancestor.exists() {
690 let Some(name) = ancestor.file_name() else {
691 return path.to_path_buf();
692 };
693 missing.push(name.to_os_string());
694 let Some(parent) = ancestor.parent() else {
695 return path.to_path_buf();
696 };
697 ancestor = parent;
698 }
699 let mut canonical = std::fs::canonicalize(ancestor).unwrap_or_else(|_| ancestor.to_path_buf());
700 for component in missing.iter().rev() {
701 canonical.push(component);
702 }
703 canonical
704}
705
706fn now_ms() -> i64 {
707 SystemTime::now()
708 .duration_since(UNIX_EPOCH)
709 .unwrap_or_default()
710 .as_millis()
711 .try_into()
712 .unwrap_or(i64::MAX)
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use crate::config::{IndexConfig, IndexRootConfig};
719 use tempfile::tempdir;
720
721 fn config(storage: &Path, roots: Vec<IndexRootConfig>) -> Config {
722 Config {
723 storage_dir: Some(storage.to_path_buf()),
724 index: IndexConfig { roots },
725 ..Config::default()
726 }
727 }
728
729 fn root(path: &Path, indexes: Vec<IndexKind>) -> IndexRootConfig {
730 IndexRootConfig {
731 path: path.to_string_lossy().into_owned(),
732 indexes,
733 }
734 }
735
736 #[test]
737 fn reconciliation_marks_restart_and_new_kind_for_strict_verification() {
738 let storage = tempdir().unwrap();
739 let first = tempdir().unwrap();
740 let roots = StandingRoots::default();
741 let mut cfg = config(
742 storage.path(),
743 vec![root(first.path(), vec![IndexKind::Search])],
744 );
745 roots.reconcile(&cfg).unwrap();
746 roots
747 .record_strict_verification(first.path().to_str().unwrap(), IndexKind::Search)
748 .unwrap();
749 cfg.index.roots[0].indexes = vec![IndexKind::Search, IndexKind::Callgraph];
750 let report = roots.reconcile(&cfg).unwrap();
751 assert_eq!(report.replaced, vec![first.path().to_string_lossy()]);
752 let conn = crate::db::open(&storage.path().join("aft.db")).unwrap();
753 assert!(standing_roots::needs_strict_verify(
754 &conn,
755 first.path().to_str().unwrap(),
756 IndexKind::Callgraph
757 )
758 .unwrap()
759 .unwrap());
760 }
761
762 #[test]
763 fn daemon_startup_empty_pass_marks_existing_snapshot_strict_on_first_configured_pass() {
764 let storage = tempdir().unwrap();
765 let root_dir = tempdir().unwrap();
766 let cfg = config(
767 storage.path(),
768 vec![root(root_dir.path(), vec![IndexKind::Search])],
769 );
770 let first = StandingRoots::default();
771 first.reconcile(&cfg).unwrap();
772 first
773 .record_strict_verification(root_dir.path().to_str().unwrap(), IndexKind::Search)
774 .unwrap();
775
776 let restarted = StandingRoots::default();
777 restarted.reconcile(&Config::default()).unwrap();
778 restarted.reconcile(&cfg).unwrap();
779 let conn = crate::db::open(&storage.path().join("aft.db")).unwrap();
780 assert!(standing_roots::needs_strict_verify(
781 &conn,
782 root_dir.path().to_str().unwrap(),
783 IndexKind::Search
784 )
785 .unwrap()
786 .unwrap());
787 }
788
789 #[test]
790 fn maintenance_snapshot_preserves_configuration_and_fixed_kind_order() {
791 let storage = tempdir().unwrap();
792 let first = tempdir().unwrap();
793 let second = tempdir().unwrap();
794 let roots = StandingRoots::default();
795 let cfg = config(
796 storage.path(),
797 vec![
798 root(first.path(), vec![IndexKind::Callgraph, IndexKind::Search]),
799 root(second.path(), vec![IndexKind::Semantic]),
800 ],
801 );
802 let report = roots.reconcile(&cfg).unwrap();
803 let scheduled = report
804 .active_entries
805 .iter()
806 .flat_map(|entry| {
807 IndexKind::ALL
808 .into_iter()
809 .filter(move |kind| entry.indexes.contains(kind))
810 .map(move |kind| (entry.literal_path.clone(), kind))
811 })
812 .collect::<Vec<_>>();
813 assert_eq!(
814 scheduled,
815 vec![
816 (
817 first.path().to_string_lossy().into_owned(),
818 IndexKind::Search
819 ),
820 (
821 first.path().to_string_lossy().into_owned(),
822 IndexKind::Callgraph
823 ),
824 (
825 second.path().to_string_lossy().into_owned(),
826 IndexKind::Semantic
827 ),
828 ]
829 );
830 }
831
832 #[test]
833 fn route_falls_back_to_shallower_entry_and_discloses_it() {
834 let storage = tempdir().unwrap();
835 let outer = tempdir().unwrap();
836 let inner = outer.path().join("inner");
837 std::fs::create_dir(&inner).unwrap();
838 let roots = StandingRoots::default();
839 let cfg = config(
840 storage.path(),
841 vec![
842 root(outer.path(), vec![IndexKind::Search]),
843 root(&inner, vec![IndexKind::Callgraph]),
844 ],
845 );
846 roots.reconcile(&cfg).unwrap();
847 roots
848 .record_strict_verification(outer.path().to_str().unwrap(), IndexKind::Search)
849 .unwrap();
850 let route = roots
851 .route_explicit_path(&inner.join("file.rs"), IndexKind::Search)
852 .unwrap();
853 assert_eq!(route.entry.literal_path, outer.path().to_string_lossy());
854 }
855
856 #[test]
857 fn publication_is_a_noop_after_bind_epoch_revocation() {
858 let storage = tempdir().unwrap();
859 let root_dir = tempdir().unwrap();
860 let roots = StandingRoots::default();
861 let cfg = config(
862 storage.path(),
863 vec![root(root_dir.path(), vec![IndexKind::Search])],
864 );
865 let mut report = roots.reconcile(&cfg).unwrap();
866 let entry = report.active_entries.remove(0);
867 let build = roots.admit_build(&entry.literal_path).unwrap();
868 crate::root_cache::configure_artifact_access(
869 &entry.resolved_target,
870 &entry.artifact_key,
871 false,
872 );
873 let cache_dir = storage.path().join("index").join(&entry.artifact_key);
874 let lease = crate::root_cache::WriterLease::acquire_shared(
875 crate::root_cache::RootCacheDomain::Index,
876 &cache_dir,
877 &entry.artifact_key,
878 &entry.resolved_target,
879 )
880 .unwrap()
881 .unwrap();
882 roots.begin_case_a_bind(&entry.literal_path).unwrap();
883 let published = std::sync::atomic::AtomicBool::new(false);
884 let result = roots
885 .publish_if_current(
886 &entry.literal_path,
887 build.publication,
888 &lease,
889 || true,
890 || true,
891 || published.store(true, Ordering::SeqCst),
892 )
893 .unwrap();
894 assert!(result.is_none());
895 assert!(!published.load(Ordering::SeqCst));
896 }
897
898 #[test]
899 fn publication_fence_holds_epoch_mutex_through_final_rename() {
900 let storage = tempdir().unwrap();
901 let root_dir = tempdir().unwrap();
902 let roots = StandingRoots::default();
903 let cfg = config(
904 storage.path(),
905 vec![root(root_dir.path(), vec![IndexKind::Search])],
906 );
907 let mut report = roots.reconcile(&cfg).unwrap();
908 let entry = report.active_entries.remove(0);
909 let admission = roots.admit_build(&entry.literal_path).unwrap();
910 crate::root_cache::configure_artifact_access(
911 &entry.resolved_target,
912 &entry.artifact_key,
913 false,
914 );
915 let cache_dir = storage.path().join("index").join(&entry.artifact_key);
916 let lease = crate::root_cache::WriterLease::acquire_shared(
917 crate::root_cache::RootCacheDomain::Index,
918 &cache_dir,
919 &entry.artifact_key,
920 &entry.resolved_target,
921 )
922 .unwrap()
923 .unwrap();
924 let (rename_entered_tx, rename_entered_rx) = std::sync::mpsc::channel();
925 let (release_rename_tx, release_rename_rx) = std::sync::mpsc::channel();
926 let publish_roots = roots.clone();
927 let publish_literal = entry.literal_path.clone();
928 let publisher = std::thread::spawn(move || {
929 publish_roots
930 .publish_if_current(
931 &publish_literal,
932 admission.publication,
933 &lease,
934 || true,
935 || true,
936 || {
937 rename_entered_tx.send(()).unwrap();
938 release_rename_rx.recv().unwrap();
939 },
940 )
941 .unwrap()
942 });
943 rename_entered_rx.recv().unwrap();
944 let (bind_done_tx, bind_done_rx) = std::sync::mpsc::channel();
945 let bind_roots = roots.clone();
946 let bind_literal = entry.literal_path.clone();
947 let binder = std::thread::spawn(move || {
948 bind_done_tx
949 .send(bind_roots.begin_case_a_bind(&bind_literal))
950 .unwrap();
951 });
952 assert!(bind_done_rx
953 .recv_timeout(Duration::from_millis(100))
954 .is_err());
955 release_rename_tx.send(()).unwrap();
956 assert!(publisher.join().unwrap().is_some());
957 bind_done_rx
958 .recv_timeout(Duration::from_secs(1))
959 .unwrap()
960 .unwrap();
961 binder.join().unwrap();
962 }
963
964 #[test]
965 fn superseded_snapshot_publication_is_a_noop() {
966 let storage = tempdir().unwrap();
967 let root_dir = tempdir().unwrap();
968 let roots = StandingRoots::default();
969 let mut cfg = config(
970 storage.path(),
971 vec![root(root_dir.path(), vec![IndexKind::Search])],
972 );
973 let mut initial = roots.reconcile(&cfg).unwrap();
974 let entry = initial.active_entries.remove(0);
975 let build = roots.admit_build(&entry.literal_path).unwrap();
976 crate::root_cache::configure_artifact_access(
977 &entry.resolved_target,
978 &entry.artifact_key,
979 false,
980 );
981 cfg.index.roots[0].indexes = vec![IndexKind::Semantic];
982 let report = roots.reconcile(&cfg).unwrap();
983 assert_eq!(report.replaced, vec![entry.literal_path.clone()]);
984 let cache_dir = storage.path().join("index").join(&entry.artifact_key);
985 let lease = crate::root_cache::WriterLease::acquire_shared(
986 crate::root_cache::RootCacheDomain::Index,
987 &cache_dir,
988 &entry.artifact_key,
989 &entry.resolved_target,
990 )
991 .unwrap()
992 .unwrap();
993 assert!(roots
994 .publish_if_current(
995 &entry.literal_path,
996 build.publication,
997 &lease,
998 || true,
999 || true,
1000 || (),
1001 )
1002 .unwrap()
1003 .is_none());
1004 }
1005
1006 #[test]
1007 fn crash_before_freshness_clear_blocks_verify_on_query_until_transactional_clear() {
1008 let storage = tempdir().unwrap();
1009 let root_dir = tempdir().unwrap();
1010 let cfg = config(
1011 storage.path(),
1012 vec![root(root_dir.path(), vec![IndexKind::Search])],
1013 );
1014 let first = StandingRoots::default();
1015 first.reconcile(&cfg).unwrap();
1016 first
1017 .record_strict_verification(root_dir.path().to_str().unwrap(), IndexKind::Search)
1018 .unwrap();
1019 first
1020 .mark_observation_gap(root_dir.path().to_str().unwrap(), &[IndexKind::Search])
1021 .unwrap();
1022 drop(first);
1023
1024 let restarted = StandingRoots::default();
1027 restarted.reconcile(&cfg).unwrap();
1028 assert!(matches!(
1029 restarted.route_explicit_path(&root_dir.path().join("missing.rs"), IndexKind::Search),
1030 Err(StandingRouteError::StrictVerificationRequired { .. })
1031 ));
1032 restarted
1033 .record_strict_verification(root_dir.path().to_str().unwrap(), IndexKind::Search)
1034 .unwrap();
1035 assert!(restarted
1036 .route_explicit_path(&root_dir.path().join("missing.rs"), IndexKind::Search)
1037 .is_ok());
1038 }
1039
1040 #[test]
1041 fn verify_on_query_retains_observation_gap_between_passes() {
1042 let storage = tempdir().unwrap();
1043 let root_dir = tempdir().unwrap();
1044 let roots = StandingRoots::default();
1045 let cfg = config(
1046 storage.path(),
1047 vec![root(root_dir.path(), vec![IndexKind::Search])],
1048 );
1049 let literal = root_dir.path().to_str().unwrap();
1050 roots.reconcile(&cfg).unwrap();
1051 roots
1052 .record_strict_verification(literal, IndexKind::Search)
1053 .unwrap();
1054 roots
1055 .mark_observation_gap(literal, &[IndexKind::Search])
1056 .unwrap();
1057 assert!(matches!(
1058 roots.route_explicit_path(&root_dir.path().join("query.rs"), IndexKind::Search),
1059 Err(StandingRouteError::StrictVerificationRequired { .. })
1060 ));
1061 }
1062
1063 #[test]
1064 fn suspension_edit_resume_requires_strict_verification_before_query() {
1065 let storage = tempdir().unwrap();
1066 let root_dir = tempdir().unwrap();
1067 let roots = StandingRoots::default();
1068 let cfg = config(
1069 storage.path(),
1070 vec![root(root_dir.path(), vec![IndexKind::Search])],
1071 );
1072 roots.reconcile(&cfg).unwrap();
1073 roots
1074 .record_strict_verification(root_dir.path().to_str().unwrap(), IndexKind::Search)
1075 .unwrap();
1076 let build = roots
1077 .admit_build(root_dir.path().to_str().unwrap())
1078 .unwrap();
1079 roots
1080 .begin_case_a_bind(root_dir.path().to_str().unwrap())
1081 .unwrap();
1082 assert!(build.checkpoint());
1083 std::fs::write(root_dir.path().join("edited.rs"), "fn changed() {}\n").unwrap();
1084 roots
1085 .resume_after_session(root_dir.path().to_str().unwrap(), &[])
1086 .unwrap();
1087 assert!(matches!(
1088 roots.route_explicit_path(&root_dir.path().join("edited.rs"), IndexKind::Search),
1089 Err(StandingRouteError::StrictVerificationRequired { .. })
1090 ));
1091 roots
1092 .record_strict_verification(root_dir.path().to_str().unwrap(), IndexKind::Search)
1093 .unwrap();
1094 assert!(roots
1095 .route_explicit_path(&root_dir.path().join("edited.rs"), IndexKind::Search)
1096 .is_ok());
1097 }
1098
1099 #[test]
1100 fn shared_key_handoff_preserves_session_proven_kind_and_marks_other_kind() {
1101 let storage = tempdir().unwrap();
1102 let root_dir = tempdir().unwrap();
1103 let roots = StandingRoots::default();
1104 let cfg = config(
1105 storage.path(),
1106 vec![root(
1107 root_dir.path(),
1108 vec![IndexKind::Search, IndexKind::Semantic],
1109 )],
1110 );
1111 roots.reconcile(&cfg).unwrap();
1112 let literal = root_dir.path().to_str().unwrap();
1113 roots
1114 .record_strict_verification(literal, IndexKind::Search)
1115 .unwrap();
1116 roots
1117 .record_strict_verification(literal, IndexKind::Semantic)
1118 .unwrap();
1119 roots.begin_case_a_bind(literal).unwrap();
1120 roots
1123 .record_strict_verification(literal, IndexKind::Search)
1124 .unwrap();
1125 roots
1126 .resume_after_session(literal, &[IndexKind::Search])
1127 .unwrap();
1128 let conn = crate::db::open(&storage.path().join("aft.db")).unwrap();
1129 assert!(
1130 !standing_roots::needs_strict_verify(&conn, literal, IndexKind::Search)
1131 .unwrap()
1132 .unwrap()
1133 );
1134 assert!(
1135 standing_roots::needs_strict_verify(&conn, literal, IndexKind::Semantic)
1136 .unwrap()
1137 .unwrap()
1138 );
1139 }
1140
1141 #[test]
1142 fn configuration_add_modify_and_remove_mint_boundaries_and_delete_rows() {
1143 let storage = tempdir().unwrap();
1144 let root_dir = tempdir().unwrap();
1145 let roots = StandingRoots::default();
1146 let mut cfg = config(
1147 storage.path(),
1148 vec![root(root_dir.path(), vec![IndexKind::Search])],
1149 );
1150 let literal = root_dir.path().to_str().unwrap().to_string();
1151 assert_eq!(roots.reconcile(&cfg).unwrap().added, vec![literal.clone()]);
1152 let admitted = roots.admit_build(&literal).unwrap();
1153 cfg.index.roots[0].indexes = vec![IndexKind::Callgraph];
1154 let report = roots.reconcile(&cfg).unwrap();
1155 assert_eq!(report.replaced, vec![literal.clone()]);
1156 let replacement = roots
1157 .admit_build(&literal)
1158 .expect("replacement reopens only a new admission");
1159 assert_ne!(
1160 admitted.publication.admission_epoch,
1161 replacement.publication.admission_epoch
1162 );
1163 let removed = roots.reconcile(&Config::default()).unwrap();
1164 assert_eq!(removed.removed, vec![literal.clone()]);
1165 let conn = crate::db::open(&storage.path().join("aft.db")).unwrap();
1166 assert!(standing_roots::get_standing_root(&conn, &literal)
1167 .unwrap()
1168 .is_none());
1169 }
1170
1171 #[test]
1172 fn bounded_join_proceeds_after_two_seconds_without_checkpoint() {
1173 let storage = tempdir().unwrap();
1174 let root_dir = tempdir().unwrap();
1175 let roots = StandingRoots::default();
1176 let cfg = config(
1177 storage.path(),
1178 vec![root(root_dir.path(), vec![IndexKind::Search])],
1179 );
1180 roots.reconcile(&cfg).unwrap();
1181 let _build = roots
1182 .admit_build(root_dir.path().to_str().unwrap())
1183 .unwrap();
1184 roots
1185 .begin_case_a_bind(root_dir.path().to_str().unwrap())
1186 .unwrap();
1187 let before = std::time::Instant::now();
1188 assert!(!roots
1189 .wait_for_case_a_checkpoint(root_dir.path().to_str().unwrap())
1190 .unwrap());
1191 assert!(before.elapsed() >= Duration::from_secs(2));
1192 assert!(before.elapsed() < Duration::from_millis(2300));
1193 }
1194
1195 #[test]
1196 fn bind_revokes_admission_and_checkpoint_join_is_bounded() {
1197 let storage = tempdir().unwrap();
1198 let root_dir = tempdir().unwrap();
1199 let roots = StandingRoots::default();
1200 let cfg = config(
1201 storage.path(),
1202 vec![root(root_dir.path(), vec![IndexKind::Search])],
1203 );
1204 roots.reconcile(&cfg).unwrap();
1205 let build = roots
1206 .admit_build(root_dir.path().to_str().unwrap())
1207 .unwrap();
1208 let before = std::time::Instant::now();
1209 roots
1210 .begin_case_a_bind(root_dir.path().to_str().unwrap())
1211 .unwrap();
1212 assert!(build.checkpoint());
1213 assert!(roots
1214 .wait_for_case_a_checkpoint(root_dir.path().to_str().unwrap())
1215 .unwrap());
1216 assert!(before.elapsed() < Duration::from_secs(2));
1217 assert!(roots
1218 .admit_build(root_dir.path().to_str().unwrap())
1219 .is_none());
1220 }
1221}