Skip to main content

graphforge_storage/
search_publication.rs

1//! Coordinated, crash-safe publication for text and vector search artifacts.
2//!
3//! A per-key advisory file lock serializes builders across threads and
4//! processes. Builds occur in a unique sibling directory, are synchronized,
5//! renamed into an immutable version directory, then made visible by atomically
6//! replacing a small `current.json` pointer. The prior pointer remains readable
7//! until that final replace.
8
9use std::fs::{File, OpenOptions};
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, Instant};
13
14use super::search_manifest::{
15    SearchArtifactError, SearchArtifactKey, SearchIndexKind, SearchManifest, SearchSourceSnapshot,
16};
17
18const CURRENT_FILE: &str = "current.json";
19const MANIFEST_FILE: &str = "manifest.json";
20const VERSIONS_DIR: &str = "versions";
21const BUILD_PREFIX: &str = ".build-";
22const VERSION_PREFIX: &str = "version-";
23const MAX_CURRENT_BYTES: usize = 4096;
24
25/// Bounds for lock waits and reopen cleanup.
26#[derive(Clone, Copy, Debug)]
27pub struct SearchCoordinationLimits {
28    /// Maximum time to wait for another builder of the same key.
29    pub lock_timeout: Duration,
30    /// Cooperative checkpoint interval while waiting.
31    pub lock_poll_interval: Duration,
32    /// Maximum filesystem entries inspected during one cleanup pass.
33    pub cleanup_entries: usize,
34}
35
36impl Default for SearchCoordinationLimits {
37    fn default() -> Self {
38        Self {
39            lock_timeout: Duration::from_secs(30),
40            lock_poll_interval: Duration::from_millis(25),
41            cleanup_entries: 10_000,
42        }
43    }
44}
45
46/// Whether a coordinated call may reuse an exactly matching fresh artifact.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum SearchPublicationMode {
49    /// Reuse a verified fresh artifact; otherwise build and publish.
50    ReuseFresh,
51    /// Always build and atomically replace the active pointer.
52    Replace,
53}
54
55/// Immutable publication parameters supplied by a concrete backend.
56#[derive(Clone, Copy, Debug)]
57pub struct SearchPublicationPlan<'a> {
58    /// Normalized artifact identity.
59    pub key: &'a SearchArtifactKey,
60    /// Pinned backend version.
61    pub backend_version: &'a str,
62    /// Pinned scoring/tokenization/vector contract version.
63    pub contract_version: &'a str,
64    /// Required vector dimension; absent for text.
65    pub dimension: Option<u32>,
66    /// Fresh reuse or forced replacement.
67    pub mode: SearchPublicationMode,
68}
69
70/// A verified currently published search artifact.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct PublishedSearchArtifact {
73    /// Immutable version directory containing backend files and manifest.
74    pub path: PathBuf,
75    /// Parsed completed manifest.
76    pub manifest: SearchManifest,
77}
78
79/// Result of one coordinated build request.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub enum SearchPublicationOutcome {
82    /// Another request or an earlier run already published an exact match.
83    Reused(PublishedSearchArtifact),
84    /// This request published a new immutable version.
85    Published {
86        /// Newly active artifact.
87        artifact: PublishedSearchArtifact,
88        /// One for the normal path, two after a concurrent mutation retry.
89        attempts: u8,
90    },
91}
92
93/// Decision returned by an atomic primary-data update builder.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum SearchUpdateBuild {
96    /// The current immutable publication already contains the requested data.
97    ReuseCurrent,
98    /// The builder wrote a complete replacement into the supplied directory.
99    Publish,
100}
101
102/// Coordinate freshness reuse, a bounded mutation retry, and atomic
103/// publication under the artifact-key writer lock.
104///
105/// `snapshot` must return both the current committed search generation and the
106/// canonical fingerprint of the relevant graph snapshot. `validate_current`
107/// validates backend files before a fresh manifest can be reused; a text
108/// backend reports rebuildable corruption as
109/// [`SearchArtifactError::CorruptDerivedIndex`]. `build` writes only inside the
110/// supplied temporary directory. `checkpoint` enforces cooperative
111/// cancellation and backend time/resource limits.
112///
113/// # Errors
114/// Distinguishes cancellation, lock failure, corrupt primary vectors,
115/// resource exhaustion, build failure, I/O, and repeated concurrent mutation.
116pub fn coordinate_search_publication<S, V, B, C>(
117    project_dir: &Path,
118    plan: SearchPublicationPlan<'_>,
119    limits: SearchCoordinationLimits,
120    mut snapshot: S,
121    mut validate_current: V,
122    mut build: B,
123    mut checkpoint: C,
124) -> Result<SearchPublicationOutcome, SearchArtifactError>
125where
126    S: FnMut() -> Result<SearchSourceSnapshot, SearchArtifactError>,
127    V: FnMut(&PublishedSearchArtifact) -> Result<(), SearchArtifactError>,
128    B: FnMut(&Path, &SearchSourceSnapshot) -> Result<(), SearchArtifactError>,
129    C: FnMut() -> Result<(), SearchArtifactError>,
130{
131    let root = plan.key.artifact_root(project_dir);
132    std::fs::create_dir_all(&root).map_err(|source| io("create artifact root", &root, source))?;
133    let _writer = SearchWriterLock::acquire(&root, limits, &mut checkpoint)?;
134    checkpoint()?;
135
136    let initial = snapshot()?;
137    match current_search_artifact(project_dir, plan.key) {
138        Ok(Some(artifact)) if plan.mode == SearchPublicationMode::ReuseFresh => {
139            match artifact.manifest.verify_fresh(
140                plan.key,
141                plan.backend_version,
142                plan.contract_version,
143                plan.dimension,
144                &initial,
145            ) {
146                Ok(()) => match validate_current(&artifact) {
147                    Ok(()) => return Ok(SearchPublicationOutcome::Reused(artifact)),
148                    Err(error)
149                        if plan.key.kind() == SearchIndexKind::Text
150                            && rebuildable_metadata(&error) => {}
151                    Err(error) if plan.key.kind() == SearchIndexKind::Vector => {
152                        return Err(primary_vector_error(root, error));
153                    }
154                    Err(error) => return Err(error),
155                },
156                Err(SearchArtifactError::Stale { .. })
157                    if plan.key.kind() == SearchIndexKind::Text => {}
158                Err(error) => return Err(error),
159            }
160        }
161        Ok(Some(_) | None) => {}
162        Err(error) if plan.key.kind() == SearchIndexKind::Text && rebuildable_metadata(&error) => {}
163        Err(error) if plan.key.kind() == SearchIndexKind::Vector => {
164            return Err(primary_vector_error(root, error));
165        }
166        Err(error) => return Err(error),
167    }
168
169    let mut before = initial;
170    for attempt in 1_u8..=2 {
171        checkpoint()?;
172        let publication = PendingPublication::new(&root)?;
173        build(publication.path(), &before)?;
174        checkpoint()?;
175        let after = snapshot()?;
176        if before != after {
177            if attempt == 2 {
178                return Err(SearchArtifactError::ConcurrentMutation);
179            }
180            before = after;
181            continue;
182        }
183        let manifest = SearchManifest::for_key(
184            plan.key,
185            plan.backend_version,
186            plan.contract_version,
187            plan.dimension,
188            &before,
189            true,
190        )?;
191        let artifact = publication.publish(&manifest)?;
192        return Ok(SearchPublicationOutcome::Published {
193            artifact,
194            attempts: attempt,
195        });
196    }
197    unreachable!("the bounded publication loop returns on both terminal paths")
198}
199
200/// Coordinate an atomic update that may inspect and reuse the current artifact.
201///
202/// This is the primary-data counterpart to [`coordinate_search_publication`].
203/// The per-key writer lock is held while `build` sees the current immutable
204/// publication.  Returning [`SearchUpdateBuild::ReuseCurrent`] makes an
205/// idempotent update a no-op; returning [`SearchUpdateBuild::Publish`] uses the
206/// same synchronized immutable-directory and atomic-pointer protocol as a
207/// derived build.  Both decisions recheck the graph snapshot, with one bounded
208/// retry, so membership validation cannot race a supported graph mutation.
209///
210/// `build` must write only inside its supplied temporary directory.  It also
211/// receives the cooperative checkpoint callback so backend work cannot publish
212/// after cancellation.
213///
214/// # Errors
215/// Distinguishes missing/corrupt primary data, cancellation, lock failure,
216/// resource exhaustion, build failure, I/O, and repeated concurrent mutation.
217pub fn coordinate_search_update<S, B, C>(
218    project_dir: &Path,
219    plan: SearchPublicationPlan<'_>,
220    limits: SearchCoordinationLimits,
221    mut snapshot: S,
222    mut build: B,
223    mut checkpoint: C,
224) -> Result<SearchPublicationOutcome, SearchArtifactError>
225where
226    S: FnMut() -> Result<SearchSourceSnapshot, SearchArtifactError>,
227    B: FnMut(
228        Option<&PublishedSearchArtifact>,
229        &Path,
230        &SearchSourceSnapshot,
231        &mut C,
232    ) -> Result<SearchUpdateBuild, SearchArtifactError>,
233    C: FnMut() -> Result<(), SearchArtifactError>,
234{
235    if plan.mode != SearchPublicationMode::Replace {
236        return Err(SearchArtifactError::Build(
237            "atomic search updates require replacement mode".to_owned(),
238        ));
239    }
240    let root = plan.key.artifact_root(project_dir);
241    std::fs::create_dir_all(&root).map_err(|source| io("create artifact root", &root, source))?;
242    let _writer = SearchWriterLock::acquire(&root, limits, &mut checkpoint)?;
243    checkpoint()?;
244    let current = match current_search_artifact(project_dir, plan.key) {
245        Ok(current) => current,
246        Err(error) if plan.key.kind() == SearchIndexKind::Vector => {
247            return Err(primary_vector_error(root, error));
248        }
249        Err(error) => return Err(error),
250    };
251
252    let mut before = snapshot()?;
253    for attempt in 1_u8..=2 {
254        checkpoint()?;
255        let publication = PendingPublication::new(&root)?;
256        let decision = build(
257            current.as_ref(),
258            publication.path(),
259            &before,
260            &mut checkpoint,
261        )?;
262        checkpoint()?;
263        let after = snapshot()?;
264        if before != after {
265            if attempt == 2 {
266                return Err(SearchArtifactError::ConcurrentMutation);
267            }
268            before = after;
269            continue;
270        }
271        match decision {
272            SearchUpdateBuild::ReuseCurrent => {
273                return current
274                    .clone()
275                    .map(SearchPublicationOutcome::Reused)
276                    .ok_or_else(|| {
277                        SearchArtifactError::Build(
278                            "update requested reuse without a current artifact".to_owned(),
279                        )
280                    });
281            }
282            SearchUpdateBuild::Publish => {
283                let manifest = SearchManifest::for_key(
284                    plan.key,
285                    plan.backend_version,
286                    plan.contract_version,
287                    plan.dimension,
288                    &before,
289                    true,
290                )?;
291                let artifact = publication.publish(&manifest)?;
292                return Ok(SearchPublicationOutcome::Published {
293                    artifact,
294                    attempts: attempt,
295                });
296            }
297        }
298    }
299    unreachable!("the bounded update loop returns on both terminal paths")
300}
301
302/// Resolve and parse the currently published immutable version.
303///
304/// Missing `current.json` is `Ok(None)`. A torn, incompatible, incomplete, or
305/// traversal-like pointer/manifest is a structured error and is never served.
306///
307/// # Errors
308/// Returns a manifest, resource, or filesystem error.
309pub fn current_search_artifact(
310    project_dir: &Path,
311    key: &SearchArtifactKey,
312) -> Result<Option<PublishedSearchArtifact>, SearchArtifactError> {
313    let root = key.artifact_root(project_dir);
314    let pointer = root.join(CURRENT_FILE);
315    let bytes = match std::fs::read(&pointer) {
316        Ok(bytes) => bytes,
317        Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
318        Err(source) => return Err(io("read current pointer", &pointer, source)),
319    };
320    if bytes.len() > MAX_CURRENT_BYTES {
321        return Err(SearchArtifactError::ResourceExhausted {
322            resource: "current_pointer_bytes",
323            limit: MAX_CURRENT_BYTES as u64,
324        });
325    }
326    let value: serde_json::Value =
327        serde_json::from_slice(&bytes).map_err(|error| SearchArtifactError::CorruptManifest {
328            path: pointer.clone(),
329            reason: error.to_string(),
330        })?;
331    let version = value
332        .as_object()
333        .and_then(|object| object.get("version"))
334        .and_then(serde_json::Value::as_str)
335        .filter(|name| valid_owned_name(name, VERSION_PREFIX))
336        .ok_or_else(|| SearchArtifactError::CorruptManifest {
337            path: pointer,
338            reason: "expected a safe version pointer".to_owned(),
339        })?;
340    let artifact_dir = root.join(VERSIONS_DIR).join(version);
341    let manifest_path = artifact_dir.join(MANIFEST_FILE);
342    let manifest_bytes = match std::fs::read(&manifest_path) {
343        Ok(bytes) => bytes,
344        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
345            return Err(SearchArtifactError::Missing {
346                path: manifest_path,
347            });
348        }
349        Err(source) => return Err(io("read published manifest", &manifest_path, source)),
350    };
351    let manifest = SearchManifest::from_json(&manifest_path, &manifest_bytes)?;
352    if !manifest.completed {
353        return Err(SearchArtifactError::CorruptManifest {
354            path: manifest_path,
355            reason: "published manifest is not completed".to_owned(),
356        });
357    }
358    Ok(Some(PublishedSearchArtifact {
359        path: artifact_dir,
360        manifest,
361    }))
362}
363
364/// Remove abandoned GraphForge search build directories and pointer temp files
365/// after reopen.
366///
367/// Only the known `indexes/search/` and `embeddings/` trees are traversed.
368/// Only exact GraphForge-owned names are removed; symlinks and unrecognized
369/// user files are preserved. The entire bounded scan completes before any
370/// deletion starts.
371///
372/// # Errors
373/// Returns an I/O or cleanup-entry resource error. Exceeding the bound removes
374/// nothing.
375pub fn cleanup_abandoned_search_builds(
376    project_dir: &Path,
377    max_entries: usize,
378) -> Result<usize, SearchArtifactError> {
379    let roots = [
380        project_dir.join("indexes").join("search"),
381        project_dir.join("embeddings"),
382    ];
383    let mut stack = roots.to_vec();
384    let mut inspected = 0_usize;
385    let mut remove = Vec::new();
386    while let Some(directory) = stack.pop() {
387        let entries = match std::fs::read_dir(&directory) {
388            Ok(entries) => entries,
389            Err(source) if source.kind() == std::io::ErrorKind::NotFound => continue,
390            Err(source) => return Err(io("scan abandoned builds", &directory, source)),
391        };
392        for entry in entries {
393            inspected = inspected
394                .checked_add(1)
395                .ok_or(SearchArtifactError::ResourceExhausted {
396                    resource: "cleanup_entries",
397                    limit: max_entries as u64,
398                })?;
399            if inspected > max_entries {
400                return Err(SearchArtifactError::ResourceExhausted {
401                    resource: "cleanup_entries",
402                    limit: max_entries as u64,
403                });
404            }
405            let entry = entry.map_err(|source| io("read cleanup entry", &directory, source))?;
406            let file_type = entry
407                .file_type()
408                .map_err(|source| io("read cleanup file type", &entry.path(), source))?;
409            let name = entry.file_name();
410            let name = name.to_string_lossy();
411            if file_type.is_dir() && valid_owned_name(&name, BUILD_PREFIX) {
412                remove.push((entry.path(), true));
413            } else if file_type.is_dir() && !file_type.is_symlink() {
414                stack.push(entry.path());
415            } else if file_type.is_file() && valid_pointer_temp(&name) {
416                remove.push((entry.path(), false));
417            }
418        }
419    }
420
421    remove.sort_unstable_by(|left, right| right.0.cmp(&left.0));
422    for (path, directory) in &remove {
423        if *directory {
424            std::fs::remove_dir_all(path)
425                .map_err(|source| io("remove abandoned build", path, source))?;
426        } else {
427            std::fs::remove_file(path)
428                .map_err(|source| io("remove abandoned pointer", path, source))?;
429        }
430    }
431    Ok(remove.len())
432}
433
434struct SearchWriterLock {
435    file: File,
436}
437
438impl SearchWriterLock {
439    fn acquire<C>(
440        root: &Path,
441        limits: SearchCoordinationLimits,
442        checkpoint: &mut C,
443    ) -> Result<Self, SearchArtifactError>
444    where
445        C: FnMut() -> Result<(), SearchArtifactError>,
446    {
447        let path = root.join(".writer.lock");
448        let file = OpenOptions::new()
449            .read(true)
450            .write(true)
451            .create(true)
452            .truncate(false)
453            .open(&path)
454            .map_err(|source| SearchArtifactError::Lock {
455                path: path.clone(),
456                reason: source.to_string(),
457            })?;
458        let started = Instant::now();
459        loop {
460            match file.try_lock() {
461                Ok(()) => return Ok(Self { file }),
462                Err(std::fs::TryLockError::WouldBlock) => {
463                    checkpoint()?;
464                    if started.elapsed() >= limits.lock_timeout {
465                        return Err(SearchArtifactError::Lock {
466                            path,
467                            reason: format!(
468                                "timed out after {} ms",
469                                limits.lock_timeout.as_millis()
470                            ),
471                        });
472                    }
473                    std::thread::sleep(limits.lock_poll_interval);
474                }
475                Err(std::fs::TryLockError::Error(source)) => {
476                    return Err(SearchArtifactError::Lock {
477                        path,
478                        reason: source.to_string(),
479                    });
480                }
481            }
482        }
483    }
484}
485
486impl Drop for SearchWriterLock {
487    fn drop(&mut self) {
488        let _ = self.file.unlock();
489    }
490}
491
492struct PendingPublication {
493    root: PathBuf,
494    temp: tempfile::TempDir,
495}
496
497impl PendingPublication {
498    fn new(root: &Path) -> Result<Self, SearchArtifactError> {
499        let temp = tempfile::Builder::new()
500            .prefix(BUILD_PREFIX)
501            .tempdir_in(root)
502            .map_err(|source| io("create build directory", root, source))?;
503        Ok(Self {
504            root: root.to_path_buf(),
505            temp,
506        })
507    }
508
509    fn path(&self) -> &Path {
510        self.temp.path()
511    }
512
513    fn publish(
514        self,
515        manifest: &SearchManifest,
516    ) -> Result<PublishedSearchArtifact, SearchArtifactError> {
517        let manifest_path = self.temp.path().join(MANIFEST_FILE);
518        let manifest_bytes = manifest.to_canonical_json()?;
519        write_synced_file(&manifest_path, &manifest_bytes)?;
520        sync_tree(self.temp.path())?;
521
522        let token = self
523            .temp
524            .path()
525            .file_name()
526            .and_then(|name| name.to_str())
527            .and_then(|name| name.strip_prefix(BUILD_PREFIX))
528            .filter(|token| {
529                !token.is_empty() && token.bytes().all(|byte| byte.is_ascii_alphanumeric())
530            })
531            .ok_or_else(|| {
532                SearchArtifactError::Build("temporary build name is invalid".to_owned())
533            })?;
534        let version_name = format!("{VERSION_PREFIX}{token}");
535        let versions = self.root.join(VERSIONS_DIR);
536        std::fs::create_dir_all(&versions)
537            .map_err(|source| io("create versions directory", &versions, source))?;
538        let version_path = versions.join(&version_name);
539        let temp_path = self.temp.keep();
540        if let Err(source) = std::fs::rename(&temp_path, &version_path) {
541            let _ = std::fs::remove_dir_all(&temp_path);
542            return Err(io("publish immutable version", &version_path, source));
543        }
544        sync_directory(&versions)?;
545
546        let pointer = serde_json::to_vec(&serde_json::json!({ "version": version_name }))
547            .map_err(|error| SearchArtifactError::Build(error.to_string()))?;
548        persist_synced_pointer(&self.root.join(CURRENT_FILE), &pointer)?;
549        sync_directory(&self.root)?;
550        Ok(PublishedSearchArtifact {
551            path: version_path,
552            manifest: manifest.clone(),
553        })
554    }
555}
556
557fn rebuildable_metadata(error: &SearchArtifactError) -> bool {
558    matches!(
559        error,
560        SearchArtifactError::Missing { .. }
561            | SearchArtifactError::CorruptManifest { .. }
562            | SearchArtifactError::CorruptDerivedIndex { .. }
563            | SearchArtifactError::IncompatibleManifest { .. }
564            | SearchArtifactError::Stale { .. }
565            | SearchArtifactError::ResourceExhausted {
566                resource: "manifest_bytes" | "current_pointer_bytes",
567                ..
568            }
569    )
570}
571
572fn primary_vector_error(root: PathBuf, error: SearchArtifactError) -> SearchArtifactError {
573    match error {
574        error @ SearchArtifactError::CorruptPrimaryVectors { .. } => error,
575        error => SearchArtifactError::CorruptPrimaryVectors {
576            path: root,
577            reason: error.to_string(),
578        },
579    }
580}
581
582fn write_synced_file(path: &Path, bytes: &[u8]) -> Result<(), SearchArtifactError> {
583    let mut file = OpenOptions::new()
584        .create(true)
585        .truncate(true)
586        .write(true)
587        .open(path)
588        .map_err(|source| io("create publication file", path, source))?;
589    file.write_all(bytes)
590        .map_err(|source| io("write publication file", path, source))?;
591    file.sync_all()
592        .map_err(|source| io("sync publication file", path, source))
593}
594
595fn persist_synced_pointer(path: &Path, bytes: &[u8]) -> Result<(), SearchArtifactError> {
596    let parent = path
597        .parent()
598        .ok_or_else(|| SearchArtifactError::Build("current pointer has no parent".to_owned()))?;
599    let mut temp = tempfile::Builder::new()
600        .prefix("current.json.")
601        .suffix(".tmp")
602        .tempfile_in(parent)
603        .map_err(|source| io("create current pointer temp", path, source))?;
604    temp.write_all(bytes)
605        .map_err(|source| io("write current pointer temp", path, source))?;
606    temp.as_file()
607        .sync_all()
608        .map_err(|source| io("sync current pointer temp", path, source))?;
609    temp.persist(path)
610        .map_err(|error| io("publish current pointer", path, error.error))?;
611    Ok(())
612}
613
614fn sync_tree(root: &Path) -> Result<(), SearchArtifactError> {
615    let mut directories = vec![root.to_path_buf()];
616    let mut files = Vec::new();
617    let mut cursor = 0;
618    while cursor < directories.len() {
619        let directory = directories[cursor].clone();
620        cursor += 1;
621        let entries = std::fs::read_dir(&directory)
622            .map_err(|source| io("scan build for sync", &directory, source))?;
623        for entry in entries {
624            let entry = entry.map_err(|source| io("read build entry", &directory, source))?;
625            let file_type = entry
626                .file_type()
627                .map_err(|source| io("read build file type", &entry.path(), source))?;
628            if file_type.is_symlink() {
629                return Err(SearchArtifactError::Build(format!(
630                    "search build must not contain symlink {}",
631                    entry.path().display()
632                )));
633            }
634            if file_type.is_dir() {
635                directories.push(entry.path());
636            } else if file_type.is_file() {
637                files.push(entry.path());
638            }
639        }
640    }
641    files.sort_unstable();
642    for path in files {
643        sync_file(&path)?;
644    }
645    directories.sort_unstable_by_key(|path| std::cmp::Reverse(path.components().count()));
646    for directory in directories {
647        sync_directory(&directory)?;
648    }
649    Ok(())
650}
651
652fn sync_file(path: &Path) -> Result<(), SearchArtifactError> {
653    OpenOptions::new()
654        .read(true)
655        .write(true)
656        .open(path)
657        .and_then(|file| file.sync_all())
658        .map_err(|source| io("sync build file", path, source))
659}
660
661#[cfg(unix)]
662fn sync_directory(path: &Path) -> Result<(), SearchArtifactError> {
663    File::open(path)
664        .and_then(|file| file.sync_all())
665        .map_err(|source| io("sync directory", path, source))
666}
667
668#[cfg(not(unix))]
669fn sync_directory(_path: &Path) -> Result<(), SearchArtifactError> {
670    Ok(())
671}
672
673fn valid_owned_name(name: &str, prefix: &str) -> bool {
674    name.strip_prefix(prefix).is_some_and(|token| {
675        !token.is_empty() && token.bytes().all(|byte| byte.is_ascii_alphanumeric())
676    })
677}
678
679fn valid_pointer_temp(name: &str) -> bool {
680    name.strip_prefix("current.json.")
681        .and_then(|name| name.strip_suffix(".tmp"))
682        .is_some_and(|token| {
683            !token.is_empty() && token.bytes().all(|byte| byte.is_ascii_alphanumeric())
684        })
685}
686
687fn io(operation: &'static str, path: &Path, source: std::io::Error) -> SearchArtifactError {
688    SearchArtifactError::Io {
689        operation,
690        path: path.to_path_buf(),
691        source,
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
698    use std::sync::{Arc, Barrier};
699
700    use tempfile::TempDir;
701
702    use super::*;
703
704    fn key() -> SearchArtifactKey {
705        SearchArtifactKey::text("Person", ["name"]).unwrap()
706    }
707
708    fn snapshot(generation: u64) -> SearchSourceSnapshot {
709        SearchSourceSnapshot {
710            generation,
711            fingerprint: format!("gf-fnv1a256:{generation:064x}"),
712        }
713    }
714
715    fn plan<'a>(
716        key: &'a SearchArtifactKey,
717        mode: SearchPublicationMode,
718    ) -> SearchPublicationPlan<'a> {
719        SearchPublicationPlan {
720            key,
721            backend_version: "tantivy-0.25",
722            contract_version: "graphforge_text_v1",
723            dimension: None,
724            mode,
725        }
726    }
727
728    #[test]
729    fn publish_is_invisible_until_current_pointer_swap() {
730        let dir = TempDir::new().unwrap();
731        let key = key();
732        let root = key.artifact_root(dir.path());
733        std::fs::create_dir_all(&root).unwrap();
734        let pending = PendingPublication::new(&root).unwrap();
735        std::fs::write(pending.path().join("index"), b"complete").unwrap();
736        assert!(current_search_artifact(dir.path(), &key).unwrap().is_none());
737
738        let manifest = SearchManifest::for_key(
739            &key,
740            "tantivy-0.25",
741            "graphforge_text_v1",
742            None,
743            &snapshot(1),
744            true,
745        )
746        .unwrap();
747        let published = pending.publish(&manifest).unwrap();
748        assert_eq!(
749            current_search_artifact(dir.path(), &key).unwrap(),
750            Some(published)
751        );
752    }
753
754    #[test]
755    fn sync_tree_flushes_regular_files_without_mutating_contents() {
756        let dir = TempDir::new().unwrap();
757        let nested = dir.path().join("nested");
758        std::fs::create_dir(&nested).unwrap();
759        let first = dir.path().join("first");
760        let second = nested.join("second");
761        std::fs::write(&first, b"one").unwrap();
762        std::fs::write(&second, b"two").unwrap();
763
764        sync_tree(dir.path()).unwrap();
765
766        assert_eq!(std::fs::read(first).unwrap(), b"one");
767        assert_eq!(std::fs::read(second).unwrap(), b"two");
768    }
769
770    #[test]
771    fn forced_rebuild_atomically_replaces_pointer_and_keeps_old_version() {
772        let dir = TempDir::new().unwrap();
773        let key = key();
774        let first = coordinate_search_publication(
775            dir.path(),
776            plan(&key, SearchPublicationMode::Replace),
777            SearchCoordinationLimits::default(),
778            || Ok(snapshot(1)),
779            |_| Ok(()),
780            |path, _| {
781                std::fs::write(path.join("index"), b"first")
782                    .map_err(|error| SearchArtifactError::Build(error.to_string()))
783            },
784            || Ok(()),
785        )
786        .unwrap();
787        let first_path = match first {
788            SearchPublicationOutcome::Published { artifact, .. } => artifact.path,
789            SearchPublicationOutcome::Reused(_) => panic!("forced build reused"),
790        };
791        let second = coordinate_search_publication(
792            dir.path(),
793            plan(&key, SearchPublicationMode::Replace),
794            SearchCoordinationLimits::default(),
795            || Ok(snapshot(1)),
796            |_| Ok(()),
797            |path, _| {
798                std::fs::write(path.join("index"), b"second")
799                    .map_err(|error| SearchArtifactError::Build(error.to_string()))
800            },
801            || Ok(()),
802        )
803        .unwrap();
804        let second_path = match second {
805            SearchPublicationOutcome::Published { artifact, .. } => artifact.path,
806            SearchPublicationOutcome::Reused(_) => panic!("forced build reused"),
807        };
808        assert_ne!(first_path, second_path);
809        assert!(
810            first_path.exists(),
811            "old readers retain an immutable version"
812        );
813        assert_eq!(
814            current_search_artifact(dir.path(), &key)
815                .unwrap()
816                .unwrap()
817                .path,
818            second_path
819        );
820    }
821
822    #[test]
823    fn failed_replacement_keeps_the_previous_publication() {
824        let dir = TempDir::new().unwrap();
825        let key = key();
826        coordinate_search_publication(
827            dir.path(),
828            plan(&key, SearchPublicationMode::Replace),
829            SearchCoordinationLimits::default(),
830            || Ok(snapshot(1)),
831            |_| Ok(()),
832            |path, _| {
833                std::fs::write(path.join("index"), b"committed")
834                    .map_err(|error| SearchArtifactError::Build(error.to_string()))
835            },
836            || Ok(()),
837        )
838        .unwrap();
839        let previous = current_search_artifact(dir.path(), &key).unwrap().unwrap();
840
841        let error = coordinate_search_publication(
842            dir.path(),
843            plan(&key, SearchPublicationMode::Replace),
844            SearchCoordinationLimits::default(),
845            || Ok(snapshot(1)),
846            |_| Ok(()),
847            |path, _| {
848                std::fs::write(path.join("index"), b"partial").unwrap();
849                Err(SearchArtifactError::Build("injected failure".to_owned()))
850            },
851            || Ok(()),
852        )
853        .unwrap_err();
854        assert!(matches!(error, SearchArtifactError::Build(_)));
855        assert_eq!(
856            current_search_artifact(dir.path(), &key).unwrap().unwrap(),
857            previous
858        );
859        assert_eq!(
860            std::fs::read(previous.path.join("index")).unwrap(),
861            b"committed"
862        );
863    }
864
865    #[test]
866    fn fresh_lazy_request_reuses_without_running_builder() {
867        let dir = TempDir::new().unwrap();
868        let key = key();
869        let builds = AtomicUsize::new(0);
870        let run = |mode| {
871            coordinate_search_publication(
872                dir.path(),
873                plan(&key, mode),
874                SearchCoordinationLimits::default(),
875                || Ok(snapshot(1)),
876                |_| Ok(()),
877                |path, _| {
878                    builds.fetch_add(1, Ordering::SeqCst);
879                    std::fs::write(path.join("index"), b"data")
880                        .map_err(|error| SearchArtifactError::Build(error.to_string()))
881                },
882                || Ok(()),
883            )
884        };
885        assert!(matches!(
886            run(SearchPublicationMode::ReuseFresh).unwrap(),
887            SearchPublicationOutcome::Published { .. }
888        ));
889        assert!(matches!(
890            run(SearchPublicationMode::ReuseFresh).unwrap(),
891            SearchPublicationOutcome::Reused(_)
892        ));
893        assert_eq!(builds.load(Ordering::SeqCst), 1);
894    }
895
896    #[test]
897    fn corrupt_derived_backend_is_rebuilt_before_reuse() {
898        let dir = TempDir::new().unwrap();
899        let key = key();
900        let builds = AtomicUsize::new(0);
901        let run = || {
902            coordinate_search_publication(
903                dir.path(),
904                plan(&key, SearchPublicationMode::ReuseFresh),
905                SearchCoordinationLimits::default(),
906                || Ok(snapshot(1)),
907                |artifact| {
908                    let path = artifact.path.join("index");
909                    let bytes = std::fs::read(&path).map_err(|error| {
910                        SearchArtifactError::CorruptDerivedIndex {
911                            path: path.clone(),
912                            reason: error.to_string(),
913                        }
914                    })?;
915                    if bytes == b"valid" {
916                        Ok(())
917                    } else {
918                        Err(SearchArtifactError::CorruptDerivedIndex {
919                            path,
920                            reason: "backend validation failed".to_owned(),
921                        })
922                    }
923                },
924                |path, _| {
925                    builds.fetch_add(1, Ordering::SeqCst);
926                    std::fs::write(path.join("index"), b"valid")
927                        .map_err(|error| SearchArtifactError::Build(error.to_string()))
928                },
929                || Ok(()),
930            )
931        };
932        assert!(matches!(
933            run().unwrap(),
934            SearchPublicationOutcome::Published { .. }
935        ));
936        let current = current_search_artifact(dir.path(), &key).unwrap().unwrap();
937        std::fs::write(current.path.join("index"), b"corrupt").unwrap();
938        assert!(matches!(
939            run().unwrap(),
940            SearchPublicationOutcome::Published { .. }
941        ));
942        assert_eq!(builds.load(Ordering::SeqCst), 2);
943    }
944
945    #[test]
946    fn mutation_retries_once_and_second_mutation_fails_closed() {
947        let dir = TempDir::new().unwrap();
948        let key = key();
949        let reads = AtomicUsize::new(0);
950        let outcome = coordinate_search_publication(
951            dir.path(),
952            plan(&key, SearchPublicationMode::Replace),
953            SearchCoordinationLimits::default(),
954            || {
955                let call = reads.fetch_add(1, Ordering::SeqCst);
956                Ok(snapshot(u64::from(call >= 1)))
957            },
958            |_| Ok(()),
959            |path, source| {
960                std::fs::write(path.join("source"), source.generation.to_string())
961                    .map_err(|error| SearchArtifactError::Build(error.to_string()))
962            },
963            || Ok(()),
964        )
965        .unwrap();
966        assert!(matches!(
967            outcome,
968            SearchPublicationOutcome::Published { attempts: 2, .. }
969        ));
970
971        let reads = AtomicUsize::new(0);
972        let error = coordinate_search_publication(
973            dir.path(),
974            plan(&key, SearchPublicationMode::Replace),
975            SearchCoordinationLimits::default(),
976            || Ok(snapshot(reads.fetch_add(1, Ordering::SeqCst) as u64)),
977            |_| Ok(()),
978            |path, _| {
979                std::fs::write(path.join("index"), b"data")
980                    .map_err(|error| SearchArtifactError::Build(error.to_string()))
981            },
982            || Ok(()),
983        )
984        .unwrap_err();
985        assert!(matches!(error, SearchArtifactError::ConcurrentMutation));
986    }
987
988    #[test]
989    fn same_key_requests_serialize_and_share_one_lazy_build() {
990        let dir = Arc::new(TempDir::new().unwrap());
991        let key = Arc::new(key());
992        let builds = Arc::new(AtomicUsize::new(0));
993        let barrier = Arc::new(Barrier::new(2));
994        let mut handles = Vec::new();
995        for _ in 0..2 {
996            let dir = Arc::clone(&dir);
997            let key = Arc::clone(&key);
998            let builds = Arc::clone(&builds);
999            let barrier = Arc::clone(&barrier);
1000            handles.push(std::thread::spawn(move || {
1001                barrier.wait();
1002                coordinate_search_publication(
1003                    dir.path(),
1004                    plan(&key, SearchPublicationMode::ReuseFresh),
1005                    SearchCoordinationLimits::default(),
1006                    || Ok(snapshot(1)),
1007                    |_| Ok(()),
1008                    |path, _| {
1009                        builds.fetch_add(1, Ordering::SeqCst);
1010                        std::thread::sleep(Duration::from_millis(75));
1011                        std::fs::write(path.join("index"), b"data")
1012                            .map_err(|error| SearchArtifactError::Build(error.to_string()))
1013                    },
1014                    || Ok(()),
1015                )
1016            }));
1017        }
1018        let outcomes = handles
1019            .into_iter()
1020            .map(|handle| handle.join().unwrap().unwrap())
1021            .collect::<Vec<_>>();
1022        assert_eq!(builds.load(Ordering::SeqCst), 1);
1023        assert!(
1024            outcomes
1025                .iter()
1026                .any(|outcome| matches!(outcome, SearchPublicationOutcome::Reused(_)))
1027        );
1028    }
1029
1030    #[test]
1031    fn cancellation_while_waiting_does_not_publish() {
1032        let dir = TempDir::new().unwrap();
1033        let key = key();
1034        let root = key.artifact_root(dir.path());
1035        std::fs::create_dir_all(&root).unwrap();
1036        let lock =
1037            SearchWriterLock::acquire(&root, SearchCoordinationLimits::default(), &mut || Ok(()))
1038                .unwrap();
1039        let cancelled = AtomicBool::new(false);
1040        let limits = SearchCoordinationLimits {
1041            lock_timeout: Duration::from_secs(1),
1042            lock_poll_interval: Duration::from_millis(1),
1043            ..SearchCoordinationLimits::default()
1044        };
1045        let result = SearchWriterLock::acquire(&root, limits, &mut || {
1046            if cancelled.swap(true, Ordering::SeqCst) {
1047                Err(SearchArtifactError::Cancelled)
1048            } else {
1049                Ok(())
1050            }
1051        });
1052        drop(lock);
1053        assert!(matches!(result, Err(SearchArtifactError::Cancelled)));
1054        assert!(current_search_artifact(dir.path(), &key).unwrap().is_none());
1055    }
1056
1057    #[test]
1058    fn cancellation_after_build_does_not_publish_partial_output() {
1059        let dir = TempDir::new().unwrap();
1060        let key = key();
1061        let checkpoints = AtomicUsize::new(0);
1062        let error = coordinate_search_publication(
1063            dir.path(),
1064            plan(&key, SearchPublicationMode::Replace),
1065            SearchCoordinationLimits::default(),
1066            || Ok(snapshot(1)),
1067            |_| Ok(()),
1068            |path, _| {
1069                std::fs::write(path.join("index"), b"partial")
1070                    .map_err(|error| SearchArtifactError::Build(error.to_string()))
1071            },
1072            || {
1073                if checkpoints.fetch_add(1, Ordering::SeqCst) >= 2 {
1074                    Err(SearchArtifactError::Cancelled)
1075                } else {
1076                    Ok(())
1077                }
1078            },
1079        )
1080        .unwrap_err();
1081        assert!(matches!(error, SearchArtifactError::Cancelled));
1082        assert!(current_search_artifact(dir.path(), &key).unwrap().is_none());
1083    }
1084
1085    #[test]
1086    fn cleanup_is_bounded_scoped_and_preserves_unknown_files() {
1087        let dir = TempDir::new().unwrap();
1088        let search = dir.path().join("indexes/search/text/key");
1089        let embeddings = dir.path().join("embeddings/space/key");
1090        let notes = dir.path().join("notes");
1091        for path in [&search, &embeddings, &notes] {
1092            std::fs::create_dir_all(path).unwrap();
1093        }
1094        let stale = [
1095            search.join(".build-Abc123"),
1096            embeddings.join(".build-Xyz789"),
1097        ];
1098        for path in &stale {
1099            std::fs::create_dir_all(path).unwrap();
1100            std::fs::write(path.join("partial"), b"x").unwrap();
1101        }
1102        let pointer_temp = search.join("current.json.Qwe456.tmp");
1103        std::fs::write(&pointer_temp, b"x").unwrap();
1104        let preserved = [
1105            search.join(".build-bad-name"),
1106            embeddings.join("vectors.parquet"),
1107            notes.join(".build-Abc123"),
1108        ];
1109        for path in &preserved {
1110            if let Some(parent) = path.parent() {
1111                std::fs::create_dir_all(parent).unwrap();
1112            }
1113            std::fs::write(path, b"keep").unwrap();
1114        }
1115
1116        assert!(matches!(
1117            cleanup_abandoned_search_builds(dir.path(), 1),
1118            Err(SearchArtifactError::ResourceExhausted {
1119                resource: "cleanup_entries",
1120                ..
1121            })
1122        ));
1123        assert!(stale.iter().all(|path| path.exists()));
1124        assert_eq!(cleanup_abandoned_search_builds(dir.path(), 100).unwrap(), 3);
1125        assert!(stale.iter().all(|path| !path.exists()));
1126        assert!(!pointer_temp.exists());
1127        assert!(preserved.iter().all(|path| path.exists()));
1128    }
1129
1130    #[test]
1131    fn missing_published_text_manifest_is_rebuilt() {
1132        let dir = TempDir::new().unwrap();
1133        let key = key();
1134        let root = key.artifact_root(dir.path());
1135        std::fs::create_dir_all(&root).unwrap();
1136        std::fs::write(root.join(CURRENT_FILE), br#"{"version":"version-Abc123"}"#).unwrap();
1137        let builds = AtomicUsize::new(0);
1138        let outcome = coordinate_search_publication(
1139            dir.path(),
1140            plan(&key, SearchPublicationMode::ReuseFresh),
1141            SearchCoordinationLimits::default(),
1142            || Ok(snapshot(1)),
1143            |_| Ok(()),
1144            |path, _| {
1145                builds.fetch_add(1, Ordering::SeqCst);
1146                std::fs::write(path.join("index"), b"rebuilt")
1147                    .map_err(|error| SearchArtifactError::Build(error.to_string()))
1148            },
1149            || Ok(()),
1150        )
1151        .unwrap();
1152        assert!(matches!(
1153            outcome,
1154            SearchPublicationOutcome::Published { attempts: 1, .. }
1155        ));
1156        assert_eq!(builds.load(Ordering::SeqCst), 1);
1157    }
1158
1159    #[test]
1160    fn corrupt_and_incompatible_text_manifests_are_rebuilt() {
1161        for incompatible in [false, true] {
1162            let dir = TempDir::new().unwrap();
1163            let key = key();
1164            let builds = AtomicUsize::new(0);
1165            let run = || {
1166                coordinate_search_publication(
1167                    dir.path(),
1168                    plan(&key, SearchPublicationMode::ReuseFresh),
1169                    SearchCoordinationLimits::default(),
1170                    || Ok(snapshot(1)),
1171                    |_| Ok(()),
1172                    |path, _| {
1173                        builds.fetch_add(1, Ordering::SeqCst);
1174                        std::fs::write(path.join("index"), b"complete")
1175                            .map_err(|error| SearchArtifactError::Build(error.to_string()))
1176                    },
1177                    || Ok(()),
1178                )
1179            };
1180            run().unwrap();
1181            let current = current_search_artifact(dir.path(), &key).unwrap().unwrap();
1182            let manifest_path = current.path.join(MANIFEST_FILE);
1183            if incompatible {
1184                let mut manifest: serde_json::Value =
1185                    serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap();
1186                manifest["manifest_version"] = serde_json::Value::from(99);
1187                std::fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
1188            } else {
1189                std::fs::write(&manifest_path, b"corrupt").unwrap();
1190            }
1191
1192            assert!(matches!(
1193                run().unwrap(),
1194                SearchPublicationOutcome::Published { attempts: 1, .. }
1195            ));
1196            assert_eq!(builds.load(Ordering::SeqCst), 2);
1197        }
1198    }
1199
1200    #[test]
1201    fn corrupt_vector_metadata_is_not_discarded() {
1202        let dir = TempDir::new().unwrap();
1203        let key = SearchArtifactKey::vector("Person", "semantic").unwrap();
1204        let root = key.artifact_root(dir.path());
1205        std::fs::create_dir_all(&root).unwrap();
1206        std::fs::write(root.join(CURRENT_FILE), b"corrupt").unwrap();
1207        let error = coordinate_search_publication(
1208            dir.path(),
1209            SearchPublicationPlan {
1210                key: &key,
1211                backend_version: "exact-cosine-v1",
1212                contract_version: "vector-v1",
1213                dimension: Some(3),
1214                mode: SearchPublicationMode::Replace,
1215            },
1216            SearchCoordinationLimits::default(),
1217            || Ok(snapshot(1)),
1218            |_| Ok(()),
1219            |_, _| Ok(()),
1220            || Ok(()),
1221        )
1222        .unwrap_err();
1223        assert!(matches!(
1224            error,
1225            SearchArtifactError::CorruptPrimaryVectors { .. }
1226        ));
1227        assert_eq!(std::fs::read(root.join(CURRENT_FILE)).unwrap(), b"corrupt");
1228    }
1229}