Skip to main content

laminar_db/
rebalance.rs

1//! Dynamic vnode rebalance control plane.
2
3#![cfg(feature = "cluster")]
4#![allow(clippy::disallowed_types)] // cold path
5
6use 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/// Tunables for the rebalance control plane.
32#[derive(Debug, Clone, Copy)]
33pub struct RebalanceConfig {
34    /// Interval between snapshot-store polls.
35    pub watcher_poll: Duration,
36    /// Quiet period before a membership change triggers rotation.
37    pub rebalance_debounce: Duration,
38    /// Upper bound on the pre-rotation forced checkpoint.
39    pub checkpoint_timeout: Duration,
40    /// Delay before retrying a failed rotation.
41    pub retry_delay: Duration,
42    /// Bound on waiting for the frozen predecessor roster to ack the draining snapshot before
43    /// the pre-rotation checkpoint; on timeout the rotation aborts. Must exceed `watcher_poll`.
44    pub drain_ack_timeout: Duration,
45    /// Locality tier the placement metrics group by (0 = coarsest).
46    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            // A healthy pre-rotation drain commits in well under a second; the longer budget
55            // absorbs slow external source cuts without weakening the frozen-roster quorum.
56            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    /// Fast timings for tests — 500ms debounce thrashes in production.
66    #[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
80/// Log what moved so there's an audit trail of every rebalance-driven state transfer.
81fn 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    // Cancel first: a compute-cycle read guard may itself be blocked in shuffle admission.
106    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    // A watcher that already owned the adoption lock could have republished and reopened after
111    // the first cancellation. Reassert the full closure while serialized, before draining it.
112    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
137/// Fail closed for a transient durable snapshot read without forcing a new assignment version.
138/// The exact retained certificate can resume after the same durable head is audited again.
139async 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    // Preserve the certificate and its sequence domain, but cancel its active lifetime before
150    // waiting for compute cycles that may be blocked on that lifetime.
151    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    // A watcher that already owned the adoption lock could have republished and resumed after
156    // the first cancellation. Reassert the full suspension while serialized, before draining it.
157    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
202/// Spawn the per-node snapshot watcher. Exits on `shutdown`.
203///
204/// # Panics
205///
206/// The spawned watcher panics if an already-validated draining snapshot lacks its transition.
207struct 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            // The durable head used above predates an authority closure by another adoption.
270            // Keep that closure in force and re-read the head on the next tick.
271            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        // Publish a version-bound, owner-complete fence off the hot path on every node.
350        // The adoption lock and authority revision reject a proof computed from a head that
351        // another task fenced while this watcher was awaiting durable I/O.
352        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                            // Cancel the cached lifetime before waiting for compute cycles that
418                            // may be blocked in send. Suspension is safe if another authority
419                            // path already installed this exact successor; a genuinely higher
420                            // certificate resets the delivery domain during installation.
421                            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                    // An incomplete adoption or process-roster read is not proof that the
459                    // installed certificate was superseded. Close admission while retaining
460                    // its delivery sequence; installing a later concrete successor resets the
461                    // delivery domain from that certified boundary.
462                    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            // The durable namespace is authoritative, so every tick audits it even when the local
601            // version has not changed. This closes the crash window after a successful CAS but
602            // before local adoption.
603            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                // Drain phase: hold every predecessor source at one global input frontier;
664                // ownership is unchanged so the registry version stays put.
665                Ok(Ok(Some(snap))) if snap.draining && snap.version > local => {
666                    // A draining head describes the successor map; checkpoint and shuffle still
667                    // belong to the exact committed predecessor until the pre-rotation cut lands.
668                    // Re-audit that predecessor on every tick, including after this process starts
669                    // while a drain is already in progress. Retaining a previously cached fence
670                    // without this read would trust an object that may no longer be the durable
671                    // predecessor of the observed head.
672                    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                                                // A target-only joining process has no predecessor
706                                                // input authority and is intentionally absent from
707                                                // the receipt quorum.
708                                                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                                                // Receipt production may consume the entire head
732                                                // budget. Re-read and recertify from a fresh
733                                                // durable head on the next tick.
734                                                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
875/// Spawn the per-node snapshot watcher. Exits on shutdown.
876///
877/// # Panics
878///
879/// The spawned watcher panics if an already-validated draining snapshot lacks its transition.
880pub 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}
890/// Per-node checkpoint fence. A reported version is insufficient: the exact current
891/// assignment must be owner-complete over the same canonical participant set.
892async 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
1008/// Audit a materialized assignment-drain outcome against the shared authority sequence.
1009/// Ordinary assignments and still-draining transitions require no terminal decision.
1010///
1011/// # Errors
1012/// Returns an error for missing/malformed authority history or a materialization that disagrees
1013/// with the immutable terminal decision.
1014pub 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
1024/// Select the last committed assignment that a cluster process may adopt during startup.
1025/// A draining head has not transferred ownership, so startup retains its audited predecessor.
1026///
1027/// # Errors
1028///
1029/// Returns an error when the head or retained predecessor lacks valid durable authority.
1030pub 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/// Apply a durable drain outcome before adopting any later assignment. The exact terminal
1270/// generation must be installed before connector resolution; skipping it would make the source
1271/// cut unverifiable.
1272#[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
1413/// Reapply a materialized drain terminal to a source generation created by coordinated recovery.
1414/// The caller keeps recovery and intake fenced until this returns.
1415pub(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        &registry,
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    // Receipt production can block on a connector FIFO. Re-read both the transition and its
1638    // leader lease before making that receipt visible to the frozen predecessor quorum.
1639    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
1646/// Publish per-domain owner counts. Resets the gauge so disappeared domains don't leave stale series.
1647fn 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
1676/// Spawn the leader-gated rebalance controller. Runs on every node;
1677/// leadership is re-checked after the debounce.
1678pub 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(())) => {}       // another change; keep waiting
1715                                Ok(Err(_)) => return,  // sender dropped
1716                                Err(_) => break,        // quiet period elapsed
1717                            }
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                // Assignable = Active, non-draining — Draining/Suspected nodes never receive vnodes.
1729                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(&registry),
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
1762/// Poll the durable assignment snapshot until `me` owns no vnodes (its
1763/// state has been reassigned elsewhere) or `deadline` elapses. Returns
1764/// true if fully drained. A version materialized from a drain is accepted only with its matching
1765/// shared-authority decision; `None` therefore supports ordinary snapshots but fails closed for a
1766/// drain finalization. Used by a draining node to know when it is safe to exit.
1767pub 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                // Wait for every process in the snapshot's frozen boot roster to durably ack the
2074                // exact source cut. Target-only joiners intentionally do not acknowledge inputs
2075                // they never owned under the predecessor.
2076                let local_receipt = match local_drain_participant(&controller, &transition) {
2077                    Some(participant) => {
2078                        prepare_and_announce_local_drain(
2079                            &db,
2080                            &store,
2081                            &registry,
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                        &registry,
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                        &current,
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                    &registry,
2134                    &drain,
2135                    &controller,
2136                    tokio::time::Instant::now() + config.checkpoint_timeout,
2137                )
2138                .await?;
2139                // Abort the drain on failure OR timeout (not just Ok(false)) — a bare
2140                // `?` here would leave nodes stuck draining.
2141                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                        &registry,
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                        &current,
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                    &registry,
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                    &current,
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            // A writer can fail after its durable CAS succeeds but before local adoption. Reconcile
2260            // that durable fact before comparing it with desired placement; otherwise an
2261            // already-correct owner map would be mistaken for a no-op forever.
2262            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            // The retained predecessor may name an owner process that died during the drain. Close
2325            // intake before publishing the rollback; if its old roster is no longer live, it remains
2326            // uncertified while the next audit rotates that dead stable owner from the committed cut.
2327            close_local_assignment_authority(&db, Some(controller.as_ref()), head_deadline).await?;
2328            return finalize_drain_snapshot(
2329                &db,
2330                &store,
2331                &controller,
2332                &current,
2333                &prior,
2334                AssignmentDrainVerdict::Abort,
2335                config,
2336            )
2337            .await;
2338        }
2339
2340        // Settle an existing drain before considering the current placement input. An empty live set
2341        // must not strand a durable transition without its authority-sequenced outcome.
2342        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        // The successor checkpoint quorum is the exact successor owner set. Deriving it from the
2377        // current membership/checkpoint roster retains a gracefully departing process after all of
2378        // its vnodes move away; the first checkpoint after that process exits can then never reach
2379        // quorum. The predecessor roster remains authoritative for the drain cut and is carried by
2380        // `current`.
2381        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, &current, &current_owners, &successors)
2390                .await;
2391        if let Some(reason) = recovery_reason {
2392            retain_recovery_predecessors(
2393                &mut new_assignment,
2394                &current.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, &current, 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            &current,
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
2623/// Run the pre-rotation checkpoint with the configured timeout. `Ok(true)` on a
2624/// successful seal, `Ok(false)` if the checkpoint ran but did not succeed.
2625async 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
2641/// Wait until every exact process in the draining assignment certificate has durably proved it
2642/// reached the global input cut. Durable read errors are retried within the existing deadline;
2643/// they never become quorum.
2644async 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
2665/// Settle a draining generation without changing the target version certified by source receipts.
2666/// The terminal verdict first enters the shared leader/checkpoint authority sequence; the snapshot
2667/// store then publishes that immutable verdict as the assignment materialization.
2668async 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
2783/// Settle a durable drain before coordinated recovery freezes a new process-local source cut.
2784/// Recovery restarts source tasks, so a pre-restart receipt can never authorize a later Release.
2785pub(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
2837/// Adopt a snapshot whether it is a draining or a committed one.
2838async 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;