1use 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct AssignmentSnapshot {
111 pub version: u64,
113 pub partitioning_abi_version: u16,
115 pub vnodes: BTreeMap<u32, NodeId>,
119 pub participants: Vec<CheckpointParticipant>,
122 pub updated_at_ms: i64,
124 #[serde(default)]
130 pub draining: bool,
131 #[serde(default)]
134 pub drain_transition: Option<AssignmentDrainTransition>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct AssignmentSnapshotRef {
141 pub version: u64,
143 pub sha256: String,
145 pub encoded_len: u64,
147}
148
149#[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 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#[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 #[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 pub fn next(&self, vnodes: BTreeMap<u32, NodeId>) -> Result<Self, SnapshotError> {
311 self.next_for_participants(vnodes, self.participants.clone())
312 }
313
314 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 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 pub fn assignment_fence(&self) -> Result<CheckpointAssignmentFence, SnapshotError> {
455 self.validate_assignment()?;
456 self.assignment_fence_unchecked()
457 }
458
459 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 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 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 #[must_use]
538 pub fn has_canonical_participants(&self) -> bool {
539 self.validate().is_ok()
540 }
541
542 #[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 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
582pub struct AssignmentSnapshotStore {
584 store: Arc<dyn ObjectStore>,
585 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#[derive(Debug, thiserror::Error)]
611pub enum SnapshotError {
612 #[error("object store I/O: {0}")]
614 Io(String),
615 #[error("JSON: {0}")]
617 Json(#[from] serde_json::Error),
618 #[error("invalid snapshot: {0}")]
620 Invalid(String),
621}
622
623impl AssignmentSnapshotStore {
624 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
1315pub enum RotateOutcome {
1316 Rotated,
1318 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 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 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 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 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 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 let mut m = BTreeMap::new();
1806 m.insert(0, NodeId(1));
1807 let mut current = snapshot(m);
1808 s.save_if_absent(¤t).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 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(¤t).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}