1use coven_database::StoreReclaimJournalError;
130use std::sync::Arc;
131
132mod candidates;
133mod claims;
134mod history;
135
136use crate::sync::store::AuthorizedWriterOperation;
137use coven_database::{
138 DurableStoreReclaimObject, DurableStoreReclaimOperation, ReclaimCommitActivation, StoreDatabase,
139};
140use coven_protocol::circle::{CircleControlCoord, CircleControlState, CircleEpochOrigin, CircleId};
141use coven_protocol::objects::StoreObjectError;
142use coven_protocol::objects::{ProtocolObjectContext, ProtocolObjectDomain, StorageError};
143use coven_protocol::reclaim::*;
144use coven_protocol::store_commit::{
145 snapshot_image_semantic_prefix, CommitFrontier, ObjectHash, StoreAckRef, StoreBatchCommitRef,
146 StoreRootRef, StoreSnapshotLocator, VerifiedStoreBatchCommit,
147};
148use coven_storage::CloudSyncObjectStorage;
149pub(crate) use history::{CircleSnapshotStream, ReclaimHistory, SelectedCircleSnapshot};
150
151#[derive(Debug, PartialEq, Eq)]
152pub struct StoreReclaimResult {
153 pub packages_deleted: u64,
154 pub physical_copies_deleted: u64,
155 pub stuck: u64,
159 pub store_packages: StorePackageReclaimReport,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct StorePackageReclaimReport {
174 pub coverage: StorePackageReclaimCoverage,
176 pub targets_considered: u64,
178 pub retained_for_replay: u64,
182 pub retained_for_blob_reclaim: u64,
186 pub already_authorized: u64,
189 pub authorized: u64,
191}
192
193impl StorePackageReclaimReport {
200 fn declined(coverage: StorePackageReclaimCoverage) -> Self {
203 Self {
204 coverage,
205 targets_considered: 0,
206 retained_for_replay: 0,
207 retained_for_blob_reclaim: 0,
208 already_authorized: 0,
209 authorized: 0,
210 }
211 }
212}
213
214enum AuthorizationOutcome {
220 Signed,
221 AlreadyJournalled,
222}
223
224struct ReclaimPass {
226 packages_deleted: u64,
227 stuck: u64,
230}
231
232enum ReclaimStep {
234 Advanced,
236 Deleted,
238 Idle,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum StorePackageReclaimCoverage {
245 Snapshot { generation: u64 },
247 NoSnapshot,
249 MissingAcknowledgement { member: String, device_id: String },
252 NotOwner,
254 InputsUnchanged,
258}
259
260#[derive(Debug, thiserror::Error)]
261pub enum StoreReclaimError {
262 #[error(transparent)]
263 Object(#[from] StoreObjectError),
264 #[error(transparent)]
265 Database(#[from] coven_database::DbError),
266 #[error(transparent)]
267 Outbound(#[from] crate::sync::store::StoreError),
268 #[error("Store reclaim journal: {0}")]
269 Journal(#[from] StoreReclaimJournalError),
270 #[error(transparent)]
271 Storage(#[from] StorageError),
272 #[error("no authorized complete Store snapshot is available for reclamation")]
273 NoSnapshot,
274 #[error("snapshot authorization history is invalid: {0}")]
275 Authorization(String),
276 #[error("snapshot authorization Store pull: {0}")]
277 StorePull(#[source] Box<crate::sync::store::pull::StorePullError>),
278 #[error("snapshot authorization Store protocol: {0}")]
279 Protocol(#[from] coven_protocol::store_commit::StoreProtocolError),
280 #[error("snapshot authorization audience package: {0}")]
281 AudiencePackage(#[from] coven_protocol::audience_package::AudiencePackageError),
282 #[error("snapshot authorization snapshot: {0}")]
283 Snapshot(#[source] Box<crate::sync::store::SnapshotError>),
284 #[error("snapshot authorization acknowledgement: {0}")]
285 Acknowledgement(#[source] Box<crate::sync::store::StoreAckError>),
286 #[error("snapshot authorization writer: {0}")]
287 WriterAuthorization(#[source] Box<crate::sync::store::StoreWriterAuthorizationError>),
288 #[error(
289 "active Store device {device_id:?} for member {member:?} has no exact acknowledgement"
290 )]
291 MissingAcknowledgement { member: String, device_id: String },
292 #[error("exact Store ancestry is missing commit {commit_hash}")]
293 MissingAncestry { commit_hash: ObjectHash },
294 #[error("deleting the exact object activated by {activation} failed: {source}")]
295 Delete {
296 activation: ObjectHash,
297 #[source]
298 source: StorageError,
299 },
300}
301
302impl From<crate::sync::store::pull::CommitCoverageError> for StoreReclaimError {
303 fn from(error: crate::sync::store::pull::CommitCoverageError) -> Self {
304 match error {
305 crate::sync::store::pull::CommitCoverageError::Object(error) => Self::Object(error),
306 crate::sync::store::pull::CommitCoverageError::MissingAncestry { commit_hash } => {
307 Self::MissingAncestry { commit_hash }
308 }
309 }
310 }
311}
312
313impl From<crate::sync::store::pull::StorePullError> for StoreReclaimError {
314 fn from(error: crate::sync::store::pull::StorePullError) -> Self {
315 Self::StorePull(Box::new(error))
316 }
317}
318
319impl From<crate::sync::store::SnapshotError> for StoreReclaimError {
320 fn from(error: crate::sync::store::SnapshotError) -> Self {
321 Self::Snapshot(Box::new(error))
322 }
323}
324
325impl From<crate::sync::store::StoreAckError> for StoreReclaimError {
326 fn from(error: crate::sync::store::StoreAckError) -> Self {
327 Self::Acknowledgement(Box::new(error))
328 }
329}
330
331impl From<crate::sync::store::StoreWriterAuthorizationError> for StoreReclaimError {
332 fn from(error: crate::sync::store::StoreWriterAuthorizationError) -> Self {
333 Self::WriterAuthorization(Box::new(error))
334 }
335}
336
337use candidates::*;
338
339pub(crate) struct AuthorizedReclaim<'operation, 'storage> {
340 writer: &'operation mut AuthorizedWriterOperation<'storage>,
341 database: StoreDatabase,
342 storage: Arc<dyn CloudSyncObjectStorage>,
343 root: StoreRootRef,
344 membership: coven_protocol::membership::MembershipChain,
345}
346
347impl<'operation, 'storage> AuthorizedReclaim<'operation, 'storage> {
348 pub(crate) fn new(
349 writer: &'operation mut AuthorizedWriterOperation<'storage>,
350 database: StoreDatabase,
351 storage: Arc<dyn CloudSyncObjectStorage>,
352 root: StoreRootRef,
353 membership: coven_protocol::membership::MembershipChain,
354 ) -> Self {
355 Self {
356 writer,
357 database,
358 storage,
359 root,
360 membership,
361 }
362 }
363
364 fn history(&mut self) -> ReclaimHistory<'_, 'storage> {
365 self.writer.reclaim_history()
366 }
367
368 pub(super) async fn run(
369 &mut self,
370 settled: &crate::sync::store::SettledCycle,
371 ) -> Result<StoreReclaimResult, StoreReclaimError> {
372 let database = self.database.clone();
373 let membership = self.membership.clone();
374 if self.writer.is_current_owner(&membership) {
381 Box::pin(self.prepare_audience_blob_authorizations()).await?;
382 }
383 let journal = Box::pin(self.resume_operations()).await?;
388 let mut packages_deleted = journal.packages_deleted;
389 if !self.writer.is_current_owner(&membership) {
390 return Ok(StoreReclaimResult {
391 packages_deleted,
392 physical_copies_deleted: packages_deleted,
393 stuck: journal.stuck,
394 store_packages: StorePackageReclaimReport::declined(
395 StorePackageReclaimCoverage::NotOwner,
396 ),
397 });
398 }
399 let inputs = crate::sync::store::CycleInputs::read(&database, &membership)
404 .await
405 .map_err(StoreReclaimError::Database)?;
406 if settled.reclaim_evaluated(&inputs) {
407 return Ok(StoreReclaimResult {
408 packages_deleted,
409 physical_copies_deleted: packages_deleted,
410 stuck: journal.stuck,
411 store_packages: StorePackageReclaimReport::declined(
412 StorePackageReclaimCoverage::InputsUnchanged,
413 ),
414 });
415 }
416 let registrations = database
417 .activated_store_device_registration_records()
418 .await
419 .map_err(StoreReclaimError::from)?;
420 let mut rollup_claims = Vec::new();
423 let (coverage, store_targets) = match Box::pin(self.choose_snapshot(®istrations)).await {
424 Ok(claim) => {
425 let generation = claim.snapshot.reference.generation;
426 rollup_claims =
427 self.prepare_store_membership_rollup_claims(®istrations, &claim)?;
428 let targets = self
429 .history()
430 .store_package_targets(&claim.snapshot.meta.coverage)
431 .await
432 .map_err(StoreReclaimError::from)?
433 .into_iter()
434 .map(|(commit, package)| (commit, package, claim.clone()))
435 .collect::<Vec<_>>();
436 (
437 StorePackageReclaimCoverage::Snapshot { generation },
438 targets,
439 )
440 }
441 Err(StoreReclaimError::NoSnapshot) => {
446 (StorePackageReclaimCoverage::NoSnapshot, Vec::new())
447 }
448 Err(StoreReclaimError::MissingAcknowledgement { member, device_id }) => (
449 StorePackageReclaimCoverage::MissingAcknowledgement { member, device_id },
450 Vec::new(),
451 ),
452 Err(error) => return Err(error),
453 };
454 let mut store_packages = StorePackageReclaimReport::declined(coverage);
455 store_packages.targets_considered = store_targets.len() as u64;
456 for (commit, package, snapshot) in store_targets {
457 if database
458 .store_package_is_retained_for_replay(
459 self.root.clone(),
460 package.clone(),
461 commit.clone(),
462 )
463 .await?
464 {
465 store_packages.retained_for_replay += 1;
466 continue;
467 }
468 if database
469 .package_is_retained_by_pending_blob_reclaim(package.object.clone())
470 .await?
471 {
472 store_packages.retained_for_blob_reclaim += 1;
473 continue;
474 }
475 let authorized = Box::pin(self.prepare_authorization(ReclaimClaim::StorePackage(
476 StorePackageReclaimClaim {
477 target: StorePackageReclaimTarget {
478 package,
479 activation: commit,
480 },
481 covering_snapshot: StoreSnapshotLocator {
482 author_registration: snapshot.snapshot.meta.author_registration.clone(),
483 snapshot: snapshot.snapshot.reference.clone(),
484 },
485 acknowledgements: snapshot.acknowledgements.clone(),
486 },
487 )))
488 .await?;
489 match authorized {
490 AuthorizationOutcome::Signed => store_packages.authorized += 1,
491 AuthorizationOutcome::AlreadyJournalled => store_packages.already_authorized += 1,
492 }
493 }
494 for claim in rollup_claims {
495 Box::pin(self.prepare_authorization(claim)).await?;
496 }
497 Box::pin(self.prepare_circle_authorizations(®istrations)).await?;
498 let authorized = Box::pin(self.resume_operations()).await?;
499 packages_deleted = packages_deleted
500 .checked_add(authorized.packages_deleted)
501 .ok_or_else(|| {
502 StoreReclaimError::Authorization("reclaimed package count exceeded u64".to_string())
503 })?;
504 settled.record_reclaim_evaluated(inputs);
507 Ok(StoreReclaimResult {
508 packages_deleted,
509 physical_copies_deleted: packages_deleted,
510 stuck: authorized.stuck,
511 store_packages,
512 })
513 }
514
515 async fn prepare_beyond_cutoff_circle_authorizations(
516 &mut self,
517 circle_id: CircleId,
518 current_control: &CircleControlCoord,
519 ) -> Result<(), StoreReclaimError> {
520 let database = self.database.clone();
521 let root = self.root.clone();
522 let successor = database
523 .verified_circle_activation(root.clone(), circle_id, current_control.clone())
524 .await?
525 .ok_or_else(|| {
526 StoreReclaimError::Authorization(format!(
527 "Circle {circle_id} current control is not a retained activation"
528 ))
529 })?;
530 if !matches!(
533 successor.control.value.state(),
534 CircleControlState::ActiveEpoch(active)
535 if matches!(active.common.origin, CircleEpochOrigin::Closed { .. })
536 ) {
537 return Ok(());
538 }
539 let frontier = CommitFrontier::from_refs(database.materialized_frontier().await?)
540 .map_err(StoreReclaimError::from)?;
541 let epochs = database.circle_replay_epoch_index(root.clone()).await?;
542 let targets = self
543 .history()
544 .circle_package_targets(circle_id, &frontier)
545 .await
546 .map_err(StoreReclaimError::from)?;
547 for (commit, package) in targets {
548 if epochs
552 .permits(&commit, circle_id, &package.control)
553 .map_err(StoreReclaimError::from)?
554 {
555 continue;
556 }
557 if database
558 .circle_package_is_retained_for_replay(
559 root.clone(),
560 package.clone(),
561 commit.clone(),
562 )
563 .await?
564 || database
565 .package_is_retained_by_pending_blob_reclaim(package.package.object.clone())
566 .await?
567 {
568 continue;
569 }
570 Box::pin(self.prepare_authorization(ReclaimClaim::CirclePackage(
571 CirclePackageReclaimClaim::BeyondEpochCutoff(CirclePackageBeyondCutoffClaim {
572 target: CirclePackageReclaimTarget {
573 package,
574 activation: commit,
575 },
576 successor_control: current_control.clone(),
577 }),
578 )))
579 .await?;
580 }
581 Ok(())
582 }
583
584 async fn prepare_audience_blob_authorizations(&mut self) -> Result<(), StoreReclaimError> {
585 let database = self.database.clone();
586 for (blob, owners) in database.stored_blob_reclaim_candidates().await? {
587 if !database.stored_blob_is_row_orphaned(blob.clone()).await? {
588 continue;
589 }
590 if database
591 .audience_blob_is_retained_for_replay(blob.clone())
592 .await?
593 {
594 continue;
595 }
596 let mut binding = None;
600 for owner in &owners {
601 let commit = self
602 .history()
603 .load_ref(owner)
604 .await
605 .map_err(StoreReclaimError::from)?;
606 if let Some(package) =
607 audience_blob_binding_package(commit.value(), blob.locator().audience())
608 {
609 binding = Some((package, owner.clone()));
610 break;
611 }
612 }
613 let Some((package, activation)) = binding else {
614 tracing::debug!(
615 blob = %coven_protocol::remote_object::remote_object_id(blob.object()),
616 "skip orphaned blob whose owning commits name no package for its audience",
617 );
618 continue;
619 };
620 let target = AudienceBlobReclaimTarget {
621 blob,
622 package,
623 activation,
624 };
625 Box::pin(self.prepare_authorization(ReclaimClaim::AudienceBlob(
626 AudienceBlobReclaimClaim { target },
627 )))
628 .await?;
629 }
630 Ok(())
631 }
632
633 fn prepare_store_membership_rollup_claims(
648 &self,
649 registrations: &[coven_protocol::store_commit::ReferencedStoreDeviceRegistration],
650 selected: &VerifiedReclaimSnapshot,
651 ) -> Result<Vec<ReclaimClaim>, StoreReclaimError> {
652 let author = &selected.snapshot.meta.author_registration;
653 let Some(registration) = registrations
654 .iter()
655 .find(|candidate| candidate.reference() == author)
656 else {
657 return Ok(Vec::new());
658 };
659 let activation = registration
660 .value()
661 .store_snapshot_activation(registration.reference())
662 .map_err(StoreReclaimError::from)?
663 .activation_id();
664 let mut claims = Vec::new();
665 for generation in &selected.authorized {
666 if generation.meta.author_registration != *author
667 || generation.reference.generation >= selected.snapshot.reference.generation
668 || generation.meta.membership_rollup.object
669 == selected.snapshot.meta.membership_rollup.object
670 || !snapshot_supersedes_seed(
671 &selected.snapshot.meta.coverage,
672 &generation.meta.coverage,
673 )
674 {
675 continue;
676 }
677 claims.push(ReclaimClaim::StoreMembershipRollup(
678 StoreMembershipRollupReclaimClaim {
679 target: StoreMembershipRollupReclaimTarget {
680 snapshot_author: author.clone(),
681 activation,
682 snapshot: generation.reference.clone(),
683 rollup: generation.meta.membership_rollup.clone(),
684 },
685 superseding: selected.snapshot.reference.clone(),
686 },
687 ));
688 }
689 Ok(claims)
690 }
691
692 async fn prepare_circle_snapshot_image_authorizations(
693 &mut self,
694 circle_id: CircleId,
695 streams: &[CircleSnapshotStream],
696 stable: &[SelectedCircleSnapshot],
697 ) -> Result<(), StoreReclaimError> {
698 let database = self.database.clone();
699 for stream in streams {
700 for (reference, meta) in &stream.generations {
701 let Some(superseding) = stable.iter().find(|candidate| {
702 candidate.author_registration == stream.author_registration
703 && candidate.reference.generation > reference.generation
704 && snapshot_supersedes_seed(
705 &candidate.meta.bootstrap.coverage,
706 &meta.bootstrap.coverage,
707 )
708 }) else {
709 continue;
710 };
711 let target = CircleSnapshotImageReclaimTarget {
712 circle_id,
713 snapshot_author: stream.author_registration.clone(),
714 control: meta.control.clone(),
715 snapshot: reference.clone(),
716 image: meta.bootstrap.image.clone(),
717 };
718 if database
719 .circle_image_is_retained_for_replay(circle_id, target.image.clone())
720 .await?
721 {
722 continue;
723 }
724 Box::pin(
725 self.prepare_authorization(ReclaimClaim::CircleSnapshotImage(
726 CircleSnapshotImageReclaimClaim {
727 target,
728 superseding: superseding.reference.clone(),
729 },
730 )),
731 )
732 .await?;
733 }
734 }
735 Ok(())
736 }
737
738 async fn prepare_circle_authorizations(
739 &mut self,
740 registrations: &[coven_protocol::store_commit::ReferencedStoreDeviceRegistration],
741 ) -> Result<(), StoreReclaimError> {
742 let database = self.database.clone();
743 for input in database.circle_acknowledgement_publication_inputs().await? {
744 let circle_id = input.circle_id();
745 let control = input.control().clone();
746 Box::pin(self.prepare_beyond_cutoff_circle_authorizations(circle_id, &control)).await?;
750 let streams = self
754 .history()
755 .load_circle_snapshot_streams(circle_id, &control, registrations)
756 .await?;
757 let stable = self
758 .history()
759 .stable_circle_snapshots(circle_id, &streams)
760 .await?;
761 let selected = maximal_stable_circle_snapshot(&stable);
762 Box::pin(self.prepare_circle_bootstrap_authorizations(circle_id, &control, selected))
763 .await?;
764 Box::pin(
767 self.prepare_circle_snapshot_image_authorizations(circle_id, &streams, &stable),
768 )
769 .await?;
770 let Some(selected) = selected else {
771 continue;
772 };
773 let targets = self
774 .history()
775 .circle_package_targets(circle_id, &selected.meta.bootstrap.coverage)
776 .await
777 .map_err(StoreReclaimError::from)?;
778 for (commit, package) in targets {
779 if database
780 .circle_package_is_retained_for_replay(
781 self.root.clone(),
782 package.clone(),
783 commit.clone(),
784 )
785 .await?
786 || database
787 .package_is_retained_by_pending_blob_reclaim(package.package.object.clone())
788 .await?
789 {
790 continue;
791 }
792 Box::pin(self.prepare_authorization(ReclaimClaim::CirclePackage(
793 CirclePackageReclaimClaim::SnapshotCovered(
794 CirclePackageSnapshotCoverageClaim {
795 target: CirclePackageReclaimTarget {
796 package,
797 activation: commit,
798 },
799 covering_snapshot: CircleSnapshotLocator {
800 author_registration: selected.author_registration.clone(),
801 circle_id,
802 control: selected.meta.control.clone(),
803 snapshot: selected.reference.clone(),
804 },
805 acknowledgements: selected.acknowledgements.clone(),
806 },
807 ),
808 )))
809 .await?;
810 }
811 }
812 Ok(())
813 }
814
815 async fn prepare_circle_bootstrap_authorizations(
816 &mut self,
817 circle_id: CircleId,
818 current_control: &CircleControlCoord,
819 selected: Option<&SelectedCircleSnapshot>,
820 ) -> Result<(), StoreReclaimError> {
821 let database = self.database.clone();
822 let root = self.root.clone();
823 let roster = database.circle_current_roster_members(circle_id).await?;
824 let stable_cut = selected.map(|selected| &selected.meta.bootstrap.coverage);
828 for acknowledgement in database.activated_circle_acks(circle_id).await? {
829 let ack = match self
830 .history()
831 .load_circle_acknowledgement(&acknowledgement)
832 .await
833 {
834 Ok(ack) => ack,
835 Err(error) => {
836 tracing::debug!(
837 circle_id = %circle_id,
838 "skip Circle acknowledgement for bootstrap reclaim: {error}"
839 );
840 continue;
841 }
842 };
843 let Some(coverage) = ack.seeded_from.clone() else {
844 continue;
846 };
847 let recipient = database
848 .activated_store_device_registration(acknowledgement.registration.clone())
849 .await?;
850 let recipient_active = roster.contains(&recipient.value().author_pubkey);
851 let seed = &coverage.bootstrap.coverage;
852 let superseded_by_snapshot = stable_cut
853 .as_ref()
854 .is_some_and(|cut| snapshot_supersedes_seed(cut, seed));
855 let proof = if recipient_active {
856 if superseded_by_snapshot {
857 CircleBootstrapReclaimProof::RecipientCoverage {
858 acknowledgement: acknowledgement.clone(),
859 }
860 } else {
861 continue;
863 }
864 } else if database
865 .circle_control_covers_strictly(
866 root.clone(),
867 circle_id,
868 current_control,
869 &coverage.control,
870 )
871 .await?
872 {
873 CircleBootstrapReclaimProof::LostAuthority {
874 acknowledgement: acknowledgement.clone(),
875 successor_control: current_control.clone(),
876 }
877 } else {
878 continue;
879 };
880 let target = CircleBootstrapImageReclaimTarget { coverage };
881 if database
882 .circle_bootstrap_image_is_retained_for_replay(target.coverage.clone())
883 .await?
884 {
885 continue;
886 }
887 Box::pin(
888 self.prepare_authorization(ReclaimClaim::CircleBootstrapImage(
889 CircleBootstrapImageReclaimClaim { target, proof },
890 )),
891 )
892 .await?;
893 }
894 Ok(())
895 }
896
897 async fn prepare_authorization(
898 &mut self,
899 claim: ReclaimClaim,
900 ) -> Result<AuthorizationOutcome, StoreReclaimError> {
901 let database = self.database.clone();
902 let root = self.root.clone();
903 let target = claim.target();
904 if database
905 .store_reclaim_operations()
906 .await?
907 .iter()
908 .any(|operation| operation.authorization().target() == &target)
909 {
910 return Ok(AuthorizationOutcome::AlreadyJournalled);
911 }
912 let plan = self.writer.prepare_plan().await?;
913 let owner_grant = plan.owner_grant().cloned().ok_or_else(|| {
914 StoreReclaimError::Authorization(
915 "Store reclaim authorization requires an active Owner grant".to_string(),
916 )
917 })?;
918 let evidence = plan
919 .sign_reclaim_evidence(claim)
920 .map_err(StoreReclaimError::from)?;
921 self.verify_evidence(&evidence).await?;
922 let evidence_context = ProtocolObjectContext::store_encrypted(
923 root.store_root_hash,
924 ProtocolObjectDomain::StoreReclaimEvidence,
925 );
926 let evidence_prefix = reclaim_evidence_semantic_prefix(evidence.evidence_hash());
927 let evidence_slot = self
928 .storage
929 .allocate_protocol_slot(&evidence_context, &evidence_prefix, ".json")
930 .await?;
931 let evidence_prepared = self.storage.prepare_protocol_object(
932 &evidence_context,
933 evidence_slot,
934 &evidence_prefix,
935 evidence.to_bytes(),
936 )?;
937 let evidence_ref =
938 ReclaimEvidenceRef::from_evidence(&evidence, evidence_prepared.reference().clone());
939 let authorization = plan.sign_reclaim_authorization(
940 evidence.claim.target(),
941 evidence_ref.clone(),
942 StoreReclaimAuthority {
943 membership: plan.membership_state().clone(),
944 owner_grant,
945 },
946 );
947 let authorization_context = ProtocolObjectContext::signed_plaintext(
948 root.store_root_hash,
949 ProtocolObjectDomain::StoreReclaimAuthorization,
950 );
951 let authorization_prefix =
952 reclaim_authorization_semantic_prefix(authorization.authorization_hash());
953 let authorization_slot = self
954 .storage
955 .allocate_protocol_slot(&authorization_context, &authorization_prefix, ".json")
956 .await?;
957 let authorization_prepared = self.storage.prepare_protocol_object(
958 &authorization_context,
959 authorization_slot,
960 &authorization_prefix,
961 authorization.to_bytes(),
962 )?;
963 let authorization_ref = ReclaimAuthorizationRef::from_authorization(
964 &authorization,
965 authorization_prepared.reference().clone(),
966 );
967 let candidate = self
968 .writer
969 .prepare_candidate(
970 plan,
971 crate::sync::store::commit_publication::operation::commit_plan::StoreOperationBatch::ReclaimAuthorization(Box::new(
972 authorization_ref.clone(),
973 )),
974 )
975 .await?;
976 let operation = DurableStoreReclaimOperation::AuthorizationCandidate {
977 object: Box::new(DurableStoreReclaimObject::Authorization {
978 evidence_ref,
979 evidence,
980 evidence_prepared,
981 authorization_ref,
982 authorization,
983 authorization_prepared,
984 }),
985 candidate: Box::new(candidate),
986 };
987 Box::pin(database.begin_store_reclaim_operation(operation)).await?;
988 Ok(AuthorizationOutcome::Signed)
989 }
990
991 async fn resume_operations(&mut self) -> Result<ReclaimPass, StoreReclaimError> {
1000 let database = self.database.clone();
1001 let mut packages_deleted = 0_u64;
1002 loop {
1003 let operations = database.runnable_store_reclaim_operations().await?;
1004 let mut progressed = false;
1005 for operation in operations {
1006 let operation_id = operation.operation_id();
1007 match Box::pin(self.run_operation(operation)).await {
1008 Ok(ReclaimStep::Deleted) => {
1009 packages_deleted = packages_deleted.checked_add(1).ok_or_else(|| {
1010 StoreReclaimError::Authorization(
1011 "reclaimed package count exceeded u64".to_string(),
1012 )
1013 })?;
1014 progressed = true;
1015 }
1016 Ok(ReclaimStep::Advanced) => progressed = true,
1017 Ok(ReclaimStep::Idle) => {}
1018 Err(error) if crate::sync::error::error_chain_contains_transport(&error) => {
1019 return Err(error)
1020 }
1021 Err(error) => {
1022 tracing::warn!(
1023 operation = %operation_id,
1024 "Store reclaim operation is stuck until the host asks for it: {error}"
1025 );
1026 database
1027 .mark_store_reclaim_operation_stuck(operation_id, error.to_string())
1028 .await?;
1029 }
1030 }
1031 }
1032 if !progressed {
1033 let stuck = database.stuck_reclaim_operations().await?.len() as u64;
1034 return Ok(ReclaimPass {
1035 packages_deleted,
1036 stuck,
1037 });
1038 }
1039 }
1040 }
1041
1042 async fn run_operation(
1044 &mut self,
1045 operation: DurableStoreReclaimOperation,
1046 ) -> Result<ReclaimStep, StoreReclaimError> {
1047 match &operation {
1048 DurableStoreReclaimOperation::AuthorizationCandidate { .. }
1049 | DurableStoreReclaimOperation::ReceiptCandidate { .. } => {
1050 Box::pin(self.drive_candidate(operation)).await?;
1051 Ok(ReclaimStep::Advanced)
1052 }
1053 DurableStoreReclaimOperation::AuthorizationReplacing { .. }
1054 | DurableStoreReclaimOperation::ReceiptReplacing { .. } => {
1055 Box::pin(self.finish_candidate_replacement(operation)).await?;
1056 Ok(ReclaimStep::Advanced)
1057 }
1058 DurableStoreReclaimOperation::Authorized { .. } => {
1059 if self.deferred_to_blob_reclaim(&operation).await? {
1065 return Ok(ReclaimStep::Idle);
1066 }
1067 Box::pin(self.execute_delete(operation)).await?;
1068 Ok(ReclaimStep::Deleted)
1069 }
1070 DurableStoreReclaimOperation::AbsentVerified { .. } => {
1071 Box::pin(self.prepare_receipt(operation)).await?;
1072 Ok(ReclaimStep::Advanced)
1073 }
1074 DurableStoreReclaimOperation::Completed { .. } => Ok(ReclaimStep::Idle),
1075 }
1076 }
1077
1078 async fn deferred_to_blob_reclaim(
1081 &self,
1082 operation: &DurableStoreReclaimOperation,
1083 ) -> Result<bool, StoreReclaimError> {
1084 let package = match operation.authorization().target() {
1085 ReclaimTarget::StorePackage(target) => target.package.object.clone(),
1086 ReclaimTarget::CirclePackage(target) => target.package.package.object.clone(),
1087 _ => return Ok(false),
1088 };
1089 Ok(self
1090 .database
1091 .package_is_retained_by_pending_blob_reclaim(package)
1092 .await?)
1093 }
1094
1095 async fn execute_delete(
1096 &mut self,
1097 operation: DurableStoreReclaimOperation,
1098 ) -> Result<(), StoreReclaimError> {
1099 let database = self.database.clone();
1100 let DurableStoreReclaimOperation::Authorized {
1101 authorization,
1102 activation,
1103 } = &operation
1104 else {
1105 return Err(StoreReclaimError::Authorization(
1106 "only an authorized reclaim can delete its target".to_string(),
1107 ));
1108 };
1109 let target = self.verify_authorized(authorization, activation).await?;
1110 if self.target_is_retained(&target).await? {
1111 return Err(StoreReclaimError::Authorization(
1112 "reclaim target remains retained for accepted replay".to_string(),
1113 ));
1114 }
1115 match &target {
1118 ReclaimTarget::AudienceBlob(blob) => self.storage.delete_blob_object(&blob.blob).await,
1119 _ => self.storage.delete_protocol_object(target.object()).await,
1120 }
1121 .map_err(|source| StoreReclaimError::Delete {
1122 activation: target.activation().object().stored_hash(),
1123 source,
1124 })?;
1125 self.verify_target_absent(&target).await?;
1126 database
1127 .mark_store_reclaim_target_absent(operation, target)
1128 .await?;
1129 Ok(())
1130 }
1131}
1132
1133#[cfg(test)]
1134mod audience_blob_order_tests;
1135#[cfg(test)]
1136mod tests;
1137
1138pub(crate) async fn create_reclaim_exact_objects(
1139 object: &coven_database::DurableStoreReclaimObject,
1140 storage: &dyn CloudSyncObjectStorage,
1141) -> Result<(), StoreReclaimJournalError> {
1142 match object {
1143 coven_database::DurableStoreReclaimObject::Authorization {
1144 evidence,
1145 evidence_prepared,
1146 authorization,
1147 authorization_prepared,
1148 ..
1149 } => {
1150 storage
1151 .create_verified_protocol_object(
1152 &ProtocolObjectContext::store_encrypted(
1153 evidence.store_root_hash,
1154 ProtocolObjectDomain::StoreReclaimEvidence,
1155 ),
1156 evidence_prepared,
1157 &reclaim_evidence_semantic_prefix(evidence.evidence_hash()),
1158 &evidence.to_bytes(),
1159 )
1160 .await?;
1161 storage
1162 .create_verified_protocol_object(
1163 &ProtocolObjectContext::signed_plaintext(
1164 authorization.store_root_hash,
1165 ProtocolObjectDomain::StoreReclaimAuthorization,
1166 ),
1167 authorization_prepared,
1168 &reclaim_authorization_semantic_prefix(authorization.authorization_hash()),
1169 &authorization.to_bytes(),
1170 )
1171 .await
1172 .map_err(StoreReclaimJournalError::Storage)
1173 }
1174 coven_database::DurableStoreReclaimObject::Receipt {
1175 receipt,
1176 receipt_prepared,
1177 ..
1178 } => storage
1179 .create_verified_protocol_object(
1180 &ProtocolObjectContext::signed_plaintext(
1181 receipt.store_root_hash,
1182 ProtocolObjectDomain::StoreReclaimReceipt,
1183 ),
1184 receipt_prepared,
1185 &reclaim_receipt_semantic_prefix(receipt.receipt_hash()),
1186 &receipt.to_bytes(),
1187 )
1188 .await
1189 .map_err(StoreReclaimJournalError::Storage),
1190 }
1191}