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