Skip to main content

coven_replication/sync/store/reclaim/
mod.rs

1//! Exact object reclamation after accepted Store snapshot publication.
2//! Store retirement follows the accepted boundary. Circle snapshots retain
3//! their separate access acknowledgement requirements.
4
5use 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    /// Operations the journal is left holding for a person: they failed with an
34    /// error running them again cannot change, so every later cycle skips them
35    /// until the host asks for one back.
36    pub stuck: u64,
37    /// What the Store-package leg did, so a run that deleted nothing says which
38    /// step declined instead of reporting a bare zero. The leg is the one whose
39    /// outcome was previously unobservable: its two commonest declines are
40    /// turned into an empty target list on purpose, so that Store trouble does
41    /// not block Circle reclaim, and that swallowed the reason with the error.
42    pub store_packages: StorePackageReclaimReport,
43}
44
45/// What the Store-package leg of one reclaim run considered and what it did.
46///
47/// Counts rather than per-target lines: a store with hundreds of covered
48/// commits would drown a cycle in log spam, and the question a reader has is
49/// which step the targets died at, not which target.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct StorePackageReclaimReport {
52    /// The coverage the leg had to work from, or why it had none.
53    pub coverage: StorePackageReclaimCoverage,
54    /// Package-bearing commits at or behind the coverage.
55    pub targets_considered: u64,
56    /// Targets left alone because a retained materialization still pins them
57    /// for replay. A run where this equals `targets_considered` is one whose
58    /// retained set has not been narrowed by a snapshot image projection.
59    pub retained_for_replay: u64,
60    /// Targets that already have a journalled operation, which blocks
61    /// re-authorizing them.
62    pub already_authorized: u64,
63    /// Targets this run signed a fresh authorization for.
64    pub authorized: u64,
65}
66
67/// The coverage the Store-package leg worked from, or why it had none.
68///
69/// A decline is a value here rather than a swallowed error because it is the
70/// leg's ordinary outcome, not a failure: `run` deliberately continues to the
71/// Circle legs when the Store leg has no coverage, and reporting the reason is
72/// the only way a reader can tell that apart from having nothing to delete.
73impl StorePackageReclaimReport {
74    /// A report for a leg that has not looked at any target yet — the shape a
75    /// declined leg keeps, and the starting point for one that proceeds.
76    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
87/// Whether a claim reached the provider or found its target already journalled.
88///
89/// An existing operation for a target blocks re-authorizing it, so the two are
90/// worth telling apart: one is progress, the other is a target this run could
91/// not have acted on however it was configured.
92enum AuthorizationOutcome {
93    Signed,
94    AlreadyJournalled,
95}
96
97/// What one pass over the journal did.
98struct ReclaimPass {
99    packages_deleted: u64,
100    /// Operations the journal holds stuck when the pass ended — the ones it
101    /// marked plus the ones an earlier pass did.
102    stuck: u64,
103}
104
105/// What advancing one journalled operation by one step did.
106enum ReclaimStep {
107    /// The operation moved to its next durable state.
108    Advanced,
109    /// The operation deleted its target.
110    Deleted,
111    /// Nothing to do this pass: the operation is finished, or it waits behind
112    /// a blob reclaim that still has to re-read the package it deletes.
113    Idle,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum StorePackageReclaimCoverage {
118    /// The exact accepted snapshot the leg deleted behind.
119    Snapshot {
120        snapshot: coven_protocol::store_commit::AcceptedStoreSnapshotRef,
121    },
122    /// No accepted Store snapshot is available.
123    NoSnapshot,
124    /// This device is not the current owner, so it does not reclaim at all.
125    NotOwner,
126    /// Nothing this evaluation depends on has changed since the last one, so
127    /// its answer is the last one. The steady state of a settled store, and the
128    /// only outcome here that reaches the provider not at all.
129    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        // Discover every blob reclaim before resuming old package work. A blob
243        // reclaim re-reads the package that published it; the in-memory set
244        // protects newly discovered claims until their durable operations are
245        // published, while already journalled claims remain protected by the
246        // database query in `deferred_to_blob_reclaim`.
247        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        // Journalled work next, always. An operation this device authorized and
260        // did not finish is durable state waiting on its author, and gating that
261        // behind "did anything change" would leave it waiting on an unrelated
262        // event.
263        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        // The evaluation below walks every candidate snapshot's stability and
294        // every device's acknowledgement chain. Its answer is a function of
295        // facts this database holds, so running it again against the same ones
296        // spends the provider to reach a conclusion already reached.
297        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        // A missing or unstable Store snapshot leaves Store packages uncovered but must
315        // not block Circle package reclamation, which carries its own Circle coverage.
316        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            // Store trouble must not block Circle reclamation, which carries
350            // its own Circle coverage — so these two do not propagate. The
351            // reason travels in the report instead of dying here, which is what
352            // makes a cycle that deleted nothing say why.
353            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(&registrations)).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        // Recorded only once the evaluation has run all the way through, so a
407        // run that failed partway is re-run rather than remembered as settled.
408        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        // Only a control that closed a predecessor epoch carries a cutoff; a Circle
435        // whose current epoch closed nothing has no beyond-cutoff package to enumerate.
436        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            // `permits` is the same predicate the pull path applies; a package it
454            // accepts is live history. A package whose control it cannot resolve, or
455            // that conflicts with the cutoff, errors rather than being reclaimed.
456            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            // Which owning commit's package carries the binding is the one thing no
523            // local state records — the audience picks the package within a commit, but
524            // not which commit. Probe only that dimension.
525            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            // A package beyond its epoch's accepted cutoff never materializes anywhere,
623            // so it needs no snapshot coverage and is enumerated whether or not this
624            // Circle has a stable snapshot.
625            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            // Both remaining passes read the same evidence: every device's snapshot
636            // stream and which of its generations every active-access device has
637            // acknowledged. Read it once.
638            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            // A superseded snapshot generation's image is reclaimable on its own
660            // stream's evidence, independent of which snapshot covers the packages.
661            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        // The maximal acknowledgement-stable Circle snapshot cut, if any. A seed a
737        // still-active recipient holds is superseded only when this cut strictly
738        // dominates it — a later sufficient snapshot every active device acknowledged.
739        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                // A founder/source device never seeded from an image — nothing to reclaim.
757                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                    // No later sufficient snapshot supersedes the recipient's live seed.
774                    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    /// Run every operation the journal holds that a cycle may still run.
923    ///
924    /// A deterministic failure — anything whose error chain carries no
925    /// transport fault — is final for that one operation: running it again
926    /// reaches the same refusal, so it is marked stuck and the pass carries on
927    /// with the operations behind it. A transport failure is the opposite: it
928    /// says nothing about the operation, so the pass ends and the loop's
929    /// backoff brings the whole thing round again.
930    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    /// Advance one journalled operation by one durable step.
977    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                // A package a pending blob reclaim still has to re-read waits
990                // its turn: the blob operation runs in this same pass or a
991                // later one, and the package goes after it. Not an error — the
992                // journal holds both, and the order between them is the only
993                // thing being decided.
994                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    /// Whether `operation` deletes a package that a pending blob reclaim still
1012    /// names as the one that published its blob.
1013    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        // A row blob has no protocol domain: it is addressed by its locator, so its
1055        // exact delete goes through the blob primitive rather than the protocol one.
1056        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}