1use std::collections::BTreeSet;
6use std::sync::Arc;
7
8mod candidates;
9mod claims;
10mod history;
11mod snapshot_retirement;
12
13use crate::sync::store::AuthorizedWriterOperation;
14use coven_database::{
15 DurableStoreReclaimObject, DurableStoreReclaimOperation, StoreDatabase,
16 StoreReclaimJournalError,
17};
18use coven_protocol::circle::{CircleControlCoord, CircleControlState, CircleEpochOrigin, CircleId};
19use coven_protocol::objects::StoreObjectError;
20use coven_protocol::objects::{ProtocolObjectContext, ProtocolObjectDomain, StorageError};
21use coven_protocol::reclaim::*;
22use coven_protocol::store_commit::{
23 snapshot_image_semantic_prefix, CommitFrontier, ObjectHash, StoreBatchCommitRef, StoreRootRef,
24 VerifiedStoreBatchCommit,
25};
26use coven_storage::CloudSyncObjectStorage;
27pub(crate) use history::{CircleSnapshotStream, ReclaimHistory, SelectedCircleSnapshot};
28
29#[derive(Debug, PartialEq, Eq)]
30pub struct StoreReclaimResult {
31 pub packages_deleted: u64,
32 pub physical_copies_deleted: u64,
33 pub stuck: u64,
37 pub store_packages: StorePackageReclaimReport,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct StorePackageReclaimReport {
52 pub coverage: StorePackageReclaimCoverage,
54 pub targets_considered: u64,
56 pub retained_for_replay: u64,
60 pub already_authorized: u64,
63 pub authorized: u64,
65}
66
67impl StorePackageReclaimReport {
74 fn declined(coverage: StorePackageReclaimCoverage) -> Self {
77 Self {
78 coverage,
79 targets_considered: 0,
80 retained_for_replay: 0,
81 already_authorized: 0,
82 authorized: 0,
83 }
84 }
85}
86
87enum AuthorizationOutcome {
93 Signed,
94 AlreadyJournalled,
95}
96
97struct ReclaimPass {
99 packages_deleted: u64,
100 stuck: u64,
103}
104
105enum ReclaimStep {
107 Advanced,
109 Deleted,
111 Idle,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum StorePackageReclaimCoverage {
118 Snapshot {
120 snapshot: coven_protocol::store_commit::AcceptedStoreSnapshotRef,
121 },
122 NoSnapshot,
124 NotOwner,
126 InputsUnchanged,
130}
131
132#[derive(Debug, thiserror::Error)]
133pub enum StoreReclaimError {
134 #[error(transparent)]
135 Object(#[from] StoreObjectError),
136 #[error(transparent)]
137 Database(#[from] coven_database::DbError),
138 #[error(transparent)]
139 Outbound(#[from] crate::sync::store::StoreError),
140 #[error("Store reclaim journal: {0}")]
141 Journal(#[from] StoreReclaimJournalError),
142 #[error(transparent)]
143 Storage(#[from] StorageError),
144 #[error("no authorized complete Store snapshot is available for reclamation")]
145 NoSnapshot,
146 #[error("snapshot authorization history is invalid: {0}")]
147 Authorization(String),
148 #[error("snapshot authorization Store pull: {0}")]
149 StorePull(#[source] Box<crate::sync::store::pull::StorePullError>),
150 #[error("snapshot authorization Store protocol: {0}")]
151 Protocol(#[from] coven_protocol::store_commit::StoreProtocolError),
152 #[error("snapshot authorization audience package: {0}")]
153 AudiencePackage(#[from] coven_protocol::audience_package::AudiencePackageError),
154 #[error("snapshot authorization snapshot: {0}")]
155 Snapshot(#[source] Box<crate::sync::store::SnapshotError>),
156 #[error("snapshot authorization acknowledgement: {0}")]
157 Acknowledgement(#[source] Box<crate::sync::store::StoreAckError>),
158 #[error("snapshot authorization writer: {0}")]
159 WriterAuthorization(#[source] Box<crate::sync::store::StoreWriterAuthorizationError>),
160 #[error("exact Store ancestry is missing commit {commit_hash}")]
161 MissingAncestry { commit_hash: ObjectHash },
162 #[error("deleting exact object {object} failed: {source}")]
163 Delete {
164 object: ObjectHash,
165 #[source]
166 source: StorageError,
167 },
168}
169
170impl From<crate::sync::store::pull::CommitCoverageError> for StoreReclaimError {
171 fn from(error: crate::sync::store::pull::CommitCoverageError) -> Self {
172 match error {
173 crate::sync::store::pull::CommitCoverageError::Object(error) => Self::Object(error),
174 crate::sync::store::pull::CommitCoverageError::MissingAncestry { commit_hash } => {
175 Self::MissingAncestry { commit_hash }
176 }
177 }
178 }
179}
180
181impl From<crate::sync::store::pull::StorePullError> for StoreReclaimError {
182 fn from(error: crate::sync::store::pull::StorePullError) -> Self {
183 Self::StorePull(Box::new(error))
184 }
185}
186
187impl From<crate::sync::store::SnapshotError> for StoreReclaimError {
188 fn from(error: crate::sync::store::SnapshotError) -> Self {
189 Self::Snapshot(Box::new(error))
190 }
191}
192
193impl From<crate::sync::store::StoreAckError> for StoreReclaimError {
194 fn from(error: crate::sync::store::StoreAckError) -> Self {
195 Self::Acknowledgement(Box::new(error))
196 }
197}
198
199impl From<crate::sync::store::StoreWriterAuthorizationError> for StoreReclaimError {
200 fn from(error: crate::sync::store::StoreWriterAuthorizationError) -> Self {
201 Self::WriterAuthorization(Box::new(error))
202 }
203}
204
205use candidates::*;
206
207pub(crate) struct AuthorizedReclaim<'operation, 'storage> {
208 writer: &'operation mut AuthorizedWriterOperation<'storage>,
209 database: StoreDatabase,
210 storage: Arc<dyn CloudSyncObjectStorage>,
211 root: StoreRootRef,
212 membership: coven_protocol::membership::MembershipChain,
213}
214
215impl<'operation, 'storage> AuthorizedReclaim<'operation, 'storage> {
216 pub(crate) fn new(
217 writer: &'operation mut AuthorizedWriterOperation<'storage>,
218 database: StoreDatabase,
219 storage: Arc<dyn CloudSyncObjectStorage>,
220 root: StoreRootRef,
221 membership: coven_protocol::membership::MembershipChain,
222 ) -> Self {
223 Self {
224 writer,
225 database,
226 storage,
227 root,
228 membership,
229 }
230 }
231
232 fn history(&mut self) -> ReclaimHistory<'_, 'storage> {
233 self.writer.reclaim_history()
234 }
235
236 pub(super) async fn run(
237 &mut self,
238 settled: &crate::sync::store::SettledCycle,
239 ) -> Result<StoreReclaimResult, StoreReclaimError> {
240 let database = self.database.clone();
241 let membership = self.membership.clone();
242 let blob_claims = Box::pin(self.audience_blob_reclaim_claims()).await?;
248 let blob_packages = blob_claims
249 .iter()
250 .filter_map(|claim| match claim {
251 ReclaimClaim::AudienceBlob(AudienceBlobReclaimClaim {
252 target: AudienceBlobReclaimTarget::Circle { source, .. },
253 }) => Some(coven_protocol::remote_object::remote_object_id(
254 &source.package.package.object,
255 )),
256 _ => None,
257 })
258 .collect::<BTreeSet<_>>();
259 let mut journal = Box::pin(self.resume_operations(&blob_packages)).await?;
264 let mut packages_deleted = journal.packages_deleted;
265 if !self.writer.is_current_owner(&membership) {
266 return Ok(StoreReclaimResult {
267 packages_deleted,
268 physical_copies_deleted: packages_deleted,
269 stuck: journal.stuck,
270 store_packages: StorePackageReclaimReport::declined(
271 StorePackageReclaimCoverage::NotOwner,
272 ),
273 });
274 }
275 for claim in blob_claims {
276 let (_, resumed) = Box::pin(self.authorize_and_resume(claim, &blob_packages)).await?;
277 packages_deleted = packages_deleted
278 .checked_add(resumed.packages_deleted)
279 .ok_or_else(|| {
280 StoreReclaimError::Authorization(
281 "reclaimed package count exceeded u64".to_string(),
282 )
283 })?;
284 journal.stuck = resumed.stuck;
285 }
286 let resumed = Box::pin(self.resume_operations(&BTreeSet::new())).await?;
287 packages_deleted = packages_deleted
288 .checked_add(resumed.packages_deleted)
289 .ok_or_else(|| {
290 StoreReclaimError::Authorization("reclaimed package count exceeded u64".to_string())
291 })?;
292 journal.stuck = resumed.stuck;
293 let inputs = crate::sync::store::CycleInputs::read(&database, &membership)
298 .await
299 .map_err(StoreReclaimError::Database)?;
300 if settled.reclaim_evaluated(&inputs) {
301 return Ok(StoreReclaimResult {
302 packages_deleted,
303 physical_copies_deleted: packages_deleted,
304 stuck: journal.stuck,
305 store_packages: StorePackageReclaimReport::declined(
306 StorePackageReclaimCoverage::InputsUnchanged,
307 ),
308 });
309 }
310 let registrations = database
311 .activated_store_device_registration_records()
312 .await
313 .map_err(StoreReclaimError::from)?;
314 let mut artifacts_deleted = 0;
317 let (coverage, store_targets) = match Box::pin(self.choose_snapshot()).await {
318 Ok(claim) => {
319 let snapshot = claim.reference();
320 let plan = self.writer.prepare_plan().await?;
321 let resolved = plan.membership().resolved();
322 if plan.owner_grant().is_some()
323 && plan
324 .effective_provider_admin_grant(resolved.provider_admin.combined_state())
325 .is_some()
326 {
327 artifacts_deleted = self.retire_snapshot_artifacts(&claim, &plan).await?;
328 } else {
329 tracing::debug!(
330 "skip accepted snapshot artifact deletion: this device lacks provider administration authority"
331 );
332 }
333 let targets = claim
334 .snapshot()
335 .meta
336 .history_summary
337 .reclaim
338 .packages
339 .values()
340 .filter_map(|target| match &target.package {
341 AudienceBlobBindingPackage::Store(package) => {
342 Some((target.activation.clone(), package.clone()))
343 }
344 AudienceBlobBindingPackage::Circle(_) => None,
345 })
346 .collect::<Vec<_>>();
347 (StorePackageReclaimCoverage::Snapshot { snapshot }, targets)
348 }
349 Err(StoreReclaimError::NoSnapshot) => {
354 (StorePackageReclaimCoverage::NoSnapshot, Vec::new())
355 }
356 Err(error) => return Err(error),
357 };
358 let mut store_packages = StorePackageReclaimReport::declined(coverage);
359 store_packages.targets_considered = store_targets.len() as u64;
360 for (commit, package) in store_targets {
361 if database
362 .store_package_is_retained_for_replay(
363 self.root.clone(),
364 package.clone(),
365 commit.clone(),
366 )
367 .await?
368 {
369 store_packages.retained_for_replay += 1;
370 continue;
371 }
372 let (authorized, resumed) = Box::pin(self.authorize_and_resume(
373 ReclaimClaim::StorePackage(StorePackageReclaimClaim {
374 target: StorePackageReclaimTarget {
375 package,
376 activation: commit,
377 },
378 }),
379 &BTreeSet::new(),
380 ))
381 .await?;
382 packages_deleted = packages_deleted
383 .checked_add(resumed.packages_deleted)
384 .ok_or_else(|| {
385 StoreReclaimError::Authorization(
386 "reclaimed package count exceeded u64".to_string(),
387 )
388 })?;
389 match authorized {
390 AuthorizationOutcome::Signed => store_packages.authorized += 1,
391 AuthorizationOutcome::AlreadyJournalled => store_packages.already_authorized += 1,
392 }
393 }
394 let circle_deleted = Box::pin(self.prepare_circle_authorizations(®istrations)).await?;
395 packages_deleted = packages_deleted
396 .checked_add(circle_deleted)
397 .ok_or_else(|| {
398 StoreReclaimError::Authorization("reclaimed package count exceeded u64".to_string())
399 })?;
400 let final_journal = Box::pin(self.resume_operations(&BTreeSet::new())).await?;
401 packages_deleted = packages_deleted
402 .checked_add(final_journal.packages_deleted)
403 .ok_or_else(|| {
404 StoreReclaimError::Authorization("reclaimed package count exceeded u64".to_string())
405 })?;
406 settled.record_reclaim_evaluated(inputs);
409 Ok(StoreReclaimResult {
410 packages_deleted,
411 physical_copies_deleted: packages_deleted.checked_add(artifacts_deleted).ok_or_else(
412 || StoreReclaimError::Authorization("retired object count exceeded u64".into()),
413 )?,
414 stuck: final_journal.stuck,
415 store_packages,
416 })
417 }
418
419 async fn prepare_beyond_cutoff_circle_authorizations(
420 &mut self,
421 circle_id: CircleId,
422 current_control: &CircleControlCoord,
423 ) -> Result<u64, StoreReclaimError> {
424 let database = self.database.clone();
425 let root = self.root.clone();
426 let successor = database
427 .verified_circle_activation(root.clone(), circle_id, current_control.clone())
428 .await?
429 .ok_or_else(|| {
430 StoreReclaimError::Authorization(format!(
431 "Circle {circle_id} current control is not a retained activation"
432 ))
433 })?;
434 if !matches!(
437 successor.control.value.state(),
438 CircleControlState::ActiveEpoch(active)
439 if matches!(active.common.origin, CircleEpochOrigin::Closed { .. })
440 ) {
441 return Ok(0);
442 }
443 let mut packages_deleted = 0_u64;
444 let frontier = CommitFrontier::from_refs(database.materialized_frontier().await?)
445 .map_err(StoreReclaimError::from)?;
446 let epochs = database.circle_replay_epoch_index(root.clone()).await?;
447 let targets = self
448 .history()
449 .circle_package_targets(circle_id, &frontier)
450 .await
451 .map_err(StoreReclaimError::from)?;
452 for (commit, package) in targets {
453 if epochs
457 .permits(&commit, circle_id, &package.control)
458 .map_err(StoreReclaimError::from)?
459 {
460 continue;
461 }
462 if database
463 .circle_package_is_retained_for_replay(
464 root.clone(),
465 package.clone(),
466 commit.clone(),
467 )
468 .await?
469 || database
470 .package_is_retained_by_pending_blob_reclaim(package.package.object.clone())
471 .await?
472 {
473 continue;
474 }
475 let (_, resumed) = Box::pin(self.authorize_and_resume(
476 ReclaimClaim::CirclePackage(CirclePackageReclaimClaim::BeyondEpochCutoff(
477 CirclePackageBeyondCutoffClaim {
478 target: CirclePackageReclaimTarget {
479 package,
480 activation: commit,
481 },
482 successor_control: current_control.clone(),
483 },
484 )),
485 &BTreeSet::new(),
486 ))
487 .await?;
488 packages_deleted = packages_deleted
489 .checked_add(resumed.packages_deleted)
490 .ok_or_else(|| {
491 StoreReclaimError::Authorization(
492 "reclaimed package count exceeded u64".to_string(),
493 )
494 })?;
495 }
496 Ok(packages_deleted)
497 }
498
499 async fn audience_blob_reclaim_claims(
500 &mut self,
501 ) -> Result<Vec<ReclaimClaim>, StoreReclaimError> {
502 let database = self.database.clone();
503 let mut claims = Vec::new();
504 for (blob, owners) in database.stored_blob_reclaim_candidates().await? {
505 if !database.stored_blob_is_row_orphaned(blob.clone()).await? {
506 continue;
507 }
508 if database
509 .audience_blob_is_retained_for_replay(blob.clone())
510 .await?
511 {
512 continue;
513 }
514 if blob.locator().audience() == coven_protocol::blob::locator::RemoteAudience::Store {
515 if self.history().store_blob_is_reclaimable(&blob).await? {
516 claims.push(ReclaimClaim::AudienceBlob(AudienceBlobReclaimClaim {
517 target: AudienceBlobReclaimTarget::Store { blob },
518 }));
519 }
520 continue;
521 }
522 let mut binding = None;
526 for owner in &owners {
527 let commit = self
528 .history()
529 .load_ref(owner)
530 .await
531 .map_err(StoreReclaimError::from)?;
532 if let Some(package) =
533 audience_blob_binding_package(commit.value(), blob.locator().audience())
534 {
535 binding = Some((package, owner.clone()));
536 break;
537 }
538 }
539 let Some((AudienceBlobBindingPackage::Circle(package), activation)) = binding else {
540 tracing::debug!(
541 blob = %coven_protocol::remote_object::remote_object_id(blob.object()),
542 "skip orphaned blob whose owning commits name no package for its audience",
543 );
544 continue;
545 };
546 let target = AudienceBlobReclaimTarget::Circle {
547 blob,
548 source: CirclePackageReclaimTarget {
549 package,
550 activation,
551 },
552 };
553 claims.push(ReclaimClaim::AudienceBlob(AudienceBlobReclaimClaim {
554 target,
555 }));
556 }
557 Ok(claims)
558 }
559
560 async fn prepare_circle_snapshot_image_authorizations(
561 &mut self,
562 circle_id: CircleId,
563 streams: &[CircleSnapshotStream],
564 stable: &[SelectedCircleSnapshot],
565 ) -> Result<u64, StoreReclaimError> {
566 let database = self.database.clone();
567 let mut packages_deleted = 0_u64;
568 for stream in streams {
569 for (reference, meta) in &stream.generations {
570 let Some(superseding) = stable.iter().find(|candidate| {
571 candidate.author_registration == stream.author_registration
572 && candidate.reference.generation > reference.generation
573 && snapshot_supersedes_seed(
574 &candidate.meta.bootstrap.coverage,
575 &meta.bootstrap.coverage,
576 )
577 }) else {
578 continue;
579 };
580 let target = CircleSnapshotImageReclaimTarget {
581 circle_id,
582 snapshot_author: stream.author_registration.clone(),
583 control: meta.control.clone(),
584 snapshot: reference.clone(),
585 image: meta.bootstrap.image.clone(),
586 };
587 if database
588 .circle_image_is_retained_for_replay(circle_id, target.image.clone())
589 .await?
590 {
591 continue;
592 }
593 let (_, resumed) = Box::pin(self.authorize_and_resume(
594 ReclaimClaim::CircleSnapshotImage(CircleSnapshotImageReclaimClaim {
595 target,
596 superseding: superseding.reference.clone(),
597 }),
598 &BTreeSet::new(),
599 ))
600 .await?;
601 packages_deleted = packages_deleted
602 .checked_add(resumed.packages_deleted)
603 .ok_or_else(|| {
604 StoreReclaimError::Authorization(
605 "reclaimed package count exceeded u64".to_string(),
606 )
607 })?;
608 }
609 }
610 Ok(packages_deleted)
611 }
612
613 async fn prepare_circle_authorizations(
614 &mut self,
615 registrations: &[coven_protocol::store_commit::ReferencedStoreDeviceRegistration],
616 ) -> Result<u64, StoreReclaimError> {
617 let database = self.database.clone();
618 let mut packages_deleted = 0_u64;
619 for input in database.circle_acknowledgement_publication_inputs().await? {
620 let circle_id = input.circle_id();
621 let control = input.control().clone();
622 packages_deleted = packages_deleted
626 .checked_add(
627 Box::pin(self.prepare_beyond_cutoff_circle_authorizations(circle_id, &control))
628 .await?,
629 )
630 .ok_or_else(|| {
631 StoreReclaimError::Authorization(
632 "reclaimed package count exceeded u64".to_string(),
633 )
634 })?;
635 let streams = self
639 .history()
640 .load_circle_snapshot_streams(circle_id, &control, registrations)
641 .await?;
642 let stable = self
643 .history()
644 .stable_circle_snapshots(circle_id, &streams)
645 .await?;
646 let selected = maximal_stable_circle_snapshot(&stable);
647 packages_deleted = packages_deleted
648 .checked_add(
649 Box::pin(
650 self.prepare_circle_bootstrap_authorizations(circle_id, &control, selected),
651 )
652 .await?,
653 )
654 .ok_or_else(|| {
655 StoreReclaimError::Authorization(
656 "reclaimed package count exceeded u64".to_string(),
657 )
658 })?;
659 packages_deleted = packages_deleted
662 .checked_add(
663 Box::pin(self.prepare_circle_snapshot_image_authorizations(
664 circle_id, &streams, &stable,
665 ))
666 .await?,
667 )
668 .ok_or_else(|| {
669 StoreReclaimError::Authorization(
670 "reclaimed package count exceeded u64".to_string(),
671 )
672 })?;
673 let Some(selected) = selected else {
674 continue;
675 };
676 let targets = self
677 .history()
678 .circle_package_targets(circle_id, &selected.meta.bootstrap.coverage)
679 .await
680 .map_err(StoreReclaimError::from)?;
681 for (commit, package) in targets {
682 if database
683 .circle_package_is_retained_for_replay(
684 self.root.clone(),
685 package.clone(),
686 commit.clone(),
687 )
688 .await?
689 || database
690 .package_is_retained_by_pending_blob_reclaim(package.package.object.clone())
691 .await?
692 {
693 continue;
694 }
695 let (_, resumed) = Box::pin(self.authorize_and_resume(
696 ReclaimClaim::CirclePackage(CirclePackageReclaimClaim::SnapshotCovered(
697 CirclePackageSnapshotCoverageClaim {
698 target: CirclePackageReclaimTarget {
699 package,
700 activation: commit,
701 },
702 covering_snapshot: CircleSnapshotLocator {
703 author_registration: selected.author_registration.clone(),
704 circle_id,
705 control: selected.meta.control.clone(),
706 snapshot: selected.reference.clone(),
707 },
708 acknowledgements: selected.acknowledgements.clone(),
709 },
710 )),
711 &BTreeSet::new(),
712 ))
713 .await?;
714 packages_deleted = packages_deleted
715 .checked_add(resumed.packages_deleted)
716 .ok_or_else(|| {
717 StoreReclaimError::Authorization(
718 "reclaimed package count exceeded u64".to_string(),
719 )
720 })?;
721 }
722 }
723 Ok(packages_deleted)
724 }
725
726 async fn prepare_circle_bootstrap_authorizations(
727 &mut self,
728 circle_id: CircleId,
729 current_control: &CircleControlCoord,
730 selected: Option<&SelectedCircleSnapshot>,
731 ) -> Result<u64, StoreReclaimError> {
732 let database = self.database.clone();
733 let root = self.root.clone();
734 let mut packages_deleted = 0_u64;
735 let roster = database.circle_current_roster_members(circle_id).await?;
736 let stable_cut = selected.map(|selected| &selected.meta.bootstrap.coverage);
740 for acknowledgement in database.activated_circle_acks(circle_id).await? {
741 let ack = match self
742 .history()
743 .load_circle_acknowledgement(&acknowledgement)
744 .await
745 {
746 Ok(ack) => ack,
747 Err(error) => {
748 tracing::debug!(
749 circle_id = %circle_id,
750 "skip Circle acknowledgement for bootstrap reclaim: {error}"
751 );
752 continue;
753 }
754 };
755 let Some(coverage) = ack.seeded_from.clone() else {
756 continue;
758 };
759 let recipient = database
760 .activated_store_device_registration(acknowledgement.registration.clone())
761 .await?;
762 let recipient_active = roster.contains(&recipient.value().author_pubkey);
763 let seed = &coverage.bootstrap.coverage;
764 let superseded_by_snapshot = stable_cut
765 .as_ref()
766 .is_some_and(|cut| snapshot_supersedes_seed(cut, seed));
767 let proof = if recipient_active {
768 if superseded_by_snapshot {
769 CircleBootstrapReclaimProof::RecipientCoverage {
770 acknowledgement: acknowledgement.clone(),
771 }
772 } else {
773 continue;
775 }
776 } else if database
777 .circle_control_covers_strictly(
778 root.clone(),
779 circle_id,
780 current_control,
781 &coverage.control,
782 )
783 .await?
784 {
785 CircleBootstrapReclaimProof::LostAuthority {
786 acknowledgement: acknowledgement.clone(),
787 successor_control: current_control.clone(),
788 }
789 } else {
790 continue;
791 };
792 let target = CircleBootstrapImageReclaimTarget { coverage };
793 if database
794 .circle_bootstrap_image_is_retained_for_replay(target.coverage.clone())
795 .await?
796 {
797 continue;
798 }
799 let (_, resumed) = Box::pin(self.authorize_and_resume(
800 ReclaimClaim::CircleBootstrapImage(CircleBootstrapImageReclaimClaim {
801 target,
802 proof,
803 }),
804 &BTreeSet::new(),
805 ))
806 .await?;
807 packages_deleted = packages_deleted
808 .checked_add(resumed.packages_deleted)
809 .ok_or_else(|| {
810 StoreReclaimError::Authorization(
811 "reclaimed package count exceeded u64".to_string(),
812 )
813 })?;
814 }
815 Ok(packages_deleted)
816 }
817
818 async fn prepare_authorization(
819 &mut self,
820 claim: ReclaimClaim,
821 ) -> Result<AuthorizationOutcome, StoreReclaimError> {
822 let database = self.database.clone();
823 let root = self.root.clone();
824 let target = claim.target();
825 if database
826 .store_reclaim_operations()
827 .await?
828 .iter()
829 .any(|operation| operation.authorization().target() == &target)
830 {
831 return Ok(AuthorizationOutcome::AlreadyJournalled);
832 }
833 let plan = self.writer.prepare_plan().await?;
834 let owner_grant = plan.owner_grant().cloned().ok_or_else(|| {
835 StoreReclaimError::Authorization(
836 "Store reclaim authorization requires an active Owner grant".to_string(),
837 )
838 })?;
839 let evidence = plan
840 .sign_reclaim_evidence(claim)
841 .map_err(StoreReclaimError::from)?;
842 self.verify_evidence(&evidence).await?;
843 let evidence_context = ProtocolObjectContext::store_encrypted(
844 root.store_root_hash,
845 ProtocolObjectDomain::StoreReclaimEvidence,
846 );
847 let evidence_prefix = reclaim_evidence_semantic_prefix(evidence.evidence_hash());
848 let evidence_slot = self
849 .storage
850 .allocate_protocol_slot(&evidence_context, &evidence_prefix, ".json")
851 .await?;
852 let evidence_prepared = self.storage.prepare_protocol_object(
853 &evidence_context,
854 evidence_slot,
855 &evidence_prefix,
856 evidence.to_bytes(),
857 )?;
858 let evidence_ref =
859 ReclaimEvidenceRef::from_evidence(&evidence, evidence_prepared.reference().clone());
860 let authorization = plan.sign_reclaim_authorization(
861 evidence.claim.target(),
862 evidence_ref.clone(),
863 StoreReclaimAuthority {
864 membership: plan.membership_state().clone(),
865 owner_grant,
866 },
867 );
868 let authorization_context = ProtocolObjectContext::signed_plaintext(
869 root.store_root_hash,
870 ProtocolObjectDomain::StoreReclaimAuthorization,
871 );
872 let authorization_prefix =
873 reclaim_authorization_semantic_prefix(authorization.authorization_hash());
874 let authorization_slot = self
875 .storage
876 .allocate_protocol_slot(&authorization_context, &authorization_prefix, ".json")
877 .await?;
878 let authorization_prepared = self.storage.prepare_protocol_object(
879 &authorization_context,
880 authorization_slot,
881 &authorization_prefix,
882 authorization.to_bytes(),
883 )?;
884 let authorization_ref = ReclaimAuthorizationRef::from_authorization(
885 &authorization,
886 authorization_prepared.reference().clone(),
887 );
888 let candidate = self
889 .writer
890 .prepare_candidate(
891 &plan,
892 crate::sync::store::commit_publication::operation::commit_plan::StoreOperationBatch::ReclaimAuthorization(Box::new(
893 authorization_ref.clone(),
894 )),
895 )
896 .await?;
897 let operation = DurableStoreReclaimOperation::AuthorizationCandidate {
898 object: Box::new(DurableStoreReclaimObject::Authorization {
899 evidence_ref,
900 evidence,
901 evidence_prepared,
902 authorization_ref,
903 authorization,
904 authorization_prepared,
905 }),
906 candidate: Box::new(candidate),
907 };
908 Box::pin(database.begin_store_reclaim_operation(operation)).await?;
909 Ok(AuthorizationOutcome::Signed)
910 }
911
912 async fn authorize_and_resume(
913 &mut self,
914 claim: ReclaimClaim,
915 protected_blob_packages: &BTreeSet<ObjectHash>,
916 ) -> Result<(AuthorizationOutcome, ReclaimPass), StoreReclaimError> {
917 let outcome = Box::pin(self.prepare_authorization(claim)).await?;
918 let resumed = Box::pin(self.resume_operations(protected_blob_packages)).await?;
919 Ok((outcome, resumed))
920 }
921
922 async fn resume_operations(
931 &mut self,
932 protected_blob_packages: &BTreeSet<ObjectHash>,
933 ) -> Result<ReclaimPass, StoreReclaimError> {
934 let database = self.database.clone();
935 let mut packages_deleted = 0_u64;
936 loop {
937 let operations = database.runnable_store_reclaim_operations().await?;
938 let mut progressed = false;
939 for operation in operations {
940 let operation_id = operation.operation_id();
941 match Box::pin(self.run_operation(operation, protected_blob_packages)).await {
942 Ok(ReclaimStep::Deleted) => {
943 packages_deleted = packages_deleted.checked_add(1).ok_or_else(|| {
944 StoreReclaimError::Authorization(
945 "reclaimed package count exceeded u64".to_string(),
946 )
947 })?;
948 progressed = true;
949 }
950 Ok(ReclaimStep::Advanced) => progressed = true,
951 Ok(ReclaimStep::Idle) => {}
952 Err(error) if crate::sync::error::error_chain_contains_transport(&error) => {
953 return Err(error)
954 }
955 Err(error) => {
956 tracing::warn!(
957 operation = %operation_id,
958 "Store reclaim operation is stuck until the host asks for it: {error}"
959 );
960 database
961 .mark_store_reclaim_operation_stuck(operation_id, error.to_string())
962 .await?;
963 }
964 }
965 }
966 if !progressed {
967 let stuck = database.stuck_reclaim_operations().await?.len() as u64;
968 return Ok(ReclaimPass {
969 packages_deleted,
970 stuck,
971 });
972 }
973 }
974 }
975
976 async fn run_operation(
978 &mut self,
979 operation: DurableStoreReclaimOperation,
980 protected_blob_packages: &BTreeSet<ObjectHash>,
981 ) -> Result<ReclaimStep, StoreReclaimError> {
982 match &operation {
983 DurableStoreReclaimOperation::AuthorizationCandidate { .. }
984 | DurableStoreReclaimOperation::ReceiptCandidate { .. } => {
985 Box::pin(self.drive_candidate(operation)).await?;
986 Ok(ReclaimStep::Advanced)
987 }
988 DurableStoreReclaimOperation::Authorized { .. } => {
989 if self
995 .deferred_to_blob_reclaim(&operation, protected_blob_packages)
996 .await?
997 {
998 return Ok(ReclaimStep::Idle);
999 }
1000 Box::pin(self.execute_delete(operation)).await?;
1001 Ok(ReclaimStep::Deleted)
1002 }
1003 DurableStoreReclaimOperation::AbsentVerified { .. } => {
1004 Box::pin(self.prepare_receipt(operation)).await?;
1005 Ok(ReclaimStep::Advanced)
1006 }
1007 DurableStoreReclaimOperation::Completed { .. } => Ok(ReclaimStep::Idle),
1008 }
1009 }
1010
1011 async fn deferred_to_blob_reclaim(
1014 &self,
1015 operation: &DurableStoreReclaimOperation,
1016 protected_blob_packages: &BTreeSet<ObjectHash>,
1017 ) -> Result<bool, StoreReclaimError> {
1018 let package = match operation.authorization().target() {
1019 ReclaimTarget::StorePackage(target) => target.package.object.clone(),
1020 ReclaimTarget::CirclePackage(target) => target.package.package.object.clone(),
1021 _ => return Ok(false),
1022 };
1023 if protected_blob_packages
1024 .contains(&coven_protocol::remote_object::remote_object_id(&package))
1025 {
1026 return Ok(true);
1027 }
1028 Ok(self
1029 .database
1030 .package_is_retained_by_pending_blob_reclaim(package)
1031 .await?)
1032 }
1033
1034 async fn execute_delete(
1035 &mut self,
1036 operation: DurableStoreReclaimOperation,
1037 ) -> Result<(), StoreReclaimError> {
1038 let database = self.database.clone();
1039 let DurableStoreReclaimOperation::Authorized {
1040 authorization,
1041 activation,
1042 } = &operation
1043 else {
1044 return Err(StoreReclaimError::Authorization(
1045 "only an authorized reclaim can delete its target".to_string(),
1046 ));
1047 };
1048 let target = self.verify_authorized(authorization, activation).await?;
1049 if self.target_is_retained(&target).await? {
1050 return Err(StoreReclaimError::Authorization(
1051 "reclaim target remains retained for accepted replay".to_string(),
1052 ));
1053 }
1054 match &target {
1057 ReclaimTarget::AudienceBlob(blob) => self.storage.delete_blob_object(blob.blob()).await,
1058 _ => self.storage.delete_protocol_object(target.object()).await,
1059 }
1060 .map_err(|source| StoreReclaimError::Delete {
1061 object: coven_protocol::remote_object::remote_object_id(target.object()),
1062 source,
1063 })?;
1064 self.verify_target_absent(&target).await?;
1065 database
1066 .mark_store_reclaim_target_absent(operation, target)
1067 .await?;
1068 Ok(())
1069 }
1070}
1071
1072#[cfg(test)]
1073mod audience_blob_order_tests;
1074#[cfg(test)]
1075mod authorization_tests;
1076#[cfg(test)]
1077mod snapshot_retirement_tests;
1078#[cfg(test)]
1079mod tests;
1080
1081pub(crate) async fn create_reclaim_exact_objects(
1082 object: &coven_database::DurableStoreReclaimObject,
1083 storage: &dyn CloudSyncObjectStorage,
1084) -> Result<(), StoreReclaimJournalError> {
1085 match object {
1086 coven_database::DurableStoreReclaimObject::Authorization {
1087 evidence,
1088 evidence_prepared,
1089 authorization,
1090 authorization_prepared,
1091 ..
1092 } => {
1093 storage
1094 .create_verified_protocol_object(
1095 &ProtocolObjectContext::store_encrypted(
1096 evidence.store_root_hash,
1097 ProtocolObjectDomain::StoreReclaimEvidence,
1098 ),
1099 evidence_prepared,
1100 &reclaim_evidence_semantic_prefix(evidence.evidence_hash()),
1101 &evidence.to_bytes(),
1102 )
1103 .await?;
1104 storage
1105 .create_verified_protocol_object(
1106 &ProtocolObjectContext::signed_plaintext(
1107 authorization.store_root_hash,
1108 ProtocolObjectDomain::StoreReclaimAuthorization,
1109 ),
1110 authorization_prepared,
1111 &reclaim_authorization_semantic_prefix(authorization.authorization_hash()),
1112 &authorization.to_bytes(),
1113 )
1114 .await
1115 .map_err(StoreReclaimJournalError::Storage)
1116 }
1117 coven_database::DurableStoreReclaimObject::Receipt {
1118 receipt,
1119 receipt_prepared,
1120 ..
1121 } => storage
1122 .create_verified_protocol_object(
1123 &ProtocolObjectContext::signed_plaintext(
1124 receipt.store_root_hash,
1125 ProtocolObjectDomain::StoreReclaimReceipt,
1126 ),
1127 receipt_prepared,
1128 &reclaim_receipt_semantic_prefix(receipt.receipt_hash()),
1129 &receipt.to_bytes(),
1130 )
1131 .await
1132 .map_err(StoreReclaimJournalError::Storage),
1133 }
1134}