Skip to main content

artifactize_core/monitor/
loader.rs

1//! One in-flight snapshot job, isolated from UI navigation. Generation checks discard stale
2//! scopes/pages; state hints coalesce in the driver, and a read reads again what they named.
3//!
4//! [`Loader`] is the UI-agnostic seam: the terminal monitor and the desktop app both read
5//! their catalog, Runs page and selected Run through it.
6use super::{Node, Scope, catalog, model};
7use crate::{
8    changes::Changed,
9    store::{self, RequestView, RunSummary, RunView, reader::Reader},
10    types::RunId,
11};
12use std::{path::PathBuf, sync::Arc};
13use time::OffsetDateTime;
14
15/// Which Runs page of which scope a snapshot reads, and the Run it opens.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Page {
18    pub scope: Scope,
19    /// The selected Run; `None` opens the first (or, with `tail`, the last) Run of the page.
20    pub open: Option<RunId>,
21    pub offset: u32,
22    pub tail: bool,
23    pub limit: u32,
24}
25
26/// Where the page of older Runs starts, when the page shown (`shown` Runs of `limit`) is full.
27pub fn older_offset(offset: u32, limit: u32, shown: u32) -> Option<u32> {
28    (shown >= limit).then(|| offset.saturating_add(limit))
29}
30
31/// Where the page of newer Runs starts, when this one is not the first.
32pub fn newer_offset(offset: u32, limit: u32) -> Option<u32> {
33    (offset > 0).then(|| offset.saturating_sub(limit))
34}
35
36/// The monitor's page plus what tells an obsolete job from a current one.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Selection {
39    pub state: PathBuf,
40    pub page: Page,
41    pub revision: u64,
42}
43
44/// Everything one snapshot read needs; the caller keeps the catalog between reads.
45pub struct Input {
46    pub state: PathBuf,
47    /// cwd, `--repo` or `--all` (`None`): only the first selection.
48    pub initial: Option<PathBuf>,
49    pub catalog: catalog::Catalog,
50    /// False before the first accepted snapshot, which picks the initial scope.
51    pub initialized: bool,
52    pub page: Page,
53    /// Read the database catalog and the shown Run afresh; Git discovery stays cached until
54    /// [`Loader::rescan`].
55    pub full: bool,
56    /// The Run the caller already shows; it is read again only when a hint named it.
57    pub displayed: Option<RunId>,
58    /// What the change hints since the last read named.
59    pub changed: Changed,
60    /// The page the caller already shows, reused while the database is unchanged.
61    pub runs: Option<Vec<RunSummary>>,
62}
63
64/// Where a snapshot was read from: the state identity and the database file.
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct Origin {
67    identity: Option<crate::types::StateId>,
68    database: Option<crate::platform::FileIdentity>,
69}
70impl Origin {
71    /// The origin of a snapshot read from the state `identity` in the database file `database`.
72    pub(crate) fn new(
73        identity: Option<crate::types::StateId>,
74        database: Option<crate::platform::FileIdentity>,
75    ) -> Self {
76        Self { identity, database }
77    }
78    /// The state was replaced since `previous` was read: Details, drafts and sessions
79    /// of the old state no longer apply.
80    pub fn replaces(&self, previous: &Self) -> bool {
81        previous.database.is_some() && previous.database != self.database
82            || previous.identity.is_some() && previous.identity != self.identity
83    }
84    pub fn identity(&self) -> Option<&crate::types::StateId> {
85        self.identity.as_ref()
86    }
87    /// The state's origin as it stands, read without a snapshot: its database file, then its
88    /// identity. A UI that keeps work outside snapshots (open reviews, drafts, claims taken)
89    /// compares it with [`Origin::replaces`] as the monitor compares snapshots.
90    pub fn read(state: &std::path::Path) -> Result<Self, String> {
91        let path = state.join(store::DATABASE);
92        let database =
93            match crate::platform::open_no_follow(std::fs::File::options().read(true), &path) {
94                Ok(file) => Some(crate::platform::file_identity(&file).map_err(|e| e.to_string())?),
95                Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
96                Err(error) => return Err(error.to_string()),
97            };
98        Ok(Self {
99            identity: store::read_state_id(state)?,
100            database,
101        })
102    }
103}
104
105/// One consistent read: the catalog, the scope's Runs page and the selected Run with its tree.
106pub struct Snapshot {
107    pub origin: Origin,
108    pub catalog: catalog::Catalog,
109    pub scope: Scope,
110    pub runs: Vec<RunSummary>,
111    pub selected_id: Option<RunId>,
112    /// The selected Run when it was read again: its requests and projected tree.
113    pub selected: Option<(RunView, Vec<RequestView>, Arc<Vec<Node>>)>,
114}
115
116/// A persistent, read-only snapshot worker with its caches. Call [`Loader::load`] on a
117/// blocking thread; it never writes.
118#[derive(Default)]
119pub struct Loader {
120    reader: Reader,
121    /// The catalog is supplied by the caller, so reset invalidates its Git caches on load.
122    reset_git: bool,
123}
124impl Loader {
125    /// The SELECT statements read since the last call, for tests that count what a read costs.
126    #[cfg(any(test, feature = "test-support"))]
127    pub fn traced_statements(&self) -> Vec<String> {
128        self.reader.traced_statements()
129    }
130
131    pub fn load(&mut self, mut input: Input) -> Result<Snapshot, String> {
132        if self.reset_git {
133            input.catalog.reset_git();
134        }
135        let result = load(&mut self.reader, input);
136        if result.is_ok() {
137            self.reset_git = false;
138        }
139        result
140    }
141    /// Ask Git again on the next load. The state is read by queries, which keep nothing that a
142    /// change could make wrong.
143    pub fn rescan(&mut self) {
144        self.reset_git = true;
145    }
146}
147
148pub struct Completed {
149    pub selection: Selection,
150    pub loader: Loader,
151    pub result: Result<Snapshot, String>,
152    /// The SELECT statements the read ran.
153    #[cfg(any(test, feature = "test-support"))]
154    pub queries: Vec<String>,
155}
156impl Completed {
157    /// Read the snapshot of `input` with `loader`, keeping what the caller needs to apply it.
158    pub fn run(selection: Selection, mut loader: Loader, input: Input) -> Self {
159        #[cfg(any(test, feature = "test-support"))]
160        loader.traced_statements();
161        let result = loader.load(input);
162        #[cfg(any(test, feature = "test-support"))]
163        let queries = loader.traced_statements();
164        Self {
165            selection,
166            loader,
167            result,
168            #[cfg(any(test, feature = "test-support"))]
169            queries,
170        }
171    }
172}
173
174fn load(reader: &mut Reader, input: Input) -> Result<Snapshot, String> {
175    let mut catalog = input.catalog;
176    let mut scope = input.page.scope.clone();
177    // Empty state is a normal baseline. File safety checks still run on the worker.
178    if !input
179        .state
180        .join(store::DATABASE)
181        .try_exists()
182        .map_err(|error| error.to_string())?
183    {
184        store::check_probe_files(&input.state)?;
185        catalog.update(&[], input.initial.as_deref());
186        if !input.initialized {
187            scope = catalog.initial(input.initial.as_deref());
188        }
189        reader.reset();
190        return Ok(Snapshot {
191            origin: Origin::default(),
192            catalog,
193            scope,
194            runs: Vec::new(),
195            selected_id: None,
196            selected: None,
197        });
198    }
199    let saved = reader.snapshot_optional(&input.state, |db, cache, state| {
200        let reuse_page = input.runs.is_some()
201            && !input.full
202            && input.initialized
203            && cache.has_catalog()
204            && !cache.changed();
205        if input.initialized {
206            catalog.selected = Some(scope.clone());
207        }
208        if input.full || !input.initialized || !cache.has_catalog() || cache.changed() {
209            let facts = cache.catalog(db, state)?;
210            if input.initialized {
211                catalog.selected = Some(scope.clone());
212            }
213            catalog.update(&facts, input.initial.as_deref());
214            if !input.initialized {
215                scope = catalog.initial(input.initial.as_deref());
216                catalog.selected = Some(scope.clone());
217                catalog.update(&facts, input.initial.as_deref());
218            }
219        }
220        if !input.full && input.initialized {
221            catalog.rebuild();
222        }
223        let paths = catalog.paths(&scope);
224        let runs = if reuse_page {
225            input.runs.clone().expect("accepted same-database page")
226        } else {
227            cache.page(
228                db,
229                state,
230                paths.as_deref(),
231                input.page.limit,
232                input.page.offset,
233                input.page.open.as_ref(),
234            )?
235        };
236        let selected = input
237            .page
238            .open
239            .as_ref()
240            .and_then(|id| runs.iter().find(|run| &run.id == id))
241            .or_else(|| {
242                if input.page.tail {
243                    runs.iter().take(input.page.limit as usize).next_back()
244                } else {
245                    runs.first()
246                }
247            });
248        let selected_id = selected.map(|run| run.id.clone());
249        let mut runs = runs;
250        // The shown Run is read again when a hint named it; another Run is read in full.
251        let shown = |id: &RunId| input.displayed.as_ref() == Some(id);
252        let changed = input.changed.or_written(cache.changed());
253        let read = selected_id
254            .as_ref()
255            .filter(|id| !shown(id) || input.full || changed.run(id) || !cache.holds(id));
256        let selected = match read {
257            Some(id) => match cache.selected(db, state, id, !shown(id)) {
258                Ok(selected) => selected,
259                // The Run's own record does not decode: it is listed with why, and not opened.
260                Err(store::receipts::Error::Unreadable(record)) => {
261                    let run = runs.iter_mut().find(|run| &run.id == id);
262                    let run = run.expect("the selected Run is listed");
263                    if !run.unreadable.contains(&record) {
264                        run.unreadable.push(record);
265                    }
266                    None
267                }
268                Err(error) => return Err(error),
269            },
270            None => None,
271        };
272        Ok((cache.identity(db)?, runs, selected_id, selected))
273    })?;
274    let Some((identity, runs, selected_id, selected)) = saved else {
275        catalog.update(&[], input.initial.as_deref());
276        if !input.initialized {
277            scope = catalog.initial(input.initial.as_deref());
278        }
279        return Ok(Snapshot {
280            origin: Origin {
281                identity: None,
282                database: reader.database(),
283            },
284            catalog,
285            scope,
286            runs: Vec::new(),
287            selected_id: None,
288            selected: None,
289        });
290    };
291    // No read transaction survives heavy projection or live-file reads.
292    let selected = selected.map(|(run, requests)| {
293        let nodes = Arc::new(model::tree(&run, &requests, OffsetDateTime::now_utc()));
294        (run, requests, nodes)
295    });
296    reader.require_current(&input.state)?;
297    Ok(Snapshot {
298        origin: Origin {
299            identity,
300            database: reader.database(),
301        },
302        catalog,
303        scope,
304        runs,
305        selected_id,
306        selected,
307    })
308}