1use 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 #[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#[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
462pub(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
622pub(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
675pub(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
730pub(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
752pub(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
788fn 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
816pub(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
882pub(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
904pub(crate) const RETAINED_REPLAY_BASELINE_OWNER_KEY: &str = "retained-replay-baseline";
907
908pub(crate) const OUTBOUND_STORE_SNAPSHOT_OWNER_KEY: &str = "outbound-store-snapshot";
911
912pub(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
919pub(crate) fn store_write_owner_key(write_id: &coven_protocol::write::WriteId) -> String {
921 format!("store-write:{write_id}")
922}
923
924pub(crate) fn circle_operation_owner_key(operation_id: &str) -> String {
926 format!("circle-operation:{operation_id}")
927}
928
929pub(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
936pub(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;