1#![cfg(feature = "cluster")]
4#![allow(clippy::disallowed_types)] mod drain_admission;
7mod head_validation;
8mod placement_metrics;
9use head_validation::{assignment_publication_error, validate_local_assignment_head};
10
11use std::sync::{atomic::AtomicU64, atomic::Ordering, Arc};
12use std::time::Duration;
13
14use laminar_connectors::connector::{SourceDrainOutcome, SourceDrainResolution};
15use laminar_core::checkpoint::{
16 AssignmentDrainId, AssignmentDrainTransition, CheckpointAttempt, CheckpointAttemptRelation,
17 CheckpointScope, CommittedCheckpointIndex, CommittedCheckpointRef,
18};
19use laminar_core::checkpoint_decision::{
20 CheckpointArtifactInventory, CheckpointOutcome, CheckpointVerdict, RecordOutcomeResult,
21};
22use laminar_core::cluster::control::{
23 AssignmentDrainDecision, AssignmentDrainVerdict, AssignmentRecoveryDecision,
24 AssignmentSnapshot, AssignmentSnapshotStore, CheckpointAssignmentAdoption,
25 CheckpointAssignmentFence, CheckpointParticipant, ClusterController, LeaderLeaseStore,
26 LeaderProof, ProcessLeaseFence, RecordAssignmentDrainDecisionResult,
27 RecordAssignmentRecoveryDecisionResult, RotateOutcome, SnapshotError,
28};
29use laminar_core::cluster::discovery::NodeState;
30use laminar_core::state::{rendezvous_assignment, NodeId, VnodeRegistry};
31#[cfg(test)]
32use tokio::sync::Notify;
33use tokio::task::JoinHandle;
34use tokio::time::{Instant, MissedTickBehavior};
35use tokio_util::sync::CancellationToken;
36use tracing::{debug, info, warn};
37
38use crate::db::{DbState, LaminarDB};
39
40mod recovery_adoption;
41use recovery_adoption::{
42 adopt_materialized_recovery_head, ensure_local_recovery_fault, local_recovery_assignment_scope,
43 prepare_recovery_assignment_adoption, prepare_watched_recovery_adoption,
44 LocalRecoveryAssignmentScope,
45};
46
47#[derive(Debug, Clone, Copy)]
49pub struct RebalanceConfig {
50 pub watcher_poll: Duration,
52 pub rebalance_debounce: Duration,
54 pub checkpoint_timeout: Duration,
57 pub retry_delay: Duration,
59 pub drain_ack_timeout: Duration,
62 pub placement_isolation_tier: usize,
64}
65
66impl Default for RebalanceConfig {
67 fn default() -> Self {
68 Self {
69 watcher_poll: Duration::from_secs(2),
70 rebalance_debounce: Duration::from_secs(2),
72 checkpoint_timeout: Duration::from_secs(15),
75 retry_delay: Duration::from_secs(2),
76 drain_ack_timeout: Duration::from_secs(10),
77 placement_isolation_tier: 0,
78 }
79 }
80}
81
82impl RebalanceConfig {
83 #[doc(hidden)]
85 #[must_use]
86 pub fn test_defaults() -> Self {
87 Self {
88 watcher_poll: Duration::from_millis(200),
89 rebalance_debounce: Duration::from_millis(500),
90 checkpoint_timeout: Duration::from_secs(30),
91 retry_delay: Duration::from_millis(500),
92 drain_ack_timeout: Duration::from_secs(5),
93 placement_isolation_tier: 0,
94 }
95 }
96}
97
98async fn close_local_assignment_authority(
99 db: &Arc<LaminarDB>,
100 controller: &ClusterController,
101 recovery_target: &CheckpointAssignmentFence,
102 removed_processes: &[CheckpointParticipant],
103 deadline: tokio::time::Instant,
104) -> Result<Vec<ProcessLeaseFence>, String> {
105 db.set_source_gate(true);
106 controller.publish_checkpoint_assignment_fence(None);
107 controller.publish_checkpoint_drain_transition(None);
108 db.invalidate_shuffle_assignment_fence();
110 let _adoption = tokio::time::timeout_at(deadline, db.assignment_adoption_lock.lock())
111 .await
112 .map_err(|_| "timed out serializing assignment authority closure".to_string())?;
113 db.set_source_gate(true);
117 controller.publish_checkpoint_assignment_fence(None);
118 controller.publish_checkpoint_drain_transition(None);
119 db.invalidate_shuffle_assignment_fence();
120 let fence_results = futures::future::join_all(
121 removed_processes
122 .iter()
123 .copied()
124 .map(|participant| controller.fence_process_incarnation(participant, deadline)),
125 )
126 .await;
127 let mut process_fences = Vec::with_capacity(fence_results.len());
128 for result in fence_results {
129 process_fences.push(result?);
130 }
131 if recovery_target.participant_incarnation(controller.instance_id().0)
135 == Some(controller.recovery_incarnation())
136 {
137 ensure_local_recovery_fault(db, controller).await?;
138 }
139 let _transition = tokio::time::timeout_at(
140 deadline,
141 Arc::clone(&db.rotation_execution_fence).write_owned(),
142 )
143 .await
144 .map_err(|_| "timed out draining assignment execution after closure".to_string())?;
145 Ok(process_fences)
146}
147
148async fn suspend_local_assignment_authority(
151 db: &Arc<LaminarDB>,
152 controller: Option<&ClusterController>,
153 deadline: tokio::time::Instant,
154) -> Result<(), String> {
155 db.set_source_gate(true);
156 if let Some(controller) = controller {
157 controller.publish_checkpoint_assignment_fence(None);
158 controller.publish_checkpoint_drain_transition(None);
159 }
160 db.suspend_shuffle_assignment_fence();
163 let _adoption = tokio::time::timeout_at(deadline, db.assignment_adoption_lock.lock())
164 .await
165 .map_err(|_| "timed out serializing assignment authority suspension".to_string())?;
166 suspend_local_assignment_authority_locked(db, controller, deadline).await
167}
168
169async fn suspend_local_assignment_authority_locked(
171 db: &Arc<LaminarDB>,
172 controller: Option<&ClusterController>,
173 deadline: tokio::time::Instant,
174) -> Result<(), String> {
175 db.set_source_gate(true);
178 if let Some(controller) = controller {
179 controller.publish_checkpoint_assignment_fence(None);
180 controller.publish_checkpoint_drain_transition(None);
181 }
182 db.suspend_shuffle_assignment_fence();
183 let _transition = tokio::time::timeout_at(
184 deadline,
185 Arc::clone(&db.rotation_execution_fence).write_owned(),
186 )
187 .await
188 .map_err(|_| "timed out draining assignment execution after suspension".to_string())?;
189 Ok(())
190}
191
192async fn try_suspend_recovery_assignment_authority(
196 db: &Arc<LaminarDB>,
197 controller: &ClusterController,
198 deadline: tokio::time::Instant,
199 held: &mut Option<u64>,
200) -> Result<bool, String> {
201 let _adoption = tokio::time::timeout_at(deadline, db.assignment_adoption_lock.lock())
202 .await
203 .map_err(|_| "timed out serializing recovery assignment suspension".to_string())?;
204 if db.has_unapplied_vnode_transition() {
205 return Ok(false);
206 }
207 let revision = db
211 .assignment_authority_revision
212 .load(std::sync::atomic::Ordering::Acquire);
213 if *held == Some(revision)
214 && db.cluster_intake_fenced()
215 && controller.checkpoint_assignment_watch().borrow().is_none()
216 {
217 return Ok(true);
218 }
219 suspend_local_assignment_authority_locked(db, Some(controller), deadline).await?;
220 *held = Some(
221 db.assignment_authority_revision
222 .load(std::sync::atomic::Ordering::Acquire),
223 );
224 Ok(true)
225}
226
227async fn hold_terminal_source_resolution(
228 db: &Arc<LaminarDB>,
229 controller: Option<&ClusterController>,
230 round: AssignmentDrainId,
231 deadline: tokio::time::Instant,
232 held: &mut Option<(AssignmentDrainId, u64)>,
233) -> Result<u64, String> {
234 let revision = db
235 .assignment_authority_revision
236 .load(std::sync::atomic::Ordering::Acquire);
237 let controller_closed = controller.is_none_or(|controller| {
238 controller
239 .checkpoint_assignment_fence(round.predecessor_version)
240 .is_none()
241 && controller
242 .checkpoint_assignment_fence(round.target_version)
243 .is_none()
244 });
245 if *held == Some((round, revision)) && db.cluster_intake_fenced() && controller_closed {
246 return Ok(revision);
247 }
248
249 suspend_local_assignment_authority(db, controller, deadline).await?;
250 let revision = db
251 .assignment_authority_revision
252 .load(std::sync::atomic::Ordering::Acquire);
253 *held = Some((round, revision));
254 Ok(revision)
255}
256
257struct SnapshotWatcher {
263 db: Arc<LaminarDB>,
264 store: Arc<AssignmentSnapshotStore>,
265 registry: Arc<VnodeRegistry>,
266 shutdown: CancellationToken,
267 config: RebalanceConfig,
268 controller: Option<Arc<ClusterController>>,
269 ticker: tokio::time::Interval,
270 metrics_version: u64,
271 last_drained: u64,
272 durable_snapshot: Option<AssignmentSnapshot>,
273 durable_drain_transition: Option<AssignmentDrainTransition>,
274 active_local_drain: Option<AssignmentDrainTransition>,
275 terminal_resolution_hold: Option<(AssignmentDrainId, u64)>,
276 installed_fence: Option<(u64, [u8; 32])>,
277 installed_authority_revision: u64,
278 assignment_authority_dirty: bool,
279}
280
281impl SnapshotWatcher {
282 fn new(
283 db: Arc<LaminarDB>,
284 store: Arc<AssignmentSnapshotStore>,
285 registry: Arc<VnodeRegistry>,
286 shutdown: CancellationToken,
287 config: RebalanceConfig,
288 controller: Option<Arc<ClusterController>>,
289 ) -> Self {
290 let mut ticker = tokio::time::interval(config.watcher_poll);
291 ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
292 Self {
293 db,
294 store,
295 registry,
296 shutdown,
297 config,
298 controller,
299 ticker,
300 metrics_version: 0,
301 last_drained: 0,
302 durable_snapshot: None,
303 durable_drain_transition: None,
304 active_local_drain: None,
305 terminal_resolution_hold: None,
306 installed_fence: None,
307 installed_authority_revision: 0,
308 assignment_authority_dirty: false,
309 }
310 }
311
312 async fn install_checkpoint_drain_transition(
317 &self,
318 draining: &AssignmentSnapshot,
319 transition: &AssignmentDrainTransition,
320 predecessor: AssignmentSnapshot,
321 deadline: tokio::time::Instant,
322 ) -> Result<AssignmentSnapshot, String> {
323 let Some(controller) = self.controller.as_deref() else {
324 return Ok(predecessor);
325 };
326 if controller.checkpoint_drain_transition().as_ref() == Some(transition) {
327 return Ok(predecessor);
328 }
329 let _adoption = tokio::time::timeout_at(deadline, self.db.assignment_adoption_lock.lock())
330 .await
331 .map_err(|_| {
332 "snapshot watcher timed out serializing source-drain activation".to_string()
333 })?;
334 let predecessor =
335 audit_exact_drain_head(&self.store, &self.registry, draining, controller, deadline)
336 .await?;
337 self.db
338 .validate_source_drain_snapshot(draining)
339 .map_err(|error| error.to_string())?;
340 match controller.checkpoint_drain_transition() {
341 None => controller.publish_checkpoint_drain_transition(Some(transition.clone())),
342 Some(installed) if installed == *transition => {}
343 Some(_) => {
344 return Err(
345 "snapshot watcher found a conflicting local source-drain transition".into(),
346 );
347 }
348 }
349 Ok(predecessor)
350 }
351
352 async fn ensure_current_assignment_authority_cached(
353 &mut self,
354 audited_successor: &AuditedAssignmentAuthority,
355 deadline: tokio::time::Instant,
356 ) -> Result<(), String> {
357 let assignment = self.registry.versioned_snapshot();
358 let version = assignment.version();
359 let expected_predecessor = audited_successor.retained_predecessor(version)?;
360 let cache_is_exact = self.durable_snapshot.as_ref().is_some_and(|snapshot| {
361 !snapshot.draining
362 && snapshot.version == version
363 && snapshot.has_canonical_participants()
364 && snapshot
365 .assignment_fence()
366 .is_ok_and(|fence| &fence == expected_predecessor)
367 && snapshot
368 .to_vnode_vec(self.registry.vnode_count())
369 .is_ok_and(|owners| owners.as_slice() == assignment.owners())
370 });
371 if cache_is_exact {
372 return Ok(());
373 }
374
375 let snapshot = tokio::time::timeout_at(deadline, self.store.load_version(version))
376 .await
377 .map_err(|_| format!("assignment {version} history read timed out"))?
378 .map_err(|error| error.to_string())?
379 .ok_or_else(|| format!("assignment {version} is absent from durable history"))?;
380 if snapshot.draining
381 || !snapshot.has_canonical_participants()
382 || snapshot
383 .to_vnode_vec(self.registry.vnode_count())
384 .map_err(|error| error.to_string())?
385 .as_slice()
386 != assignment.owners()
387 {
388 return Err(format!(
389 "assignment {version} durable history does not match the current registry"
390 ));
391 }
392 if snapshot
393 .assignment_fence()
394 .map_err(|error| error.to_string())?
395 != *expected_predecessor
396 {
397 return Err(format!(
398 "assignment {version} durable history is not the audited successor's exact predecessor"
399 ));
400 }
401 self.durable_drain_transition = None;
402 self.durable_snapshot = Some(snapshot);
403 Ok(())
404 }
405
406 async fn publish_authority(
407 &mut self,
408 mut authority_revision: u64,
409 operation_timeout: Duration,
410 ) {
411 let (head_deadline, current_authority_revision) = (
413 Instant::now() + operation_timeout,
414 AtomicU64::load(&self.db.assignment_authority_revision, Ordering::Acquire),
415 );
416 if authority_revision != current_authority_revision {
417 self.db.set_source_gate(true);
420 if let Some(ref c) = self.controller {
421 c.publish_checkpoint_drain_transition(None);
422 c.publish_checkpoint_assignment_fence(None);
423 }
424 self.assignment_authority_dirty = true;
425 return;
426 }
427 if self.installed_fence.is_some()
428 && self.installed_authority_revision != current_authority_revision
429 {
430 self.assignment_authority_dirty = true;
431 }
432
433 let Ok(adoption_guard) =
437 tokio::time::timeout_at(head_deadline, self.db.assignment_adoption_lock.lock()).await
438 else {
439 self.assignment_authority_dirty = true;
440 warn!(
441 "vnode-state readiness publication timed out waiting for assignment serialization"
442 );
443 return;
444 };
445 let assignment = self.registry.versioned_snapshot();
446 let version = assignment.version();
447 if let Some(ref c) = self.controller {
448 let participant = CheckpointParticipant {
449 node_id: c.instance_id().0,
450 boot_incarnation: c.recovery_incarnation(),
451 };
452 let exact_assignment_fence = self
453 .durable_snapshot
454 .as_ref()
455 .filter(|snapshot| {
456 !snapshot.draining
457 && snapshot.version == version
458 && snapshot.has_canonical_participants()
459 && snapshot
460 .to_vnode_vec(self.registry.vnode_count())
461 .is_ok_and(|owners| owners.as_slice() == assignment.owners())
462 })
463 .and_then(|snapshot| snapshot.assignment_fence().ok());
464 let local_is_participant = exact_assignment_fence.as_ref().is_some_and(|fence| {
465 fence.participant_incarnation(participant.node_id)
466 == Some(participant.boot_incarnation)
467 });
468 if let Some(exact_assignment_fence) = exact_assignment_fence {
469 let vnode_state_ready = if local_is_participant {
470 match tokio::time::timeout_at(
471 head_deadline,
472 self.db
473 .local_vnode_state_is_ready(&self.registry, &exact_assignment_fence),
474 )
475 .await
476 {
477 Ok(Ok(ready)) => ready,
478 Ok(Err(error)) => {
479 warn!(%error, version, "could not determine local vnode-state readiness");
480 drop(adoption_guard);
481 return;
482 }
483 Err(_) => {
484 warn!(
485 version,
486 "local vnode-state readiness exceeded the head deadline"
487 );
488 drop(adoption_guard);
489 return;
490 }
491 }
492 } else {
493 false
497 };
498 let publication =
502 self.db
503 .publish_local_vnode_state_report(c, &assignment, vnode_state_ready);
504 match tokio::time::timeout_at(head_deadline, publication).await {
505 Ok(Ok(_)) => {}
506 Ok(Err(error)) => {
507 warn!(%error, version, "assignment vnode-state report publication failed; retrying");
508 drop(adoption_guard);
509 return;
510 }
511 Err(_) => {
512 warn!(
513 version,
514 "assignment vnode-state report publication exceeded the head deadline"
515 );
516 drop(adoption_guard);
517 return;
518 }
519 }
520 }
521 }
522 drop(adoption_guard);
523 if version != self.metrics_version {
524 self.metrics_version = version;
525 if let (Some(c), Some(metrics)) = (self.controller.as_ref(), self.db.engine_metrics()) {
526 placement_metrics::publish(
527 &metrics.placement,
528 &self.registry,
529 NodeId(c.instance_id().0),
530 &c.assignable_with_locality(),
531 self.config.placement_isolation_tier,
532 );
533 }
534 }
535
536 if let Some(ref c) = self.controller {
540 let fence = match self.durable_snapshot.as_ref() {
541 Some(snapshot)
542 if !snapshot.draining
543 && snapshot.version == version
544 && snapshot.has_canonical_participants()
545 && snapshot
546 .to_vnode_vec(self.registry.vnode_count())
547 .is_ok_and(|owners| owners.as_slice() == assignment.owners()) =>
548 {
549 if let Ok(fence) = tokio::time::timeout_at(
550 head_deadline,
551 compute_checkpoint_assignment_fence(
552 c,
553 &self.registry,
554 &snapshot.participants,
555 ),
556 )
557 .await
558 {
559 fence.filter(|fence| fence.participants == snapshot.participants)
560 } else {
561 warn!(
562 version,
563 "assignment fence computation exceeded the head deadline"
564 );
565 None
566 }
567 }
568 _ => None,
569 };
570 if authority_revision
571 != self
572 .db
573 .assignment_authority_revision
574 .load(std::sync::atomic::Ordering::Acquire)
575 {
576 self.db.set_source_gate(true);
577 c.publish_checkpoint_drain_transition(None);
578 c.publish_checkpoint_assignment_fence(None);
579 self.assignment_authority_dirty = true;
580 return;
581 }
582 let drain_transition = self
583 .durable_drain_transition
584 .as_ref()
585 .filter(|transition| fence.as_ref() == Some(&transition.predecessor))
586 .cloned();
587 match fence {
588 Some(fence) => {
589 let identity = (fence.assignment_version, fence.digest());
590 let local_has_authority = fence.participant_incarnation(c.instance_id().0)
591 == Some(c.recovery_incarnation());
592 let published_fence = c.checkpoint_assignment_fence(fence.assignment_version);
593 let needs_activation = self.assignment_authority_dirty
594 || self.installed_fence != Some(identity)
595 || published_fence.as_ref() != Some(&fence)
596 || c.checkpoint_drain_transition() != drain_transition
597 || (local_has_authority
598 && !c.is_recovering()
599 && drain_transition.is_none()
600 && self.db.cluster_intake_fenced());
601 if needs_activation {
602 if self.installed_fence.is_some() && self.installed_fence != Some(identity)
603 {
604 self.db.set_source_gate(true);
609 c.publish_checkpoint_drain_transition(None);
610 c.publish_checkpoint_assignment_fence(None);
611 self.db.suspend_shuffle_assignment_fence();
612 authority_revision = self
613 .db
614 .assignment_authority_revision
615 .load(std::sync::atomic::Ordering::Acquire);
616 self.installed_fence = None;
617 }
618 match self
619 .db
620 .activate_watcher_assignment_authority(
621 c,
622 &fence,
623 drain_transition,
624 authority_revision,
625 head_deadline,
626 operation_timeout,
627 )
628 .await
629 {
630 Ok(activation) if activation.installed => {
631 self.installed_fence = Some(identity);
632 self.installed_authority_revision = activation.revision;
633 self.assignment_authority_dirty = false;
634 }
635 Ok(_) => self.assignment_authority_dirty = true,
636 Err(error) => {
637 self.installed_fence = None;
638 self.assignment_authority_dirty = true;
639 warn!(%error, version, "shuffle assignment certificate install failed");
640 }
641 }
642 }
643 }
644 None if !self.assignment_authority_dirty => {
645 self.assignment_authority_dirty = true;
650 match suspend_local_assignment_authority(
651 &self.db,
652 Some(c.as_ref()),
653 head_deadline,
654 )
655 .await
656 {
657 Ok(()) => warn!(
658 version,
659 "owner-complete assignment certificate unavailable; authority suspended"
660 ),
661 Err(error) => warn!(
662 %error,
663 version,
664 "assignment suspension could not drain the prior execution scope"
665 ),
666 }
667 }
668 None => {}
669 }
670 }
671 }
672 async fn settle_terminal_drain(
673 &mut self,
674 snapshot: &AssignmentSnapshot,
675 audited_terminal: Option<&AuditedDrainOutcome>,
676 head_deadline: tokio::time::Instant,
677 authority_revision: &mut u64,
678 ) -> bool {
679 let transition = self.active_local_drain.clone().or_else(|| {
680 audited_terminal.and_then(|audited| {
681 self.controller
682 .as_deref()
683 .and_then(|controller| local_drain_participant(controller, &audited.transition))
684 .map(|_| audited.transition.clone())
685 })
686 });
687 let Some(transition) = transition else {
688 self.terminal_resolution_hold = None;
689 return true;
690 };
691
692 let audited = audited_terminal.filter(|audited| audited.transition == transition);
693 let stopped_terminal_may_defer = match (audited, self.controller.as_deref()) {
698 (Some(audited), Some(controller)) => {
699 let predecessor = audited.transition.predecessor.clone();
700 crate::db::audited_stopped_terminal_round(controller, &predecessor, head_deadline)
701 .await
702 .is_ok_and(|round| round.is_some())
703 && DbState::load(&self.db.state) == DbState::Created
704 && self.db.runtime_shutdown.read().is_cancelled()
705 && self.db.cluster_intake_fenced()
706 && self.db.coordinated_recovery_in_progress()
707 }
708 _ => false,
709 };
710 if stopped_terminal_may_defer {
711 self.active_local_drain = None;
712 self.terminal_resolution_hold = None;
713 return true;
714 }
715 let already_resolved = match audited {
716 Some(audited) => {
717 let resolution = SourceDrainResolution {
718 round: transition.id(),
719 outcome: audited.outcome,
720 };
721 match crate::pipeline::streaming_coordinator::owned_source_drain_resolved(
722 &self.db.owned_source_tasks,
723 resolution,
724 ) {
725 Ok(resolved) => resolved,
726 Err(error) => {
727 self.assignment_authority_dirty = true;
728 let _ = hold_terminal_source_resolution(
729 &self.db,
730 self.controller.as_deref(),
731 transition.id(),
732 head_deadline,
733 &mut self.terminal_resolution_hold,
734 )
735 .await;
736 warn!(%error, version = snapshot.version, "snapshot watcher: source drain status audit failed; assignment authority suspended");
737 return false;
738 }
739 }
740 }
741 None => false,
742 };
743 if already_resolved {
744 self.active_local_drain = None;
745 self.terminal_resolution_hold = None;
746 return true;
747 }
748
749 self.assignment_authority_dirty = true;
750 match hold_terminal_source_resolution(
751 &self.db,
752 self.controller.as_deref(),
753 transition.id(),
754 head_deadline,
755 &mut self.terminal_resolution_hold,
756 )
757 .await
758 {
759 Ok(revision) => *authority_revision = revision,
760 Err(error) => {
761 warn!(%error, version = snapshot.version, "snapshot watcher: could not suspend authority for source drain resolution");
762 return false;
763 }
764 }
765 match settle_observed_local_drain(
766 &self.db,
767 &self.store,
768 &self.registry,
769 self.controller.as_deref(),
770 &transition,
771 snapshot,
772 audited,
773 head_deadline,
774 SourceDrainResolutionDeadline::Fresh(self.config.drain_ack_timeout),
775 )
776 .await
777 {
778 Ok(true) => {
779 self.active_local_drain = None;
780 self.terminal_resolution_hold = None;
781 *authority_revision = self
782 .db
783 .assignment_authority_revision
784 .load(std::sync::atomic::Ordering::Acquire);
785 true
786 }
787 Ok(false) => false,
788 Err(error) => {
789 warn!(%error, version = snapshot.version, "snapshot watcher: local source drain resolution failed; assignment authority suspended");
790 false
791 }
792 }
793 }
794
795 async fn run(mut self) {
796 let mut recovery_suspension_held: Option<u64> = None;
797 loop {
798 tokio::select! {
799 biased;
800 () = self.shutdown.cancelled() => return,
801 _ = self.ticker.tick() => {}
802 }
803 let local = self.registry.assignment_version();
804 let head_deadline = tokio::time::Instant::now() + self.config.checkpoint_timeout;
805 let mut authority_revision = self
806 .db
807 .assignment_authority_revision
808 .load(std::sync::atomic::Ordering::Acquire);
809
810 let audit = tokio::select! {
814 biased;
815 () = self.shutdown.cancelled() => return,
816 result = tokio::time::timeout_at(head_deadline, self.load_materialized_assignment_head()) => result,
817 };
818 let mut audited_target = None;
819 let mut audited_terminal = None;
820 let mut audited_recovery = false;
821 if let Ok(Ok(Some(snapshot))) = &audit {
822 if !snapshot.draining {
823 let materialization_audit = tokio::time::timeout_at(
824 head_deadline,
825 audit_assignment_snapshot_authority_outcome(
826 &self.store,
827 self.controller.as_deref(),
828 snapshot,
829 ),
830 )
831 .await;
832 match materialization_audit {
833 Ok(Ok(outcome)) => {
834 audited_recovery = outcome.is_recovery();
835 audited_terminal = outcome.terminal().cloned();
836 audited_target = Some(outcome);
837 }
838 failed => {
839 self.assignment_authority_dirty = true;
840 let _ = suspend_local_assignment_authority(
841 &self.db,
842 self.controller.as_deref(),
843 head_deadline,
844 )
845 .await;
846 match failed {
847 Ok(Err(error)) => {
848 warn!(%error, version = snapshot.version, "snapshot watcher: drain finalization audit failed; assignment authority suspended");
849 }
850 Err(_) => {
851 warn!(version = snapshot.version, timeout = ?self.config.checkpoint_timeout, "snapshot watcher: drain finalization audit timed out; assignment authority suspended");
852 }
853 Ok(Ok(_)) => unreachable!(),
854 }
855 continue;
856 }
857 }
858 }
859 }
860 if let Ok(Ok(Some(snapshot))) = &audit {
861 if !snapshot.draining
862 && !self
863 .settle_terminal_drain(
864 snapshot,
865 audited_terminal.as_ref(),
866 head_deadline,
867 &mut authority_revision,
868 )
869 .await
870 {
871 continue;
872 }
873 }
874 match audit {
875 Ok(Ok(Some(snap))) if snap.draining && snap.version > local => {
878 let predecessor = tokio::time::timeout_at(
885 head_deadline,
886 audit_drain_predecessor(
887 &self.store,
888 &self.registry,
889 &snap,
890 self.controller.as_deref(),
891 ),
892 )
893 .await;
894 match predecessor {
895 Ok(Ok(predecessor)) => {
896 let transition = snap
897 .drain_transition
898 .as_ref()
899 .expect("validated draining snapshot has a transition")
900 .clone();
901 let predecessor = match self
902 .install_checkpoint_drain_transition(
903 &snap,
904 &transition,
905 predecessor,
906 head_deadline,
907 )
908 .await
909 {
910 Ok(predecessor) => predecessor,
911 Err(error) => {
912 self.durable_snapshot = None;
913 self.durable_drain_transition = None;
914 self.assignment_authority_dirty = true;
915 let _ = suspend_local_assignment_authority(
916 &self.db,
917 self.controller.as_deref(),
918 head_deadline,
919 )
920 .await;
921 warn!(%error, version = snap.version, "snapshot watcher: source-drain activation failed; assignment authority suspended");
922 continue;
923 }
924 };
925 self.durable_drain_transition = Some(transition.clone());
926 self.durable_snapshot = Some(predecessor);
927 if snap.version != self.last_drained {
928 match self.db.validate_source_drain_snapshot(&snap) {
929 Ok(()) => {
930 let acknowledgement = async {
931 let c = self.controller.as_ref().ok_or_else(|| {
932 "assignment drain has no cluster controller"
933 .to_string()
934 })?;
935 let Some(participant) =
936 local_drain_participant(c, &transition)
937 else {
938 return Ok(());
942 };
943 self.active_local_drain = Some(transition.clone());
944 prepare_and_announce_local_drain(
945 &self.db,
946 &self.store,
947 &self.registry,
948 c,
949 &snap,
950 &transition,
951 participant,
952 std::cmp::min(
953 head_deadline,
954 tokio::time::Instant::now()
955 + self.config.drain_ack_timeout,
956 ),
957 )
958 .await
959 }
960 .await;
961 match acknowledgement {
962 Ok(()) => {
963 self.last_drained = snap.version;
964 continue;
968 }
969 Err(error) => {
970 warn!(%error, version = snap.version, "snapshot watcher: durable drain acknowledgement failed; retrying");
971 continue;
972 }
973 }
974 }
975 Err(error) => {
976 warn!(%error, "snapshot watcher: draining adoption failed");
977 }
978 }
979 }
980 }
981 Ok(Err(error)) => {
982 self.durable_snapshot = None;
983 self.durable_drain_transition = None;
984 self.assignment_authority_dirty = true;
985 let _ = suspend_local_assignment_authority(
986 &self.db,
987 self.controller.as_deref(),
988 head_deadline,
989 )
990 .await;
991 warn!(%error, version = snap.version, "snapshot watcher: draining predecessor audit failed; assignment authority suspended");
992 }
993 Err(_) => {
994 self.durable_snapshot = None;
995 self.durable_drain_transition = None;
996 self.assignment_authority_dirty = true;
997 let _ = suspend_local_assignment_authority(
998 &self.db,
999 self.controller.as_deref(),
1000 head_deadline,
1001 )
1002 .await;
1003 warn!(version = snap.version, timeout = ?self.config.checkpoint_timeout, "snapshot watcher: draining predecessor audit timed out; assignment authority suspended");
1004 }
1005 }
1006 }
1007 Ok(Ok(Some(snap))) if !snap.draining && snap.version > local => {
1008 if audited_recovery {
1009 let Some(controller) = self.controller.as_deref() else {
1010 self.assignment_authority_dirty = true;
1011 let _ =
1012 suspend_local_assignment_authority(&self.db, None, head_deadline)
1013 .await;
1014 warn!(
1015 version = snap.version,
1016 "snapshot watcher: recovery assignment has no cluster controller"
1017 );
1018 continue;
1019 };
1020 let recovery_scope = match local_recovery_assignment_scope(
1021 &snap, controller,
1022 ) {
1023 Ok(scope) => scope,
1024 Err(error) => {
1025 self.assignment_authority_dirty = true;
1026 let _ = suspend_local_assignment_authority(
1027 &self.db,
1028 Some(controller),
1029 head_deadline,
1030 )
1031 .await;
1032 warn!(%error, version = snap.version, "snapshot watcher: recovery target does not bind the local process");
1033 continue;
1034 }
1035 };
1036 if recovery_scope == LocalRecoveryAssignmentScope::Participant {
1037 match try_suspend_recovery_assignment_authority(
1038 &self.db,
1039 controller,
1040 head_deadline,
1041 &mut recovery_suspension_held,
1042 )
1043 .await
1044 {
1045 Ok(true) => {}
1046 Ok(false) => {
1047 debug!(
1048 version = snap.version,
1049 "snapshot watcher: recovery assignment waits for local vnode transition"
1050 );
1051 if let Err(error) = self
1052 .ensure_current_assignment_authority_cached(
1053 audited_target.as_ref().expect(
1054 "stable successor was audited before suspension",
1055 ),
1056 Instant::now() + self.config.checkpoint_timeout,
1057 )
1058 .await
1059 {
1060 warn!(%error, version = snap.version, "snapshot watcher: could not audit predecessor authority for pending vnode transition");
1061 continue;
1062 }
1063 self.publish_authority(
1067 authority_revision,
1068 self.config.checkpoint_timeout,
1069 )
1070 .await;
1071 continue;
1072 }
1073 Err(error) => {
1074 self.assignment_authority_dirty = true;
1075 warn!(%error, version = snap.version, "snapshot watcher: could not suspend recovery assignment");
1076 continue;
1077 }
1078 }
1079 self.durable_snapshot = None;
1083 self.durable_drain_transition = None;
1084 self.installed_fence = None;
1085 match prepare_watched_recovery_adoption(
1086 &self.db,
1087 &self.store,
1088 controller,
1089 &self.registry,
1090 &snap,
1091 head_deadline,
1092 )
1093 .await
1094 {
1095 Ok(revision) => authority_revision = revision,
1096 Err(error) => {
1097 self.assignment_authority_dirty = true;
1098 warn!(%error, version = snap.version, "snapshot watcher: recovery assignment preparation failed");
1099 continue;
1100 }
1101 }
1102 self.assignment_authority_dirty = true;
1103 } else {
1104 debug!(
1105 version = snap.version,
1106 "snapshot watcher: ownerless process follows recovery topology without publishing a fault"
1107 );
1108 }
1109 }
1110 if snap.version > self.registry.assignment_version() {
1111 debug!(remote = snap.version, "adopting newer assignment");
1112 let recovery_timeout = self.config.checkpoint_timeout;
1113 let adoption = if audited_recovery {
1114 self.db
1115 .adopt_recovery_assignment_snapshot(snap.clone(), recovery_timeout)
1116 .await
1117 } else {
1118 self.db
1119 .adopt_assignment_snapshot(snap.clone(), head_deadline)
1120 .await
1121 };
1122 match adoption {
1123 Ok(adoption) => {
1124 self.durable_drain_transition = None;
1125 self.durable_snapshot = Some(snap.clone());
1126 authority_revision = self
1127 .db
1128 .assignment_authority_revision
1129 .load(std::sync::atomic::Ordering::Acquire);
1130 if adoption.recovery_required {
1131 let Some(controller) = self.controller.as_deref() else {
1132 self.assignment_authority_dirty = true;
1133 warn!(version = snap.version, "snapshot watcher: exact-handoff recovery has no cluster controller");
1134 continue;
1135 };
1136 self.installed_fence = None;
1137 self.assignment_authority_dirty = true;
1138 match local_recovery_assignment_scope(&snap, controller) {
1139 Ok(LocalRecoveryAssignmentScope::Participant) => {
1140 if let Err(error) =
1141 ensure_local_recovery_fault(&self.db, controller)
1142 .await
1143 {
1144 warn!(%error, version = snap.version, "snapshot watcher: could not persist exact-handoff recovery fault");
1145 }
1146 }
1147 Ok(LocalRecoveryAssignmentScope::Ownerless) => {
1148 debug!(
1149 version = snap.version,
1150 "snapshot watcher: ownerless process retains the exact-handoff recovery fence without publishing a fault"
1151 );
1152 }
1153 Err(error) => {
1154 warn!(%error, version = snap.version, "snapshot watcher: exact-handoff recovery target does not bind the local process");
1155 }
1156 }
1157 continue;
1158 }
1159 }
1160 Err(error) if error.is_shuffle_not_ready() => {
1161 warn!(
1165 error = %error,
1166 version = snap.version,
1167 "snapshot watcher: successor waits for local vnode transition"
1168 );
1169 if let Err(cache_error) = self
1170 .ensure_current_assignment_authority_cached(
1171 audited_target
1172 .as_ref()
1173 .expect("stable successor was audited before adoption"),
1174 Instant::now() + self.config.checkpoint_timeout,
1175 )
1176 .await
1177 {
1178 warn!(%cache_error, version = snap.version, "snapshot watcher: could not audit predecessor authority for pending vnode transition");
1179 continue;
1180 }
1181 }
1185 Err(e) => warn!(error = %e, "snapshot watcher: adoption failed"),
1186 }
1187 } else {
1188 self.durable_drain_transition = None;
1189 self.durable_snapshot = Some(snap);
1190 }
1191 }
1192 Ok(Ok(Some(snap))) => {
1193 self.durable_drain_transition
1194 .clone_from(&snap.drain_transition);
1195 self.durable_snapshot = Some(snap);
1196 }
1197 Ok(Ok(None)) => {
1198 self.durable_drain_transition = None;
1199 self.durable_snapshot = None;
1200 }
1201 Ok(Err(error)) => {
1202 self.durable_snapshot = None;
1203 self.durable_drain_transition = None;
1204 self.assignment_authority_dirty = true;
1205 let _ = suspend_local_assignment_authority(
1206 &self.db,
1207 self.controller.as_deref(),
1208 head_deadline,
1209 )
1210 .await;
1211 warn!(%error, "snapshot watcher: durable audit failed; assignment authority suspended");
1212 }
1213 Err(_) => {
1214 self.durable_snapshot = None;
1215 self.durable_drain_transition = None;
1216 self.assignment_authority_dirty = true;
1217 let _ = suspend_local_assignment_authority(
1218 &self.db,
1219 self.controller.as_deref(),
1220 head_deadline,
1221 )
1222 .await;
1223 warn!(
1224 timeout = ?self.config.checkpoint_timeout,
1225 "snapshot watcher: durable audit timed out; assignment authority suspended"
1226 );
1227 }
1228 }
1229
1230 self.publish_authority(authority_revision, self.config.checkpoint_timeout)
1231 .await;
1232 }
1233 }
1234}
1235
1236pub fn spawn_snapshot_watcher(
1242 db: Arc<LaminarDB>,
1243 store: Arc<AssignmentSnapshotStore>,
1244 registry: Arc<VnodeRegistry>,
1245 shutdown: CancellationToken,
1246 config: RebalanceConfig,
1247 controller: Option<Arc<ClusterController>>,
1248) -> JoinHandle<()> {
1249 tokio::spawn(SnapshotWatcher::new(db, store, registry, shutdown, config, controller).run())
1250}
1251async fn compute_checkpoint_assignment_fence(
1254 c: &ClusterController,
1255 registry: &VnodeRegistry,
1256 expected_participants: &[CheckpointParticipant],
1257) -> Option<CheckpointAssignmentFence> {
1258 let assignment = registry.versioned_snapshot();
1259 let participant_ids: Vec<u64> = expected_participants
1260 .iter()
1261 .map(|participant| participant.node_id)
1262 .collect();
1263 let participants = c
1264 .recovery_participant_incarnations(&participant_ids)
1265 .await
1266 .ok()?;
1267 if participants != expected_participants {
1268 return None;
1269 }
1270 let reported: rustc_hash::FxHashMap<u64, CheckpointAssignmentAdoption> = c
1271 .read_adopted_assignments()
1272 .await
1273 .ok()?
1274 .into_iter()
1275 .map(|(node, adoption)| (node.0, adoption))
1276 .collect();
1277 checkpoint_assignment_fence(
1278 assignment.version(),
1279 assignment.owners(),
1280 participants,
1281 &reported,
1282 )
1283}
1284
1285fn checkpoint_assignment_fence(
1286 assignment_version: u64,
1287 owners: &[NodeId],
1288 participants: Vec<CheckpointParticipant>,
1289 reported: &rustc_hash::FxHashMap<u64, CheckpointAssignmentAdoption>,
1290) -> Option<CheckpointAssignmentFence> {
1291 let owner_ids: Vec<u64> = owners.iter().map(|owner| owner.0).collect();
1292 let vnode_count = u32::try_from(owners.len()).ok()?;
1293 let assignment_digest = CheckpointAssignmentFence::owner_map_digest(vnode_count, &owner_ids);
1294 if assignment_version == 0
1295 || owners.is_empty()
1296 || participants.is_empty()
1297 || owners.iter().any(|owner| {
1298 owner.is_unassigned()
1299 || participants
1300 .binary_search_by_key(&owner.0, |participant| participant.node_id)
1301 .is_err()
1302 })
1303 || participants.iter().any(|participant| {
1304 reported.get(&participant.node_id).is_none_or(|adoption| {
1305 adoption.participant != *participant
1309 || adoption.assignment_version != assignment_version
1310 || adoption.vnode_count != vnode_count
1311 || adoption.assignment_digest != assignment_digest
1312 })
1313 })
1314 {
1315 return None;
1316 }
1317
1318 CheckpointAssignmentFence::from_owner_map(assignment_version, &owner_ids, participants).ok()
1319}
1320
1321fn assignment_vnode_state_is_ready(
1322 fence: &CheckpointAssignmentFence,
1323 reported: &rustc_hash::FxHashMap<u64, CheckpointAssignmentAdoption>,
1324) -> bool {
1325 fence.participants.iter().all(|participant| {
1326 reported.get(&participant.node_id).is_some_and(|adoption| {
1327 adoption.vnode_state_ready
1328 && adoption.participant == *participant
1329 && adoption.matches_fence(fence)
1330 })
1331 })
1332}
1333
1334async fn read_assignment_vnode_state_readiness(
1335 controller: &ClusterController,
1336 fence: &CheckpointAssignmentFence,
1337 deadline: tokio::time::Instant,
1338) -> Result<bool, String> {
1339 let reported = tokio::time::timeout_at(deadline, controller.read_adopted_assignments())
1340 .await
1341 .map_err(|_| {
1342 format!(
1343 "vnode-state readiness read for assignment {} timed out",
1344 fence.assignment_version
1345 )
1346 })??
1347 .into_iter()
1348 .map(|(node, adoption)| (node.0, adoption))
1349 .collect();
1350 Ok(assignment_vnode_state_is_ready(fence, &reported))
1351}
1352
1353fn local_drain_participant(
1354 controller: &ClusterController,
1355 transition: &AssignmentDrainTransition,
1356) -> Option<CheckpointParticipant> {
1357 let participant = CheckpointParticipant {
1358 node_id: controller.instance_id().0,
1359 boot_incarnation: controller.recovery_incarnation(),
1360 };
1361 (transition
1362 .predecessor
1363 .participant_incarnation(participant.node_id)
1364 == Some(participant.boot_incarnation))
1365 .then_some(participant)
1366}
1367
1368fn finalized_drain_outcome(
1369 transition: &AssignmentDrainTransition,
1370 finalized: &AssignmentSnapshot,
1371) -> Result<SourceDrainOutcome, String> {
1372 if finalized.draining || finalized.version != transition.target.assignment_version {
1373 return Err(format!(
1374 "assignment {} is not a terminal snapshot for drain target {}",
1375 finalized.version, transition.target.assignment_version
1376 ));
1377 }
1378 let fence = finalized
1379 .assignment_fence()
1380 .map_err(|error| error.to_string())?;
1381 if fence == transition.target {
1382 return Ok(SourceDrainOutcome::Commit);
1383 }
1384 if fence.assignment_version == transition.target.assignment_version
1385 && fence.vnode_count == transition.predecessor.vnode_count
1386 && fence.assignment_digest == transition.predecessor.assignment_digest
1387 && fence.participants == transition.predecessor.participants
1388 {
1389 return Ok(SourceDrainOutcome::Abort);
1390 }
1391 Err(format!(
1392 "assignment {} is neither the committed target nor predecessor rollback for drain {:?}",
1393 finalized.version,
1394 transition.id()
1395 ))
1396}
1397
1398#[derive(Debug, Clone, PartialEq, Eq)]
1399pub(crate) struct AuditedDrainOutcome {
1400 transition: AssignmentDrainTransition,
1401 outcome: SourceDrainOutcome,
1402 handoff_checkpoint: Option<CommittedCheckpointRef>,
1403}
1404
1405#[derive(Debug, Clone, PartialEq, Eq)]
1410pub(crate) struct AuditedAssignmentAuthority {
1411 target_version: u64,
1412 predecessor: Option<CheckpointAssignmentFence>,
1413 handoff_checkpoint: Option<CommittedCheckpointRef>,
1414 recovery_checkpoint: Option<AuditedRecoveryCheckpoint>,
1415 recovery_leader_proof: Option<LeaderProof>,
1416 terminal: Option<AuditedDrainOutcome>,
1417}
1418
1419#[derive(Debug, Clone, PartialEq, Eq)]
1420enum AuditedRecoveryCheckpoint {
1421 Active {
1423 checkpoint: CommittedCheckpointRef,
1424 origin: CheckpointAssignmentFence,
1425 },
1426 Propagated {
1429 checkpoint: CommittedCheckpointRef,
1430 origin: CheckpointAssignmentFence,
1431 successor: CheckpointAssignmentFence,
1432 },
1433 Consumed {
1435 checkpoint: CommittedCheckpointRef,
1436 origin: CheckpointAssignmentFence,
1437 },
1438}
1439
1440impl AuditedRecoveryCheckpoint {
1441 fn checkpoint(&self) -> &CommittedCheckpointRef {
1442 match self {
1443 Self::Active { checkpoint, .. }
1444 | Self::Propagated { checkpoint, .. }
1445 | Self::Consumed { checkpoint, .. } => checkpoint,
1446 }
1447 }
1448
1449 fn origin(&self) -> &CheckpointAssignmentFence {
1450 match self {
1451 Self::Active { origin, .. }
1452 | Self::Propagated { origin, .. }
1453 | Self::Consumed { origin, .. } => origin,
1454 }
1455 }
1456
1457 const fn pin_is_active(&self) -> bool {
1458 matches!(self, Self::Active { .. })
1459 }
1460
1461 const fn was_consumed(&self) -> bool {
1462 matches!(self, Self::Consumed { .. })
1463 }
1464
1465 #[cfg(test)]
1466 const fn was_propagated(&self) -> bool {
1467 matches!(self, Self::Propagated { .. })
1468 }
1469
1470 fn propagated_successor(&self) -> Option<&CheckpointAssignmentFence> {
1471 match self {
1472 Self::Propagated { successor, .. } => Some(successor),
1473 Self::Active { .. } | Self::Consumed { .. } => None,
1474 }
1475 }
1476}
1477
1478impl AuditedAssignmentAuthority {
1479 fn without_successor_edge(target_version: u64) -> Self {
1480 Self {
1481 target_version,
1482 predecessor: None,
1483 handoff_checkpoint: None,
1484 recovery_checkpoint: None,
1485 recovery_leader_proof: None,
1486 terminal: None,
1487 }
1488 }
1489
1490 fn recovery(
1491 target_version: u64,
1492 predecessor: CheckpointAssignmentFence,
1493 recovery_checkpoint: AuditedRecoveryCheckpoint,
1494 recovery_leader_proof: LeaderProof,
1495 ) -> Self {
1496 let handoff_checkpoint = recovery_checkpoint.checkpoint().clone();
1497 Self {
1498 target_version,
1499 predecessor: Some(predecessor),
1500 handoff_checkpoint: Some(handoff_checkpoint),
1501 recovery_checkpoint: Some(recovery_checkpoint),
1502 recovery_leader_proof: Some(recovery_leader_proof),
1503 terminal: None,
1504 }
1505 }
1506
1507 fn drain(target_version: u64, terminal: AuditedDrainOutcome) -> Self {
1508 let handoff_checkpoint = terminal.committed_handoff_checkpoint().cloned();
1509 Self {
1510 target_version,
1511 predecessor: Some(terminal.transition.predecessor.clone()),
1512 handoff_checkpoint,
1513 recovery_checkpoint: None,
1514 recovery_leader_proof: None,
1515 terminal: Some(terminal),
1516 }
1517 }
1518
1519 pub(crate) fn is_recovery(&self) -> bool {
1520 self.recovery_checkpoint.is_some()
1521 }
1522
1523 fn terminal(&self) -> Option<&AuditedDrainOutcome> {
1524 self.terminal.as_ref()
1525 }
1526
1527 pub(crate) fn predecessor(&self) -> Option<&CheckpointAssignmentFence> {
1528 self.predecessor.as_ref()
1529 }
1530
1531 pub(crate) fn handoff_checkpoint(&self) -> Option<&CommittedCheckpointRef> {
1532 self.handoff_checkpoint.as_ref()
1533 }
1534
1535 pub(crate) fn recovery_origin(&self) -> Option<&CheckpointAssignmentFence> {
1536 self.recovery_checkpoint
1537 .as_ref()
1538 .map(AuditedRecoveryCheckpoint::origin)
1539 }
1540
1541 pub(crate) fn recovery_pin_is_active(&self) -> bool {
1542 self.recovery_checkpoint
1543 .as_ref()
1544 .is_some_and(AuditedRecoveryCheckpoint::pin_is_active)
1545 }
1546
1547 pub(crate) fn active_recovery_leader_proof(&self) -> Option<&LeaderProof> {
1548 self.recovery_checkpoint
1549 .as_ref()
1550 .filter(|checkpoint| checkpoint.pin_is_active())
1551 .and(self.recovery_leader_proof.as_ref())
1552 }
1553
1554 pub(crate) fn recovery_checkpoint_was_consumed(&self) -> bool {
1555 self.recovery_checkpoint
1556 .as_ref()
1557 .is_some_and(AuditedRecoveryCheckpoint::was_consumed)
1558 }
1559
1560 #[cfg(test)]
1561 pub(crate) fn recovery_checkpoint_was_propagated(&self) -> bool {
1562 self.recovery_checkpoint
1563 .as_ref()
1564 .is_some_and(AuditedRecoveryCheckpoint::was_propagated)
1565 }
1566
1567 pub(crate) fn propagated_recovery_successor(&self) -> Option<&CheckpointAssignmentFence> {
1568 self.recovery_checkpoint
1569 .as_ref()
1570 .and_then(AuditedRecoveryCheckpoint::propagated_successor)
1571 }
1572
1573 pub(crate) fn into_terminal(self) -> Option<AuditedDrainOutcome> {
1574 self.terminal
1575 }
1576
1577 fn retained_predecessor(
1578 &self,
1579 local_version: u64,
1580 ) -> Result<&CheckpointAssignmentFence, String> {
1581 if local_version.checked_add(1) != Some(self.target_version) {
1582 return Err(format!(
1583 "audited assignment {} is not the exact successor of local assignment {local_version}",
1584 self.target_version
1585 ));
1586 }
1587 self.predecessor.as_ref().ok_or_else(|| {
1588 format!(
1589 "audited assignment {} has no predecessor certificate",
1590 self.target_version
1591 )
1592 })
1593 }
1594}
1595
1596pub(crate) struct AuditedCommittedDrainTransition(AssignmentDrainTransition);
1601
1602impl AuditedCommittedDrainTransition {
1603 pub(crate) fn into_transition(self) -> AssignmentDrainTransition {
1604 self.0
1605 }
1606
1607 #[cfg(test)]
1608 pub(crate) fn from_canonical_for_test(
1609 transition: AssignmentDrainTransition,
1610 ) -> Result<Self, String> {
1611 transition
1612 .is_canonical()
1613 .then_some(Self(transition))
1614 .ok_or_else(|| "test drain transition is not canonical".into())
1615 }
1616}
1617
1618impl AuditedDrainOutcome {
1619 #[must_use]
1620 pub(crate) const fn transition(&self) -> &AssignmentDrainTransition {
1621 &self.transition
1622 }
1623
1624 #[must_use]
1625 pub(crate) fn is_abort(&self) -> bool {
1626 self.outcome == SourceDrainOutcome::Abort
1627 }
1628
1629 #[must_use]
1630 pub(crate) fn is_commit(&self) -> bool {
1631 self.outcome == SourceDrainOutcome::Commit
1632 }
1633
1634 #[must_use]
1635 pub(crate) fn committed_handoff_checkpoint(&self) -> Option<&CommittedCheckpointRef> {
1636 if self.outcome == SourceDrainOutcome::Commit {
1637 self.handoff_checkpoint.as_ref()
1638 } else {
1639 None
1640 }
1641 }
1642
1643 #[must_use]
1645 pub(crate) fn into_committed_transition(self) -> Option<AuditedCommittedDrainTransition> {
1646 (self.outcome == SourceDrainOutcome::Commit)
1647 .then_some(AuditedCommittedDrainTransition(self.transition))
1648 }
1649}
1650
1651pub async fn audit_assignment_snapshot_authority(
1658 store: &AssignmentSnapshotStore,
1659 controller: Option<&ClusterController>,
1660 snapshot: &AssignmentSnapshot,
1661) -> Result<(), String> {
1662 audit_assignment_snapshot_authority_outcome(store, controller, snapshot)
1663 .await
1664 .map(|_| ())
1665}
1666
1667pub async fn startup_committed_assignment(
1674 store: &AssignmentSnapshotStore,
1675 controller: Option<&ClusterController>,
1676 head: AssignmentSnapshot,
1677) -> Result<AssignmentSnapshot, String> {
1678 audit_assignment_snapshot_authority(store, controller, &head)
1679 .await
1680 .map_err(|error| {
1681 format!(
1682 "audit startup assignment {} authority: {error}",
1683 head.version
1684 )
1685 })?;
1686 if !head.draining {
1687 return Ok(head);
1688 }
1689
1690 let transition = store
1691 .load_drain_transition(head.version)
1692 .await
1693 .map_err(|error| {
1694 format!(
1695 "load startup assignment {} drain transition: {error}",
1696 head.version
1697 )
1698 })?
1699 .ok_or_else(|| {
1700 format!(
1701 "draining startup assignment {} has no exact transition",
1702 head.version
1703 )
1704 })?;
1705 if head.drain_transition.as_ref() != Some(&transition) {
1706 return Err(format!(
1707 "draining startup assignment {} does not match its durable transition",
1708 head.version
1709 ));
1710 }
1711 let prior_version = head
1712 .version
1713 .checked_sub(1)
1714 .ok_or_else(|| "draining assignment has no retained committed predecessor".to_string())?;
1715 let prior = store
1716 .load_version(prior_version)
1717 .await
1718 .map_err(|error| {
1719 format!(
1720 "load retained assignment {prior_version} before draining head {}: {error}",
1721 head.version
1722 )
1723 })?
1724 .ok_or_else(|| {
1725 format!(
1726 "draining assignment {} has no retained committed predecessor {prior_version}",
1727 head.version
1728 )
1729 })?;
1730 if prior.draining {
1731 return Err(format!(
1732 "draining assignment {} has a draining predecessor {prior_version}",
1733 head.version
1734 ));
1735 }
1736 if prior
1737 .assignment_fence()
1738 .map_err(|error| error.to_string())?
1739 != transition.predecessor
1740 {
1741 return Err(format!(
1742 "draining assignment {} does not bind retained predecessor {prior_version}",
1743 head.version
1744 ));
1745 }
1746 audit_assignment_snapshot_authority(store, controller, &prior)
1747 .await
1748 .map_err(|error| format!("audit retained assignment {prior_version} authority: {error}"))?;
1749 Ok(prior)
1750}
1751
1752pub(crate) fn audit_assignment_snapshot_authority_outcome<'a>(
1753 store: &'a AssignmentSnapshotStore,
1754 controller: Option<&'a ClusterController>,
1755 snapshot: &'a AssignmentSnapshot,
1756) -> futures::future::BoxFuture<'a, Result<AuditedAssignmentAuthority, String>> {
1757 Box::pin(async move {
1758 if snapshot.draining {
1759 return Ok(AuditedAssignmentAuthority::without_successor_edge(
1760 snapshot.version,
1761 ));
1762 }
1763 if snapshot.version == 1 {
1764 return Ok(AuditedAssignmentAuthority::without_successor_edge(
1765 snapshot.version,
1766 ));
1767 }
1768 let authority = match controller {
1769 Some(controller) => Some(
1770 controller
1771 .checkpoint_authority()
1772 .map_err(|error| error.to_string())?,
1773 ),
1774 None => None,
1775 };
1776 let Some(transition) = store
1777 .load_drain_transition(snapshot.version)
1778 .await
1779 .map_err(|error| error.to_string())?
1780 else {
1781 let (predecessor, handoff_checkpoint, leader_proof) =
1782 audit_materialized_recovery_with_authority(store, authority.as_deref(), snapshot)
1783 .await?;
1784 return Ok(AuditedAssignmentAuthority::recovery(
1785 snapshot.version,
1786 predecessor,
1787 handoff_checkpoint,
1788 leader_proof,
1789 ));
1790 };
1791 audit_materialized_drain_transition(store, authority.as_deref(), snapshot, transition)
1792 .await
1793 .map(|terminal| AuditedAssignmentAuthority::drain(snapshot.version, terminal))
1794 })
1795}
1796
1797async fn audit_materialized_drain_with_authority(
1798 store: &AssignmentSnapshotStore,
1799 authority: Option<&LeaderLeaseStore>,
1800 snapshot: &AssignmentSnapshot,
1801) -> Result<Option<AuditedDrainOutcome>, String> {
1802 if snapshot.draining {
1803 return Ok(None);
1804 }
1805 if snapshot.version == 1 {
1806 return Ok(None);
1807 }
1808 let Some(transition) = store
1809 .load_drain_transition(snapshot.version)
1810 .await
1811 .map_err(|error| error.to_string())?
1812 else {
1813 audit_materialized_recovery_with_authority(store, authority, snapshot).await?;
1814 return Ok(None);
1815 };
1816 audit_materialized_drain_transition(store, authority, snapshot, transition)
1817 .await
1818 .map(Some)
1819}
1820
1821fn validate_portable_recovery_cut(
1822 outcome: &CheckpointOutcome,
1823 index: &CommittedCheckpointIndex,
1824 reference: &CommittedCheckpointRef,
1825 predecessor: &CheckpointAssignmentFence,
1826 context: &str,
1827) -> Result<CheckpointAssignmentFence, String> {
1828 index
1829 .validate()
1830 .map_err(|error| format!("{context} committed checkpoint index is invalid: {error}"))?;
1831 let origin = index
1832 .assignment_fence
1833 .as_ref()
1834 .ok_or_else(|| format!("{context} committed checkpoint has no cluster assignment fence"))?;
1835 if !outcome.is_commit()
1836 || outcome.scope != CheckpointScope::Cluster
1837 || outcome.committed_checkpoint.as_ref() != Some(reference)
1838 || outcome.epoch != reference.epoch
1839 || outcome.checkpoint_id != reference.checkpoint_id
1840 || outcome.epoch != index.epoch
1841 || outcome.checkpoint_id != index.checkpoint_id
1842 || outcome.deployment_id != index.deployment_id
1843 || outcome.assignment_fence.as_ref() != Some(origin)
1844 || outcome.leader_proof.is_none()
1845 || index.scope != CheckpointScope::Cluster
1846 || index.epoch != reference.epoch
1847 || index.checkpoint_id != reference.checkpoint_id
1848 || !index.reassignment_portable
1849 {
1850 return Err(format!(
1851 "{context} does not bind one exact reassignment-portable cluster Commit"
1852 ));
1853 }
1854 if !origin.is_canonical()
1855 || !predecessor.is_canonical()
1856 || origin.assignment_version > predecessor.assignment_version
1857 || origin.vnode_count != predecessor.vnode_count
1858 || origin.partitioning_abi_version != predecessor.partitioning_abi_version
1859 || (origin.assignment_version == predecessor.assignment_version && origin != predecessor)
1860 {
1861 return Err(format!(
1862 "{context} origin assignment is newer than or incompatible with the recovery predecessor"
1863 ));
1864 }
1865 Ok(origin.clone())
1866}
1867
1868type AuditedRecoveryAuthority = (
1869 CheckpointAssignmentFence,
1870 AuditedRecoveryCheckpoint,
1871 LeaderProof,
1872);
1873
1874fn audit_materialized_recovery_with_authority<'a>(
1875 store: &'a AssignmentSnapshotStore,
1876 authority: Option<&'a LeaderLeaseStore>,
1877 snapshot: &'a AssignmentSnapshot,
1878) -> futures::future::BoxFuture<'a, Result<AuditedRecoveryAuthority, String>> {
1879 Box::pin(async move {
1880 let version = snapshot.version;
1881 let authority = authority.ok_or_else(|| {
1882 format!("materialized assignment recovery {version} has no cluster authority")
1883 })?;
1884 let decision = authority
1885 .assignment_recovery_decision(version)
1886 .await
1887 .map_err(|error| error.to_string())?
1888 .ok_or_else(|| {
1889 format!(
1890 "assignment {version} has no drain transition or recovery authority decision"
1891 )
1892 })?;
1893 let proposal = store
1894 .load_recovery_proposal(&decision.proposal)
1895 .await
1896 .map_err(|error| error.to_string())?;
1897 if proposal != *snapshot
1898 || proposal
1899 .assignment_fence()
1900 .map_err(|error| error.to_string())?
1901 != decision.target
1902 {
1903 return Err(format!(
1904 "assignment {} does not match its authorized recovery proposal",
1905 snapshot.version
1906 ));
1907 }
1908 let predecessor_version = version
1909 .checked_sub(1)
1910 .ok_or_else(|| "recovery assignment has no predecessor generation".to_string())?;
1911 let predecessor = store
1912 .load_version(predecessor_version)
1913 .await
1914 .map_err(|error| error.to_string())?
1915 .ok_or_else(|| {
1916 format!("recovery assignment {version} lost predecessor {predecessor_version}")
1917 })?;
1918 if predecessor.draining
1919 || predecessor
1920 .assignment_fence()
1921 .map_err(|error| error.to_string())?
1922 != decision.predecessor
1923 {
1924 return Err(format!(
1925 "assignment {} recovery decision does not bind its exact committed predecessor",
1926 snapshot.version
1927 ));
1928 }
1929 let commit_head = authority
1932 .highest_cluster_commit_with_checkpoint()
1933 .await
1934 .map_err(|error| error.to_string())?
1935 .ok_or_else(|| {
1936 format!(
1937 "assignment {} recovery has no committed checkpoint head",
1938 snapshot.version
1939 )
1940 })?;
1941 let (committed_head, committed_index) = (&commit_head.outcome, &commit_head.index);
1942 let head_reference = committed_head.committed_checkpoint.clone().ok_or_else(|| {
1943 format!(
1944 "assignment {} recovery committed head has no checkpoint reference",
1945 snapshot.version
1946 )
1947 })?;
1948 let pinned = commit_head.handoff_checkpoint(&decision.target).cloned();
1949
1950 let recovery_checkpoint = if let Some(pinned) = pinned {
1951 if pinned != decision.recovery_checkpoint || head_reference != pinned {
1952 return Err(format!(
1953 "assignment {} recovery does not retain its exact authoritative checkpoint head",
1954 snapshot.version
1955 ));
1956 }
1957 let origin = validate_portable_recovery_cut(
1958 committed_head,
1959 committed_index,
1960 &pinned,
1961 &decision.predecessor,
1962 "materialized assignment recovery",
1963 )?;
1964 AuditedRecoveryCheckpoint::Active {
1965 checkpoint: pinned,
1966 origin,
1967 }
1968 } else if committed_head.assignment_fence.as_ref() == Some(&decision.target) {
1969 let origin = validate_portable_recovery_cut(
1973 committed_head,
1974 committed_index,
1975 &head_reference,
1976 &decision.target,
1977 "consumed assignment recovery",
1978 )?;
1979 AuditedRecoveryCheckpoint::Consumed {
1980 checkpoint: head_reference,
1981 origin,
1982 }
1983 } else {
1984 let successor_version = snapshot
1989 .version
1990 .checked_add(1)
1991 .ok_or_else(|| "assignment version exhausted during recovery audit".to_string())?;
1992 let successor = authority
1993 .assignment_recovery_decision(successor_version)
1994 .await
1995 .map_err(|error| error.to_string())?
1996 .ok_or_else(|| {
1997 format!(
1998 "assignment {} recovery checkpoint was superseded before the target committed",
1999 snapshot.version
2000 )
2001 })?;
2002 if successor.predecessor != decision.target
2003 || successor.recovery_checkpoint != decision.recovery_checkpoint
2004 || successor.recovery_checkpoint != head_reference
2005 {
2006 return Err(format!(
2007 "assignment {} recovery checkpoint does not continue through its exact successor decision",
2008 snapshot.version
2009 ));
2010 }
2011 if commit_head.handoff_checkpoint(&successor.target) != Some(&head_reference) {
2012 return Err(format!(
2013 "assignment {} recovery checkpoint is not pinned to its exact pending successor",
2014 snapshot.version
2015 ));
2016 }
2017 let origin = validate_portable_recovery_cut(
2018 committed_head,
2019 committed_index,
2020 &head_reference,
2021 &decision.predecessor,
2022 "propagated assignment recovery",
2023 )?;
2024 AuditedRecoveryCheckpoint::Propagated {
2025 checkpoint: head_reference,
2026 origin,
2027 successor: successor.target,
2028 }
2029 };
2030 Ok((
2031 decision.predecessor,
2032 recovery_checkpoint,
2033 decision.leader_proof,
2034 ))
2035 })
2036}
2037
2038fn outcome_is_exact_abort_for_inventory(
2039 outcome: &CheckpointOutcome,
2040 inventory: &CheckpointArtifactInventory,
2041 predecessor: &CheckpointAssignmentFence,
2042) -> bool {
2043 outcome.scope == CheckpointScope::Cluster
2044 && outcome.epoch == inventory.attempt.epoch
2045 && outcome.checkpoint_id == inventory.attempt.checkpoint_id
2046 && outcome.deployment_id == inventory.deployment_id
2047 && outcome.assignment_fence.as_ref() == Some(predecessor)
2048 && outcome.verdict == CheckpointVerdict::Abort
2049 && outcome.committed_checkpoint.is_none()
2050 && outcome.leader_proof.is_some()
2051}
2052
2053fn abort_predecessor_checkpoint_for_recovery<'a>(
2058 store: &'a AssignmentSnapshotStore,
2059 controller: &'a ClusterController,
2060 snapshot: &'a AssignmentSnapshot,
2061 deadline: tokio::time::Instant,
2062) -> futures::future::BoxFuture<'a, Result<(), String>> {
2063 Box::pin(async move {
2064 if controller.capture_leader_proof().is_none() {
2067 return Ok(());
2068 }
2069 let authority = controller
2070 .checkpoint_authority()
2071 .map_err(|error| error.to_string())?;
2072 let (predecessor, audited_recovery, _) = tokio::time::timeout_at(
2073 deadline,
2074 audit_materialized_recovery_with_authority(store, Some(authority.as_ref()), snapshot),
2075 )
2076 .await
2077 .map_err(|_| {
2078 format!(
2079 "assignment {} recovery authority audit timed out while settling its predecessor checkpoint",
2080 snapshot.version
2081 )
2082 })??;
2083 settle_audited_recovery_predecessor_checkpoint(
2084 controller,
2085 authority,
2086 snapshot,
2087 predecessor,
2088 audited_recovery,
2089 deadline,
2090 )
2091 .await
2092 })
2093}
2094
2095fn settle_audited_recovery_predecessor_checkpoint<'a>(
2099 controller: &'a ClusterController,
2100 authority: Arc<LeaderLeaseStore>,
2101 snapshot: &'a AssignmentSnapshot,
2102 predecessor: CheckpointAssignmentFence,
2103 audited_recovery: AuditedRecoveryCheckpoint,
2104 deadline: tokio::time::Instant,
2105) -> futures::future::BoxFuture<'a, Result<(), String>> {
2106 Box::pin(async move {
2107 let recovery_checkpoint = audited_recovery.checkpoint().clone();
2108 let target = snapshot
2109 .assignment_fence()
2110 .map_err(|error| error.to_string())?;
2111 let pin_target = audited_recovery
2112 .propagated_successor()
2113 .cloned()
2114 .unwrap_or_else(|| target.clone());
2115 let decision = tokio::time::timeout_at(
2116 deadline,
2117 authority.assignment_recovery_decision(snapshot.version),
2118 )
2119 .await
2120 .map_err(|_| "recovery decision reload timed out before checkpoint settlement".to_string())?
2121 .map_err(|error| error.to_string())?
2122 .ok_or_else(|| {
2123 format!(
2124 "assignment {} lost its recovery decision before checkpoint settlement",
2125 snapshot.version
2126 )
2127 })?;
2128 if decision.predecessor != predecessor
2129 || decision.target != target
2130 || (!audited_recovery.was_consumed()
2131 && decision.recovery_checkpoint != recovery_checkpoint)
2132 {
2133 return Err(format!(
2134 "assignment {} recovery decision changed during checkpoint settlement",
2135 snapshot.version
2136 ));
2137 }
2138
2139 let Some(inventory) =
2140 tokio::time::timeout_at(deadline, authority.cluster_checkpoint_artifacts())
2141 .await
2142 .map_err(|_| "recovery checkpoint artifact inventory read timed out".to_string())?
2143 .map_err(|error| error.to_string())?
2144 else {
2145 return Ok(());
2146 };
2147 inventory.validate()?;
2148 if inventory.assignment_fence.as_ref() == Some(&target) {
2152 return Ok(());
2153 }
2154 if inventory.assignment_fence.as_ref() != Some(&predecessor) {
2155 return Err(format!(
2156 "assignment {} recovery found checkpoint {} artifacts for an unrelated assignment",
2157 snapshot.version, inventory.attempt.checkpoint_id
2158 ));
2159 }
2160
2161 if audited_recovery.was_consumed() {
2162 return Err(format!(
2163 "assignment {} recovery target already consumed its checkpoint pin while predecessor artifacts remain",
2164 snapshot.version
2165 ));
2166 }
2167
2168 let pinned = tokio::time::timeout_at(
2169 deadline,
2170 authority.assignment_handoff_checkpoint(&pin_target),
2171 )
2172 .await
2173 .map_err(|_| "recovery handoff checkpoint lookup timed out".to_string())?
2174 .map_err(|error| error.to_string())?;
2175 if pinned.as_ref() != Some(&recovery_checkpoint) {
2176 return Err(format!(
2177 "assignment {} recovery lost its exact target-keyed checkpoint pin",
2178 snapshot.version
2179 ));
2180 }
2181 let committed_head =
2182 tokio::time::timeout_at(deadline, authority.highest_cluster_committed_outcome())
2183 .await
2184 .map_err(|_| "recovery committed-head lookup timed out".to_string())?
2185 .map_err(|error| error.to_string())?
2186 .ok_or_else(|| {
2187 "recovery assignment has no committed checkpoint head".to_string()
2188 })?;
2189 if committed_head.committed_checkpoint.as_ref() != Some(&recovery_checkpoint) {
2190 return Err(format!(
2191 "assignment {} recovery pin is not the authoritative committed checkpoint head",
2192 snapshot.version
2193 ));
2194 }
2195 let (observed_outcome, observed_index) = tokio::time::timeout_at(
2196 deadline,
2197 authority.cluster_outcome_with_committed_checkpoint(recovery_checkpoint.epoch),
2198 )
2199 .await
2200 .map_err(|_| "recovery checkpoint head load timed out".to_string())?
2201 .map_err(|error| error.to_string())?
2202 .ok_or_else(|| "recovery checkpoint head disappeared".to_string())?;
2203 if observed_outcome != committed_head {
2204 return Err(format!(
2205 "assignment {} recovery checkpoint head changed during predecessor settlement",
2206 snapshot.version
2207 ));
2208 }
2209 let recovery_index = observed_index
2210 .ok_or_else(|| "recovery checkpoint Commit has no committed index".to_string())?;
2211 let observed_origin = validate_portable_recovery_cut(
2212 &observed_outcome,
2213 &recovery_index,
2214 &recovery_checkpoint,
2215 &predecessor,
2216 "predecessor checkpoint settlement",
2217 )?;
2218 if &observed_origin != audited_recovery.origin() {
2219 return Err(format!(
2220 "assignment {} recovery checkpoint origin changed during predecessor settlement",
2221 snapshot.version
2222 ));
2223 }
2224 if inventory.deployment_id != recovery_index.deployment_id
2225 || inventory.pipeline_identity != recovery_index.pipeline_identity
2226 {
2227 return Err(format!(
2228 "assignment {} recovery checkpoint and active predecessor artifacts are not one pipeline cut",
2229 snapshot.version
2230 ));
2231 }
2232 let recovery_attempt =
2233 CheckpointAttempt::new(recovery_checkpoint.epoch, recovery_checkpoint.checkpoint_id);
2234 if inventory.attempt.relation_to(recovery_attempt) != CheckpointAttemptRelation::Newer {
2235 return Err(format!(
2236 "assignment {} recovery cannot abort checkpoint {} because it does not follow pinned checkpoint {}",
2237 snapshot.version,
2238 inventory.attempt.checkpoint_id,
2239 recovery_checkpoint.checkpoint_id
2240 ));
2241 }
2242
2243 let existing = tokio::time::timeout_at(
2244 deadline,
2245 authority.cluster_attempt_settlement(inventory.attempt),
2246 )
2247 .await
2248 .map_err(|_| "predecessor checkpoint settlement read timed out".to_string())?
2249 .map_err(|error| error.to_string())?;
2250 let winner = if let Some(outcome) = existing {
2251 outcome
2252 } else {
2253 let current_proof = controller.capture_leader_proof().ok_or_else(|| {
2254 "leadership changed before predecessor checkpoint Abort publication".to_string()
2255 })?;
2256 let recorded = tokio::time::timeout_at(
2257 deadline,
2258 authority.record_cluster_outcome(
2259 ¤t_proof,
2260 inventory.attempt.epoch,
2261 inventory.attempt.checkpoint_id,
2262 predecessor.clone(),
2263 CheckpointVerdict::Abort,
2264 None,
2265 ),
2266 )
2267 .await
2268 .map_err(|_| "predecessor checkpoint Abort publication timed out".to_string())?
2269 .map_err(|error| error.to_string())?;
2270 match recorded {
2271 RecordOutcomeResult::Created(outcome)
2272 | RecordOutcomeResult::Unchanged(outcome)
2273 | RecordOutcomeResult::Conflict { winner: outcome } => outcome,
2274 }
2275 };
2276 if !outcome_is_exact_abort_for_inventory(&winner, &inventory, &predecessor) {
2277 return Err(format!(
2278 "checkpoint {} has incompatible terminal authority after assignment {} recovery",
2279 inventory.attempt.checkpoint_id, snapshot.version
2280 ));
2281 }
2282 let settled = tokio::time::timeout_at(
2283 deadline,
2284 authority.cluster_attempt_settlement(inventory.attempt),
2285 )
2286 .await
2287 .map_err(|_| "predecessor checkpoint Abort verification timed out".to_string())?
2288 .map_err(|error| error.to_string())?
2289 .ok_or_else(|| "predecessor checkpoint Abort was not durably visible".to_string())?;
2290 if !outcome_is_exact_abort_for_inventory(&settled, &inventory, &predecessor) {
2291 return Err(format!(
2292 "checkpoint {} Abort verification observed incompatible terminal authority",
2293 inventory.attempt.checkpoint_id
2294 ));
2295 }
2296 let final_pin = tokio::time::timeout_at(
2297 deadline,
2298 authority.assignment_handoff_checkpoint(&pin_target),
2299 )
2300 .await
2301 .map_err(|_| "post-Abort recovery handoff checkpoint lookup timed out".to_string())?
2302 .map_err(|error| error.to_string())?;
2303 if final_pin.as_ref() != Some(&recovery_checkpoint) {
2304 return Err(format!(
2305 "assignment {} recovery checkpoint pin changed while publishing predecessor Abort",
2306 snapshot.version
2307 ));
2308 }
2309 let remaining = tokio::time::timeout_at(deadline, authority.cluster_checkpoint_artifacts())
2310 .await
2311 .map_err(|_| "post-Abort checkpoint artifact inventory read timed out".to_string())?
2312 .map_err(|error| error.to_string())?;
2313 if remaining
2314 .as_ref()
2315 .is_some_and(|current| current != &inventory)
2316 {
2317 return Err(format!(
2318 "checkpoint {} artifact inventory changed while publishing its recovery Abort",
2319 inventory.attempt.checkpoint_id
2320 ));
2321 }
2322 info!(
2323 checkpoint_id = inventory.attempt.checkpoint_id,
2324 predecessor_version = predecessor.assignment_version,
2325 target_version = snapshot.version,
2326 "recovery assignment published the predecessor checkpoint Abort"
2327 );
2328 Ok(())
2329 })
2330}
2331
2332async fn audit_materialized_drain_transition(
2333 store: &AssignmentSnapshotStore,
2334 authority: Option<&LeaderLeaseStore>,
2335 snapshot: &AssignmentSnapshot,
2336 transition: AssignmentDrainTransition,
2337) -> Result<AuditedDrainOutcome, String> {
2338 let authority = authority
2339 .ok_or_else(|| "materialized assignment drain has no cluster authority".to_string())?;
2340 let decision = authority
2341 .assignment_drain_decision(snapshot.version)
2342 .await
2343 .map_err(|error| error.to_string())?
2344 .ok_or_else(|| {
2345 format!(
2346 "assignment {} has a materialized drain outcome without an authority decision",
2347 snapshot.version
2348 )
2349 })?;
2350 if decision.transition != transition {
2351 return Err(format!(
2352 "assignment {} materialization binds a different authority transition",
2353 snapshot.version
2354 ));
2355 }
2356 let predecessor_version = snapshot
2357 .version
2358 .checked_sub(1)
2359 .ok_or_else(|| "materialized drain has no predecessor generation".to_string())?;
2360 let predecessor = store
2361 .load_version(predecessor_version)
2362 .await
2363 .map_err(|error| error.to_string())?
2364 .ok_or_else(|| {
2365 format!(
2366 "materialized drain assignment {} lost predecessor {predecessor_version}",
2367 snapshot.version
2368 )
2369 })?;
2370 if predecessor.draining
2371 || predecessor
2372 .assignment_fence()
2373 .map_err(|error| error.to_string())?
2374 != transition.predecessor
2375 {
2376 return Err(format!(
2377 "assignment {} drain transition does not bind its exact committed predecessor",
2378 snapshot.version
2379 ));
2380 }
2381 let observed = finalized_drain_outcome(&transition, snapshot)?;
2382 let expected = match decision.verdict {
2383 AssignmentDrainVerdict::Commit => SourceDrainOutcome::Commit,
2384 AssignmentDrainVerdict::Abort => SourceDrainOutcome::Abort,
2385 };
2386 if observed != expected {
2387 return Err(format!(
2388 "assignment {} materialization conflicts with its authority decision",
2389 snapshot.version
2390 ));
2391 }
2392 Ok(AuditedDrainOutcome {
2393 transition,
2394 outcome: observed,
2395 handoff_checkpoint: decision.handoff_checkpoint,
2396 })
2397}
2398
2399#[derive(Clone, Copy)]
2403enum SourceDrainResolutionDeadline {
2404 Fresh(Duration),
2405 Absolute(tokio::time::Instant),
2406}
2407
2408impl SourceDrainResolutionDeadline {
2409 fn resolve(self) -> tokio::time::Instant {
2410 match self {
2411 Self::Fresh(timeout) => tokio::time::Instant::now() + timeout,
2412 Self::Absolute(deadline) => deadline,
2413 }
2414 }
2415}
2416
2417async fn settle_observed_local_drain(
2418 db: &Arc<LaminarDB>,
2419 store: &AssignmentSnapshotStore,
2420 registry: &VnodeRegistry,
2421 controller: Option<&ClusterController>,
2422 transition: &AssignmentDrainTransition,
2423 observed: &AssignmentSnapshot,
2424 audited_observed: Option<&AuditedDrainOutcome>,
2425 adoption_deadline: tokio::time::Instant,
2426 drain_deadline: SourceDrainResolutionDeadline,
2427) -> Result<bool, String> {
2428 if observed.draining {
2429 if observed.drain_transition.as_ref() == Some(transition) {
2430 return Ok(false);
2431 }
2432 if observed.version >= transition.target.assignment_version {
2433 return Err(format!(
2434 "drain {:?} was superseded by a different draining assignment {}",
2435 transition.id(),
2436 observed.version
2437 ));
2438 }
2439 return Ok(false);
2440 }
2441 if observed.version < transition.target.assignment_version {
2442 return Ok(false);
2443 }
2444
2445 let finalized = if observed.version == transition.target.assignment_version {
2446 observed.clone()
2447 } else {
2448 tokio::time::timeout_at(
2449 adoption_deadline,
2450 store.load_version(transition.target.assignment_version),
2451 )
2452 .await
2453 .map_err(|_| {
2454 format!(
2455 "timed out loading terminal assignment {} for drain resolution",
2456 transition.target.assignment_version
2457 )
2458 })?
2459 .map_err(|error| error.to_string())?
2460 .ok_or_else(|| {
2461 format!(
2462 "terminal assignment {} was pruned before local drain resolution",
2463 transition.target.assignment_version
2464 )
2465 })?
2466 };
2467 let audited = match audited_observed
2468 .filter(|audited| {
2469 finalized.version == observed.version && audited.transition == *transition
2470 })
2471 .cloned()
2472 {
2473 Some(audited) => audited,
2474 None => tokio::time::timeout_at(
2475 adoption_deadline,
2476 audit_assignment_snapshot_authority_outcome(store, controller, &finalized),
2477 )
2478 .await
2479 .map_err(|_| {
2480 format!(
2481 "timed out auditing terminal assignment {} for drain resolution",
2482 finalized.version
2483 )
2484 })??
2485 .into_terminal()
2486 .ok_or_else(|| {
2487 format!(
2488 "terminal assignment {} lost drain transition history",
2489 finalized.version
2490 )
2491 })?,
2492 };
2493 if audited.transition != *transition {
2494 return Err(format!(
2495 "terminal assignment {} binds a different drain transition",
2496 finalized.version
2497 ));
2498 }
2499 let outcome = audited.outcome;
2500 let target_version = transition.target.assignment_version;
2501 let local_version = registry.assignment_version();
2502 if local_version < target_version {
2503 db.adopt_assignment_snapshot(finalized.clone(), adoption_deadline)
2504 .await
2505 .map_err(|error| error.to_string())?;
2506 }
2507 if registry.assignment_version() != target_version {
2508 return Err(format!(
2509 "cannot resolve drain {:?} at local assignment {}",
2510 transition.id(),
2511 registry.assignment_version()
2512 ));
2513 }
2514 let expected = finalized
2515 .to_vnode_vec(registry.vnode_count())
2516 .map_err(|error| error.to_string())?;
2517 if registry.snapshot().as_ref() != expected.as_slice() {
2518 return Err(format!(
2519 "local assignment {target_version} does not match the durable drain outcome"
2520 ));
2521 }
2522 db.resolve_local_source_drain(transition.id(), outcome, drain_deadline.resolve())
2523 .await
2524 .map_err(|error| error.to_string())?;
2525 Ok(true)
2526}
2527
2528fn clear_settled_source_drain(
2529 controller: &ClusterController,
2530 transition: &AssignmentDrainTransition,
2531) -> Result<(), String> {
2532 match controller.checkpoint_drain_transition() {
2533 Some(active) if active == *transition => controller
2534 .clear_checkpoint_drain_transition_if_matches(transition)
2535 .then_some(())
2536 .ok_or_else(|| "process-local source drain changed during settlement".into()),
2537 Some(_) => Err("process-local source drain changed during settlement".into()),
2538 None => Ok(()),
2539 }
2540}
2541
2542pub(crate) async fn settle_source_drain_before_recovery_release(
2545 db: &Arc<LaminarDB>,
2546 controller: &ClusterController,
2547 expected_fence: &CheckpointAssignmentFence,
2548 deadline: tokio::time::Instant,
2549) -> Result<Option<u64>, String> {
2550 if !controller.is_recovering() || !db.cluster_intake_fenced() {
2551 return Err("recovery source-drain settlement requires closed intake".into());
2552 }
2553 let published_transition = controller.checkpoint_drain_transition();
2554 let store =
2555 db.assignment_snapshot_store.lock().clone().ok_or_else(|| {
2556 "recovery source-drain settlement has no assignment store".to_string()
2557 })?;
2558 let snapshot = tokio::time::timeout_at(deadline, store.load())
2559 .await
2560 .map_err(|_| "recovery source-drain head read timed out".to_string())?
2561 .map_err(|error| error.to_string())?
2562 .ok_or_else(|| "recovery source-drain settlement has no assignment head".to_string())?;
2563 if snapshot.draining {
2564 return Err(format!(
2565 "recovery Release reached unresolved draining assignment {}",
2566 snapshot.version
2567 ));
2568 }
2569 let fence = snapshot
2570 .assignment_fence()
2571 .map_err(|error| error.to_string())?;
2572 if &fence != expected_fence {
2573 return Err(format!(
2574 "recovery assignment {} changed before source-drain settlement",
2575 snapshot.version
2576 ));
2577 }
2578 let audited = tokio::time::timeout_at(
2579 deadline,
2580 audit_assignment_snapshot_authority_outcome(&store, Some(controller), &snapshot),
2581 )
2582 .await
2583 .map_err(|_| "recovery source-drain terminal audit timed out".to_string())??;
2584 let Some(audited) = audited.into_terminal() else {
2585 if published_transition.is_some() {
2586 return Err("process-local source drain has no durable terminal".into());
2587 }
2588 return Ok(None);
2589 };
2590 if published_transition.is_some_and(|active| active != audited.transition) {
2591 return Err("process-local source drain conflicts with the durable terminal".into());
2592 }
2593 if local_drain_participant(controller, &audited.transition).is_none() {
2594 clear_settled_source_drain(controller, &audited.transition)?;
2595 return Ok(None);
2596 }
2597 if tokio::time::Instant::now() >= deadline {
2598 return Err("recovery source-drain settlement deadline expired".into());
2599 }
2600 let registry = db
2601 .vnode_registry
2602 .lock()
2603 .clone()
2604 .ok_or_else(|| "recovery source-drain settlement has no vnode registry".to_string())?;
2605 if !settle_observed_local_drain(
2606 db,
2607 &store,
2608 ®istry,
2609 Some(controller),
2610 &audited.transition,
2611 &snapshot,
2612 Some(&audited),
2613 deadline,
2614 SourceDrainResolutionDeadline::Absolute(deadline),
2615 )
2616 .await?
2617 {
2618 return Err("materialized source drain was not ready for recovery Release".into());
2619 }
2620 let confirmed = tokio::time::timeout_at(deadline, store.load())
2621 .await
2622 .map_err(|_| "recovery source-drain head recheck timed out".to_string())?
2623 .map_err(|error| error.to_string())?
2624 .ok_or_else(|| "recovery source-drain assignment disappeared".to_string())?;
2625 if confirmed != snapshot {
2626 return Err("recovery source-drain assignment changed during settlement".into());
2627 }
2628 let confirmed_terminal = tokio::time::timeout_at(
2629 deadline,
2630 audit_assignment_snapshot_authority_outcome(&store, Some(controller), &confirmed),
2631 )
2632 .await
2633 .map_err(|_| "recovery source-drain terminal recheck timed out".to_string())??;
2634 if confirmed_terminal.terminal() != Some(&audited) {
2635 return Err("recovery source-drain terminal changed during settlement".into());
2636 }
2637 clear_settled_source_drain(controller, &audited.transition)?;
2638 Ok(Some(snapshot.version))
2639}
2640
2641pub(crate) async fn audit_drain_predecessor(
2642 store: &AssignmentSnapshotStore,
2643 registry: &VnodeRegistry,
2644 draining: &AssignmentSnapshot,
2645 controller: Option<&ClusterController>,
2646) -> Result<AssignmentSnapshot, String> {
2647 if !draining.draining {
2648 return Err("drain predecessor audit requires a draining head".into());
2649 }
2650 let local_version = registry.assignment_version();
2651 let expected_target = local_version
2652 .checked_add(1)
2653 .ok_or_else(|| "assignment version overflow during drain audit".to_string())?;
2654 if draining.version != expected_target {
2655 return Err(format!(
2656 "draining assignment {} is not the exact successor of local assignment {local_version}",
2657 draining.version
2658 ));
2659 }
2660 draining
2661 .to_vnode_vec(registry.vnode_count())
2662 .map_err(|error| error.to_string())?;
2663
2664 let predecessor = store
2665 .load_version(local_version)
2666 .await
2667 .map_err(|error| error.to_string())?
2668 .ok_or_else(|| {
2669 format!(
2670 "draining assignment {} lost committed predecessor {local_version}",
2671 draining.version
2672 )
2673 })?;
2674 if predecessor.draining || predecessor.version != local_version {
2675 return Err(format!(
2676 "assignment {local_version} is not the committed predecessor of draining assignment {}",
2677 draining.version
2678 ));
2679 }
2680 let transition = draining
2681 .drain_transition
2682 .as_ref()
2683 .ok_or_else(|| "draining assignment has no exact transition".to_string())?;
2684 if predecessor
2685 .assignment_fence()
2686 .map_err(|error| error.to_string())?
2687 != transition.predecessor
2688 {
2689 return Err(format!(
2690 "draining assignment {} does not bind committed predecessor {local_version}",
2691 draining.version
2692 ));
2693 }
2694 let controller = controller
2695 .ok_or_else(|| "draining assignment requires a cluster controller".to_string())?;
2696 if !controller.process_lease_is_live() {
2697 return Err("local process lease expired while auditing assignment drain".into());
2698 }
2699 let authority = controller
2700 .checkpoint_authority()
2701 .map_err(|error| error.to_string())?;
2702 if authority
2703 .assignment_drain_decision(transition.target.assignment_version)
2704 .await
2705 .map_err(|error| error.to_string())?
2706 .is_some()
2707 {
2708 return Err("assignment drain already has a terminal authority decision".into());
2709 }
2710 let current_leader = authority
2711 .load()
2712 .await
2713 .map_err(|error| error.to_string())?
2714 .ok_or_else(|| "assignment drain leader authority is missing".to_string())?;
2715 if !current_leader.matches_proof(&transition.leader) {
2716 return Err("assignment drain leader term was superseded".into());
2717 }
2718 let owners = predecessor
2719 .to_vnode_vec(registry.vnode_count())
2720 .map_err(|error| error.to_string())?;
2721 let local = registry.snapshot();
2722 if owners.as_slice() != local.as_ref() {
2723 return Err(format!(
2724 "committed predecessor {local_version} does not match the local owner map"
2725 ));
2726 }
2727 Ok(predecessor)
2728}
2729
2730async fn audit_exact_drain_head(
2731 store: &AssignmentSnapshotStore,
2732 registry: &VnodeRegistry,
2733 draining: &AssignmentSnapshot,
2734 controller: &ClusterController,
2735 deadline: tokio::time::Instant,
2736) -> Result<AssignmentSnapshot, String> {
2737 let refreshed = tokio::time::timeout_at(deadline, store.load())
2738 .await
2739 .map_err(|_| "durable drain re-audit timed out".to_string())?
2740 .map_err(|error| error.to_string())?;
2741 if refreshed.as_ref() != Some(draining) {
2742 return Err("durable assignment drain is no longer the unfinalized head".into());
2743 }
2744 tokio::time::timeout_at(
2745 deadline,
2746 audit_drain_predecessor(store, registry, draining, Some(controller)),
2747 )
2748 .await
2749 .map_err(|_| "durable drain predecessor audit timed out".to_string())?
2750}
2751
2752async fn prepare_and_announce_local_drain(
2753 db: &LaminarDB,
2754 store: &AssignmentSnapshotStore,
2755 registry: &VnodeRegistry,
2756 controller: &ClusterController,
2757 draining: &AssignmentSnapshot,
2758 transition: &AssignmentDrainTransition,
2759 participant: CheckpointParticipant,
2760 deadline: tokio::time::Instant,
2761) -> Result<(), String> {
2762 db.prepare_local_source_drain(transition, participant, deadline)
2763 .await
2764 .map_err(|error| error.to_string())?;
2765
2766 audit_exact_drain_head(store, registry, draining, controller, deadline).await?;
2769 if controller.checkpoint_drain_transition().as_ref() != Some(transition) {
2770 return Err(
2771 "source-drain transition changed before durable acknowledgement publication".into(),
2772 );
2773 }
2774 tokio::time::timeout_at(deadline, controller.announce_drain_ack(transition))
2775 .await
2776 .map_err(|_| "durable drain acknowledgement publication timed out".to_string())?
2777}
2778
2779pub fn spawn_rebalance_controller(
2782 db: Arc<LaminarDB>,
2783 controller: Arc<ClusterController>,
2784 store: Arc<AssignmentSnapshotStore>,
2785 registry: Arc<VnodeRegistry>,
2786 shutdown: CancellationToken,
2787 config: RebalanceConfig,
2788) -> JoinHandle<()> {
2789 tokio::spawn(async move {
2790 let mut members = controller.members_watch();
2791 let mut audit = tokio::time::interval(config.watcher_poll);
2792 audit.set_missed_tick_behavior(MissedTickBehavior::Skip);
2793 loop {
2794 let membership_changed = tokio::select! {
2795 biased;
2796 () = shutdown.cancelled() => return,
2797 res = members.changed() => {
2798 if res.is_err() {
2799 warn!("membership watch sender dropped; rebalance controller exiting");
2800 return;
2801 }
2802 true
2803 }
2804 _ = audit.tick() => false,
2805 };
2806
2807 if membership_changed {
2808 debug!("membership change observed; debouncing");
2809 loop {
2810 tokio::select! {
2811 biased;
2812 () = shutdown.cancelled() => return,
2813 res = tokio::time::timeout(
2814 config.rebalance_debounce, members.changed()
2815 ) => {
2816 match res {
2817 Ok(Ok(())) => {} Ok(Err(_)) => return, Err(_) => break, }
2821 }
2822 }
2823 }
2824 }
2825
2826 loop {
2827 if !controller.is_leader() {
2828 debug!("membership changed; not the leader — skipping rotation check");
2829 break;
2830 }
2831 let live = controller.assignable_instances();
2833 match try_rebalance_owned(
2834 Arc::clone(&db),
2835 Arc::clone(&controller),
2836 Arc::clone(&store),
2837 Arc::clone(®istry),
2838 live,
2839 config,
2840 )
2841 .await
2842 {
2843 Ok(Some(v)) => {
2844 info!(version = v, "rotated assignment");
2845 break;
2846 }
2847 Ok(None) => {
2848 debug!("live set matches current snapshot; no rotation");
2849 break;
2850 }
2851 Err(e) => {
2852 warn!(error = %e, "rebalance failed; retrying after backoff");
2853 tokio::select! {
2854 biased;
2855 () = shutdown.cancelled() => return,
2856 () = tokio::time::sleep(config.retry_delay) => {}
2857 }
2858 }
2859 }
2860 }
2861 }
2862 })
2863}
2864
2865pub async fn wait_until_drained(
2871 store: &AssignmentSnapshotStore,
2872 authority: Option<&LeaderLeaseStore>,
2873 me: NodeId,
2874 vnode_count: u32,
2875 poll: Duration,
2876 deadline: Duration,
2877) -> bool {
2878 let start = tokio::time::Instant::now();
2879 loop {
2880 if start.elapsed() >= deadline {
2881 return false;
2882 }
2883 let remaining = deadline.saturating_sub(start.elapsed());
2884 match tokio::time::timeout(remaining, store.load()).await {
2885 Ok(Ok(None)) => {
2886 warn!(
2887 "wait_until_drained: durable assignment head is missing; ownership is unknown"
2888 );
2889 }
2890 Ok(Ok(Some(snap))) => {
2891 match audit_materialized_drain_with_authority(store, authority, &snap).await {
2892 Ok(_) => match snap.to_vnode_vec(vnode_count) {
2893 Ok(owners) if !snap.draining && !owners.contains(&me) => return true,
2894 Ok(_) => {}
2895 Err(error) => {
2896 warn!(%error, "wait_until_drained: snapshot cardinality mismatch");
2897 }
2898 },
2899 Err(error) => {
2900 warn!(%error, version = snap.version, "wait_until_drained: assignment authority audit failed");
2901 }
2902 }
2903 }
2904 Ok(Err(e)) => warn!(error = %e, "wait_until_drained: snapshot load failed"),
2905 Err(_) => {
2906 warn!("wait_until_drained: snapshot load exceeded the shutdown deadline");
2907 return false;
2908 }
2909 }
2910 let remaining = deadline.saturating_sub(start.elapsed());
2911 tokio::time::sleep(poll.min(remaining)).await;
2912 if start.elapsed() >= deadline {
2913 return false;
2914 }
2915 }
2916}
2917
2918fn materialize_recovery_decision<'a>(
2919 db: &'a Arc<LaminarDB>,
2920 store: &'a Arc<AssignmentSnapshotStore>,
2921 controller: &'a ClusterController,
2922 registry: &'a VnodeRegistry,
2923 decision: AssignmentRecoveryDecision,
2924 operation_timeout: Duration,
2925) -> futures::future::BoxFuture<'a, Result<Option<u64>, String>> {
2926 Box::pin(async move {
2927 let deadline = tokio::time::Instant::now() + operation_timeout;
2928 let _process_fences =
2929 close_local_assignment_authority(db, controller, &decision.target, &[], deadline)
2930 .await?;
2931 let proposal =
2932 tokio::time::timeout_at(deadline, store.load_recovery_proposal(&decision.proposal))
2933 .await
2934 .map_err(|_| {
2935 "recovery proposal load exceeded the materialization deadline".to_string()
2936 })?
2937 .map_err(|error| error.to_string())?;
2938 if proposal
2939 .assignment_fence()
2940 .map_err(|error| error.to_string())?
2941 != decision.target
2942 {
2943 return Err("recovery authority winner does not match its staged proposal".into());
2944 }
2945 local_recovery_assignment_scope(&proposal, controller)?;
2946 let authority = controller
2947 .checkpoint_authority()
2948 .map_err(|error| error.to_string())?;
2949 let durable = match tokio::time::timeout_at(
2950 deadline,
2951 authority.materialize_assignment_recovery(decision.target_version()),
2952 )
2953 .await
2954 .map_err(|_| "recovery assignment materialization exceeded its deadline".to_string())?
2955 .map_err(|error| error.to_string())?
2956 {
2957 RotateOutcome::Rotated => proposal.clone(),
2958 RotateOutcome::Conflict(winner) => *winner,
2959 };
2960 if durable != proposal {
2961 return Err(format!(
2962 "assignment {} materialization conflicts with the authority recovery winner",
2963 decision.target_version()
2964 ));
2965 }
2966 prepare_recovery_assignment_adoption(db, store, controller, registry, &durable, deadline)
2967 .await?;
2968 let version = durable.version;
2969 db.adopt_recovery_assignment_snapshot(durable, operation_timeout)
2970 .await
2971 .map_err(|error| error.to_string())?;
2972 let oldest_retained = version.saturating_sub(1);
2973 let maintenance_store = Arc::clone(store);
2974 let maintenance_authority = Arc::clone(&authority);
2975 let maintenance_proof = controller.capture_leader_proof();
2976 tokio::spawn(async move {
2977 match maintenance_store.prune_before(oldest_retained).await {
2978 Ok(()) => {
2979 if let Some(proof) = maintenance_proof {
2980 if let Err(error) = maintenance_authority
2981 .prune_assignment_drain_decisions_before(&proof, oldest_retained)
2982 .await
2983 {
2984 warn!(%error, "assignment recovery authority prune failed after snapshot prune");
2985 }
2986 }
2987 }
2988 Err(error) => warn!(%error, "snapshot prune failed after assignment recovery"),
2989 }
2990 });
2991 Ok(Some(version))
2992 })
2993}
2994
2995async fn reconcile_pending_recovery_decision(
2996 db: &Arc<LaminarDB>,
2997 store: &Arc<AssignmentSnapshotStore>,
2998 controller: &ClusterController,
2999 registry: &VnodeRegistry,
3000 current: &AssignmentSnapshot,
3001 operation_timeout: Duration,
3002) -> Result<Option<u64>, String> {
3003 let target_version = current
3004 .version
3005 .checked_add(1)
3006 .ok_or_else(|| "assignment version exhausted".to_string())?;
3007 let authority = controller
3008 .checkpoint_authority()
3009 .map_err(|error| error.to_string())?;
3010 let Some(decision) = tokio::time::timeout(
3011 operation_timeout,
3012 authority.assignment_recovery_decision(target_version),
3013 )
3014 .await
3015 .map_err(|_| "recovery authority lookup timed out".to_string())?
3016 .map_err(|error| error.to_string())?
3017 else {
3018 return Ok(None);
3019 };
3020 if decision.predecessor
3021 != current
3022 .assignment_fence()
3023 .map_err(|error| error.to_string())?
3024 {
3025 return Err(format!(
3026 "pending recovery decision for assignment {target_version} has the wrong predecessor"
3027 ));
3028 }
3029 materialize_recovery_decision(db, store, controller, registry, decision, operation_timeout)
3030 .await
3031}
3032
3033fn replaced_predecessor_processes(
3034 predecessor: &CheckpointAssignmentFence,
3035 target: &CheckpointAssignmentFence,
3036) -> Vec<CheckpointParticipant> {
3037 predecessor
3038 .participants
3039 .iter()
3040 .copied()
3041 .filter(|participant| {
3042 target.participant_incarnation(participant.node_id)
3043 != Some(participant.boot_incarnation)
3044 })
3045 .collect()
3046}
3047
3048fn authorize_recovery_successor<'a>(
3049 db: &'a Arc<LaminarDB>,
3050 store: &'a Arc<AssignmentSnapshotStore>,
3051 controller: &'a ClusterController,
3052 current: &'a AssignmentSnapshot,
3053 proposal: AssignmentSnapshot,
3054 operation_timeout: Duration,
3055 reason: &'a str,
3056) -> futures::future::BoxFuture<'a, Result<AssignmentRecoveryDecision, String>> {
3057 Box::pin(async move {
3058 let predecessor = current
3059 .assignment_fence()
3060 .map_err(|error| error.to_string())?;
3061 let target = proposal
3062 .assignment_fence()
3063 .map_err(|error| error.to_string())?;
3064 let deadline = controller.process_fencing_deadline(operation_timeout)?;
3065 let authority = controller
3066 .checkpoint_authority()
3067 .map_err(|error| error.to_string())?;
3068 tokio::time::timeout_at(
3071 deadline,
3072 authority.validate_topology_assignment_proposal(&target),
3073 )
3074 .await
3075 .map_err(|_| "topology assignment preflight exceeded the fencing deadline".to_string())?
3076 .map_err(|error| error.to_string())?;
3077 let removed = replaced_predecessor_processes(&predecessor, &target);
3078 let process_fences =
3079 close_local_assignment_authority(db, controller, &target, &removed, deadline).await?;
3080 let proposal_ref =
3081 tokio::time::timeout_at(deadline, store.stage_recovery_proposal(&proposal))
3082 .await
3083 .map_err(|_| "recovery proposal staging exceeded the fencing deadline".to_string())?
3084 .map_err(|error| error.to_string())?;
3085 let observed = tokio::time::timeout_at(deadline, store.load())
3086 .await
3087 .map_err(|_| "assignment head revalidation exceeded the fencing deadline".to_string())?
3088 .map_err(|error| error.to_string())?
3089 .ok_or_else(|| "assignment head disappeared during recovery fencing".to_string())?;
3090 if observed != *current {
3091 return Err(format!(
3092 "assignment head advanced from {} while process fencing was in progress",
3093 current.version
3094 ));
3095 }
3096 tokio::time::timeout_at(
3097 deadline,
3098 audit_assignment_snapshot_authority(store, Some(controller), &observed),
3099 )
3100 .await
3101 .map_err(|_| "predecessor authority audit exceeded the fencing deadline".to_string())??;
3102
3103 let target_checks =
3104 futures::future::join_all(target.participants.iter().copied().map(|participant| {
3105 controller.verify_current_process_incarnation(participant, deadline)
3106 }));
3107 for (participant, check) in target.participants.iter().zip(target_checks.await) {
3108 if !check? {
3109 return Err(format!(
3110 "successor process {} is not the current durable lease owner",
3111 participant.node_id
3112 ));
3113 }
3114 }
3115
3116 let committed_head =
3117 tokio::time::timeout_at(deadline, authority.highest_cluster_committed_outcome())
3118 .await
3119 .map_err(|_| {
3120 "recovery checkpoint lookup exceeded the fencing deadline".to_string()
3121 })?
3122 .map_err(|error| error.to_string())?
3123 .ok_or_else(|| {
3124 "assignment recovery requires a committed cluster checkpoint".to_string()
3125 })?;
3126 let head_reference = committed_head.committed_checkpoint.clone().ok_or_else(|| {
3127 "latest committed cluster checkpoint has no committed index reference".to_string()
3128 })?;
3129 let pinned_checkpoint = tokio::time::timeout_at(
3130 deadline,
3131 authority.assignment_handoff_checkpoint(&predecessor),
3132 )
3133 .await
3134 .map_err(|_| "recovery handoff lookup exceeded the fencing deadline".to_string())?
3135 .map_err(|error| error.to_string())?;
3136 let recovery_checkpoint = if let Some(reference) = pinned_checkpoint {
3137 if reference != head_reference {
3138 return Err(
3139 "recovery handoff pin is not the exact latest committed checkpoint head".into(),
3140 );
3141 }
3142 reference
3143 } else {
3144 if committed_head.assignment_fence.as_ref() != Some(&predecessor) {
3145 return Err(
3146 "latest committed cluster checkpoint does not bind the recovery predecessor"
3147 .into(),
3148 );
3149 }
3150 head_reference
3151 };
3152 let (observed_outcome, observed_index) = tokio::time::timeout_at(
3153 deadline,
3154 authority.cluster_outcome_with_committed_checkpoint(recovery_checkpoint.epoch),
3155 )
3156 .await
3157 .map_err(|_| "recovery checkpoint head load exceeded the fencing deadline".to_string())?
3158 .map_err(|error| error.to_string())?
3159 .ok_or_else(|| "recovery checkpoint Commit disappeared during fencing".to_string())?;
3160 if observed_outcome != committed_head {
3161 return Err("recovery checkpoint head changed during successor authorization".into());
3162 }
3163 let recovery_index = observed_index
3164 .ok_or_else(|| "recovery checkpoint Commit has no committed index".to_string())?;
3165 validate_portable_recovery_cut(
3166 &observed_outcome,
3167 &recovery_index,
3168 &recovery_checkpoint,
3169 &predecessor,
3170 "assignment recovery authorization",
3171 )?;
3172 let leader_proof = controller.capture_leader_proof().ok_or_else(|| {
3173 "assignment recovery lost the current durable leader proof".to_string()
3174 })?;
3175 let decision = AssignmentRecoveryDecision::new(
3176 predecessor,
3177 target,
3178 proposal_ref,
3179 process_fences,
3180 recovery_checkpoint,
3181 leader_proof.clone(),
3182 )?;
3183 let decision = match tokio::time::timeout_at(
3184 deadline,
3185 controller.record_assignment_recovery_decision(&leader_proof, decision, deadline),
3186 )
3187 .await
3188 .map_err(|_| "recovery authority admission exceeded the fencing deadline".to_string())?
3189 .map_err(|error| error.clone())?
3190 {
3191 RecordAssignmentRecoveryDecisionResult::Created(decision)
3192 | RecordAssignmentRecoveryDecisionResult::Unchanged(decision) => decision,
3193 RecordAssignmentRecoveryDecisionResult::Conflict { winner } => winner,
3194 };
3195 warn!(
3196 predecessor_version = current.version,
3197 target_version = decision.target_version(),
3198 %reason,
3199 "authorized successor assignment from the last committed cluster cut"
3200 );
3201 Ok(decision)
3202 })
3203}
3204
3205enum DrainPublicationReconciliation {
3206 Deferred,
3207 Resolved {
3208 outcome: RotateOutcome,
3209 authority_changed: bool,
3210 },
3211}
3212
3213async fn reconcile_drain_publication(
3214 store: &AssignmentSnapshotStore,
3215 controller: &ClusterController,
3216 drain: &AssignmentSnapshot,
3217 prior_version: u64,
3218 retry_delay: Duration,
3219) -> Result<DrainPublicationReconciliation, SnapshotError> {
3220 let transition = drain.drain_transition.as_ref().ok_or_else(|| {
3221 SnapshotError::Invalid("drain publication has no exact transition".into())
3222 })?;
3223 let mut had_ambiguous_attempt = false;
3224 let mut authority_changed = false;
3225
3226 loop {
3227 let changed = controller.is_recovering() || !controller.proof_is_live(&transition.leader);
3228 authority_changed |= changed;
3229 if changed && !had_ambiguous_attempt {
3230 return Ok(DrainPublicationReconciliation::Deferred);
3231 }
3232
3233 if transition.predecessor.assignment_version != prior_version {
3234 return Err(SnapshotError::Invalid(
3235 "drain predecessor differs from publication version".into(),
3236 ));
3237 }
3238 let authority = controller
3239 .checkpoint_authority()
3240 .map_err(assignment_publication_error)?;
3241 let published = authority
3242 .publish_assignment_drain(&transition.leader, store, drain)
3243 .await;
3244 if matches!(
3245 &published,
3246 Err(laminar_core::cluster::control::ClusterCheckpointAuthorityError::Fenced)
3247 ) {
3248 return Ok(DrainPublicationReconciliation::Deferred);
3249 }
3250 match published.map_err(assignment_publication_error) {
3251 Ok(outcome) => {
3252 authority_changed |=
3253 controller.is_recovering() || !controller.proof_is_live(&transition.leader);
3254 let outcome = match outcome {
3255 RotateOutcome::Conflict(winner) if winner.as_ref() == drain => {
3256 RotateOutcome::Rotated
3257 }
3258 outcome => outcome,
3259 };
3260 return Ok(DrainPublicationReconciliation::Resolved {
3261 outcome,
3262 authority_changed,
3263 });
3264 }
3265 Err(SnapshotError::Io(error)) => {
3266 had_ambiguous_attempt = true;
3267 authority_changed |=
3268 controller.is_recovering() || !controller.proof_is_live(&transition.leader);
3269 warn!(
3270 version = drain.version,
3271 %error,
3272 "ambiguous assignment drain publication; reconciling the exact create"
3273 );
3274 tokio::time::sleep(retry_delay).await;
3275 }
3276 Err(error @ (SnapshotError::Invalid(_) | SnapshotError::Json(_))) => {
3277 return Err(error);
3278 }
3279 }
3280 }
3281}
3282
3283fn execute_graceful_rotation_owned(
3284 db: Arc<LaminarDB>,
3285 controller: Arc<ClusterController>,
3286 store: Arc<AssignmentSnapshotStore>,
3287 registry: Arc<VnodeRegistry>,
3288 current: AssignmentSnapshot,
3289 new_vnodes: std::collections::BTreeMap<u32, NodeId>,
3290 participants: Vec<CheckpointParticipant>,
3291 config: RebalanceConfig,
3292) -> futures::future::BoxFuture<'static, Result<Option<u64>, String>> {
3293 Box::pin(async move {
3294 let leader = controller.capture_leader_proof().ok_or_else(|| {
3295 "assignment drain requires the current durable leader proof".to_string()
3296 })?;
3297 let drain = current
3298 .next_draining(new_vnodes.clone(), participants.clone(), leader)
3299 .map_err(|error| error.to_string())?;
3300 let transition = drain
3301 .drain_transition
3302 .as_ref()
3303 .expect("validated draining snapshot has a transition")
3304 .clone();
3305 let current_owners = current
3306 .to_vnode_vec(registry.vnode_count())
3307 .map_err(|error| error.to_string())?;
3308 let rotate_outcome = {
3309 let _checkpoints = drain_admission::hold_checkpoints_until_sink_epoch_opens(
3310 &db,
3311 &controller,
3312 tokio::time::Instant::now() + config.checkpoint_timeout,
3313 )
3314 .await;
3315 let publication_deadline = tokio::time::Instant::now() + config.checkpoint_timeout;
3316 let _adoption = tokio::time::timeout_at(
3324 publication_deadline,
3325 db.assignment_adoption_lock.lock(),
3326 )
3327 .await
3328 .map_err(|_| {
3329 format!(
3330 "graceful rotation from assignment {} timed out waiting for assignment serialization",
3331 current.version
3332 )
3333 })?;
3334 let local = registry.versioned_snapshot();
3335 if local.version() != current.version || local.owners() != current_owners.as_slice() {
3336 return Err(format!(
3337 "graceful rotation predecessor assignment {} does not match local assignment {}",
3338 current.version,
3339 local.version()
3340 ));
3341 }
3342 if !drain_admission::predecessor_admits_drain(
3343 &db,
3344 &controller,
3345 ®istry,
3346 current.version,
3347 &transition,
3348 publication_deadline,
3349 )
3350 .await?
3351 {
3352 return Ok(None);
3353 }
3354 let outcome = match reconcile_drain_publication(
3355 &store,
3356 &controller,
3357 &drain,
3358 current.version,
3359 config.retry_delay,
3360 )
3361 .await
3362 .map_err(|error| {
3363 format!(
3364 "assignment drain {} conditional publication failed deterministically: {error}",
3365 drain.version
3366 )
3367 })? {
3368 DrainPublicationReconciliation::Deferred => {
3369 debug!(
3370 version = current.version,
3371 "graceful rotation deferred after publication authority changed"
3372 );
3373 return Ok(None);
3374 }
3375 DrainPublicationReconciliation::Resolved {
3376 authority_changed: true,
3377 ..
3378 } => {
3379 return Err(format!(
3380 "assignment drain {} became durable after publication authority changed; yielding to durable drain recovery",
3381 drain.version
3382 ));
3383 }
3384 DrainPublicationReconciliation::Resolved {
3385 outcome,
3386 authority_changed: false,
3387 } => outcome,
3388 };
3389 if matches!(&outcome, RotateOutcome::Rotated) {
3390 db.validate_source_drain_snapshot(&drain)
3395 .map_err(|error| error.to_string())?;
3396 match controller.checkpoint_drain_transition() {
3397 None => {
3398 controller.publish_checkpoint_drain_transition(Some(transition.clone()));
3399 }
3400 Some(installed) if installed == transition => {}
3401 Some(_) => {
3402 return Err(
3403 "a different source-drain transition is already installed locally"
3404 .into(),
3405 );
3406 }
3407 }
3408 }
3409 outcome
3410 };
3411 match rotate_outcome {
3412 RotateOutcome::Rotated => {
3413 let drain_deadline = tokio::time::Instant::now() + config.drain_ack_timeout;
3414 let local_receipt = match local_drain_participant(&controller, &transition) {
3418 Some(participant) => {
3419 prepare_and_announce_local_drain(
3420 &db,
3421 &store,
3422 ®istry,
3423 &controller,
3424 &drain,
3425 &transition,
3426 participant,
3427 drain_deadline,
3428 )
3429 .await
3430 }
3431 None => Ok(()),
3432 };
3433 let acked = match local_receipt {
3434 Ok(()) => await_drain_quorum(&controller, &transition, drain_deadline).await,
3435 Err(error) => {
3436 warn!(%error, version = drain.version, "leader durable drain acknowledgement failed");
3437 false
3438 }
3439 };
3440 if !acked {
3441 let failure = "drain ack quorum not reached before timeout";
3442 if let Err(error) = audit_exact_drain_head(
3443 &store,
3444 ®istry,
3445 &drain,
3446 &controller,
3447 tokio::time::Instant::now() + config.checkpoint_timeout,
3448 )
3449 .await
3450 {
3451 return Err(format!(
3452 "{failure}; drain is no longer authoritative: {error}"
3453 ));
3454 }
3455 if let Err(recovery_error) = ensure_local_recovery_fault(&db, &controller).await
3461 {
3462 return Err(format!(
3463 "{failure}; coordinated recovery request failed: {recovery_error}"
3464 ));
3465 }
3466 return Err(format!("{failure}; coordinated recovery requested"));
3467 }
3468 audit_exact_drain_head(
3469 &store,
3470 ®istry,
3471 &drain,
3472 &controller,
3473 tokio::time::Instant::now() + config.checkpoint_timeout,
3474 )
3475 .await?;
3476 let handoff_checkpoint =
3479 match pre_rotation_checkpoint(&db, &controller, &transition, config).await {
3480 Ok(reference) => reference,
3481 Err(failure) => {
3482 if let Err(error) = audit_exact_drain_head(
3483 &store,
3484 ®istry,
3485 &drain,
3486 &controller,
3487 tokio::time::Instant::now() + config.checkpoint_timeout,
3488 )
3489 .await
3490 {
3491 return Err(format!(
3492 "{failure}; drain is no longer authoritative: {error}"
3493 ));
3494 }
3495 if let Err(abort_error) = finalize_drain_snapshot(
3496 &db,
3497 &store,
3498 &controller,
3499 &drain,
3500 ¤t,
3501 AssignmentDrainVerdict::Abort,
3502 None,
3503 config,
3504 DrainFinalizationMode::Live,
3505 )
3506 .await
3507 {
3508 return Err(format!(
3509 "{failure}; assignment drain abort failed: {abort_error}"
3510 ));
3511 }
3512 return Err(failure);
3513 }
3514 };
3515 audit_exact_drain_head(
3516 &store,
3517 ®istry,
3518 &drain,
3519 &controller,
3520 tokio::time::Instant::now() + config.checkpoint_timeout,
3521 )
3522 .await?;
3523 return finalize_drain_snapshot(
3524 &db,
3525 &store,
3526 &controller,
3527 &drain,
3528 ¤t,
3529 AssignmentDrainVerdict::Commit,
3530 Some(handoff_checkpoint),
3531 config,
3532 DrainFinalizationMode::Live,
3533 )
3534 .await;
3535 }
3536 RotateOutcome::Conflict(winner) => {
3537 let v = winner.version;
3538 adopt_any(
3539 &db,
3540 &store,
3541 &controller,
3542 ®istry,
3543 *winner,
3544 config.checkpoint_timeout,
3545 )
3546 .await?;
3547 Ok(Some(v))
3548 }
3549 }
3550 })
3551}
3552fn try_rebalance_owned(
3553 db: Arc<LaminarDB>,
3554 controller: Arc<ClusterController>,
3555 store: Arc<AssignmentSnapshotStore>,
3556 registry: Arc<VnodeRegistry>,
3557 live: Vec<NodeId>,
3558 config: RebalanceConfig,
3559) -> futures::future::BoxFuture<'static, Result<Option<u64>, String>> {
3560 Box::pin(async move {
3561 let head_deadline = tokio::time::Instant::now() + config.checkpoint_timeout;
3562 let authority = controller
3563 .checkpoint_authority()
3564 .map_err(|error| error.to_string())?;
3565 tokio::time::timeout_at(
3566 head_deadline,
3567 authority.materialize_reserved_assignment_drain(&store),
3568 )
3569 .await
3570 .map_err(|_| "reserved assignment drain materialization timed out".to_string())?
3571 .map_err(|error| error.to_string())?;
3572 let current = tokio::time::timeout_at(head_deadline, store.load())
3573 .await
3574 .map_err(|_| "durable assignment head audit timed out".to_string())?
3575 .map_err(|e| e.to_string())?
3576 .ok_or_else(|| "no snapshot on store — boot seed missing".to_string())?;
3577 let current_authority = tokio::time::timeout_at(
3578 head_deadline,
3579 audit_assignment_snapshot_authority_outcome_owned(
3580 Arc::clone(&store),
3581 Arc::clone(&controller),
3582 current.clone(),
3583 ),
3584 )
3585 .await
3586 .map_err(|_| "durable assignment authority audit timed out".to_string())??;
3587 if current_authority.is_recovery() {
3591 abort_predecessor_checkpoint_for_recovery(&store, &controller, ¤t, head_deadline)
3592 .await?;
3593 }
3594 let current_owners = current
3595 .to_vnode_vec(registry.vnode_count())
3596 .map_err(|error| error.to_string())?;
3597
3598 let local_assignment = registry.versioned_snapshot();
3599 validate_local_assignment_head(
3600 ¤t,
3601 ¤t_owners,
3602 local_assignment.version(),
3603 local_assignment.owners(),
3604 )?;
3605
3606 let may_reconcile_propagated_successor = local_assignment.version() >= current.version
3612 || DbState::load(&db.state) == DbState::Faulted;
3613 if let Some(successor) = current_authority
3614 .propagated_recovery_successor()
3615 .filter(|_| may_reconcile_propagated_successor)
3616 {
3617 let expected_version = successor.assignment_version;
3618 let materialized = reconcile_pending_recovery_decision_owned(
3619 Arc::clone(&db),
3620 Arc::clone(&store),
3621 Arc::clone(&controller),
3622 Arc::clone(®istry),
3623 current.clone(),
3624 config.checkpoint_timeout,
3625 )
3626 .await?
3627 .ok_or_else(|| {
3628 format!(
3629 "assignment {} propagated its checkpoint pin to missing recovery successor {expected_version}",
3630 current.version
3631 )
3632 })?;
3633 if materialized != expected_version {
3634 return Err(format!(
3635 "assignment {} propagated recovery materialized {materialized}, expected {expected_version}",
3636 current.version
3637 ));
3638 }
3639 return Ok(Some(materialized));
3640 }
3641 if current.draining {
3642 let prior_version = current.version.checked_sub(1).ok_or_else(|| {
3643 "draining assignment has no prior committed generation".to_string()
3644 })?;
3645 let prior = tokio::time::timeout_at(head_deadline, store.load_version(prior_version))
3646 .await
3647 .map_err(|_| {
3648 format!("committed predecessor {prior_version} load exceeded the head deadline")
3649 })?
3650 .map_err(|error| error.to_string())?
3651 .ok_or_else(|| {
3652 format!(
3653 "draining assignment {} lost committed predecessor {prior_version}",
3654 current.version
3655 )
3656 })?;
3657 if prior.draining {
3658 return Err(format!(
3659 "draining assignment {} has a non-committed predecessor",
3660 current.version
3661 ));
3662 }
3663 tokio::time::timeout_at(
3664 head_deadline,
3665 audit_assignment_snapshot_authority_owned(
3666 Arc::clone(&store),
3667 Arc::clone(&controller),
3668 prior.clone(),
3669 ),
3670 )
3671 .await
3672 .map_err(|_| {
3673 format!(
3674 "committed predecessor {prior_version} authority audit exceeded the head deadline"
3675 )
3676 })??;
3677 prior
3678 .to_vnode_vec(registry.vnode_count())
3679 .map_err(|error| error.to_string())?;
3680 suspend_local_assignment_authority(&db, Some(controller.as_ref()), head_deadline)
3686 .await?;
3687 return finalize_drain_snapshot(
3688 &db,
3689 &store,
3690 &controller,
3691 ¤t,
3692 &prior,
3693 AssignmentDrainVerdict::Abort,
3694 None,
3695 config,
3696 DrainFinalizationMode::Live,
3697 )
3698 .await;
3699 }
3700
3701 if live.is_empty() {
3704 return Ok(None);
3705 }
3706 let live_ids = successor_participant_ids(&live);
3707 let observed_processes = controller
3708 .available_recovery_participant_incarnations(&live_ids)
3709 .await?;
3710 let process_read_deadline = tokio::time::Instant::now() + config.checkpoint_timeout;
3711 let process_checks =
3712 futures::future::join_all(observed_processes.iter().copied().map(|participant| {
3713 controller.verify_current_process_incarnation(participant, process_read_deadline)
3714 }))
3715 .await;
3716 let mut available_processes = Vec::with_capacity(observed_processes.len());
3717 for (participant, check) in observed_processes.into_iter().zip(process_checks) {
3718 if check? {
3719 available_processes.push(participant);
3720 }
3721 }
3722 let successor_processes = available_processes
3723 .iter()
3724 .copied()
3725 .filter(|participant| controller.admit_successor_process(*participant))
3726 .collect::<Vec<_>>();
3727 let successors: Vec<NodeId> = successor_processes
3728 .iter()
3729 .map(|participant| NodeId(participant.node_id))
3730 .collect();
3731 if successors.is_empty() {
3732 return Ok(None);
3733 }
3734 let current_roster_is_live = successor_participants(¤t_owners, &successor_processes)
3735 .is_ok_and(|participants| participants == current.participants);
3736 if current.version > local_assignment.version()
3737 && current_authority.is_recovery()
3738 && assignment_binds_local_process(¤t, &controller)?
3739 {
3740 return adopt_materialized_recovery_head(
3741 &db,
3742 &store,
3743 &controller,
3744 ®istry,
3745 ¤t,
3746 head_deadline,
3747 config.checkpoint_timeout,
3748 )
3749 .await
3750 .map(Some);
3751 }
3752 if current.version > local_assignment.version() && current_roster_is_live {
3753 db.adopt_assignment_snapshot(current.clone(), head_deadline)
3758 .await
3759 .map_err(|error| error.to_string())?;
3760 let reconciled_version = registry.assignment_version();
3761 if reconciled_version < current.version {
3762 return Err(format!(
3763 "durable assignment {} was not adopted; local assignment remains {}",
3764 current.version, reconciled_version
3765 ));
3766 }
3767 return Ok(Some(reconciled_version));
3768 }
3769 if let Some(version) = reconcile_pending_recovery_decision_owned(
3773 Arc::clone(&db),
3774 Arc::clone(&store),
3775 Arc::clone(&controller),
3776 Arc::clone(®istry),
3777 current.clone(),
3778 config.checkpoint_timeout,
3779 )
3780 .await?
3781 {
3782 return Ok(Some(version));
3783 }
3784 let mut new_assignment =
3785 rendezvous_assignment(registry.vnode_count(), &successors).to_vec();
3786 let mut new_vnodes = AssignmentSnapshot::vnodes_from_vec(&new_assignment);
3787 let mut participants = successor_participants(&new_assignment, &successor_processes)?;
3793 let roster_changed = current.participants != participants;
3794
3795 if new_vnodes == current.vnodes && !roster_changed {
3796 return Ok(None);
3797 }
3798
3799 let recovery_reason =
3800 predecessor_cut_unavailability(&controller, ¤t, ¤t_owners, &successors)
3801 .await;
3802 if let Some(reason) = recovery_reason {
3803 retain_recovery_predecessors(
3804 &mut new_assignment,
3805 ¤t.participants,
3806 &successor_processes,
3807 )?;
3808 new_vnodes = AssignmentSnapshot::vnodes_from_vec(&new_assignment);
3809 participants = successor_participants(&new_assignment, &successor_processes)?;
3810 let proposal = current
3811 .next_for_participants(new_vnodes, participants)
3812 .map_err(|error| error.to_string())?;
3813 return authorize_recovery_successor_owned(
3814 Arc::clone(&db),
3815 Arc::clone(&store),
3816 Arc::clone(&controller),
3817 Arc::clone(®istry),
3818 current.clone(),
3819 proposal,
3820 config.checkpoint_timeout,
3821 reason,
3822 )
3823 .await;
3824 }
3825
3826 execute_graceful_rotation_owned(
3827 db,
3828 controller,
3829 store,
3830 registry,
3831 current,
3832 new_vnodes,
3833 participants,
3834 config,
3835 )
3836 .await
3837 })
3838}
3839
3840#[cfg(test)]
3841async fn try_rebalance(
3842 db: &Arc<LaminarDB>,
3843 controller: &Arc<ClusterController>,
3844 store: &Arc<AssignmentSnapshotStore>,
3845 registry: &Arc<VnodeRegistry>,
3846 live: &[NodeId],
3847 config: RebalanceConfig,
3848) -> Result<Option<u64>, String> {
3849 try_rebalance_owned(
3850 Arc::clone(db),
3851 Arc::clone(controller),
3852 Arc::clone(store),
3853 Arc::clone(registry),
3854 live.to_vec(),
3855 config,
3856 )
3857 .await
3858}
3859
3860fn audit_assignment_snapshot_authority_owned(
3861 store: Arc<AssignmentSnapshotStore>,
3862 controller: Arc<ClusterController>,
3863 snapshot: AssignmentSnapshot,
3864) -> futures::future::BoxFuture<'static, Result<(), String>> {
3865 Box::pin(async move {
3866 audit_assignment_snapshot_authority(&store, Some(&controller), &snapshot).await
3867 })
3868}
3869
3870fn audit_assignment_snapshot_authority_outcome_owned(
3871 store: Arc<AssignmentSnapshotStore>,
3872 controller: Arc<ClusterController>,
3873 snapshot: AssignmentSnapshot,
3874) -> futures::future::BoxFuture<'static, Result<AuditedAssignmentAuthority, String>> {
3875 Box::pin(async move {
3876 audit_assignment_snapshot_authority_outcome(&store, Some(&controller), &snapshot).await
3877 })
3878}
3879
3880fn reconcile_pending_recovery_decision_owned(
3881 db: Arc<LaminarDB>,
3882 store: Arc<AssignmentSnapshotStore>,
3883 controller: Arc<ClusterController>,
3884 registry: Arc<VnodeRegistry>,
3885 current: AssignmentSnapshot,
3886 operation_timeout: Duration,
3887) -> futures::future::BoxFuture<'static, Result<Option<u64>, String>> {
3888 Box::pin(async move {
3889 reconcile_pending_recovery_decision(
3890 &db,
3891 &store,
3892 &controller,
3893 ®istry,
3894 ¤t,
3895 operation_timeout,
3896 )
3897 .await
3898 })
3899}
3900
3901fn authorize_recovery_successor_owned(
3902 db: Arc<LaminarDB>,
3903 store: Arc<AssignmentSnapshotStore>,
3904 controller: Arc<ClusterController>,
3905 registry: Arc<VnodeRegistry>,
3906 current: AssignmentSnapshot,
3907 proposal: AssignmentSnapshot,
3908 operation_timeout: Duration,
3909 reason: String,
3910) -> futures::future::BoxFuture<'static, Result<Option<u64>, String>> {
3911 Box::pin(async move {
3912 let decision = authorize_recovery_successor(
3913 &db,
3914 &store,
3915 &controller,
3916 ¤t,
3917 proposal,
3918 operation_timeout,
3919 &reason,
3920 )
3921 .await?;
3922 materialize_recovery_decision(
3923 &db,
3924 &store,
3925 &controller,
3926 ®istry,
3927 decision,
3928 operation_timeout,
3929 )
3930 .await
3931 })
3932}
3933
3934async fn predecessor_cut_unavailability(
3935 controller: &ClusterController,
3936 current: &AssignmentSnapshot,
3937 current_owners: &[NodeId],
3938 live: &[NodeId],
3939) -> Option<String> {
3940 let participant_ids: Vec<u64> = current
3941 .participants
3942 .iter()
3943 .map(|participant| participant.node_id)
3944 .collect();
3945 match controller
3946 .recovery_participant_incarnations(&participant_ids)
3947 .await
3948 {
3949 Ok(observed) if observed == current.participants => {}
3950 Ok(_) => {
3951 return Some("the predecessor process incarnation roster changed".into());
3952 }
3953 Err(error) => {
3954 return Some(format!(
3955 "the predecessor process incarnation roster is unavailable: {error}"
3956 ));
3957 }
3958 }
3959
3960 let members = controller.members_watch().borrow().clone();
3961 let owners: std::collections::BTreeSet<NodeId> = current_owners.iter().copied().collect();
3962 for owner in owners {
3963 if controller.is_unresponsive(owner) {
3964 return Some(format!(
3965 "predecessor owner {owner} cannot certify the source cut"
3966 ));
3967 }
3968 if live.contains(&owner) {
3969 continue;
3970 }
3971 let can_drain = members.iter().any(|member| {
3972 member.id == owner && matches!(member.state, NodeState::Active | NodeState::Draining)
3973 });
3974 if !can_drain {
3975 return Some(format!(
3976 "predecessor owner {owner} cannot certify the source cut"
3977 ));
3978 }
3979 }
3980 None
3981}
3982
3983fn successor_participant_ids(owners: &[NodeId]) -> Vec<u64> {
3984 owners
3985 .iter()
3986 .map(|owner| owner.0)
3987 .collect::<std::collections::BTreeSet<_>>()
3988 .into_iter()
3989 .collect()
3990}
3991
3992fn successor_participants(
3993 owners: &[NodeId],
3994 available: &[CheckpointParticipant],
3995) -> Result<Vec<CheckpointParticipant>, String> {
3996 let participant_ids = successor_participant_ids(owners);
3997 let participant_set: std::collections::BTreeSet<u64> =
3998 participant_ids.iter().copied().collect();
3999 let participants = available
4000 .iter()
4001 .copied()
4002 .filter(|participant| participant_set.contains(&participant.node_id))
4003 .collect::<Vec<_>>();
4004 if participants.len() != participant_ids.len() {
4005 return Err("successor owner roster lost a lease-validated process identity".into());
4006 }
4007 Ok(participants)
4008}
4009
4010fn retain_recovery_predecessors(
4011 assignment: &mut [NodeId],
4012 predecessor: &[CheckpointParticipant],
4013 successors: &[CheckpointParticipant],
4014) -> Result<(), String> {
4015 let successor_processes = successors
4016 .iter()
4017 .map(|participant| (participant.node_id, participant.boot_incarnation))
4018 .collect::<std::collections::BTreeMap<_, _>>();
4019 let retained = predecessor
4020 .iter()
4021 .filter(|participant| {
4022 successor_processes.get(&participant.node_id) == Some(&participant.boot_incarnation)
4023 })
4024 .map(|participant| NodeId(participant.node_id))
4025 .collect::<std::collections::BTreeSet<_>>();
4026 let mut counts = assignment.iter().copied().fold(
4027 std::collections::BTreeMap::<NodeId, usize>::new(),
4028 |mut counts, owner| {
4029 *counts.entry(owner).or_default() += 1;
4030 counts
4031 },
4032 );
4033
4034 for participant in retained.iter().copied() {
4035 if counts.get(&participant).copied().unwrap_or_default() != 0 {
4036 continue;
4037 }
4038 let mut donor = None;
4039 for (index, owner) in assignment.iter().copied().enumerate() {
4040 let owner_count = counts.get(&owner).copied().unwrap_or_default();
4041 if retained.contains(&owner) && owner_count <= 1 {
4042 continue;
4043 }
4044 if donor.is_none_or(|(_, best_count)| owner_count > best_count) {
4045 donor = Some((index, owner_count));
4046 }
4047 }
4048 let Some((index, _)) = donor else {
4049 return Err(format!(
4050 "recovery placement cannot retain healthy predecessor {participant}"
4051 ));
4052 };
4053 let displaced = assignment[index];
4054 assignment[index] = participant;
4055 *counts
4056 .get_mut(&displaced)
4057 .expect("assignment owner has a placement count") -= 1;
4058 *counts.entry(participant).or_default() += 1;
4059 }
4060 Ok(())
4061}
4062
4063#[cfg(test)]
4064pub(crate) async fn admit_cluster_checkpoint_artifacts_for_test(
4065 authority: &LeaderLeaseStore,
4066 proof: &laminar_core::checkpoint::LeaderProof,
4067 index: &laminar_core::checkpoint::CommittedCheckpointIndex,
4068) {
4069 use laminar_core::checkpoint::CheckpointAttempt;
4070 use laminar_core::checkpoint_decision::CheckpointArtifactInventory;
4071
4072 let inventory = CheckpointArtifactInventory {
4073 deployment_id: index.deployment_id.clone(),
4074 pipeline_identity: index.pipeline_identity.clone(),
4075 attempt: CheckpointAttempt::new(index.epoch, index.checkpoint_id),
4076 assignment_fence: index.assignment_fence.clone(),
4077 sink_artifact_intent_protocol: true,
4078 };
4079 assert_eq!(
4080 authority
4081 .begin_cluster_checkpoint_artifacts(proof, inventory.clone())
4082 .await
4083 .unwrap(),
4084 inventory
4085 );
4086}
4087
4088#[cfg(test)]
4089pub(crate) async fn record_assignment_checkpoint_for_test(
4090 authority: &LeaderLeaseStore,
4091 authority_store: &Arc<dyn object_store::ObjectStore>,
4092 fence: &CheckpointAssignmentFence,
4093 proof: &laminar_core::checkpoint::LeaderProof,
4094) -> CommittedCheckpointRef {
4095 use laminar_core::checkpoint::{
4096 CheckpointScope, CommittedCheckpointIndex, CommittedParticipantRef, PipelineIdentity,
4097 COMMITTED_CHECKPOINT_INDEX_VERSION,
4098 };
4099 use laminar_core::checkpoint_decision::{CheckpointDecisionStore, CheckpointVerdict};
4100
4101 let epoch = authority
4102 .highest_cluster_terminal_outcome()
4103 .await
4104 .unwrap()
4105 .map_or(1, |outcome| outcome.epoch.checked_add(1).unwrap());
4106 let predecessor = authority
4107 .highest_cluster_committed_outcome()
4108 .await
4109 .unwrap()
4110 .and_then(|outcome| outcome.committed_checkpoint);
4111 let deployment_id = CheckpointDecisionStore::new(Arc::clone(authority_store))
4112 .load_or_create_deployment_id()
4113 .await
4114 .unwrap();
4115 let index = CommittedCheckpointIndex {
4116 version: COMMITTED_CHECKPOINT_INDEX_VERSION,
4117 deployment_id,
4118 pipeline_identity: PipelineIdentity::empty(),
4119 epoch,
4120 checkpoint_id: epoch,
4121 scope: CheckpointScope::Cluster,
4122 vnode_count: u16::try_from(fence.vnode_count).unwrap(),
4123 assignment_fence: Some(fence.clone()),
4124 reassignment_portable: true,
4125 predecessor,
4126 participants: fence
4127 .participants
4128 .iter()
4129 .map(|participant| CommittedParticipantRef {
4130 participant_id: participant.node_id,
4131 manifest_len: 1,
4132 manifest_sha256: "0".repeat(64),
4133 node_data_len: 0,
4134 node_data_sha256: "1".repeat(64),
4135 })
4136 .collect(),
4137 source_names: Vec::new(),
4138 source_offsets: Default::default(),
4139 channel_progress: Vec::new(),
4140 source_watermarks: Default::default(),
4141 checkpoint_watermark: None,
4142 view_frontiers: Default::default(),
4143 };
4144 admit_cluster_checkpoint_artifacts_for_test(authority, proof, &index).await;
4145 let reference = authority.create_committed_checkpoint(&index).await.unwrap();
4146 authority
4147 .record_cluster_outcome(
4148 proof,
4149 epoch,
4150 epoch,
4151 fence.clone(),
4152 CheckpointVerdict::Commit,
4153 Some(reference.clone()),
4154 )
4155 .await
4156 .unwrap();
4157 reference
4158}
4159
4160async fn pre_rotation_checkpoint(
4162 db: &Arc<LaminarDB>,
4163 controller: &ClusterController,
4164 transition: &AssignmentDrainTransition,
4165 config: RebalanceConfig,
4166) -> Result<CommittedCheckpointRef, String> {
4167 let deadline = tokio::time::Instant::now() + config.checkpoint_timeout;
4168 let ckpt = db
4169 .checkpoint_until(deadline)
4170 .await
4171 .map_err(|e| e.to_string())?;
4172 if !ckpt.success {
4173 return Err(ckpt
4174 .error
4175 .unwrap_or_else(|| "pre-rotation checkpoint did not commit".into()));
4176 }
4177 if let Some(error) = ckpt.continuation_error() {
4178 return Err(format!(
4179 "pre-rotation checkpoint committed but cannot continue safely: {error}"
4180 ));
4181 }
4182
4183 let authority = controller
4184 .checkpoint_authority()
4185 .map_err(|error| error.to_string())?;
4186 let verification_deadline = tokio::time::Instant::now()
4191 .checked_add(config.checkpoint_timeout)
4192 .ok_or_else(|| "pre-rotation checkpoint authority-read deadline overflowed".to_string())?;
4193 let outcome =
4194 tokio::time::timeout_at(verification_deadline, authority.cluster_outcome(ckpt.epoch))
4195 .await
4196 .map_err(|_| "pre-rotation checkpoint authority read timed out".to_string())?
4197 .map_err(|error| error.to_string())?
4198 .ok_or_else(|| {
4199 "pre-rotation checkpoint disappeared from cluster authority".to_string()
4200 })?;
4201 if !outcome.is_commit()
4202 || outcome.epoch != ckpt.epoch
4203 || outcome.checkpoint_id != ckpt.checkpoint_id
4204 || outcome.assignment_fence.as_ref() != Some(&transition.predecessor)
4205 {
4206 return Err("pre-rotation checkpoint authority does not bind the predecessor cut".into());
4207 }
4208 outcome
4209 .committed_checkpoint
4210 .ok_or_else(|| "pre-rotation Commit has no checkpoint reference".to_string())
4211}
4212
4213async fn await_drain_quorum(
4217 controller: &Arc<ClusterController>,
4218 transition: &AssignmentDrainTransition,
4219 deadline: tokio::time::Instant,
4220) -> bool {
4221 tokio::time::timeout_at(deadline, async {
4222 loop {
4223 match controller.drain_ack_quorum_reached(transition).await {
4224 Ok(true) => return,
4225 Ok(false) => {}
4226 Err(error) => {
4227 debug!(%error, version = transition.target.assignment_version, "durable drain quorum observation failed; retrying");
4228 }
4229 }
4230 tokio::time::sleep(Duration::from_millis(100)).await;
4231 }
4232 })
4233 .await
4234 .is_ok()
4235}
4236
4237fn assignment_binds_local_process(
4238 snapshot: &AssignmentSnapshot,
4239 controller: &ClusterController,
4240) -> Result<bool, String> {
4241 let fence = snapshot
4242 .assignment_fence()
4243 .map_err(|error| error.to_string())?;
4244 let owners = snapshot
4245 .to_vnode_vec(fence.vnode_count)
4246 .map_err(|error| error.to_string())?;
4247 let local = controller.instance_id().0;
4248 let owns = owners.iter().any(|owner| owner.0 == local);
4249 let bound = fence.participant_incarnation(local);
4250 Ok(if owns {
4251 bound == Some(controller.recovery_incarnation())
4252 } else {
4253 bound.is_none()
4254 })
4255}
4256
4257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4261enum DrainFinalizationMode {
4262 Live,
4263 StoppedRecovery,
4268}
4269
4270async fn finalize_drain_snapshot(
4271 db: &Arc<LaminarDB>,
4272 store: &Arc<AssignmentSnapshotStore>,
4273 controller: &ClusterController,
4274 draining: &AssignmentSnapshot,
4275 predecessor: &AssignmentSnapshot,
4276 requested_verdict: AssignmentDrainVerdict,
4277 handoff_checkpoint: Option<CommittedCheckpointRef>,
4278 config: RebalanceConfig,
4279 mode: DrainFinalizationMode,
4280) -> Result<Option<u64>, String> {
4281 if mode == DrainFinalizationMode::StoppedRecovery
4282 && (requested_verdict != AssignmentDrainVerdict::Abort || handoff_checkpoint.is_some())
4283 {
4284 return Err("stopped recovery may only finalize a source drain as Abort".into());
4285 }
4286 let finalization_deadline = tokio::time::Instant::now() + config.checkpoint_timeout;
4287 let finalization_guard = if mode == DrainFinalizationMode::Live {
4288 let guard =
4289 tokio::time::timeout_at(finalization_deadline, db.assignment_adoption_lock.lock())
4290 .await
4291 .map_err(|_| {
4292 "source-drain finalization timed out waiting for assignment serialization"
4293 .to_string()
4294 })?;
4295 if controller.is_recovering() {
4296 return Err(
4297 "live source-drain finalization yielded to coordinated recovery authority".into(),
4298 );
4299 }
4300 Some(guard)
4301 } else {
4302 None
4303 };
4304 let transition = draining
4305 .drain_transition
4306 .as_ref()
4307 .ok_or_else(|| "draining assignment has no exact transition".to_string())?;
4308 if predecessor
4309 .assignment_fence()
4310 .map_err(|error| error.to_string())?
4311 != transition.predecessor
4312 {
4313 return Err("drain finalization predecessor does not match the transition".into());
4314 }
4315 let deciding_proof = controller
4316 .capture_leader_proof()
4317 .ok_or_else(|| "drain finalization requires a current leader proof".to_string())?;
4318 let requested = match (requested_verdict, handoff_checkpoint) {
4319 (AssignmentDrainVerdict::Commit, Some(reference)) => {
4320 AssignmentDrainDecision::commit(transition, deciding_proof.clone(), reference)
4321 }
4322 (AssignmentDrainVerdict::Abort, None) => {
4323 AssignmentDrainDecision::abort(transition, deciding_proof.clone())
4324 }
4325 _ => Err("drain finalization has an invalid handoff checkpoint".into()),
4326 }?;
4327 let authority = controller
4328 .checkpoint_authority()
4329 .map_err(|error| error.to_string())?;
4330 let decision = match authority
4331 .record_assignment_drain_decision_with_assignments(&deciding_proof, requested, store)
4332 .await
4333 .map_err(|error| error.to_string())?
4334 {
4335 RecordAssignmentDrainDecisionResult::Created(decision)
4336 | RecordAssignmentDrainDecisionResult::Unchanged(decision) => decision,
4337 RecordAssignmentDrainDecisionResult::Conflict { winner } => {
4338 warn!(
4339 requested = ?requested_verdict,
4340 winner = ?winner.verdict,
4341 version = draining.version,
4342 "another authority-sequenced drain decision already won"
4343 );
4344 winner
4345 }
4346 };
4347 if decision.transition != *transition {
4348 return Err(format!(
4349 "authority decision for assignment {} binds a different drain transition",
4350 draining.version
4351 ));
4352 }
4353 if mode == DrainFinalizationMode::StoppedRecovery
4354 && decision.verdict != AssignmentDrainVerdict::Abort
4355 {
4356 return Err(format!(
4357 "stopped recovery cannot materialize the {:?} authority winner for assignment {}; the round must yield",
4358 decision.verdict, draining.version
4359 ));
4360 }
4361 let proposal = match decision.verdict {
4362 AssignmentDrainVerdict::Commit => draining
4363 .committed_target()
4364 .map_err(|error| error.to_string())?,
4365 AssignmentDrainVerdict::Abort => draining
4366 .aborted_target(predecessor)
4367 .map_err(|error| error.to_string())?,
4368 };
4369
4370 let durable = match store
4371 .finalize_drain(draining, &proposal)
4372 .await
4373 .map_err(|error| error.to_string())?
4374 {
4375 RotateOutcome::Rotated => proposal,
4376 RotateOutcome::Conflict(winner) => *winner,
4377 };
4378 let source_outcome = finalized_drain_outcome(transition, &durable)?;
4379 let authority_outcome = match decision.verdict {
4380 AssignmentDrainVerdict::Commit => laminar_connectors::connector::SourceDrainOutcome::Commit,
4381 AssignmentDrainVerdict::Abort => laminar_connectors::connector::SourceDrainOutcome::Abort,
4382 };
4383 if source_outcome != authority_outcome {
4384 return Err(format!(
4385 "materialized assignment {} conflicts with the authority drain decision",
4386 durable.version
4387 ));
4388 }
4389 drop(finalization_guard);
4393
4394 let version = durable.version;
4395 if mode == DrainFinalizationMode::Live && assignment_binds_local_process(&durable, controller)?
4396 {
4397 let adoption_deadline = tokio::time::Instant::now() + config.checkpoint_timeout;
4398 db.adopt_assignment_snapshot(durable, adoption_deadline)
4399 .await
4400 .map_err(|error| error.to_string())?;
4401 if local_drain_participant(controller, transition).is_some() {
4402 db.resolve_local_source_drain(
4403 transition.id(),
4404 source_outcome,
4405 tokio::time::Instant::now() + config.drain_ack_timeout,
4406 )
4407 .await
4408 .map_err(|error| error.to_string())?;
4409 }
4410 } else {
4411 warn!(
4412 version,
4413 "materialized drain terminal names a predecessor process; local adoption waits for a recovery successor"
4414 );
4415 }
4416 clear_settled_source_drain(controller, transition)?;
4417 let oldest_retained = version.saturating_sub(1);
4418 match store.prune_before(oldest_retained).await {
4419 Ok(()) => {
4420 if let Err(error) = authority
4421 .prune_assignment_drain_decisions_before(&deciding_proof, oldest_retained)
4422 .await
4423 {
4424 warn!(%error, "assignment drain authority prune failed after snapshot prune");
4425 }
4426 }
4427 Err(error) => {
4428 warn!(%error, "snapshot prune failed after drain finalization");
4429 }
4430 }
4431 Ok(Some(version))
4432}
4433
4434#[cfg(test)]
4437pub(crate) async fn settle_source_drain_before_recovery(
4438 db: &Arc<LaminarDB>,
4439 controller: &ClusterController,
4440 config: RebalanceConfig,
4441) -> Result<Option<u64>, String> {
4442 let published_transition = controller.checkpoint_drain_transition();
4443 let Some(store) = db.assignment_snapshot_store.lock().clone() else {
4444 return if published_transition.is_some() {
4445 Err("recovery source-drain settlement has no assignment store".into())
4446 } else {
4447 Ok(None)
4448 };
4449 };
4450 let Some(draining) = store.load().await.map_err(|error| error.to_string())? else {
4451 return Ok(None);
4452 };
4453 if !draining.draining {
4454 return if published_transition.is_some() {
4455 Err("local source-drain authority outlived its durable draining assignment".into())
4456 } else {
4457 Ok(None)
4458 };
4459 }
4460 let transition = draining
4461 .drain_transition
4462 .as_ref()
4463 .ok_or_else(|| "draining assignment has no exact source transition".to_string())?;
4464 if published_transition.as_ref() != Some(transition) {
4465 return Err("durable and locally published source-drain transitions differ".into());
4466 }
4467 let predecessor = store
4468 .load_version(transition.predecessor.assignment_version)
4469 .await
4470 .map_err(|error| error.to_string())?
4471 .ok_or_else(|| {
4472 format!(
4473 "source-drain predecessor assignment {} is unavailable during recovery",
4474 transition.predecessor.assignment_version
4475 )
4476 })?;
4477 finalize_drain_snapshot(
4478 db,
4479 &store,
4480 controller,
4481 &draining,
4482 &predecessor,
4483 AssignmentDrainVerdict::Abort,
4484 None,
4485 config,
4486 DrainFinalizationMode::Live,
4487 )
4488 .await
4489}
4490
4491pub(crate) async fn settle_stopped_source_drain_after_recovery_quorum(
4497 db: &Arc<LaminarDB>,
4498 controller: &ClusterController,
4499 expected_predecessor: &CheckpointAssignmentFence,
4500 config: RebalanceConfig,
4501) -> Result<Option<u64>, String> {
4502 if DbState::load(&db.state) != DbState::Created
4503 || !db.runtime_shutdown.read().is_cancelled()
4504 || !db.cluster_intake_fenced()
4505 || !db.coordinated_recovery_in_progress()
4506 || !controller.is_recovering()
4507 || !controller.process_lease_is_live()
4508 {
4509 return Err(
4510 "stopped source-drain settlement requires the exact fenced Created recovery generation"
4511 .into(),
4512 );
4513 }
4514 if !expected_predecessor.is_canonical() {
4515 return Err("stopped source-drain predecessor certificate is not canonical".into());
4516 }
4517 let store = db
4518 .assignment_snapshot_store
4519 .lock()
4520 .clone()
4521 .ok_or_else(|| "stopped source-drain settlement has no assignment store".to_string())?;
4522 let deadline = tokio::time::Instant::now() + config.checkpoint_timeout;
4523 let Some(draining) = tokio::time::timeout_at(deadline, store.load())
4524 .await
4525 .map_err(|_| "stopped source-drain head read timed out".to_string())?
4526 .map_err(|error| error.to_string())?
4527 else {
4528 return Err("stopped source-drain settlement has no durable assignment head".into());
4529 };
4530 let target_version = expected_predecessor
4531 .assignment_version
4532 .checked_add(1)
4533 .ok_or_else(|| "stopped source-drain assignment version overflow".to_string())?;
4534 if !draining.draining {
4535 if draining.version == expected_predecessor.assignment_version {
4536 if draining
4537 .assignment_fence()
4538 .map_err(|error| error.to_string())?
4539 != *expected_predecessor
4540 {
4541 return Err(
4542 "stable source-drain head does not match the stopped recovery predecessor"
4543 .into(),
4544 );
4545 }
4546 tokio::time::timeout_at(
4547 deadline,
4548 audit_assignment_snapshot_authority_outcome(&store, Some(controller), &draining),
4549 )
4550 .await
4551 .map_err(|_| {
4552 "stopped source-drain predecessor authority audit timed out".to_string()
4553 })??;
4554 let confirmed = tokio::time::timeout_at(deadline, store.load())
4555 .await
4556 .map_err(|_| "stopped source-drain predecessor recheck timed out".to_string())?
4557 .map_err(|error| error.to_string())?;
4558 if confirmed.as_ref() != Some(&draining) {
4559 return Err("stopped source-drain predecessor changed during audit".into());
4560 }
4561 return Ok(None);
4562 }
4563 if draining.version != target_version {
4564 return Err(format!(
4565 "stable assignment {} superseded stopped source-drain predecessor {}",
4566 draining.version, expected_predecessor.assignment_version
4567 ));
4568 }
4569 let audited = tokio::time::timeout_at(
4570 deadline,
4571 audit_assignment_snapshot_authority_outcome(&store, Some(controller), &draining),
4572 )
4573 .await
4574 .map_err(|_| "stopped source-drain terminal authority audit timed out".to_string())??;
4575 let terminal = audited.into_terminal().ok_or_else(|| {
4576 format!(
4577 "stable assignment {} is not an authority-sequenced drain terminal",
4578 draining.version
4579 )
4580 })?;
4581 if terminal.transition.predecessor != *expected_predecessor
4582 || terminal.transition.target.assignment_version != target_version
4583 {
4584 return Err(format!(
4585 "stable assignment {} is not an exact terminal for stopped predecessor {}",
4586 draining.version, expected_predecessor.assignment_version
4587 ));
4588 }
4589 let target_fence = draining
4590 .assignment_fence()
4591 .map_err(|error| error.to_string())?;
4592 let predecessor = tokio::time::timeout_at(
4593 deadline,
4594 store.load_version(expected_predecessor.assignment_version),
4595 )
4596 .await
4597 .map_err(|_| "stopped source-drain predecessor read timed out".to_string())?
4598 .map_err(|error| error.to_string())?
4599 .ok_or_else(|| {
4600 format!(
4601 "source-drain predecessor assignment {} is unavailable after stop quorum",
4602 expected_predecessor.assignment_version
4603 )
4604 })?;
4605 if predecessor.draining
4606 || predecessor
4607 .assignment_fence()
4608 .map_err(|error| error.to_string())?
4609 != *expected_predecessor
4610 {
4611 return Err("stable source-drain terminal lost its frozen predecessor".into());
4612 }
4613 let commit_pin = if terminal.is_abort() {
4614 if draining.vnodes != predecessor.vnodes
4615 || draining.participants != predecessor.participants
4616 || target_fence.assignment_version != target_version
4617 || target_fence.partitioning_abi_version
4618 != expected_predecessor.partitioning_abi_version
4619 || target_fence.vnode_count != expected_predecessor.vnode_count
4620 || target_fence.assignment_digest != expected_predecessor.assignment_digest
4621 || target_fence.participants != expected_predecessor.participants
4622 {
4623 return Err(
4624 "stable source-drain Abort does not retain the exact stopped predecessor topology"
4625 .into(),
4626 );
4627 }
4628 None
4629 } else if terminal.is_commit() {
4630 if target_fence != terminal.transition.target {
4631 return Err(
4632 "stable source-drain Commit does not materialize its exact target fence".into(),
4633 );
4634 }
4635 let handoff = terminal
4636 .committed_handoff_checkpoint()
4637 .cloned()
4638 .ok_or_else(|| {
4639 "stable source-drain Commit has no exact handoff checkpoint".to_string()
4640 })?;
4641 let authority = controller
4642 .checkpoint_authority()
4643 .map_err(|error| error.to_string())?;
4644 let pinned = tokio::time::timeout_at(
4645 deadline,
4646 authority.assignment_handoff_checkpoint(&target_fence),
4647 )
4648 .await
4649 .map_err(|_| "stopped source-drain Commit handoff-pin read timed out".to_string())?
4650 .map_err(|error| error.to_string())?;
4651 if pinned.as_ref() != Some(&handoff) {
4652 return Err(
4653 "stable source-drain Commit no longer retains its exact active handoff checkpoint pin"
4654 .into(),
4655 );
4656 }
4657 Some((authority, handoff))
4658 } else {
4659 return Err("stable source-drain terminal has no supported verdict".into());
4660 };
4661 let confirmed = tokio::time::timeout_at(deadline, store.load())
4662 .await
4663 .map_err(|_| "stopped source-drain terminal recheck timed out".to_string())?
4664 .map_err(|error| error.to_string())?;
4665 if confirmed.as_ref() != Some(&draining) {
4666 return Err("stopped source-drain terminal changed during reconciliation".into());
4667 }
4668 let confirmed_authority = tokio::time::timeout_at(
4669 deadline,
4670 audit_assignment_snapshot_authority_outcome(&store, Some(controller), &draining),
4671 )
4672 .await
4673 .map_err(|_| "stopped source-drain terminal authority recheck timed out".to_string())??;
4674 if confirmed_authority.into_terminal().as_ref() != Some(&terminal) {
4675 return Err(
4676 "stopped source-drain terminal authority changed during reconciliation".into(),
4677 );
4678 }
4679 if let Some((authority, handoff)) = commit_pin {
4680 let confirmed_pin = tokio::time::timeout_at(
4681 deadline,
4682 authority.assignment_handoff_checkpoint(&target_fence),
4683 )
4684 .await
4685 .map_err(|_| "stopped source-drain Commit handoff-pin recheck timed out".to_string())?
4686 .map_err(|error| error.to_string())?;
4687 if confirmed_pin.as_ref() != Some(&handoff) {
4688 return Err(
4689 "stable source-drain Commit handoff pin changed during reconciliation".into(),
4690 );
4691 }
4692 }
4693 clear_settled_source_drain(controller, &terminal.transition)?;
4694 return Ok(Some(target_version));
4695 }
4696 let transition = draining
4697 .drain_transition
4698 .as_ref()
4699 .ok_or_else(|| "draining assignment has no exact source transition".to_string())?;
4700 if transition.predecessor != *expected_predecessor
4701 || transition.target.assignment_version != target_version
4702 || draining.version != transition.target.assignment_version
4703 {
4704 return Err(format!(
4705 "draining assignment {} does not bind the stopped recovery predecessor {}",
4706 draining.version, expected_predecessor.assignment_version
4707 ));
4708 }
4709 if controller
4710 .checkpoint_drain_transition()
4711 .as_ref()
4712 .is_some_and(|published| published != transition)
4713 {
4714 return Err("durable and locally published source-drain transitions differ".into());
4715 }
4716 let predecessor = tokio::time::timeout_at(
4717 deadline,
4718 store.load_version(expected_predecessor.assignment_version),
4719 )
4720 .await
4721 .map_err(|_| "stopped source-drain predecessor read timed out".to_string())?
4722 .map_err(|error| error.to_string())?
4723 .ok_or_else(|| {
4724 format!(
4725 "source-drain predecessor assignment {} is unavailable after stop quorum",
4726 expected_predecessor.assignment_version
4727 )
4728 })?;
4729 if predecessor.draining
4730 || predecessor
4731 .assignment_fence()
4732 .map_err(|error| error.to_string())?
4733 != *expected_predecessor
4734 {
4735 return Err("stopped source-drain predecessor no longer matches the frozen round".into());
4736 }
4737 let confirmed = tokio::time::timeout_at(deadline, store.load())
4738 .await
4739 .map_err(|_| "stopped source-drain head recheck timed out".to_string())?
4740 .map_err(|error| error.to_string())?;
4741 if confirmed.as_ref() != Some(&draining) {
4742 return Err("stopped source-drain head changed during settlement".into());
4743 }
4744 finalize_drain_snapshot(
4745 db,
4746 &store,
4747 controller,
4748 &draining,
4749 &predecessor,
4750 AssignmentDrainVerdict::Abort,
4751 None,
4752 config,
4753 DrainFinalizationMode::StoppedRecovery,
4754 )
4755 .await
4756}
4757
4758async fn adopt_any(
4760 db: &Arc<LaminarDB>,
4761 store: &AssignmentSnapshotStore,
4762 controller: &ClusterController,
4763 registry: &VnodeRegistry,
4764 snap: AssignmentSnapshot,
4765 operation_timeout: Duration,
4766) -> Result<(), String> {
4767 let deadline = tokio::time::Instant::now() + operation_timeout;
4768 let audited = tokio::time::timeout_at(
4769 deadline,
4770 audit_assignment_snapshot_authority_outcome(store, Some(controller), &snap),
4771 )
4772 .await
4773 .map_err(|_| format!("assignment {} authority audit timed out", snap.version))??;
4774 if snap.draining {
4775 db.validate_source_drain_snapshot(&snap)
4776 .map_err(|error| error.to_string())?;
4777 } else if audited.is_recovery() {
4778 prepare_recovery_assignment_adoption(db, store, controller, registry, &snap, deadline)
4779 .await?;
4780 db.adopt_recovery_assignment_snapshot(snap, operation_timeout)
4781 .await
4782 .map_err(|error| error.to_string())?;
4783 } else {
4784 db.adopt_assignment_snapshot(snap, deadline)
4785 .await
4786 .map_err(|e| e.to_string())?;
4787 }
4788 Ok(())
4789}
4790
4791#[cfg(test)]
4792mod tests;