Skip to main content

aft/views/
assembly.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::time::Instant;
5
6use rusqlite::{Connection, OptionalExtension};
7
8use crate::alias::{head_tree_entries, AliasStore, GitMode};
9use crate::blob_store::{
10    BlobPlane, BlobStore, CallgraphKey, FullKey, PutOutcome, CALLGRAPH_PRODUCER_VERSION,
11};
12use crate::callgraph_store::join::CallgraphBlob;
13use crate::parser::detect_language;
14use crate::path_status::PathStatusStore;
15use crate::pins::AssemblyPin;
16
17use super::{
18    ArtifactPlane, ByteString, ClosureRequirements, Manifest, ManifestEntry, PublicationArtifacts,
19    PublicationClosure, PublicationRequest, PublishOutcome, RegularPlanes, RelPath, Result,
20    ViewError, ViewStore,
21};
22
23#[derive(Clone, Debug)]
24pub struct AssemblyRequest {
25    pub storage: PathBuf,
26    pub project_root: PathBuf,
27    pub family: String,
28    pub scope: String,
29    pub desired_head: String,
30    pub changed_paths: BTreeSet<Vec<u8>>,
31    pub semantic_keys: BTreeMap<Vec<u8>, String>,
32    pub require_semantic: bool,
33    pub allow_blob_put: bool,
34}
35
36#[derive(Clone, Debug)]
37pub struct AssemblyReport {
38    pub generation: Option<String>,
39    pub manifest: Option<Manifest>,
40    pub blob_puts: usize,
41    pub pending_paths: BTreeSet<Vec<u8>>,
42    pub published: bool,
43}
44
45struct Candidate {
46    path: RelPath,
47    entry: ManifestEntry,
48    key: Option<FullKey>,
49    payload: Option<Vec<u8>>,
50    tracked: Option<crate::alias::TrackedPath>,
51    source: Option<Vec<u8>>,
52}
53
54pub fn head_tree_fingerprint(entries: &[crate::alias::TrackedPath]) -> String {
55    let mut hasher = blake3::Hasher::new();
56    for entry in entries {
57        hasher.update(&(entry.rel_path.len() as u64).to_le_bytes());
58        hasher.update(&entry.rel_path);
59        hasher.update(entry.mode.as_bytes());
60        hasher.update(entry.git_oid.as_bytes());
61    }
62    hasher.finalize().to_hex().to_string()
63}
64
65pub fn publish_checkout(request: &AssemblyRequest) -> Result<AssemblyReport> {
66    let mut prepared = prepare_checkout(request, &mut |_| Ok(()))?;
67    prepared.commit()
68}
69
70/// Owns the unpublished files and assembly pin until CAS succeeds or the build
71/// is dropped. Cancellation and errors follow the same cleanup path.
72pub struct PreparedAssembly {
73    report: AssemblyReport,
74    publication: Option<super::PreparedPublication>,
75    files: Option<(ViewStore, String)>,
76    derived_checkpoint: Option<(PathBuf, Connection)>,
77    pin: Option<AssemblyPin>,
78    _base_pin: Option<crate::pins::QueryPin>,
79    profile: PublicationProfile,
80}
81
82impl PreparedAssembly {
83    pub fn report(&self) -> &AssemblyReport {
84        &self.report
85    }
86
87    pub fn commit(&mut self) -> Result<AssemblyReport> {
88        if let Some(publication) = self.publication.take() {
89            let retires_legacy_plane = publication.base_generation.is_none();
90            let pointer_started = Instant::now();
91            let outcome = publication.commit();
92            self.profile.pointer_ms = pointer_started.elapsed().as_millis();
93            match outcome? {
94                PublishOutcome::Published => {
95                    self.report.published = true;
96                    self.files = None;
97                    if retires_legacy_plane {
98                        log::info!(
99                            "views: root={} legacy plane retired at generation={}",
100                            self.profile.root.display(),
101                            self.report
102                                .generation
103                                .as_deref()
104                                .expect("prepared publication has a generation")
105                        );
106                    }
107                    if let Some((path, connection)) = self.derived_checkpoint.take() {
108                        self.profile.finish();
109                        super::generation::schedule_derived_checkpoint(
110                            path,
111                            connection,
112                            self.profile.root.clone(),
113                        );
114                    }
115                    self.profile.outcome = "published";
116                }
117                PublishOutcome::Conflict { current_generation } => {
118                    self.profile.outcome = "conflict";
119                    return Err(ViewError::InvalidManifest(format!(
120                        "publication base changed to {current_generation:?}"
121                    )));
122                }
123            }
124        }
125        self.profile.finish();
126        Ok(AssemblyReport {
127            generation: self.report.generation.take(),
128            manifest: self.report.manifest.take(),
129            blob_puts: self.report.blob_puts,
130            pending_paths: std::mem::take(&mut self.report.pending_paths),
131            published: self.report.published,
132        })
133    }
134}
135
136impl Drop for PreparedAssembly {
137    fn drop(&mut self) {
138        if let Some((view, generation)) = &self.files {
139            if view
140                .current_generation()
141                .is_ok_and(|current| current.as_deref() != Some(generation))
142            {
143                view.remove_generation_files(generation);
144            }
145        }
146        // Keep the pin alive until generation cleanup has finished.
147        self.pin.take();
148    }
149}
150
151pub fn prepare_checkout(
152    request: &AssemblyRequest,
153    phase: &mut impl FnMut(&str) -> Result<()>,
154) -> Result<PreparedAssembly> {
155    let mut timing = super::profile::PublicationTiming::new(&request.project_root);
156    // Every publisher (scheduler, migration import, tests) reaches this point
157    // with the HEAD fingerprint it observed, so the read-path cache that
158    // navigation and Tier-2 compare generations against is populated here
159    // rather than only where the scheduler happened to compute it.
160    super::cache_head_fingerprint(request.project_root.clone(), request.desired_head.clone());
161    let mut profile = PublicationProfile::new(&request.project_root);
162    profile.enter(0, phase)?;
163    let view = ViewStore::open(&request.storage, &request.scope)?;
164    let current_generation = view.current_generation()?;
165    let base_pin = current_generation
166        .as_deref()
167        .map(|generation| crate::pins::QueryPin::acquire(view.view_dir(), generation))
168        .transpose()
169        .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
170    let previous = current_generation
171        .as_deref()
172        .map(|generation| view.load_manifest(generation))
173        .transpose()?;
174    timing.phase("previous_manifest");
175    let head_started = Instant::now();
176    let head = head_tree_entries(&request.project_root)
177        .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
178    profile.head_ms = head_started.elapsed().as_millis();
179    timing.phase("head_tree");
180    let mut callgraph = BlobStore::open(
181        &request.storage,
182        request.family.clone(),
183        BlobPlane::Callgraph,
184    )
185    .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
186    let semantic = BlobStore::open(
187        &request.storage,
188        request.family.clone(),
189        BlobPlane::Semantic,
190    )
191    .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
192    let mut aliases = AliasStore::open(&request.storage, &request.family)
193        .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
194
195    timing.phase("store_opens");
196    let previous_entries = previous
197        .as_ref()
198        .map(|manifest| {
199            manifest
200                .entries()
201                .map(|(path, entry)| (path.as_bytes().to_vec(), entry.clone()))
202                .collect::<BTreeMap<_, _>>()
203        })
204        .unwrap_or_default();
205    let rebuild_all = request.changed_paths.is_empty();
206    let assembly_started = Instant::now();
207    let mut blocking_paths = BTreeSet::new();
208    let mut semantic_pending_paths = BTreeSet::new();
209    let mut candidates = Vec::with_capacity(head.len());
210
211    for tracked in head {
212        let rel_path = RelPath::new(tracked.rel_path.clone())?;
213        if !rebuild_all && !request.changed_paths.contains(&tracked.rel_path) {
214            if let Some(entry) = previous_entries.get(&tracked.rel_path) {
215                candidates.push(Candidate {
216                    path: rel_path,
217                    entry: entry.clone(),
218                    key: None,
219                    payload: None,
220                    tracked: None,
221                    source: None,
222                });
223                continue;
224            }
225        }
226
227        match tracked.mode {
228            GitMode::Gitlink => candidates.push(Candidate {
229                path: rel_path,
230                entry: ManifestEntry::Gitlink {
231                    oid: tracked.git_oid.to_hex(),
232                },
233                key: None,
234                payload: None,
235                tracked: None,
236                source: None,
237            }),
238            GitMode::Symlink => {
239                let target = read_symlink_bytes(
240                    &request
241                        .project_root
242                        .join(path_from_bytes(&tracked.rel_path)),
243                )?;
244                candidates.push(Candidate {
245                    path: rel_path,
246                    entry: ManifestEntry::Symlink {
247                        target_bytes: ByteString::new(target),
248                    },
249                    key: None,
250                    payload: None,
251                    tracked: None,
252                    source: None,
253                });
254            }
255            GitMode::Regular { executable } => {
256                let absolute = request
257                    .project_root
258                    .join(path_from_bytes(&tracked.rel_path));
259                let source = fs::read(&absolute)?;
260                let resolution_input = is_resolution_input(&tracked.rel_path);
261                let language = if resolution_input {
262                    Some("config".to_string())
263                } else {
264                    detect_language(&absolute)
265                        .map(|language| format!("{language:?}").to_lowercase())
266                };
267                let (key, payload) = language
268                    .as_deref()
269                    .map(|language| {
270                        let key = CallgraphKey::for_current(&source, language).full_key();
271                        let key_hex = key.to_hex();
272                        let callgraph_is_current = previous_entries
273                            .get(&tracked.rel_path)
274                            .is_some_and(|entry| {
275                                manifest_entry_callgraph_key(entry) == Some(key_hex.as_str())
276                            });
277                        let payload = if callgraph_is_current {
278                            None
279                        } else {
280                            missing_callgraph_payload(
281                                &callgraph,
282                                &key,
283                                &source,
284                                language,
285                                resolution_input,
286                            )?
287                        };
288                        Ok::<_, ViewError>((key, payload))
289                    })
290                    .transpose()?
291                    .map_or((None, None), |(key, payload)| (Some(key), payload));
292                let callgraph_key = key.as_ref().map(FullKey::to_hex);
293                candidates.push(Candidate {
294                    path: rel_path,
295                    entry: ManifestEntry::Regular {
296                        mode: if executable { 0o100755 } else { 0o100644 },
297                        planes: RegularPlanes {
298                            semantic: request.semantic_keys.get(&tracked.rel_path).cloned(),
299                            callgraph: callgraph_key,
300                        },
301                        resolution_input,
302                    },
303                    key,
304                    payload,
305                    tracked: Some(tracked),
306                    source: Some(source),
307                });
308            }
309            GitMode::Other(_) => {
310                blocking_paths.insert(tracked.rel_path);
311            }
312        }
313    }
314
315    timing.phase("candidate_assembly");
316    profile.candidates = candidates.len();
317    profile.assembly_ms = assembly_started.elapsed().as_millis();
318
319    if request.require_semantic {
320        for candidate in &candidates {
321            let missing = matches!(
322                &candidate.entry,
323                ManifestEntry::Regular { planes, .. }
324                    if planes.semantic.is_none()
325                        && crate::semantic_index::is_semantic_indexed_extension(
326                            &request
327                                .project_root
328                                .join(path_from_bytes(candidate.path.as_bytes()))
329                        )
330            );
331            if missing {
332                semantic_pending_paths.insert(candidate.path.as_bytes().to_vec());
333            }
334        }
335    }
336
337    let keys = candidates
338        .iter()
339        .filter_map(|candidate| candidate.key.clone())
340        .collect::<Vec<_>>();
341    let next_generation = next_generation(current_generation.as_deref(), &request.desired_head);
342    profile.generation = next_generation.clone();
343    let pin = AssemblyPin::create(
344        view.view_dir(),
345        request.family.clone(),
346        request.scope.clone(),
347        next_generation.clone(),
348        &keys,
349    )
350    .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
351    let mut prepared = PreparedAssembly {
352        report: AssemblyReport {
353            generation: current_generation.clone(),
354            manifest: previous.clone(),
355            blob_puts: 0,
356            pending_paths: BTreeSet::new(),
357            published: false,
358        },
359        publication: None,
360        files: Some((view.clone(), next_generation.clone())),
361        derived_checkpoint: None,
362        pin: Some(pin),
363        _base_pin: base_pin,
364        profile,
365    };
366    prepared.profile.enter(1, phase)?;
367    timing.phase("assembly_pin");
368    let mut blob_puts = 0;
369    for candidate in &candidates {
370        let (Some(key), Some(payload)) = (&candidate.key, &candidate.payload) else {
371            continue;
372        };
373        if request.allow_blob_put {
374            prepared
375                .pin
376                .as_mut()
377                .expect("assembly pin")
378                .renew_if_due()
379                .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
380            let put = callgraph
381                .put(key, payload)
382                .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
383            blob_puts += usize::from(matches!(put.outcome, PutOutcome::Inserted));
384            if let (Some(tracked), Some(source)) = (&candidate.tracked, &candidate.source) {
385                aliases
386                    .seed_proven_alias(tracked, source)
387                    .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
388            }
389        } else if callgraph
390            .get(key)
391            .map_err(|error| ViewError::InvalidManifest(error.to_string()))?
392            .is_none()
393        {
394            blocking_paths.insert(candidate.path.as_bytes().to_vec());
395        }
396    }
397
398    timing.phase("blob_and_alias_puts");
399    prepared.profile.blob_puts = blob_puts;
400    prepared.profile.pending_paths = blocking_paths.len() + semantic_pending_paths.len();
401    let mut status = PathStatusStore::open(view.view_dir())
402        .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
403    for path in blocking_paths.iter().chain(&semantic_pending_paths) {
404        status
405            .mark_pending(
406                path,
407                if blocking_paths.contains(path) {
408                    "shared callgraph blob unavailable"
409                } else {
410                    "shared semantic blob unavailable"
411                },
412                generation_number(&next_generation),
413            )
414            .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
415    }
416    if !blocking_paths.is_empty() {
417        prepared.profile.outcome = "pending";
418        prepared.report.blob_puts = blob_puts;
419        prepared.report.pending_paths = blocking_paths
420            .union(&semantic_pending_paths)
421            .cloned()
422            .collect();
423        return Ok(prepared);
424    }
425    for candidate in &candidates {
426        if !semantic_pending_paths.contains(candidate.path.as_bytes()) {
427            status
428                .clear(candidate.path.as_bytes())
429                .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
430        }
431    }
432    timing.phase("path_status");
433    prepared.report.pending_paths = semantic_pending_paths;
434
435    let manifest = Manifest::new(
436        candidates
437            .into_iter()
438            .map(|candidate| (candidate.path, candidate.entry)),
439    )?;
440    prepared.profile.semantic_fill = previous.as_ref().is_some_and(|base| {
441        base.entries
442            .iter()
443            .map(|(path, entry)| (path, manifest_entry_callgraph_key(entry)))
444            .eq(manifest
445                .entries
446                .iter()
447                .map(|(path, entry)| (path, manifest_entry_callgraph_key(entry))))
448            && base != &manifest
449    });
450    // A publication that reproduces the current manifest byte for byte is a
451    // no-op: callers fire on triggers that often leave HEAD untouched (a
452    // semantic refresh completing, a watcher batch of ignored edits), and each
453    // redundant generation costs a full derived-database materialization and
454    // a pointer swap. Keep the current generation and report nothing published.
455    if current_generation
456        .as_deref()
457        .is_some_and(|generation| generation.ends_with(&request.desired_head))
458        && previous.as_ref() == Some(&manifest)
459    {
460        prepared.profile.outcome = "no_op";
461        prepared.report.manifest = None;
462        prepared.report.blob_puts = blob_puts;
463        return Ok(prepared);
464    }
465    prepared.profile.enter(2, phase)?;
466    let reused_derived = previous
467        .as_ref()
468        .is_some_and(|base| super::materialization::manifest_callgraph_equivalent(base, &manifest))
469        && current_generation
470            .as_deref()
471            .is_some_and(|base| view.derived_path(base).is_ok_and(|path| path.is_file()));
472    if reused_derived {
473        view.reuse_derived(
474            &next_generation,
475            current_generation.as_deref().expect("reused base"),
476        )?;
477    }
478    prepared.profile.io.enter(super::io::Phase::Clone);
479    let derived = view.derived_path(&next_generation)?;
480    let mut cloned_base = false;
481    if !reused_derived {
482        let clone_started = Instant::now();
483        if let Some(base) = current_generation.as_deref() {
484            let base_path = view.derived_path(base)?;
485            if base_path.is_file() {
486                super::generation::clone_derived(&base_path, &derived)?;
487                cloned_base = true;
488            }
489        }
490        prepared.profile.derived_clone_ms = clone_started.elapsed().as_millis();
491        // Keep one connection alive so SQLite does not checkpoint the committed WAL
492        // when the materializer closes its writer before pointer publication.
493        let derived_keeper = Connection::open(&derived)?;
494        derived_keeper.busy_timeout(std::time::Duration::from_secs(5))?;
495        // Opening a handle or setting journal_mode alone does not attach its
496        // pager to the WAL. Read the schema so closing the materializer is not
497        // the last WAL connection and cannot checkpoint before publication.
498        derived_keeper.pragma_update(None, "journal_mode", "WAL")?;
499        derived_keeper.query_row("SELECT COUNT(*) FROM sqlite_schema", [], |row| {
500            row.get::<_, i64>(0)
501        })?;
502        prepared.derived_checkpoint = Some((derived.clone(), derived_keeper));
503        prepared.profile.io.enter(super::io::Phase::Materialize);
504        let materialization_started = Instant::now();
505        let derived_manifest = current_generation
506            .as_deref()
507            .filter(|_| cloned_base)
508            .map(|base| {
509                view.derived_owner(base)
510                    .and_then(|owner| view.load_manifest(&owner))
511            })
512            .transpose()?;
513        if let Some(base_manifest) = derived_manifest.as_ref() {
514            let (stats, timings) = super::materialization::apply_manifest_diff_profiled(
515                &derived,
516                base_manifest,
517                &manifest,
518                callgraph.path(),
519            )
520            .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
521            prepared.profile.materialization = timings;
522            log::info!(
523                "view manifest diff: generation={} stats={stats:?}",
524                next_generation
525            );
526        } else {
527            crate::callgraph_store::materialize_manifest_view_database(
528                &derived,
529                callgraph.path(),
530                &manifest,
531            )
532            .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
533        }
534        prepared.profile.materialization_call_ms = materialization_started.elapsed().as_millis();
535    }
536    prepared.profile.io.enter(super::io::Phase::DerivedOther);
537    let trigram = view.trigram_path(&next_generation)?;
538    fs::write(&trigram, [])?;
539    let artifacts = PublicationArtifacts {
540        blob_databases: if reused_derived {
541            vec![semantic.path().to_path_buf()]
542        } else {
543            vec![
544                semantic.path().to_path_buf(),
545                callgraph.path().to_path_buf(),
546            ]
547        },
548        derived_database: derived.clone(),
549        trigram_artifact: trigram.clone(),
550        alias_database: aliases.path().to_path_buf(),
551    };
552    let closure = SqliteClosure {
553        semantic: semantic.path().to_path_buf(),
554        callgraph: callgraph.path().to_path_buf(),
555        trigram,
556        connections: Default::default(),
557    };
558    prepared.profile.io.enter(super::io::Phase::Closure);
559    let closure_started = Instant::now();
560    let publication = view.prepare_with_reused_derived(
561        &PublicationRequest {
562            generation: &next_generation,
563            base_generation: current_generation.as_deref(),
564            manifest: &manifest,
565            artifacts,
566            closure_requirements: ClosureRequirements::default(),
567        },
568        &closure,
569        if reused_derived {
570            previous.as_ref()
571        } else {
572            None
573        },
574    )?;
575    prepared.profile.closure_ms = closure_started.elapsed().as_millis();
576    prepared.profile.derived_bytes = fs::metadata(&derived)
577        .map(|metadata| metadata.len())
578        .unwrap_or(0);
579    prepared.profile.enter(3, phase)?;
580    prepared.publication = Some(publication);
581    prepared.report = AssemblyReport {
582        generation: Some(next_generation),
583        manifest: Some(manifest),
584        blob_puts,
585        pending_paths: prepared.report.pending_paths.clone(),
586        published: false,
587    };
588    Ok(prepared)
589}
590
591/// One profile follows the same boundaries as cancellation and health reporting.
592/// It survives preparation so CAS time is included, and logs only after the
593/// prepared generation leaves the actor barrier (including failed attempts).
594struct PublicationProfile {
595    root: PathBuf,
596    io: super::io::PublicationIo,
597    overlap: super::io::Overlap,
598    concurrent_publications: u64,
599    semantic_fill: bool,
600    generation: String,
601    outcome: &'static str,
602    candidates: usize,
603    blob_puts: usize,
604    pending_paths: usize,
605    head_ms: u128,
606    assembly_ms: u128,
607    pointer_ms: u128,
608    phase_ms: [u128; 4],
609    active_phase: Option<usize>,
610    phase_started: Instant,
611    started: Instant,
612    total_ms: u128,
613    derived_bytes: u64,
614    derived_clone_ms: u128,
615    materialization_call_ms: u128,
616    closure_ms: u128,
617    materialization: super::materialization::profile::PhaseTimings,
618}
619
620impl PublicationProfile {
621    fn new(root: &Path) -> Self {
622        let now = Instant::now();
623        Self {
624            root: root.to_owned(),
625            io: super::io::PublicationIo::new(),
626            overlap: super::io::Overlap::new(true),
627            concurrent_publications: 0,
628            semantic_fill: false,
629            generation: "none".into(),
630            outcome: "cancelled_or_failed",
631            candidates: 0,
632            blob_puts: 0,
633            pending_paths: 0,
634            head_ms: 0,
635            assembly_ms: 0,
636            pointer_ms: 0,
637            phase_ms: [0; 4],
638            active_phase: Some(0),
639            phase_started: now,
640            started: now,
641            total_ms: 0,
642            derived_bytes: 0,
643            derived_clone_ms: 0,
644            materialization_call_ms: 0,
645            closure_ms: 0,
646            materialization: super::materialization::profile::PhaseTimings::default(),
647        }
648    }
649
650    fn enter(&mut self, phase: usize, callback: &mut impl FnMut(&str) -> Result<()>) -> Result<()> {
651        self.checkpoint();
652        self.io.enter(
653            [
654                super::io::Phase::Manifest,
655                super::io::Phase::Blobs,
656                super::io::Phase::DerivedOther,
657                super::io::Phase::Cas,
658            ][phase],
659        );
660        self.active_phase = Some(phase);
661        self.phase_started = Instant::now();
662        callback(["manifest", "blobs", "derived", "cas"][phase])
663    }
664
665    fn checkpoint(&mut self) {
666        if let Some(phase) = self.active_phase.take() {
667            self.phase_ms[phase] += self.phase_started.elapsed().as_millis();
668        }
669    }
670
671    fn finish(&mut self) {
672        if self.active_phase.is_none() {
673            return;
674        }
675        self.io.finish();
676        self.concurrent_publications = self.overlap.finish();
677        self.checkpoint();
678        self.total_ms = self.started.elapsed().as_millis();
679    }
680}
681
682impl Drop for PublicationProfile {
683    fn drop(&mut self) {
684        if self.active_phase.is_some() {
685            self.finish();
686        }
687        log_publication_profile(self);
688    }
689}
690
691fn log_publication_profile(profile: &PublicationProfile) {
692    crate::slog_info!("{}", publication_profile_line(profile));
693}
694
695fn publication_profile_line(profile: &PublicationProfile) -> String {
696    // Retain the existing drill fields alongside the shared phase names. The
697    // pointer transaction is a subset of the cas phase, which also includes
698    // waiting to acquire the actor barrier.
699    format!(
700        "index_event kind=view_publication plane=views root={} outcome={} candidates={} blob_puts={} pending_paths={} manifest_ms={} blobs_ms={} derived_ms={} cas_ms={} head_ms={} assembly_ms={} blob_ms={} materialize_ms={} derived_clone_ms={} materialization_call_ms={} closure_ms={} materialize_load_bindings_select_ms={} materialize_delete_rows_ms={} materialize_owned_blob_decode_insert_ms={} materialize_join_load_payloads_ms={} materialize_join_decode_bind_index_entries_ms={} materialize_join_index_surface_replay_ms={} materialize_join_decode_resolved_callers_ms={} materialize_join_resolve_record_ms={} materialize_join_dependency_union_ms={} materialize_selected_join_ms={} materialize_write_bindings_ms={} materialize_emit_refs_edges_ms={} materialize_commit_ms={} materialize_cleanup_memory_ms={} materialize_cleanup_connections_ms={} pointer_ms={} total_ms={} derived_bytes={} semantic_fill={} generation={} concurrent_publications={} {}",
701        profile.root.display(), profile.outcome, profile.candidates, profile.blob_puts,
702        profile.pending_paths, profile.phase_ms[0], profile.phase_ms[1],
703        profile.phase_ms[2], profile.phase_ms[3], profile.head_ms, profile.assembly_ms,
704        profile.phase_ms[1], profile.materialization_call_ms,
705        profile.derived_clone_ms,
706        profile.materialization_call_ms,
707        profile.closure_ms,
708        profile.materialization.load_bindings_select_ms,
709        profile.materialization.delete_rows_ms,
710        profile.materialization.owned_blob_decode_and_insert_ms,
711        profile.materialization.join_load_payloads_ms,
712        profile.materialization.join_decode_bind_index_entries_ms,
713        profile.materialization.join_index_and_surface_replay_ms,
714        profile.materialization.join_decode_resolved_callers_ms,
715        profile.materialization.join_resolve_and_record_ms,
716        profile.materialization.join_dependency_union_ms,
717        profile.materialization.selected_join_ms,
718        profile.materialization.write_bindings_ms,
719        profile.materialization.emit_refs_edges_ms,
720        profile.materialization.commit_ms,
721        profile.materialization.cleanup_memory_ms,
722        profile.materialization.cleanup_connections_ms,
723        profile.pointer_ms, profile.total_ms, profile.derived_bytes,
724        profile.semantic_fill, profile.generation, profile.concurrent_publications, profile.io.fields(),
725    )
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    #[test]
733    fn publication_profile_line_attributes_the_canonical_root() {
734        let mut profile = PublicationProfile::new(Path::new("/checkout"));
735        profile.outcome = "published";
736        profile.phase_ms = [5, 6, 7, 8];
737        profile.derived_clone_ms = 9;
738        profile.materialization_call_ms = 10;
739        profile.closure_ms = 11;
740        profile.materialization = crate::views::materialization::profile::PhaseTimings {
741            load_bindings_select_ms: 11,
742            delete_rows_ms: 12,
743            owned_blob_decode_and_insert_ms: 13,
744            join_load_payloads_ms: 14,
745            join_decode_bind_index_entries_ms: 15,
746            join_index_and_surface_replay_ms: 16,
747            join_decode_resolved_callers_ms: 17,
748            join_resolve_and_record_ms: 18,
749            join_dependency_union_ms: 19,
750            selected_join_ms: 20,
751            write_bindings_ms: 21,
752            emit_refs_edges_ms: 22,
753            commit_ms: 23,
754            cleanup_memory_ms: 24,
755            cleanup_connections_ms: 25,
756        };
757        let line = publication_profile_line(&profile);
758        assert!(line.contains("plane=views root=/checkout outcome=published"));
759        assert!(line.contains("manifest_ms=5 blobs_ms=6 derived_ms=7 cas_ms=8"));
760        assert!(line.contains("derived_clone_ms=9 materialization_call_ms=10 closure_ms=11"));
761        assert!(line.contains("blob_ms=6 materialize_ms=10"));
762        assert!(line.contains(
763            "materialize_load_bindings_select_ms=11 materialize_delete_rows_ms=12 \
764             materialize_owned_blob_decode_insert_ms=13 materialize_join_load_payloads_ms=14 \
765             materialize_join_decode_bind_index_entries_ms=15 \
766             materialize_join_index_surface_replay_ms=16 \
767             materialize_join_decode_resolved_callers_ms=17 \
768             materialize_join_resolve_record_ms=18 materialize_join_dependency_union_ms=19 \
769             materialize_selected_join_ms=20 materialize_write_bindings_ms=21 \
770             materialize_emit_refs_edges_ms=22 materialize_commit_ms=23 \
771             materialize_cleanup_memory_ms=24 materialize_cleanup_connections_ms=25"
772        ));
773        assert!(line.contains("io_scope=process io_available="));
774        assert!(line.contains("manifest_physical_bytes_written="));
775        assert!(line.contains("closure_logical_bytes_written="));
776        assert!(line.contains("total_bytes_read="));
777        assert!(line.contains("semantic_fill=false generation=none concurrent_publications=0"));
778        assert_eq!(line.matches("index_event kind=view_publication").count(), 1);
779    }
780}
781
782fn manifest_entry_callgraph_key(entry: &ManifestEntry) -> Option<&str> {
783    match entry {
784        ManifestEntry::Regular { planes, .. } => planes.callgraph.as_deref(),
785        ManifestEntry::Synthetic { planes, .. } => Some(&planes.callgraph),
786        ManifestEntry::Symlink { .. } | ManifestEntry::Gitlink { .. } => None,
787    }
788}
789
790pub(super) fn is_resolution_input(path: &[u8]) -> bool {
791    let name = path.rsplit(|byte| *byte == b'/').next().unwrap_or(path);
792    name == b"package.json"
793        || name == b"Cargo.toml"
794        || name == b".gitignore"
795        || name.starts_with(b"tsconfig") && name.ends_with(b".json")
796}
797
798fn next_generation(current: Option<&str>, desired_head: &str) -> String {
799    let generation = current
800        .map(generation_number)
801        .unwrap_or(0)
802        .saturating_add(1);
803    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
804    let serial = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
805    let nanos = std::time::SystemTime::now()
806        .duration_since(std::time::UNIX_EPOCH)
807        .unwrap_or_default()
808        .as_nanos();
809    format!(
810        "{generation}-{}-{nanos}-{serial}-{desired_head}",
811        std::process::id()
812    )
813}
814
815fn generation_number(generation: &str) -> u64 {
816    generation
817        .split_once('-')
818        .and_then(|(number, _)| number.parse().ok())
819        .unwrap_or(0)
820}
821
822struct SqliteClosure {
823    semantic: PathBuf,
824    callgraph: PathBuf,
825    trigram: PathBuf,
826    // Lazy handles preserve probes of empty planes and malformed keys without
827    // opening their databases. Valid keys share a handle for this closure only.
828    connections: std::cell::RefCell<BTreeMap<bool, crate::db::lifecycle::TrackedConnection>>,
829}
830
831impl PublicationClosure for SqliteClosure {
832    fn contains_blob(&self, plane: ArtifactPlane, full_key: &str) -> Result<bool> {
833        let Some(key) = decode_hex(full_key) else {
834            return Ok(false);
835        };
836        let path = match plane {
837            ArtifactPlane::Semantic => &self.semantic,
838            ArtifactPlane::Callgraph => &self.callgraph,
839        };
840        let mut connections = self.connections.borrow_mut();
841        let semantic = plane == ArtifactPlane::Semantic;
842        if let std::collections::btree_map::Entry::Vacant(entry) = connections.entry(semantic) {
843            entry.insert(crate::db::lifecycle::TrackedConnection::open(
844                path,
845                crate::db::lifecycle::SqliteStore::BlobStore,
846            )?);
847        }
848        let present = connections[&semantic]
849            .prepare_cached(
850                "SELECT 1 FROM blob_payloads INDEXED BY blob_membership WHERE full_key = ?1",
851            )?
852            .query_row([key], |_| Ok(()))
853            .optional()?
854            .is_some();
855        Ok(present)
856    }
857
858    fn probe_blobs(&self, keys: &[(ArtifactPlane, &str)]) -> Result<()> {
859        let mut present = BTreeSet::new();
860        for (plane_id, plane) in [ArtifactPlane::Semantic, ArtifactPlane::Callgraph]
861            .into_iter()
862            .enumerate()
863        {
864            let wanted = keys
865                .iter()
866                .filter(|(p, _)| *p == plane)
867                .map(|(_, key)| *key)
868                .collect::<BTreeSet<_>>()
869                .into_iter()
870                .collect::<Vec<_>>();
871            for chunk in wanted.chunks(500) {
872                // Sorted key batches follow the blob index rather than manifest
873                // path order. Keep the tracked plane handle across all batches.
874                let Some(first_valid) = chunk.iter().find(|key| decode_hex(key).is_some()) else {
875                    continue;
876                };
877                self.contains_blob(plane, first_valid)?;
878                let connections = self.connections.borrow();
879                let Some(connection) = connections.get(&(plane == ArtifactPlane::Semantic)) else {
880                    continue;
881                };
882                let decoded = chunk
883                    .iter()
884                    .filter_map(|key| decode_hex(key))
885                    .collect::<Vec<_>>();
886                if decoded.is_empty() {
887                    continue;
888                }
889                let sql = membership_query(decoded.len());
890                let mut statement = connection.prepare_cached(&sql)?;
891                for key in statement.query_map(rusqlite::params_from_iter(&decoded), |row| {
892                    row.get::<_, Vec<u8>>(0)
893                })? {
894                    present.insert((plane_id, key?));
895                }
896            }
897        }
898        // Return the first missing key in manifest order, even though membership
899        // reads are grouped by plane and sorted for index locality.
900        for &(plane, key) in keys {
901            let plane_id = usize::from(plane == ArtifactPlane::Callgraph);
902            if decode_hex(key).is_none_or(|key| !present.contains(&(plane_id, key))) {
903                return Err(ViewError::MissingBlob {
904                    plane,
905                    key: key.to_owned(),
906                });
907            }
908        }
909        Ok(())
910    }
911
912    fn trigram_is_present(&self) -> Result<bool> {
913        Ok(self.trigram.is_file())
914    }
915
916    fn contains_alias(&self, _git_oid: &str) -> Result<bool> {
917        Ok(true)
918    }
919}
920
921fn missing_callgraph_payload(
922    store: &BlobStore,
923    key: &FullKey,
924    source: &[u8],
925    language: &str,
926    resolution_input: bool,
927) -> Result<Option<Vec<u8>>> {
928    // A branch return can name content absent from the previous manifest but
929    // already stored by an earlier generation. Validate that payload before
930    // paying for extraction, serialization, and an immutable no-op put.
931    if store
932        .get(key)
933        .map_err(|error| ViewError::InvalidManifest(error.to_string()))?
934        .is_some()
935    {
936        return Ok(None);
937    }
938    let blob = if resolution_input {
939        CallgraphBlob::config(source.to_vec(), CALLGRAPH_PRODUCER_VERSION)
940    } else {
941        CallgraphBlob::extract(
942            std::str::from_utf8(source)
943                .map_err(|error| ViewError::InvalidManifest(error.to_string()))?,
944            language,
945            CALLGRAPH_PRODUCER_VERSION,
946        )
947        .map_err(|error| ViewError::InvalidManifest(error.to_string()))?
948    };
949    Ok(Some(blob.to_bytes().map_err(|error| {
950        ViewError::InvalidManifest(error.to_string())
951    })?))
952}
953
954#[cfg(test)]
955mod reuse_tests {
956    use super::*;
957
958    #[test]
959    fn views_corrupt_cached_payload_is_not_reused() {
960        let dir = tempfile::tempdir().unwrap();
961        let mut store = BlobStore::open(dir.path(), "corrupt", BlobPlane::Callgraph).unwrap();
962        let source = b"export function target() {}";
963        let key = CallgraphKey::for_current(source, "typescript").full_key();
964        let payload = missing_callgraph_payload(&store, &key, source, "typescript", false)
965            .unwrap()
966            .unwrap();
967        store.put(&key, &payload).unwrap();
968        Connection::open(store.path())
969            .unwrap()
970            .execute("UPDATE blob_payloads SET payload_digest = zeroblob(32)", [])
971            .unwrap();
972        assert!(
973            missing_callgraph_payload(&store, &key, source, "typescript", false)
974                .unwrap()
975                .is_some()
976        );
977    }
978
979    #[test]
980    fn views_cached_callgraph_payload_does_not_extract_again() {
981        let dir = tempfile::tempdir().unwrap();
982        let mut store = BlobStore::open(dir.path(), "reuse", BlobPlane::Callgraph).unwrap();
983        let source = b"export function target() {}";
984        let key = CallgraphKey::for_current(source, "typescript").full_key();
985        let payload = missing_callgraph_payload(&store, &key, source, "typescript", false)
986            .unwrap()
987            .unwrap();
988        store.put(&key, &payload).unwrap();
989        assert!(
990            missing_callgraph_payload(&store, &key, source, "typescript", false)
991                .unwrap()
992                .is_none(),
993            "cached content must not be extracted or put again"
994        );
995    }
996}
997
998fn membership_query(count: usize) -> String {
999    format!(
1000        "SELECT full_key FROM blob_payloads INDEXED BY blob_membership WHERE full_key IN ({})",
1001        vec!["?"; count].join(",")
1002    )
1003}
1004
1005fn decode_hex(value: &str) -> Option<Vec<u8>> {
1006    if value.len() != 64 {
1007        return None;
1008    }
1009    (0..value.len())
1010        .step_by(2)
1011        .map(|index| u8::from_str_radix(&value[index..index + 2], 16).ok())
1012        .collect()
1013}
1014
1015fn path_from_bytes(bytes: &[u8]) -> PathBuf {
1016    #[cfg(unix)]
1017    {
1018        use std::os::unix::ffi::OsStringExt as _;
1019        PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec()))
1020    }
1021    #[cfg(not(unix))]
1022    {
1023        PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
1024    }
1025}
1026
1027fn read_symlink_bytes(path: &Path) -> Result<Vec<u8>> {
1028    let target = fs::read_link(path)?;
1029    #[cfg(unix)]
1030    {
1031        use std::os::unix::ffi::OsStrExt as _;
1032        Ok(target.as_os_str().as_bytes().to_vec())
1033    }
1034    #[cfg(not(unix))]
1035    {
1036        Ok(target.to_string_lossy().as_bytes().to_vec())
1037    }
1038}
1039
1040#[cfg(test)]
1041#[path = "closure_connection_tests.rs"]
1042mod closure_connection_tests;
1043
1044#[cfg(test)]
1045#[path = "semantic_fill_tests.rs"]
1046mod semantic_fill_tests;