Skip to main content

dpm_store/sqlite/runs/
read.rs

1//! Reading runs, lifecycle and activity in one consistent transaction.
2
3use super::{
4    RunStore,
5    codec::{
6        ACTIVITY_COLUMNS, LIFECYCLE_COLUMNS, LINK_COLUMNS, counter, read_activity, read_event,
7        read_link, read_run,
8    },
9};
10use crate::{
11    RunStoreError, StoreError, StoredRecord,
12    error::database_error,
13    sqlite::snapshot::{decode, revision_to_sql},
14};
15use dpm_model::{
16    ActivityEntry, ActivityGap, ActivityPage, ActivityTally, LatestActivity, LifecycleEntry,
17    LifecyclePage, RunId, RunLink, RunRecord, RunSnapshot, WorkItemId,
18};
19use rusqlite::{Connection, OptionalExtension, params};
20use std::path::Path;
21
22/// Most entries one feed page or run list returns.
23pub(super) const PAGE_LIMIT: u16 = 1000;
24
25pub(super) fn load_run(
26    connection: &Connection,
27    path: &Path,
28    id: RunId,
29) -> Result<Option<RunRecord>, StoreError> {
30    connection
31        .query_row(
32            "SELECT record_json FROM runs WHERE run_id = ?1",
33            params![id.to_string()],
34            |row| Ok(read_run(row, path)),
35        )
36        .optional()
37        .map_err(database_error(path, "look up run"))?
38        .transpose()
39}
40
41pub(super) fn last_event(
42    connection: &Connection,
43    path: &Path,
44    run: RunId,
45) -> Result<Option<LifecycleEntry>, StoreError> {
46    connection
47        .query_row(
48            &format!("{LIFECYCLE_COLUMNS} WHERE run_id = ?1 ORDER BY sequence DESC LIMIT 1"),
49            params![run.to_string()],
50            |row| Ok(read_event(row, path)),
51        )
52        .optional()
53        .map_err(database_error(path, "look up run state"))?
54        .transpose()
55}
56
57pub(super) fn event_by_id(
58    connection: &Connection,
59    path: &Path,
60    id: dpm_model::RunEventId,
61) -> Result<Option<LifecycleEntry>, StoreError> {
62    connection
63        .query_row(
64            &format!("{LIFECYCLE_COLUMNS} WHERE event_id = ?1"),
65            params![id.to_string()],
66            |row| Ok(read_event(row, path)),
67        )
68        .optional()
69        .map_err(database_error(path, "look up transition"))?
70        .transpose()
71}
72
73pub(super) fn activity_by_sequence(
74    connection: &Connection,
75    path: &Path,
76    run: RunId,
77    source_sequence: u64,
78) -> Result<Option<ActivityEntry>, StoreError> {
79    connection
80        .query_row(
81            &format!("{ACTIVITY_COLUMNS} WHERE run_id = ?1 AND source_sequence = ?2"),
82            params![run.to_string(), revision_to_sql(source_sequence)],
83            |row| Ok(read_activity(row, path)),
84        )
85        .optional()
86        .map_err(database_error(path, "look up activity"))?
87        .transpose()
88}
89
90/// What a run's tally row holds: how many records it ever received and the highest source
91/// sequence it accepted.
92pub(super) struct TallyRow {
93    pub(super) recorded: u64,
94    pub(super) high_water: u64,
95}
96
97pub(super) fn tally_row(
98    connection: &Connection,
99    path: &Path,
100    run: RunId,
101) -> Result<Option<TallyRow>, StoreError> {
102    connection
103        .query_row(
104            "SELECT recorded, high_water FROM run_activity_tally WHERE run_id = ?1",
105            params![run.to_string()],
106            |row| Ok(read_tally(row, path)),
107        )
108        .optional()
109        .map_err(database_error(path, "read run tally"))?
110        .transpose()
111}
112
113fn read_tally(row: &rusqlite::Row<'_>, path: &Path) -> Result<TallyRow, StoreError> {
114    Ok(TallyRow {
115        recorded: counter(row, 0, path, "recorded")?,
116        high_water: counter(row, 1, path, "high_water")?,
117    })
118}
119
120pub(super) fn link_by_operation(
121    connection: &Connection,
122    path: &Path,
123    operation: dpm_model::OperationId,
124) -> Result<Option<RunLink>, StoreError> {
125    connection
126        .query_row(
127            &format!("{LINK_COLUMNS} WHERE operation_id = ?1"),
128            params![operation.to_string()],
129            |row| Ok(read_link(row, path)),
130        )
131        .optional()
132        .map_err(database_error(path, "look up run link"))?
133        .transpose()
134}
135
136fn links_of(connection: &Connection, path: &Path, run: RunId) -> Result<Vec<RunLink>, StoreError> {
137    let mut statement = connection
138        .prepare(&format!(
139            "{LINK_COLUMNS} WHERE run_id = ?1 ORDER BY linked_at, operation_id"
140        ))
141        .map_err(database_error(path, "prepare run links"))?;
142    let mut rows = statement
143        .query(params![run.to_string()])
144        .map_err(database_error(path, "read run links"))?;
145    let mut links = Vec::new();
146    while let Some(row) = rows
147        .next()
148        .map_err(database_error(path, "read run links"))?
149    {
150        links.push(read_link(row, path)?);
151    }
152    Ok(links)
153}
154
155fn tally(connection: &Connection, path: &Path, run: RunId) -> Result<ActivityTally, StoreError> {
156    let retained: u64 = connection
157        .query_row(
158            "SELECT COUNT(*) FROM run_activity WHERE run_id = ?1",
159            params![run.to_string()],
160            |row| row.get(0),
161        )
162        .map_err(database_error(path, "count run activity"))?;
163    let Some(counts) = tally_row(connection, path, run)? else {
164        return Ok(ActivityTally {
165            recorded: 0,
166            retained,
167            source_high_water: 0,
168            latest: None,
169        });
170    };
171    let (kind, at): (String, String) = connection
172        .query_row(
173            "SELECT last_kind, last_at FROM run_activity_tally WHERE run_id = ?1",
174            params![run.to_string()],
175            |row| Ok((row.get(0)?, row.get(1)?)),
176        )
177        .map_err(database_error(path, "read latest activity"))?;
178    let scalar = |value: String| serde_json::Value::String(value).to_string();
179    Ok(ActivityTally {
180        recorded: counts.recorded,
181        retained,
182        source_high_water: counts.high_water,
183        latest: Some(LatestActivity {
184            kind: decode(&scalar(kind), path, StoredRecord::Run, "last_kind")?,
185            source_sequence: counts.high_water,
186            recorded_at: decode(&scalar(at), path, StoredRecord::Run, "last_at")?,
187        }),
188    })
189}
190
191/// Everything the store holds about one run.
192pub(super) fn snapshot(
193    connection: &Connection,
194    path: &Path,
195    id: RunId,
196) -> Result<Option<RunSnapshot>, StoreError> {
197    let Some(record) = load_run(connection, path, id)? else {
198        return Ok(None);
199    };
200    let last = last_event(connection, path, id)?.ok_or_else(|| StoreError::CorruptJson {
201        path: path.to_path_buf(),
202        record: StoredRecord::Run,
203        field: "run_lifecycle",
204        source: missing_start(id),
205    })?;
206    Ok(Some(RunSnapshot {
207        operations: links_of(connection, path, id)?,
208        activity: tally(connection, path, id)?,
209        last: last.event,
210        record,
211    }))
212}
213
214/// A run row with no lifecycle row is damage; this reports it with the run it concerns.
215fn missing_start(id: RunId) -> serde_json::Error {
216    <serde_json::Error as serde::de::Error>::custom(format!("run {id} has no lifecycle record"))
217}
218
219impl RunStore {
220    /// Where the feeds and the link count stand and the epoch they belong to, in one read
221    /// transaction.
222    pub fn heads_blocking(&self) -> Result<dpm_model::RunFeedHeads, RunStoreError> {
223        let transaction = self.read_transaction()?;
224        let binding = super::read_binding(&transaction, &self.path)?;
225        Ok(dpm_model::RunFeedHeads {
226            epoch: Some(binding.lineage_id),
227            lifecycle_head: head(&transaction, &self.path, "run_lifecycle")?,
228            activity_head: head(&transaction, &self.path, "run_activity")?,
229            activity_pruned_through: pruned_through(&transaction, &self.path)?,
230            link_count: link_count_blocking(&transaction, &self.path)?,
231        })
232    }
233
234    /// Everything recorded about one run, or `None` for an unknown identity.
235    pub fn snapshot_blocking(&self, id: RunId) -> Result<Option<RunSnapshot>, RunStoreError> {
236        let transaction = self.read_transaction()?;
237        Ok(snapshot(&transaction, &self.path, id)?)
238    }
239
240    /// Runs, newest first, optionally only those executing one task; at most 1000.
241    pub fn snapshots_blocking(
242        &self,
243        work: Option<WorkItemId>,
244        limit: u16,
245    ) -> Result<Vec<RunSnapshot>, RunStoreError> {
246        let transaction = self.read_transaction()?;
247        let mut statement = transaction
248            .prepare(
249                "SELECT run_id FROM runs WHERE (?1 IS NULL OR work_id = ?1) \
250                 ORDER BY rowid DESC LIMIT ?2",
251            )
252            .map_err(database_error(&self.path, "prepare run list"))?;
253        let ids = statement
254            .query_map(
255                params![work.map(|id| id.to_string()), limit.min(PAGE_LIMIT)],
256                |row| row.get::<_, String>(0),
257            )
258            .map_err(database_error(&self.path, "list runs"))?
259            .collect::<Result<Vec<_>, _>>()
260            .map_err(database_error(&self.path, "read run list"))?;
261        let mut runs = Vec::with_capacity(ids.len());
262        for text in ids {
263            let id: RunId = decode(
264                &serde_json::Value::String(text).to_string(),
265                &self.path,
266                StoredRecord::Run,
267                "run_id",
268            )?;
269            runs.extend(snapshot(&transaction, &self.path, id)?);
270        }
271        Ok(runs)
272    }
273
274    /// Lifecycle facts after a feed cursor, optionally only one run's; at most 1000. Lifecycle is
275    /// never pruned, so a cursor is always continuous.
276    pub fn lifecycle_page_blocking(
277        &self,
278        after_sequence: u64,
279        limit: u16,
280        run: Option<RunId>,
281    ) -> Result<LifecyclePage, RunStoreError> {
282        let transaction = self.read_transaction()?;
283        let mut entries = Vec::new();
284        if let Ok(cursor) = i64::try_from(after_sequence) {
285            let mut statement = transaction
286                .prepare(&format!(
287                    "{LIFECYCLE_COLUMNS} WHERE sequence > ?1 AND (?2 IS NULL OR run_id = ?2) \
288                     ORDER BY sequence LIMIT ?3"
289                ))
290                .map_err(database_error(&self.path, "prepare lifecycle feed"))?;
291            let mut rows = statement
292                .query(params![
293                    cursor,
294                    run.map(|id| id.to_string()),
295                    limit.min(PAGE_LIMIT)
296                ])
297                .map_err(database_error(&self.path, "read lifecycle feed"))?;
298            while let Some(row) = rows
299                .next()
300                .map_err(database_error(&self.path, "read lifecycle feed"))?
301            {
302                entries.push(read_event(row, &self.path)?);
303            }
304        }
305        let head = head(&transaction, &self.path, "run_lifecycle")?;
306        Ok(LifecyclePage {
307            next_after_sequence: entries
308                .last()
309                .map_or(after_sequence, |entry| entry.sequence),
310            entries,
311            head_sequence: head,
312        })
313    }
314
315    /// Activity after a feed cursor, optionally only one run's; at most 1000.
316    ///
317    /// A cursor older than what retention kept returns a [`ActivityGap`] and resumes from the
318    /// oldest record still held, so a client is told the feed is discontinuous rather than served
319    /// a silent hole.
320    pub fn activity_page_blocking(
321        &self,
322        after_sequence: u64,
323        limit: u16,
324        run: Option<RunId>,
325    ) -> Result<ActivityPage, RunStoreError> {
326        let transaction = self.read_transaction()?;
327        let pruned = pruned_through(&transaction, &self.path)?;
328        let mut entries = Vec::new();
329        if let Ok(cursor) = i64::try_from(after_sequence.max(pruned)) {
330            let mut statement = transaction
331                .prepare(&format!(
332                    "{ACTIVITY_COLUMNS} WHERE sequence > ?1 AND (?2 IS NULL OR run_id = ?2) \
333                     ORDER BY sequence LIMIT ?3"
334                ))
335                .map_err(database_error(&self.path, "prepare activity feed"))?;
336            let mut rows = statement
337                .query(params![
338                    cursor,
339                    run.map(|id| id.to_string()),
340                    limit.min(PAGE_LIMIT)
341                ])
342                .map_err(database_error(&self.path, "read activity feed"))?;
343            while let Some(row) = rows
344                .next()
345                .map_err(database_error(&self.path, "read activity feed"))?
346            {
347                entries.push(read_activity(row, &self.path)?);
348            }
349        }
350        let gap = (after_sequence < pruned).then(|| ActivityGap {
351            requested_after: after_sequence,
352            resumes_at: pruned.saturating_add(1),
353            lost: pruned.saturating_sub(after_sequence),
354        });
355        let resumed = gap.map_or(after_sequence, |_| pruned);
356        Ok(ActivityPage {
357            next_after_sequence: entries.last().map_or(resumed, |entry| entry.sequence),
358            entries,
359            head_sequence: head(&transaction, &self.path, "run_activity")?,
360            gap,
361        })
362    }
363}
364
365/// The highest activity sequence retention has removed; zero while nothing was pruned.
366pub(super) fn pruned_through(connection: &Connection, path: &Path) -> Result<u64, StoreError> {
367    connection
368        .query_row(
369            "SELECT pruned_through FROM run_activity_retention WHERE singleton = 1",
370            [],
371            |row| row.get(0),
372        )
373        .map_err(database_error(path, "read activity retention"))
374}
375
376/// How many operations were ever linked to a run; zero while none was.
377///
378/// `run_links` is append-only: a link is inserted once and never updated or removed, and a replayed
379/// link inserts nothing. The count is therefore a monotone invalidation token. It deliberately
380/// does not use the table's implicit `rowid`, which SQLite does not promise to keep stable and
381/// which can be reused at its maximum, so it offers no order and no resumable position.
382fn link_count_blocking(connection: &Connection, path: &Path) -> Result<u64, StoreError> {
383    connection
384        .query_row("SELECT COUNT(*) FROM run_links", [], |row| row.get(0))
385        .map_err(database_error(path, "count run links"))
386}
387
388/// The newest sequence ever assigned in an `AUTOINCREMENT` table, even if retention removed it.
389pub(super) fn head(
390    connection: &Connection,
391    path: &Path,
392    table: &'static str,
393) -> Result<u64, StoreError> {
394    connection
395        .query_row(
396            "SELECT seq FROM sqlite_sequence WHERE name = ?1",
397            params![table],
398            |row| row.get(0),
399        )
400        .optional()
401        .map_err(database_error(path, "read feed head"))
402        .map(Option::unwrap_or_default)
403}