1use super::{
10 RunBinding, RunStore, check_workspace,
11 codec::{read_activity, read_event, read_link, read_run},
12 pairing::{PairFacts, read_forks},
13 read::{head, pruned_through, tally_row},
14 read_binding, schema,
15};
16use crate::{
17 LineageError, RunStoreError, StoreError,
18 error::database_error,
19 schema::Layout,
20 sqlite::{
21 integrity::integrity_check_blocking,
22 recovery::{
23 copy_blocking,
24 files::{
25 ReadMode, canonical_blocking, create_target_blocking, discard_blocking,
26 fingerprint_blocking, open_read_only_blocking, read_mode_blocking,
27 },
28 },
29 snapshot::revision_to_sql,
30 },
31};
32use chrono::{DateTime, Utc};
33use dpm_engine::{check_run_transition, run_start_event};
34use dpm_model::{LineageId, RunId, RunLink, RunState, WorkspaceId};
35use rusqlite::Connection;
36use serde::Serialize;
37use std::{
38 collections::BTreeMap,
39 path::{Path, PathBuf},
40};
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44pub struct RunStoreReport {
45 pub path: PathBuf,
47 pub schema_version: i64,
49 pub workspace_id: WorkspaceId,
51 pub lineage_id: LineageId,
53 pub archived: bool,
55 pub runs: u64,
57 pub lifecycle_events: u64,
59 pub activity_retained: u64,
61 pub activity_pruned_through: u64,
63}
64
65pub(super) fn corrupt(path: &Path, detail: impl Into<String>) -> RunStoreError {
66 RunStoreError::Corrupt {
67 path: path.to_path_buf(),
68 detail: detail.into(),
69 }
70}
71
72pub(in crate::sqlite) fn verify_blocking(path: &Path) -> Result<RunStoreReport, RunStoreError> {
74 verify_with_facts_blocking(path).map(|(report, _)| report)
75}
76
77pub(in crate::sqlite) fn verify_with_facts_blocking(
79 path: &Path,
80) -> Result<(RunStoreReport, PairFacts), RunStoreError> {
81 let path = canonical_blocking(path)?;
82 if read_mode_blocking(&path)? == ReadMode::Immutable {
83 let before = fingerprint_blocking(&path)?;
84 let result = verify_with_blocking(&path, ReadMode::Immutable);
85 if fingerprint_blocking(&path)? == before {
87 return result;
88 }
89 }
90 verify_with_blocking(&path, ReadMode::Shared)
91}
92
93fn verify_with_blocking(
94 path: &Path,
95 mode: ReadMode,
96) -> Result<(RunStoreReport, PairFacts), RunStoreError> {
97 let mut connection = open_read_only_blocking(path, mode)?;
98 let transaction = connection
99 .transaction()
100 .map_err(database_error(path, "begin run store verification"))?;
101 if schema::check_blocking(&transaction, path)? == Layout::Empty {
102 return Err(StoreError::UnrecognizedSchema {
103 path: path.to_path_buf(),
104 detail: "no run store tables".into(),
105 }
106 .into());
107 }
108 integrity_check_blocking(&transaction, path)?;
109 let binding = read_binding(&transaction, path)?;
110 let runs = check_runs(&transaction, path)?;
111 let (lifecycle_events, last) = check_lifecycle(&transaction, path, &runs)?;
112 let (activity_retained, pruned) = check_activity(&transaction, path, &runs)?;
113 let links = check_links(&transaction, path, &runs)?;
114 let report = RunStoreReport {
115 path: path.to_path_buf(),
116 schema_version: schema::RUN_STORE_VERSION,
117 workspace_id: binding.workspace_id,
118 lineage_id: binding.lineage_id,
119 archived: binding.archived,
120 runs: u64::try_from(runs.len()).unwrap_or(u64::MAX),
121 lifecycle_events,
122 activity_retained,
123 activity_pruned_through: pruned,
124 };
125 let runs = runs
126 .into_values()
127 .map(|record| {
128 let ended = last
129 .get(&record.id)
130 .filter(|(state, _)| state.is_terminal())
131 .map(|(_, at)| *at);
132 (record, ended)
133 })
134 .collect();
135 let facts = PairFacts {
136 path: path.to_path_buf(),
137 binding,
138 runs,
139 links,
140 forks: read_forks(&transaction, path)?,
141 };
142 Ok((report, facts))
143}
144
145fn check_runs(
147 connection: &Connection,
148 path: &Path,
149) -> Result<BTreeMap<RunId, dpm_model::RunRecord>, RunStoreError> {
150 let mut statement = connection
151 .prepare("SELECT record_json, run_id, work_id FROM runs")
152 .map_err(database_error(path, "prepare run verification"))?;
153 let mut rows = statement
154 .query([])
155 .map_err(database_error(path, "read runs"))?;
156 let mut runs = BTreeMap::new();
157 while let Some(row) = rows.next().map_err(database_error(path, "read runs"))? {
158 let record = read_run(row, path)?;
159 let (id, work): (String, String) = (
160 row.get(1).map_err(database_error(path, "read run id"))?,
161 row.get(2).map_err(database_error(path, "read run task"))?,
162 );
163 if record.id.to_string() != id || record.work.to_string() != work {
164 return Err(corrupt(
165 path,
166 format!("run row {id} disagrees with its record"),
167 ));
168 }
169 runs.insert(record.id, record);
170 }
171 Ok(runs)
172}
173
174type LastStates = BTreeMap<RunId, (RunState, DateTime<Utc>)>;
176
177fn check_lifecycle(
179 connection: &Connection,
180 path: &Path,
181 runs: &BTreeMap<RunId, dpm_model::RunRecord>,
182) -> Result<(u64, LastStates), RunStoreError> {
183 let mut statement = connection
184 .prepare(&format!(
185 "{} ORDER BY sequence",
186 super::codec::LIFECYCLE_COLUMNS
187 ))
188 .map_err(database_error(path, "prepare lifecycle verification"))?;
189 let mut rows = statement
190 .query([])
191 .map_err(database_error(path, "read lifecycle"))?;
192 let mut states = LastStates::new();
193 let mut previous = 0_u64;
194 while let Some(row) = rows
195 .next()
196 .map_err(database_error(path, "read lifecycle"))?
197 {
198 let entry = read_event(row, path)?;
199 if entry.sequence != previous.wrapping_add(1) {
200 return Err(corrupt(
201 path,
202 format!(
203 "lifecycle skips from sequence {previous} to {}",
204 entry.sequence
205 ),
206 ));
207 }
208 previous = entry.sequence;
209 let event = entry.event;
210 let Some(record) = runs.get(&event.run) else {
211 return Err(corrupt(
212 path,
213 format!("lifecycle names unknown run {}", event.run),
214 ));
215 };
216 match states.get(&event.run) {
217 None => {
218 let begins = event.id == run_start_event(event.run)
219 && event.state == RunState::Working
220 && event.recorded_at == record.started_at;
221 if !begins {
222 return Err(corrupt(
223 path,
224 format!("run {} does not begin with its start", event.run),
225 ));
226 }
227 }
228 Some((from, _)) => {
229 check_run_transition(event.run, *from, event.state).map_err(|error| {
230 corrupt(path, format!("illegal transition recorded: {error}"))
231 })?;
232 }
233 }
234 states.insert(event.run, (event.state, event.recorded_at));
235 }
236 if let Some(unstarted) = runs.keys().find(|id| !states.contains_key(id)) {
237 return Err(corrupt(
238 path,
239 format!("run {unstarted} has no lifecycle record"),
240 ));
241 }
242 if head(connection, path, "run_lifecycle")? != previous {
243 return Err(corrupt(path, "lifecycle records were removed from the end"));
244 }
245 Ok((previous, states))
246}
247
248fn check_activity(
250 connection: &Connection,
251 path: &Path,
252 runs: &BTreeMap<RunId, dpm_model::RunRecord>,
253) -> Result<(u64, u64), RunStoreError> {
254 let pruned = pruned_through(connection, path)?;
255 let mut statement = connection
256 .prepare(&format!(
257 "{} ORDER BY sequence",
258 super::codec::ACTIVITY_COLUMNS
259 ))
260 .map_err(database_error(path, "prepare activity verification"))?;
261 let mut rows = statement
262 .query([])
263 .map_err(database_error(path, "read activity"))?;
264 let (mut previous, mut retained) = (None::<u64>, 0_u64);
265 let mut per_run: BTreeMap<RunId, u64> = BTreeMap::new();
266 let mut newest_source: BTreeMap<RunId, u64> = BTreeMap::new();
267 while let Some(row) = rows.next().map_err(database_error(path, "read activity"))? {
268 let entry = read_activity(row, path)?;
269 let expected = previous.map_or(pruned.wrapping_add(1), |last| last.wrapping_add(1));
270 if entry.sequence != expected {
271 return Err(corrupt(
272 path,
273 format!(
274 "activity holds sequence {} where {expected} should follow",
275 entry.sequence
276 ),
277 ));
278 }
279 if !runs.contains_key(&entry.record.run) {
280 return Err(corrupt(
281 path,
282 format!("activity names unknown run {}", entry.record.run),
283 ));
284 }
285 previous = Some(entry.sequence);
286 retained = retained.wrapping_add(1);
287 let held = per_run.entry(entry.record.run).or_default();
288 *held = held.wrapping_add(1);
289 let source = entry.record.source_sequence;
290 if newest_source
291 .insert(entry.record.run, source)
292 .is_some_and(|earlier| earlier >= source)
293 {
294 return Err(corrupt(
295 path,
296 format!(
297 "run {} holds source sequence {source} after a later or equal one",
298 entry.record.run
299 ),
300 ));
301 }
302 }
303 if head(connection, path, "run_activity")? != pruned.wrapping_add(retained) {
304 return Err(corrupt(
305 path,
306 "activity records were removed outside retention",
307 ));
308 }
309 for (run, newest) in newest_source {
312 let high_water = tally_row(connection, path, run)?.map(|counts| counts.high_water);
313 if high_water.is_none_or(|high_water| high_water < newest) {
314 return Err(corrupt(
315 path,
316 format!("run {run} holds source sequence {newest} above its high-water mark"),
317 ));
318 }
319 }
320 Ok((retained, pruned))
321}
322
323fn check_links(
324 connection: &Connection,
325 path: &Path,
326 runs: &BTreeMap<RunId, dpm_model::RunRecord>,
327) -> Result<Vec<RunLink>, RunStoreError> {
328 let mut statement = connection
329 .prepare(&format!(
330 "{} ORDER BY operation_id",
331 super::codec::LINK_COLUMNS
332 ))
333 .map_err(database_error(path, "prepare link verification"))?;
334 let mut rows = statement
335 .query([])
336 .map_err(database_error(path, "read links"))?;
337 let mut links = Vec::new();
338 while let Some(row) = rows.next().map_err(database_error(path, "read links"))? {
339 let link = read_link(row, path)?;
340 match runs.get(&link.run) {
341 Some(record) if record.work == link.work => {}
342 _ => {
343 return Err(corrupt(
344 path,
345 format!(
346 "operation {} is linked to a run that does not match it",
347 link.operation
348 ),
349 ));
350 }
351 }
352 links.push(link);
353 }
354 Ok(links)
355}
356
357#[derive(Clone, Copy)]
359enum Seal {
360 Archive,
361 Fork {
363 lineage: LineageId,
364 revision: u64,
365 },
366}
367
368fn seal_blocking(target: &Connection, path: &Path, seal: Seal) -> Result<(), StoreError> {
369 let changed = match seal {
370 Seal::Archive => target.execute("UPDATE run_binding SET archived = 1", []),
371 Seal::Fork { lineage, revision } => {
372 let parent: String = target
375 .query_row("SELECT lineage_id FROM run_binding", [], |row| row.get(0))
376 .map_err(database_error(path, "read the run store's lineage"))?;
377 target
378 .execute(
379 "INSERT INTO run_lineage_forks(lineage_id, parent_lineage_id, revision) \
380 VALUES(?1, ?2, ?3)",
381 rusqlite::params![lineage.to_string(), parent, revision_to_sql(revision)],
382 )
383 .map_err(database_error(path, "record the lineage fork"))?;
384 target.execute(
385 "UPDATE run_binding SET lineage_id = ?1, archived = 0",
386 [lineage.to_string()],
387 )
388 }
389 }
390 .map_err(database_error(path, "seal copied run store"))?;
391 if changed == 1 {
392 Ok(())
393 } else {
394 Err(StoreError::Lineage(LineageError::Missing {
395 path: path.to_path_buf(),
396 }))
397 }
398}
399
400impl RunStore {
401 pub(in crate::sqlite) fn backup_to_blocking(
404 &self,
405 to: &Path,
406 ) -> Result<RunStoreReport, RunStoreError> {
407 copy_blocking(&self.connection, to, "delete", |target| {
408 seal_blocking(target, to, Seal::Archive)
409 })?;
410 verify_blocking(to)
411 }
412}
413
414pub(in crate::sqlite) fn restore_blocking(
420 from: &Path,
421 to: &Path,
422 workspace: WorkspaceId,
423 lineage: LineageId,
424 revision: u64,
425) -> Result<RunStoreReport, RunStoreError> {
426 let from = canonical_blocking(from)?;
427 let report = verify_blocking(&from)?;
428 if !report.archived {
429 return Err(StoreError::from(LineageError::NotAnArchive { path: from }).into());
430 }
431 check_workspace(
432 &RunBinding {
433 workspace_id: report.workspace_id,
434 lineage_id: report.lineage_id,
435 archived: report.archived,
436 },
437 workspace,
438 &from,
439 )?;
440 let mode = read_mode_blocking(&from)?;
441 let before = fingerprint_blocking(&from)?;
442 let source = open_read_only_blocking(&from, mode)?;
443 copy_blocking(&source, to, "wal", |target| {
444 seal_blocking(target, to, Seal::Fork { lineage, revision })
445 })?;
446 if mode == ReadMode::Immutable && fingerprint_blocking(&from)? != before {
449 return Err(StoreError::BackupIncomplete {
450 path: to.to_path_buf(),
451 state: format!("{} changed during the restore", from.display()),
452 }
453 .into());
454 }
455 verify_blocking(to)
456}
457
458pub(in crate::sqlite) fn create_target(to: &Path) -> Result<(), StoreError> {
460 create_target_blocking(to)
461}
462
463pub(in crate::sqlite) fn discard(to: &Path) {
465 discard_blocking(to);
466}