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 database catalog afresh; Git discovery stays cached until [`Loader::reset`].
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    /// The catalog is supplied by the caller, so reset invalidates its Git caches on load.
111    reset_git: bool,
112}
113impl Loader {
114    /// The SELECT statements read since the last call, for tests that count what a read costs.
115    #[cfg(any(test, feature = "test-support"))]
116    pub fn traced_statements(&self) -> Vec<String> {
117        self.reader.traced_statements()
118    }
119
120    pub fn load(&mut self, mut input: Input) -> Result<Snapshot, String> {
121        if self.reset_git {
122            input.catalog.reset_git();
123        }
124        let result = load(&mut self.reader, input);
125        if result.is_ok() {
126            self.reset_git = false;
127        }
128        result
129    }
130    /// Drop every cache, so the next load reads the whole state and Git metadata afresh.
131    pub fn reset(&mut self) {
132        self.reader.reset();
133        self.reset_git = true;
134    }
135}
136
137pub struct Completed {
138    pub selection: Selection,
139    pub loader: Loader,
140    pub result: Result<Snapshot, String>,
141    /// The SELECT statements the read ran.
142    #[cfg(any(test, feature = "test-support"))]
143    pub queries: Vec<String>,
144}
145impl Completed {
146    /// Read the snapshot of `input` with `loader`, keeping what the caller needs to apply it.
147    pub fn run(selection: Selection, mut loader: Loader, input: Input) -> Self {
148        #[cfg(any(test, feature = "test-support"))]
149        loader.traced_statements();
150        let result = loader.load(input);
151        #[cfg(any(test, feature = "test-support"))]
152        let queries = loader.traced_statements();
153        Self {
154            selection,
155            loader,
156            result,
157            #[cfg(any(test, feature = "test-support"))]
158            queries,
159        }
160    }
161}
162
163fn load(reader: &mut Reader, input: Input) -> Result<Snapshot, String> {
164    let mut catalog = input.catalog;
165    let mut scope = input.page.scope.clone();
166    // Empty state is a normal baseline. File safety checks still run on the worker.
167    if !input
168        .state
169        .join(store::DATABASE)
170        .try_exists()
171        .map_err(|error| error.to_string())?
172    {
173        store::check_probe_files(&input.state)?;
174        catalog.update(&[], input.initial.as_deref());
175        if !input.initialized {
176            scope = catalog.initial(input.initial.as_deref());
177        }
178        reader.reset();
179        return Ok(Snapshot {
180            origin: Origin::default(),
181            catalog,
182            scope,
183            runs: Vec::new(),
184            selected_id: None,
185            selected: None,
186        });
187    }
188    let saved = reader.snapshot_optional(&input.state, |db, cache, state| {
189        let reuse_page = input.runs.is_some()
190            && !input.full
191            && input.initialized
192            && cache.has_catalog()
193            && !cache.changed();
194        if input.initialized {
195            catalog.selected = Some(scope.clone());
196        }
197        if input.full || !input.initialized || !cache.has_catalog() || cache.changed() {
198            let facts = cache.catalog(db, state)?;
199            if input.initialized {
200                catalog.selected = Some(scope.clone());
201            }
202            catalog.update(&facts, input.initial.as_deref());
203            if !input.initialized {
204                scope = catalog.initial(input.initial.as_deref());
205                catalog.selected = Some(scope.clone());
206                catalog.update(&facts, input.initial.as_deref());
207            }
208        }
209        if !input.full && input.initialized {
210            catalog.rebuild();
211        }
212        let paths = catalog.paths(&scope);
213        let runs = if reuse_page {
214            input.runs.clone().expect("accepted same-database page")
215        } else {
216            cache.page(
217                db,
218                state,
219                paths.as_deref(),
220                input.page.limit,
221                input.page.offset,
222                input.page.open.as_ref(),
223            )?
224        };
225        let selected = input
226            .page
227            .open
228            .as_ref()
229            .and_then(|id| runs.iter().find(|run| &run.id == id))
230            .or_else(|| {
231                if input.page.tail {
232                    runs.iter().take(input.page.limit as usize).next_back()
233                } else {
234                    runs.first()
235                }
236            });
237        let selected_id = selected.map(|run| run.id.clone());
238        let selected = match selected.filter(|run| run.readable()) {
239            Some(run) => cache.selected(
240                db,
241                state,
242                &run.id,
243                Some(run.unreadable.clone()),
244                input.displayed.as_ref() != Some(&run.id),
245            )?,
246            None => None,
247        };
248        Ok((cache.identity(db)?, runs, selected_id, selected))
249    })?;
250    let Some((identity, runs, selected_id, selected)) = saved else {
251        catalog.update(&[], input.initial.as_deref());
252        if !input.initialized {
253            scope = catalog.initial(input.initial.as_deref());
254        }
255        return Ok(Snapshot {
256            origin: Origin {
257                identity: None,
258                database: reader.database(),
259            },
260            catalog,
261            scope,
262            runs: Vec::new(),
263            selected_id: None,
264            selected: None,
265        });
266    };
267    // No read transaction survives heavy projection or live-file reads.
268    let selected = selected.map(|(run, requests)| {
269        let nodes = Arc::new(model::tree(&run, &requests, OffsetDateTime::now_utc()));
270        (run, requests, nodes)
271    });
272    reader.require_current(&input.state)?;
273    Ok(Snapshot {
274        origin: Origin {
275            identity,
276            database: reader.database(),
277        },
278        catalog,
279        scope,
280        runs,
281        selected_id,
282        selected,
283    })
284}