1use std::collections::HashMap;
42
43use crate::blob::{cache, Provenance, RowBlobRef};
44use crate::database::{Database, DbError, MakeRemoteIntentState};
45use crate::db::{OutboxEntry, OutboxOperation, OutboxUploadState};
46use crate::store_dir::StoreDir;
47use crate::sync::hlc::Hlc;
48use crate::sync::session::SyncedTable;
49
50use crate::blob::BlobTransitionObserver;
51use crate::sync::storage::SyncStorage;
52use std::path::PathBuf;
53use tokio::sync::watch;
54
55#[derive(Debug, thiserror::Error)]
57pub enum MakeRemoteError {
58 #[error("sync is not running, so a transition cannot start")]
59 SyncNotReady,
60 #[error("table {0:?} is not a gated root, so it has no Local/Remote state")]
61 NotGated(String),
62 #[error("table {0:?} is a remote root, so its blobs are already Remote")]
63 RemoteRoot(String),
64 #[error("root {0:?}/{1:?} is already Remote, so make_remote has nothing to do")]
65 AlreadyRemote(String, String),
66 #[error("root {0:?}/{1:?} has no resolvable Local/Remote state (row absent or gate NULL)")]
67 UnresolvedLocality(String, String),
68 #[error("nothing to make Remote: root {0:?}/{1:?} has no blobs")]
69 NothingToMakeRemote(String, String),
70 #[error("blob {0:?} is not a user-provided (external) file, so it cannot be made Remote")]
71 NotExternal(String),
72 #[error("external source for blob {blob_id:?} at {path}: {detail}")]
73 Source {
74 blob_id: String,
75 path: String,
76 detail: String,
77 },
78 #[error("database error: {0}")]
79 Db(#[from] DbError),
80}
81
82#[derive(Debug, thiserror::Error)]
84pub enum MakeLocalError {
85 #[error("sync is not running, so a transition cannot start")]
86 SyncNotReady,
87 #[error("table {0:?} is not a gated root, so it has no Local/Remote state")]
88 NotGated(String),
89 #[error("table {0:?} is a remote root, so its blobs have no Local state")]
90 RemoteRoot(String),
91 #[error("root {0:?}/{1:?} is already Local, so make_local has nothing to do")]
92 AlreadyLocal(String, String),
93 #[error("root {0:?}/{1:?} has no resolvable Local/Remote state (row absent or gate NULL)")]
94 UnresolvedLocality(String, String),
95 #[error("no destination path supplied for user-provided blob {0:?}")]
96 MissingDest(String),
97 #[error("destination path for user-provided blob {blob_id:?} is not valid UTF-8: {path}")]
98 NonUtf8Dest { blob_id: String, path: String },
99 #[error("read blob {0:?} to materialize: {1}")]
100 Read(String, String),
101 #[error("write materialized blob {blob_id:?} to {path}: {detail}")]
102 Write {
103 blob_id: String,
104 path: String,
105 detail: String,
106 },
107 #[error("make_local cancelled before the commit; the release stays Remote")]
108 Cancelled,
109 #[error("could not roll back materialized file at {path}: {detail}")]
110 Cleanup { path: String, detail: String },
111 #[error("database error: {0}")]
112 Db(#[from] DbError),
113}
114
115fn gate_column<'a>(tables: &'a [SyncedTable], root_table: &str) -> Option<&'a str> {
117 tables
118 .iter()
119 .find(|t| t.name() == root_table)
120 .and_then(|t| t.gate_column())
121}
122
123fn is_remote_root(tables: &[SyncedTable], root_table: &str) -> bool {
124 tables
125 .iter()
126 .any(|t| t.name() == root_table && t.is_remote_root())
127}
128
129fn gated_root_gate_col(
134 tables: &[SyncedTable],
135 root_table: &str,
136) -> Result<String, MakeRemoteError> {
137 if is_remote_root(tables, root_table) {
138 return Err(MakeRemoteError::RemoteRoot(root_table.to_string()));
139 }
140 gate_column(tables, root_table)
141 .map(str::to_string)
142 .ok_or_else(|| MakeRemoteError::NotGated(root_table.to_string()))
143}
144
145pub async fn make_remote(
156 db: &Database,
157 store_dir: &StoreDir,
158 hlc: &Hlc,
159 root_table: &str,
160 root_id: &str,
161 pin: bool,
162) -> Result<(), MakeRemoteError> {
163 let tables = db.synced_tables().to_vec();
164 let gate_col = gated_root_gate_col(&tables, root_table)?;
165 let (locality_table, locality_column, locality_id) = (
166 root_table.to_string(),
167 gate_col.clone(),
168 root_id.to_string(),
169 );
170 let locality = db
171 .call(move |conn| {
172 crate::sync::gate::query_truth(conn, &locality_table, &locality_column, &locality_id)
173 .map_err(|error| DbError::Message(error.to_string()))
174 })
175 .await?;
176 match locality {
177 Some(false) => {}
178 Some(true) => {
179 return Err(MakeRemoteError::AlreadyRemote(
180 root_table.to_string(),
181 root_id.to_string(),
182 ));
183 }
184 None => {
185 return Err(MakeRemoteError::UnresolvedLocality(
186 root_table.to_string(),
187 root_id.to_string(),
188 ));
189 }
190 }
191
192 let refs = db.row_blob_refs_for_root(root_table, root_id).await?;
193 if refs.is_empty() {
194 return Err(MakeRemoteError::NothingToMakeRemote(
195 root_table.to_string(),
196 root_id.to_string(),
197 ));
198 }
199
200 let mut uploads = Vec::with_capacity(refs.len());
201 for reference in refs {
202 if reference.authority() != &crate::blob::RowBlobAuthority::Local
203 || reference.stored().is_some()
204 {
205 return Err(MakeRemoteError::AlreadyRemote(
206 root_table.to_string(),
207 root_id.to_string(),
208 ));
209 }
210 let blob = reference.blob();
211 let source_path = match blob.provenance {
212 Provenance::UserProvided => {
213 db.external_blob_for_row(&reference)
214 .await?
215 .ok_or_else(|| MakeRemoteError::NotExternal(blob.id.clone()))?
216 .path
217 }
218 Provenance::HostProvided => store_dir
219 .local_blob_path(&blob.namespace, &blob.id)
220 .map_err(|error| MakeRemoteError::Source {
221 blob_id: blob.id.clone(),
222 path: format!("local/{}/{}", blob.namespace, blob.id),
223 detail: error.to_string(),
224 })?,
225 };
226 let (actual_size, actual_hash) = crate::local_blob::exact_file_facts(&source_path)
227 .await
228 .map_err(|detail| MakeRemoteError::Source {
229 blob_id: blob.id.clone(),
230 path: source_path.display().to_string(),
231 detail,
232 })?;
233 if actual_size != reference.plaintext_size() || actual_hash != reference.plaintext_hash() {
234 return Err(MakeRemoteError::Source {
235 blob_id: blob.id.clone(),
236 path: source_path.display().to_string(),
237 detail: format!(
238 "plaintext facts {actual_size}/{actual_hash} differ from row facts {}/{}",
239 reference.plaintext_size(),
240 reference.plaintext_hash(),
241 ),
242 });
243 }
244 uploads.push((reference, source_path));
245 }
246
247 let created_at = hlc.now().to_string();
248 let (rt, gc, ri) = (
249 root_table.to_string(),
250 gate_col.clone(),
251 root_id.to_string(),
252 );
253 let gates = db.gates();
254 let locality = db
255 .call(move |conn| {
256 let tx = conn.unchecked_transaction()?;
257 let locality = crate::sync::gate::query_truth(&tx, &rt, &gc, &ri)
258 .map_err(|e| DbError::Message(e.to_string()))?;
259 if locality == Some(false) {
260 let current = Database::row_blob_refs_for_root_on(
261 &tx, &gates, &tables, &rt, &ri,
262 )?;
263 if current.len() != uploads.len()
264 || current
265 .iter()
266 .zip(&uploads)
267 .any(|(current, (verified, _))| current != verified)
268 {
269 return Err(DbError::Message(format!(
270 "blob rows below {rt:?}/{ri:?} changed while make_remote verified their sources"
271 )));
272 }
273 Database::insert_make_remote_intent_on(&tx, &rt, &ri, pin)?;
274 for (reference, source_path) in &uploads {
275 Database::enqueue_upload_on(
276 &tx,
277 &rt,
278 &ri,
279 reference,
280 source_path,
281 pin,
282 &created_at,
283 )?;
284 }
285 tx.commit().map_err(DbError::from)?;
286 }
287 Ok(locality)
288 })
289 .await?;
290 match locality {
291 Some(false) => Ok(()),
292 Some(true) => Err(MakeRemoteError::AlreadyRemote(
293 root_table.to_string(),
294 root_id.to_string(),
295 )),
296 None => Err(MakeRemoteError::UnresolvedLocality(
297 root_table.to_string(),
298 root_id.to_string(),
299 )),
300 }
301}
302
303pub async fn cancel_make_remote(
309 db: &Database,
310 root_table: &str,
311 root_id: &str,
312) -> Result<(), MakeRemoteError> {
313 let tables = db.synced_tables();
314 gated_root_gate_col(tables, root_table)?;
315 let (root_table_owned, root_id_owned) = (root_table.to_string(), root_id.to_string());
316 db.call(move |conn| {
317 let tx = conn.unchecked_transaction()?;
318 match Database::make_remote_intent_state(&tx, &root_table_owned, &root_id_owned)? {
319 Some(MakeRemoteIntentState::Uploading) => {
320 Database::mark_make_remote_cancelling_on(
321 &tx,
322 &root_table_owned,
323 &root_id_owned,
324 )?;
325 }
326 Some(MakeRemoteIntentState::Cancelling) => {}
327 Some(MakeRemoteIntentState::Publishing(write_id)) => {
328 return Err(DbError::Message(format!(
329 "make_remote for {root_table_owned:?}/{root_id_owned:?} is already publishing as {write_id}"
330 )));
331 }
332 None => {
333 return Err(DbError::Message(format!(
334 "make_remote for {root_table_owned:?}/{root_id_owned:?} does not exist"
335 )));
336 }
337 }
338 tx.commit().map_err(DbError::from)
339 })
340 .await
341 .map_err(MakeRemoteError::from)
342}
343
344pub(crate) enum PostUpload {
345 Waiting,
346 Cancelled,
347 MadeRemote { root_table: String, root_id: String },
348}
349
350pub(crate) async fn finalize_created_upload(
358 db: &Database,
359 entry: &OutboxEntry,
360 stamp: String,
361 routing_encryption: Option<crate::encryption::EncryptionService>,
362) -> Result<PostUpload, DbError> {
363 let OutboxOperation::Upload {
364 root_table,
365 root_id,
366 row,
367 state,
368 ..
369 } = &entry.operation
370 else {
371 return Err(DbError::Message(
372 "make_remote finalizer received a non-upload outbox entry".to_string(),
373 ));
374 };
375 if !matches!(state, OutboxUploadState::Created { .. }) {
376 return Err(DbError::Message(
377 "make_remote finalizer requires a Created exact upload".to_string(),
378 ));
379 }
380
381 let entry = entry.clone();
382 let root_table = root_table.clone();
383 let root_id = root_id.clone();
384 let row = row.clone();
385 let tables = db.synced_tables().to_vec();
386 let gates = db.gates();
387 let write_policy = db.write_policy();
388 let write_id = db.new_write_id();
389 db.call(move |conn| {
390 let resolved_root = gates
391 .resolve_root_of(conn, row.table(), row.row_id())
392 .map_err(|error| DbError::Message(error.to_string()))?
393 .ok_or_else(|| {
394 DbError::Message(format!(
395 "upload row {:?}/{:?} has no gated transition root",
396 row.table(),
397 row.row_id()
398 ))
399 })?;
400 if resolved_root != (root_table.clone(), root_id.clone()) {
401 return Err(DbError::Message(format!(
402 "upload row {:?}/{:?} moved from make_remote root {:?}/{:?} to {:?}/{:?}",
403 row.table(),
404 row.row_id(),
405 root_table,
406 root_id,
407 resolved_root.0,
408 resolved_root.1
409 )));
410 }
411 match Database::make_remote_intent_state(conn, &root_table, &root_id)? {
412 Some(MakeRemoteIntentState::Uploading) => {}
413 Some(MakeRemoteIntentState::Publishing(_)) => return Ok(PostUpload::Waiting),
414 Some(MakeRemoteIntentState::Cancelling) => {
415 return Ok(PostUpload::Cancelled);
416 }
417 None => {
418 return Err(DbError::Message(format!(
419 "upload for {root_table:?}/{root_id:?} has no make_remote intent"
420 )));
421 }
422 }
423
424 let rows = Database::row_blob_refs_for_root_on(
425 conn,
426 &gates,
427 &tables,
428 &root_table,
429 &root_id,
430 )?;
431 let entries = Database::upload_entries_for_root_on(
432 conn,
433 &gates,
434 &tables,
435 &root_table,
436 &root_id,
437 )?;
438 if rows.len() != entries.len() {
439 return Err(DbError::Message(format!(
440 "make_remote root {root_table:?}/{root_id:?} has {} blob rows but {} exact upload journals",
441 rows.len(),
442 entries.len()
443 )));
444 }
445 if !entries.iter().all(|candidate| {
446 matches!(
447 candidate.operation,
448 OutboxOperation::Upload {
449 state: OutboxUploadState::Created { .. },
450 ..
451 }
452 )
453 }) {
454 return Ok(PostUpload::Waiting);
455 }
456 if !entries.iter().any(|candidate| candidate == &entry) {
457 return Err(DbError::Message(
458 "Created upload changed before make_remote finalization".to_string(),
459 ));
460 }
461
462 let gate_column = gate_column(&tables, &root_table).ok_or_else(|| {
463 DbError::Message(format!(
464 "make_remote root {root_table:?} has no boolean gate column"
465 ))
466 })?;
467 let publication_write_id = write_id.clone();
468 Database::run_internal_store_write_transaction_on(
469 conn,
470 &tables,
471 write_policy,
472 routing_encryption.as_ref(),
473 write_id,
474 |tx| {
475 crate::sync::gate::write_gate(
476 tx,
477 &root_table,
478 gate_column,
479 true,
480 &stamp,
481 &root_id,
482 )
483 .map_err(DbError::from)?;
484 for reference in &rows {
485 if reference.blob().provenance == Provenance::UserProvided {
486 Database::clear_external_blob_on(tx, reference)?;
487 }
488 }
489 Database::mark_make_remote_publishing_on(
490 tx,
491 &root_table,
492 &root_id,
493 &publication_write_id,
494 )
495 },
496 )?;
497 Ok(PostUpload::MadeRemote {
498 root_table,
499 root_id,
500 })
501 })
502 .await
503}
504
505#[derive(Clone)]
514struct Materialized {
515 remote: RowBlobRef,
516 stored: crate::blob::locator::StoredBlobRef,
517 dest: Option<PathBuf>,
518}
519
520#[allow(clippy::too_many_arguments)]
540pub async fn make_local(
541 db: &Database,
542 storage: &dyn SyncStorage,
543 store_dir: &StoreDir,
544 hlc: &Hlc,
545 routing_encryption: Option<crate::encryption::EncryptionService>,
546 observer: Option<&dyn BlobTransitionObserver>,
547 root_table: &str,
548 root_id: &str,
549 dest: &HashMap<String, PathBuf>,
550 cancel: &watch::Receiver<bool>,
551) -> Result<(), MakeLocalError> {
552 let tables = db.synced_tables().to_vec();
553 let write_policy = db.write_policy();
554 Database::validate_store_write_routing(
555 db.gates().as_ref(),
556 write_policy,
557 routing_encryption.as_ref(),
558 )?;
559 if is_remote_root(&tables, root_table) {
560 return Err(MakeLocalError::RemoteRoot(root_table.to_string()));
561 }
562 let gate_col = gate_column(&tables, root_table)
563 .ok_or_else(|| MakeLocalError::NotGated(root_table.to_string()))?
564 .to_string();
565
566 let (rt, gc, ri) = (
572 root_table.to_string(),
573 gate_col.clone(),
574 root_id.to_string(),
575 );
576 let locality = db
577 .call(move |conn| {
578 crate::sync::gate::query_truth(conn, &rt, &gc, &ri)
579 .map_err(|e| DbError::Message(e.to_string()))
580 })
581 .await?;
582 match locality {
583 Some(true) => {}
584 Some(false) => {
585 return Err(MakeLocalError::AlreadyLocal(
586 root_table.to_string(),
587 root_id.to_string(),
588 ))
589 }
590 None => {
591 return Err(MakeLocalError::UnresolvedLocality(
592 root_table.to_string(),
593 root_id.to_string(),
594 ))
595 }
596 }
597
598 let refs = db.row_blob_refs_for_root(root_table, root_id).await?;
599 for reference in &refs {
600 if !matches!(
601 reference.authority(),
602 crate::blob::RowBlobAuthority::Remote(_)
603 ) || reference.stored().is_none()
604 {
605 return Err(MakeLocalError::UnresolvedLocality(
606 root_table.to_string(),
607 root_id.to_string(),
608 ));
609 }
610 }
611
612 for (blob_id, path) in dest {
620 if path.to_str().is_none() {
621 return Err(MakeLocalError::NonUtf8Dest {
622 blob_id: blob_id.clone(),
623 path: path.display().to_string(),
624 });
625 }
626 }
627
628 let mut written: Vec<PathBuf> = Vec::new();
633 let materialized = match materialize_blobs(
634 db,
635 storage,
636 store_dir,
637 observer,
638 root_table,
639 root_id,
640 &refs,
641 dest,
642 cancel,
643 &mut written,
644 )
645 .await
646 {
647 Ok(m) => m,
648 Err(e) => return Err(roll_back(&written, e).await),
649 };
650
651 let stamp = hlc.now().to_string();
652 let (root_table_owned, root_id_owned) = (root_table.to_string(), root_id.to_string());
653 let committed = materialized.clone();
654
655 let tables = db.synced_tables().to_vec();
660 let write_id = db.new_write_id();
661 let gates = db.gates();
662 db.call(move |conn| {
663 Database::run_internal_store_write_transaction_on(
664 conn,
665 &tables,
666 write_policy,
667 routing_encryption.as_ref(),
668 write_id,
669 |tx| {
670 let remote = Database::row_blob_refs_for_root_on(
671 tx,
672 &gates,
673 &tables,
674 &root_table_owned,
675 &root_id_owned,
676 )?;
677 if remote.len() != committed.len()
678 || remote
679 .iter()
680 .zip(&committed)
681 .any(|(current, materialized)| {
682 !same_row_blob_version(current, &materialized.remote)
683 || current.authority() != materialized.remote.authority()
684 || current.stored() != Some(&materialized.stored)
685 })
686 {
687 return Err(DbError::Message(format!(
688 "make_local root {:?}/{:?} changed while its blobs were materialized",
689 root_table_owned, root_id_owned
690 )));
691 }
692 crate::sync::gate::write_gate(
693 tx,
694 &root_table_owned,
695 &gate_col,
696 false,
697 &stamp,
698 &root_id_owned,
699 )
700 .map_err(DbError::from)?;
701
702 for materialized in &committed {
706 let reference = &materialized.remote;
707 if reference.table() == root_table_owned && reference.row_id() == root_id_owned
708 {
709 continue;
710 }
711 let sql = format!(
712 "UPDATE {} SET _updated_at = ?1 WHERE id = ?2 AND _updated_at = ?3",
713 crate::sync::session::quote_ident(reference.table())
714 );
715 let updated = tx
716 .execute(
717 &sql,
718 rusqlite::params![&stamp, reference.row_id(), reference.row_stamp()],
719 )
720 .map_err(DbError::from)?;
721 if updated != 1 {
722 return Err(DbError::Message(format!(
723 "make_local row {:?}/{:?} changed before restamping",
724 reference.table(),
725 reference.row_id()
726 )));
727 }
728 }
729 let local = Database::row_blob_refs_for_root_on(
730 tx,
731 &gates,
732 &tables,
733 &root_table_owned,
734 &root_id_owned,
735 )?;
736 if local.len() != committed.len() {
737 return Err(DbError::Message(format!(
738 "make_local root {:?}/{:?} changed while its blobs were materialized",
739 root_table_owned, root_id_owned
740 )));
741 }
742 for (local, materialized) in local.iter().zip(&committed) {
743 if local.table() != materialized.remote.table()
744 || local.row_id() != materialized.remote.row_id()
745 || local.column() != materialized.remote.column()
746 || local.row_stamp() != stamp
747 || local.blob() != materialized.remote.blob()
748 || local.plaintext_size() != materialized.remote.plaintext_size()
749 || local.plaintext_hash() != materialized.remote.plaintext_hash()
750 || local.authority() != &crate::blob::RowBlobAuthority::Local
751 || local.stored().is_some()
752 {
753 return Err(DbError::Message(format!(
754 "make_local row {:?}/{:?}/{:?} changed while its blob was materialized",
755 materialized.remote.table(),
756 materialized.remote.row_id(),
757 materialized.remote.column()
758 )));
759 }
760 if let Some(path) = &materialized.dest {
761 Database::register_external_blob_on(tx, local, path)?;
762 }
763 Database::enqueue_delete_on(tx, &materialized.stored, &stamp)?;
764 }
765 Ok(())
766 },
767 )
768 })
769 .await?;
770
771 if let Some(obs) = observer {
772 obs.on_root_made_local(root_table, root_id).await;
773 }
774 Ok(())
775}
776
777fn same_row_blob_version(left: &RowBlobRef, right: &RowBlobRef) -> bool {
778 left.table() == right.table()
779 && left.row_id() == right.row_id()
780 && left.row_stamp() == right.row_stamp()
781 && left.column() == right.column()
782 && left.blob() == right.blob()
783 && left.plaintext_size() == right.plaintext_size()
784 && left.plaintext_hash() == right.plaintext_hash()
785}
786
787#[allow(clippy::too_many_arguments)]
795async fn materialize_blobs(
796 db: &Database,
797 storage: &dyn SyncStorage,
798 store_dir: &StoreDir,
799 observer: Option<&dyn BlobTransitionObserver>,
800 root_table: &str,
801 root_id: &str,
802 refs: &[RowBlobRef],
803 dest: &HashMap<String, PathBuf>,
804 cancel: &watch::Receiver<bool>,
805 written: &mut Vec<PathBuf>,
806) -> Result<Vec<Materialized>, MakeLocalError> {
807 let total = refs.len() as u64;
808 let mut materialized: Vec<Materialized> = Vec::new();
809
810 for (i, reference) in refs.iter().enumerate() {
811 if *cancel.borrow() {
812 return Err(MakeLocalError::Cancelled);
813 }
814 let blob = reference.blob();
815 let stored = reference.stored().cloned().ok_or_else(|| {
816 MakeLocalError::Read(
817 blob.id.clone(),
818 "remote row has no exact stored blob reference".to_string(),
819 )
820 })?;
821
822 let record = match blob.provenance {
828 Provenance::UserProvided => {
829 let dest_path = dest
830 .get(&blob.id)
831 .ok_or_else(|| MakeLocalError::MissingDest(blob.id.clone()))?
832 .clone();
833 prepare_parent_dir(&dest_path)
834 .await
835 .map_err(|detail| MakeLocalError::Write {
836 blob_id: blob.id.clone(),
837 path: dest_path.display().to_string(),
838 detail,
839 })?;
840 let staged = cache::stage_remote_blob_plaintext(
841 db,
842 store_dir,
843 Some(storage),
844 reference,
845 &dest_path,
846 )
847 .await
848 .map_err(|e| MakeLocalError::Read(blob.id.clone(), e.to_string()))?;
849 staged
850 .commit_new()
851 .await
852 .map_err(|detail| MakeLocalError::Write {
853 blob_id: blob.id.clone(),
854 path: dest_path.display().to_string(),
855 detail: detail.to_string(),
856 })?;
857 verify_durable(&dest_path, reference.plaintext_size())
858 .await
859 .map_err(|detail| MakeLocalError::Write {
860 blob_id: blob.id.clone(),
861 path: dest_path.display().to_string(),
862 detail,
863 })?;
864 written.push(dest_path.clone());
865 Materialized {
866 remote: reference.clone(),
867 stored,
868 dest: Some(dest_path),
869 }
870 }
871 Provenance::HostProvided => {
872 let store_path = store_dir
873 .local_blob_path(&blob.namespace, &blob.id)
874 .map_err(|e| MakeLocalError::Write {
875 blob_id: blob.id.clone(),
876 path: format!("local/{}/{}", blob.namespace, blob.id),
877 detail: e.to_string(),
878 })?;
879 let staged = cache::stage_remote_blob_plaintext(
880 db,
881 store_dir,
882 Some(storage),
883 reference,
884 &store_path,
885 )
886 .await
887 .map_err(|e| MakeLocalError::Read(blob.id.clone(), e.to_string()))?;
888 match staged.commit_new().await {
889 Ok(()) => written.push(store_path.clone()),
890 Err(crate::local_blob::CommitNewFileError::DestinationExists(_)) => {
891 let (size, hash) = crate::local_blob::exact_file_facts(&store_path)
892 .await
893 .map_err(|detail| MakeLocalError::Write {
894 blob_id: blob.id.clone(),
895 path: store_path.display().to_string(),
896 detail,
897 })?;
898 if size != reference.plaintext_size() || hash != reference.plaintext_hash()
899 {
900 return Err(MakeLocalError::Write {
901 blob_id: blob.id.clone(),
902 path: store_path.display().to_string(),
903 detail:
904 "existing local-store file differs from the exact remote blob"
905 .to_string(),
906 });
907 }
908 }
909 Err(error) => {
910 return Err(MakeLocalError::Write {
911 blob_id: blob.id.clone(),
912 path: store_path.display().to_string(),
913 detail: error.to_string(),
914 });
915 }
916 }
917 verify_durable(&store_path, reference.plaintext_size())
918 .await
919 .map_err(|detail| MakeLocalError::Write {
920 blob_id: blob.id.clone(),
921 path: store_path.display().to_string(),
922 detail,
923 })?;
924 Materialized {
925 remote: reference.clone(),
926 stored,
927 dest: None,
928 }
929 }
930 };
931 materialized.push(record);
932
933 if let Some(obs) = observer {
934 obs.on_blob_materialize_progress(root_table, root_id, &blob.id, (i + 1) as u64, total)
935 .await;
936 }
937 }
938
939 if *cancel.borrow() {
940 return Err(MakeLocalError::Cancelled);
941 }
942 Ok(materialized)
943}
944
945async fn verify_durable(dest: &std::path::Path, expected_size: u64) -> Result<(), String> {
953 let len = crate::local_blob::file_len(dest).await?;
954 if len != expected_size {
955 return Err(format!(
956 "dest {} is {len} bytes after materialize, expected {expected_size}",
957 dest.display()
958 ));
959 }
960
961 crate::local_blob::sync_parent_dir(dest).await?;
964 Ok(())
965}
966
967async fn prepare_parent_dir(dest: &std::path::Path) -> Result<(), String> {
968 let parent = dest
969 .parent()
970 .ok_or_else(|| format!("blob path has no parent dir: {}", dest.display()))?;
971 crate::local_blob::create_dir_all(parent).await
972}
973
974async fn roll_back(written: &[PathBuf], abort_err: MakeLocalError) -> MakeLocalError {
980 match cleanup_partial(written).await {
981 Ok(()) => abort_err,
982 Err(cleanup_err) => cleanup_err,
983 }
984}
985
986async fn cleanup_partial(written: &[PathBuf]) -> Result<(), MakeLocalError> {
991 for path in written {
992 crate::local_blob::remove_file(path)
993 .await
994 .map_err(|detail| MakeLocalError::Cleanup {
995 path: path.display().to_string(),
996 detail,
997 })?;
998 }
999 Ok(())
1000}