Skip to main content

laminar_core/cluster/control/
snapshot.rs

1//! Durable vnode→instance assignment snapshots. One object per
2//! version at `control/assignment-snapshots/v{N:020}.json`. Chitchat
3//! carries the ephemeral copy; these files survive full-cluster
4//! restart.
5//!
6//! Rotation and drain finalization use `PutMode::Create` on separate per-version paths. The
7//! append-only winner works on every backend, including `LocalFileSystem`, without relying on
8//! conditional overwrite support.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::sync::Arc;
12
13use bytes::Bytes;
14use object_store::path::Path as OsPath;
15use object_store::{ObjectStore, ObjectStoreExt, PutMode, PutOptions, PutPayload};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use tokio_stream::StreamExt;
19
20use crate::checkpoint::{
21    AssignmentDrainTransition, CheckpointAssignmentFence, CheckpointParticipant, LeaderProof,
22    MAX_CHECKPOINT_PARTICIPANTS,
23};
24use crate::cluster::discovery::NodeId;
25use crate::state::{KeyGroupCount, PARTITIONING_ABI_VERSION};
26
27const SNAPSHOT_PREFIX: &str = "control/assignment-snapshots/";
28const RECOVERY_PROPOSAL_PREFIX: &str = "control/assignment-recovery-proposals/v1/";
29const RECOVERY_MATERIALIZATION_RELATIVE_PREFIX: &str = "recovery-materializations/v1/";
30const RECOVERY_MATERIALIZATION_PREFIX: &str =
31    "control/assignment-snapshots/recovery-materializations/v1/";
32const DRAIN_FINALIZATION_PREFIX: &str = "control/assignment-drain-finalizations/";
33const SNAPSHOT_VERSION_WIDTH: usize = 20;
34const DRAIN_FINALIZATION_VERSION: u16 = 1;
35const RECOVERY_MATERIALIZATION_VERSION: u16 = 1;
36const MAX_RECOVERY_PROPOSAL_BYTES: usize = 8 * 1024 * 1024;
37const MAX_RECOVERY_MATERIALIZATION_BYTES: u64 = 8 * 1024 * 1024 + 1024;
38const RECOVERY_PROPOSAL_GC_BATCH: usize = 64;
39const RECOVERY_PROPOSAL_GC_MAX_BATCHES: usize = 4;
40
41fn snapshot_path(version: u64) -> OsPath {
42    // Fixed-width so lexicographic list order matches numeric order.
43    OsPath::from(format!(
44        "{SNAPSHOT_PREFIX}v{version:0SNAPSHOT_VERSION_WIDTH$}.json"
45    ))
46}
47
48fn drain_finalization_path(version: u64) -> OsPath {
49    OsPath::from(format!(
50        "{DRAIN_FINALIZATION_PREFIX}v{version:0SNAPSHOT_VERSION_WIDTH$}.json"
51    ))
52}
53
54fn recovery_proposal_path(reference: &AssignmentSnapshotRef) -> OsPath {
55    OsPath::from(format!(
56        "{RECOVERY_PROPOSAL_PREFIX}v{:0width$}/sha256={}.json",
57        reference.version,
58        reference.sha256,
59        width = SNAPSHOT_VERSION_WIDTH
60    ))
61}
62
63fn recovery_proposal_version_prefix(version: u64) -> OsPath {
64    OsPath::from(format!(
65        "{RECOVERY_PROPOSAL_PREFIX}v{version:0SNAPSHOT_VERSION_WIDTH$}/"
66    ))
67}
68
69fn recovery_materialization_path(version: u64) -> OsPath {
70    OsPath::from(format!(
71        "{RECOVERY_MATERIALIZATION_PREFIX}v{version:0SNAPSHOT_VERSION_WIDTH$}.json"
72    ))
73}
74
75fn current_time_millis() -> i64 {
76    std::time::SystemTime::now()
77        .duration_since(std::time::UNIX_EPOCH)
78        .map_or(0, |duration| {
79            i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
80        })
81}
82
83fn version_from_file(name: &str, kind: &str, minimum: u64) -> Result<u64, SnapshotError> {
84    let Some(number) = name
85        .strip_prefix('v')
86        .and_then(|name| name.strip_suffix(".json"))
87    else {
88        return Err(SnapshotError::Invalid(format!(
89            "non-canonical {kind} filename {name}"
90        )));
91    };
92    if number.len() != SNAPSHOT_VERSION_WIDTH || !number.bytes().all(|byte| byte.is_ascii_digit()) {
93        return Err(SnapshotError::Invalid(format!(
94            "non-canonical {kind} filename {name}"
95        )));
96    }
97    let version = number.parse::<u64>().map_err(|error| {
98        SnapshotError::Invalid(format!("invalid {kind} filename {name}: {error}"))
99    })?;
100    if version < minimum {
101        return Err(SnapshotError::Invalid(format!(
102            "{kind} version must be at least {minimum}"
103        )));
104    }
105    Ok(version)
106}
107
108/// Durable vnode-to-instance assignment snapshot.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct AssignmentSnapshot {
111    /// Monotonic version. Writers bump on each update.
112    pub version: u64,
113    /// Key encoding, hashing, and key-group mapping contract used by this owner map.
114    pub partitioning_abi_version: u16,
115    /// Vnode id → owning instance. `BTreeMap` (not `Vec`) so snapshots
116    /// with different `vnode_count` are still deserializable — sparse
117    /// indices surface as missing keys the caller can diagnose.
118    pub vnodes: BTreeMap<u32, NodeId>,
119    /// Exact process roster certified for this assignment generation. A stable node restart
120    /// changes this roster and therefore requires a new version even when vnode owners do not.
121    pub participants: Vec<CheckpointParticipant>,
122    /// Wall-clock timestamp of the last update, millis since epoch.
123    pub updated_at_ms: i64,
124    /// Pre-rotation drain phase: when set, this snapshot carries the *intended*
125    /// next assignment but ownership has NOT changed yet. Nodes mark the vnodes
126    /// they are about to lose as draining (pausing those source partitions) so the
127    /// pre-rotation checkpoint is a clean cut; the leader then publishes the same
128    /// assignment with `draining = false` to commit the rotation.
129    #[serde(default)]
130    pub draining: bool,
131    /// Exact predecessor, successor, and durable leader term for a draining generation.
132    /// Present if and only if `draining` is true.
133    #[serde(default)]
134    pub drain_transition: Option<AssignmentDrainTransition>,
135}
136
137/// Exact immutable reference to a staged failure-recovery successor.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct AssignmentSnapshotRef {
141    /// Assignment generation carried by the referenced snapshot.
142    pub version: u64,
143    /// Lowercase hexadecimal SHA-256 of its canonical JSON body.
144    pub sha256: String,
145    /// Exact canonical body length.
146    pub encoded_len: u64,
147}
148
149/// Create-only logical assignment head for an authority-selected recovery proposal.
150///
151/// Recovery uses a separate namespace from graceful-drain intents. A delayed drain writer may
152/// still win the raw snapshot key after losing leadership, but it cannot replace this immutable
153/// materialization winner for the same assignment version.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156struct RecoveryMaterialization {
157    protocol_version: u16,
158    proposal: AssignmentSnapshotRef,
159    snapshot: AssignmentSnapshot,
160}
161
162impl RecoveryMaterialization {
163    fn new(
164        proposal: AssignmentSnapshotRef,
165        snapshot: AssignmentSnapshot,
166    ) -> Result<Self, SnapshotError> {
167        let materialization = Self {
168            protocol_version: RECOVERY_MATERIALIZATION_VERSION,
169            proposal,
170            snapshot,
171        };
172        materialization.validate()?;
173        Ok(materialization)
174    }
175
176    fn validate(&self) -> Result<(), SnapshotError> {
177        self.proposal.validate()?;
178        if self.protocol_version != RECOVERY_MATERIALIZATION_VERSION {
179            return Err(SnapshotError::Invalid(format!(
180                "unsupported recovery materialization version {}",
181                self.protocol_version
182            )));
183        }
184        let (_, actual_reference) = self.snapshot.encode_recovery_proposal()?;
185        if actual_reference != self.proposal {
186            return Err(SnapshotError::Invalid(
187                "recovery materialization body does not match its proposal reference".into(),
188            ));
189        }
190        Ok(())
191    }
192}
193
194impl AssignmentSnapshotRef {
195    /// Validate the reference independently of the staged object.
196    ///
197    /// # Errors
198    /// Rejects a non-successor version, malformed digest, or unsafe encoded length.
199    pub fn validate(&self) -> Result<(), SnapshotError> {
200        if self.version < 2 {
201            return Err(SnapshotError::Invalid(
202                "recovery proposal must be a successor generation".into(),
203            ));
204        }
205        if self.sha256.len() != 64
206            || !self
207                .sha256
208                .bytes()
209                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
210        {
211            return Err(SnapshotError::Invalid(
212                "recovery proposal SHA-256 must be 64 lowercase hexadecimal characters".into(),
213            ));
214        }
215        let encoded_len = usize::try_from(self.encoded_len).map_err(|_| {
216            SnapshotError::Invalid("recovery proposal encoded length exceeds usize".into())
217        })?;
218        if encoded_len == 0 || encoded_len > MAX_RECOVERY_PROPOSAL_BYTES {
219            return Err(SnapshotError::Invalid(format!(
220                "recovery proposal encoded length {} is outside 1..={MAX_RECOVERY_PROPOSAL_BYTES}",
221                self.encoded_len
222            )));
223        }
224        Ok(())
225    }
226}
227
228/// Immutable winner that settles one draining snapshot without changing its certified target
229/// version. The original transition remains append-only at the snapshot path.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(deny_unknown_fields)]
232struct DrainFinalization {
233    protocol_version: u16,
234    transition_digest: [u8; 32],
235    proposal: AssignmentSnapshot,
236}
237
238impl DrainFinalization {
239    fn new(
240        draining: &AssignmentSnapshot,
241        proposal: AssignmentSnapshot,
242    ) -> Result<Self, SnapshotError> {
243        let transition = draining.drain_transition.as_ref().ok_or_else(|| {
244            SnapshotError::Invalid("drain finalization requires a draining transition".into())
245        })?;
246        let finalization = Self {
247            protocol_version: DRAIN_FINALIZATION_VERSION,
248            transition_digest: transition.digest(),
249            proposal,
250        };
251        finalization.validate_against(draining)?;
252        Ok(finalization)
253    }
254
255    fn validate_against(&self, draining: &AssignmentSnapshot) -> Result<(), SnapshotError> {
256        draining.validate()?;
257        self.proposal.validate()?;
258        let transition = draining.drain_transition.as_ref().ok_or_else(|| {
259            SnapshotError::Invalid("drain finalization requires a draining transition".into())
260        })?;
261        if self.protocol_version != DRAIN_FINALIZATION_VERSION
262            || self.transition_digest != transition.digest()
263            || !draining.draining
264            || self.proposal.draining
265            || self.proposal.version != draining.version
266            || self.proposal.drain_transition.is_some()
267        {
268            return Err(SnapshotError::Invalid(
269                "drain finalization does not preserve the exact transition identity".into(),
270            ));
271        }
272        let proposed_fence = self.proposal.assignment_fence()?;
273        let predecessor = &transition.predecessor;
274        let commits_target = proposed_fence == transition.target;
275        let aborts_to_predecessor = proposed_fence.assignment_version
276            == transition.target.assignment_version
277            && proposed_fence.vnode_count == predecessor.vnode_count
278            && proposed_fence.assignment_digest == predecessor.assignment_digest
279            && proposed_fence.participants == predecessor.participants;
280        if !commits_target && !aborts_to_predecessor {
281            return Err(SnapshotError::Invalid(
282                "drain finalization is neither the certified target nor exact predecessor rollback"
283                    .into(),
284            ));
285        }
286        Ok(())
287    }
288}
289
290impl AssignmentSnapshot {
291    /// Empty snapshot at version 0.
292    #[must_use]
293    pub fn empty() -> Self {
294        Self {
295            version: 0,
296            partitioning_abi_version: PARTITIONING_ABI_VERSION,
297            vnodes: BTreeMap::new(),
298            participants: Vec::new(),
299            updated_at_ms: 0,
300            draining: false,
301            drain_transition: None,
302        }
303    }
304
305    /// Next snapshot with bumped version and current wall-clock time.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if the version overflows or the successor is noncanonical.
310    pub fn next(&self, vnodes: BTreeMap<u32, NodeId>) -> Result<Self, SnapshotError> {
311        self.next_for_participants(vnodes, self.participants.clone())
312    }
313
314    /// Next snapshot bound to the supplied canonical process roster.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if the version overflows or the successor is noncanonical.
319    pub fn next_for_participants(
320        &self,
321        vnodes: BTreeMap<u32, NodeId>,
322        participants: Vec<CheckpointParticipant>,
323    ) -> Result<Self, SnapshotError> {
324        let version = self
325            .version
326            .checked_add(1)
327            .ok_or_else(|| SnapshotError::Invalid("assignment snapshot version overflow".into()))?;
328        let next = Self {
329            version,
330            partitioning_abi_version: self.partitioning_abi_version,
331            vnodes,
332            participants,
333            updated_at_ms: current_time_millis(),
334            draining: false,
335            drain_transition: None,
336        };
337        next.validate()?;
338        Ok(next)
339    }
340
341    fn validate_assignment(&self) -> Result<(), SnapshotError> {
342        if self.partitioning_abi_version != PARTITIONING_ABI_VERSION {
343            return Err(SnapshotError::Invalid(format!(
344                "assignment snapshot partitioning ABI {} does not match runtime ABI {PARTITIONING_ABI_VERSION}",
345                self.partitioning_abi_version
346            )));
347        }
348        if self.participants.len() > MAX_CHECKPOINT_PARTICIPANTS {
349            return Err(SnapshotError::Invalid(format!(
350                "assignment snapshot has {} participants; maximum is {MAX_CHECKPOINT_PARTICIPANTS}",
351                self.participants.len()
352            )));
353        }
354        let vnode_count = u32::try_from(self.vnodes.len()).map_err(|_| {
355            SnapshotError::Invalid("assignment snapshot has more than u32::MAX key groups".into())
356        })?;
357        KeyGroupCount::try_from(vnode_count).map_err(|_| {
358            SnapshotError::Invalid(format!(
359                "assignment snapshot key-group count must be between 1 and {}, got {vnode_count}",
360                crate::state::MAX_KEY_GROUP_COUNT
361            ))
362        })?;
363        let dense = !self.vnodes.is_empty()
364            && self
365                .vnodes
366                .keys()
367                .copied()
368                .zip(0_u32..)
369                .all(|(actual, expected)| actual == expected);
370        let canonical_participants = !self.participants.is_empty()
371            && self
372                .participants
373                .windows(2)
374                .all(|pair| pair[0].node_id < pair[1].node_id)
375            && self.participants.iter().all(|participant| {
376                participant.node_id != 0 && !participant.boot_incarnation.is_nil()
377            })
378            && {
379                let owners: BTreeSet<u64> = self.vnodes.values().map(|owner| owner.0).collect();
380                owners.len() == self.participants.len()
381                    && self
382                        .participants
383                        .iter()
384                        .all(|participant| owners.contains(&participant.node_id))
385            };
386        if self.version == 0 || !dense || !canonical_participants {
387            return Err(SnapshotError::Invalid(
388                "assignment snapshot is not canonical".into(),
389            ));
390        }
391        Ok(())
392    }
393
394    /// Validate the durable owner map, process roster, and optional drain transition.
395    ///
396    /// # Errors
397    ///
398    /// Returns an error when the snapshot or drain transition is noncanonical.
399    pub fn validate(&self) -> Result<(), SnapshotError> {
400        self.validate_assignment()?;
401        match (self.draining, self.drain_transition.as_ref()) {
402            (false, None) => Ok(()),
403            (true, Some(transition)) => {
404                let target = self.assignment_fence_unchecked()?;
405                if !transition.is_canonical() || transition.target != target {
406                    return Err(SnapshotError::Invalid(
407                        "draining snapshot does not match its exact target transition".into(),
408                    ));
409                }
410                Ok(())
411            }
412            _ => Err(SnapshotError::Invalid(
413                "assignment drain flag and transition disagree".into(),
414            )),
415        }
416    }
417
418    fn encode_recovery_proposal(&self) -> Result<(Vec<u8>, AssignmentSnapshotRef), SnapshotError> {
419        self.validate()?;
420        if self.draining || self.drain_transition.is_some() || self.version < 2 {
421            return Err(SnapshotError::Invalid(
422                "recovery proposal must be a committed successor generation".into(),
423            ));
424        }
425        let encoded = serde_json::to_vec(self)?;
426        if encoded.len() > MAX_RECOVERY_PROPOSAL_BYTES {
427            return Err(SnapshotError::Invalid(format!(
428                "encoded recovery proposal is {} bytes; maximum is {MAX_RECOVERY_PROPOSAL_BYTES}",
429                encoded.len()
430            )));
431        }
432        let reference = AssignmentSnapshotRef {
433            version: self.version,
434            sha256: format!("{:x}", Sha256::digest(&encoded)),
435            encoded_len: u64::try_from(encoded.len()).map_err(|_| {
436                SnapshotError::Invalid("recovery proposal encoded length overflow".into())
437            })?,
438        };
439        reference.validate()?;
440        Ok((encoded, reference))
441    }
442
443    fn assignment_fence_unchecked(&self) -> Result<CheckpointAssignmentFence, SnapshotError> {
444        let owners: Vec<u64> = self.vnodes.values().map(|owner| owner.0).collect();
445        CheckpointAssignmentFence::from_owner_map(self.version, &owners, self.participants.clone())
446            .map_err(SnapshotError::Invalid)
447    }
448
449    /// Exact checkpoint certificate represented by this snapshot.
450    ///
451    /// # Errors
452    ///
453    /// Returns an error when the assignment map or process roster is noncanonical.
454    pub fn assignment_fence(&self) -> Result<CheckpointAssignmentFence, SnapshotError> {
455        self.validate_assignment()?;
456        self.assignment_fence_unchecked()
457    }
458
459    /// Create the exact successor as a leader-fenced draining generation.
460    ///
461    /// # Errors
462    /// Rejects a non-committed predecessor, invalid successor, or leader proof outside both
463    /// certified rosters.
464    pub fn next_draining(
465        &self,
466        vnodes: BTreeMap<u32, NodeId>,
467        participants: Vec<CheckpointParticipant>,
468        leader: LeaderProof,
469    ) -> Result<Self, SnapshotError> {
470        self.validate()?;
471        if self.draining {
472            return Err(SnapshotError::Invalid(
473                "cannot start a drain from a draining assignment".into(),
474            ));
475        }
476        let predecessor = self.assignment_fence()?;
477        let mut target = self.next_for_participants(vnodes, participants)?;
478        let target_fence = target.assignment_fence()?;
479        target.drain_transition = Some(
480            AssignmentDrainTransition::new(predecessor, target_fence, leader)
481                .map_err(SnapshotError::Invalid)?,
482        );
483        target.draining = true;
484        target.validate()?;
485        Ok(target)
486    }
487
488    /// Convert a draining generation into its committed target without changing identity.
489    ///
490    /// # Errors
491    ///
492    /// Returns an error unless this is a valid draining snapshot.
493    pub fn committed_target(&self) -> Result<Self, SnapshotError> {
494        self.validate()?;
495        if !self.draining {
496            return Err(SnapshotError::Invalid(
497                "only a draining assignment has a target to commit".into(),
498            ));
499        }
500        let mut committed = self.clone();
501        committed.draining = false;
502        committed.drain_transition = None;
503        committed.updated_at_ms = current_time_millis();
504        committed.validate()?;
505        Ok(committed)
506    }
507
508    /// Convert a draining generation into a committed rollback of its predecessor map.
509    ///
510    /// # Errors
511    ///
512    /// Returns an error when either snapshot is invalid or the predecessor does not match.
513    pub fn aborted_target(&self, predecessor: &Self) -> Result<Self, SnapshotError> {
514        self.validate()?;
515        predecessor.validate()?;
516        let transition = self.drain_transition.as_ref().ok_or_else(|| {
517            SnapshotError::Invalid("draining assignment has no transition".into())
518        })?;
519        if predecessor.draining
520            || predecessor.assignment_fence()? != transition.predecessor
521            || self.version != predecessor.version.saturating_add(1)
522        {
523            return Err(SnapshotError::Invalid(
524                "drain rollback does not match the exact predecessor".into(),
525            ));
526        }
527        let mut aborted = predecessor.clone();
528        aborted.version = self.version;
529        aborted.updated_at_ms = current_time_millis();
530        aborted.draining = false;
531        aborted.drain_transition = None;
532        aborted.validate()?;
533        Ok(aborted)
534    }
535
536    /// Whether the durable process roster is canonical and covers every vnode owner.
537    #[must_use]
538    pub fn has_canonical_participants(&self) -> bool {
539        self.validate().is_ok()
540    }
541
542    /// Convert a `Vec<NodeId>` (one entry per vnode id, dense) into the
543    /// `BTreeMap` shape this snapshot uses. Mirrors the layout returned
544    /// by `rendezvous_assignment`.
545    #[must_use]
546    pub fn vnodes_from_vec(assignment: &[NodeId]) -> BTreeMap<u32, NodeId> {
547        #[allow(clippy::cast_possible_truncation)]
548        assignment
549            .iter()
550            .enumerate()
551            .map(|(i, n)| (i as u32, *n))
552            .collect()
553    }
554
555    /// Convert the canonical owner map to a dense vector of exactly `vnode_count` entries.
556    ///
557    /// # Errors
558    ///
559    /// Returns an error when the snapshot is invalid or its vnode map is not exactly dense.
560    pub fn to_vnode_vec(&self, vnode_count: u32) -> Result<Vec<NodeId>, SnapshotError> {
561        self.validate()?;
562        if usize::try_from(vnode_count).ok() != Some(self.vnodes.len()) {
563            return Err(SnapshotError::Invalid(format!(
564                "assignment {} vnode cardinality {} does not match runtime cardinality {vnode_count}",
565                self.version,
566                self.vnodes.len()
567            )));
568        }
569        (0..vnode_count)
570            .map(|v| {
571                self.vnodes.get(&v).copied().ok_or_else(|| {
572                    SnapshotError::Invalid(format!(
573                        "assignment {} is missing vnode {v}",
574                        self.version
575                    ))
576                })
577            })
578            .collect()
579    }
580}
581
582/// I/O wrapper for [`AssignmentSnapshot`] on an object store.
583pub struct AssignmentSnapshotStore {
584    store: Arc<dyn ObjectStore>,
585    /// Exact kind returned by the last successful head inventory/load. Snapshot watchers audit
586    /// that same version immediately, so they can reuse this immutable provenance instead of
587    /// issuing a speculative overlay GET. Unknown versions still take the verified fallback.
588    last_loaded_head: parking_lot::Mutex<Option<(u64, SnapshotHeadKind)>>,
589}
590
591struct AssignmentVersionInventory {
592    versions: Vec<u64>,
593    recovery_materializations: BTreeSet<u64>,
594}
595
596#[derive(Clone, Copy, PartialEq, Eq)]
597enum SnapshotHeadKind {
598    Raw,
599    Recovery,
600}
601
602impl std::fmt::Debug for AssignmentSnapshotStore {
603    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
604        f.debug_struct("AssignmentSnapshotStore")
605            .finish_non_exhaustive()
606    }
607}
608
609/// Errors loading or saving an [`AssignmentSnapshot`].
610#[derive(Debug, thiserror::Error)]
611pub enum SnapshotError {
612    /// Underlying object store I/O failure.
613    #[error("object store I/O: {0}")]
614    Io(String),
615    /// JSON de/serialization failure.
616    #[error("JSON: {0}")]
617    Json(#[from] serde_json::Error),
618    /// Snapshot metadata, owner map, or process roster is non-canonical.
619    #[error("invalid snapshot: {0}")]
620    Invalid(String),
621}
622
623impl AssignmentSnapshotStore {
624    /// Wrap a pre-constructed object store.
625    #[must_use]
626    pub fn new(store: Arc<dyn ObjectStore>) -> Self {
627        Self {
628            store,
629            last_loaded_head: parking_lot::Mutex::new(None),
630        }
631    }
632
633    /// Stage one committed successor under its canonical content address.
634    ///
635    /// Identical retries converge on the same immutable object. This does not change the durable
636    /// assignment head; callers publish the returned reference through their fencing authority
637    /// before materialization.
638    ///
639    /// # Errors
640    /// Rejects an invalid/non-committed successor or a write that cannot be reconciled exactly.
641    pub async fn stage_recovery_proposal(
642        &self,
643        proposal: &AssignmentSnapshot,
644    ) -> Result<AssignmentSnapshotRef, SnapshotError> {
645        let (encoded, reference) = proposal.encode_recovery_proposal()?;
646        let path = recovery_proposal_path(&reference);
647        let options = PutOptions {
648            mode: PutMode::Create,
649            ..PutOptions::default()
650        };
651        let put_error = self
652            .store
653            .put_opts(
654                &path,
655                PutPayload::from(Bytes::copy_from_slice(&encoded)),
656                options,
657            )
658            .await
659            .err();
660
661        match self.load_recovery_proposal(&reference).await {
662            Ok(stored) if stored == *proposal => Ok(reference),
663            Ok(_) => Err(SnapshotError::Invalid(format!(
664                "recovery proposal '{}' differs from the proposed snapshot",
665                reference.sha256
666            ))),
667            Err(reconcile_error) => {
668                if let Some(put_error) = put_error {
669                    Err(SnapshotError::Io(format!(
670                        "recovery proposal write failed ({put_error}); reconciliation failed ({reconcile_error})"
671                    )))
672                } else {
673                    Err(reconcile_error)
674                }
675            }
676        }
677    }
678
679    /// Load and verify one exact immutable recovery proposal.
680    ///
681    /// # Errors
682    /// Rejects a missing, malformed, non-canonical, or reference-mismatched object.
683    pub async fn load_recovery_proposal(
684        &self,
685        reference: &AssignmentSnapshotRef,
686    ) -> Result<AssignmentSnapshot, SnapshotError> {
687        reference.validate()?;
688        let result = match self.store.get(&recovery_proposal_path(reference)).await {
689            Ok(result) => result,
690            Err(object_store::Error::NotFound { .. }) => {
691                return Err(SnapshotError::Invalid(format!(
692                    "recovery proposal '{}' is missing",
693                    reference.sha256
694                )));
695            }
696            Err(error) => return Err(SnapshotError::Io(error.to_string())),
697        };
698        if result.meta.size != reference.encoded_len {
699            return Err(SnapshotError::Invalid(format!(
700                "recovery proposal '{}' is {} bytes, expected {}",
701                reference.sha256, result.meta.size, reference.encoded_len
702            )));
703        }
704        let bytes = result
705            .bytes()
706            .await
707            .map_err(|error| SnapshotError::Io(error.to_string()))?;
708        if u64::try_from(bytes.len()).ok() != Some(reference.encoded_len) {
709            return Err(SnapshotError::Invalid(format!(
710                "recovery proposal '{}' payload length changed while reading",
711                reference.sha256
712            )));
713        }
714        let proposal: AssignmentSnapshot = serde_json::from_slice(&bytes).map_err(|error| {
715            SnapshotError::Invalid(format!("recovery proposal '{}': {error}", reference.sha256))
716        })?;
717        let (canonical, actual_reference) = proposal.encode_recovery_proposal()?;
718        if actual_reference != *reference || canonical.as_slice() != bytes.as_ref() {
719            return Err(SnapshotError::Invalid(format!(
720                "recovery proposal '{}' does not match its content-addressed reference",
721                reference.sha256
722            )));
723        }
724        Ok(proposal)
725    }
726
727    async fn load_recovery_materialization(
728        &self,
729        version: u64,
730    ) -> Result<Option<RecoveryMaterialization>, SnapshotError> {
731        let result = match self
732            .store
733            .get(&recovery_materialization_path(version))
734            .await
735        {
736            Ok(result) => result,
737            Err(object_store::Error::NotFound { .. }) => return Ok(None),
738            Err(error) => return Err(SnapshotError::Io(error.to_string())),
739        };
740        if result.meta.size == 0 || result.meta.size > MAX_RECOVERY_MATERIALIZATION_BYTES {
741            return Err(SnapshotError::Invalid(format!(
742                "recovery materialization is {} bytes; expected 1..={MAX_RECOVERY_MATERIALIZATION_BYTES}",
743                result.meta.size
744            )));
745        }
746        let bytes = result
747            .bytes()
748            .await
749            .map_err(|error| SnapshotError::Io(error.to_string()))?;
750        let materialization: RecoveryMaterialization = serde_json::from_slice(&bytes)?;
751        materialization.validate()?;
752        if materialization.proposal.version != version {
753            return Err(SnapshotError::Invalid(format!(
754                "recovery materialization path version {version} references proposal version {}",
755                materialization.proposal.version
756            )));
757        }
758        let canonical = serde_json::to_vec(&materialization)?;
759        if canonical.as_slice() != bytes.as_ref() {
760            return Err(SnapshotError::Invalid(format!(
761                "recovery materialization {version} does not use its canonical body"
762            )));
763        }
764        Ok(Some(materialization))
765    }
766
767    /// Verify and publish a staged successor as the monotonic durable assignment head.
768    ///
769    /// The create-only materialization is the version reservation. It lives outside the raw
770    /// graceful-drain snapshot namespace, so a drain write already in flight under a superseded
771    /// leader cannot occupy or replace the recovery winner. Readers always prefer this record for
772    /// its exact version.
773    ///
774    /// # Errors
775    /// Rejects an invalid proposal reference or a durable head other than its predecessor.
776    pub(super) async fn materialize_recovery(
777        &self,
778        reference: &AssignmentSnapshotRef,
779    ) -> Result<RotateOutcome, SnapshotError> {
780        let proposal = self.load_recovery_proposal(reference).await?;
781        let predecessor_version = reference.version.checked_sub(1).ok_or_else(|| {
782            SnapshotError::Invalid("recovery proposal has no predecessor generation".into())
783        })?;
784        let head = self.list_versions().await?.last().copied();
785        if head != Some(predecessor_version) && head != Some(reference.version) {
786            return Err(SnapshotError::Invalid(format!(
787                "recovery materialization requires durable head {predecessor_version} or {}, observed {head:?}",
788                reference.version
789            )));
790        }
791
792        let materialization = RecoveryMaterialization::new(reference.clone(), proposal.clone())?;
793        let options = PutOptions {
794            mode: PutMode::Create,
795            ..PutOptions::default()
796        };
797        let result = self
798            .store
799            .put_opts(
800                &recovery_materialization_path(reference.version),
801                PutPayload::from(Bytes::from(serde_json::to_vec(&materialization)?)),
802                options,
803            )
804            .await;
805        let winner = self
806            .load_recovery_materialization(reference.version)
807            .await?
808            .ok_or_else(|| {
809                SnapshotError::Io(format!(
810                    "recovery materialization {} was not durably visible",
811                    reference.version
812                ))
813            })?;
814        let winner_snapshot = winner.snapshot.clone();
815        if result.is_ok() {
816            if winner != materialization || winner_snapshot != proposal {
817                return Err(SnapshotError::Invalid(format!(
818                    "recovery materialization {} changed after its create succeeded",
819                    reference.version
820                )));
821            }
822            return Ok(RotateOutcome::Rotated);
823        }
824        Ok(RotateOutcome::Conflict(Box::new(winner_snapshot)))
825    }
826
827    /// Enumerate raw snapshots and recovery materializations with one object-store LIST.
828    async fn list_version_inventory(&self) -> Result<AssignmentVersionInventory, SnapshotError> {
829        let prefix = OsPath::from(SNAPSHOT_PREFIX);
830        let mut entries = self.store.list(Some(&prefix));
831        let mut versions = Vec::new();
832        let mut recovery_materializations = BTreeSet::new();
833        while let Some(entry) = entries.next().await {
834            let entry = entry.map_err(|e| SnapshotError::Io(e.to_string()))?;
835            let loc = entry.location.as_ref();
836            // Accept only canonical fixed-width snapshot names. Unrelated siblings are ignored,
837            // but a snapshot-like name with another shape is a split-history risk and fails load.
838            let Some(rest) = loc.strip_prefix(SNAPSHOT_PREFIX) else {
839                continue;
840            };
841            if let Some(name) = rest.strip_prefix(RECOVERY_MATERIALIZATION_RELATIVE_PREFIX) {
842                let version = version_from_file(name, "recovery materialization", 2)?;
843                versions.push(version);
844                recovery_materializations.insert(version);
845                continue;
846            }
847            if !rest.starts_with('v') {
848                continue;
849            }
850            versions.push(version_from_file(rest, "assignment snapshot", 1)?);
851        }
852        versions.sort_unstable();
853        versions.dedup();
854        if versions.windows(2).any(|pair| {
855            pair[0]
856                .checked_add(1)
857                .is_none_or(|expected| expected != pair[1])
858        }) {
859            return Err(SnapshotError::Invalid(
860                "assignment snapshot versions are not contiguous".into(),
861            ));
862        }
863        Ok(AssignmentVersionInventory {
864            versions,
865            recovery_materializations,
866        })
867    }
868
869    /// Scan the shared history prefix and return every logical version in ascending order.
870    async fn list_versions(&self) -> Result<Vec<u64>, SnapshotError> {
871        Ok(self.list_version_inventory().await?.versions)
872    }
873
874    async fn list_drain_finalization_versions(&self) -> Result<Vec<u64>, SnapshotError> {
875        let prefix = OsPath::from(DRAIN_FINALIZATION_PREFIX);
876        let mut entries = self.store.list(Some(&prefix));
877        let mut versions = Vec::new();
878        while let Some(entry) = entries.next().await {
879            let entry = entry.map_err(|error| SnapshotError::Io(error.to_string()))?;
880            let location = entry.location.as_ref();
881            let Some(rest) = location.strip_prefix(DRAIN_FINALIZATION_PREFIX) else {
882                continue;
883            };
884            let Some(number) = rest
885                .strip_prefix('v')
886                .and_then(|name| name.strip_suffix(".json"))
887            else {
888                return Err(SnapshotError::Invalid(format!(
889                    "non-canonical drain finalization filename {rest}"
890                )));
891            };
892            if number.len() != SNAPSHOT_VERSION_WIDTH
893                || !number.bytes().all(|byte| byte.is_ascii_digit())
894            {
895                return Err(SnapshotError::Invalid(format!(
896                    "non-canonical drain finalization filename {rest}"
897                )));
898            }
899            let version = number.parse::<u64>().map_err(|error| {
900                SnapshotError::Invalid(format!(
901                    "invalid drain finalization filename {rest}: {error}"
902                ))
903            })?;
904            if version == 0 {
905                return Err(SnapshotError::Invalid(
906                    "assignment drain finalization version zero is not durable".into(),
907                ));
908            }
909            versions.push(version);
910        }
911        versions.sort_unstable();
912        versions.dedup();
913        Ok(versions)
914    }
915
916    /// Load the current (highest-versioned) snapshot; `Ok(None)` on
917    /// fresh cluster.
918    ///
919    /// # Errors
920    /// Object-store I/O or JSON decode failure.
921    pub async fn load(&self) -> Result<Option<AssignmentSnapshot>, SnapshotError> {
922        let inventory = self.list_version_inventory().await?;
923        let Some(&latest) = inventory.versions.last() else {
924            return Ok(None);
925        };
926        if inventory.recovery_materializations.contains(&latest) {
927            let materialization = self
928                .load_recovery_materialization(latest)
929                .await?
930                .ok_or_else(|| {
931                    SnapshotError::Io(format!(
932                        "listed recovery materialization {latest} disappeared before load"
933                    ))
934                })?;
935            self.last_loaded_head
936                .lock()
937                .replace((latest, SnapshotHeadKind::Recovery));
938            return Ok(Some(materialization.snapshot));
939        }
940        let loaded = self.load_base_version(latest).await?;
941        if loaded.is_some() {
942            let mut last_loaded_head = self.last_loaded_head.lock();
943            if *last_loaded_head != Some((latest, SnapshotHeadKind::Recovery)) {
944                last_loaded_head.replace((latest, SnapshotHeadKind::Raw));
945            }
946        }
947        Ok(loaded)
948    }
949
950    /// Load a specific version's snapshot. `Ok(None)` if that version
951    /// was never written or has been pruned.
952    ///
953    /// # Errors
954    /// Object-store I/O or JSON decode failure.
955    pub async fn load_version(
956        &self,
957        version: u64,
958    ) -> Result<Option<AssignmentSnapshot>, SnapshotError> {
959        if let Some(materialization) = self.load_recovery_materialization(version).await? {
960            return Ok(Some(materialization.snapshot));
961        }
962        self.load_base_version(version).await
963    }
964
965    async fn load_base_version(
966        &self,
967        version: u64,
968    ) -> Result<Option<AssignmentSnapshot>, SnapshotError> {
969        let Some(snapshot) = self.load_snapshot_object(version).await? else {
970            return Ok(None);
971        };
972        if !snapshot.draining {
973            return Ok(Some(snapshot));
974        }
975        match self.load_drain_finalization(version).await? {
976            Some(finalization) => {
977                finalization.validate_against(&snapshot)?;
978                Ok(Some(finalization.proposal))
979            }
980            None => Ok(Some(snapshot)),
981        }
982    }
983
984    /// Load the immutable drain transition underlying a materialized assignment version.
985    ///
986    /// A terminal `load_version` result intentionally contains only the installed assignment.
987    /// Cluster readers use this accessor to bind that materialized result back to the shared
988    /// authority decision before adoption. Ordinary assignment versions return `None`.
989    ///
990    /// # Errors
991    /// Object-store I/O, JSON decode failure, or a malformed base snapshot.
992    pub async fn load_drain_transition(
993        &self,
994        version: u64,
995    ) -> Result<Option<AssignmentDrainTransition>, SnapshotError> {
996        let last_loaded_head = *self.last_loaded_head.lock();
997        match last_loaded_head {
998            Some((loaded, SnapshotHeadKind::Recovery)) if loaded == version => return Ok(None),
999            Some((loaded, SnapshotHeadKind::Raw)) if loaded == version => {
1000                return Ok(self
1001                    .load_snapshot_object(version)
1002                    .await?
1003                    .and_then(|snapshot| snapshot.drain_transition));
1004            }
1005            _ => {}
1006        }
1007        if self.load_recovery_materialization(version).await?.is_some() {
1008            return Ok(None);
1009        }
1010        Ok(self
1011            .load_snapshot_object(version)
1012            .await?
1013            .and_then(|snapshot| snapshot.drain_transition))
1014    }
1015
1016    async fn load_snapshot_object(
1017        &self,
1018        version: u64,
1019    ) -> Result<Option<AssignmentSnapshot>, SnapshotError> {
1020        let path = snapshot_path(version);
1021        match self.store.get(&path).await {
1022            Ok(res) => {
1023                let bytes = res
1024                    .bytes()
1025                    .await
1026                    .map_err(|e| SnapshotError::Io(e.to_string()))?;
1027                let snap: AssignmentSnapshot = serde_json::from_slice(&bytes)?;
1028                if snap.version != version {
1029                    return Err(SnapshotError::Invalid(format!(
1030                        "snapshot path version {version} contains payload version {}",
1031                        snap.version
1032                    )));
1033                }
1034                snap.validate()?;
1035                Ok(Some(snap))
1036            }
1037            Err(object_store::Error::NotFound { .. }) => Ok(None),
1038            Err(e) => Err(SnapshotError::Io(e.to_string())),
1039        }
1040    }
1041
1042    async fn load_drain_finalization(
1043        &self,
1044        version: u64,
1045    ) -> Result<Option<DrainFinalization>, SnapshotError> {
1046        let path = drain_finalization_path(version);
1047        match self.store.get(&path).await {
1048            Ok(result) => {
1049                let bytes = result
1050                    .bytes()
1051                    .await
1052                    .map_err(|error| SnapshotError::Io(error.to_string()))?;
1053                let finalization: DrainFinalization = serde_json::from_slice(&bytes)?;
1054                if finalization.proposal.version != version {
1055                    return Err(SnapshotError::Invalid(format!(
1056                        "drain finalization path version {version} contains payload version {}",
1057                        finalization.proposal.version
1058                    )));
1059                }
1060                Ok(Some(finalization))
1061            }
1062            Err(object_store::Error::NotFound { .. }) => Ok(None),
1063            Err(error) => Err(SnapshotError::Io(error.to_string())),
1064        }
1065    }
1066
1067    async fn create_if_absent(
1068        &self,
1069        snapshot: &AssignmentSnapshot,
1070    ) -> Result<Option<AssignmentSnapshot>, SnapshotError> {
1071        snapshot.validate()?;
1072        let path = snapshot_path(snapshot.version);
1073        let bytes = serde_json::to_vec_pretty(snapshot)?;
1074        let opts = PutOptions {
1075            mode: PutMode::Create,
1076            ..PutOptions::default()
1077        };
1078        match self
1079            .store
1080            .put_opts(&path, PutPayload::from(Bytes::from(bytes)), opts)
1081            .await
1082        {
1083            Ok(_) => Ok(Some(snapshot.clone())),
1084            Err(object_store::Error::AlreadyExists { .. }) => Ok(None),
1085            Err(e) => Err(SnapshotError::Io(e.to_string())),
1086        }
1087    }
1088
1089    async fn prune_recovery_proposals_for_version(
1090        &self,
1091        version: u64,
1092    ) -> Result<(), SnapshotError> {
1093        let prefix = recovery_proposal_version_prefix(version);
1094        for _ in 0..RECOVERY_PROPOSAL_GC_MAX_BATCHES {
1095            let mut entries = self.store.list(Some(&prefix));
1096            let mut candidates = Vec::with_capacity(RECOVERY_PROPOSAL_GC_BATCH);
1097            while candidates.len() < RECOVERY_PROPOSAL_GC_BATCH {
1098                let Some(entry) = entries.next().await else {
1099                    break;
1100                };
1101                candidates.push(
1102                    entry
1103                        .map_err(|error| SnapshotError::Io(error.to_string()))?
1104                        .location,
1105                );
1106            }
1107            if candidates.is_empty() {
1108                return Ok(());
1109            }
1110            let deletions =
1111                futures::stream::iter(candidates.into_iter().map(Ok::<_, object_store::Error>));
1112            let mut results = self.store.delete_stream(Box::pin(deletions));
1113            while let Some(result) = results.next().await {
1114                if let Err(error) = result {
1115                    if !matches!(error, object_store::Error::NotFound { .. }) {
1116                        return Err(SnapshotError::Io(error.to_string()));
1117                    }
1118                }
1119            }
1120            tokio::task::yield_now().await;
1121        }
1122
1123        let mut remaining = self.store.list(Some(&prefix));
1124        match remaining.next().await {
1125            None => Ok(()),
1126            Some(Ok(_)) => Err(SnapshotError::Io(format!(
1127                "recovery proposal garbage for assignment {version} exceeds the bounded cleanup budget"
1128            ))),
1129            Some(Err(error)) => Err(SnapshotError::Io(error.to_string())),
1130        }
1131    }
1132
1133    /// CAS-create the version-one seed. `Ok(None)` means another initial writer won.
1134    ///
1135    /// # Errors
1136    /// Object-store I/O or JSON encode failure.
1137    pub async fn save_if_absent(
1138        &self,
1139        snapshot: &AssignmentSnapshot,
1140    ) -> Result<Option<AssignmentSnapshot>, SnapshotError> {
1141        if snapshot.version != 1 {
1142            return Err(SnapshotError::Invalid(format!(
1143                "save_if_absent only accepts the version-one seed, got {}",
1144                snapshot.version
1145            )));
1146        }
1147        if let Some(head) = self
1148            .list_versions()
1149            .await?
1150            .last()
1151            .copied()
1152            .filter(|head| *head != 1)
1153        {
1154            return Err(SnapshotError::Invalid(format!(
1155                "cannot seed assignment history with durable head {head}"
1156            )));
1157        }
1158        self.create_if_absent(snapshot).await
1159    }
1160
1161    /// Rotate to `snapshot` assuming the current durable version is
1162    /// `prior_version`. Returns [`RotateOutcome::Conflict`] carrying
1163    /// the winner's snapshot if a racer produced `prior_version + 1`
1164    /// first.
1165    ///
1166    /// # Errors
1167    /// Object-store I/O, JSON encode, or a non-monotonic version bump
1168    /// (caller bug).
1169    pub async fn save_if_version(
1170        &self,
1171        snapshot: &AssignmentSnapshot,
1172        prior_version: u64,
1173    ) -> Result<RotateOutcome, SnapshotError> {
1174        snapshot.validate()?;
1175        let expected = prior_version
1176            .checked_add(1)
1177            .ok_or_else(|| SnapshotError::Invalid("assignment snapshot version overflow".into()))?;
1178        if snapshot.version != expected {
1179            return Err(SnapshotError::Invalid(format!(
1180                "save_if_version requires monotonic +1 bump: prior={prior_version}, \
1181                 proposed={}",
1182                snapshot.version,
1183            )));
1184        }
1185        let head = self.list_versions().await?.last().copied();
1186        if head == Some(expected) {
1187            let winner = self.load_version(expected).await?.ok_or_else(|| {
1188                SnapshotError::Io("durable head disappeared while loading CAS winner".into())
1189            })?;
1190            return Ok(RotateOutcome::Conflict(Box::new(winner)));
1191        }
1192        if head != Some(prior_version) {
1193            return Err(SnapshotError::Invalid(format!(
1194                "save_if_version requires durable head {prior_version}, observed {head:?}"
1195            )));
1196        }
1197        if self.create_if_absent(snapshot).await?.is_some() {
1198            return Ok(RotateOutcome::Rotated);
1199        }
1200        let winner = self.load_version(snapshot.version).await?.ok_or_else(|| {
1201            SnapshotError::Io("CAS conflict but load of winner returned None".into())
1202        })?;
1203        Ok(RotateOutcome::Conflict(Box::new(winner)))
1204    }
1205
1206    /// Append exactly one immutable winner for a draining object: its target or a rollback.
1207    ///
1208    /// The object version is intentionally unchanged: source receipts certify the target
1209    /// assignment version, so committing the map under another version would discard the very
1210    /// identity they proved. `PutMode::Create` makes commit versus abort a store-level race with
1211    /// one winner on local and cloud backends; the original transition remains auditable.
1212    /// Cluster callers must first serialize the verdict through `LeaderLeaseStore`; this method
1213    /// only materializes that already-authoritative verdict.
1214    ///
1215    /// # Errors
1216    /// Rejects a stale/non-draining expected value, an unrelated proposal, or a non-head object.
1217    pub async fn finalize_drain(
1218        &self,
1219        draining: &AssignmentSnapshot,
1220        proposal: &AssignmentSnapshot,
1221    ) -> Result<RotateOutcome, SnapshotError> {
1222        let finalization = DrainFinalization::new(draining, proposal.clone())?;
1223        if self.list_versions().await?.last().copied() != Some(draining.version) {
1224            return Err(SnapshotError::Invalid(format!(
1225                "draining assignment {} is no longer the durable head",
1226                draining.version
1227            )));
1228        }
1229
1230        let current = self
1231            .load_snapshot_object(draining.version)
1232            .await?
1233            .ok_or_else(|| SnapshotError::Io("draining assignment disappeared".into()))?;
1234        if current != *draining {
1235            let winner = self
1236                .load_version(draining.version)
1237                .await?
1238                .ok_or_else(|| SnapshotError::Io("drain conflict winner disappeared".into()))?;
1239            return Ok(RotateOutcome::Conflict(Box::new(winner)));
1240        }
1241        if let Some(winner) = self.load_drain_finalization(draining.version).await? {
1242            winner.validate_against(draining)?;
1243            return Ok(RotateOutcome::Conflict(Box::new(winner.proposal)));
1244        }
1245
1246        let path = drain_finalization_path(draining.version);
1247        let payload = PutPayload::from(Bytes::from(serde_json::to_vec_pretty(&finalization)?));
1248        let options = PutOptions {
1249            mode: PutMode::Create,
1250            ..PutOptions::default()
1251        };
1252        match self.store.put_opts(&path, payload, options).await {
1253            Ok(_) => Ok(RotateOutcome::Rotated),
1254            Err(error) => match self.load_drain_finalization(draining.version).await {
1255                Ok(Some(winner)) => {
1256                    winner.validate_against(draining)?;
1257                    Ok(RotateOutcome::Conflict(Box::new(winner.proposal)))
1258                }
1259                Ok(None) | Err(_) => Err(SnapshotError::Io(error.to_string())),
1260            },
1261        }
1262    }
1263
1264    /// Delete every snapshot object with `version < before`.
1265    /// Idempotent — missing objects are tolerated.
1266    ///
1267    /// # Errors
1268    /// Object-store I/O.
1269    pub async fn prune_before(&self, before: u64) -> Result<(), SnapshotError> {
1270        if before == 0 {
1271            return Ok(());
1272        }
1273        let inventory = self.list_version_inventory().await?;
1274        for version in inventory.versions {
1275            if version >= before {
1276                break;
1277            }
1278            // Remove every winning and losing staged body while the version marker still exists.
1279            // A crash leaves that marker discoverable, so the next retention pass resumes GC
1280            // instead of leaking an orphaned body of up to 8 MiB.
1281            self.prune_recovery_proposals_for_version(version).await?;
1282            match self.store.delete(&snapshot_path(version)).await {
1283                Ok(()) | Err(object_store::Error::NotFound { .. }) => {}
1284                Err(e) => return Err(SnapshotError::Io(e.to_string())),
1285            }
1286            if inventory.recovery_materializations.contains(&version) {
1287                match self
1288                    .store
1289                    .delete(&recovery_materialization_path(version))
1290                    .await
1291                {
1292                    Ok(()) | Err(object_store::Error::NotFound { .. }) => {}
1293                    Err(error) => return Err(SnapshotError::Io(error.to_string())),
1294                }
1295            }
1296        }
1297        // Finalization records are in a separate append-only namespace. Scan it independently so
1298        // a prior failure after deleting the snapshot can be repaired without leaking orphans.
1299        for version in self.list_drain_finalization_versions().await? {
1300            if version >= before {
1301                break;
1302            }
1303            let path = drain_finalization_path(version);
1304            match self.store.delete(&path).await {
1305                Ok(()) | Err(object_store::Error::NotFound { .. }) => {}
1306                Err(error) => return Err(SnapshotError::Io(error.to_string())),
1307            }
1308        }
1309        Ok(())
1310    }
1311}
1312
1313/// Outcome of [`AssignmentSnapshotStore::save_if_version`].
1314#[derive(Debug, Clone)]
1315pub enum RotateOutcome {
1316    /// Our write landed. The snapshot we passed in is now canonical.
1317    Rotated,
1318    /// Another writer (a racing leader) won the CAS. The attached
1319    /// snapshot is what's currently durable; the caller must adopt it
1320    /// rather than retry with a stale view.
1321    Conflict(Box<AssignmentSnapshot>),
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326    use super::*;
1327    use crate::checkpoint::LeaderProofOwner;
1328    use object_store::local::LocalFileSystem;
1329    use tempfile::tempdir;
1330    use uuid::Uuid;
1331
1332    fn participant(node_id: u64, boot: u128) -> CheckpointParticipant {
1333        CheckpointParticipant {
1334            node_id,
1335            boot_incarnation: Uuid::from_u128(boot),
1336        }
1337    }
1338
1339    fn leader(node_id: u64, boot: u128, token: u64) -> LeaderProof {
1340        LeaderProof {
1341            owner: LeaderProofOwner {
1342                node_id,
1343                boot_id: Uuid::from_u128(boot),
1344                process_term: 1,
1345            },
1346            fencing_token: token,
1347        }
1348    }
1349
1350    fn participants_for(vnodes: &BTreeMap<u32, NodeId>) -> Vec<CheckpointParticipant> {
1351        vnodes
1352            .values()
1353            .map(|owner| owner.0)
1354            .collect::<BTreeSet<_>>()
1355            .into_iter()
1356            .map(|node| participant(node, u128::from(node)))
1357            .collect()
1358    }
1359
1360    fn snapshot(vnodes: BTreeMap<u32, NodeId>) -> AssignmentSnapshot {
1361        let participants = participants_for(&vnodes);
1362        AssignmentSnapshot::empty()
1363            .next_for_participants(vnodes, participants)
1364            .unwrap()
1365    }
1366
1367    fn next_snapshot(
1368        current: &AssignmentSnapshot,
1369        vnodes: BTreeMap<u32, NodeId>,
1370    ) -> AssignmentSnapshot {
1371        let participants = participants_for(&vnodes);
1372        current.next_for_participants(vnodes, participants).unwrap()
1373    }
1374
1375    fn store_in(dir: &std::path::Path) -> AssignmentSnapshotStore {
1376        let fs: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new_with_prefix(dir).unwrap());
1377        AssignmentSnapshotStore::new(fs)
1378    }
1379
1380    async fn put_raw(store: &AssignmentSnapshotStore, path: OsPath, snapshot: &AssignmentSnapshot) {
1381        let bytes = serde_json::to_vec(snapshot).unwrap();
1382        store
1383            .store
1384            .put(&path, PutPayload::from(Bytes::from(bytes)))
1385            .await
1386            .unwrap();
1387    }
1388
1389    #[tokio::test]
1390    async fn load_missing_returns_none() {
1391        let dir = tempdir().unwrap();
1392        let s = store_in(dir.path());
1393        assert!(s.load().await.unwrap().is_none());
1394    }
1395
1396    #[test]
1397    fn assignment_generation_persists_exact_process_roster() {
1398        let vnodes = BTreeMap::from([(0, NodeId(1)), (1, NodeId(2))]);
1399        let first = AssignmentSnapshot::empty()
1400            .next_for_participants(vnodes.clone(), vec![participant(1, 11), participant(2, 22)])
1401            .unwrap();
1402        assert!(first.has_canonical_participants());
1403
1404        let restarted = first
1405            .next_for_participants(vnodes, vec![participant(1, 11), participant(2, 222)])
1406            .unwrap();
1407        assert_eq!(restarted.version, first.version + 1);
1408        assert_eq!(restarted.vnodes, first.vnodes);
1409        assert_ne!(restarted.participants, first.participants);
1410        assert!(restarted.has_canonical_participants());
1411    }
1412
1413    #[test]
1414    fn assignment_generation_rejects_zero_vnode_participants() {
1415        let error = AssignmentSnapshot::empty()
1416            .next_for_participants(
1417                BTreeMap::from([(0, NodeId(1))]),
1418                vec![participant(1, 11), participant(2, 22)],
1419            )
1420            .unwrap_err();
1421
1422        assert!(matches!(error, SnapshotError::Invalid(message) if message.contains("canonical")));
1423    }
1424
1425    #[test]
1426    fn assignment_snapshot_requires_partitioning_abi() {
1427        let snapshot = snapshot(BTreeMap::from([(0, NodeId(1))]));
1428        assert_eq!(snapshot.partitioning_abi_version, PARTITIONING_ABI_VERSION);
1429
1430        let mut value = serde_json::to_value(snapshot).unwrap();
1431        value
1432            .as_object_mut()
1433            .unwrap()
1434            .remove("partitioning_abi_version");
1435        assert!(serde_json::from_value::<AssignmentSnapshot>(value).is_err());
1436    }
1437
1438    #[test]
1439    fn assignment_snapshot_rejects_more_than_the_partitioning_abi_limit() {
1440        let vnodes = (0..=u32::from(u16::MAX))
1441            .map(|key_group| (key_group, NodeId(1)))
1442            .collect();
1443
1444        assert!(matches!(
1445            AssignmentSnapshot::empty()
1446                .next_for_participants(vnodes, vec![participant(1, 11)]),
1447            Err(SnapshotError::Invalid(message)) if message.contains("key-group count")
1448        ));
1449    }
1450
1451    #[tokio::test]
1452    async fn durable_assignment_rejects_wrong_partitioning_abi() {
1453        let dir = tempdir().unwrap();
1454        let store = store_in(dir.path());
1455        let mut snapshot = snapshot(BTreeMap::from([(0, NodeId(1))]));
1456        snapshot.partitioning_abi_version = PARTITIONING_ABI_VERSION + 1;
1457
1458        assert!(matches!(
1459            store.save_if_absent(&snapshot).await,
1460            Err(SnapshotError::Invalid(message)) if message.contains("partitioning ABI")
1461        ));
1462
1463        put_raw(&store, snapshot_path(1), &snapshot).await;
1464        assert!(matches!(
1465            store.load().await,
1466            Err(SnapshotError::Invalid(message)) if message.contains("partitioning ABI")
1467        ));
1468    }
1469
1470    #[tokio::test]
1471    async fn save_if_absent_then_load_roundtrip() {
1472        let dir = tempdir().unwrap();
1473        let s = store_in(dir.path());
1474
1475        let mut vnodes = BTreeMap::new();
1476        vnodes.insert(0, NodeId(1));
1477        vnodes.insert(1, NodeId(2));
1478        let snap = snapshot(vnodes);
1479
1480        assert_eq!(s.save_if_absent(&snap).await.unwrap().as_ref(), Some(&snap),);
1481        let loaded = s.load().await.unwrap().unwrap();
1482        assert_eq!(loaded, snap);
1483    }
1484
1485    #[test]
1486    fn snapshot_path_is_canonical_across_the_u64_range() {
1487        assert_eq!(
1488            snapshot_path(1).as_ref(),
1489            "control/assignment-snapshots/v00000000000000000001.json"
1490        );
1491        assert_eq!(
1492            snapshot_path(u64::MAX).as_ref(),
1493            "control/assignment-snapshots/v18446744073709551615.json"
1494        );
1495    }
1496
1497    #[test]
1498    fn next_rejects_generation_overflow() {
1499        let mut current = snapshot(BTreeMap::from([(0, NodeId(1))]));
1500        current.version = u64::MAX;
1501        assert!(matches!(
1502            current.next(current.vnodes.clone()),
1503            Err(SnapshotError::Invalid(message)) if message.contains("overflow")
1504        ));
1505    }
1506
1507    #[tokio::test]
1508    async fn seed_write_rejects_non_seed_generation() {
1509        let dir = tempdir().unwrap();
1510        let store = store_in(dir.path());
1511        let first = snapshot(BTreeMap::from([(0, NodeId(1))]));
1512        let second = first.next(first.vnodes.clone()).unwrap();
1513
1514        assert!(matches!(
1515            store.save_if_absent(&second).await,
1516            Err(SnapshotError::Invalid(message)) if message.contains("version-one seed")
1517        ));
1518        assert!(store.load().await.unwrap().is_none());
1519    }
1520
1521    #[tokio::test]
1522    async fn seed_write_rejects_retained_nonempty_history() {
1523        let dir = tempdir().unwrap();
1524        let store = store_in(dir.path());
1525        let first = snapshot(BTreeMap::from([(0, NodeId(1))]));
1526        store.save_if_absent(&first).await.unwrap();
1527        let second = first.next(first.vnodes.clone()).unwrap();
1528        store.save_if_version(&second, first.version).await.unwrap();
1529        let third = second.next(second.vnodes.clone()).unwrap();
1530        store.save_if_version(&third, second.version).await.unwrap();
1531        store.prune_before(3).await.unwrap();
1532
1533        assert!(matches!(
1534            store.save_if_absent(&first).await,
1535            Err(SnapshotError::Invalid(message)) if message.contains("durable head 3")
1536        ));
1537        assert_eq!(store.list_versions().await.unwrap(), vec![3]);
1538    }
1539
1540    #[tokio::test]
1541    async fn save_rejects_noncanonical_owner_map_and_roster() {
1542        let dir = tempdir().unwrap();
1543        let store = store_in(dir.path());
1544        let canonical = snapshot(BTreeMap::from([(0, NodeId(1))]));
1545
1546        let mut sparse = canonical.clone();
1547        sparse.vnodes = BTreeMap::from([(1, NodeId(1))]);
1548        assert!(matches!(
1549            store.save_if_absent(&sparse).await,
1550            Err(SnapshotError::Invalid(_))
1551        ));
1552
1553        let mut uncovered = canonical;
1554        uncovered.participants.clear();
1555        assert!(matches!(
1556            store.save_if_absent(&uncovered).await,
1557            Err(SnapshotError::Invalid(_))
1558        ));
1559    }
1560
1561    #[tokio::test]
1562    async fn durable_assignment_rejects_oversized_participant_roster() {
1563        let dir = tempdir().unwrap();
1564        let store = store_in(dir.path());
1565        let maximum = u64::try_from(MAX_CHECKPOINT_PARTICIPANTS).unwrap();
1566        let participants = (1..=maximum + 1)
1567            .map(|node_id| participant(node_id, u128::from(node_id)))
1568            .collect();
1569        let oversized = AssignmentSnapshot {
1570            version: 1,
1571            partitioning_abi_version: PARTITIONING_ABI_VERSION,
1572            vnodes: BTreeMap::from([(0, NodeId(1))]),
1573            participants,
1574            updated_at_ms: 1,
1575            draining: false,
1576            drain_transition: None,
1577        };
1578
1579        assert!(matches!(
1580            store.save_if_absent(&oversized).await,
1581            Err(SnapshotError::Invalid(message)) if message.contains("maximum is 129")
1582        ));
1583
1584        put_raw(&store, snapshot_path(1), &oversized).await;
1585        assert!(matches!(
1586            store.load().await,
1587            Err(SnapshotError::Invalid(message)) if message.contains("maximum is 129")
1588        ));
1589    }
1590
1591    #[tokio::test]
1592    async fn load_rejects_path_payload_version_mismatch() {
1593        let dir = tempdir().unwrap();
1594        let store = store_in(dir.path());
1595        let first = snapshot(BTreeMap::from([(0, NodeId(1))]));
1596        put_raw(&store, snapshot_path(2), &first).await;
1597
1598        assert!(matches!(
1599            store.load_version(2).await,
1600            Err(SnapshotError::Invalid(message)) if message.contains("payload version")
1601        ));
1602    }
1603
1604    #[tokio::test]
1605    async fn load_rejects_generation_gap() {
1606        let dir = tempdir().unwrap();
1607        let store = store_in(dir.path());
1608        let first = snapshot(BTreeMap::from([(0, NodeId(1))]));
1609        let second = first.next(first.vnodes.clone()).unwrap();
1610        let third = second.next(second.vnodes.clone()).unwrap();
1611        put_raw(&store, snapshot_path(1), &first).await;
1612        put_raw(&store, snapshot_path(3), &third).await;
1613
1614        assert!(matches!(
1615            store.load().await,
1616            Err(SnapshotError::Invalid(message)) if message.contains("not contiguous")
1617        ));
1618    }
1619
1620    #[tokio::test]
1621    async fn load_rejects_noncanonical_snapshot_filename() {
1622        let dir = tempdir().unwrap();
1623        let store = store_in(dir.path());
1624        let first = snapshot(BTreeMap::from([(0, NodeId(1))]));
1625        put_raw(
1626            &store,
1627            OsPath::from("control/assignment-snapshots/v1.json"),
1628            &first,
1629        )
1630        .await;
1631
1632        assert!(matches!(
1633            store.load().await,
1634            Err(SnapshotError::Invalid(message)) if message.contains("filename")
1635        ));
1636    }
1637
1638    #[tokio::test]
1639    async fn load_returns_highest_version() {
1640        let dir = tempdir().unwrap();
1641        let s = store_in(dir.path());
1642
1643        let mut v1_map = BTreeMap::new();
1644        v1_map.insert(0, NodeId(1));
1645        let v1 = snapshot(v1_map);
1646        s.save_if_absent(&v1).await.unwrap();
1647
1648        let mut v2_map = BTreeMap::new();
1649        v2_map.insert(0, NodeId(2));
1650        let v2 = next_snapshot(&v1, v2_map);
1651        // Rotate via save_if_version — the canonical post-boot path.
1652        assert!(matches!(
1653            s.save_if_version(&v2, v1.version).await.unwrap(),
1654            RotateOutcome::Rotated,
1655        ));
1656
1657        let loaded = s.load().await.unwrap().unwrap();
1658        assert_eq!(loaded.version, 2);
1659        assert_eq!(loaded.vnodes.get(&0), Some(&NodeId(2)));
1660
1661        // Older version is still readable directly until pruned.
1662        let v1_loaded = s.load_version(1).await.unwrap().unwrap();
1663        assert_eq!(v1_loaded, v1);
1664    }
1665
1666    #[tokio::test]
1667    async fn save_if_absent_first_writer_wins() {
1668        let dir = tempdir().unwrap();
1669        let s = store_in(dir.path());
1670
1671        let mut first_map = BTreeMap::new();
1672        first_map.insert(0, NodeId(1));
1673        first_map.insert(1, NodeId(2));
1674        let first = snapshot(first_map);
1675
1676        let winner = s.save_if_absent(&first).await.unwrap();
1677        assert_eq!(winner.as_ref(), Some(&first), "first writer must win");
1678
1679        // Second writer attempts a different assignment; should be
1680        // rejected without mutating the store.
1681        let mut second_map = BTreeMap::new();
1682        second_map.insert(0, NodeId(99));
1683        let second = snapshot(second_map);
1684        let rejected = s.save_if_absent(&second).await.unwrap();
1685        assert!(rejected.is_none(), "second writer must lose the CAS");
1686
1687        let loaded = s.load().await.unwrap().unwrap();
1688        assert_eq!(loaded, first, "stored snapshot is the first writer's");
1689    }
1690
1691    #[tokio::test]
1692    async fn save_if_version_rejects_non_monotonic_bump() {
1693        let dir = tempdir().unwrap();
1694        let s = store_in(dir.path());
1695
1696        let mut m = BTreeMap::new();
1697        m.insert(0, NodeId(1));
1698        let v1 = snapshot(m);
1699        s.save_if_absent(&v1).await.unwrap();
1700
1701        // Caller builds v3 but claims prior=1 — enforcing monotonic +1
1702        // catches accidental gap-skipping bugs before they land on
1703        // durable storage.
1704        let mut m2 = BTreeMap::new();
1705        m2.insert(0, NodeId(2));
1706        let v2 = next_snapshot(&v1, m2);
1707        let mut m3 = BTreeMap::new();
1708        m3.insert(0, NodeId(3));
1709        let v3 = next_snapshot(&v2, m3);
1710        let err = s.save_if_version(&v3, 1).await.unwrap_err();
1711        assert!(
1712            matches!(err, SnapshotError::Invalid(msg) if msg.contains("monotonic")),
1713            "non-monotonic bump must surface a clear error",
1714        );
1715    }
1716
1717    #[tokio::test]
1718    async fn save_if_version_rejects_future_prior_without_punching_a_gap() {
1719        let dir = tempdir().unwrap();
1720        let store = store_in(dir.path());
1721        let first = snapshot(BTreeMap::from([(0, NodeId(1))]));
1722        store.save_if_absent(&first).await.unwrap();
1723        let second = first.next(first.vnodes.clone()).unwrap();
1724        store.save_if_version(&second, first.version).await.unwrap();
1725        let third = second.next(second.vnodes.clone()).unwrap();
1726        store.save_if_version(&third, second.version).await.unwrap();
1727        let fourth = third.next(third.vnodes.clone()).unwrap();
1728        let fifth = fourth.next(fourth.vnodes.clone()).unwrap();
1729        let sixth = fifth.next(fifth.vnodes.clone()).unwrap();
1730
1731        assert!(matches!(
1732            store.save_if_version(&sixth, fifth.version).await,
1733            Err(SnapshotError::Invalid(message)) if message.contains("durable head 5")
1734        ));
1735        assert_eq!(store.list_versions().await.unwrap(), vec![1, 2, 3]);
1736        assert_eq!(store.load().await.unwrap().unwrap(), third);
1737    }
1738
1739    #[tokio::test]
1740    async fn save_if_version_succeeds_on_match() {
1741        let dir = tempdir().unwrap();
1742        let s = store_in(dir.path());
1743
1744        let mut v1_map = BTreeMap::new();
1745        v1_map.insert(0, NodeId(1));
1746        let first = snapshot(v1_map);
1747        s.save_if_absent(&first).await.unwrap();
1748
1749        let mut v2_map = BTreeMap::new();
1750        v2_map.insert(0, NodeId(2));
1751        let second = next_snapshot(&first, v2_map);
1752        let outcome = s.save_if_version(&second, first.version).await.unwrap();
1753        assert!(matches!(outcome, RotateOutcome::Rotated));
1754
1755        let loaded = s.load().await.unwrap().unwrap();
1756        assert_eq!(loaded, second);
1757    }
1758
1759    #[tokio::test]
1760    async fn save_if_version_conflict_surfaces_winner() {
1761        // Two racing rotations both propose v2 from v1. CAS at
1762        // `v{2}.json` picks one; the loser reloads and finds the
1763        // winner's canonical snapshot.
1764        let dir = tempdir().unwrap();
1765        let s = store_in(dir.path());
1766
1767        let mut seed = BTreeMap::new();
1768        seed.insert(0, NodeId(1));
1769        let v1 = snapshot(seed);
1770        s.save_if_absent(&v1).await.unwrap();
1771
1772        let mut winner_map = BTreeMap::new();
1773        winner_map.insert(0, NodeId(10));
1774        let winner = next_snapshot(&v1, winner_map);
1775        assert!(matches!(
1776            s.save_if_version(&winner, v1.version).await.unwrap(),
1777            RotateOutcome::Rotated,
1778        ));
1779
1780        let mut loser_map = BTreeMap::new();
1781        loser_map.insert(0, NodeId(20));
1782        let loser = next_snapshot(&v1, loser_map);
1783        match s.save_if_version(&loser, v1.version).await.unwrap() {
1784            RotateOutcome::Conflict(current) => {
1785                assert_eq!(
1786                    *current, winner,
1787                    "conflict must surface the winner's snapshot",
1788                );
1789            }
1790            RotateOutcome::Rotated => {
1791                panic!("stale-token update must not win the CAS");
1792            }
1793        }
1794
1795        let loaded = s.load().await.unwrap().unwrap();
1796        assert_eq!(loaded, winner, "stored snapshot is the CAS winner's");
1797    }
1798
1799    #[tokio::test]
1800    async fn prune_before_drops_old_versions() {
1801        let dir = tempdir().unwrap();
1802        let s = store_in(dir.path());
1803
1804        // Seed v1..=v4 by repeatedly rotating.
1805        let mut m = BTreeMap::new();
1806        m.insert(0, NodeId(1));
1807        let mut current = snapshot(m);
1808        s.save_if_absent(&current).await.unwrap();
1809        for _ in 0..3 {
1810            let next = current.next(current.vnodes.clone()).unwrap();
1811            s.save_if_version(&next, current.version).await.unwrap();
1812            current = next;
1813        }
1814
1815        s.prune_before(3).await.unwrap();
1816
1817        assert!(s.load_version(1).await.unwrap().is_none());
1818        assert!(s.load_version(2).await.unwrap().is_none());
1819        assert!(s.load_version(3).await.unwrap().is_some());
1820        assert!(s.load_version(4).await.unwrap().is_some());
1821        // `load()` still returns the most recent surviving snapshot.
1822        assert_eq!(s.load().await.unwrap().unwrap().version, 4);
1823    }
1824
1825    #[tokio::test]
1826    async fn prune_stops_at_first_delete_failure_without_punching_a_gap() {
1827        use crate::cluster::testing::{FaultyObjectStore, ObjectStoreFault};
1828        use object_store::memory::InMemory;
1829
1830        let inner: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
1831        let faulty = Arc::new(FaultyObjectStore::new(inner));
1832        let wrapped: Arc<dyn ObjectStore> = faulty.clone();
1833        let store = AssignmentSnapshotStore::new(wrapped);
1834        let mut current = snapshot(BTreeMap::from([(0, NodeId(1))]));
1835        store.save_if_absent(&current).await.unwrap();
1836        for _ in 0..2 {
1837            let next = current.next(current.vnodes.clone()).unwrap();
1838            store.save_if_version(&next, current.version).await.unwrap();
1839            current = next;
1840        }
1841
1842        faulty.set_fault(ObjectStoreFault::FailWrites);
1843        assert!(matches!(
1844            store.prune_before(3).await,
1845            Err(SnapshotError::Io(_))
1846        ));
1847        faulty.set_fault(ObjectStoreFault::None);
1848        assert_eq!(store.list_versions().await.unwrap(), vec![1, 2, 3]);
1849    }
1850
1851    #[test]
1852    fn empty_starts_at_version_zero() {
1853        let s = AssignmentSnapshot::empty();
1854        assert_eq!(s.version, 0);
1855        assert!(s.vnodes.is_empty());
1856    }
1857
1858    #[test]
1859    fn next_bumps_version() {
1860        let mut vnodes = BTreeMap::new();
1861        vnodes.insert(0, NodeId(1));
1862        let s = snapshot(vnodes);
1863        assert_eq!(s.version, 1);
1864    }
1865
1866    #[test]
1867    fn roundtrip_vec_conversions() {
1868        let assignment = vec![NodeId(1), NodeId(2), NodeId(1), NodeId(2)];
1869        let map = AssignmentSnapshot::vnodes_from_vec(&assignment);
1870        let snap = snapshot(map);
1871        let back = snap
1872            .to_vnode_vec(u32::try_from(assignment.len()).expect("test len fits u32"))
1873            .unwrap();
1874        assert_eq!(back, assignment);
1875    }
1876
1877    #[test]
1878    fn dense_conversion_rejects_smaller_and_larger_runtime_cardinality() {
1879        let snap = snapshot(BTreeMap::from([(0, NodeId(1)), (1, NodeId(1))]));
1880        for count in [1, 3] {
1881            assert!(matches!(
1882                snap.to_vnode_vec(count),
1883                Err(SnapshotError::Invalid(message)) if message.contains("vnode cardinality")
1884            ));
1885        }
1886    }
1887
1888    #[tokio::test]
1889    async fn recovery_proposal_stage_and_materialization_are_idempotent() {
1890        let backing: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
1891        let store = AssignmentSnapshotStore::new(backing);
1892        let predecessor = snapshot(BTreeMap::from([(0, NodeId(1))]));
1893        store.save_if_absent(&predecessor).await.unwrap();
1894        let proposal = next_snapshot(&predecessor, BTreeMap::from([(0, NodeId(2))]));
1895
1896        let first_reference = store.stage_recovery_proposal(&proposal).await.unwrap();
1897        let retry_reference = store.stage_recovery_proposal(&proposal).await.unwrap();
1898        assert_eq!(retry_reference, first_reference);
1899        assert_eq!(
1900            store
1901                .load_recovery_proposal(&first_reference)
1902                .await
1903                .unwrap(),
1904            proposal
1905        );
1906        assert!(matches!(
1907            store.materialize_recovery(&first_reference).await.unwrap(),
1908            RotateOutcome::Rotated
1909        ));
1910        assert!(matches!(
1911            store.materialize_recovery(&first_reference).await.unwrap(),
1912            RotateOutcome::Conflict(existing) if *existing == proposal
1913        ));
1914    }
1915
1916    #[tokio::test]
1917    async fn recovery_materialization_surfaces_a_different_same_version_winner() {
1918        let backing: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
1919        let store = AssignmentSnapshotStore::new(backing);
1920        let predecessor = snapshot(BTreeMap::from([(0, NodeId(1))]));
1921        store.save_if_absent(&predecessor).await.unwrap();
1922        let winner = next_snapshot(&predecessor, BTreeMap::from([(0, NodeId(2))]));
1923        let loser = next_snapshot(&predecessor, BTreeMap::from([(0, NodeId(3))]));
1924        let winner_reference = store.stage_recovery_proposal(&winner).await.unwrap();
1925        let loser_reference = store.stage_recovery_proposal(&loser).await.unwrap();
1926
1927        assert!(matches!(
1928            store.materialize_recovery(&winner_reference).await.unwrap(),
1929            RotateOutcome::Rotated
1930        ));
1931        assert!(matches!(
1932            store.materialize_recovery(&loser_reference).await.unwrap(),
1933            RotateOutcome::Conflict(existing) if *existing == winner
1934        ));
1935    }
1936
1937    #[tokio::test]
1938    async fn recovery_retention_removes_winning_and_losing_staged_bodies() {
1939        let backing = Arc::new(object_store::memory::InMemory::new());
1940        let store = AssignmentSnapshotStore::new(backing.clone());
1941        let predecessor = snapshot(BTreeMap::from([(0, NodeId(1))]));
1942        store.save_if_absent(&predecessor).await.unwrap();
1943        let winner = next_snapshot(&predecessor, BTreeMap::from([(0, NodeId(2))]));
1944        let loser = next_snapshot(&predecessor, BTreeMap::from([(0, NodeId(3))]));
1945        let winner_reference = store.stage_recovery_proposal(&winner).await.unwrap();
1946        let loser_reference = store.stage_recovery_proposal(&loser).await.unwrap();
1947        assert!(matches!(
1948            store.materialize_recovery(&winner_reference).await.unwrap(),
1949            RotateOutcome::Rotated
1950        ));
1951        let successor = next_snapshot(&winner, winner.vnodes.clone());
1952        assert!(matches!(
1953            store
1954                .save_if_version(&successor, winner.version)
1955                .await
1956                .unwrap(),
1957            RotateOutcome::Rotated
1958        ));
1959
1960        store.prune_before(successor.version).await.unwrap();
1961
1962        for reference in [&winner_reference, &loser_reference] {
1963            assert!(matches!(
1964                backing.get(&recovery_proposal_path(reference)).await,
1965                Err(object_store::Error::NotFound { .. })
1966            ));
1967        }
1968        assert!(matches!(
1969            backing
1970                .get(&recovery_materialization_path(winner.version))
1971                .await,
1972            Err(object_store::Error::NotFound { .. })
1973        ));
1974        assert!(store.load_version(winner.version).await.unwrap().is_none());
1975        assert_eq!(store.load().await.unwrap(), Some(successor));
1976    }
1977
1978    #[tokio::test]
1979    async fn recovery_materialization_rejects_a_tampered_staged_body() {
1980        let backing = Arc::new(object_store::memory::InMemory::new());
1981        let store = AssignmentSnapshotStore::new(backing.clone());
1982        let predecessor = snapshot(BTreeMap::from([(0, NodeId(1))]));
1983        store.save_if_absent(&predecessor).await.unwrap();
1984        let proposal = next_snapshot(&predecessor, BTreeMap::from([(0, NodeId(2))]));
1985        let reference = store.stage_recovery_proposal(&proposal).await.unwrap();
1986        let (mut tampered, encoded_reference) = proposal.encode_recovery_proposal().unwrap();
1987        assert_eq!(encoded_reference, reference);
1988        let marker = b"\"updated_at_ms\":";
1989        let value_start = tampered
1990            .windows(marker.len())
1991            .position(|window| window == marker)
1992            .unwrap()
1993            + marker.len();
1994        let digit = tampered[value_start..]
1995            .iter()
1996            .position(u8::is_ascii_digit)
1997            .map(|offset| value_start + offset)
1998            .unwrap();
1999        tampered[digit] = if tampered[digit] == b'9' { b'8' } else { b'9' };
2000        backing
2001            .put(
2002                &recovery_proposal_path(&reference),
2003                PutPayload::from(Bytes::from(tampered)),
2004            )
2005            .await
2006            .unwrap();
2007
2008        assert!(matches!(
2009            store.load_recovery_proposal(&reference).await,
2010            Err(SnapshotError::Invalid(message)) if message.contains("content-addressed reference")
2011        ));
2012        assert!(store.materialize_recovery(&reference).await.is_err());
2013        assert_eq!(store.load().await.unwrap(), Some(predecessor));
2014    }
2015
2016    #[test]
2017    fn draining_survives_roundtrip() {
2018        let committed = AssignmentSnapshot::empty()
2019            .next_for_participants(BTreeMap::from([(0, NodeId(1))]), vec![participant(1, 1)])
2020            .unwrap();
2021        assert!(!committed.draining);
2022
2023        let drain = committed
2024            .next_draining(
2025                BTreeMap::from([(0, NodeId(2))]),
2026                vec![participant(2, 2)],
2027                leader(1, 1, 7),
2028            )
2029            .unwrap();
2030        let json = serde_json::to_vec(&drain).unwrap();
2031        let back: AssignmentSnapshot = serde_json::from_slice(&json).unwrap();
2032        back.validate().unwrap();
2033        assert!(back.draining);
2034        assert_eq!(back.drain_transition, drain.drain_transition);
2035        assert_eq!(back.version, drain.version);
2036    }
2037
2038    #[tokio::test]
2039    async fn drain_finalization_commits_the_certified_target_version() {
2040        let directory = tempdir().unwrap();
2041        let store = store_in(directory.path());
2042        let committed = AssignmentSnapshot::empty()
2043            .next_for_participants(BTreeMap::from([(0, NodeId(1))]), vec![participant(1, 1)])
2044            .unwrap();
2045        store.save_if_absent(&committed).await.unwrap();
2046        let drain = committed
2047            .next_draining(
2048                BTreeMap::from([(0, NodeId(2))]),
2049                vec![participant(2, 2)],
2050                leader(1, 1, 7),
2051            )
2052            .unwrap();
2053        store
2054            .save_if_version(&drain, committed.version)
2055            .await
2056            .unwrap();
2057        let transition = drain.drain_transition.as_ref().unwrap().clone();
2058        let target = drain.committed_target().unwrap();
2059
2060        assert!(matches!(
2061            store.finalize_drain(&drain, &target).await.unwrap(),
2062            RotateOutcome::Rotated
2063        ));
2064        let loaded = store.load().await.unwrap().unwrap();
2065        assert_eq!(loaded, target);
2066        assert_eq!(loaded.version, drain.version);
2067        assert_eq!(
2068            loaded.assignment_fence().unwrap(),
2069            transition.target.clone()
2070        );
2071        assert_eq!(
2072            store.load_drain_transition(drain.version).await.unwrap(),
2073            Some(transition)
2074        );
2075    }
2076
2077    #[tokio::test]
2078    async fn concurrent_drain_commit_and_abort_have_one_append_only_winner() {
2079        let memory: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
2080        let left = AssignmentSnapshotStore::new(Arc::clone(&memory));
2081        let right = AssignmentSnapshotStore::new(memory);
2082        let predecessor = AssignmentSnapshot::empty()
2083            .next_for_participants(BTreeMap::from([(0, NodeId(1))]), vec![participant(1, 1)])
2084            .unwrap();
2085        left.save_if_absent(&predecessor).await.unwrap();
2086        let drain = predecessor
2087            .next_draining(
2088                BTreeMap::from([(0, NodeId(2))]),
2089                vec![participant(2, 2)],
2090                leader(1, 1, 9),
2091            )
2092            .unwrap();
2093        left.save_if_version(&drain, predecessor.version)
2094            .await
2095            .unwrap();
2096        let commit = drain.committed_target().unwrap();
2097        let abort = drain.aborted_target(&predecessor).unwrap();
2098
2099        let (commit_result, abort_result) = tokio::join!(
2100            left.finalize_drain(&drain, &commit),
2101            right.finalize_drain(&drain, &abort)
2102        );
2103        let outcomes = [commit_result.unwrap(), abort_result.unwrap()];
2104        assert_eq!(
2105            outcomes
2106                .iter()
2107                .filter(|outcome| matches!(outcome, RotateOutcome::Rotated))
2108                .count(),
2109            1
2110        );
2111        assert_eq!(
2112            outcomes
2113                .iter()
2114                .filter(|outcome| matches!(outcome, RotateOutcome::Conflict(_)))
2115                .count(),
2116            1
2117        );
2118        let loaded = left.load().await.unwrap().unwrap();
2119        assert!(loaded == commit || loaded == abort);
2120    }
2121}