1use 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 #[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#[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
395pub(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
564pub(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
617pub(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
672pub(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
685pub(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
713fn 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
741pub(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
807pub(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
829pub(crate) const RETAINED_REPLAY_BASELINE_OWNER_KEY: &str = "retained-replay-baseline";
832
833pub(crate) const OUTBOUND_STORE_SNAPSHOT_OWNER_KEY: &str = "outbound-store-snapshot";
836
837pub(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
844pub(crate) fn store_write_owner_key(write_id: &coven_protocol::write::WriteId) -> String {
846 format!("store-write:{write_id}")
847}
848
849pub(crate) fn circle_operation_owner_key(operation_id: &str) -> String {
851 format!("circle-operation:{operation_id}")
852}
853
854pub(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
861pub(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;