1#![cfg(feature = "cluster")]
4#![allow(clippy::disallowed_types)] use std::sync::Arc;
7use std::time::Duration;
8
9use laminar_connectors::connector::{SourceDrainOutcome, SourceDrainResolution};
10use laminar_core::checkpoint::{AssignmentDrainId, AssignmentDrainTransition};
11use laminar_core::cluster::control::{
12 AssignmentDrainDecision, AssignmentDrainVerdict, AssignmentRecoveryDecision,
13 AssignmentSnapshot, AssignmentSnapshotStore, CheckpointAssignmentAdoption,
14 CheckpointAssignmentFence, CheckpointParticipant, ClusterController, LeaderLeaseStore,
15 RecordAssignmentDrainDecisionResult, RecordAssignmentRecoveryDecisionResult, RotateOutcome,
16};
17use laminar_core::cluster::discovery::NodeState;
18use laminar_core::state::{
19 owners_per_domain, rendezvous_assignment, Locality, NodeId, VnodeRegistry,
20};
21#[cfg(test)]
22use tokio::sync::Notify;
23use tokio::task::JoinHandle;
24use tokio::time::MissedTickBehavior;
25use tokio_util::sync::CancellationToken;
26use tracing::{debug, info, warn};
27
28use crate::db::{LaminarDB, SnapshotAdoption};
29use crate::engine_metrics::EngineMetrics;
30
31#[derive(Debug, Clone, Copy)]
33pub struct RebalanceConfig {
34 pub watcher_poll: Duration,
36 pub rebalance_debounce: Duration,
38 pub checkpoint_timeout: Duration,
40 pub retry_delay: Duration,
42 pub drain_ack_timeout: Duration,
45 pub placement_isolation_tier: usize,
47}
48
49impl Default for RebalanceConfig {
50 fn default() -> Self {
51 Self {
52 watcher_poll: Duration::from_secs(2),
53 rebalance_debounce: Duration::from_secs(5),
54 checkpoint_timeout: Duration::from_secs(15),
57 retry_delay: Duration::from_secs(2),
58 drain_ack_timeout: Duration::from_secs(10),
59 placement_isolation_tier: 0,
60 }
61 }
62}
63
64impl RebalanceConfig {
65 #[doc(hidden)]
67 #[must_use]
68 pub fn test_defaults() -> Self {
69 Self {
70 watcher_poll: Duration::from_millis(200),
71 rebalance_debounce: Duration::from_millis(500),
72 checkpoint_timeout: Duration::from_secs(30),
73 retry_delay: Duration::from_millis(500),
74 drain_ack_timeout: Duration::from_secs(5),
75 placement_isolation_tier: 0,
76 }
77 }
78}
79
80fn log_adoption(source: &str, adoption: &SnapshotAdoption) {
82 if adoption.newly_acquired.is_empty() {
83 return;
84 }
85 info!(
86 source,
87 version = adoption.version,
88 newly_acquired = adoption.newly_acquired.len(),
89 rehydrated = adoption.rehydrated,
90 rehydration_epoch = ?adoption.rehydration_epoch,
91 "rehydrated newly-acquired vnodes after rebalance",
92 );
93}
94
95async fn close_local_assignment_authority(
96 db: &Arc<LaminarDB>,
97 controller: Option<&ClusterController>,
98 deadline: tokio::time::Instant,
99) -> Result<(), String> {
100 db.set_source_gate(true);
101 if let Some(controller) = controller {
102 controller.publish_checkpoint_assignment_fence(None);
103 controller.publish_checkpoint_drain_transition(None);
104 }
105 db.invalidate_shuffle_assignment_fence();
107 let _adoption = tokio::time::timeout_at(deadline, db.assignment_adoption_lock.lock())
108 .await
109 .map_err(|_| "timed out serializing assignment authority closure".to_string())?;
110 db.set_source_gate(true);
113 if let Some(controller) = controller {
114 controller.publish_checkpoint_assignment_fence(None);
115 controller.publish_checkpoint_drain_transition(None);
116 }
117 db.invalidate_shuffle_assignment_fence();
118 let _transition = tokio::time::timeout_at(
119 deadline,
120 Arc::clone(&db.rotation_execution_fence).write_owned(),
121 )
122 .await
123 .map_err(|_| "timed out draining assignment execution after closure".to_string())?;
124 Ok(())
125}
126
127async fn ensure_local_recovery_fault(
128 db: &LaminarDB,
129 controller: &ClusterController,
130) -> Result<(), String> {
131 controller.set_recovering(true);
132 crate::coordinated_recovery::request_local_fault(controller, &db.pending_recovery_fault)
133 .await
134 .map(|_| ())
135}
136
137async fn suspend_local_assignment_authority(
140 db: &Arc<LaminarDB>,
141 controller: Option<&ClusterController>,
142 deadline: tokio::time::Instant,
143) -> Result<(), String> {
144 db.set_source_gate(true);
145 if let Some(controller) = controller {
146 controller.publish_checkpoint_assignment_fence(None);
147 controller.publish_checkpoint_drain_transition(None);
148 }
149 db.suspend_shuffle_assignment_fence();
152 let _adoption = tokio::time::timeout_at(deadline, db.assignment_adoption_lock.lock())
153 .await
154 .map_err(|_| "timed out serializing assignment authority suspension".to_string())?;
155 db.set_source_gate(true);
158 if let Some(controller) = controller {
159 controller.publish_checkpoint_assignment_fence(None);
160 controller.publish_checkpoint_drain_transition(None);
161 }
162 db.suspend_shuffle_assignment_fence();
163 let _transition = tokio::time::timeout_at(
164 deadline,
165 Arc::clone(&db.rotation_execution_fence).write_owned(),
166 )
167 .await
168 .map_err(|_| "timed out draining assignment execution after suspension".to_string())?;
169 Ok(())
170}
171
172async fn hold_terminal_source_resolution(
173 db: &Arc<LaminarDB>,
174 controller: Option<&ClusterController>,
175 round: AssignmentDrainId,
176 deadline: tokio::time::Instant,
177 held: &mut Option<(AssignmentDrainId, u64)>,
178) -> Result<u64, String> {
179 let revision = db
180 .assignment_authority_revision
181 .load(std::sync::atomic::Ordering::Acquire);
182 let controller_closed = controller.is_none_or(|controller| {
183 controller
184 .checkpoint_assignment_fence(round.predecessor_version)
185 .is_none()
186 && controller
187 .checkpoint_assignment_fence(round.target_version)
188 .is_none()
189 });
190 if *held == Some((round, revision)) && db.cluster_intake_fenced() && controller_closed {
191 return Ok(revision);
192 }
193
194 suspend_local_assignment_authority(db, controller, deadline).await?;
195 let revision = db
196 .assignment_authority_revision
197 .load(std::sync::atomic::Ordering::Acquire);
198 *held = Some((round, revision));
199 Ok(revision)
200}
201
202struct SnapshotWatcher {
208 db: Arc<LaminarDB>,
209 store: Arc<AssignmentSnapshotStore>,
210 registry: Arc<VnodeRegistry>,
211 shutdown: CancellationToken,
212 config: RebalanceConfig,
213 controller: Option<Arc<ClusterController>>,
214 ticker: tokio::time::Interval,
215 published_adoption: Option<CheckpointAssignmentAdoption>,
216 metrics_version: u64,
217 last_drained: u64,
218 durable_snapshot: Option<AssignmentSnapshot>,
219 durable_drain_transition: Option<AssignmentDrainTransition>,
220 active_local_drain: Option<AssignmentDrainTransition>,
221 terminal_resolution_hold: Option<(AssignmentDrainId, u64)>,
222 installed_fence: Option<(u64, [u8; 32])>,
223 installed_authority_revision: u64,
224 assignment_authority_dirty: bool,
225}
226
227impl SnapshotWatcher {
228 fn new(
229 db: Arc<LaminarDB>,
230 store: Arc<AssignmentSnapshotStore>,
231 registry: Arc<VnodeRegistry>,
232 shutdown: CancellationToken,
233 config: RebalanceConfig,
234 controller: Option<Arc<ClusterController>>,
235 ) -> Self {
236 let mut ticker = tokio::time::interval(config.watcher_poll);
237 ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
238 Self {
239 db,
240 store,
241 registry,
242 shutdown,
243 config,
244 controller,
245 ticker,
246 published_adoption: None,
247 metrics_version: 0,
248 last_drained: 0,
249 durable_snapshot: None,
250 durable_drain_transition: None,
251 active_local_drain: None,
252 terminal_resolution_hold: None,
253 installed_fence: None,
254 installed_authority_revision: 0,
255 assignment_authority_dirty: false,
256 }
257 }
258
259 async fn publish_authority(
260 &mut self,
261 mut authority_revision: u64,
262 head_deadline: tokio::time::Instant,
263 ) {
264 let current_authority_revision = self
265 .db
266 .assignment_authority_revision
267 .load(std::sync::atomic::Ordering::Acquire);
268 if authority_revision != current_authority_revision {
269 self.db.set_source_gate(true);
272 if let Some(ref c) = self.controller {
273 c.publish_checkpoint_drain_transition(None);
274 c.publish_checkpoint_assignment_fence(None);
275 }
276 self.assignment_authority_dirty = true;
277 return;
278 }
279 if self.installed_fence.is_some()
280 && self.installed_authority_revision != current_authority_revision
281 {
282 self.assignment_authority_dirty = true;
283 }
284
285 let assignment = self.registry.versioned_snapshot();
286 let version = assignment.version();
287 if let Some(ref c) = self.controller {
288 let participant = CheckpointParticipant {
289 node_id: c.instance_id().0,
290 boot_incarnation: c.recovery_incarnation(),
291 };
292 let local_is_participant = self.durable_snapshot.as_ref().is_some_and(|snapshot| {
293 snapshot.version == version
294 && snapshot
295 .participants
296 .binary_search_by_key(&participant.node_id, |entry| entry.node_id)
297 .ok()
298 .and_then(|index| snapshot.participants.get(index))
299 == Some(&participant)
300 });
301 if local_is_participant {
302 let owner_ids: Vec<u64> = assignment.owners().iter().map(|owner| owner.0).collect();
303 let adoption = CheckpointAssignmentAdoption {
304 participant,
305 assignment_version: version,
306 vnode_count: self.registry.vnode_count(),
307 partitioning_abi_version: laminar_core::state::PARTITIONING_ABI_VERSION,
308 assignment_digest: CheckpointAssignmentFence::owner_map_digest(
309 self.registry.vnode_count(),
310 &owner_ids,
311 ),
312 };
313 if self.published_adoption.as_ref() != Some(&adoption) {
314 match tokio::time::timeout_at(
315 head_deadline,
316 c.announce_adopted_assignment(&adoption),
317 )
318 .await
319 {
320 Ok(Ok(())) => self.published_adoption = Some(adoption),
321 Ok(Err(error)) => {
322 warn!(%error, version, "adopted assignment publication failed; retrying");
323 }
324 Err(_) => {
325 warn!(
326 version,
327 "adopted assignment publication exceeded the head deadline"
328 );
329 }
330 }
331 }
332 } else {
333 self.published_adoption = None;
334 }
335 }
336 if version != self.metrics_version {
337 self.metrics_version = version;
338 if let (Some(c), Some(metrics)) = (self.controller.as_ref(), self.db.engine_metrics()) {
339 let nodes = c.assignable_with_locality();
340 publish_placement_metrics(
341 &metrics,
342 &self.registry,
343 &nodes,
344 self.config.placement_isolation_tier,
345 );
346 }
347 }
348
349 if let Some(ref c) = self.controller {
353 let fence = match self.durable_snapshot.as_ref() {
354 Some(snapshot)
355 if !snapshot.draining
356 && snapshot.version == version
357 && snapshot.has_canonical_participants()
358 && snapshot
359 .to_vnode_vec(self.registry.vnode_count())
360 .is_ok_and(|owners| owners.as_slice() == assignment.owners()) =>
361 {
362 if let Ok(fence) = tokio::time::timeout_at(
363 head_deadline,
364 compute_checkpoint_assignment_fence(
365 c,
366 &self.registry,
367 &snapshot.participants,
368 ),
369 )
370 .await
371 {
372 fence.filter(|fence| fence.participants == snapshot.participants)
373 } else {
374 warn!(
375 version,
376 "assignment fence computation exceeded the head deadline"
377 );
378 None
379 }
380 }
381 _ => None,
382 };
383 if authority_revision
384 != self
385 .db
386 .assignment_authority_revision
387 .load(std::sync::atomic::Ordering::Acquire)
388 {
389 self.db.set_source_gate(true);
390 c.publish_checkpoint_drain_transition(None);
391 c.publish_checkpoint_assignment_fence(None);
392 self.assignment_authority_dirty = true;
393 return;
394 }
395 let drain_transition = self
396 .durable_drain_transition
397 .as_ref()
398 .filter(|transition| fence.as_ref() == Some(&transition.predecessor))
399 .cloned();
400 match fence {
401 Some(fence) => {
402 let identity = (fence.assignment_version, fence.digest());
403 let local_has_authority = fence.participant_incarnation(c.instance_id().0)
404 == Some(c.recovery_incarnation());
405 let published_fence = c.checkpoint_assignment_fence(fence.assignment_version);
406 let needs_activation = self.assignment_authority_dirty
407 || self.installed_fence != Some(identity)
408 || published_fence.as_ref() != Some(&fence)
409 || c.checkpoint_drain_transition() != drain_transition
410 || (local_has_authority
411 && !c.is_recovering()
412 && drain_transition.is_none()
413 && self.db.cluster_intake_fenced());
414 if needs_activation {
415 if self.installed_fence.is_some() && self.installed_fence != Some(identity)
416 {
417 self.db.set_source_gate(true);
422 c.publish_checkpoint_drain_transition(None);
423 c.publish_checkpoint_assignment_fence(None);
424 self.db.suspend_shuffle_assignment_fence();
425 authority_revision = self
426 .db
427 .assignment_authority_revision
428 .load(std::sync::atomic::Ordering::Acquire);
429 self.installed_fence = None;
430 }
431 match self
432 .db
433 .activate_assignment_authority(
434 &fence,
435 drain_transition,
436 authority_revision,
437 head_deadline,
438 )
439 .await
440 {
441 Ok(activation) if activation.installed => {
442 self.installed_fence = Some(identity);
443 self.installed_authority_revision = activation.revision;
444 self.assignment_authority_dirty = false;
445 }
446 Ok(_) => self.assignment_authority_dirty = true,
447 Err(error) => {
448 c.publish_checkpoint_drain_transition(None);
449 c.publish_checkpoint_assignment_fence(None);
450 self.installed_fence = None;
451 self.assignment_authority_dirty = true;
452 warn!(%error, version, "shuffle assignment certificate install failed");
453 }
454 }
455 }
456 }
457 None if !self.assignment_authority_dirty => {
458 self.assignment_authority_dirty = true;
463 match suspend_local_assignment_authority(
464 &self.db,
465 Some(c.as_ref()),
466 head_deadline,
467 )
468 .await
469 {
470 Ok(()) => warn!(
471 version,
472 "owner-complete assignment certificate unavailable; authority suspended"
473 ),
474 Err(error) => warn!(
475 %error,
476 version,
477 "assignment suspension could not drain the prior execution scope"
478 ),
479 }
480 }
481 None => {}
482 }
483 }
484 }
485 async fn settle_terminal_drain(
486 &mut self,
487 snapshot: &AssignmentSnapshot,
488 audited_terminal: Option<&AuditedDrainOutcome>,
489 head_deadline: tokio::time::Instant,
490 authority_revision: &mut u64,
491 ) -> bool {
492 let transition = self.active_local_drain.clone().or_else(|| {
493 audited_terminal.and_then(|audited| {
494 self.controller
495 .as_deref()
496 .and_then(|controller| local_drain_participant(controller, &audited.transition))
497 .map(|_| audited.transition.clone())
498 })
499 });
500 let Some(transition) = transition else {
501 self.terminal_resolution_hold = None;
502 return true;
503 };
504
505 let audited = audited_terminal.filter(|audited| audited.transition == transition);
506 let already_resolved = match audited {
507 Some(audited) => {
508 let resolution = SourceDrainResolution {
509 round: transition.id(),
510 outcome: audited.outcome,
511 };
512 match crate::pipeline::streaming_coordinator::owned_source_drain_resolved(
513 &self.db.owned_source_tasks,
514 resolution,
515 ) {
516 Ok(resolved) => resolved,
517 Err(error) => {
518 self.assignment_authority_dirty = true;
519 let _ = hold_terminal_source_resolution(
520 &self.db,
521 self.controller.as_deref(),
522 transition.id(),
523 head_deadline,
524 &mut self.terminal_resolution_hold,
525 )
526 .await;
527 warn!(%error, version = snapshot.version, "snapshot watcher: source drain status audit failed; assignment authority suspended");
528 return false;
529 }
530 }
531 }
532 None => false,
533 };
534 if already_resolved {
535 self.active_local_drain = None;
536 self.terminal_resolution_hold = None;
537 return true;
538 }
539
540 self.assignment_authority_dirty = true;
541 match hold_terminal_source_resolution(
542 &self.db,
543 self.controller.as_deref(),
544 transition.id(),
545 head_deadline,
546 &mut self.terminal_resolution_hold,
547 )
548 .await
549 {
550 Ok(revision) => *authority_revision = revision,
551 Err(error) => {
552 warn!(%error, version = snapshot.version, "snapshot watcher: could not suspend authority for source drain resolution");
553 return false;
554 }
555 }
556 match settle_observed_local_drain(
557 &self.db,
558 &self.store,
559 &self.registry,
560 self.controller.as_deref(),
561 &transition,
562 snapshot,
563 audited,
564 head_deadline,
565 SourceDrainResolutionDeadline::Fresh(self.config.drain_ack_timeout),
566 )
567 .await
568 {
569 Ok(true) => {
570 self.active_local_drain = None;
571 self.terminal_resolution_hold = None;
572 *authority_revision = self
573 .db
574 .assignment_authority_revision
575 .load(std::sync::atomic::Ordering::Acquire);
576 true
577 }
578 Ok(false) => false,
579 Err(error) => {
580 warn!(%error, version = snapshot.version, "snapshot watcher: local source drain resolution failed; assignment authority suspended");
581 false
582 }
583 }
584 }
585
586 async fn run(mut self) {
587 loop {
588 tokio::select! {
589 biased;
590 () = self.shutdown.cancelled() => return,
591 _ = self.ticker.tick() => {}
592 }
593 let local = self.registry.assignment_version();
594 let head_deadline = tokio::time::Instant::now() + self.config.checkpoint_timeout;
595 let mut authority_revision = self
596 .db
597 .assignment_authority_revision
598 .load(std::sync::atomic::Ordering::Acquire);
599
600 let audit = tokio::select! {
604 biased;
605 () = self.shutdown.cancelled() => return,
606 result = tokio::time::timeout_at(head_deadline, self.store.load()) => result,
607 };
608 let mut audited_terminal = None;
609 let mut audited_recovery = false;
610 if let Ok(Ok(Some(snapshot))) = &audit {
611 if !snapshot.draining {
612 let materialization_audit = tokio::time::timeout_at(
613 head_deadline,
614 audit_assignment_snapshot_authority_outcome(
615 &self.store,
616 self.controller.as_deref(),
617 snapshot,
618 ),
619 )
620 .await;
621 match materialization_audit {
622 Ok(Ok(outcome)) => {
623 audited_recovery = snapshot.version > 1 && outcome.is_none();
624 audited_terminal = outcome;
625 }
626 failed => {
627 self.assignment_authority_dirty = true;
628 let _ = suspend_local_assignment_authority(
629 &self.db,
630 self.controller.as_deref(),
631 head_deadline,
632 )
633 .await;
634 match failed {
635 Ok(Err(error)) => {
636 warn!(%error, version = snapshot.version, "snapshot watcher: drain finalization audit failed; assignment authority suspended");
637 }
638 Err(_) => {
639 warn!(version = snapshot.version, timeout = ?self.config.checkpoint_timeout, "snapshot watcher: drain finalization audit timed out; assignment authority suspended");
640 }
641 Ok(Ok(_)) => unreachable!(),
642 }
643 continue;
644 }
645 }
646 }
647 }
648 if let Ok(Ok(Some(snapshot))) = &audit {
649 if !snapshot.draining
650 && !self
651 .settle_terminal_drain(
652 snapshot,
653 audited_terminal.as_ref(),
654 head_deadline,
655 &mut authority_revision,
656 )
657 .await
658 {
659 continue;
660 }
661 }
662 match audit {
663 Ok(Ok(Some(snap))) if snap.draining && snap.version > local => {
666 let predecessor = tokio::time::timeout_at(
673 head_deadline,
674 audit_drain_predecessor(
675 &self.store,
676 &self.registry,
677 &snap,
678 self.controller.as_deref(),
679 ),
680 )
681 .await;
682 match predecessor {
683 Ok(Ok(predecessor)) => {
684 let transition = snap
685 .drain_transition
686 .as_ref()
687 .expect("validated draining snapshot has a transition")
688 .clone();
689 self.durable_drain_transition = Some(transition.clone());
690 self.durable_snapshot = Some(predecessor);
691 if let Some(ref c) = self.controller {
692 c.publish_checkpoint_drain_transition(Some(transition.clone()));
693 }
694 if snap.version != self.last_drained {
695 match self.db.validate_source_drain_snapshot(&snap) {
696 Ok(()) => {
697 let acknowledgement = async {
698 let c = self.controller.as_ref().ok_or_else(|| {
699 "assignment drain has no cluster controller"
700 .to_string()
701 })?;
702 let Some(participant) =
703 local_drain_participant(c, &transition)
704 else {
705 return Ok(());
709 };
710 self.active_local_drain = Some(transition.clone());
711 prepare_and_announce_local_drain(
712 &self.db,
713 &self.store,
714 &self.registry,
715 c,
716 &snap,
717 &transition,
718 participant,
719 std::cmp::min(
720 head_deadline,
721 tokio::time::Instant::now()
722 + self.config.drain_ack_timeout,
723 ),
724 )
725 .await
726 }
727 .await;
728 match acknowledgement {
729 Ok(()) => {
730 self.last_drained = snap.version;
731 continue;
735 }
736 Err(error) => {
737 warn!(%error, version = snap.version, "snapshot watcher: durable drain acknowledgement failed; retrying");
738 continue;
739 }
740 }
741 }
742 Err(error) => {
743 warn!(%error, "snapshot watcher: draining adoption failed");
744 }
745 }
746 }
747 }
748 Ok(Err(error)) => {
749 self.durable_snapshot = None;
750 self.durable_drain_transition = None;
751 self.assignment_authority_dirty = true;
752 let _ = suspend_local_assignment_authority(
753 &self.db,
754 self.controller.as_deref(),
755 head_deadline,
756 )
757 .await;
758 warn!(%error, version = snap.version, "snapshot watcher: draining predecessor audit failed; assignment authority suspended");
759 }
760 Err(_) => {
761 self.durable_snapshot = None;
762 self.durable_drain_transition = None;
763 self.assignment_authority_dirty = true;
764 let _ = suspend_local_assignment_authority(
765 &self.db,
766 self.controller.as_deref(),
767 head_deadline,
768 )
769 .await;
770 warn!(version = snap.version, timeout = ?self.config.checkpoint_timeout, "snapshot watcher: draining predecessor audit timed out; assignment authority suspended");
771 }
772 }
773 }
774 Ok(Ok(Some(snap))) if !snap.draining && snap.version > local => {
775 if audited_recovery {
776 let Some(controller) = self.controller.as_deref() else {
777 self.assignment_authority_dirty = true;
778 let _ =
779 suspend_local_assignment_authority(&self.db, None, head_deadline)
780 .await;
781 warn!(
782 version = snap.version,
783 "snapshot watcher: recovery assignment has no cluster controller"
784 );
785 continue;
786 };
787 if let Err(error) = suspend_local_assignment_authority(
788 &self.db,
789 Some(controller),
790 head_deadline,
791 )
792 .await
793 {
794 self.assignment_authority_dirty = true;
795 warn!(%error, version = snap.version, "snapshot watcher: could not suspend recovery assignment");
796 continue;
797 }
798 if let Err(error) = ensure_local_recovery_fault(&self.db, controller).await
799 {
800 self.assignment_authority_dirty = true;
801 warn!(%error, version = snap.version, "snapshot watcher: could not publish recovery fault");
802 continue;
803 }
804 authority_revision = self
805 .db
806 .assignment_authority_revision
807 .load(std::sync::atomic::Ordering::Acquire);
808 self.assignment_authority_dirty = true;
809 }
810 self.durable_drain_transition = None;
811 self.durable_snapshot = Some(snap.clone());
812 let resolved_local = self.registry.assignment_version();
813 if snap.version > resolved_local {
814 debug!(
815 local = resolved_local,
816 remote = snap.version,
817 "adopting newer assignment"
818 );
819 match self.db.adopt_assignment_snapshot(snap, head_deadline).await {
820 Ok(adoption) => {
821 authority_revision = self
822 .db
823 .assignment_authority_revision
824 .load(std::sync::atomic::Ordering::Acquire);
825 log_adoption("watcher", &adoption);
826 }
827 Err(e) => warn!(error = %e, "snapshot watcher: adoption failed"),
828 }
829 }
830 }
831 Ok(Ok(Some(snap))) => {
832 self.durable_drain_transition
833 .clone_from(&snap.drain_transition);
834 self.durable_snapshot = Some(snap);
835 }
836 Ok(Ok(None)) => {
837 self.durable_drain_transition = None;
838 self.durable_snapshot = None;
839 }
840 Ok(Err(error)) => {
841 self.durable_snapshot = None;
842 self.durable_drain_transition = None;
843 self.assignment_authority_dirty = true;
844 let _ = suspend_local_assignment_authority(
845 &self.db,
846 self.controller.as_deref(),
847 head_deadline,
848 )
849 .await;
850 warn!(%error, "snapshot watcher: durable audit failed; assignment authority suspended");
851 }
852 Err(_) => {
853 self.durable_snapshot = None;
854 self.durable_drain_transition = None;
855 self.assignment_authority_dirty = true;
856 let _ = suspend_local_assignment_authority(
857 &self.db,
858 self.controller.as_deref(),
859 head_deadline,
860 )
861 .await;
862 warn!(
863 timeout = ?self.config.checkpoint_timeout,
864 "snapshot watcher: durable audit timed out; assignment authority suspended"
865 );
866 }
867 }
868
869 self.publish_authority(authority_revision, head_deadline)
870 .await;
871 }
872 }
873}
874
875pub fn spawn_snapshot_watcher(
881 db: Arc<LaminarDB>,
882 store: Arc<AssignmentSnapshotStore>,
883 registry: Arc<VnodeRegistry>,
884 shutdown: CancellationToken,
885 config: RebalanceConfig,
886 controller: Option<Arc<ClusterController>>,
887) -> JoinHandle<()> {
888 tokio::spawn(SnapshotWatcher::new(db, store, registry, shutdown, config, controller).run())
889}
890async fn compute_checkpoint_assignment_fence(
893 c: &ClusterController,
894 registry: &VnodeRegistry,
895 expected_participants: &[CheckpointParticipant],
896) -> Option<CheckpointAssignmentFence> {
897 let assignment = registry.versioned_snapshot();
898 let participant_ids: Vec<u64> = expected_participants
899 .iter()
900 .map(|participant| participant.node_id)
901 .collect();
902 let participants = c
903 .recovery_participant_incarnations(&participant_ids)
904 .await
905 .ok()?;
906 if participants != expected_participants {
907 return None;
908 }
909 let reported: rustc_hash::FxHashMap<u64, CheckpointAssignmentAdoption> = c
910 .read_adopted_assignments()
911 .await
912 .ok()?
913 .into_iter()
914 .map(|(node, adoption)| (node.0, adoption))
915 .collect();
916 checkpoint_assignment_fence(
917 assignment.version(),
918 assignment.owners(),
919 participants,
920 &reported,
921 )
922}
923
924fn checkpoint_assignment_fence(
925 assignment_version: u64,
926 owners: &[NodeId],
927 participants: Vec<CheckpointParticipant>,
928 reported: &rustc_hash::FxHashMap<u64, CheckpointAssignmentAdoption>,
929) -> Option<CheckpointAssignmentFence> {
930 let owner_ids: Vec<u64> = owners.iter().map(|owner| owner.0).collect();
931 let vnode_count = u32::try_from(owners.len()).ok()?;
932 let assignment_digest = CheckpointAssignmentFence::owner_map_digest(vnode_count, &owner_ids);
933 if assignment_version == 0
934 || owners.is_empty()
935 || participants.is_empty()
936 || owners.iter().any(|owner| {
937 owner.is_unassigned()
938 || participants
939 .binary_search_by_key(&owner.0, |participant| participant.node_id)
940 .is_err()
941 })
942 || participants.iter().any(|participant| {
943 reported.get(&participant.node_id).is_none_or(|adoption| {
944 adoption.participant != *participant
945 || adoption.assignment_version != assignment_version
946 || adoption.vnode_count != vnode_count
947 || adoption.assignment_digest != assignment_digest
948 })
949 })
950 {
951 return None;
952 }
953
954 CheckpointAssignmentFence::from_owner_map(assignment_version, &owner_ids, participants).ok()
955}
956
957fn local_drain_participant(
958 controller: &ClusterController,
959 transition: &AssignmentDrainTransition,
960) -> Option<CheckpointParticipant> {
961 let participant = CheckpointParticipant {
962 node_id: controller.instance_id().0,
963 boot_incarnation: controller.recovery_incarnation(),
964 };
965 (transition
966 .predecessor
967 .participant_incarnation(participant.node_id)
968 == Some(participant.boot_incarnation))
969 .then_some(participant)
970}
971
972fn finalized_drain_outcome(
973 transition: &AssignmentDrainTransition,
974 finalized: &AssignmentSnapshot,
975) -> Result<SourceDrainOutcome, String> {
976 if finalized.draining || finalized.version != transition.target.assignment_version {
977 return Err(format!(
978 "assignment {} is not a terminal snapshot for drain target {}",
979 finalized.version, transition.target.assignment_version
980 ));
981 }
982 let fence = finalized
983 .assignment_fence()
984 .map_err(|error| error.to_string())?;
985 if fence == transition.target {
986 return Ok(SourceDrainOutcome::Commit);
987 }
988 if fence.assignment_version == transition.target.assignment_version
989 && fence.vnode_count == transition.predecessor.vnode_count
990 && fence.assignment_digest == transition.predecessor.assignment_digest
991 && fence.participants == transition.predecessor.participants
992 {
993 return Ok(SourceDrainOutcome::Abort);
994 }
995 Err(format!(
996 "assignment {} is neither the committed target nor predecessor rollback for drain {:?}",
997 finalized.version,
998 transition.id()
999 ))
1000}
1001
1002#[derive(Debug, Clone, PartialEq, Eq)]
1003struct AuditedDrainOutcome {
1004 transition: AssignmentDrainTransition,
1005 outcome: SourceDrainOutcome,
1006}
1007
1008pub async fn audit_assignment_snapshot_authority(
1015 store: &AssignmentSnapshotStore,
1016 controller: Option<&ClusterController>,
1017 snapshot: &AssignmentSnapshot,
1018) -> Result<(), String> {
1019 audit_assignment_snapshot_authority_outcome(store, controller, snapshot)
1020 .await
1021 .map(|_| ())
1022}
1023
1024pub async fn startup_committed_assignment(
1031 store: &AssignmentSnapshotStore,
1032 controller: Option<&ClusterController>,
1033 head: AssignmentSnapshot,
1034) -> Result<AssignmentSnapshot, String> {
1035 audit_assignment_snapshot_authority(store, controller, &head)
1036 .await
1037 .map_err(|error| {
1038 format!(
1039 "audit startup assignment {} authority: {error}",
1040 head.version
1041 )
1042 })?;
1043 if !head.draining {
1044 return Ok(head);
1045 }
1046
1047 let transition = store
1048 .load_drain_transition(head.version)
1049 .await
1050 .map_err(|error| {
1051 format!(
1052 "load startup assignment {} drain transition: {error}",
1053 head.version
1054 )
1055 })?
1056 .ok_or_else(|| {
1057 format!(
1058 "draining startup assignment {} has no exact transition",
1059 head.version
1060 )
1061 })?;
1062 if head.drain_transition.as_ref() != Some(&transition) {
1063 return Err(format!(
1064 "draining startup assignment {} does not match its durable transition",
1065 head.version
1066 ));
1067 }
1068 let prior_version = head
1069 .version
1070 .checked_sub(1)
1071 .ok_or_else(|| "draining assignment has no retained committed predecessor".to_string())?;
1072 let prior = store
1073 .load_version(prior_version)
1074 .await
1075 .map_err(|error| {
1076 format!(
1077 "load retained assignment {prior_version} before draining head {}: {error}",
1078 head.version
1079 )
1080 })?
1081 .ok_or_else(|| {
1082 format!(
1083 "draining assignment {} has no retained committed predecessor {prior_version}",
1084 head.version
1085 )
1086 })?;
1087 if prior.draining {
1088 return Err(format!(
1089 "draining assignment {} has a draining predecessor {prior_version}",
1090 head.version
1091 ));
1092 }
1093 if prior
1094 .assignment_fence()
1095 .map_err(|error| error.to_string())?
1096 != transition.predecessor
1097 {
1098 return Err(format!(
1099 "draining assignment {} does not bind retained predecessor {prior_version}",
1100 head.version
1101 ));
1102 }
1103 audit_assignment_snapshot_authority(store, controller, &prior)
1104 .await
1105 .map_err(|error| format!("audit retained assignment {prior_version} authority: {error}"))?;
1106 Ok(prior)
1107}
1108
1109async fn audit_assignment_snapshot_authority_outcome(
1110 store: &AssignmentSnapshotStore,
1111 controller: Option<&ClusterController>,
1112 snapshot: &AssignmentSnapshot,
1113) -> Result<Option<AuditedDrainOutcome>, String> {
1114 if snapshot.draining {
1115 return Ok(None);
1116 }
1117 if snapshot.version == 1 {
1118 return Ok(None);
1119 }
1120 let authority = match controller {
1121 Some(controller) => Some(
1122 controller
1123 .checkpoint_authority()
1124 .map_err(|error| error.to_string())?,
1125 ),
1126 None => None,
1127 };
1128 let Some(transition) = store
1129 .load_drain_transition(snapshot.version)
1130 .await
1131 .map_err(|error| error.to_string())?
1132 else {
1133 audit_materialized_recovery_with_authority(store, authority.as_deref(), snapshot).await?;
1134 return Ok(None);
1135 };
1136 audit_materialized_drain_transition(authority.as_deref(), snapshot, transition)
1137 .await
1138 .map(Some)
1139}
1140
1141async fn audit_materialized_drain_with_authority(
1142 store: &AssignmentSnapshotStore,
1143 authority: Option<&LeaderLeaseStore>,
1144 snapshot: &AssignmentSnapshot,
1145) -> Result<Option<AuditedDrainOutcome>, String> {
1146 if snapshot.draining {
1147 return Ok(None);
1148 }
1149 let Some(transition) = store
1150 .load_drain_transition(snapshot.version)
1151 .await
1152 .map_err(|error| error.to_string())?
1153 else {
1154 audit_materialized_recovery_with_authority(store, authority, snapshot).await?;
1155 return Ok(None);
1156 };
1157 audit_materialized_drain_transition(authority, snapshot, transition)
1158 .await
1159 .map(Some)
1160}
1161
1162async fn audit_materialized_recovery_with_authority(
1163 store: &AssignmentSnapshotStore,
1164 authority: Option<&LeaderLeaseStore>,
1165 snapshot: &AssignmentSnapshot,
1166) -> Result<(), String> {
1167 if snapshot.version == 1 {
1168 return Ok(());
1169 }
1170 let authority = authority.ok_or_else(|| {
1171 format!(
1172 "materialized assignment recovery {} has no cluster authority",
1173 snapshot.version
1174 )
1175 })?;
1176 let decision = authority
1177 .assignment_recovery_decision(snapshot.version)
1178 .await
1179 .map_err(|error| error.to_string())?
1180 .ok_or_else(|| {
1181 format!(
1182 "assignment {} has no drain transition or recovery authority decision",
1183 snapshot.version
1184 )
1185 })?;
1186 let proposal = store
1187 .load_recovery_proposal(&decision.proposal)
1188 .await
1189 .map_err(|error| error.to_string())?;
1190 if proposal != *snapshot
1191 || proposal
1192 .assignment_fence()
1193 .map_err(|error| error.to_string())?
1194 != decision.target
1195 {
1196 return Err(format!(
1197 "assignment {} does not match its authorized recovery proposal",
1198 snapshot.version
1199 ));
1200 }
1201 let predecessor_version = snapshot
1202 .version
1203 .checked_sub(1)
1204 .ok_or_else(|| "recovery assignment has no predecessor generation".to_string())?;
1205 let predecessor = store
1206 .load_version(predecessor_version)
1207 .await
1208 .map_err(|error| error.to_string())?
1209 .ok_or_else(|| {
1210 format!(
1211 "recovery assignment {} lost predecessor {predecessor_version}",
1212 snapshot.version
1213 )
1214 })?;
1215 if predecessor.draining
1216 || predecessor
1217 .assignment_fence()
1218 .map_err(|error| error.to_string())?
1219 != decision.predecessor
1220 {
1221 return Err(format!(
1222 "assignment {} recovery decision does not bind its exact committed predecessor",
1223 snapshot.version
1224 ));
1225 }
1226 Ok(())
1227}
1228
1229async fn audit_materialized_drain_transition(
1230 authority: Option<&LeaderLeaseStore>,
1231 snapshot: &AssignmentSnapshot,
1232 transition: AssignmentDrainTransition,
1233) -> Result<AuditedDrainOutcome, String> {
1234 let authority = authority
1235 .ok_or_else(|| "materialized assignment drain has no cluster authority".to_string())?;
1236 let decision = authority
1237 .assignment_drain_decision(snapshot.version)
1238 .await
1239 .map_err(|error| error.to_string())?
1240 .ok_or_else(|| {
1241 format!(
1242 "assignment {} has a materialized drain outcome without an authority decision",
1243 snapshot.version
1244 )
1245 })?;
1246 if decision.transition != transition {
1247 return Err(format!(
1248 "assignment {} materialization binds a different authority transition",
1249 snapshot.version
1250 ));
1251 }
1252 let observed = finalized_drain_outcome(&transition, snapshot)?;
1253 let expected = match decision.verdict {
1254 AssignmentDrainVerdict::Commit => SourceDrainOutcome::Commit,
1255 AssignmentDrainVerdict::Abort => SourceDrainOutcome::Abort,
1256 };
1257 if observed != expected {
1258 return Err(format!(
1259 "assignment {} materialization conflicts with its authority decision",
1260 snapshot.version
1261 ));
1262 }
1263 Ok(AuditedDrainOutcome {
1264 transition,
1265 outcome: observed,
1266 })
1267}
1268
1269#[derive(Clone, Copy)]
1273enum SourceDrainResolutionDeadline {
1274 Fresh(Duration),
1275 Absolute(tokio::time::Instant),
1276}
1277
1278impl SourceDrainResolutionDeadline {
1279 fn resolve(self) -> tokio::time::Instant {
1280 match self {
1281 Self::Fresh(timeout) => tokio::time::Instant::now() + timeout,
1282 Self::Absolute(deadline) => deadline,
1283 }
1284 }
1285}
1286
1287async fn settle_observed_local_drain(
1288 db: &Arc<LaminarDB>,
1289 store: &AssignmentSnapshotStore,
1290 registry: &VnodeRegistry,
1291 controller: Option<&ClusterController>,
1292 transition: &AssignmentDrainTransition,
1293 observed: &AssignmentSnapshot,
1294 audited_observed: Option<&AuditedDrainOutcome>,
1295 adoption_deadline: tokio::time::Instant,
1296 drain_deadline: SourceDrainResolutionDeadline,
1297) -> Result<bool, String> {
1298 if observed.draining {
1299 if observed.drain_transition.as_ref() == Some(transition) {
1300 return Ok(false);
1301 }
1302 if observed.version >= transition.target.assignment_version {
1303 return Err(format!(
1304 "drain {:?} was superseded by a different draining assignment {}",
1305 transition.id(),
1306 observed.version
1307 ));
1308 }
1309 return Ok(false);
1310 }
1311 if observed.version < transition.target.assignment_version {
1312 return Ok(false);
1313 }
1314
1315 let finalized = if observed.version == transition.target.assignment_version {
1316 observed.clone()
1317 } else {
1318 tokio::time::timeout_at(
1319 adoption_deadline,
1320 store.load_version(transition.target.assignment_version),
1321 )
1322 .await
1323 .map_err(|_| {
1324 format!(
1325 "timed out loading terminal assignment {} for drain resolution",
1326 transition.target.assignment_version
1327 )
1328 })?
1329 .map_err(|error| error.to_string())?
1330 .ok_or_else(|| {
1331 format!(
1332 "terminal assignment {} was pruned before local drain resolution",
1333 transition.target.assignment_version
1334 )
1335 })?
1336 };
1337 let audited = match audited_observed
1338 .filter(|audited| {
1339 finalized.version == observed.version && audited.transition == *transition
1340 })
1341 .cloned()
1342 {
1343 Some(audited) => audited,
1344 None => tokio::time::timeout_at(
1345 adoption_deadline,
1346 audit_assignment_snapshot_authority_outcome(store, controller, &finalized),
1347 )
1348 .await
1349 .map_err(|_| {
1350 format!(
1351 "timed out auditing terminal assignment {} for drain resolution",
1352 finalized.version
1353 )
1354 })??
1355 .ok_or_else(|| {
1356 format!(
1357 "terminal assignment {} lost drain transition history",
1358 finalized.version
1359 )
1360 })?,
1361 };
1362 if audited.transition != *transition {
1363 return Err(format!(
1364 "terminal assignment {} binds a different drain transition",
1365 finalized.version
1366 ));
1367 }
1368 let outcome = audited.outcome;
1369 let target_version = transition.target.assignment_version;
1370 let local_version = registry.assignment_version();
1371 if local_version < target_version {
1372 let adoption = db
1373 .adopt_assignment_snapshot(finalized.clone(), adoption_deadline)
1374 .await
1375 .map_err(|error| error.to_string())?;
1376 log_adoption("watcher-drain-resolution", &adoption);
1377 }
1378 if registry.assignment_version() != target_version {
1379 return Err(format!(
1380 "cannot resolve drain {:?} at local assignment {}",
1381 transition.id(),
1382 registry.assignment_version()
1383 ));
1384 }
1385 let expected = finalized
1386 .to_vnode_vec(registry.vnode_count())
1387 .map_err(|error| error.to_string())?;
1388 if registry.snapshot().as_ref() != expected.as_slice() {
1389 return Err(format!(
1390 "local assignment {target_version} does not match the durable drain outcome"
1391 ));
1392 }
1393 db.resolve_local_source_drain(transition.id(), outcome, drain_deadline.resolve())
1394 .await
1395 .map_err(|error| error.to_string())?;
1396 Ok(true)
1397}
1398
1399fn clear_settled_source_drain(
1400 controller: &ClusterController,
1401 transition: &AssignmentDrainTransition,
1402) -> Result<(), String> {
1403 match controller.checkpoint_drain_transition() {
1404 Some(active) if active == *transition => controller
1405 .clear_checkpoint_drain_transition_if_matches(transition)
1406 .then_some(())
1407 .ok_or_else(|| "process-local source drain changed during settlement".into()),
1408 Some(_) => Err("process-local source drain changed during settlement".into()),
1409 None => Ok(()),
1410 }
1411}
1412
1413pub(crate) async fn settle_source_drain_before_recovery_release(
1416 db: &Arc<LaminarDB>,
1417 controller: &ClusterController,
1418 expected_fence: &CheckpointAssignmentFence,
1419 deadline: tokio::time::Instant,
1420) -> Result<Option<u64>, String> {
1421 if !controller.is_recovering() || !db.cluster_intake_fenced() {
1422 return Err("recovery source-drain settlement requires closed intake".into());
1423 }
1424 let published_transition = controller.checkpoint_drain_transition();
1425 let store =
1426 db.assignment_snapshot_store.lock().clone().ok_or_else(|| {
1427 "recovery source-drain settlement has no assignment store".to_string()
1428 })?;
1429 let snapshot = tokio::time::timeout_at(deadline, store.load())
1430 .await
1431 .map_err(|_| "recovery source-drain head read timed out".to_string())?
1432 .map_err(|error| error.to_string())?
1433 .ok_or_else(|| "recovery source-drain settlement has no assignment head".to_string())?;
1434 if snapshot.draining {
1435 return Err(format!(
1436 "recovery Release reached unresolved draining assignment {}",
1437 snapshot.version
1438 ));
1439 }
1440 let fence = snapshot
1441 .assignment_fence()
1442 .map_err(|error| error.to_string())?;
1443 if &fence != expected_fence {
1444 return Err(format!(
1445 "recovery assignment {} changed before source-drain settlement",
1446 snapshot.version
1447 ));
1448 }
1449 let audited = tokio::time::timeout_at(
1450 deadline,
1451 audit_assignment_snapshot_authority_outcome(&store, Some(controller), &snapshot),
1452 )
1453 .await
1454 .map_err(|_| "recovery source-drain terminal audit timed out".to_string())??;
1455 let Some(audited) = audited else {
1456 if published_transition.is_some() {
1457 return Err("process-local source drain has no durable terminal".into());
1458 }
1459 return Ok(None);
1460 };
1461 if published_transition.is_some_and(|active| active != audited.transition) {
1462 return Err("process-local source drain conflicts with the durable terminal".into());
1463 }
1464 if local_drain_participant(controller, &audited.transition).is_none() {
1465 clear_settled_source_drain(controller, &audited.transition)?;
1466 return Ok(None);
1467 }
1468 if tokio::time::Instant::now() >= deadline {
1469 return Err("recovery source-drain settlement deadline expired".into());
1470 }
1471 let registry = db
1472 .vnode_registry
1473 .lock()
1474 .clone()
1475 .ok_or_else(|| "recovery source-drain settlement has no vnode registry".to_string())?;
1476 if !settle_observed_local_drain(
1477 db,
1478 &store,
1479 ®istry,
1480 Some(controller),
1481 &audited.transition,
1482 &snapshot,
1483 Some(&audited),
1484 deadline,
1485 SourceDrainResolutionDeadline::Absolute(deadline),
1486 )
1487 .await?
1488 {
1489 return Err("materialized source drain was not ready for recovery Release".into());
1490 }
1491 let confirmed = tokio::time::timeout_at(deadline, store.load())
1492 .await
1493 .map_err(|_| "recovery source-drain head recheck timed out".to_string())?
1494 .map_err(|error| error.to_string())?
1495 .ok_or_else(|| "recovery source-drain assignment disappeared".to_string())?;
1496 if confirmed != snapshot {
1497 return Err("recovery source-drain assignment changed during settlement".into());
1498 }
1499 let confirmed_terminal = tokio::time::timeout_at(
1500 deadline,
1501 audit_assignment_snapshot_authority_outcome(&store, Some(controller), &confirmed),
1502 )
1503 .await
1504 .map_err(|_| "recovery source-drain terminal recheck timed out".to_string())??;
1505 if confirmed_terminal.as_ref() != Some(&audited) {
1506 return Err("recovery source-drain terminal changed during settlement".into());
1507 }
1508 clear_settled_source_drain(controller, &audited.transition)?;
1509 Ok(Some(snapshot.version))
1510}
1511
1512async fn audit_drain_predecessor(
1513 store: &AssignmentSnapshotStore,
1514 registry: &VnodeRegistry,
1515 draining: &AssignmentSnapshot,
1516 controller: Option<&ClusterController>,
1517) -> Result<AssignmentSnapshot, String> {
1518 if !draining.draining {
1519 return Err("drain predecessor audit requires a draining head".into());
1520 }
1521 let local_version = registry.assignment_version();
1522 let expected_target = local_version
1523 .checked_add(1)
1524 .ok_or_else(|| "assignment version overflow during drain audit".to_string())?;
1525 if draining.version != expected_target {
1526 return Err(format!(
1527 "draining assignment {} is not the exact successor of local assignment {local_version}",
1528 draining.version
1529 ));
1530 }
1531 draining
1532 .to_vnode_vec(registry.vnode_count())
1533 .map_err(|error| error.to_string())?;
1534
1535 let predecessor = store
1536 .load_version(local_version)
1537 .await
1538 .map_err(|error| error.to_string())?
1539 .ok_or_else(|| {
1540 format!(
1541 "draining assignment {} lost committed predecessor {local_version}",
1542 draining.version
1543 )
1544 })?;
1545 if predecessor.draining || predecessor.version != local_version {
1546 return Err(format!(
1547 "assignment {local_version} is not the committed predecessor of draining assignment {}",
1548 draining.version
1549 ));
1550 }
1551 let transition = draining
1552 .drain_transition
1553 .as_ref()
1554 .ok_or_else(|| "draining assignment has no exact transition".to_string())?;
1555 if predecessor
1556 .assignment_fence()
1557 .map_err(|error| error.to_string())?
1558 != transition.predecessor
1559 {
1560 return Err(format!(
1561 "draining assignment {} does not bind committed predecessor {local_version}",
1562 draining.version
1563 ));
1564 }
1565 let controller = controller
1566 .ok_or_else(|| "draining assignment requires a cluster controller".to_string())?;
1567 if !controller.process_lease_is_live() {
1568 return Err("local process lease expired while auditing assignment drain".into());
1569 }
1570 let authority = controller
1571 .checkpoint_authority()
1572 .map_err(|error| error.to_string())?;
1573 if authority
1574 .assignment_drain_decision(transition.target.assignment_version)
1575 .await
1576 .map_err(|error| error.to_string())?
1577 .is_some()
1578 {
1579 return Err("assignment drain already has a terminal authority decision".into());
1580 }
1581 let current_leader = authority
1582 .load()
1583 .await
1584 .map_err(|error| error.to_string())?
1585 .ok_or_else(|| "assignment drain leader authority is missing".to_string())?;
1586 if !current_leader.matches_proof(&transition.leader) {
1587 return Err("assignment drain leader term was superseded".into());
1588 }
1589 let owners = predecessor
1590 .to_vnode_vec(registry.vnode_count())
1591 .map_err(|error| error.to_string())?;
1592 let local = registry.snapshot();
1593 if owners.as_slice() != local.as_ref() {
1594 return Err(format!(
1595 "committed predecessor {local_version} does not match the local owner map"
1596 ));
1597 }
1598 Ok(predecessor)
1599}
1600
1601async fn audit_exact_drain_head(
1602 store: &AssignmentSnapshotStore,
1603 registry: &VnodeRegistry,
1604 draining: &AssignmentSnapshot,
1605 controller: &ClusterController,
1606 deadline: tokio::time::Instant,
1607) -> Result<AssignmentSnapshot, String> {
1608 let refreshed = tokio::time::timeout_at(deadline, store.load())
1609 .await
1610 .map_err(|_| "durable drain re-audit timed out".to_string())?
1611 .map_err(|error| error.to_string())?;
1612 if refreshed.as_ref() != Some(draining) {
1613 return Err("durable assignment drain is no longer the unfinalized head".into());
1614 }
1615 tokio::time::timeout_at(
1616 deadline,
1617 audit_drain_predecessor(store, registry, draining, Some(controller)),
1618 )
1619 .await
1620 .map_err(|_| "durable drain predecessor audit timed out".to_string())?
1621}
1622
1623async fn prepare_and_announce_local_drain(
1624 db: &LaminarDB,
1625 store: &AssignmentSnapshotStore,
1626 registry: &VnodeRegistry,
1627 controller: &ClusterController,
1628 draining: &AssignmentSnapshot,
1629 transition: &AssignmentDrainTransition,
1630 participant: CheckpointParticipant,
1631 deadline: tokio::time::Instant,
1632) -> Result<(), String> {
1633 db.prepare_local_source_drain(transition, participant, deadline)
1634 .await
1635 .map_err(|error| error.to_string())?;
1636
1637 audit_exact_drain_head(store, registry, draining, controller, deadline).await?;
1640 controller.publish_checkpoint_drain_transition(Some(transition.clone()));
1641 tokio::time::timeout_at(deadline, controller.announce_drain_ack(transition))
1642 .await
1643 .map_err(|_| "durable drain acknowledgement publication timed out".to_string())?
1644}
1645
1646fn publish_placement_metrics(
1648 metrics: &EngineMetrics,
1649 registry: &VnodeRegistry,
1650 nodes: &[(NodeId, Locality)],
1651 isolation_tier: usize,
1652) {
1653 let owners = registry.snapshot();
1654 let total = u32::try_from(owners.len().max(1)).unwrap_or(u32::MAX);
1655 let counts = owners_per_domain(&owners, nodes, isolation_tier);
1656
1657 metrics.placement_vnodes_per_domain.reset();
1658 let mut max = 0u32;
1659 for (domain, &count) in &counts {
1660 let label = if domain.is_empty() {
1661 "unknown"
1662 } else {
1663 domain.as_str()
1664 };
1665 metrics
1666 .placement_vnodes_per_domain
1667 .with_label_values(&[label])
1668 .set(i64::from(count));
1669 max = max.max(count);
1670 }
1671 metrics
1672 .placement_blast_radius_ratio
1673 .set(f64::from(max) / f64::from(total));
1674}
1675
1676pub fn spawn_rebalance_controller(
1679 db: Arc<LaminarDB>,
1680 controller: Arc<ClusterController>,
1681 store: Arc<AssignmentSnapshotStore>,
1682 registry: Arc<VnodeRegistry>,
1683 shutdown: CancellationToken,
1684 config: RebalanceConfig,
1685) -> JoinHandle<()> {
1686 tokio::spawn(async move {
1687 let mut members = controller.members_watch();
1688 let mut audit = tokio::time::interval(config.watcher_poll);
1689 audit.set_missed_tick_behavior(MissedTickBehavior::Skip);
1690 loop {
1691 let membership_changed = tokio::select! {
1692 biased;
1693 () = shutdown.cancelled() => return,
1694 res = members.changed() => {
1695 if res.is_err() {
1696 warn!("membership watch sender dropped; rebalance controller exiting");
1697 return;
1698 }
1699 true
1700 }
1701 _ = audit.tick() => false,
1702 };
1703
1704 if membership_changed {
1705 debug!("membership change observed; debouncing");
1706 loop {
1707 tokio::select! {
1708 biased;
1709 () = shutdown.cancelled() => return,
1710 res = tokio::time::timeout(
1711 config.rebalance_debounce, members.changed()
1712 ) => {
1713 match res {
1714 Ok(Ok(())) => {} Ok(Err(_)) => return, Err(_) => break, }
1718 }
1719 }
1720 }
1721 }
1722
1723 loop {
1724 if !controller.is_leader() {
1725 debug!("membership changed; not the leader — skipping rotation check");
1726 break;
1727 }
1728 let live = controller.assignable_instances();
1730 match try_rebalance_owned(
1731 Arc::clone(&db),
1732 Arc::clone(&controller),
1733 Arc::clone(&store),
1734 Arc::clone(®istry),
1735 live,
1736 config,
1737 )
1738 .await
1739 {
1740 Ok(Some(v)) => {
1741 info!(version = v, "rotated assignment");
1742 break;
1743 }
1744 Ok(None) => {
1745 debug!("live set matches current snapshot; no rotation");
1746 break;
1747 }
1748 Err(e) => {
1749 warn!(error = %e, "rebalance failed; retrying after backoff");
1750 tokio::select! {
1751 biased;
1752 () = shutdown.cancelled() => return,
1753 () = tokio::time::sleep(config.retry_delay) => {}
1754 }
1755 }
1756 }
1757 }
1758 }
1759 })
1760}
1761
1762pub async fn wait_until_drained(
1768 store: &AssignmentSnapshotStore,
1769 authority: Option<&LeaderLeaseStore>,
1770 me: NodeId,
1771 vnode_count: u32,
1772 poll: Duration,
1773 deadline: Duration,
1774) -> bool {
1775 let start = tokio::time::Instant::now();
1776 loop {
1777 if start.elapsed() >= deadline {
1778 return false;
1779 }
1780 let remaining = deadline.saturating_sub(start.elapsed());
1781 match tokio::time::timeout(remaining, store.load()).await {
1782 Ok(Ok(None)) => {
1783 warn!(
1784 "wait_until_drained: durable assignment head is missing; ownership is unknown"
1785 );
1786 }
1787 Ok(Ok(Some(snap))) => {
1788 match audit_materialized_drain_with_authority(store, authority, &snap).await {
1789 Ok(_) => match snap.to_vnode_vec(vnode_count) {
1790 Ok(owners) if !snap.draining && !owners.contains(&me) => return true,
1791 Ok(_) => {}
1792 Err(error) => {
1793 warn!(%error, "wait_until_drained: snapshot cardinality mismatch");
1794 }
1795 },
1796 Err(error) => {
1797 warn!(%error, version = snap.version, "wait_until_drained: assignment authority audit failed");
1798 }
1799 }
1800 }
1801 Ok(Err(e)) => warn!(error = %e, "wait_until_drained: snapshot load failed"),
1802 Err(_) => {
1803 warn!("wait_until_drained: snapshot load exceeded the shutdown deadline");
1804 return false;
1805 }
1806 }
1807 let remaining = deadline.saturating_sub(start.elapsed());
1808 tokio::time::sleep(poll.min(remaining)).await;
1809 if start.elapsed() >= deadline {
1810 return false;
1811 }
1812 }
1813}
1814
1815async fn materialize_recovery_decision(
1816 db: &Arc<LaminarDB>,
1817 store: &Arc<AssignmentSnapshotStore>,
1818 controller: &ClusterController,
1819 decision: AssignmentRecoveryDecision,
1820 operation_timeout: Duration,
1821) -> Result<Option<u64>, String> {
1822 let deadline = tokio::time::Instant::now() + operation_timeout;
1823 close_local_assignment_authority(db, Some(controller), deadline).await?;
1824 ensure_local_recovery_fault(db, controller).await?;
1825 let proposal =
1826 tokio::time::timeout_at(deadline, store.load_recovery_proposal(&decision.proposal))
1827 .await
1828 .map_err(|_| {
1829 "recovery proposal load exceeded the materialization deadline".to_string()
1830 })?
1831 .map_err(|error| error.to_string())?;
1832 if proposal
1833 .assignment_fence()
1834 .map_err(|error| error.to_string())?
1835 != decision.target
1836 {
1837 return Err("recovery authority winner does not match its staged proposal".into());
1838 }
1839 let authority = controller
1840 .checkpoint_authority()
1841 .map_err(|error| error.to_string())?;
1842 let durable = match tokio::time::timeout_at(
1843 deadline,
1844 authority.materialize_assignment_recovery(decision.target_version()),
1845 )
1846 .await
1847 .map_err(|_| "recovery assignment materialization exceeded its deadline".to_string())?
1848 .map_err(|error| error.to_string())?
1849 {
1850 RotateOutcome::Rotated => proposal.clone(),
1851 RotateOutcome::Conflict(winner) => *winner,
1852 };
1853 if durable != proposal {
1854 return Err(format!(
1855 "assignment {} materialization conflicts with the authority recovery winner",
1856 decision.target_version()
1857 ));
1858 }
1859 tokio::time::timeout_at(
1860 deadline,
1861 audit_assignment_snapshot_authority(store, Some(controller), &durable),
1862 )
1863 .await
1864 .map_err(|_| "recovery assignment audit exceeded the materialization deadline".to_string())??;
1865 let version = durable.version;
1866 let adoption = db
1867 .adopt_assignment_snapshot(durable, deadline)
1868 .await
1869 .map_err(|error| error.to_string())?;
1870 log_adoption("rebalance-recovery", &adoption);
1871 let oldest_retained = version.saturating_sub(1);
1872 let maintenance_store = Arc::clone(store);
1873 let maintenance_authority = Arc::clone(&authority);
1874 let maintenance_proof = controller.capture_leader_proof();
1875 tokio::spawn(async move {
1876 match maintenance_store.prune_before(oldest_retained).await {
1877 Ok(()) => {
1878 if let Some(proof) = maintenance_proof {
1879 if let Err(error) = maintenance_authority
1880 .prune_assignment_drain_decisions_before(&proof, oldest_retained)
1881 .await
1882 {
1883 warn!(%error, "assignment recovery authority prune failed after snapshot prune");
1884 }
1885 }
1886 }
1887 Err(error) => warn!(%error, "snapshot prune failed after assignment recovery"),
1888 }
1889 });
1890 Ok(Some(version))
1891}
1892
1893async fn reconcile_pending_recovery_decision(
1894 db: &Arc<LaminarDB>,
1895 store: &Arc<AssignmentSnapshotStore>,
1896 controller: &ClusterController,
1897 current: &AssignmentSnapshot,
1898 operation_timeout: Duration,
1899) -> Result<Option<u64>, String> {
1900 let target_version = current
1901 .version
1902 .checked_add(1)
1903 .ok_or_else(|| "assignment version exhausted".to_string())?;
1904 let authority = controller
1905 .checkpoint_authority()
1906 .map_err(|error| error.to_string())?;
1907 let Some(decision) = tokio::time::timeout(
1908 operation_timeout,
1909 authority.assignment_recovery_decision(target_version),
1910 )
1911 .await
1912 .map_err(|_| "recovery authority lookup timed out".to_string())?
1913 .map_err(|error| error.to_string())?
1914 else {
1915 return Ok(None);
1916 };
1917 if decision.predecessor
1918 != current
1919 .assignment_fence()
1920 .map_err(|error| error.to_string())?
1921 {
1922 return Err(format!(
1923 "pending recovery decision for assignment {target_version} has the wrong predecessor"
1924 ));
1925 }
1926 materialize_recovery_decision(db, store, controller, decision, operation_timeout).await
1927}
1928
1929fn replaced_predecessor_processes(
1930 predecessor: &CheckpointAssignmentFence,
1931 target: &CheckpointAssignmentFence,
1932) -> Vec<CheckpointParticipant> {
1933 predecessor
1934 .participants
1935 .iter()
1936 .copied()
1937 .filter(|participant| {
1938 target.participant_incarnation(participant.node_id)
1939 != Some(participant.boot_incarnation)
1940 })
1941 .collect()
1942}
1943
1944async fn authorize_recovery_successor(
1945 db: &Arc<LaminarDB>,
1946 store: &Arc<AssignmentSnapshotStore>,
1947 controller: &ClusterController,
1948 current: &AssignmentSnapshot,
1949 proposal: AssignmentSnapshot,
1950 operation_timeout: Duration,
1951 reason: &str,
1952) -> Result<Option<u64>, String> {
1953 let predecessor = current
1954 .assignment_fence()
1955 .map_err(|error| error.to_string())?;
1956 let target = proposal
1957 .assignment_fence()
1958 .map_err(|error| error.to_string())?;
1959 let deadline = controller.process_fencing_deadline(operation_timeout)?;
1960 close_local_assignment_authority(db, Some(controller), deadline).await?;
1961 let proposal_ref = tokio::time::timeout_at(deadline, store.stage_recovery_proposal(&proposal))
1962 .await
1963 .map_err(|_| "recovery proposal staging exceeded the fencing deadline".to_string())?
1964 .map_err(|error| error.to_string())?;
1965
1966 let removed = replaced_predecessor_processes(&predecessor, &target);
1967 let fence_results = futures::future::join_all(
1968 removed
1969 .iter()
1970 .copied()
1971 .map(|participant| controller.fence_process_incarnation(participant, deadline)),
1972 )
1973 .await;
1974 let mut process_fences = Vec::with_capacity(fence_results.len());
1975 for result in fence_results {
1976 process_fences.push(result?);
1977 }
1978
1979 let observed = tokio::time::timeout_at(deadline, store.load())
1980 .await
1981 .map_err(|_| "assignment head revalidation exceeded the fencing deadline".to_string())?
1982 .map_err(|error| error.to_string())?
1983 .ok_or_else(|| "assignment head disappeared during recovery fencing".to_string())?;
1984 if observed != *current {
1985 return Err(format!(
1986 "assignment head advanced from {} while process fencing was in progress",
1987 current.version
1988 ));
1989 }
1990 tokio::time::timeout_at(
1991 deadline,
1992 audit_assignment_snapshot_authority(store, Some(controller), &observed),
1993 )
1994 .await
1995 .map_err(|_| "predecessor authority audit exceeded the fencing deadline".to_string())??;
1996
1997 let target_checks =
1998 futures::future::join_all(target.participants.iter().copied().map(|participant| {
1999 controller.verify_current_process_incarnation(participant, deadline)
2000 }));
2001 for (participant, check) in target.participants.iter().zip(target_checks.await) {
2002 if !check? {
2003 return Err(format!(
2004 "successor process {} is not the current durable lease owner",
2005 participant.node_id
2006 ));
2007 }
2008 }
2009
2010 let leader_proof = controller
2011 .capture_leader_proof()
2012 .ok_or_else(|| "assignment recovery lost the current durable leader proof".to_string())?;
2013 let decision = AssignmentRecoveryDecision::new(
2014 predecessor,
2015 target,
2016 proposal_ref,
2017 process_fences,
2018 leader_proof.clone(),
2019 )?;
2020 let decision = match tokio::time::timeout_at(
2021 deadline,
2022 controller.record_assignment_recovery_decision(&leader_proof, decision, deadline),
2023 )
2024 .await
2025 .map_err(|_| "recovery authority admission exceeded the fencing deadline".to_string())?
2026 .map_err(|error| error.clone())?
2027 {
2028 RecordAssignmentRecoveryDecisionResult::Created(decision)
2029 | RecordAssignmentRecoveryDecisionResult::Unchanged(decision) => decision,
2030 RecordAssignmentRecoveryDecisionResult::Conflict { winner } => winner,
2031 };
2032 warn!(
2033 predecessor_version = current.version,
2034 target_version = decision.target_version(),
2035 %reason,
2036 "authorized successor assignment from the last committed cluster cut"
2037 );
2038 materialize_recovery_decision(db, store, controller, decision, operation_timeout).await
2039}
2040
2041fn execute_graceful_rotation_owned(
2042 db: Arc<LaminarDB>,
2043 controller: Arc<ClusterController>,
2044 store: Arc<AssignmentSnapshotStore>,
2045 registry: Arc<VnodeRegistry>,
2046 current: AssignmentSnapshot,
2047 new_vnodes: std::collections::BTreeMap<u32, NodeId>,
2048 participants: Vec<CheckpointParticipant>,
2049 config: RebalanceConfig,
2050) -> futures::future::BoxFuture<'static, Result<Option<u64>, String>> {
2051 Box::pin(async move {
2052 let leader = controller.capture_leader_proof().ok_or_else(|| {
2053 "assignment drain requires the current durable leader proof".to_string()
2054 })?;
2055 let drain = current
2056 .next_draining(new_vnodes.clone(), participants.clone(), leader)
2057 .map_err(|error| error.to_string())?;
2058 let transition = drain
2059 .drain_transition
2060 .as_ref()
2061 .expect("validated draining snapshot has a transition")
2062 .clone();
2063 match store
2064 .save_if_version(&drain, current.version)
2065 .await
2066 .map_err(|e| e.to_string())?
2067 {
2068 RotateOutcome::Rotated => {
2069 let drain_deadline = tokio::time::Instant::now() + config.drain_ack_timeout;
2070 db.validate_source_drain_snapshot(&drain)
2071 .map_err(|error| error.to_string())?;
2072 controller.publish_checkpoint_drain_transition(Some(transition.clone()));
2073 let local_receipt = match local_drain_participant(&controller, &transition) {
2077 Some(participant) => {
2078 prepare_and_announce_local_drain(
2079 &db,
2080 &store,
2081 ®istry,
2082 &controller,
2083 &drain,
2084 &transition,
2085 participant,
2086 drain_deadline,
2087 )
2088 .await
2089 }
2090 None => Ok(()),
2091 };
2092 let acked = match local_receipt {
2093 Ok(()) => await_drain_quorum(&controller, &transition, drain_deadline).await,
2094 Err(error) => {
2095 warn!(%error, version = drain.version, "leader durable drain acknowledgement failed");
2096 false
2097 }
2098 };
2099 if !acked {
2100 let failure = "drain ack quorum not reached before timeout";
2101 if let Err(error) = audit_exact_drain_head(
2102 &store,
2103 ®istry,
2104 &drain,
2105 &controller,
2106 tokio::time::Instant::now() + config.checkpoint_timeout,
2107 )
2108 .await
2109 {
2110 return Err(format!(
2111 "{failure}; drain is no longer authoritative: {error}"
2112 ));
2113 }
2114 if let Err(abort_error) = finalize_drain_snapshot(
2115 &db,
2116 &store,
2117 &controller,
2118 &drain,
2119 ¤t,
2120 AssignmentDrainVerdict::Abort,
2121 config,
2122 )
2123 .await
2124 {
2125 return Err(format!(
2126 "{failure}; assignment drain abort failed: {abort_error}"
2127 ));
2128 }
2129 return Err(failure.into());
2130 }
2131 audit_exact_drain_head(
2132 &store,
2133 ®istry,
2134 &drain,
2135 &controller,
2136 tokio::time::Instant::now() + config.checkpoint_timeout,
2137 )
2138 .await?;
2139 let checkpointed = pre_rotation_checkpoint(&db, config).await;
2142 if !matches!(checkpointed, Ok(true)) {
2143 let failure = checkpointed
2144 .err()
2145 .unwrap_or_else(|| "pre-rotation checkpoint failed during drain".into());
2146 if let Err(error) = audit_exact_drain_head(
2147 &store,
2148 ®istry,
2149 &drain,
2150 &controller,
2151 tokio::time::Instant::now() + config.checkpoint_timeout,
2152 )
2153 .await
2154 {
2155 return Err(format!(
2156 "{failure}; drain is no longer authoritative: {error}"
2157 ));
2158 }
2159 if let Err(abort_error) = finalize_drain_snapshot(
2160 &db,
2161 &store,
2162 &controller,
2163 &drain,
2164 ¤t,
2165 AssignmentDrainVerdict::Abort,
2166 config,
2167 )
2168 .await
2169 {
2170 return Err(format!(
2171 "{failure}; assignment drain abort failed: {abort_error}"
2172 ));
2173 }
2174 return Err(failure);
2175 }
2176 audit_exact_drain_head(
2177 &store,
2178 ®istry,
2179 &drain,
2180 &controller,
2181 tokio::time::Instant::now() + config.checkpoint_timeout,
2182 )
2183 .await?;
2184 return finalize_drain_snapshot(
2185 &db,
2186 &store,
2187 &controller,
2188 &drain,
2189 ¤t,
2190 AssignmentDrainVerdict::Commit,
2191 config,
2192 )
2193 .await;
2194 }
2195 RotateOutcome::Conflict(winner) => {
2196 let v = winner.version;
2197 adopt_any(
2198 &db,
2199 &store,
2200 &controller,
2201 *winner,
2202 tokio::time::Instant::now() + config.checkpoint_timeout,
2203 )
2204 .await?;
2205 Ok(Some(v))
2206 }
2207 }
2208 })
2209}
2210fn try_rebalance_owned(
2211 db: Arc<LaminarDB>,
2212 controller: Arc<ClusterController>,
2213 store: Arc<AssignmentSnapshotStore>,
2214 registry: Arc<VnodeRegistry>,
2215 live: Vec<NodeId>,
2216 config: RebalanceConfig,
2217) -> futures::future::BoxFuture<'static, Result<Option<u64>, String>> {
2218 Box::pin(async move {
2219 let head_deadline = tokio::time::Instant::now() + config.checkpoint_timeout;
2220 let current = tokio::time::timeout_at(head_deadline, store.load())
2221 .await
2222 .map_err(|_| "durable assignment head audit timed out".to_string())?
2223 .map_err(|e| e.to_string())?
2224 .ok_or_else(|| "no snapshot on store — boot seed missing".to_string())?;
2225 tokio::time::timeout_at(
2226 head_deadline,
2227 audit_assignment_snapshot_authority_owned(
2228 Arc::clone(&store),
2229 Arc::clone(&controller),
2230 current.clone(),
2231 ),
2232 )
2233 .await
2234 .map_err(|_| "durable assignment authority audit timed out".to_string())??;
2235 if let Some(version) = reconcile_pending_recovery_decision_owned(
2236 Arc::clone(&db),
2237 Arc::clone(&store),
2238 Arc::clone(&controller),
2239 current.clone(),
2240 config.checkpoint_timeout,
2241 )
2242 .await?
2243 {
2244 return Ok(Some(version));
2245 }
2246 let current_owners = current
2247 .to_vnode_vec(registry.vnode_count())
2248 .map_err(|error| error.to_string())?;
2249
2250 let local_assignment = registry.versioned_snapshot();
2251 if current.version < local_assignment.version() {
2252 return Err(format!(
2253 "durable assignment head {} regressed behind local assignment {}",
2254 current.version,
2255 local_assignment.version()
2256 ));
2257 }
2258 if !current.draining && current.version > local_assignment.version() {
2259 let adoption = db
2263 .adopt_assignment_snapshot(current.clone(), head_deadline)
2264 .await
2265 .map_err(|error| error.to_string())?;
2266 log_adoption("rebalance-reconcile", &adoption);
2267 let reconciled_version = registry.assignment_version();
2268 if reconciled_version < current.version {
2269 return Err(format!(
2270 "durable assignment {} was not adopted; local assignment remains {}",
2271 current.version, reconciled_version
2272 ));
2273 }
2274 return Ok(Some(reconciled_version));
2275 }
2276 if current.version == local_assignment.version()
2277 && current_owners.as_slice() != local_assignment.owners()
2278 {
2279 return Err(format!(
2280 "durable and local assignment {} have different owner maps",
2281 current.version
2282 ));
2283 }
2284
2285 if current.draining {
2286 let prior_version = current.version.checked_sub(1).ok_or_else(|| {
2287 "draining assignment has no prior committed generation".to_string()
2288 })?;
2289 let prior = tokio::time::timeout_at(head_deadline, store.load_version(prior_version))
2290 .await
2291 .map_err(|_| {
2292 format!("committed predecessor {prior_version} load exceeded the head deadline")
2293 })?
2294 .map_err(|error| error.to_string())?
2295 .ok_or_else(|| {
2296 format!(
2297 "draining assignment {} lost committed predecessor {prior_version}",
2298 current.version
2299 )
2300 })?;
2301 if prior.draining {
2302 return Err(format!(
2303 "draining assignment {} has a non-committed predecessor",
2304 current.version
2305 ));
2306 }
2307 tokio::time::timeout_at(
2308 head_deadline,
2309 audit_assignment_snapshot_authority_owned(
2310 Arc::clone(&store),
2311 Arc::clone(&controller),
2312 prior.clone(),
2313 ),
2314 )
2315 .await
2316 .map_err(|_| {
2317 format!(
2318 "committed predecessor {prior_version} authority audit exceeded the head deadline"
2319 )
2320 })??;
2321 prior
2322 .to_vnode_vec(registry.vnode_count())
2323 .map_err(|error| error.to_string())?;
2324 close_local_assignment_authority(&db, Some(controller.as_ref()), head_deadline).await?;
2328 return finalize_drain_snapshot(
2329 &db,
2330 &store,
2331 &controller,
2332 ¤t,
2333 &prior,
2334 AssignmentDrainVerdict::Abort,
2335 config,
2336 )
2337 .await;
2338 }
2339
2340 if live.is_empty() {
2343 return Ok(None);
2344 }
2345 let live_ids = successor_participant_ids(&live);
2346 let observed_processes = controller
2347 .available_recovery_participant_incarnations(&live_ids)
2348 .await?;
2349 let process_read_deadline = tokio::time::Instant::now() + config.checkpoint_timeout;
2350 let process_checks =
2351 futures::future::join_all(observed_processes.iter().copied().map(|participant| {
2352 controller.verify_current_process_incarnation(participant, process_read_deadline)
2353 }))
2354 .await;
2355 let mut available_processes = Vec::with_capacity(observed_processes.len());
2356 for (participant, check) in observed_processes.into_iter().zip(process_checks) {
2357 if check? {
2358 available_processes.push(participant);
2359 }
2360 }
2361 let successor_processes = available_processes
2362 .iter()
2363 .copied()
2364 .filter(|participant| controller.admit_successor_process(*participant))
2365 .collect::<Vec<_>>();
2366 let successors: Vec<NodeId> = successor_processes
2367 .iter()
2368 .map(|participant| NodeId(participant.node_id))
2369 .collect();
2370 if successors.is_empty() {
2371 return Ok(None);
2372 }
2373 let mut new_assignment =
2374 rendezvous_assignment(registry.vnode_count(), &successors).to_vec();
2375 let mut new_vnodes = AssignmentSnapshot::vnodes_from_vec(&new_assignment);
2376 let mut participants = successor_participants(&new_assignment, &successor_processes)?;
2382 let roster_changed = current.participants != participants;
2383
2384 if new_vnodes == current.vnodes && !roster_changed {
2385 return Ok(None);
2386 }
2387
2388 let recovery_reason =
2389 predecessor_cut_unavailability(&controller, ¤t, ¤t_owners, &successors)
2390 .await;
2391 if let Some(reason) = recovery_reason {
2392 retain_recovery_predecessors(
2393 &mut new_assignment,
2394 ¤t.participants,
2395 &successor_processes,
2396 )?;
2397 new_vnodes = AssignmentSnapshot::vnodes_from_vec(&new_assignment);
2398 participants = successor_participants(&new_assignment, &successor_processes)?;
2399 let proposal = current
2400 .next_for_participants(new_vnodes, participants)
2401 .map_err(|error| error.to_string())?;
2402 return authorize_recovery_successor_owned(
2403 Arc::clone(&db),
2404 Arc::clone(&store),
2405 Arc::clone(&controller),
2406 current.clone(),
2407 proposal,
2408 config.checkpoint_timeout,
2409 reason,
2410 )
2411 .await;
2412 }
2413
2414 execute_graceful_rotation_owned(
2415 db,
2416 controller,
2417 store,
2418 registry,
2419 current,
2420 new_vnodes,
2421 participants,
2422 config,
2423 )
2424 .await
2425 })
2426}
2427
2428#[cfg(test)]
2429async fn try_rebalance(
2430 db: &Arc<LaminarDB>,
2431 controller: &Arc<ClusterController>,
2432 store: &Arc<AssignmentSnapshotStore>,
2433 registry: &Arc<VnodeRegistry>,
2434 live: &[NodeId],
2435 config: RebalanceConfig,
2436) -> Result<Option<u64>, String> {
2437 try_rebalance_owned(
2438 Arc::clone(db),
2439 Arc::clone(controller),
2440 Arc::clone(store),
2441 Arc::clone(registry),
2442 live.to_vec(),
2443 config,
2444 )
2445 .await
2446}
2447
2448fn audit_assignment_snapshot_authority_owned(
2449 store: Arc<AssignmentSnapshotStore>,
2450 controller: Arc<ClusterController>,
2451 snapshot: AssignmentSnapshot,
2452) -> futures::future::BoxFuture<'static, Result<(), String>> {
2453 Box::pin(async move {
2454 audit_assignment_snapshot_authority(&store, Some(&controller), &snapshot).await
2455 })
2456}
2457
2458fn reconcile_pending_recovery_decision_owned(
2459 db: Arc<LaminarDB>,
2460 store: Arc<AssignmentSnapshotStore>,
2461 controller: Arc<ClusterController>,
2462 current: AssignmentSnapshot,
2463 operation_timeout: Duration,
2464) -> futures::future::BoxFuture<'static, Result<Option<u64>, String>> {
2465 Box::pin(async move {
2466 reconcile_pending_recovery_decision(&db, &store, &controller, ¤t, operation_timeout)
2467 .await
2468 })
2469}
2470
2471fn authorize_recovery_successor_owned(
2472 db: Arc<LaminarDB>,
2473 store: Arc<AssignmentSnapshotStore>,
2474 controller: Arc<ClusterController>,
2475 current: AssignmentSnapshot,
2476 proposal: AssignmentSnapshot,
2477 operation_timeout: Duration,
2478 reason: String,
2479) -> futures::future::BoxFuture<'static, Result<Option<u64>, String>> {
2480 Box::pin(async move {
2481 authorize_recovery_successor(
2482 &db,
2483 &store,
2484 &controller,
2485 ¤t,
2486 proposal,
2487 operation_timeout,
2488 &reason,
2489 )
2490 .await
2491 })
2492}
2493
2494async fn predecessor_cut_unavailability(
2495 controller: &ClusterController,
2496 current: &AssignmentSnapshot,
2497 current_owners: &[NodeId],
2498 live: &[NodeId],
2499) -> Option<String> {
2500 let participant_ids: Vec<u64> = current
2501 .participants
2502 .iter()
2503 .map(|participant| participant.node_id)
2504 .collect();
2505 match controller
2506 .recovery_participant_incarnations(&participant_ids)
2507 .await
2508 {
2509 Ok(observed) if observed == current.participants => {}
2510 Ok(_) => {
2511 return Some("the predecessor process incarnation roster changed".into());
2512 }
2513 Err(error) => {
2514 return Some(format!(
2515 "the predecessor process incarnation roster is unavailable: {error}"
2516 ));
2517 }
2518 }
2519
2520 let members = controller.members_watch().borrow().clone();
2521 let owners: std::collections::BTreeSet<NodeId> = current_owners.iter().copied().collect();
2522 for owner in owners {
2523 if controller.is_unresponsive(owner) {
2524 return Some(format!(
2525 "predecessor owner {owner} cannot certify the source cut"
2526 ));
2527 }
2528 if live.contains(&owner) {
2529 continue;
2530 }
2531 let can_drain = members.iter().any(|member| {
2532 member.id == owner && matches!(member.state, NodeState::Active | NodeState::Draining)
2533 });
2534 if !can_drain {
2535 return Some(format!(
2536 "predecessor owner {owner} cannot certify the source cut"
2537 ));
2538 }
2539 }
2540 None
2541}
2542
2543fn successor_participant_ids(owners: &[NodeId]) -> Vec<u64> {
2544 owners
2545 .iter()
2546 .map(|owner| owner.0)
2547 .collect::<std::collections::BTreeSet<_>>()
2548 .into_iter()
2549 .collect()
2550}
2551
2552fn successor_participants(
2553 owners: &[NodeId],
2554 available: &[CheckpointParticipant],
2555) -> Result<Vec<CheckpointParticipant>, String> {
2556 let participant_ids = successor_participant_ids(owners);
2557 let participant_set: std::collections::BTreeSet<u64> =
2558 participant_ids.iter().copied().collect();
2559 let participants = available
2560 .iter()
2561 .copied()
2562 .filter(|participant| participant_set.contains(&participant.node_id))
2563 .collect::<Vec<_>>();
2564 if participants.len() != participant_ids.len() {
2565 return Err("successor owner roster lost a lease-validated process identity".into());
2566 }
2567 Ok(participants)
2568}
2569
2570fn retain_recovery_predecessors(
2571 assignment: &mut [NodeId],
2572 predecessor: &[CheckpointParticipant],
2573 successors: &[CheckpointParticipant],
2574) -> Result<(), String> {
2575 let successor_processes = successors
2576 .iter()
2577 .map(|participant| (participant.node_id, participant.boot_incarnation))
2578 .collect::<std::collections::BTreeMap<_, _>>();
2579 let retained = predecessor
2580 .iter()
2581 .filter(|participant| {
2582 successor_processes.get(&participant.node_id) == Some(&participant.boot_incarnation)
2583 })
2584 .map(|participant| NodeId(participant.node_id))
2585 .collect::<std::collections::BTreeSet<_>>();
2586 let mut counts = assignment.iter().copied().fold(
2587 std::collections::BTreeMap::<NodeId, usize>::new(),
2588 |mut counts, owner| {
2589 *counts.entry(owner).or_default() += 1;
2590 counts
2591 },
2592 );
2593
2594 for participant in retained.iter().copied() {
2595 if counts.get(&participant).copied().unwrap_or_default() != 0 {
2596 continue;
2597 }
2598 let mut donor = None;
2599 for (index, owner) in assignment.iter().copied().enumerate() {
2600 let owner_count = counts.get(&owner).copied().unwrap_or_default();
2601 if retained.contains(&owner) && owner_count <= 1 {
2602 continue;
2603 }
2604 if donor.is_none_or(|(_, best_count)| owner_count > best_count) {
2605 donor = Some((index, owner_count));
2606 }
2607 }
2608 let Some((index, _)) = donor else {
2609 return Err(format!(
2610 "recovery placement cannot retain healthy predecessor {participant}"
2611 ));
2612 };
2613 let displaced = assignment[index];
2614 assignment[index] = participant;
2615 *counts
2616 .get_mut(&displaced)
2617 .expect("assignment owner has a placement count") -= 1;
2618 *counts.entry(participant).or_default() += 1;
2619 }
2620 Ok(())
2621}
2622
2623async fn pre_rotation_checkpoint(
2626 db: &Arc<LaminarDB>,
2627 config: RebalanceConfig,
2628) -> Result<bool, String> {
2629 let ckpt = tokio::time::timeout(config.checkpoint_timeout, db.checkpoint())
2630 .await
2631 .map_err(|_| {
2632 format!(
2633 "pre-rotation checkpoint did not complete within {}s",
2634 config.checkpoint_timeout.as_secs()
2635 )
2636 })?
2637 .map_err(|e| e.to_string())?;
2638 Ok(ckpt.success)
2639}
2640
2641async fn await_drain_quorum(
2645 controller: &Arc<ClusterController>,
2646 transition: &AssignmentDrainTransition,
2647 deadline: tokio::time::Instant,
2648) -> bool {
2649 tokio::time::timeout_at(deadline, async {
2650 loop {
2651 match controller.drain_ack_quorum_reached(transition).await {
2652 Ok(true) => return,
2653 Ok(false) => {}
2654 Err(error) => {
2655 debug!(%error, version = transition.target.assignment_version, "durable drain quorum observation failed; retrying");
2656 }
2657 }
2658 tokio::time::sleep(Duration::from_millis(100)).await;
2659 }
2660 })
2661 .await
2662 .is_ok()
2663}
2664
2665async fn finalize_drain_snapshot(
2669 db: &Arc<LaminarDB>,
2670 store: &Arc<AssignmentSnapshotStore>,
2671 controller: &ClusterController,
2672 draining: &AssignmentSnapshot,
2673 predecessor: &AssignmentSnapshot,
2674 requested_verdict: AssignmentDrainVerdict,
2675 config: RebalanceConfig,
2676) -> Result<Option<u64>, String> {
2677 let transition = draining
2678 .drain_transition
2679 .as_ref()
2680 .ok_or_else(|| "draining assignment has no exact transition".to_string())?;
2681 if predecessor
2682 .assignment_fence()
2683 .map_err(|error| error.to_string())?
2684 != transition.predecessor
2685 {
2686 return Err("drain finalization predecessor does not match the transition".into());
2687 }
2688 let deciding_proof = controller
2689 .capture_leader_proof()
2690 .ok_or_else(|| "drain finalization requires a current leader proof".to_string())?;
2691 let requested =
2692 AssignmentDrainDecision::new(transition, deciding_proof.clone(), requested_verdict)
2693 .map_err(|error| error.clone())?;
2694 let authority = controller
2695 .checkpoint_authority()
2696 .map_err(|error| error.to_string())?;
2697 let decision = match authority
2698 .record_assignment_drain_decision(&deciding_proof, requested)
2699 .await
2700 .map_err(|error| error.to_string())?
2701 {
2702 RecordAssignmentDrainDecisionResult::Created(decision)
2703 | RecordAssignmentDrainDecisionResult::Unchanged(decision) => decision,
2704 RecordAssignmentDrainDecisionResult::Conflict { winner } => {
2705 warn!(
2706 requested = ?requested_verdict,
2707 winner = ?winner.verdict,
2708 version = draining.version,
2709 "another authority-sequenced drain decision already won"
2710 );
2711 winner
2712 }
2713 };
2714 if decision.transition != *transition {
2715 return Err(format!(
2716 "authority decision for assignment {} binds a different drain transition",
2717 draining.version
2718 ));
2719 }
2720 let proposal = match decision.verdict {
2721 AssignmentDrainVerdict::Commit => draining
2722 .committed_target()
2723 .map_err(|error| error.to_string())?,
2724 AssignmentDrainVerdict::Abort => draining
2725 .aborted_target(predecessor)
2726 .map_err(|error| error.to_string())?,
2727 };
2728
2729 let durable = match store
2730 .finalize_drain(draining, &proposal)
2731 .await
2732 .map_err(|error| error.to_string())?
2733 {
2734 RotateOutcome::Rotated => proposal,
2735 RotateOutcome::Conflict(winner) => *winner,
2736 };
2737 let source_outcome = finalized_drain_outcome(transition, &durable)?;
2738 let authority_outcome = match decision.verdict {
2739 AssignmentDrainVerdict::Commit => laminar_connectors::connector::SourceDrainOutcome::Commit,
2740 AssignmentDrainVerdict::Abort => laminar_connectors::connector::SourceDrainOutcome::Abort,
2741 };
2742 if source_outcome != authority_outcome {
2743 return Err(format!(
2744 "materialized assignment {} conflicts with the authority drain decision",
2745 durable.version
2746 ));
2747 }
2748
2749 let version = durable.version;
2750 let adoption_deadline = tokio::time::Instant::now() + config.checkpoint_timeout;
2751 let adoption = db
2752 .adopt_assignment_snapshot(durable, adoption_deadline)
2753 .await
2754 .map_err(|error| error.to_string())?;
2755 log_adoption("rebalance-drain-finalize", &adoption);
2756 if local_drain_participant(controller, transition).is_some() {
2757 db.resolve_local_source_drain(
2758 transition.id(),
2759 source_outcome,
2760 tokio::time::Instant::now() + config.drain_ack_timeout,
2761 )
2762 .await
2763 .map_err(|error| error.to_string())?;
2764 }
2765 controller.publish_checkpoint_drain_transition(None);
2766 let oldest_retained = version.saturating_sub(1);
2767 match store.prune_before(oldest_retained).await {
2768 Ok(()) => {
2769 if let Err(error) = authority
2770 .prune_assignment_drain_decisions_before(&deciding_proof, oldest_retained)
2771 .await
2772 {
2773 warn!(%error, "assignment drain authority prune failed after snapshot prune");
2774 }
2775 }
2776 Err(error) => {
2777 warn!(%error, "snapshot prune failed after drain finalization");
2778 }
2779 }
2780 Ok(Some(version))
2781}
2782
2783pub(crate) async fn settle_source_drain_before_recovery(
2786 db: &Arc<LaminarDB>,
2787 controller: &ClusterController,
2788 config: RebalanceConfig,
2789) -> Result<Option<u64>, String> {
2790 let published_transition = controller.checkpoint_drain_transition();
2791 let Some(store) = db.assignment_snapshot_store.lock().clone() else {
2792 return if published_transition.is_some() {
2793 Err("recovery source-drain settlement has no assignment store".into())
2794 } else {
2795 Ok(None)
2796 };
2797 };
2798 let Some(draining) = store.load().await.map_err(|error| error.to_string())? else {
2799 return Ok(None);
2800 };
2801 if !draining.draining {
2802 return if published_transition.is_some() {
2803 Err("local source-drain authority outlived its durable draining assignment".into())
2804 } else {
2805 Ok(None)
2806 };
2807 }
2808 let transition = draining
2809 .drain_transition
2810 .as_ref()
2811 .ok_or_else(|| "draining assignment has no exact source transition".to_string())?;
2812 if published_transition.as_ref() != Some(transition) {
2813 return Err("durable and locally published source-drain transitions differ".into());
2814 }
2815 let predecessor = store
2816 .load_version(transition.predecessor.assignment_version)
2817 .await
2818 .map_err(|error| error.to_string())?
2819 .ok_or_else(|| {
2820 format!(
2821 "source-drain predecessor assignment {} is unavailable during recovery",
2822 transition.predecessor.assignment_version
2823 )
2824 })?;
2825 finalize_drain_snapshot(
2826 db,
2827 &store,
2828 controller,
2829 &draining,
2830 &predecessor,
2831 AssignmentDrainVerdict::Abort,
2832 config,
2833 )
2834 .await
2835}
2836
2837async fn adopt_any(
2839 db: &Arc<LaminarDB>,
2840 store: &AssignmentSnapshotStore,
2841 controller: &ClusterController,
2842 snap: AssignmentSnapshot,
2843 deadline: tokio::time::Instant,
2844) -> Result<(), String> {
2845 tokio::time::timeout_at(
2846 deadline,
2847 audit_assignment_snapshot_authority(store, Some(controller), &snap),
2848 )
2849 .await
2850 .map_err(|_| format!("assignment {} authority audit timed out", snap.version))??;
2851 if snap.draining {
2852 db.validate_source_drain_snapshot(&snap)
2853 .map_err(|error| error.to_string())?;
2854 } else {
2855 let adoption = db
2856 .adopt_assignment_snapshot(snap, deadline)
2857 .await
2858 .map_err(|e| e.to_string())?;
2859 log_adoption("rebalance-conflict", &adoption);
2860 }
2861 Ok(())
2862}
2863
2864#[cfg(test)]
2865mod tests;