Skip to main content

aft/
standing_roots.rs

1//! Durable lifecycle coordination for configured standing index roots.
2//!
3//! The subc standing actor is the only background owner. This module keeps the
4//! lifecycle and publication decisions independent from transport details so a
5//! configuration replacement, a session bind, and a delayed worker all consume
6//! the same admission epoch.
7
8use 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/// Configuration snapshots are observed only at standing maintenance-pass
24/// boundaries. This keeps a pass internally coherent while allowing the next
25/// pass to apply a later configured selection.
26#[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/// The typed result used when a contained path has no selected index kind.
94#[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/// A successful route discloses the selected configuration entry rather than
108/// implying that the deepest containing entry necessarily served the request.
109#[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
120/// An admitted build retains a lifecycle reference until it reaches a
121/// checkpoint or exits. Its epoch is copied into the limiter permit by the
122/// subc actor, so a checkpoint reacquisition cannot lose publication identity.
123pub struct StandingBuildAdmission {
124    lifecycle: Arc<ArtifactLifecycle>,
125    pub publication: StandingPublicationAdmission,
126}
127
128impl StandingBuildAdmission {
129    /// A batch boundary must acknowledge bind cancellation before doing more
130    /// work. The bind operation waits at most two seconds for this signal.
131    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/// The sole state owner for standing roots. `subc::standing` invokes this at
153/// startup and from the existing subc maintenance timer; daemonless paths may
154/// read the same durable rows but cannot start background maintenance here.
155#[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        // Advance the publication epoch so any admission captured before this
236        // lifecycle transition is rejected by `StandingRoots::publish_if_current`
237        // while ArtifactPublishEpoch holds its mutex.
238        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    /// Reconcile one validated user-tier configuration snapshot. A configuration
293    /// replacement revokes stale admissions before any obsolete worker can
294    /// publish; new kinds remain durably strict until their own verification.
295    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        // An empty snapshot removes entries from the last observed user-tier
303        // storage namespace; it must not silently switch to the process default
304        // and leave durable rows behind in the former namespace.
305        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        // Daemon restart is an observation gap. Startup first reconciles an
374        // empty default before subc has received a RouteBind configuration, so
375        // the first non-empty observed snapshot marks every retained row strict.
376        // Only the transactional strict-verification clear below can consume it.
377        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    /// Admit a standing build and capture its lifecycle epoch. The caller must
408    /// carry `publication.admission_epoch` through its Standing limiter permit.
409    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    /// Begin a session bind by revoking standing admissions, advancing the
419    /// publication epoch, marking relevant freshness rows, and signaling
420    /// cancellation. The bounded worker join occurs after releasing the lock.
421    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    /// Bind may wait for one checkpoint acknowledgement but never indefinitely.
445    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    /// Resume standing ownership after a session unbind. Any standing kind that
454    /// the session did not prove current is set strict before the next pass.
455    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    /// Mark an observation gap such as suspension/resume, a watcher gap (for a
488    /// root that has a watcher), or CLI-snapshot-to-query handoff. This durable
489    /// set is paired with `record_strict_verification` below.
490    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    /// Clear a durable gap only after a successful current-state strict verify.
500    /// The outcome timestamp and clear share one SQLite transaction in the
501    /// standing-roots database API, so a crash before commit leaves the flag set.
502    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    /// Route an explicit path through overlapping entries by deepest recorded
518    /// path then configuration order. If no candidate supports the requested
519    /// kind, preserve the deepest entry in the typed unavailable result.
520    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    /// Execute a standing publication while `ArtifactPublishEpoch` holds its
565    /// exclusive per-root mutex continuously across WriterLease validation,
566    /// admission comparison, caller-supplied fingerprint/generation comparisons,
567    /// and the final rename closure. A failed comparison is a no-op.
568    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                // Compare the worker's captured admission epoch with the epoch
582                // advanced by a bind or configuration replacement. A mismatch
583                // rejects stale publication before the final rename can run.
584                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
681/// Route containment must compare the same canonical spelling recorded for an
682/// entry, while still accepting an explicit path whose final file is absent.
683fn 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        // Restarting between verification and its transaction commit must retain
1025        // the durable gap and reject a query rather than serving its baseline.
1026        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        // The session verified only the Search index. After it ends, Semantic
1121        // still needs a durable strict-verification record before it is eligible.
1122        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}