Skip to main content

coven_database/store/store_session/
payload_store.rs

1//! Content-addressed payloads owned by Store database rows.
2//!
3//! Every payload is compressed. Compressed values through 64 KiB live in SQLite;
4//! larger values live in files beside it. The catalog on the same connection is
5//! the sole authority for which representation a hash uses.
6//!
7//! Deletion rides row deletion, counted by owner. Rows of different kinds can
8//! name the same payload — a Circle operation and the remote object it prepared
9//! both need one object's bytes — so a row does not delete the storage it is done
10//! with; it drops its claim with [`set_payload_owner_claims_on`], and the
11//! transaction that drops the last claim records the deletion obligation.
12//!
13//! [`pay_owed_payload_deletions_on`] is the other half. Every call this store
14//! makes runs it once the call's own work has returned, so the flow that
15//! committed an obligation is the flow that discharges it and no sweeper exists
16//! to be kept correct. A failure between removing file-backed bytes and clearing
17//! the obligation leaves the obligation durable, so the caller's retry finishes
18//! the deletion rather than losing it.
19
20use std::collections::BTreeSet;
21use std::io::Write as _;
22use std::path::{Path, PathBuf};
23
24use coven_foundation::atomic_file::AtomicFileStage;
25use coven_foundation::store_dir::StoreDir;
26use rusqlite::{Connection, OptionalExtension};
27use tracing::debug;
28
29use super::StoreTransaction;
30#[cfg(any(test, feature = "test-utils"))]
31use super::{StoreDatabase, StoreSession};
32use crate::DbError;
33use coven_protocol::store_commit::ObjectHash;
34
35#[derive(Debug, thiserror::Error)]
36pub enum PayloadStoreError {
37    /// A payload a caller holds the hash of is not in the spool. The row naming
38    /// it and the file it names have gone out of step, which no flow may
39    /// recover from by reading something else.
40    #[error("payload {hash} is absent from the spool at {}", path.display())]
41    Missing { hash: ObjectHash, path: PathBuf },
42    #[error(
43        "payload at {} hashes to {actual}, but its row names {expected}",
44        path.display()
45    )]
46    ContentMismatch {
47        expected: ObjectHash,
48        actual: ObjectHash,
49        path: PathBuf,
50    },
51    #[error("{operation} payload spool {}: {source}", path.display())]
52    FileIo {
53        operation: &'static str,
54        path: PathBuf,
55        #[source]
56        source: std::io::Error,
57    },
58    #[error("commit payload spool {}: {source}", path.display())]
59    AtomicFile {
60        path: PathBuf,
61        #[source]
62        source: coven_foundation::atomic_file::WriteError<coven_foundation::atomic_file::FileError>,
63    },
64    #[error("payload spool filesystem: {0}")]
65    LocalFile(#[from] coven_foundation::atomic_file::FileError),
66    #[error("payload {hash} database operation: {source}")]
67    Database {
68        hash: ObjectHash,
69        #[source]
70        source: rusqlite::Error,
71    },
72    #[error("payload {hash} size does not fit SQLite: {source}")]
73    SizeConversion {
74        hash: ObjectHash,
75        #[source]
76        source: std::num::TryFromIntError,
77    },
78    #[error("payload {hash} has invalid storage metadata: {error}")]
79    Storage { hash: ObjectHash, error: String },
80    #[error("inline payload {expected} contains bytes hashing to {actual}")]
81    InlineContentMismatch {
82        expected: ObjectHash,
83        actual: ObjectHash,
84    },
85    #[error("payload {hash} compression I/O failed: {source}")]
86    CompressionIo {
87        hash: ObjectHash,
88        #[source]
89        source: std::io::Error,
90    },
91    #[error("payload {hash} compression framing failed: {source}")]
92    CompressionFrame {
93        hash: ObjectHash,
94        #[source]
95        source: lz4_flex::frame::Error,
96    },
97}
98
99const INLINE_PAYLOAD_LIMIT: usize = 64 * 1024;
100
101enum StoredPayload {
102    Inline {
103        compressed: Vec<u8>,
104        payload_size: u64,
105    },
106    File {
107        compressed_size: u64,
108        payload_size: u64,
109    },
110}
111
112impl StoredPayload {
113    fn payload_size(&self) -> u64 {
114        match self {
115            Self::Inline { payload_size, .. } | Self::File { payload_size, .. } => *payload_size,
116        }
117    }
118}
119
120enum ExistingPayloadState {
121    Absent,
122    Verified,
123    RepairableFile,
124}
125
126/// One database connection's closed access to the payload bytes its rows own.
127/// The catalog on that connection selects inline SQLite bytes or the Store
128/// directory's file spool; callers never receive either dependency.
129#[derive(Clone, Copy)]
130pub(crate) struct PayloadStore<'store> {
131    conn: &'store Connection,
132    store_dir: &'store StoreDir,
133}
134
135impl<'store> PayloadStore<'store> {
136    pub(crate) fn new(conn: &'store Connection, store_dir: &'store StoreDir) -> Self {
137        Self { conn, store_dir }
138    }
139
140    pub(crate) fn install(self, bytes: &[u8]) -> Result<ObjectHash, PayloadStoreError> {
141        let hash = ObjectHash::digest(bytes);
142        self.require_transaction(hash)?;
143        let existing_file = match self.existing_payload_state(hash, bytes.len() as u64)? {
144            ExistingPayloadState::Absent => false,
145            ExistingPayloadState::RepairableFile => true,
146            ExistingPayloadState::Verified => return Ok(hash),
147        };
148        let compressed = compress_payload(hash, bytes)?;
149        if existing_file || compressed.len() > INLINE_PAYLOAD_LIMIT {
150            self.record_file(hash, bytes.len() as u64, compressed.len() as u64)?;
151            write_payload_file_bytes_blocking(self.store_dir, hash, &compressed)?;
152        } else {
153            self.record_inline(hash, bytes.len() as u64, &compressed)?;
154        }
155        Ok(hash)
156    }
157
158    pub(super) fn copy_verified(
159        self,
160        hash: ObjectHash,
161        output: &mut impl std::io::Write,
162    ) -> Result<u64, PayloadStoreError> {
163        let stored = self.require_stored(hash)?;
164        let size = stored.payload_size();
165        let inline = matches!(stored, StoredPayload::Inline { .. });
166        let mut output = reading::HashedPayloadOutput::new(output);
167        self.copy_stored(hash, stored, &mut output)?;
168        self.verify_hash(hash, output.finish(), inline)?;
169        Ok(size)
170    }
171
172    fn writer(self) -> PayloadWriter<'store> {
173        PayloadWriter {
174            payloads: self,
175            encoder: lz4_flex::frame::FrameEncoder::new(CompressedPayloadTarget::new(
176                self.store_dir,
177            )),
178            hasher: coven_protocol::blob::ContentHasher::new(),
179            size: 0,
180        }
181    }
182
183    pub(super) fn file_writer(
184        self,
185        source: &Path,
186    ) -> Result<PayloadWriter<'store>, PayloadStoreError> {
187        let mut input = std::fs::File::open(source).map_err(|error| PayloadStoreError::FileIo {
188            operation: "open",
189            path: source.to_path_buf(),
190            source: error,
191        })?;
192        let mut writer = self.writer();
193        std::io::copy(&mut input, &mut writer).map_err(|error| PayloadStoreError::FileIo {
194            operation: "copy",
195            path: source.to_path_buf(),
196            source: error,
197        })?;
198        Ok(writer)
199    }
200
201    fn require_transaction(self, hash: ObjectHash) -> Result<(), PayloadStoreError> {
202        if self.conn.is_autocommit() {
203            return Err(PayloadStoreError::Storage {
204                hash,
205                error: "installation requires the owning database transaction".to_string(),
206            });
207        }
208        Ok(())
209    }
210
211    fn existing_payload_state(
212        self,
213        hash: ObjectHash,
214        expected_size: u64,
215    ) -> Result<ExistingPayloadState, PayloadStoreError> {
216        let Some(stored) = self.stored(hash)? else {
217            return Ok(ExistingPayloadState::Absent);
218        };
219        let payload_size = stored.payload_size();
220        if payload_size != expected_size {
221            return Err(PayloadStoreError::Storage {
222                hash,
223                error: format!(
224                    "catalog records {payload_size} payload bytes, but installation has {expected_size}"
225                ),
226            });
227        }
228        let inline = matches!(stored, StoredPayload::Inline { .. });
229        let mut sink = std::io::sink();
230        let mut output = reading::HashedPayloadOutput::new(&mut sink);
231        match self.copy_stored(hash, stored, &mut output) {
232            Ok(()) => {}
233            Err(
234                PayloadStoreError::Missing { .. }
235                | PayloadStoreError::Storage { .. }
236                | PayloadStoreError::FileIo { .. }
237                | PayloadStoreError::CompressionIo { .. }
238                | PayloadStoreError::CompressionFrame { .. },
239            ) if !inline => return Ok(ExistingPayloadState::RepairableFile),
240            Err(error) => return Err(error),
241        };
242        self.verify_hash(hash, output.finish(), inline)?;
243        Ok(ExistingPayloadState::Verified)
244    }
245
246    fn stored(self, hash: ObjectHash) -> Result<Option<StoredPayload>, PayloadStoreError> {
247        let row = self
248            .conn
249            .query_row(
250                "SELECT storage, payload_size, compressed_bytes, compressed_size
251                 FROM payload_storage WHERE payload_hash = ?1",
252                [hash.to_string()],
253                |row| {
254                    Ok((
255                        row.get::<_, String>(0)?,
256                        row.get::<_, i64>(1)?,
257                        row.get::<_, Option<Vec<u8>>>(2)?,
258                        row.get::<_, i64>(3)?,
259                    ))
260                },
261            )
262            .optional()
263            .map_err(|source| PayloadStoreError::Database { hash, source })?;
264        match row {
265            None => Ok(None),
266            Some((storage, payload_size, Some(compressed), compressed_size))
267                if storage == "inline"
268                    && payload_size >= 0
269                    && compressed_size == compressed.len() as i64 =>
270            {
271                Ok(Some(StoredPayload::Inline {
272                    compressed,
273                    payload_size: payload_size as u64,
274                }))
275            }
276            Some((storage, payload_size, None, compressed_size))
277                if storage == "file" && payload_size >= 0 && compressed_size > 0 =>
278            {
279                Ok(Some(StoredPayload::File {
280                    compressed_size: compressed_size as u64,
281                    payload_size: payload_size as u64,
282                }))
283            }
284            Some((storage, payload_size, compressed, compressed_size)) => {
285                Err(PayloadStoreError::Storage {
286                    hash,
287                    error: format!(
288                        "tag {storage:?}, payload size {payload_size}, compressed bytes {}, compressed size {compressed_size}",
289                        compressed
290                            .as_ref()
291                            .map_or("absent".to_string(), |bytes| format!(
292                                "{} bytes",
293                                bytes.len()
294                            ))
295                    ),
296                })
297            }
298        }
299    }
300
301    fn record_inline(
302        self,
303        hash: ObjectHash,
304        payload_size: u64,
305        compressed: &[u8],
306    ) -> Result<(), PayloadStoreError> {
307        let payload_size = i64::try_from(payload_size)
308            .map_err(|source| PayloadStoreError::SizeConversion { hash, source })?;
309        let compressed_size = i64::try_from(compressed.len())
310            .map_err(|source| PayloadStoreError::SizeConversion { hash, source })?;
311        match self.stored(hash)? {
312            None => self
313                .conn
314                .execute(
315                    "INSERT INTO payload_storage
316                     (payload_hash, payload_size, storage, compressed_bytes, compressed_size)
317                     VALUES (?1, ?2, 'inline', ?3, ?4)",
318                    rusqlite::params![hash.to_string(), payload_size, compressed, compressed_size],
319                )
320                .map(|_| ())
321                .map_err(|source| PayloadStoreError::Database { hash, source }),
322            Some(StoredPayload::Inline {
323                compressed: stored,
324                payload_size: stored_payload_size,
325            }) if stored == compressed && stored_payload_size == payload_size as u64 => Ok(()),
326            Some(StoredPayload::Inline { .. }) => Err(PayloadStoreError::Storage {
327                hash,
328                error: "installed inline representation differs from the payload".to_string(),
329            }),
330            Some(StoredPayload::File { .. }) => Err(PayloadStoreError::Storage {
331                hash,
332                error: "an inline installation conflicts with file storage".to_string(),
333            }),
334        }
335    }
336
337    fn record_file(
338        self,
339        hash: ObjectHash,
340        payload_size: u64,
341        compressed_size: u64,
342    ) -> Result<(), PayloadStoreError> {
343        let payload_size = i64::try_from(payload_size)
344            .map_err(|source| PayloadStoreError::SizeConversion { hash, source })?;
345        let compressed_size = i64::try_from(compressed_size)
346            .map_err(|source| PayloadStoreError::SizeConversion { hash, source })?;
347        match self.stored(hash)? {
348            None => self
349                .conn
350                .execute(
351                    "INSERT INTO payload_storage
352                     (payload_hash, payload_size, storage, compressed_bytes, compressed_size)
353                     VALUES (?1, ?2, 'file', NULL, ?3)",
354                    rusqlite::params![hash.to_string(), payload_size, compressed_size],
355                )
356                .map(|_| ())
357                .map_err(|source| PayloadStoreError::Database { hash, source }),
358            Some(StoredPayload::File {
359                payload_size: stored_payload_size,
360                ..
361            }) if stored_payload_size == payload_size as u64 => {
362                let updated = self
363                    .conn
364                    .execute(
365                        "UPDATE payload_storage SET compressed_size = ?2
366                     WHERE payload_hash = ?1 AND storage = 'file'",
367                        rusqlite::params![hash.to_string(), compressed_size],
368                    )
369                    .map_err(|source| PayloadStoreError::Database { hash, source })?;
370                if updated != 1 {
371                    return Err(PayloadStoreError::Storage {
372                        hash,
373                        error: format!("file metadata update changed {updated} rows"),
374                    });
375                }
376                Ok(())
377            }
378            Some(StoredPayload::File {
379                compressed_size: stored_compressed_size,
380                payload_size: stored_payload_size,
381            }) => Err(PayloadStoreError::Storage {
382                hash,
383                error: format!(
384                    "catalog sizes ({stored_payload_size} payload, {stored_compressed_size} compressed) differ from installed sizes ({payload_size} payload, {compressed_size} compressed)"
385                ),
386            }),
387            Some(StoredPayload::Inline { .. }) => Err(PayloadStoreError::Storage {
388                hash,
389                error: "a file installation conflicts with inline storage".to_string(),
390            }),
391        }
392    }
393}
394
395/// One payload being streamed into an unpublished file while its content hash
396/// is computed from the bytes the file accepted.
397pub(crate) struct PayloadWriter<'store> {
398    payloads: PayloadStore<'store>,
399    encoder: lz4_flex::frame::FrameEncoder<CompressedPayloadTarget<'store>>,
400    hasher: coven_protocol::blob::ContentHasher,
401    size: u64,
402}
403
404enum PayloadWriterTarget {
405    Inline(Vec<u8>),
406    File(AtomicFileStage),
407}
408
409struct CompressedPayloadTarget<'store> {
410    store_dir: &'store StoreDir,
411    target: PayloadWriterTarget,
412    size: u64,
413}
414
415impl<'store> CompressedPayloadTarget<'store> {
416    fn new(store_dir: &'store StoreDir) -> Self {
417        Self {
418            store_dir,
419            target: PayloadWriterTarget::Inline(Vec::new()),
420            size: 0,
421        }
422    }
423}
424
425impl<'store> PayloadWriter<'store> {
426    pub(crate) fn commit(self) -> Result<(ObjectHash, u64), PayloadStoreError> {
427        self.commit_tracking_created_files(None)
428    }
429
430    pub(super) fn commit_for_capture(
431        self,
432        created_files: &mut Vec<PathBuf>,
433    ) -> Result<(ObjectHash, u64), PayloadStoreError> {
434        self.commit_tracking_created_files(Some(created_files))
435    }
436
437    fn commit_tracking_created_files(
438        self,
439        created_files: Option<&mut Vec<PathBuf>>,
440    ) -> Result<(ObjectHash, u64), PayloadStoreError> {
441        let hash = self
442            .hasher
443            .finish()
444            .parse::<ObjectHash>()
445            .expect("SHA-256 hex is an ObjectHash");
446        self.payloads.require_transaction(hash)?;
447        let existing_file = match self.payloads.existing_payload_state(hash, self.size)? {
448            ExistingPayloadState::Absent => false,
449            ExistingPayloadState::RepairableFile => true,
450            ExistingPayloadState::Verified => return Ok((hash, self.size)),
451        };
452        let compressed = self
453            .encoder
454            .finish()
455            .map_err(|source| PayloadStoreError::CompressionFrame { hash, source })?;
456        match (existing_file, compressed.target) {
457            (true, PayloadWriterTarget::Inline(bytes)) => {
458                self.payloads
459                    .record_file(hash, self.size, compressed.size)?;
460                write_payload_file_bytes_blocking(self.payloads.store_dir, hash, &bytes)?;
461            }
462            (false, PayloadWriterTarget::Inline(bytes)) => {
463                self.payloads.record_inline(hash, self.size, &bytes)?;
464            }
465            (_, PayloadWriterTarget::File(staged)) => {
466                let path = self.payloads.store_dir.payload_spool_path(hash);
467                self.payloads
468                    .record_file(hash, self.size, compressed.size)?;
469                let installation = staged.commit(&path);
470                let renamed = match &installation {
471                    Ok(()) => true,
472                    Err(error) => error.committed(),
473                };
474                if !existing_file && renamed {
475                    if let Some(created_files) = created_files {
476                        created_files.push(path.clone());
477                    }
478                }
479                installation.map_err(|source| PayloadStoreError::AtomicFile { path, source })?;
480            }
481        }
482        Ok((hash, self.size))
483    }
484}
485
486impl<'store, 'connection> StoreTransaction<'store, 'connection> {
487    pub(crate) fn payload_writer(self) -> PayloadWriter<'store> {
488        PayloadStore::new(self.transaction, self.store_dir).writer()
489    }
490}
491
492impl std::io::Write for PayloadWriter<'_> {
493    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
494        let written = self.encoder.write(bytes)?;
495        self.hasher.update(&bytes[..written]);
496        self.size = self
497            .size
498            .checked_add(written as u64)
499            .ok_or_else(|| std::io::Error::other("payload size overflow"))?;
500        Ok(written)
501    }
502
503    fn flush(&mut self) -> std::io::Result<()> {
504        self.encoder.flush()
505    }
506}
507
508impl std::io::Write for CompressedPayloadTarget<'_> {
509    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
510        let written = match &mut self.target {
511            PayloadWriterTarget::Inline(buffer)
512                if buffer.len().saturating_add(bytes.len()) <= INLINE_PAYLOAD_LIMIT =>
513            {
514                buffer.extend_from_slice(bytes);
515                bytes.len()
516            }
517            PayloadWriterTarget::Inline(buffer) => {
518                let directory = self.store_dir.payload_spool_dir();
519                let mut staged = self
520                    .store_dir
521                    .create_payload_spool_stage()
522                    .map_err(|error| {
523                        std::io::Error::new(
524                            error.kind(),
525                            coven_foundation::atomic_file::FileError::at(
526                                "create payload stage",
527                                &directory,
528                                error,
529                            ),
530                        )
531                    })?;
532                staged.write_all(buffer)?;
533                let written = staged.write(bytes)?;
534                self.target = PayloadWriterTarget::File(staged);
535                written
536            }
537            PayloadWriterTarget::File(staged) => staged.write(bytes)?,
538        };
539        self.size = self
540            .size
541            .checked_add(written as u64)
542            .ok_or_else(|| std::io::Error::other("compressed payload size overflow"))?;
543        Ok(written)
544    }
545
546    fn flush(&mut self) -> std::io::Result<()> {
547        match &mut self.target {
548            PayloadWriterTarget::Inline(_) => Ok(()),
549            PayloadWriterTarget::File(staged) => staged.flush(),
550        }
551    }
552}
553
554fn compress_payload(hash: ObjectHash, bytes: &[u8]) -> Result<Vec<u8>, PayloadStoreError> {
555    let mut encoder = lz4_flex::frame::FrameEncoder::new(Vec::new());
556    encoder
557        .write_all(bytes)
558        .map_err(|source| PayloadStoreError::CompressionIo { hash, source })?;
559    encoder
560        .finish()
561        .map_err(|source| PayloadStoreError::CompressionFrame { hash, source })
562}
563
564/// Delete the payload behind every committed cleanup obligation, clearing each
565/// obligation once its file is gone.
566///
567/// Runs on the database's connection thread, where the transactions that record
568/// obligations also run, so a payload cannot be re-claimed and rewritten between
569/// this reading its obligation and removing its file. A failure between the two
570/// leaves the obligation durable and fails the caller, so the caller's retry
571/// finishes the deletion rather than losing it.
572pub(crate) fn pay_owed_payload_deletions_on(
573    conn: &Connection,
574    store_dir: &StoreDir,
575) -> Result<(), DbError> {
576    for hash in payload_cleanup_hashes_on(conn)? {
577        let payloads = PayloadStore::new(conn, store_dir);
578        match payloads.stored(hash).map_err(DbError::from)? {
579            Some(StoredPayload::Inline { .. }) => {}
580            Some(StoredPayload::File { .. }) => {
581                delete_payload_file_blocking(store_dir, hash).map_err(DbError::from)?;
582            }
583            None => {
584                return Err(DbError::Message(format!(
585                    "payload deletion obligation {hash} has no storage row"
586                )));
587            }
588        }
589        let transaction = conn.unchecked_transaction().map_err(DbError::from)?;
590        transaction
591            .execute(
592                "DELETE FROM payload_cleanup WHERE payload_hash = ?1",
593                [hash.to_string()],
594            )
595            .map_err(DbError::from)?;
596        let removed = transaction
597            .execute(
598                "DELETE FROM payload_storage
599                 WHERE payload_hash = ?1
600                   AND NOT EXISTS (
601                       SELECT 1 FROM payload_owners
602                       WHERE payload_hash = ?1
603                   )",
604                [hash.to_string()],
605            )
606            .map_err(DbError::from)?;
607        if removed != 1 {
608            return Err(DbError::Message(format!(
609                "payload deletion obligation {hash} is still claimed"
610            )));
611        }
612        transaction.commit().map_err(DbError::from)?;
613    }
614    Ok(())
615}
616
617/// Install `bytes` as one payload from a caller that owns its thread.
618///
619/// The rows that name payloads are written on the database's own connection
620/// thread, and payload storage has to exist before the row naming it commits, so
621/// installation runs there too — the same blocking-IO position SQLite's own
622/// writes occupy.
623pub(crate) fn write_payload_blocking(
624    conn: &Connection,
625    store_dir: &StoreDir,
626    bytes: &[u8],
627) -> Result<ObjectHash, PayloadStoreError> {
628    PayloadStore::new(conn, store_dir).install(bytes)
629}
630
631fn write_payload_file_bytes_blocking(
632    store_dir: &StoreDir,
633    hash: ObjectHash,
634    bytes: &[u8],
635) -> Result<(), PayloadStoreError> {
636    let path = store_dir.payload_spool_path(hash);
637    match std::fs::read(&path) {
638        Ok(installed) if installed == bytes => return Ok(()),
639        Ok(_) => {}
640        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
641        Err(source) => {
642            return Err(PayloadStoreError::FileIo {
643                operation: "read",
644                path,
645                source,
646            });
647        }
648    }
649
650    let directory = store_dir.payload_spool_dir();
651    let mut staged =
652        store_dir
653            .create_payload_spool_stage()
654            .map_err(|source| PayloadStoreError::FileIo {
655                operation: "create stage in",
656                path: directory.clone(),
657                source,
658            })?;
659    staged
660        .write_all(bytes)
661        .map_err(|source| PayloadStoreError::FileIo {
662            operation: "write stage in",
663            path: directory,
664            source,
665        })?;
666    staged
667        .commit(&path)
668        .map_err(|source| PayloadStoreError::AtomicFile { path, source })?;
669    Ok(())
670}
671
672/// Copy an existing file into the payload spool without reading it into one
673/// contiguous buffer. Returns the content hash and byte length naming the
674/// installed payload.
675pub(crate) fn write_payload_file_blocking(
676    conn: &Connection,
677    store_dir: &StoreDir,
678    source: &Path,
679) -> Result<(ObjectHash, u64), PayloadStoreError> {
680    PayloadStore::new(conn, store_dir)
681        .file_writer(source)?
682        .commit()
683}
684
685/// Read a payload on the database's connection thread.
686pub(crate) fn read_payload_blocking(
687    conn: &Connection,
688    store_dir: &StoreDir,
689    hash: ObjectHash,
690) -> Result<Vec<u8>, PayloadStoreError> {
691    PayloadStore::new(conn, store_dir).read(hash)
692}
693
694pub(super) fn read_verified_payload_blocking(
695    conn: &Connection,
696    store_dir: &StoreDir,
697    hash: ObjectHash,
698) -> Result<Vec<u8>, PayloadStoreError> {
699    PayloadStore::new(conn, store_dir).read_verified(hash)
700}
701
702fn read_error(hash: ObjectHash, path: PathBuf, error: std::io::Error) -> PayloadStoreError {
703    if error.kind() == std::io::ErrorKind::NotFound {
704        return PayloadStoreError::Missing { hash, path };
705    }
706    PayloadStoreError::FileIo {
707        operation: "read",
708        path,
709        source: error,
710    }
711}
712
713/// Remove the payload stored under `hash`. An absent file is success: the
714/// obligation this discharges says the payload must not be there, and a drain
715/// that failed after the removal retries the whole deletion.
716fn delete_payload_file_blocking(
717    store_dir: &StoreDir,
718    hash: ObjectHash,
719) -> Result<(), PayloadStoreError> {
720    let path = store_dir.payload_spool_path(hash);
721    match std::fs::remove_file(&path) {
722        Ok(()) => sync_parent(store_dir, &path),
723        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
724            debug!(payload = %hash, "payload spool file is already absent");
725            Ok(())
726        }
727        Err(error) => Err(PayloadStoreError::FileIo {
728            operation: "remove",
729            path,
730            source: error,
731        }),
732    }
733}
734
735fn sync_parent(store_dir: &StoreDir, path: &Path) -> Result<(), PayloadStoreError> {
736    store_dir
737        .sync_parent_dir_blocking(path)
738        .map_err(PayloadStoreError::LocalFile)
739}
740
741/// Claim `payloads` for `owner_key`, replacing whatever that owner claimed
742/// before. Called in the transaction that writes the row holding the claim, so
743/// the row and its claims commit together.
744///
745/// The whole set is replaced rather than one hash added or dropped, because the
746/// flows that rewrite a journal in place — a Circle operation reaching its
747/// finalization, a membership mutation advancing — carry one owner key across
748/// both the payloads they drop and the payloads they take on, and a payload
749/// named by both must not pass through a moment of being owed a deletion.
750///
751/// A payload leaving the set with no other claimant is owed a deletion,
752/// recorded here. A payload entering it discharges any deletion it was owed:
753/// the obligation says no row names the payload, and this claim is a row that
754/// does.
755pub(crate) fn set_payload_owner_claims_on(
756    conn: &Connection,
757    owner_key: &str,
758    payloads: &BTreeSet<ObjectHash>,
759) -> Result<(), DbError> {
760    let held = crate::query_mapped_rows(
761        conn,
762        "SELECT payload_hash FROM payload_owners WHERE owner_key = ?1",
763        [owner_key],
764        |row| row.get::<_, String>(0),
765    )
766    .map_err(DbError::from)?
767    .into_iter()
768    .map(|hash| hash.parse::<ObjectHash>().map_err(DbError::from))
769    .collect::<Result<BTreeSet<_>, _>>()?;
770
771    for hash in held.difference(payloads) {
772        conn.execute(
773            "DELETE FROM payload_owners WHERE payload_hash = ?1 AND owner_key = ?2",
774            rusqlite::params![hash.to_string(), owner_key],
775        )
776        .map_err(DbError::from)?;
777        let claimed: bool = conn
778            .query_row(
779                "SELECT EXISTS(SELECT 1 FROM payload_owners WHERE payload_hash = ?1)",
780                [hash.to_string()],
781                |row| row.get(0),
782            )
783            .map_err(DbError::from)?;
784        if !claimed {
785            conn.execute(
786                "INSERT OR IGNORE INTO payload_cleanup (payload_hash) VALUES (?1)",
787                [hash.to_string()],
788            )
789            .map_err(DbError::from)?;
790        }
791    }
792    for hash in payloads.difference(&held) {
793        conn.execute(
794            "INSERT INTO payload_owners (payload_hash, owner_key) VALUES (?1, ?2)",
795            rusqlite::params![hash.to_string(), owner_key],
796        )
797        .map_err(DbError::from)?;
798        conn.execute(
799            "DELETE FROM payload_cleanup WHERE payload_hash = ?1",
800            [hash.to_string()],
801        )
802        .map_err(DbError::from)?;
803    }
804    Ok(())
805}
806
807/// Drop every claim `owner_key` holds, owing a deletion for each payload it was
808/// the last claimant of. Called in the transaction that drops the row.
809pub(crate) fn release_payload_owner_on(conn: &Connection, owner_key: &str) -> Result<(), DbError> {
810    set_payload_owner_claims_on(conn, owner_key, &BTreeSet::new())
811}
812
813pub(crate) fn payload_owner_claims_on(
814    conn: &Connection,
815    owner_key: &str,
816) -> Result<BTreeSet<ObjectHash>, DbError> {
817    crate::query_mapped_rows(
818        conn,
819        "SELECT payload_hash FROM payload_owners
820         WHERE owner_key = ?1 ORDER BY payload_hash",
821        [owner_key],
822        |row| row.get::<_, String>(0),
823    )?
824    .into_iter()
825    .map(|hash| hash.parse::<ObjectHash>().map_err(DbError::from))
826    .collect()
827}
828
829/// The owner key naming the single retained replay baseline row's claim on the
830/// two payloads it names: its database image and its canonical authority bytes.
831pub(crate) const RETAINED_REPLAY_BASELINE_OWNER_KEY: &str = "retained-replay-baseline";
832
833/// The singleton outbound Store snapshot row's plaintext and ciphertext image
834/// payloads.
835pub(crate) const OUTBOUND_STORE_SNAPSHOT_OWNER_KEY: &str = "outbound-store-snapshot";
836
837/// One outbound Circle snapshot row's plaintext and ciphertext image payloads.
838pub(crate) fn outbound_circle_snapshot_owner_key(
839    circle_id: coven_protocol::circle::CircleId,
840) -> String {
841    format!("outbound-circle-snapshot:{circle_id}")
842}
843
844/// One queued Store write's captured edits, partitions and immutable blob sources.
845pub(crate) fn store_write_owner_key(write_id: &coven_protocol::write::WriteId) -> String {
846    format!("store-write:{write_id}")
847}
848
849/// The owner key naming one Circle operation's claim on its prepared objects.
850pub(crate) fn circle_operation_owner_key(operation_id: &str) -> String {
851    format!("circle-operation:{operation_id}")
852}
853
854/// One retained Circle bootstrap coverage row's database image.
855pub(crate) fn circle_bootstrap_coverage_owner_key(
856    circle_id: coven_protocol::circle::CircleId,
857) -> String {
858    format!("circle-bootstrap-coverage:{circle_id}")
859}
860
861/// The owner key naming one remote object record's claim on its payloads.
862pub(crate) fn remote_object_owner_key(object_id: ObjectHash) -> String {
863    format!("remote-object:{object_id}")
864}
865
866pub(crate) fn payload_cleanup_hashes_on(conn: &Connection) -> Result<Vec<ObjectHash>, DbError> {
867    crate::query_mapped_rows(
868        conn,
869        "SELECT payload_hash FROM payload_cleanup ORDER BY payload_hash",
870        [],
871        |row| row.get::<_, String>(0),
872    )
873    .map_err(DbError::from)?
874    .into_iter()
875    .map(|hash| hash.parse::<ObjectHash>().map_err(DbError::from))
876    .collect()
877}
878
879#[cfg(any(test, feature = "test-utils"))]
880#[path = "payload_store_test_support.rs"]
881mod test_support;
882
883#[cfg(test)]
884#[path = "payload_store_tests.rs"]
885mod tests;
886
887#[path = "payload_store_reading.rs"]
888mod reading;