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 pointer_started = Instant::now();
90            let outcome = publication.commit();
91            self.profile.pointer_ms = pointer_started.elapsed().as_millis();
92            match outcome? {
93                PublishOutcome::Published => {
94                    self.report.published = true;
95                    self.files = None;
96                    if let Some((path, connection)) = self.derived_checkpoint.take() {
97                        super::generation::schedule_derived_checkpoint(path, connection);
98                    }
99                    self.profile.outcome = "published";
100                }
101                PublishOutcome::Conflict { current_generation } => {
102                    self.profile.outcome = "conflict";
103                    return Err(ViewError::InvalidManifest(format!(
104                        "publication base changed to {current_generation:?}"
105                    )));
106                }
107            }
108        }
109        self.profile.finish();
110        Ok(AssemblyReport {
111            generation: self.report.generation.take(),
112            manifest: self.report.manifest.take(),
113            blob_puts: self.report.blob_puts,
114            pending_paths: std::mem::take(&mut self.report.pending_paths),
115            published: self.report.published,
116        })
117    }
118}
119
120impl Drop for PreparedAssembly {
121    fn drop(&mut self) {
122        if let Some((view, generation)) = &self.files {
123            if view
124                .current_generation()
125                .is_ok_and(|current| current.as_deref() != Some(generation))
126            {
127                view.remove_generation_files(generation);
128            }
129        }
130        // Keep the pin alive until generation cleanup has finished.
131        self.pin.take();
132    }
133}
134
135pub fn prepare_checkout(
136    request: &AssemblyRequest,
137    phase: &mut impl FnMut(&str) -> Result<()>,
138) -> Result<PreparedAssembly> {
139    let mut profile = PublicationProfile::new(&request.project_root);
140    profile.enter(0, phase)?;
141    let view = ViewStore::open(&request.storage, &request.scope)?;
142    let current_generation = view.current_generation()?;
143    let base_pin = current_generation
144        .as_deref()
145        .map(|generation| crate::pins::QueryPin::acquire(view.view_dir(), generation))
146        .transpose()
147        .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
148    let previous = current_generation
149        .as_deref()
150        .map(|generation| view.load_manifest(generation))
151        .transpose()?;
152    let head_started = Instant::now();
153    let head = head_tree_entries(&request.project_root)
154        .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
155    profile.head_ms = head_started.elapsed().as_millis();
156    let mut callgraph = BlobStore::open(
157        &request.storage,
158        request.family.clone(),
159        BlobPlane::Callgraph,
160    )
161    .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
162    let semantic = BlobStore::open(
163        &request.storage,
164        request.family.clone(),
165        BlobPlane::Semantic,
166    )
167    .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
168    let mut aliases = AliasStore::open(&request.storage, &request.family)
169        .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
170
171    let previous_entries = previous
172        .as_ref()
173        .map(|manifest| {
174            manifest
175                .entries()
176                .map(|(path, entry)| (path.as_bytes().to_vec(), entry.clone()))
177                .collect::<BTreeMap<_, _>>()
178        })
179        .unwrap_or_default();
180    let rebuild_all = request.changed_paths.is_empty();
181    let assembly_started = Instant::now();
182    let mut blocking_paths = BTreeSet::new();
183    let mut semantic_pending_paths = BTreeSet::new();
184    let mut candidates = Vec::with_capacity(head.len());
185
186    for tracked in head {
187        let rel_path = RelPath::new(tracked.rel_path.clone())?;
188        if !rebuild_all && !request.changed_paths.contains(&tracked.rel_path) {
189            if let Some(entry) = previous_entries.get(&tracked.rel_path) {
190                candidates.push(Candidate {
191                    path: rel_path,
192                    entry: entry.clone(),
193                    key: None,
194                    payload: None,
195                    tracked: None,
196                    source: None,
197                });
198                continue;
199            }
200        }
201
202        match tracked.mode {
203            GitMode::Gitlink => candidates.push(Candidate {
204                path: rel_path,
205                entry: ManifestEntry::Gitlink {
206                    oid: tracked.git_oid.to_hex(),
207                },
208                key: None,
209                payload: None,
210                tracked: None,
211                source: None,
212            }),
213            GitMode::Symlink => {
214                let target = read_symlink_bytes(
215                    &request
216                        .project_root
217                        .join(path_from_bytes(&tracked.rel_path)),
218                )?;
219                candidates.push(Candidate {
220                    path: rel_path,
221                    entry: ManifestEntry::Symlink {
222                        target_bytes: ByteString::new(target),
223                    },
224                    key: None,
225                    payload: None,
226                    tracked: None,
227                    source: None,
228                });
229            }
230            GitMode::Regular { executable } => {
231                let absolute = request
232                    .project_root
233                    .join(path_from_bytes(&tracked.rel_path));
234                let source = fs::read(&absolute)?;
235                let resolution_input = is_resolution_input(&tracked.rel_path);
236                let language = if resolution_input {
237                    Some("config".to_string())
238                } else {
239                    detect_language(&absolute)
240                        .map(|language| format!("{language:?}").to_lowercase())
241                };
242                let (key, payload) = language
243                    .as_deref()
244                    .map(|language| {
245                        let key = CallgraphKey::for_current(&source, language).full_key();
246                        let key_hex = key.to_hex();
247                        let callgraph_is_current = previous_entries
248                            .get(&tracked.rel_path)
249                            .is_some_and(|entry| {
250                                manifest_entry_callgraph_key(entry) == Some(key_hex.as_str())
251                            });
252                        let payload = if callgraph_is_current {
253                            None
254                        } else {
255                            let blob = if resolution_input {
256                                CallgraphBlob::config(source.clone(), CALLGRAPH_PRODUCER_VERSION)
257                            } else {
258                                CallgraphBlob::extract(
259                                    std::str::from_utf8(&source).map_err(|error| {
260                                        ViewError::InvalidManifest(error.to_string())
261                                    })?,
262                                    language,
263                                    CALLGRAPH_PRODUCER_VERSION,
264                                )
265                                .map_err(|error| ViewError::InvalidManifest(error.to_string()))?
266                            };
267                            Some(
268                                blob.to_bytes().map_err(|error| {
269                                    ViewError::InvalidManifest(error.to_string())
270                                })?,
271                            )
272                        };
273                        Ok::<_, ViewError>((key, payload))
274                    })
275                    .transpose()?
276                    .map_or((None, None), |(key, payload)| (Some(key), payload));
277                let callgraph_key = key.as_ref().map(FullKey::to_hex);
278                candidates.push(Candidate {
279                    path: rel_path,
280                    entry: ManifestEntry::Regular {
281                        mode: if executable { 0o100755 } else { 0o100644 },
282                        planes: RegularPlanes {
283                            semantic: request.semantic_keys.get(&tracked.rel_path).cloned(),
284                            callgraph: callgraph_key,
285                        },
286                        resolution_input,
287                    },
288                    key,
289                    payload,
290                    tracked: Some(tracked),
291                    source: Some(source),
292                });
293            }
294            GitMode::Other(_) => {
295                blocking_paths.insert(tracked.rel_path);
296            }
297        }
298    }
299
300    profile.candidates = candidates.len();
301    profile.assembly_ms = assembly_started.elapsed().as_millis();
302
303    if request.require_semantic {
304        for candidate in &candidates {
305            let missing = matches!(
306                &candidate.entry,
307                ManifestEntry::Regular { planes, .. }
308                    if planes.semantic.is_none()
309                        && crate::semantic_index::is_semantic_indexed_extension(
310                            &request
311                                .project_root
312                                .join(path_from_bytes(candidate.path.as_bytes()))
313                        )
314            );
315            if missing {
316                semantic_pending_paths.insert(candidate.path.as_bytes().to_vec());
317            }
318        }
319    }
320
321    let keys = candidates
322        .iter()
323        .filter_map(|candidate| candidate.key.clone())
324        .collect::<Vec<_>>();
325    let next_generation = next_generation(current_generation.as_deref(), &request.desired_head);
326    let pin = AssemblyPin::create(
327        view.view_dir(),
328        request.family.clone(),
329        request.scope.clone(),
330        next_generation.clone(),
331        &keys,
332    )
333    .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
334    let mut prepared = PreparedAssembly {
335        report: AssemblyReport {
336            generation: current_generation.clone(),
337            manifest: previous.clone(),
338            blob_puts: 0,
339            pending_paths: BTreeSet::new(),
340            published: false,
341        },
342        publication: None,
343        files: Some((view.clone(), next_generation.clone())),
344        derived_checkpoint: None,
345        pin: Some(pin),
346        _base_pin: base_pin,
347        profile,
348    };
349    prepared.profile.enter(1, phase)?;
350    let mut blob_puts = 0;
351    for candidate in &candidates {
352        let (Some(key), Some(payload)) = (&candidate.key, &candidate.payload) else {
353            continue;
354        };
355        if request.allow_blob_put {
356            prepared
357                .pin
358                .as_mut()
359                .expect("assembly pin")
360                .renew_if_due()
361                .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
362            let put = callgraph
363                .put(key, payload)
364                .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
365            blob_puts += usize::from(matches!(put.outcome, PutOutcome::Inserted));
366            if let (Some(tracked), Some(source)) = (&candidate.tracked, &candidate.source) {
367                aliases
368                    .seed_proven_alias(tracked, source)
369                    .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
370            }
371        } else if callgraph
372            .get(key)
373            .map_err(|error| ViewError::InvalidManifest(error.to_string()))?
374            .is_none()
375        {
376            blocking_paths.insert(candidate.path.as_bytes().to_vec());
377        }
378    }
379
380    prepared.profile.blob_puts = blob_puts;
381    prepared.profile.pending_paths = blocking_paths.len() + semantic_pending_paths.len();
382    let mut status = PathStatusStore::open(view.view_dir())
383        .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
384    for path in blocking_paths.iter().chain(&semantic_pending_paths) {
385        status
386            .mark_pending(
387                path,
388                if blocking_paths.contains(path) {
389                    "shared callgraph blob unavailable"
390                } else {
391                    "shared semantic blob unavailable"
392                },
393                generation_number(&next_generation),
394            )
395            .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
396    }
397    if !blocking_paths.is_empty() {
398        prepared.profile.outcome = "pending";
399        prepared.report.blob_puts = blob_puts;
400        prepared.report.pending_paths = blocking_paths
401            .union(&semantic_pending_paths)
402            .cloned()
403            .collect();
404        return Ok(prepared);
405    }
406    for candidate in &candidates {
407        if !semantic_pending_paths.contains(candidate.path.as_bytes()) {
408            status
409                .clear(candidate.path.as_bytes())
410                .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
411        }
412    }
413    prepared.report.pending_paths = semantic_pending_paths;
414
415    let manifest = Manifest::new(
416        candidates
417            .into_iter()
418            .map(|candidate| (candidate.path, candidate.entry)),
419    )?;
420    // A publication that reproduces the current manifest byte for byte is a
421    // no-op: callers fire on triggers that often leave HEAD untouched (a
422    // semantic refresh completing, a watcher batch of ignored edits), and each
423    // redundant generation costs a full derived-database materialization and
424    // a pointer swap. Keep the current generation and report nothing published.
425    if current_generation
426        .as_deref()
427        .is_some_and(|generation| generation.ends_with(&request.desired_head))
428        && previous.as_ref() == Some(&manifest)
429    {
430        prepared.profile.outcome = "no_op";
431        prepared.report.manifest = None;
432        prepared.report.blob_puts = blob_puts;
433        return Ok(prepared);
434    }
435    prepared.profile.enter(2, phase)?;
436    let derived = view.derived_path(&next_generation)?;
437    let mut cloned_base = false;
438    let clone_started = Instant::now();
439    if let Some(base) = current_generation.as_deref() {
440        let base_path = view.derived_path(base)?;
441        if base_path.is_file() {
442            super::generation::clone_derived(&base_path, &derived)?;
443            cloned_base = true;
444        }
445    }
446    prepared.profile.derived_clone_ms = clone_started.elapsed().as_millis();
447    // Keep one connection alive so SQLite does not checkpoint the committed WAL
448    // when the materializer closes its writer before pointer publication.
449    let derived_keeper = Connection::open(&derived)?;
450    derived_keeper.busy_timeout(std::time::Duration::from_secs(5))?;
451    prepared.derived_checkpoint = Some((derived.clone(), derived_keeper));
452    let materialization_started = Instant::now();
453    if let Some(base_manifest) = previous.as_ref().filter(|_| cloned_base) {
454        let (stats, timings) = super::materialization::apply_manifest_diff_profiled(
455            &derived,
456            base_manifest,
457            &manifest,
458            callgraph.path(),
459        )
460        .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
461        prepared.profile.materialization = timings;
462        log::info!(
463            "view manifest diff: generation={} stats={stats:?}",
464            next_generation
465        );
466    } else {
467        crate::callgraph_store::materialize_manifest_view_database(
468            &derived,
469            callgraph.path(),
470            &manifest,
471        )
472        .map_err(|error| ViewError::InvalidManifest(error.to_string()))?;
473    }
474    prepared.profile.materialization_call_ms = materialization_started.elapsed().as_millis();
475    let trigram = view.trigram_path(&next_generation)?;
476    fs::write(&trigram, [])?;
477    let artifacts = PublicationArtifacts {
478        blob_databases: vec![
479            semantic.path().to_path_buf(),
480            callgraph.path().to_path_buf(),
481        ],
482        derived_database: derived.clone(),
483        trigram_artifact: trigram.clone(),
484        alias_database: aliases.path().to_path_buf(),
485    };
486    let closure = SqliteClosure {
487        semantic: semantic.path().to_path_buf(),
488        callgraph: callgraph.path().to_path_buf(),
489        trigram,
490        connections: Default::default(),
491    };
492    let closure_started = Instant::now();
493    let publication = view.prepare_with_observer(
494        &PublicationRequest {
495            generation: &next_generation,
496            base_generation: current_generation.as_deref(),
497            manifest: &manifest,
498            artifacts,
499            closure_requirements: ClosureRequirements::default(),
500        },
501        &closure,
502        None,
503    )?;
504    prepared.profile.closure_ms = closure_started.elapsed().as_millis();
505    prepared.profile.derived_bytes = fs::metadata(&derived)
506        .map(|metadata| metadata.len())
507        .unwrap_or(0);
508    prepared.profile.enter(3, phase)?;
509    prepared.publication = Some(publication);
510    prepared.report = AssemblyReport {
511        generation: Some(next_generation),
512        manifest: Some(manifest),
513        blob_puts,
514        pending_paths: prepared.report.pending_paths.clone(),
515        published: false,
516    };
517    Ok(prepared)
518}
519
520/// One profile follows the same boundaries as cancellation and health reporting.
521/// It survives preparation so CAS time is included, and logs only after the
522/// prepared generation leaves the actor barrier (including failed attempts).
523struct PublicationProfile {
524    root: PathBuf,
525    outcome: &'static str,
526    candidates: usize,
527    blob_puts: usize,
528    pending_paths: usize,
529    head_ms: u128,
530    assembly_ms: u128,
531    pointer_ms: u128,
532    phase_ms: [u128; 4],
533    active_phase: Option<usize>,
534    phase_started: Instant,
535    started: Instant,
536    total_ms: u128,
537    derived_bytes: u64,
538    derived_clone_ms: u128,
539    materialization_call_ms: u128,
540    closure_ms: u128,
541    materialization: super::materialization::profile::PhaseTimings,
542}
543
544impl PublicationProfile {
545    fn new(root: &Path) -> Self {
546        let now = Instant::now();
547        Self {
548            root: root.to_owned(),
549            outcome: "cancelled_or_failed",
550            candidates: 0,
551            blob_puts: 0,
552            pending_paths: 0,
553            head_ms: 0,
554            assembly_ms: 0,
555            pointer_ms: 0,
556            phase_ms: [0; 4],
557            active_phase: Some(0),
558            phase_started: now,
559            started: now,
560            total_ms: 0,
561            derived_bytes: 0,
562            derived_clone_ms: 0,
563            materialization_call_ms: 0,
564            closure_ms: 0,
565            materialization: super::materialization::profile::PhaseTimings::default(),
566        }
567    }
568
569    fn enter(&mut self, phase: usize, callback: &mut impl FnMut(&str) -> Result<()>) -> Result<()> {
570        self.checkpoint();
571        self.active_phase = Some(phase);
572        self.phase_started = Instant::now();
573        callback(["manifest", "blobs", "derived", "cas"][phase])
574    }
575
576    fn checkpoint(&mut self) {
577        if let Some(phase) = self.active_phase.take() {
578            self.phase_ms[phase] += self.phase_started.elapsed().as_millis();
579        }
580    }
581
582    fn finish(&mut self) {
583        self.checkpoint();
584        self.total_ms = self.started.elapsed().as_millis();
585    }
586}
587
588impl Drop for PublicationProfile {
589    fn drop(&mut self) {
590        if self.active_phase.is_some() {
591            self.finish();
592        }
593        log_publication_profile(self);
594    }
595}
596
597fn log_publication_profile(profile: &PublicationProfile) {
598    crate::slog_info!("{}", publication_profile_line(profile));
599}
600
601fn publication_profile_line(profile: &PublicationProfile) -> String {
602    // Retain the existing drill fields alongside the shared phase names. The
603    // pointer transaction is a subset of the cas phase, which also includes
604    // waiting to acquire the actor barrier.
605    format!(
606        "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={} pointer_ms={} total_ms={} derived_bytes={}",
607        profile.root.display(), profile.outcome, profile.candidates, profile.blob_puts,
608        profile.pending_paths, profile.phase_ms[0], profile.phase_ms[1],
609        profile.phase_ms[2], profile.phase_ms[3], profile.head_ms, profile.assembly_ms,
610        profile.phase_ms[1], profile.materialization_call_ms,
611        profile.derived_clone_ms,
612        profile.materialization_call_ms,
613        profile.closure_ms,
614        profile.materialization.load_bindings_select_ms,
615        profile.materialization.delete_rows_ms,
616        profile.materialization.owned_blob_decode_and_insert_ms,
617        profile.materialization.join_load_payloads_ms,
618        profile.materialization.join_decode_bind_index_entries_ms,
619        profile.materialization.join_index_and_surface_replay_ms,
620        profile.materialization.join_decode_resolved_callers_ms,
621        profile.materialization.join_resolve_and_record_ms,
622        profile.materialization.join_dependency_union_ms,
623        profile.materialization.selected_join_ms,
624        profile.materialization.write_bindings_ms,
625        profile.materialization.emit_refs_edges_ms,
626        profile.materialization.commit_ms,
627        profile.pointer_ms, profile.total_ms, profile.derived_bytes,
628    )
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn publication_profile_line_attributes_the_canonical_root() {
637        let mut profile = PublicationProfile::new(Path::new("/checkout"));
638        profile.outcome = "published";
639        profile.phase_ms = [5, 6, 7, 8];
640        profile.derived_clone_ms = 9;
641        profile.materialization_call_ms = 10;
642        profile.closure_ms = 11;
643        profile.materialization = crate::views::materialization::profile::PhaseTimings {
644            load_bindings_select_ms: 11,
645            delete_rows_ms: 12,
646            owned_blob_decode_and_insert_ms: 13,
647            join_load_payloads_ms: 14,
648            join_decode_bind_index_entries_ms: 15,
649            join_index_and_surface_replay_ms: 16,
650            join_decode_resolved_callers_ms: 17,
651            join_resolve_and_record_ms: 18,
652            join_dependency_union_ms: 19,
653            selected_join_ms: 20,
654            write_bindings_ms: 21,
655            emit_refs_edges_ms: 22,
656            commit_ms: 23,
657        };
658        let line = publication_profile_line(&profile);
659        assert!(line.contains("plane=views root=/checkout outcome=published"));
660        assert!(line.contains("manifest_ms=5 blobs_ms=6 derived_ms=7 cas_ms=8"));
661        assert!(line.contains("derived_clone_ms=9 materialization_call_ms=10 closure_ms=11"));
662        assert!(line.contains("blob_ms=6 materialize_ms=10"));
663        assert!(line.contains(
664            "materialize_load_bindings_select_ms=11 materialize_delete_rows_ms=12 \
665             materialize_owned_blob_decode_insert_ms=13 materialize_join_load_payloads_ms=14 \
666             materialize_join_decode_bind_index_entries_ms=15 \
667             materialize_join_index_surface_replay_ms=16 \
668             materialize_join_decode_resolved_callers_ms=17 \
669             materialize_join_resolve_record_ms=18 materialize_join_dependency_union_ms=19 \
670             materialize_selected_join_ms=20 materialize_write_bindings_ms=21 \
671             materialize_emit_refs_edges_ms=22 materialize_commit_ms=23"
672        ));
673        assert_eq!(line.matches("index_event kind=view_publication").count(), 1);
674    }
675}
676
677fn manifest_entry_callgraph_key(entry: &ManifestEntry) -> Option<&str> {
678    match entry {
679        ManifestEntry::Regular { planes, .. } => planes.callgraph.as_deref(),
680        ManifestEntry::Synthetic { planes, .. } => Some(&planes.callgraph),
681        ManifestEntry::Symlink { .. } | ManifestEntry::Gitlink { .. } => None,
682    }
683}
684
685fn is_resolution_input(path: &[u8]) -> bool {
686    let name = path.rsplit(|byte| *byte == b'/').next().unwrap_or(path);
687    name == b"package.json"
688        || name == b"Cargo.toml"
689        || name == b".gitignore"
690        || name.starts_with(b"tsconfig") && name.ends_with(b".json")
691}
692
693fn next_generation(current: Option<&str>, desired_head: &str) -> String {
694    let generation = current
695        .map(generation_number)
696        .unwrap_or(0)
697        .saturating_add(1);
698    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
699    let serial = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
700    let nanos = std::time::SystemTime::now()
701        .duration_since(std::time::UNIX_EPOCH)
702        .unwrap_or_default()
703        .as_nanos();
704    format!(
705        "{generation}-{}-{nanos}-{serial}-{desired_head}",
706        std::process::id()
707    )
708}
709
710fn generation_number(generation: &str) -> u64 {
711    generation
712        .split_once('-')
713        .and_then(|(number, _)| number.parse().ok())
714        .unwrap_or(0)
715}
716
717struct SqliteClosure {
718    semantic: PathBuf,
719    callgraph: PathBuf,
720    trigram: PathBuf,
721    // Lazy handles preserve probes of empty planes and malformed keys without
722    // opening their databases. Valid keys share a handle for this closure only.
723    connections: std::cell::RefCell<BTreeMap<bool, crate::db::lifecycle::TrackedConnection>>,
724}
725
726impl PublicationClosure for SqliteClosure {
727    fn contains_blob(&self, plane: ArtifactPlane, full_key: &str) -> Result<bool> {
728        let Some(key) = decode_hex(full_key) else {
729            return Ok(false);
730        };
731        let path = match plane {
732            ArtifactPlane::Semantic => &self.semantic,
733            ArtifactPlane::Callgraph => &self.callgraph,
734        };
735        let mut connections = self.connections.borrow_mut();
736        let semantic = plane == ArtifactPlane::Semantic;
737        if let std::collections::btree_map::Entry::Vacant(entry) = connections.entry(semantic) {
738            entry.insert(crate::db::lifecycle::TrackedConnection::open(
739                path,
740                crate::db::lifecycle::SqliteStore::BlobStore,
741            )?);
742        }
743        let present = connections[&semantic]
744            .prepare_cached("SELECT 1 FROM blob_payloads WHERE full_key = ?1")?
745            .query_row([key], |_| Ok(()))
746            .optional()?
747            .is_some();
748        Ok(present)
749    }
750
751    fn probe_blobs(&self, keys: &[(ArtifactPlane, &str)]) -> Result<()> {
752        let mut present = BTreeSet::new();
753        for (plane_id, plane) in [ArtifactPlane::Semantic, ArtifactPlane::Callgraph]
754            .into_iter()
755            .enumerate()
756        {
757            let wanted = keys
758                .iter()
759                .filter(|(p, _)| *p == plane)
760                .map(|(_, key)| *key)
761                .collect::<BTreeSet<_>>()
762                .into_iter()
763                .collect::<Vec<_>>();
764            for chunk in wanted.chunks(500) {
765                // Sorted key batches follow the blob index rather than manifest
766                // path order. Keep the tracked plane handle across all batches.
767                let Some(first_valid) = chunk.iter().find(|key| decode_hex(key).is_some()) else {
768                    continue;
769                };
770                self.contains_blob(plane, first_valid)?;
771                let connections = self.connections.borrow();
772                let Some(connection) = connections.get(&(plane == ArtifactPlane::Semantic)) else {
773                    continue;
774                };
775                let decoded = chunk
776                    .iter()
777                    .filter_map(|key| decode_hex(key))
778                    .collect::<Vec<_>>();
779                if decoded.is_empty() {
780                    continue;
781                }
782                let sql = format!(
783                    "SELECT full_key FROM blob_payloads WHERE full_key IN ({})",
784                    vec!["?"; decoded.len()].join(",")
785                );
786                let mut statement = connection.prepare_cached(&sql)?;
787                for key in statement.query_map(rusqlite::params_from_iter(&decoded), |row| {
788                    row.get::<_, Vec<u8>>(0)
789                })? {
790                    present.insert((plane_id, key?));
791                }
792            }
793        }
794        // Return the first missing key in manifest order, even though membership
795        // reads are grouped by plane and sorted for index locality.
796        for &(plane, key) in keys {
797            let plane_id = usize::from(plane == ArtifactPlane::Callgraph);
798            if decode_hex(key).is_none_or(|key| !present.contains(&(plane_id, key))) {
799                return Err(ViewError::MissingBlob {
800                    plane,
801                    key: key.to_owned(),
802                });
803            }
804        }
805        Ok(())
806    }
807
808    fn trigram_is_present(&self) -> Result<bool> {
809        Ok(self.trigram.is_file())
810    }
811
812    fn contains_alias(&self, _git_oid: &str) -> Result<bool> {
813        Ok(true)
814    }
815}
816
817fn decode_hex(value: &str) -> Option<Vec<u8>> {
818    if value.len() != 64 {
819        return None;
820    }
821    (0..value.len())
822        .step_by(2)
823        .map(|index| u8::from_str_radix(&value[index..index + 2], 16).ok())
824        .collect()
825}
826
827fn path_from_bytes(bytes: &[u8]) -> PathBuf {
828    #[cfg(unix)]
829    {
830        use std::os::unix::ffi::OsStringExt as _;
831        PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec()))
832    }
833    #[cfg(not(unix))]
834    {
835        PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
836    }
837}
838
839fn read_symlink_bytes(path: &Path) -> Result<Vec<u8>> {
840    let target = fs::read_link(path)?;
841    #[cfg(unix)]
842    {
843        use std::os::unix::ffi::OsStrExt as _;
844        Ok(target.as_os_str().as_bytes().to_vec())
845    }
846    #[cfg(not(unix))]
847    {
848        Ok(target.to_string_lossy().as_bytes().to_vec())
849    }
850}
851
852#[cfg(test)]
853#[path = "closure_connection_tests.rs"]
854mod closure_connection_tests;