Skip to main content

coven_database/
database_file_retention.rs

1//! File references used to prove that storage deletion cannot strand a row.
2
3use super::Database;
4use crate::file_protection::value as retain_value;
5use crate::{
6    DbError, DownloadedPartStream, DownloadedWriteStream, FileEvidence, Slot, UnprovenFiles,
7};
8use coven_format::{value::Value, write::RowChange};
9use coven_foundation::id_source::{DeviceId, ObjectId};
10use coven_merge::{ColumnValue, Operation};
11use rusqlite::params;
12use std::collections::{BTreeMap, BTreeSet};
13use std::ops::RangeInclusive;
14
15/// Local references read in one database transaction.
16#[cfg(test)]
17pub(crate) struct FileRetention {
18    /// Fixed paths named by rows, losses, published rows, waiting writes or
19    /// this device's upload queue.
20    pub references: BTreeSet<(DeviceId, ObjectId)>,
21}
22
23impl Database {
24    /// A snapshot whose absence was confirmed by an exact read or deletion.
25    pub async fn snapshot_absent(
26        &self,
27        id: coven_format::admin::SnapshotId,
28    ) -> Result<bool, DbError> {
29        self.call(move |inner| {
30            let reader = inner.access.readers.acquire_reader();
31            reader.with_reader(|db| db.query_row(
32                "SELECT EXISTS(SELECT 1 FROM _coven_absent_snapshots WHERE audience=?1 AND device=?2 AND number=?3)",
33                params![&id.audience, id.device, id.number.to_be_bytes()], |row| row.get(0)))
34        }).await
35    }
36
37    /// Confirm that a snapshot no longer protects its checked file references.
38    /// The references become candidates in the same transaction as that fact.
39    pub async fn record_absent_snapshot(
40        &self,
41        id: coven_format::admin::SnapshotId,
42        files: BTreeSet<(DeviceId, ObjectId)>,
43    ) -> Result<(), DbError> {
44        self.call(move |inner| {
45            let number = std::num::NonZeroU64::new(id.number).ok_or(DbError::DamagedDatabase)?;
46            let path = coven_format::path::ObjectPath::snapshot(
47                inner.access.directory.id(),
48                id.audience.clone(),
49                id.device,
50                number,
51            );
52            inner.access.with_writer(|writer| writer.transaction(|db| {
53                for (device, object) in files { insert_candidate(db, device, object)?; }
54                db.internal_execute("INSERT INTO _coven_absent_snapshots(audience,device,number) VALUES(?1,?2,?3) ON CONFLICT DO NOTHING",
55                    params![&id.audience, id.device, id.number.to_be_bytes()])?;
56                Ok(())
57            }))?;
58            inner.inputs.forget_path(path.as_str())
59        }).await
60    }
61    /// Forget announcements no rule reads any more (§16.8): their original
62    /// slots at `slots`, and the absences recorded for `snapshots`, the
63    /// bodies only they named. `listings` journals the listings that find
64    /// those bodies in storage, in the same transaction, so a forgotten
65    /// announcement's body is never left without one.
66    pub async fn forget_announcements(
67        &self,
68        slots: BTreeSet<Slot>,
69        snapshots: BTreeSet<coven_format::admin::SnapshotId>,
70        listings: Vec<crate::NewOperation>,
71    ) -> Result<(), DbError> {
72        self.call(move |inner| {
73            inner.access.with_writer(|writer| {
74                writer.transaction(|db| {
75                    crate::sequence_apply::forget_announcements(db, &slots)?;
76                    for id in &snapshots {
77                        db.internal_execute(
78                            "DELETE FROM _coven_absent_snapshots WHERE audience=?1 AND device=?2 AND number=?3",
79                            params![&id.audience, id.device, id.number.to_be_bytes()],
80                        )?;
81                    }
82                    for listing in &listings {
83                        crate::operation::insert(db, listing)?;
84                    }
85                    Ok(())
86                })
87            })
88        })
89        .await
90    }
91
92    /// Every fixed path local data protects, in one read transaction.
93    #[cfg(test)]
94    pub(crate) async fn retained_files(&self) -> Result<FileRetention, DbError> {
95        self.call(move |inner| {
96            inner.access.with_committed(|db| {
97                let references = db.query("SELECT device,object FROM _coven_row_file_refs UNION SELECT device,object FROM _coven_published_file_refs UNION SELECT device,object FROM _coven_queued_file_refs UNION SELECT device,object FROM _coven_file_uploads", [], |r| Ok((r.get::<_, DeviceId>(0)?,r.get::<_, ObjectId>(1)?)))?.into_iter().collect();
98                Ok(FileRetention { references })
99            })
100        })
101        .await
102    }
103
104    /// Stream the stored paths a write's records name from checked plaintext. The caller
105    /// must authenticate the complete object before using this result. It
106    /// names the audience and key of each part it skipped: a skipped part, or
107    /// a schema version this version cannot interpret, proves no file absent.
108    pub async fn write_file_references<R: std::io::Read + Send + 'static>(
109        &self,
110        write: DownloadedWriteStream<R>,
111        skipped: BTreeSet<(coven_merge::Audience, coven_foundation::id_source::KeyId)>,
112    ) -> Result<FileEvidence, DbError> {
113        self.call(move |inner| {
114            let mut references = BTreeSet::new();
115            write.header.encode()?;
116            let version = write.header.header.schema_version;
117            if !inner.access.write_schema.versions.contains(&version) {
118                return Ok(FileEvidence::Unproven(UnprovenFiles::Schema(version)));
119            }
120            if write.header.parts.len() != write.parts.len() {
121                return Err(crate::SnapshotError::Inconsistent(
122                    "write stream count differs from its header",
123                )
124                .into());
125            }
126            let opened = write
127                .parts
128                .iter()
129                .filter(|part| matches!(part, DownloadedPartStream::Opened(_)))
130                .count();
131            if (opened == write.parts.len()) != skipped.is_empty() {
132                return Err(crate::SnapshotError::Inconsistent(
133                    "skipped parts disagree with the parts named skipped",
134                )
135                .into());
136            }
137            if !skipped.is_empty() {
138                return Ok(FileEvidence::Unproven(UnprovenFiles::Parts(skipped)));
139            }
140            for (header, part) in write.header.parts.into_iter().zip(write.parts) {
141                let DownloadedPartStream::Opened(input) = part else {
142                    unreachable!("every part opened");
143                };
144                crate::download_stream::read_part(input, header, |frame| {
145                    if let coven_format::dismissal::WriteFrame::Change(row) = frame {
146                        retain_change(&row, &mut references)?;
147                    }
148                    Ok::<_, DbError>(())
149                })?;
150            }
151            Ok(FileEvidence::Complete(references))
152        })
153        .await
154    }
155
156    /// The schema versions whose writes and snapshots this version interprets.
157    pub async fn write_schema_versions(&self) -> Result<RangeInclusive<u32>, DbError> {
158        self.call(move |inner| Ok(inner.access.write_schema.versions.clone())).await
159    }
160
161    /// The candidates of these uploaders, each with whether local data still
162    /// protects it (§17.10). Other uploaders' candidates are not read: their
163    /// files are deleted by another device until ownership changes.
164    pub async fn file_candidates(
165        &self,
166        uploaders: BTreeSet<DeviceId>,
167    ) -> Result<Vec<FileCandidate>, DbError> {
168        self.call(move |inner| {
169            inner.access.with_committed(|db| {
170                let mut candidates = Vec::new();
171                for device in uploaders {
172                    candidates.extend(db.query(
173                        "SELECT object,EXISTS(SELECT 1 FROM _coven_file_protection p WHERE p.device=c.device AND p.object=c.object),since FROM _coven_file_candidates c WHERE c.device=?1 ORDER BY c.object",
174                        [device],
175                        |r| {
176                            Ok(FileCandidate {
177                                device,
178                                object: r.get(0)?,
179                                protected: r.get(1)?,
180                                since: crate::store_state_tables::slot(r, 2)?,
181                            })
182                        },
183                    )?);
184                }
185                Ok(candidates)
186            })
187        })
188        .await
189    }
190
191    /// The slot count each of these devices had read through when it made its
192    /// last published write, for those that published one. Every write it
193    /// made before is published too (§6.3, §17.10).
194    pub async fn published_reads(
195        &self,
196        devices: BTreeSet<DeviceId>,
197    ) -> Result<BTreeMap<DeviceId, u64>, DbError> {
198        self.call(move |inner| {
199            inner.access.with_committed(|db| {
200                let mut reads = BTreeMap::new();
201                for device in devices {
202                    if let Some(read) = crate::write_metadata::last_landed_read(db, device)? {
203                        reads.insert(device, read);
204                    }
205                }
206                Ok(reads)
207            })
208        })
209        .await
210    }
211
212    /// Settle candidates in one transaction. A protected one goes only while
213    /// local data still protects it, so releasing that protection adds it
214    /// again; a deleted one goes after storage confirmed its deletion.
215    pub async fn settle_file_candidates(
216        &self,
217        protected: Vec<(DeviceId, ObjectId)>,
218        deleted: Vec<(DeviceId, ObjectId)>,
219    ) -> Result<(), DbError> {
220        if protected.is_empty() && deleted.is_empty() {
221            return Ok(());
222        }
223        self.call(move |inner| {
224            inner.access.with_writer(|writer| {
225                writer.transaction(|db| {
226                    for (device, object) in protected {
227                        db.internal_execute(
228                            "DELETE FROM _coven_file_candidates WHERE device=?1 AND object=?2 AND EXISTS(SELECT 1 FROM _coven_file_protection WHERE device=?1 AND object=?2)",
229                            params![device, object],
230                        )?;
231                    }
232                    for (device, object) in deleted {
233                        db.internal_execute(
234                            "DELETE FROM _coven_file_candidates WHERE device=?1 AND object=?2",
235                            params![device, object],
236                        )?;
237                    }
238                    Ok(())
239                })
240            })
241        })
242        .await
243    }
244
245    /// Paths named by a retained object this device confirmed deleted: its
246    /// protection ended with the object (§17.10).
247    #[cfg(test)]
248    pub async fn add_file_candidates(
249        &self,
250        files: BTreeSet<(DeviceId, ObjectId)>,
251    ) -> Result<(), DbError> {
252        if files.is_empty() {
253            return Ok(());
254        }
255        self.call(move |inner| {
256            inner.access.with_writer(|writer| {
257                writer.transaction(|db| {
258                    for (device, object) in files {
259                        insert_candidate(db, device, object)?;
260                    }
261                    Ok(())
262                })
263            })
264        })
265        .await
266    }
267
268    /// The counted entry stopping `device` for good at which this device
269    /// last listed its file folder, if it did (§17.10).
270    pub async fn file_scan(&self, device: DeviceId) -> Result<Option<Slot>, DbError> {
271        self.call(move |inner| {
272            let reader = inner.access.readers.acquire_reader();
273            reader.with_reader(|db| {
274                Ok(db
275                    .query("SELECT slot FROM _coven_file_scans WHERE device=?1", [device], |r| {
276                        crate::store_state_tables::slot(r, 0)
277                    })?
278                    .pop())
279            })
280        })
281        .await
282    }
283
284    /// Record a complete listing of the file folder of an uploader gone for
285    /// good, taken once the entry at `stop` stopped it, with every path it
286    /// found as a candidate. Returns false, recording nothing, when this
287    /// device hasn't applied a counted cut-off at `stop` naming the device or
288    /// its member: the history the caller judged by is not the one applied
289    /// here. Whether a replacement of the member's keys stopped the device,
290    /// which depends on the slot that added it and the entry's author, the
291    /// caller's history decides (§14.1). Nothing the device writes after the
292    /// stop counts, so any listing taken after it is sound, however often it
293    /// is taken (§17.10).
294    pub async fn complete_file_scan(
295        &self,
296        device: DeviceId,
297        stop: Slot,
298        objects: BTreeSet<ObjectId>,
299    ) -> Result<bool, DbError> {
300        self.call(move |inner| {
301            inner.access.with_writer(|writer| {
302                writer.transaction(|db| {
303                    let kept: bool = db.query_row(
304                        "SELECT EXISTS(SELECT 1 FROM _coven_cutoffs c JOIN _coven_devices d ON d.device=?1 WHERE c.slot=?2 AND (c.device=d.device OR c.member=d.member))",
305                        params![device, stop.get().to_be_bytes()],
306                        |r| r.get(0),
307                    )?;
308                    if !kept {
309                        return Ok(false);
310                    }
311                    for object in objects {
312                        insert_candidate(db, device, object)?;
313                    }
314                    db.internal_execute(
315                        "INSERT INTO _coven_file_scans(device,slot) VALUES(?1,?2) ON CONFLICT(device) DO UPDATE SET slot=excluded.slot",
316                        params![
317                            device,
318                            stop.get().to_be_bytes()
319                        ],
320                    )?;
321                    Ok(true)
322                })
323            })
324        })
325        .await
326    }
327}
328
329/// A fixed file path awaiting a storage-deletion decision (§17.10).
330#[derive(Clone, Copy, Debug, PartialEq, Eq)]
331pub struct FileCandidate {
332    /// The uploader named by its path.
333    pub device: DeviceId,
334    /// The stored object's id, the last part of its path.
335    pub object: ObjectId,
336    /// A local row, loss, published row, queued write or upload still names it.
337    pub protected: bool,
338    /// This device's applied slot when the path last lost its local
339    /// protection, or a later one: a write that read through it saw every
340    /// change that left the path unnamed (§17.10).
341    pub since: Slot,
342}
343
344fn insert_candidate(
345    db: &crate::sqlite::DatabaseConnection,
346    device: DeviceId,
347    object: ObjectId,
348) -> Result<(), DbError> {
349    db.internal_execute(
350        "INSERT INTO _coven_file_candidates(device,object,since) VALUES(?1,?2,(SELECT applied FROM _coven_sequence WHERE singleton=1)) ON CONFLICT DO UPDATE SET since=max(since,excluded.since)",
351        params![device, object],
352    )?;
353    Ok(())
354}
355
356/// Count every candidate from the applied slot `slot`, as though each lost
357/// its protection there.
358pub(crate) fn count_candidates_from(
359    db: &crate::sqlite::DatabaseConnection,
360    slot: u64,
361) -> Result<(), DbError> {
362    db.internal_execute("UPDATE _coven_file_candidates SET since=?1", [slot.to_be_bytes()])?;
363    Ok(())
364}
365
366/// Stored files named by the records and losses of the audience snapshot
367/// just read into this transaction. A location that won't decode is the
368/// snapshot's own failure.
369pub(crate) fn snapshot_references(
370    db: &crate::sqlite::DatabaseConnection,
371    schema: &crate::write_schema::WriteSchema,
372    audience: &coven_merge::Audience,
373) -> Result<BTreeSet<(DeviceId, ObjectId)>, DbError> {
374    let mut references = BTreeSet::new();
375    crate::snapshot_install::file_locations(db, schema, audience, |value| {
376        retain_value(value, &mut references)
377    })
378    .and_then(|()| {
379        crate::loss_record::visit(db, audience, |loss| {
380            crate::file_protection::loss(&loss, &mut references);
381            Ok::<_, DbError>(())
382        })
383    })
384    .map_err(|error| match error {
385        DbError::DamagedDatabase => {
386            crate::snapshot_error::invalid("snapshot file location is invalid")
387        }
388        error => error,
389    })?;
390    Ok(references)
391}
392
393/// The stored paths a published change names: the coordinates a file record's
394/// insert or update writes, and those its delete removes. An update names no
395/// coordinates it replaced (D5.5): its author protected them only until the
396/// write was published (§17.10).
397fn retain_change(
398    row: &RowChange,
399    references: &mut BTreeSet<(DeviceId, ObjectId)>,
400) -> Result<(), DbError> {
401    if let Operation::Insert(columns) | Operation::Update(columns) = &row.change.operation {
402        retain_columns(&row.row.table, columns, references)?;
403    }
404    if let Some(column) = crate::file_protection::location(&row.row.table) {
405        if let Some(value) = row.old.get(column) {
406            retain_value(value, references)?;
407        }
408    }
409    Ok(())
410}
411
412fn retain_columns(
413    table: &str,
414    columns: &BTreeMap<String, ColumnValue<Value>>,
415    references: &mut BTreeSet<(DeviceId, ObjectId)>,
416) -> Result<(), DbError> {
417    if let Some(column) = crate::file_protection::location(table) {
418        if let Some(value) = columns.get(column) {
419            retain_value(&value.value, references)?;
420        }
421    }
422    Ok(())
423}
424
425#[cfg(test)]
426#[path = "database_file_retention_tests.rs"]
427pub(crate) mod tests;