Skip to main content

backhopper_core/jigsaw/
loaded.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::iter;
7use std::panic;
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::thread;
10
11use serde::{Deserialize, Serialize};
12
13use crate::digest::DigestWriter;
14use crate::errors::StoreError;
15use crate::jigsaw::plan::JigsawPlan;
16use crate::jigsaw::surface::{PinApps, PlanSurfaces};
17use crate::model::names::{ApplicationName, CommitSha, ProjectName, StoreDigest, vocabulary};
18use crate::model::pin::Pin;
19use crate::model::snapshot::{Snapshot, SnapshotHeader, state};
20use crate::snapshot::freshness::{ExpectedExtraction, StaleReason, extraction_freshness};
21use crate::snapshot::parser;
22use crate::store::{ReadOnly, SnapshotStore};
23
24vocabulary!(
25    pub enum SnapshotGap: "snapshot gap" {
26        Missing => "missing",
27        Stale => "stale",
28        CommitMismatch => "commit_mismatch",
29    }
30);
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct StalePin {
34    pub pin: Pin,
35    pub reason: StaleReason,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct CommitMismatch {
40    pub pin: Pin,
41    pub expected: CommitSha,
42    pub found: CommitSha,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
46pub struct UnstockedPlan {
47    pub missing: Vec<Pin>,
48    pub stale: Vec<StalePin>,
49    pub commit_mismatch: Vec<CommitMismatch>,
50}
51
52impl UnstockedPlan {
53    #[must_use]
54    pub fn is_empty(&self) -> bool {
55        self.missing.is_empty() && self.stale.is_empty() && self.commit_mismatch.is_empty()
56    }
57
58    /// Why a stale or mismatched pin is not usable as stored.
59    #[must_use]
60    pub fn detail(&self, pin: &Pin) -> Option<String> {
61        let stale = self
62            .stale
63            .iter()
64            .find(|stale| &stale.pin == pin)
65            .map(|stale| stale.reason.to_string());
66        stale.or_else(|| {
67            self.commit_mismatch
68                .iter()
69                .find(|mismatch| &mismatch.pin == pin)
70                .map(|mismatch| format!("expected {}, found {}", mismatch.expected, mismatch.found))
71        })
72    }
73
74    #[must_use]
75    pub fn gaps(&self) -> Vec<(Pin, SnapshotGap)> {
76        let missing = self
77            .missing
78            .iter()
79            .map(|pin| (pin.clone(), SnapshotGap::Missing));
80        let stale = self
81            .stale
82            .iter()
83            .map(|s| (s.pin.clone(), SnapshotGap::Stale));
84        let mismatched = self
85            .commit_mismatch
86            .iter()
87            .map(|m| (m.pin.clone(), SnapshotGap::CommitMismatch));
88        let mut gaps: Vec<_> = missing.chain(stale).chain(mismatched).collect();
89        gaps.sort_by(|a, b| a.0.cmp(&b.0));
90        gaps
91    }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum StoreStatus {
96    Stocked,
97    Unstocked(UnstockedPlan),
98}
99
100enum Gap {
101    Missing,
102    Stale(StaleReason),
103    CommitMismatch {
104        expected: CommitSha,
105        found: CommitSha,
106    },
107}
108
109impl UnstockedPlan {
110    fn record(&mut self, pin: Pin, gap: Gap) {
111        match gap {
112            Gap::Missing => self.missing.push(pin),
113            Gap::Stale(reason) => self.stale.push(StalePin { pin, reason }),
114            Gap::CommitMismatch { expected, found } => self.commit_mismatch.push(CommitMismatch {
115                pin,
116                expected,
117                found,
118            }),
119        }
120    }
121}
122
123fn check_header(
124    plan: &JigsawPlan,
125    pin: &Pin,
126    header: &SnapshotHeader,
127    expected: Option<ExpectedExtraction>,
128) -> Option<Gap> {
129    let Some(expected) = expected else {
130        return Some(Gap::Missing);
131    };
132    if let Some(reason) = extraction_freshness(header, &expected) {
133        return Some(Gap::Stale(reason));
134    }
135    if &pin.project != plan.host_project() {
136        return None;
137    }
138    if header.host_facts.as_ref().map(|facts| &facts.digest) != Some(plan.host_facts()) {
139        return Some(Gap::Stale(StaleReason::HostFacts));
140    }
141    let release = plan
142        .releases()
143        .iter()
144        .find(|release| release.tag.tag() == &pin.tag)?;
145    (release.commit != header.commit).then(|| Gap::CommitMismatch {
146        expected: release.commit.clone(),
147        found: header.commit.clone(),
148    })
149}
150
151fn unreadable(error: &StoreError) -> Gap {
152    match error {
153        StoreError::SnapshotNotFound { .. } => Gap::Missing,
154        _ => Gap::Stale(StaleReason::Unreadable),
155    }
156}
157
158impl JigsawPlan {
159    pub fn store_status(
160        &self,
161        store: &SnapshotStore<ReadOnly>,
162        expected: impl Fn(&ProjectName) -> Option<ExpectedExtraction>,
163    ) -> StoreStatus {
164        let mut unstocked = UnstockedPlan::default();
165        for pin in self.required() {
166            let gap = match store.read_header(&pin.project, &pin.tag) {
167                Ok(header) => check_header(self, &pin, &header, expected(&pin.project)),
168                Err(error) => Some(unreadable(&error)),
169            };
170            if let Some(gap) = gap {
171                unstocked.record(pin, gap);
172            }
173        }
174        if unstocked.is_empty() {
175            StoreStatus::Stocked
176        } else {
177            StoreStatus::Unstocked(unstocked)
178        }
179    }
180
181    #[must_use]
182    pub fn nothing_stored(&self) -> StoreStatus {
183        StoreStatus::Unstocked(self.all_missing())
184    }
185
186    /// What `load` would report against a store with nothing in it.
187    #[must_use]
188    pub fn all_missing(&self) -> UnstockedPlan {
189        UnstockedPlan {
190            missing: self.required().into_iter().collect(),
191            ..UnstockedPlan::default()
192        }
193    }
194
195    pub fn load(
196        self,
197        store: &SnapshotStore<ReadOnly>,
198        expected: impl Fn(&ProjectName) -> Option<ExpectedExtraction> + Sync,
199    ) -> Result<LoadedPlan, UnstockedPlan> {
200        let pins: Vec<Pin> = self.required().into_iter().collect();
201        let outcomes = read_all(&self, store, &expected, &pins);
202        let mut unstocked = UnstockedPlan::default();
203        let mut snapshots = BTreeMap::new();
204        for (pin, outcome) in pins.into_iter().zip(outcomes) {
205            match outcome {
206                Ok(snapshot) => {
207                    snapshots.insert(pin, snapshot);
208                }
209                Err(gap) => unstocked.record(pin, gap),
210            }
211        }
212        if !unstocked.is_empty() {
213            return Err(unstocked);
214        }
215        let surfaces = Surfaces::build(&self, &snapshots)?;
216        let store_digest = store_digest(&snapshots);
217        Ok(LoadedPlan {
218            plan: self,
219            snapshots,
220            store_digest,
221            surfaces,
222        })
223    }
224}
225
226fn read_one(
227    plan: &JigsawPlan,
228    store: &SnapshotStore<ReadOnly>,
229    expected: Option<ExpectedExtraction>,
230    pin: &Pin,
231) -> Result<Snapshot<state::Canonical>, Gap> {
232    let text = store
233        .read_text(&pin.project, &pin.tag)
234        .map_err(|e| unreadable(&e))?;
235    let header = parser::parse_header(&text).map_err(|_| Gap::Stale(StaleReason::Unreadable))?;
236    if let Some(gap) = check_header(plan, pin, &header, expected) {
237        return Err(gap);
238    }
239    parser::parse(&text).map_err(|_| Gap::Stale(StaleReason::Unreadable))
240}
241
242/// Reads on scoped threads: the OTP snapshots are most of the bytes.
243fn read_all(
244    plan: &JigsawPlan,
245    store: &SnapshotStore<ReadOnly>,
246    expected: &(impl Fn(&ProjectName) -> Option<ExpectedExtraction> + Sync),
247    pins: &[Pin],
248) -> Vec<Result<Snapshot<state::Canonical>, Gap>> {
249    let workers = thread::available_parallelism()
250        .map_or(1, usize::from)
251        .min(pins.len().max(1));
252    let next = AtomicUsize::new(0);
253    let mut outcomes = Vec::with_capacity(pins.len());
254    thread::scope(|scope| {
255        let handles: Vec<_> = (0..workers)
256            .map(|_| {
257                scope.spawn(|| {
258                    let mut done = Vec::new();
259                    loop {
260                        let index = next.fetch_add(1, Ordering::Relaxed);
261                        let Some(pin) = pins.get(index) else {
262                            break done;
263                        };
264                        done.push((index, read_one(plan, store, expected(&pin.project), pin)));
265                    }
266                })
267            })
268            .collect();
269        for handle in handles {
270            match handle.join() {
271                Ok(done) => outcomes.extend(done),
272                Err(payload) => panic::resume_unwind(payload),
273            }
274        }
275    });
276    outcomes.sort_by_key(|(index, _)| *index);
277    outcomes.into_iter().map(|(_, outcome)| outcome).collect()
278}
279
280fn store_digest(snapshots: &BTreeMap<Pin, Snapshot<state::Canonical>>) -> StoreDigest {
281    let mut writer = DigestWriter::new("jigsaw store");
282    writer.count(snapshots.len());
283    for (pin, snapshot) in snapshots {
284        writer
285            .text(pin.project.as_str())
286            .text(pin.tag.as_str())
287            .text(snapshot.header().commit.as_str())
288            .text(&snapshot.header().extractor_version);
289    }
290    StoreDigest::from_digest(writer.finish())
291}
292
293#[derive(Debug)]
294struct Surfaces {
295    build_app_names: BTreeSet<ApplicationName>,
296    pin_apps: BTreeMap<Pin, PinApps>,
297}
298
299impl Surfaces {
300    fn build(
301        plan: &JigsawPlan,
302        snapshots: &BTreeMap<Pin, Snapshot<state::Canonical>>,
303    ) -> Result<Self, UnstockedPlan> {
304        let mut unstocked = UnstockedPlan::default();
305        let mut pin_apps = BTreeMap::new();
306        for (pin, snapshot) in snapshots {
307            match PinApps::of(snapshot) {
308                Some(apps) => {
309                    pin_apps.insert(pin.clone(), apps);
310                }
311                None => unstocked.record(pin.clone(), Gap::Stale(StaleReason::MissingApp)),
312            }
313        }
314        if !unstocked.is_empty() {
315            return Err(unstocked);
316        }
317        let build = plan.releases().build();
318        let build_pins = iter::once(Pin::new(
319            plan.host_project().clone(),
320            build.tag.tag().clone(),
321        ))
322        .chain(
323            build
324                .pins
325                .iter()
326                .map(|(project, tag)| Pin::new(project.clone(), tag.clone())),
327        )
328        .chain(plan.otp_lines().iter().flat_map(|line| {
329            line.tags()
330                .iter()
331                .map(|tag| Pin::new(plan.otp_project().clone(), tag.tag().clone()))
332        }));
333        let build_app_names = build_pins
334            .filter_map(|pin| snapshots.get(&pin))
335            .flat_map(|snapshot| snapshot.apps().iter().map(|app| app.name.clone()))
336            .collect();
337        Ok(Self {
338            build_app_names,
339            pin_apps,
340        })
341    }
342}
343
344#[derive(Debug)]
345pub struct LoadedPlan {
346    plan: JigsawPlan,
347    snapshots: BTreeMap<Pin, Snapshot<state::Canonical>>,
348    store_digest: StoreDigest,
349    surfaces: Surfaces,
350}
351
352impl LoadedPlan {
353    #[must_use]
354    pub fn plan(&self) -> &JigsawPlan {
355        &self.plan
356    }
357
358    #[must_use]
359    pub fn snapshot(&self, pin: &Pin) -> Option<&Snapshot<state::Canonical>> {
360        self.snapshots.get(pin)
361    }
362
363    #[must_use]
364    pub fn snapshots(&self) -> &BTreeMap<Pin, Snapshot<state::Canonical>> {
365        &self.snapshots
366    }
367
368    /// Borrowing views of every release and OTP tag in the plan. They are
369    /// built here, on each call, because a view that borrows the snapshots
370    /// cannot be stored next to them.
371    #[must_use]
372    pub fn surfaces(&self) -> PlanSurfaces<'_> {
373        PlanSurfaces::new(&self.plan, &self.snapshots, &self.surfaces.pin_apps)
374    }
375
376    #[must_use]
377    pub fn store_digest(&self) -> &StoreDigest {
378        &self.store_digest
379    }
380
381    /// Every app the build patch's surface provides: the host snapshot's
382    /// apps, the pinned dependency snapshots' apps and the apps of every
383    /// OTP tag in the run.
384    #[must_use]
385    pub fn build_app_names(&self) -> &BTreeSet<ApplicationName> {
386        &self.surfaces.build_app_names
387    }
388}