1use std::collections::HashMap;
45
46use coven_database::DbError;
47use coven_database::StoreDatabase;
48use coven_foundation::store_dir::StoreDir;
49use coven_protocol::blob::{Provenance, RowBlobRef};
50
51use coven_protocol::blob::BlobTransitionObserver;
52use std::path::PathBuf;
53use std::sync::Arc;
54use tokio::sync::watch;
55
56#[derive(Debug, thiserror::Error)]
58pub enum MakeRemoteError {
59 #[error("a make_remote batch must contain at least one root")]
60 EmptyBatch,
61 #[error("root {0:?} appears more than once in the make_remote batch")]
62 DuplicateRoot(String),
63 #[error("sync is not running, so a transition cannot start")]
64 SyncNotReady,
65 #[error("table {0:?} is not a gated root, so it has no Local/Remote state")]
66 NotGated(String),
67 #[error("table {0:?} is a remote root, so its blobs are already Remote")]
68 RemoteRoot(String),
69 #[error("root {0:?}/{1:?} is already Remote, so make_remote has nothing to do")]
70 AlreadyRemote(String, String),
71 #[error("root {0:?}/{1:?} has no resolvable Local/Remote state (row absent or gate NULL)")]
72 UnresolvedLocality(String, String),
73 #[error("nothing to make Remote: root {0:?}/{1:?} has no blobs")]
74 NothingToMakeRemote(String, String),
75 #[error("blob {0:?} is not a user-provided (external) file, so it cannot be made Remote")]
76 NotExternal(String),
77 #[error("external source path for blob {blob_id:?} at {path}: {source}")]
78 SourcePath {
79 blob_id: String,
80 path: String,
81 #[source]
82 source: coven_foundation::store_dir::PathTokenError,
83 },
84 #[error("database error: {0}")]
85 Db(#[from] DbError),
86}
87
88#[derive(Clone)]
89pub struct MakeRemoteRoot {
90 pub id: String,
91 pub label: String,
92 pub refs: Vec<RowBlobRef>,
93}
94
95#[derive(Debug, thiserror::Error)]
97pub enum MakeLocalError {
98 #[error("sync is not running, so a transition cannot start")]
99 SyncNotReady,
100 #[error("table {0:?} is not a gated root, so it has no Local/Remote state")]
101 NotGated(String),
102 #[error("table {0:?} is a remote root, so its blobs have no Local state")]
103 RemoteRoot(String),
104 #[error("root {0:?}/{1:?} is already Local, so make_local has nothing to do")]
105 AlreadyLocal(String, String),
106 #[error("root {0:?}/{1:?} has no resolvable Local/Remote state (row absent or gate NULL)")]
107 UnresolvedLocality(String, String),
108 #[error("no destination path supplied for user-provided blob {0:?}")]
109 MissingDest(String),
110 #[error("destination path for user-provided blob {blob_id:?} is not valid UTF-8: {path}")]
111 NonUtf8Dest { blob_id: String, path: String },
112 #[error("remote row for blob {0:?} has no exact stored reference")]
113 MissingStoredReference(String),
114 #[error("read blob {blob_id:?} to materialize: {source}")]
115 Read {
116 blob_id: String,
117 #[source]
118 source: crate::sync::BlobCacheError,
119 },
120 #[error("materialized blob path for {blob_id:?} at {path}: {source}")]
121 WritePath {
122 blob_id: String,
123 path: String,
124 #[source]
125 source: coven_foundation::store_dir::PathTokenError,
126 },
127 #[error("write materialized blob {blob_id:?} to {path}: {source}")]
128 WriteFile {
129 blob_id: String,
130 path: String,
131 #[source]
132 source: ExactPlaintextFileError,
133 },
134 #[error("publish materialized blob {blob_id:?} to {path}: {source}")]
135 CommitFile {
136 blob_id: String,
137 path: String,
138 #[source]
139 source: coven_foundation::local_file::CommitNewFileError,
140 },
141 #[error("make_local cancelled before the commit; the release stays Remote")]
142 Cancelled,
143 #[error("{operation}; materialized-file rollback failed: {failures}")]
144 Cleanup {
145 operation: Box<MakeLocalError>,
146 failures: MaterializedFileCleanupFailures,
147 },
148 #[error("database error: {0}")]
149 Db(#[from] DbError),
150}
151
152struct ExactPlaintextFile {
153 path: PathBuf,
154 expected_size: u64,
155 expected_hash: coven_protocol::store_commit::ObjectHash,
156}
157
158#[derive(Debug, thiserror::Error)]
159pub enum ExactPlaintextFileError {
160 #[error("{operation} {}: {source}", path.display())]
161 Io {
162 operation: &'static str,
163 path: PathBuf,
164 #[source]
165 source: std::io::Error,
166 },
167 #[error("blob path has no parent: {}", path.display())]
168 NoParent { path: PathBuf },
169 #[error("file size overflow: {}", path.display())]
170 SizeOverflow { path: PathBuf },
171 #[error("plaintext facts {actual_size}/{actual_hash} differ from expected facts {expected_size}/{expected_hash}")]
172 FactsMismatch {
173 actual_size: u64,
174 actual_hash: coven_protocol::store_commit::ObjectHash,
175 expected_size: u64,
176 expected_hash: coven_protocol::store_commit::ObjectHash,
177 },
178 #[error("destination {} has {actual} bytes, expected {expected}", path.display())]
179 SizeMismatch {
180 path: PathBuf,
181 actual: u64,
182 expected: u64,
183 },
184}
185
186#[derive(Debug)]
187pub struct MaterializedFileCleanupFailure {
188 path: PathBuf,
189 source: ExactPlaintextFileError,
190}
191
192#[derive(Debug)]
193pub struct MaterializedFileCleanupFailures(Vec<MaterializedFileCleanupFailure>);
194
195impl std::fmt::Display for MaterializedFileCleanupFailures {
196 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 for (index, failure) in self.0.iter().enumerate() {
198 if index > 0 {
199 formatter.write_str("; ")?;
200 }
201 write!(formatter, "{}: {}", failure.path.display(), failure.source)?;
202 }
203 Ok(())
204 }
205}
206
207impl std::error::Error for MaterializedFileCleanupFailures {
208 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
209 self.0.first().map(|failure| &failure.source as _)
210 }
211}
212
213impl ExactPlaintextFile {
214 fn new(
215 path: PathBuf,
216 expected_size: u64,
217 expected_hash: coven_protocol::store_commit::ObjectHash,
218 ) -> Self {
219 Self {
220 path,
221 expected_size,
222 expected_hash,
223 }
224 }
225
226 fn path(&self) -> &std::path::Path {
227 &self.path
228 }
229
230 async fn verify(&self) -> Result<(), ExactPlaintextFileError> {
231 use sha2::{Digest, Sha256};
232 use tokio::io::AsyncReadExt;
233
234 let mut file = tokio::fs::File::open(&self.path).await.map_err(|source| {
235 ExactPlaintextFileError::Io {
236 operation: "open",
237 path: self.path.clone(),
238 source,
239 }
240 })?;
241 let mut size = 0_u64;
242 let mut hasher = Sha256::new();
243 let mut buffer = vec![0_u8; 1 << 20];
244 loop {
245 let read =
246 file.read(&mut buffer)
247 .await
248 .map_err(|source| ExactPlaintextFileError::Io {
249 operation: "read",
250 path: self.path.clone(),
251 source,
252 })?;
253 if read == 0 {
254 break;
255 }
256 size = size.checked_add(read as u64).ok_or_else(|| {
257 ExactPlaintextFileError::SizeOverflow {
258 path: self.path.clone(),
259 }
260 })?;
261 hasher.update(&buffer[..read]);
262 }
263 let hash = coven_protocol::store_commit::ObjectHash::from_digest(hasher.finalize().into());
264 if size != self.expected_size || hash != self.expected_hash {
265 return Err(ExactPlaintextFileError::FactsMismatch {
266 actual_size: size,
267 actual_hash: hash,
268 expected_size: self.expected_size,
269 expected_hash: self.expected_hash,
270 });
271 }
272 Ok(())
273 }
274
275 async fn ensure_parent(&self) -> Result<(), ExactPlaintextFileError> {
276 let parent = self
277 .path
278 .parent()
279 .ok_or_else(|| ExactPlaintextFileError::NoParent {
280 path: self.path.clone(),
281 })?;
282 tokio::fs::create_dir_all(parent)
283 .await
284 .map_err(|source| ExactPlaintextFileError::Io {
285 operation: "create parent",
286 path: parent.to_path_buf(),
287 source,
288 })
289 }
290
291 async fn verify_installed_size(&self) -> Result<(), ExactPlaintextFileError> {
292 let length = tokio::fs::metadata(&self.path)
293 .await
294 .map_err(|source| ExactPlaintextFileError::Io {
295 operation: "stat",
296 path: self.path.clone(),
297 source,
298 })?
299 .len();
300 if length != self.expected_size {
301 return Err(ExactPlaintextFileError::SizeMismatch {
302 path: self.path.clone(),
303 actual: length,
304 expected: self.expected_size,
305 });
306 }
307 Ok(())
308 }
309
310 async fn remove(&self) -> Result<(), ExactPlaintextFileError> {
311 match tokio::fs::remove_file(&self.path).await {
312 Ok(()) => Ok(()),
313 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
314 Err(source) => Err(ExactPlaintextFileError::Io {
315 operation: "remove",
316 path: self.path.clone(),
317 source,
318 }),
319 }
320 }
321}
322
323#[derive(Clone)]
324pub struct LocalBlobTransitions {
325 database: StoreDatabase,
326 store_dir: StoreDir,
327}
328
329impl LocalBlobTransitions {
330 pub fn new(database: StoreDatabase, store_dir: StoreDir) -> Self {
331 Self {
332 database,
333 store_dir,
334 }
335 }
336
337 pub(crate) async fn make_remote(
351 &self,
352 root_table: &str,
353 root_id: &str,
354 root_label: &str,
355 pin: bool,
356 refs: Vec<coven_protocol::blob::RowBlobRef>,
357 ) -> Result<(), MakeRemoteError> {
358 require_make_remote_root(&self.database, root_table)?;
359 let prepared = self
360 .prepare_make_remote(root_table, root_id, root_label, refs)
361 .await?;
362 let locality = self
363 .database
364 .begin_make_remote(
365 root_table,
366 &prepared.root_id,
367 &prepared.root_label,
368 pin,
369 self.database.stamp(),
370 prepared.uploads,
371 )
372 .await?;
373 match locality {
374 Some(false) => Ok(()),
375 Some(true) => Err(MakeRemoteError::AlreadyRemote(
376 root_table.to_string(),
377 root_id.to_string(),
378 )),
379 None => Err(MakeRemoteError::UnresolvedLocality(
380 root_table.to_string(),
381 root_id.to_string(),
382 )),
383 }
384 }
385
386 async fn prepare_make_remote(
387 &self,
388 root_table: &str,
389 root_id: &str,
390 root_label: &str,
391 refs: Vec<RowBlobRef>,
392 ) -> Result<coven_database::MakeRemoteAdmission, MakeRemoteError> {
393 let db = &self.database;
394 let locality = db.gated_root_locality(root_table, root_id).await?;
395 match locality {
396 Some(false) => {}
397 Some(true) => {
398 return Err(MakeRemoteError::AlreadyRemote(
399 root_table.to_string(),
400 root_id.to_string(),
401 ));
402 }
403 None => {
404 return Err(MakeRemoteError::UnresolvedLocality(
405 root_table.to_string(),
406 root_id.to_string(),
407 ));
408 }
409 }
410 if refs.is_empty() {
411 return Err(MakeRemoteError::NothingToMakeRemote(
412 root_table.to_string(),
413 root_id.to_string(),
414 ));
415 }
416
417 let mut uploads = Vec::with_capacity(refs.len());
418 for reference in refs {
419 if reference.authority() != &coven_protocol::blob::RowBlobAuthority::Local
420 || reference.stored().is_some()
421 {
422 return Err(MakeRemoteError::AlreadyRemote(
423 root_table.to_string(),
424 root_id.to_string(),
425 ));
426 }
427 let blob = reference.blob();
428 let source_path = match blob.provenance {
429 Provenance::UserProvided => {
430 db.external_blob_for_row(&reference)
431 .await?
432 .ok_or_else(|| MakeRemoteError::NotExternal(blob.id.clone()))?
433 .path
434 }
435 Provenance::HostProvided => self
436 .store_dir
437 .local_blob_path(&blob.namespace, &blob.id)
438 .map_err(|source| MakeRemoteError::SourcePath {
439 blob_id: blob.id.clone(),
440 path: format!("local/{}/{}", blob.namespace, blob.id),
441 source,
442 })?,
443 };
444 uploads.push((reference, source_path));
445 }
446 Ok(coven_database::MakeRemoteAdmission {
447 root_id: root_id.to_string(),
448 root_label: root_label.to_string(),
449 uploads,
450 })
451 }
452
453 pub(crate) async fn make_remote_batch(
454 &self,
455 root_table: &str,
456 roots: Vec<MakeRemoteRoot>,
457 pin: bool,
458 ) -> Result<(), MakeRemoteError> {
459 require_make_remote_root(&self.database, root_table)?;
460 if roots.is_empty() {
461 return Err(MakeRemoteError::EmptyBatch);
462 }
463 let mut root_ids = std::collections::HashSet::with_capacity(roots.len());
464 for root in &roots {
465 if !root_ids.insert(root.id.clone()) {
466 return Err(MakeRemoteError::DuplicateRoot(root.id.clone()));
467 }
468 }
469 let mut prepared = Vec::with_capacity(roots.len());
470 for root in roots {
471 prepared.push(
472 self.prepare_make_remote(root_table, &root.id, &root.label, root.refs)
473 .await?,
474 );
475 }
476 self.database
477 .begin_make_remote_batch(root_table, pin, self.database.stamp(), prepared)
478 .await
479 .map_err(MakeRemoteError::from)
480 }
481
482 pub(crate) async fn cancel_make_remote(
483 &self,
484 root_table: &str,
485 root_id: &str,
486 ) -> Result<(), MakeRemoteError> {
487 require_make_remote_root(&self.database, root_table)?;
488 self.database
489 .cancel_make_remote(root_table, root_id)
490 .await
491 .map_err(MakeRemoteError::from)
492 }
493
494 async fn prepare_make_local(
495 &self,
496 root_table: &str,
497 root_id: &str,
498 routing_encryption: Option<&coven_keys::encryption::EncryptionService>,
499 ) -> Result<PreparedMakeLocal, MakeLocalError> {
500 self.database
501 .validate_store_write_routing(routing_encryption)?;
502 require_make_local_root(&self.database, root_table)?;
503 match self
504 .database
505 .gated_root_locality(root_table, root_id)
506 .await?
507 {
508 Some(true) => {}
509 Some(false) => {
510 return Err(MakeLocalError::AlreadyLocal(
511 root_table.to_string(),
512 root_id.to_string(),
513 ));
514 }
515 None => {
516 return Err(MakeLocalError::UnresolvedLocality(
517 root_table.to_string(),
518 root_id.to_string(),
519 ));
520 }
521 }
522
523 let references = self
524 .database
525 .row_blob_refs_for_root(root_table, root_id)
526 .await?;
527 for reference in &references {
528 if !matches!(
529 reference.authority(),
530 coven_protocol::blob::RowBlobAuthority::Remote(_)
531 ) || reference.stored().is_none()
532 {
533 return Err(MakeLocalError::UnresolvedLocality(
534 root_table.to_string(),
535 root_id.to_string(),
536 ));
537 }
538 }
539 Ok(PreparedMakeLocal { references })
540 }
541
542 async fn commit_make_local(
543 &self,
544 root_table: &str,
545 root_id: &str,
546 routing_encryption: Option<coven_keys::encryption::EncryptionService>,
547 records: Vec<coven_database::MaterializedLocalBlob>,
548 ) -> Result<(), DbError> {
549 self.database
550 .commit_make_local(
551 root_table,
552 root_id,
553 self.database.stamp(),
554 routing_encryption,
555 records,
556 )
557 .await
558 }
559}
560
561struct PreparedMakeLocal {
562 references: Vec<RowBlobRef>,
563}
564
565#[async_trait::async_trait]
569pub trait VerifiedLocalCopyStaging: Send + Sync {
570 async fn stage_verified_local_copy(
571 &self,
572 reference: &RowBlobRef,
573 destination: &std::path::Path,
574 ) -> Result<coven_foundation::local_file::AtomicStagedFile, crate::sync::BlobCacheError>;
575}
576
577pub struct ConnectedBlobTransitions {
578 local: LocalBlobTransitions,
579 blob_access: Arc<dyn VerifiedLocalCopyStaging>,
580 routing_encryption: Option<coven_keys::encryption::EncryptionService>,
581 observer: Option<Arc<dyn BlobTransitionObserver>>,
582}
583
584impl ConnectedBlobTransitions {
585 pub fn new(
586 local: LocalBlobTransitions,
587 blob_access: Arc<dyn VerifiedLocalCopyStaging>,
588 routing_encryption: Option<coven_keys::encryption::EncryptionService>,
589 observer: Option<Arc<dyn BlobTransitionObserver>>,
590 ) -> Self {
591 Self {
592 local,
593 blob_access,
594 routing_encryption,
595 observer,
596 }
597 }
598
599 pub(crate) async fn make_remote(
600 &self,
601 root_table: &str,
602 root_id: &str,
603 root_label: &str,
604 pin: bool,
605 refs: Vec<coven_protocol::blob::RowBlobRef>,
606 ) -> Result<(), MakeRemoteError> {
607 self.local
608 .make_remote(root_table, root_id, root_label, pin, refs)
609 .await
610 }
611
612 pub(crate) async fn make_remote_batch(
613 &self,
614 root_table: &str,
615 roots: Vec<MakeRemoteRoot>,
616 pin: bool,
617 ) -> Result<(), MakeRemoteError> {
618 self.local.make_remote_batch(root_table, roots, pin).await
619 }
620
621 pub(crate) async fn cancel_make_remote(
622 &self,
623 root_table: &str,
624 root_id: &str,
625 ) -> Result<(), MakeRemoteError> {
626 self.local.cancel_make_remote(root_table, root_id).await
627 }
628
629 pub(crate) async fn make_local(
630 &self,
631 root_table: &str,
632 root_id: &str,
633 dest: &HashMap<String, PathBuf>,
634 cancel: &watch::Receiver<bool>,
635 ) -> Result<(), MakeLocalError> {
636 let prepared = self
637 .local
638 .prepare_make_local(root_table, root_id, self.routing_encryption.as_ref())
639 .await?;
640
641 for (blob_id, path) in dest {
649 if path.to_str().is_none() {
650 return Err(MakeLocalError::NonUtf8Dest {
651 blob_id: blob_id.clone(),
652 path: path.display().to_string(),
653 });
654 }
655 }
656
657 let mut materialization = MakeLocalMaterialization::new(self, root_table, root_id);
661 if let Err(error) = self
662 .materialize_blobs(
663 root_table,
664 root_id,
665 &prepared.references,
666 dest,
667 cancel,
668 &mut materialization,
669 )
670 .await
671 {
672 return Err(materialization.abort(error).await);
673 }
674
675 materialization.commit().await?;
680
681 if let Some(obs) = self.observer.as_deref() {
682 obs.on_root_made_local(root_table, root_id).await;
683 }
684 Ok(())
685 }
686
687 async fn materialize_blobs(
690 &self,
691 root_table: &str,
692 root_id: &str,
693 refs: &[RowBlobRef],
694 dest: &HashMap<String, PathBuf>,
695 cancel: &watch::Receiver<bool>,
696 materialization: &mut MakeLocalMaterialization<'_>,
697 ) -> Result<(), MakeLocalError> {
698 let total = refs.len() as u64;
699
700 for (i, reference) in refs.iter().enumerate() {
701 if *cancel.borrow() {
702 return Err(MakeLocalError::Cancelled);
703 }
704 let blob = reference.blob();
705 let stored = reference
706 .stored()
707 .cloned()
708 .ok_or_else(|| MakeLocalError::MissingStoredReference(blob.id.clone()))?;
709
710 let record = match blob.provenance {
716 Provenance::UserProvided => {
717 let dest_path = dest
718 .get(&blob.id)
719 .ok_or_else(|| MakeLocalError::MissingDest(blob.id.clone()))?
720 .clone();
721 let destination = ExactPlaintextFile::new(
722 dest_path.clone(),
723 reference.plaintext_size(),
724 reference.plaintext_hash(),
725 );
726 destination.ensure_parent().await.map_err(|source| {
727 MakeLocalError::WriteFile {
728 blob_id: blob.id.clone(),
729 path: dest_path.display().to_string(),
730 source,
731 }
732 })?;
733 let staged = self
734 .blob_access
735 .stage_verified_local_copy(reference, &dest_path)
736 .await
737 .map_err(|source| MakeLocalError::Read {
738 blob_id: blob.id.clone(),
739 source,
740 })?;
741 staged
742 .commit_new()
743 .await
744 .map_err(|source| MakeLocalError::CommitFile {
745 blob_id: blob.id.clone(),
746 path: dest_path.display().to_string(),
747 source,
748 })?;
749 destination
750 .verify_installed_size()
751 .await
752 .map_err(|source| MakeLocalError::WriteFile {
753 blob_id: blob.id.clone(),
754 path: dest_path.display().to_string(),
755 source,
756 })?;
757 materialization.record_created_file(destination);
758 coven_database::MaterializedLocalBlob {
759 remote: reference.clone(),
760 stored,
761 destination: Some(dest_path),
762 }
763 }
764 Provenance::HostProvided => {
765 let store_path = self
766 .local
767 .store_dir
768 .local_blob_path(&blob.namespace, &blob.id)
769 .map_err(|source| MakeLocalError::WritePath {
770 blob_id: blob.id.clone(),
771 path: format!("local/{}/{}", blob.namespace, blob.id),
772 source,
773 })?;
774 let destination = ExactPlaintextFile::new(
775 store_path.clone(),
776 reference.plaintext_size(),
777 reference.plaintext_hash(),
778 );
779 let staged = self
780 .blob_access
781 .stage_verified_local_copy(reference, &store_path)
782 .await
783 .map_err(|source| MakeLocalError::Read {
784 blob_id: blob.id.clone(),
785 source,
786 })?;
787 match staged.commit_new().await {
788 Ok(()) => materialization.record_created_file(destination),
789 Err(
790 coven_foundation::local_file::CommitNewFileError::DestinationExists(_),
791 ) => {
792 destination.verify().await.map_err(|source| {
793 MakeLocalError::WriteFile {
794 blob_id: blob.id.clone(),
795 path: store_path.display().to_string(),
796 source,
797 }
798 })?;
799 }
800 Err(error) => {
801 return Err(MakeLocalError::CommitFile {
802 blob_id: blob.id.clone(),
803 path: store_path.display().to_string(),
804 source: error,
805 });
806 }
807 }
808 ExactPlaintextFile::new(
809 store_path.clone(),
810 reference.plaintext_size(),
811 reference.plaintext_hash(),
812 )
813 .verify_installed_size()
814 .await
815 .map_err(|source| MakeLocalError::WriteFile {
816 blob_id: blob.id.clone(),
817 path: store_path.display().to_string(),
818 source,
819 })?;
820 coven_database::MaterializedLocalBlob {
821 remote: reference.clone(),
822 stored,
823 destination: None,
824 }
825 }
826 };
827 materialization.record_blob(record);
828
829 if let Some(obs) = self.observer.as_deref() {
830 obs.on_blob_materialize_progress(
831 root_table,
832 root_id,
833 &blob.id,
834 (i + 1) as u64,
835 total,
836 )
837 .await;
838 }
839 }
840
841 if *cancel.borrow() {
842 return Err(MakeLocalError::Cancelled);
843 }
844 Ok(())
845 }
846}
847
848fn require_make_remote_root(
849 database: &StoreDatabase,
850 root_table: &str,
851) -> Result<(), MakeRemoteError> {
852 match database.blob_transition_root(root_table) {
853 coven_database::BlobTransitionRoot::Gated => Ok(()),
854 coven_database::BlobTransitionRoot::RemoteRoot => {
855 Err(MakeRemoteError::RemoteRoot(root_table.to_string()))
856 }
857 coven_database::BlobTransitionRoot::NotGated => {
858 Err(MakeRemoteError::NotGated(root_table.to_string()))
859 }
860 }
861}
862
863fn require_make_local_root(
864 database: &StoreDatabase,
865 root_table: &str,
866) -> Result<(), MakeLocalError> {
867 match database.blob_transition_root(root_table) {
868 coven_database::BlobTransitionRoot::Gated => Ok(()),
869 coven_database::BlobTransitionRoot::RemoteRoot => {
870 Err(MakeLocalError::RemoteRoot(root_table.to_string()))
871 }
872 coven_database::BlobTransitionRoot::NotGated => {
873 Err(MakeLocalError::NotGated(root_table.to_string()))
874 }
875 }
876}
877
878struct MakeLocalMaterialization<'operation> {
886 transitions: &'operation ConnectedBlobTransitions,
887 root_table: &'operation str,
888 root_id: &'operation str,
889 records: Vec<coven_database::MaterializedLocalBlob>,
890 created_files: Vec<ExactPlaintextFile>,
891}
892
893impl<'operation> MakeLocalMaterialization<'operation> {
894 fn new(
895 transitions: &'operation ConnectedBlobTransitions,
896 root_table: &'operation str,
897 root_id: &'operation str,
898 ) -> Self {
899 Self {
900 transitions,
901 root_table,
902 root_id,
903 records: Vec::new(),
904 created_files: Vec::new(),
905 }
906 }
907
908 fn record_created_file(&mut self, file: ExactPlaintextFile) {
909 self.created_files.push(file);
910 }
911
912 fn record_blob(&mut self, record: coven_database::MaterializedLocalBlob) {
913 self.records.push(record);
914 }
915
916 async fn abort(self, cause: MakeLocalError) -> MakeLocalError {
917 let mut failures = Vec::new();
918 for file in self.created_files {
919 if let Err(source) = file.remove().await {
920 failures.push(MaterializedFileCleanupFailure {
921 path: file.path().to_path_buf(),
922 source,
923 });
924 }
925 }
926 if failures.is_empty() {
927 cause
928 } else {
929 MakeLocalError::Cleanup {
930 operation: Box::new(cause),
931 failures: MaterializedFileCleanupFailures(failures),
932 }
933 }
934 }
935
936 async fn commit(self) -> Result<(), MakeLocalError> {
937 let records = self.records.clone();
938 match self
939 .transitions
940 .local
941 .commit_make_local(
942 self.root_table,
943 self.root_id,
944 self.transitions.routing_encryption.clone(),
945 records,
946 )
947 .await
948 {
949 Ok(()) => Ok(()),
950 Err(error) => Err(self.abort(MakeLocalError::Db(error)).await),
951 }
952 }
953}