Skip to main content

laminar_db/rebalance/
mod.rs

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