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::{Read as _, 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(Vec<u8>),
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(installed) if installed == bytes => return Ok(hash),
147            ExistingPayloadState::Verified(_) => {
148                return Err(PayloadStoreError::Storage {
149                    hash,
150                    error: "installed logical bytes differ from the payload".to_string(),
151                });
152            }
153        };
154        let compressed = compress_payload(hash, bytes)?;
155        if existing_file || compressed.len() > INLINE_PAYLOAD_LIMIT {
156            self.record_file(hash, bytes.len() as u64, compressed.len() as u64)?;
157            write_payload_file_bytes_blocking(self.store_dir, hash, &compressed)?;
158        } else {
159            self.record_inline(hash, bytes.len() as u64, &compressed)?;
160        }
161        Ok(hash)
162    }
163
164    pub(crate) fn read(self, hash: ObjectHash) -> Result<Vec<u8>, PayloadStoreError> {
165        let stored = self
166            .stored(hash)?
167            .ok_or_else(|| PayloadStoreError::Storage {
168                hash,
169                error: "no catalog row".to_string(),
170            })?;
171        self.decode_stored(hash, stored)
172    }
173
174    fn decode_stored(
175        self,
176        hash: ObjectHash,
177        stored: StoredPayload,
178    ) -> Result<Vec<u8>, PayloadStoreError> {
179        let (compressed, payload_size) = match stored {
180            StoredPayload::Inline {
181                compressed,
182                payload_size,
183            } => (compressed, payload_size),
184            StoredPayload::File {
185                compressed_size,
186                payload_size,
187            } => {
188                let compressed = read_payload_file_blocking(self.store_dir, hash)?;
189                if compressed.len() as u64 != compressed_size {
190                    return Err(PayloadStoreError::Storage {
191                        hash,
192                        error: format!(
193                            "catalog records {compressed_size} compressed file bytes, but the spool contains {}",
194                            compressed.len()
195                        ),
196                    });
197                }
198                (compressed, payload_size)
199            }
200        };
201        let bytes = decompress_payload(hash, &compressed, payload_size)?;
202        if bytes.len() as u64 != payload_size {
203            return Err(PayloadStoreError::Storage {
204                hash,
205                error: format!(
206                    "catalog records {payload_size} payload bytes, but decompression produced {}",
207                    bytes.len()
208                ),
209            });
210        }
211        Ok(bytes)
212    }
213
214    pub(crate) fn read_verified(self, hash: ObjectHash) -> Result<Vec<u8>, PayloadStoreError> {
215        let stored = self
216            .stored(hash)?
217            .ok_or_else(|| PayloadStoreError::Storage {
218                hash,
219                error: "no catalog row".to_string(),
220            })?;
221        let inline = matches!(stored, StoredPayload::Inline { .. });
222        let bytes = self.decode_stored(hash, stored)?;
223        let actual = ObjectHash::digest(&bytes);
224        if actual == hash {
225            return Ok(bytes);
226        }
227        if inline {
228            Err(PayloadStoreError::InlineContentMismatch {
229                expected: hash,
230                actual,
231            })
232        } else {
233            Err(PayloadStoreError::ContentMismatch {
234                expected: hash,
235                actual,
236                path: self.store_dir.payload_spool_path(hash),
237            })
238        }
239    }
240
241    fn writer(self) -> PayloadWriter<'store> {
242        PayloadWriter {
243            payloads: self,
244            encoder: lz4_flex::frame::FrameEncoder::new(CompressedPayloadTarget::new(
245                self.store_dir,
246            )),
247            hasher: coven_protocol::blob::ContentHasher::new(),
248            size: 0,
249        }
250    }
251
252    fn require_transaction(self, hash: ObjectHash) -> Result<(), PayloadStoreError> {
253        if self.conn.is_autocommit() {
254            return Err(PayloadStoreError::Storage {
255                hash,
256                error: "installation requires the owning database transaction".to_string(),
257            });
258        }
259        Ok(())
260    }
261
262    fn existing_payload_state(
263        self,
264        hash: ObjectHash,
265        expected_size: u64,
266    ) -> Result<ExistingPayloadState, PayloadStoreError> {
267        let Some(stored) = self.stored(hash)? else {
268            return Ok(ExistingPayloadState::Absent);
269        };
270        let payload_size = stored.payload_size();
271        if payload_size != expected_size {
272            return Err(PayloadStoreError::Storage {
273                hash,
274                error: format!(
275                    "catalog records {payload_size} payload bytes, but installation has {expected_size}"
276                ),
277            });
278        }
279        let inline = matches!(stored, StoredPayload::Inline { .. });
280        let bytes = match self.decode_stored(hash, stored) {
281            Ok(bytes) => bytes,
282            Err(
283                PayloadStoreError::Missing { .. }
284                | PayloadStoreError::Storage { .. }
285                | PayloadStoreError::FileIo { .. }
286                | PayloadStoreError::CompressionIo { .. }
287                | PayloadStoreError::CompressionFrame { .. },
288            ) if !inline => return Ok(ExistingPayloadState::RepairableFile),
289            Err(error) => return Err(error),
290        };
291        let actual = ObjectHash::digest(&bytes);
292        if actual == hash {
293            return Ok(ExistingPayloadState::Verified(bytes));
294        }
295        if inline {
296            Err(PayloadStoreError::InlineContentMismatch {
297                expected: hash,
298                actual,
299            })
300        } else {
301            Err(PayloadStoreError::ContentMismatch {
302                expected: hash,
303                actual,
304                path: self.store_dir.payload_spool_path(hash),
305            })
306        }
307    }
308
309    fn stored(self, hash: ObjectHash) -> Result<Option<StoredPayload>, PayloadStoreError> {
310        let row = self
311            .conn
312            .query_row(
313                "SELECT storage, payload_size, compressed_bytes, compressed_size
314                 FROM payload_storage WHERE payload_hash = ?1",
315                [hash.to_string()],
316                |row| {
317                    Ok((
318                        row.get::<_, String>(0)?,
319                        row.get::<_, i64>(1)?,
320                        row.get::<_, Option<Vec<u8>>>(2)?,
321                        row.get::<_, i64>(3)?,
322                    ))
323                },
324            )
325            .optional()
326            .map_err(|source| PayloadStoreError::Database { hash, source })?;
327        match row {
328            None => Ok(None),
329            Some((storage, payload_size, Some(compressed), compressed_size))
330                if storage == "inline"
331                    && payload_size >= 0
332                    && compressed_size == compressed.len() as i64 =>
333            {
334                Ok(Some(StoredPayload::Inline {
335                    compressed,
336                    payload_size: payload_size as u64,
337                }))
338            }
339            Some((storage, payload_size, None, compressed_size))
340                if storage == "file" && payload_size >= 0 && compressed_size > 0 =>
341            {
342                Ok(Some(StoredPayload::File {
343                    compressed_size: compressed_size as u64,
344                    payload_size: payload_size as u64,
345                }))
346            }
347            Some((storage, payload_size, compressed, compressed_size)) => Err(PayloadStoreError::Storage {
348                hash,
349                error: format!(
350                    "tag {storage:?}, payload size {payload_size}, compressed bytes {}, compressed size {compressed_size}",
351                    compressed
352                        .as_ref()
353                        .map_or("absent".to_string(), |bytes| format!(
354                            "{} bytes",
355                            bytes.len()
356                        ))
357                ),
358            }),
359        }
360    }
361
362    fn record_inline(
363        self,
364        hash: ObjectHash,
365        payload_size: u64,
366        compressed: &[u8],
367    ) -> Result<(), PayloadStoreError> {
368        let payload_size = i64::try_from(payload_size)
369            .map_err(|source| PayloadStoreError::SizeConversion { hash, source })?;
370        let compressed_size = i64::try_from(compressed.len())
371            .map_err(|source| PayloadStoreError::SizeConversion { hash, source })?;
372        match self.stored(hash)? {
373            None => self
374                .conn
375                .execute(
376                    "INSERT INTO payload_storage
377                     (payload_hash, payload_size, storage, compressed_bytes, compressed_size)
378                     VALUES (?1, ?2, 'inline', ?3, ?4)",
379                    rusqlite::params![hash.to_string(), payload_size, compressed, compressed_size],
380                )
381                .map(|_| ())
382                .map_err(|source| PayloadStoreError::Database { hash, source }),
383            Some(StoredPayload::Inline {
384                compressed: stored,
385                payload_size: stored_payload_size,
386            }) if stored == compressed && stored_payload_size == payload_size as u64 => Ok(()),
387            Some(StoredPayload::Inline { .. }) => Err(PayloadStoreError::Storage {
388                hash,
389                error: "installed inline representation differs from the payload".to_string(),
390            }),
391            Some(StoredPayload::File { .. }) => Err(PayloadStoreError::Storage {
392                hash,
393                error: "an inline installation conflicts with file storage".to_string(),
394            }),
395        }
396    }
397
398    fn record_file(
399        self,
400        hash: ObjectHash,
401        payload_size: u64,
402        compressed_size: u64,
403    ) -> Result<(), PayloadStoreError> {
404        let payload_size = i64::try_from(payload_size)
405            .map_err(|source| PayloadStoreError::SizeConversion { hash, source })?;
406        let compressed_size = i64::try_from(compressed_size)
407            .map_err(|source| PayloadStoreError::SizeConversion { hash, source })?;
408        match self.stored(hash)? {
409            None => self
410                .conn
411                .execute(
412                    "INSERT INTO payload_storage
413                     (payload_hash, payload_size, storage, compressed_bytes, compressed_size)
414                     VALUES (?1, ?2, 'file', NULL, ?3)",
415                    rusqlite::params![hash.to_string(), payload_size, compressed_size],
416                )
417                .map(|_| ())
418                .map_err(|source| PayloadStoreError::Database {
419                    hash,
420                    source,
421                }),
422            Some(StoredPayload::File {
423                payload_size: stored_payload_size,
424                ..
425            }) if stored_payload_size == payload_size as u64 => {
426                let updated = self
427                    .conn
428                    .execute(
429                    "UPDATE payload_storage SET compressed_size = ?2
430                     WHERE payload_hash = ?1 AND storage = 'file'",
431                    rusqlite::params![hash.to_string(), compressed_size],
432                )
433                .map_err(|source| PayloadStoreError::Database {
434                    hash,
435                    source,
436                })?;
437                if updated != 1 {
438                    return Err(PayloadStoreError::Storage {
439                        hash,
440                        error: format!("file metadata update changed {updated} rows"),
441                    });
442                }
443                Ok(())
444            }
445            Some(StoredPayload::File {
446                compressed_size: stored_compressed_size,
447                payload_size: stored_payload_size,
448            }) => Err(PayloadStoreError::Storage {
449                hash,
450                error: format!(
451                    "catalog sizes ({stored_payload_size} payload, {stored_compressed_size} compressed) differ from installed sizes ({payload_size} payload, {compressed_size} compressed)"
452                ),
453            }),
454            Some(StoredPayload::Inline { .. }) => Err(PayloadStoreError::Storage {
455                hash,
456                error: "a file installation conflicts with inline storage".to_string(),
457            }),
458        }
459    }
460}
461
462/// One payload being streamed into an unpublished file while its content hash
463/// is computed from the bytes the file accepted.
464pub(crate) struct PayloadWriter<'store> {
465    payloads: PayloadStore<'store>,
466    encoder: lz4_flex::frame::FrameEncoder<CompressedPayloadTarget<'store>>,
467    hasher: coven_protocol::blob::ContentHasher,
468    size: u64,
469}
470
471enum PayloadWriterTarget {
472    Inline(Vec<u8>),
473    File(AtomicFileStage),
474}
475
476struct CompressedPayloadTarget<'store> {
477    store_dir: &'store StoreDir,
478    target: PayloadWriterTarget,
479    size: u64,
480}
481
482impl<'store> CompressedPayloadTarget<'store> {
483    fn new(store_dir: &'store StoreDir) -> Self {
484        Self {
485            store_dir,
486            target: PayloadWriterTarget::Inline(Vec::new()),
487            size: 0,
488        }
489    }
490}
491
492impl<'store> PayloadWriter<'store> {
493    pub(crate) fn commit(self) -> Result<(ObjectHash, u64), PayloadStoreError> {
494        let hash = self
495            .hasher
496            .finish()
497            .parse::<ObjectHash>()
498            .expect("SHA-256 hex is an ObjectHash");
499        self.payloads.require_transaction(hash)?;
500        let existing_file = match self.payloads.existing_payload_state(hash, self.size)? {
501            ExistingPayloadState::Absent => false,
502            ExistingPayloadState::RepairableFile => true,
503            ExistingPayloadState::Verified(_) => return Ok((hash, self.size)),
504        };
505        let compressed = self
506            .encoder
507            .finish()
508            .map_err(|source| PayloadStoreError::CompressionFrame { hash, source })?;
509        match (existing_file, compressed.target) {
510            (true, PayloadWriterTarget::Inline(bytes)) => {
511                self.payloads
512                    .record_file(hash, self.size, compressed.size)?;
513                write_payload_file_bytes_blocking(self.payloads.store_dir, hash, &bytes)?;
514            }
515            (false, PayloadWriterTarget::Inline(bytes)) => {
516                self.payloads.record_inline(hash, self.size, &bytes)?;
517            }
518            (_, PayloadWriterTarget::File(staged)) => {
519                let path = self.payloads.store_dir.payload_spool_path(hash);
520                self.payloads
521                    .record_file(hash, self.size, compressed.size)?;
522                staged
523                    .commit(&path)
524                    .map_err(|source| PayloadStoreError::AtomicFile { path, source })?;
525            }
526        }
527        Ok((hash, self.size))
528    }
529}
530
531impl<'store, 'connection> StoreTransaction<'store, 'connection> {
532    pub(crate) fn payload_writer(self) -> PayloadWriter<'store> {
533        PayloadStore::new(self.transaction, self.store_dir).writer()
534    }
535}
536
537impl std::io::Write for PayloadWriter<'_> {
538    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
539        let written = self.encoder.write(bytes)?;
540        self.hasher.update(&bytes[..written]);
541        self.size = self
542            .size
543            .checked_add(written as u64)
544            .ok_or_else(|| std::io::Error::other("payload size overflow"))?;
545        Ok(written)
546    }
547
548    fn flush(&mut self) -> std::io::Result<()> {
549        self.encoder.flush()
550    }
551}
552
553impl std::io::Write for CompressedPayloadTarget<'_> {
554    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
555        let written = match &mut self.target {
556            PayloadWriterTarget::Inline(buffer)
557                if buffer.len().saturating_add(bytes.len()) <= INLINE_PAYLOAD_LIMIT =>
558            {
559                buffer.extend_from_slice(bytes);
560                bytes.len()
561            }
562            PayloadWriterTarget::Inline(buffer) => {
563                let directory = self.store_dir.payload_spool_dir();
564                let mut staged = self
565                    .store_dir
566                    .create_payload_spool_stage()
567                    .map_err(|error| {
568                        std::io::Error::new(
569                            error.kind(),
570                            coven_foundation::atomic_file::FileError::at(
571                                "create payload stage",
572                                &directory,
573                                error,
574                            ),
575                        )
576                    })?;
577                staged.write_all(buffer)?;
578                let written = staged.write(bytes)?;
579                self.target = PayloadWriterTarget::File(staged);
580                written
581            }
582            PayloadWriterTarget::File(staged) => staged.write(bytes)?,
583        };
584        self.size = self
585            .size
586            .checked_add(written as u64)
587            .ok_or_else(|| std::io::Error::other("compressed payload size overflow"))?;
588        Ok(written)
589    }
590
591    fn flush(&mut self) -> std::io::Result<()> {
592        match &mut self.target {
593            PayloadWriterTarget::Inline(_) => Ok(()),
594            PayloadWriterTarget::File(staged) => staged.flush(),
595        }
596    }
597}
598
599fn compress_payload(hash: ObjectHash, bytes: &[u8]) -> Result<Vec<u8>, PayloadStoreError> {
600    let mut encoder = lz4_flex::frame::FrameEncoder::new(Vec::new());
601    encoder
602        .write_all(bytes)
603        .map_err(|source| PayloadStoreError::CompressionIo { hash, source })?;
604    encoder
605        .finish()
606        .map_err(|source| PayloadStoreError::CompressionFrame { hash, source })
607}
608
609fn decompress_payload(
610    hash: ObjectHash,
611    compressed: &[u8],
612    payload_size: u64,
613) -> Result<Vec<u8>, PayloadStoreError> {
614    let mut bytes = Vec::new();
615    lz4_flex::frame::FrameDecoder::new(compressed)
616        .take(payload_size.saturating_add(1))
617        .read_to_end(&mut bytes)
618        .map_err(|source| PayloadStoreError::CompressionIo { hash, source })?;
619    Ok(bytes)
620}
621
622/// Delete the payload behind every committed cleanup obligation, clearing each
623/// obligation once its file is gone.
624///
625/// Runs on the database's connection thread, where the transactions that record
626/// obligations also run, so a payload cannot be re-claimed and rewritten between
627/// this reading its obligation and removing its file. A failure between the two
628/// leaves the obligation durable and fails the caller, so the caller's retry
629/// finishes the deletion rather than losing it.
630pub(crate) fn pay_owed_payload_deletions_on(
631    conn: &Connection,
632    store_dir: &StoreDir,
633) -> Result<(), DbError> {
634    for hash in payload_cleanup_hashes_on(conn)? {
635        let payloads = PayloadStore::new(conn, store_dir);
636        match payloads.stored(hash).map_err(DbError::from)? {
637            Some(StoredPayload::Inline { .. }) => {}
638            Some(StoredPayload::File { .. }) => {
639                delete_payload_file_blocking(store_dir, hash).map_err(DbError::from)?;
640            }
641            None => {
642                return Err(DbError::Message(format!(
643                    "payload deletion obligation {hash} has no storage row"
644                )));
645            }
646        }
647        let transaction = conn.unchecked_transaction().map_err(DbError::from)?;
648        transaction
649            .execute(
650                "DELETE FROM payload_cleanup WHERE payload_hash = ?1",
651                [hash.to_string()],
652            )
653            .map_err(DbError::from)?;
654        let removed = transaction
655            .execute(
656                "DELETE FROM payload_storage
657                 WHERE payload_hash = ?1
658                   AND NOT EXISTS (
659                       SELECT 1 FROM payload_owners
660                       WHERE payload_hash = ?1
661                   )",
662                [hash.to_string()],
663            )
664            .map_err(DbError::from)?;
665        if removed != 1 {
666            return Err(DbError::Message(format!(
667                "payload deletion obligation {hash} is still claimed"
668            )));
669        }
670        transaction.commit().map_err(DbError::from)?;
671    }
672    Ok(())
673}
674
675/// Install `bytes` as one payload from a caller that owns its thread.
676///
677/// The rows that name payloads are written on the database's own connection
678/// thread, and payload storage has to exist before the row naming it commits, so
679/// installation runs there too — the same blocking-IO position SQLite's own
680/// writes occupy.
681pub(crate) fn write_payload_blocking(
682    conn: &Connection,
683    store_dir: &StoreDir,
684    bytes: &[u8],
685) -> Result<ObjectHash, PayloadStoreError> {
686    PayloadStore::new(conn, store_dir).install(bytes)
687}
688
689fn write_payload_file_bytes_blocking(
690    store_dir: &StoreDir,
691    hash: ObjectHash,
692    bytes: &[u8],
693) -> Result<(), PayloadStoreError> {
694    let path = store_dir.payload_spool_path(hash);
695    match std::fs::read(&path) {
696        Ok(installed) if installed == bytes => return Ok(()),
697        Ok(_) => {}
698        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
699        Err(source) => {
700            return Err(PayloadStoreError::FileIo {
701                operation: "read",
702                path,
703                source,
704            });
705        }
706    }
707
708    let directory = store_dir.payload_spool_dir();
709    let mut staged =
710        store_dir
711            .create_payload_spool_stage()
712            .map_err(|source| PayloadStoreError::FileIo {
713                operation: "create stage in",
714                path: directory.clone(),
715                source,
716            })?;
717    staged
718        .write_all(bytes)
719        .map_err(|source| PayloadStoreError::FileIo {
720            operation: "write stage in",
721            path: directory,
722            source,
723        })?;
724    staged
725        .commit(&path)
726        .map_err(|source| PayloadStoreError::AtomicFile { path, source })?;
727    Ok(())
728}
729
730/// Copy an existing file into the payload spool without reading it into one
731/// contiguous buffer. Returns the content hash and byte length naming the
732/// installed payload.
733pub(crate) fn write_payload_file_blocking(
734    conn: &Connection,
735    store_dir: &StoreDir,
736    source: &Path,
737) -> Result<(ObjectHash, u64), PayloadStoreError> {
738    let mut input = std::fs::File::open(source).map_err(|error| PayloadStoreError::FileIo {
739        operation: "open",
740        path: source.to_path_buf(),
741        source: error,
742    })?;
743    let mut writer = PayloadStore::new(conn, store_dir).writer();
744    std::io::copy(&mut input, &mut writer).map_err(|error| PayloadStoreError::FileIo {
745        operation: "copy",
746        path: source.to_path_buf(),
747        source: error,
748    })?;
749    writer.commit()
750}
751
752/// Read a payload on the database's connection thread.
753pub(crate) fn read_payload_blocking(
754    conn: &Connection,
755    store_dir: &StoreDir,
756    hash: ObjectHash,
757) -> Result<Vec<u8>, PayloadStoreError> {
758    PayloadStore::new(conn, store_dir).read(hash)
759}
760
761fn read_payload_file_blocking(
762    store_dir: &StoreDir,
763    hash: ObjectHash,
764) -> Result<Vec<u8>, PayloadStoreError> {
765    let path = store_dir.payload_spool_path(hash);
766    std::fs::read(&path).map_err(|error| read_error(hash, path, error))
767}
768
769pub(super) fn read_verified_payload_blocking(
770    conn: &Connection,
771    store_dir: &StoreDir,
772    hash: ObjectHash,
773) -> Result<Vec<u8>, PayloadStoreError> {
774    PayloadStore::new(conn, store_dir).read_verified(hash)
775}
776
777fn read_error(hash: ObjectHash, path: PathBuf, error: std::io::Error) -> PayloadStoreError {
778    if error.kind() == std::io::ErrorKind::NotFound {
779        return PayloadStoreError::Missing { hash, path };
780    }
781    PayloadStoreError::FileIo {
782        operation: "read",
783        path,
784        source: error,
785    }
786}
787
788/// Remove the payload stored under `hash`. An absent file is success: the
789/// obligation this discharges says the payload must not be there, and a drain
790/// that failed after the removal retries the whole deletion.
791fn delete_payload_file_blocking(
792    store_dir: &StoreDir,
793    hash: ObjectHash,
794) -> Result<(), PayloadStoreError> {
795    let path = store_dir.payload_spool_path(hash);
796    match std::fs::remove_file(&path) {
797        Ok(()) => sync_parent(store_dir, &path),
798        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
799            debug!(payload = %hash, "payload spool file is already absent");
800            Ok(())
801        }
802        Err(error) => Err(PayloadStoreError::FileIo {
803            operation: "remove",
804            path,
805            source: error,
806        }),
807    }
808}
809
810fn sync_parent(store_dir: &StoreDir, path: &Path) -> Result<(), PayloadStoreError> {
811    store_dir
812        .sync_parent_dir_blocking(path)
813        .map_err(PayloadStoreError::LocalFile)
814}
815
816/// Claim `payloads` for `owner_key`, replacing whatever that owner claimed
817/// before. Called in the transaction that writes the row holding the claim, so
818/// the row and its claims commit together.
819///
820/// The whole set is replaced rather than one hash added or dropped, because the
821/// flows that rewrite a journal in place — a Circle operation reaching its
822/// finalization, a membership mutation advancing — carry one owner key across
823/// both the payloads they drop and the payloads they take on, and a payload
824/// named by both must not pass through a moment of being owed a deletion.
825///
826/// A payload leaving the set with no other claimant is owed a deletion,
827/// recorded here. A payload entering it discharges any deletion it was owed:
828/// the obligation says no row names the payload, and this claim is a row that
829/// does.
830pub(crate) fn set_payload_owner_claims_on(
831    conn: &Connection,
832    owner_key: &str,
833    payloads: &BTreeSet<ObjectHash>,
834) -> Result<(), DbError> {
835    let held = crate::query_mapped_rows(
836        conn,
837        "SELECT payload_hash FROM payload_owners WHERE owner_key = ?1",
838        [owner_key],
839        |row| row.get::<_, String>(0),
840    )
841    .map_err(DbError::from)?
842    .into_iter()
843    .map(|hash| hash.parse::<ObjectHash>().map_err(DbError::from))
844    .collect::<Result<BTreeSet<_>, _>>()?;
845
846    for hash in held.difference(payloads) {
847        conn.execute(
848            "DELETE FROM payload_owners WHERE payload_hash = ?1 AND owner_key = ?2",
849            rusqlite::params![hash.to_string(), owner_key],
850        )
851        .map_err(DbError::from)?;
852        let claimed: bool = conn
853            .query_row(
854                "SELECT EXISTS(SELECT 1 FROM payload_owners WHERE payload_hash = ?1)",
855                [hash.to_string()],
856                |row| row.get(0),
857            )
858            .map_err(DbError::from)?;
859        if !claimed {
860            conn.execute(
861                "INSERT OR IGNORE INTO payload_cleanup (payload_hash) VALUES (?1)",
862                [hash.to_string()],
863            )
864            .map_err(DbError::from)?;
865        }
866    }
867    for hash in payloads.difference(&held) {
868        conn.execute(
869            "INSERT INTO payload_owners (payload_hash, owner_key) VALUES (?1, ?2)",
870            rusqlite::params![hash.to_string(), owner_key],
871        )
872        .map_err(DbError::from)?;
873        conn.execute(
874            "DELETE FROM payload_cleanup WHERE payload_hash = ?1",
875            [hash.to_string()],
876        )
877        .map_err(DbError::from)?;
878    }
879    Ok(())
880}
881
882/// Drop every claim `owner_key` holds, owing a deletion for each payload it was
883/// the last claimant of. Called in the transaction that drops the row.
884pub(crate) fn release_payload_owner_on(conn: &Connection, owner_key: &str) -> Result<(), DbError> {
885    set_payload_owner_claims_on(conn, owner_key, &BTreeSet::new())
886}
887
888pub(crate) fn payload_owner_claims_on(
889    conn: &Connection,
890    owner_key: &str,
891) -> Result<BTreeSet<ObjectHash>, DbError> {
892    crate::query_mapped_rows(
893        conn,
894        "SELECT payload_hash FROM payload_owners
895         WHERE owner_key = ?1 ORDER BY payload_hash",
896        [owner_key],
897        |row| row.get::<_, String>(0),
898    )?
899    .into_iter()
900    .map(|hash| hash.parse::<ObjectHash>().map_err(DbError::from))
901    .collect()
902}
903
904/// The owner key naming the single retained replay baseline row's claim on the
905/// two payloads it names: its database image and its canonical authority bytes.
906pub(crate) const RETAINED_REPLAY_BASELINE_OWNER_KEY: &str = "retained-replay-baseline";
907
908/// The singleton outbound Store snapshot row's plaintext and ciphertext image
909/// payloads.
910pub(crate) const OUTBOUND_STORE_SNAPSHOT_OWNER_KEY: &str = "outbound-store-snapshot";
911
912/// One outbound Circle snapshot row's plaintext and ciphertext image payloads.
913pub(crate) fn outbound_circle_snapshot_owner_key(
914    circle_id: coven_protocol::circle::CircleId,
915) -> String {
916    format!("outbound-circle-snapshot:{circle_id}")
917}
918
919/// One queued Store write's captured SQLite changeset.
920pub(crate) fn store_write_owner_key(write_id: &coven_protocol::write::WriteId) -> String {
921    format!("store-write:{write_id}")
922}
923
924/// The owner key naming one Circle operation's claim on its prepared objects.
925pub(crate) fn circle_operation_owner_key(operation_id: &str) -> String {
926    format!("circle-operation:{operation_id}")
927}
928
929/// One retained Circle bootstrap coverage row's database image.
930pub(crate) fn circle_bootstrap_coverage_owner_key(
931    circle_id: coven_protocol::circle::CircleId,
932) -> String {
933    format!("circle-bootstrap-coverage:{circle_id}")
934}
935
936/// The owner key naming one remote object record's claim on its payloads.
937pub(crate) fn remote_object_owner_key(object_id: ObjectHash) -> String {
938    format!("remote-object:{object_id}")
939}
940
941pub(crate) fn payload_cleanup_hashes_on(conn: &Connection) -> Result<Vec<ObjectHash>, DbError> {
942    crate::query_mapped_rows(
943        conn,
944        "SELECT payload_hash FROM payload_cleanup ORDER BY payload_hash",
945        [],
946        |row| row.get::<_, String>(0),
947    )
948    .map_err(DbError::from)?
949    .into_iter()
950    .map(|hash| hash.parse::<ObjectHash>().map_err(DbError::from))
951    .collect()
952}
953
954#[cfg(any(test, feature = "test-utils"))]
955#[path = "payload_store_test_support.rs"]
956mod test_support;
957
958#[cfg(test)]
959#[path = "payload_store_tests.rs"]
960mod tests;