Skip to main content

aft/path_status/
mod.rs

1//! Per-view status annotations for paths that could not join a complete generation.
2//!
3//! The path table contains only pending or failed annotations. Removing an
4//! annotation means assembly no longer reports a problem for that path; manifest
5//! membership remains the authority for whether the path is in a generation.
6
7use std::error::Error;
8use std::fmt;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use rusqlite::{params, Connection, OptionalExtension};
13
14/// Maximum paths included in a refresh-status response; counts include paths beyond this limit.
15pub const VISIBLE_PATH_CAP: usize = 20;
16
17const PATH_STATUS_SCHEMA: &str = r#"
18CREATE TABLE IF NOT EXISTS path_status (
19    rel_path BLOB NOT NULL PRIMARY KEY,
20    state TEXT NOT NULL CHECK(state IN ('pending', 'failed')),
21    reason TEXT NOT NULL,
22    since_generation INTEGER NOT NULL CHECK(since_generation >= 0)
23) WITHOUT ROWID;
24CREATE TABLE IF NOT EXISTS maintenance_outcomes (
25    operation TEXT NOT NULL PRIMARY KEY,
26    outcome TEXT NOT NULL,
27    generation INTEGER NOT NULL CHECK(generation >= 0)
28) WITHOUT ROWID;
29"#;
30
31/// The two path problem states exposed by view status.
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum PathState {
34    Pending,
35    Failed,
36}
37
38impl PathState {
39    pub const fn as_str(self) -> &'static str {
40        match self {
41            Self::Pending => "pending",
42            Self::Failed => "failed",
43        }
44    }
45
46    fn parse(value: &str) -> Option<Self> {
47        match value {
48            "pending" => Some(Self::Pending),
49            "failed" => Some(Self::Failed),
50            _ => None,
51        }
52    }
53}
54
55/// One durable annotation from the per-view `path_status` derived table.
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct PathStatus {
58    pub rel_path: Vec<u8>,
59    pub state: PathState,
60    pub reason: String,
61    pub since_generation: u64,
62}
63
64/// Summary data for refresh-status responses: total counts plus a bounded,
65/// bytewise-ordered path list. This is internal response data, not a tool schema.
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct PathStatusSummary {
68    pub pending_count: usize,
69    pub failed_count: usize,
70    pub paths: Vec<PathStatus>,
71}
72
73#[derive(Debug)]
74pub enum PathStatusError {
75    Io(std::io::Error),
76    Sqlite(rusqlite::Error),
77    InvalidState(String),
78    InvalidGeneration(i64),
79    GenerationOutOfRange(u64),
80}
81
82impl fmt::Display for PathStatusError {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Self::Io(error) => write!(f, "path-status I/O error: {error}"),
86            Self::Sqlite(error) => write!(f, "path-status SQLite error: {error}"),
87            Self::InvalidState(state) => write!(f, "invalid path-status state `{state}`"),
88            Self::InvalidGeneration(generation) => {
89                write!(f, "invalid negative path-status generation {generation}")
90            }
91            Self::GenerationOutOfRange(generation) => {
92                write!(
93                    f,
94                    "path-status generation {generation} exceeds SQLite INTEGER"
95                )
96            }
97        }
98    }
99}
100
101impl Error for PathStatusError {
102    fn source(&self) -> Option<&(dyn Error + 'static)> {
103        match self {
104            Self::Io(error) => Some(error),
105            Self::Sqlite(error) => Some(error),
106            Self::InvalidState(_) | Self::InvalidGeneration(_) | Self::GenerationOutOfRange(_) => {
107                None
108            }
109        }
110    }
111}
112
113impl From<std::io::Error> for PathStatusError {
114    fn from(error: std::io::Error) -> Self {
115        Self::Io(error)
116    }
117}
118
119impl From<rusqlite::Error> for PathStatusError {
120    fn from(error: rusqlite::Error) -> Self {
121        Self::Sqlite(error)
122    }
123}
124
125/// Owns the path-status table in one view's derived SQLite database.
126#[derive(Debug)]
127pub struct PathStatusStore {
128    path: PathBuf,
129    connection: Connection,
130}
131
132impl PathStatusStore {
133    /// Opens the conventional derived-state database for one view.
134    pub fn open(view_dir: &Path) -> Result<Self, PathStatusError> {
135        Self::open_at(&view_dir.join("derived.sqlite"))
136    }
137
138    /// Opens a view-derived database at an explicit path. This allows the view
139    /// assembler to share its already-created derived database with this table.
140    pub fn open_at(path: &Path) -> Result<Self, PathStatusError> {
141        if let Some(parent) = path.parent() {
142            fs::create_dir_all(parent)?;
143        }
144        let connection = Connection::open(path)?;
145        connection.execute_batch(PATH_STATUS_SCHEMA)?;
146        Ok(Self {
147            path: path.to_path_buf(),
148            connection,
149        })
150    }
151
152    pub fn path(&self) -> &Path {
153        &self.path
154    }
155
156    /// Marks a path pending. Repeated pending reports preserve the generation
157    /// where the annotation began, so status consumers can identify its age.
158    pub fn mark_pending(
159        &mut self,
160        rel_path: &[u8],
161        reason: impl Into<String>,
162        since_generation: u64,
163    ) -> Result<(), PathStatusError> {
164        self.upsert(
165            rel_path,
166            PathState::Pending,
167            reason.into(),
168            since_generation,
169        )
170    }
171
172    /// Marks a path failed. A state transition starts a new annotation age.
173    pub fn mark_failed(
174        &mut self,
175        rel_path: &[u8],
176        reason: impl Into<String>,
177        since_generation: u64,
178    ) -> Result<(), PathStatusError> {
179        self.upsert(rel_path, PathState::Failed, reason.into(), since_generation)
180    }
181
182    /// Removes an annotation after the path joined a complete generation.
183    pub fn clear(&mut self, rel_path: &[u8]) -> Result<(), PathStatusError> {
184        self.connection.execute(
185            "DELETE FROM path_status WHERE rel_path = ?1",
186            params![rel_path],
187        )?;
188        Ok(())
189    }
190
191    pub fn status_for(&self, rel_path: &[u8]) -> Result<Option<PathStatus>, PathStatusError> {
192        let row = self
193            .connection
194            .query_row(
195                "SELECT rel_path, state, reason, since_generation
196                 FROM path_status WHERE rel_path = ?1",
197                params![rel_path],
198                |row| {
199                    Ok((
200                        row.get::<_, Vec<u8>>(0)?,
201                        row.get::<_, String>(1)?,
202                        row.get::<_, String>(2)?,
203                        row.get::<_, i64>(3)?,
204                    ))
205                },
206            )
207            .optional()?;
208        row.map(Self::decode_row).transpose()
209    }
210
211    /// Returns counts for both states and at most twenty bytewise-ordered paths.
212    pub fn record_maintenance_outcome(
213        &mut self,
214        operation: &str,
215        outcome: &str,
216        generation: u64,
217    ) -> Result<(), PathStatusError> {
218        self.connection.execute(
219            "INSERT INTO maintenance_outcomes(operation, outcome, generation)
220             VALUES (?1, ?2, ?3)
221             ON CONFLICT(operation) DO NOTHING",
222            params![operation, outcome, generation],
223        )?;
224        Ok(())
225    }
226
227    pub fn maintenance_outcome(
228        &self,
229        operation: &str,
230    ) -> Result<Option<(String, u64)>, PathStatusError> {
231        self.connection
232            .query_row(
233                "SELECT outcome, generation FROM maintenance_outcomes WHERE operation = ?1",
234                [operation],
235                |row| Ok((row.get(0)?, row.get(1)?)),
236            )
237            .optional()
238            .map_err(PathStatusError::from)
239    }
240
241    pub fn summary(&self) -> Result<PathStatusSummary, PathStatusError> {
242        let (pending_count, failed_count) = self.connection.query_row(
243            "SELECT
244                 COALESCE(SUM(CASE WHEN state = 'pending' THEN 1 ELSE 0 END), 0),
245                 COALESCE(SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END), 0)
246             FROM path_status",
247            [],
248            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
249        )?;
250        let pending_count = usize::try_from(pending_count)
251            .map_err(|_| PathStatusError::InvalidGeneration(pending_count))?;
252        let failed_count = usize::try_from(failed_count)
253            .map_err(|_| PathStatusError::InvalidGeneration(failed_count))?;
254
255        let mut statement = self.connection.prepare(
256            "SELECT rel_path, state, reason, since_generation
257             FROM path_status
258             ORDER BY rel_path
259             LIMIT ?1",
260        )?;
261        let paths = statement
262            .query_map(params![VISIBLE_PATH_CAP as i64], |row| {
263                Ok((
264                    row.get::<_, Vec<u8>>(0)?,
265                    row.get::<_, String>(1)?,
266                    row.get::<_, String>(2)?,
267                    row.get::<_, i64>(3)?,
268                ))
269            })?
270            .collect::<Result<Vec<_>, _>>()?
271            .into_iter()
272            .map(Self::decode_row)
273            .collect::<Result<Vec<_>, _>>()?;
274
275        Ok(PathStatusSummary {
276            pending_count,
277            failed_count,
278            paths,
279        })
280    }
281
282    fn upsert(
283        &mut self,
284        rel_path: &[u8],
285        state: PathState,
286        reason: String,
287        since_generation: u64,
288    ) -> Result<(), PathStatusError> {
289        let generation = i64::try_from(since_generation)
290            .map_err(|_| PathStatusError::GenerationOutOfRange(since_generation))?;
291        self.connection.execute(
292            "INSERT INTO path_status (rel_path, state, reason, since_generation)
293             VALUES (?1, ?2, ?3, ?4)
294             ON CONFLICT(rel_path) DO UPDATE SET
295                 state = excluded.state,
296                 reason = excluded.reason,
297                 since_generation = CASE
298                     WHEN path_status.state = excluded.state THEN path_status.since_generation
299                     ELSE excluded.since_generation
300                 END",
301            params![rel_path, state.as_str(), reason, generation],
302        )?;
303        Ok(())
304    }
305
306    fn decode_row(
307        (rel_path, state, reason, since_generation): (Vec<u8>, String, String, i64),
308    ) -> Result<PathStatus, PathStatusError> {
309        Ok(PathStatus {
310            rel_path,
311            state: PathState::parse(&state).ok_or(PathStatusError::InvalidState(state))?,
312            reason,
313            since_generation: u64::try_from(since_generation)
314                .map_err(|_| PathStatusError::InvalidGeneration(since_generation))?,
315        })
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn summary_counts_all_rows_but_caps_and_orders_visible_paths_by_bytes() {
325        let dir = tempfile::tempdir().expect("create view dir");
326        let mut store = PathStatusStore::open(dir.path()).expect("open path-status store");
327        assert_eq!(
328            store.summary().expect("summarize empty statuses"),
329            PathStatusSummary {
330                pending_count: 0,
331                failed_count: 0,
332                paths: Vec::new(),
333            }
334        );
335        for index in 0..25 {
336            store
337                .mark_pending(format!("z{index:02}").as_bytes(), "read changed", 7)
338                .expect("mark pending");
339        }
340        store
341            .mark_failed(b"\x80binary", "quarantined", 8)
342            .expect("mark failed");
343        store
344            .mark_failed(b"a/path", "quota", 9)
345            .expect("mark failed");
346
347        let summary = store.summary().expect("summarize statuses");
348        assert_eq!(summary.pending_count, 25);
349        assert_eq!(summary.failed_count, 2);
350        assert_eq!(summary.paths.len(), VISIBLE_PATH_CAP);
351        assert_eq!(summary.paths[0].rel_path, b"a/path");
352        assert_eq!(summary.paths[1].rel_path, b"z00");
353        assert!(summary
354            .paths
355            .windows(2)
356            .all(|paths| paths[0].rel_path <= paths[1].rel_path));
357    }
358
359    #[test]
360    fn repeated_state_preserves_annotation_age_and_success_clears_it() {
361        let dir = tempfile::tempdir().expect("create view dir");
362        let mut store = PathStatusStore::open(dir.path()).expect("open path-status store");
363
364        store
365            .mark_pending(b"src/lib.rs", "read changed", 3)
366            .expect("mark pending");
367        store
368            .mark_pending(b"src/lib.rs", "still changing", 9)
369            .expect("repeat pending");
370        assert_eq!(
371            store
372                .status_for(b"src/lib.rs")
373                .expect("read status")
374                .expect("pending row")
375                .since_generation,
376            3
377        );
378
379        store
380            .mark_failed(b"src/lib.rs", "quarantined", 10)
381            .expect("mark failed");
382        assert_eq!(
383            store
384                .status_for(b"src/lib.rs")
385                .expect("read status")
386                .expect("failed row")
387                .since_generation,
388            10
389        );
390        store.clear(b"src/lib.rs").expect("clear completed path");
391        assert!(store
392            .status_for(b"src/lib.rs")
393            .expect("read status")
394            .is_none());
395    }
396}