Skip to main content

laminar_core/state/
vnode.rs

1//! [`VnodeRegistry`] — runtime-configurable virtual node topology.
2//!
3//! Replaces the compile-time `VNODE_COUNT` constant that previously
4//! lived in `laminar-storage`. A registry owns:
5//!
6//! - the runtime-selected vnode count,
7//! - the node-per-vnode assignment (for distributed modes),
8//! - a monotonically increasing `assignment_version` used by
9//!   [`ObjectStoreBackend`](super::object_store::ObjectStoreBackend) to
10//!   fence out stale writers.
11//!
12//! Vnode assignment is derived from the row's primary key via
13//! [`key_hash`] (xxh3) and modulo `vnode_count`. Connectors that
14//! need a vnode ID for an event call [`VnodeRegistry::vnode_for_key`].
15
16use std::collections::BTreeMap;
17use std::fmt;
18use std::num::NonZeroU16;
19use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
20use std::sync::Arc;
21
22use parking_lot::{RwLock, RwLockReadGuard};
23use serde::{Deserialize, Serialize};
24
25use crate::checkpoint::{CheckpointWatermark, CommittedSourceHandoff, SourceHandoffState};
26use crate::state::CheckpointAttempt;
27
28/// Durable encoding and hashing contract used to map keys to key groups.
29///
30/// Any change to Arrow row encoding, key hashing, or modulo mapping requires a
31/// version bump and a coordinated compatibility fence. Placement is not part of
32/// this ABI: durable assignment publications are the ownership authority.
33pub const PARTITIONING_ABI_VERSION: u16 = 1;
34
35/// Validated number of stable key groups in a pipeline.
36///
37/// Zero and values above [`u16::MAX`] are rejected. The compact upper bound keeps
38/// ownership metadata bounded while providing substantially more rescale slots
39/// than a production pipeline should need.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
41#[serde(transparent)]
42pub struct KeyGroupCount(NonZeroU16);
43
44/// Fixed key-group count for embedded and single-node runtimes.
45pub const LOCAL_KEY_GROUP_COUNT: KeyGroupCount = KeyGroupCount(NonZeroU16::MIN);
46
47/// Default key-group count for cluster runtimes.
48pub const DEFAULT_CLUSTER_KEY_GROUP_COUNT: KeyGroupCount = match NonZeroU16::new(256) {
49    Some(value) => KeyGroupCount(value),
50    None => unreachable!(),
51};
52
53/// Largest key-group count representable by the persisted checkpoint ABI.
54pub const MAX_KEY_GROUP_COUNT: u32 = u16::MAX as u32;
55
56impl KeyGroupCount {
57    /// Build from a nonzero value.
58    #[must_use]
59    pub const fn new(value: NonZeroU16) -> Self {
60        Self(value)
61    }
62
63    /// Exact underlying count.
64    #[must_use]
65    pub const fn get(self) -> u16 {
66        self.0.get()
67    }
68
69    /// Exact nonzero representation.
70    #[must_use]
71    pub const fn into_non_zero(self) -> NonZeroU16 {
72        self.0
73    }
74}
75
76impl fmt::Display for KeyGroupCount {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        self.get().fmt(f)
79    }
80}
81
82/// A key-group count outside the supported `1..=65535` range.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
84#[error("key-group count {value} is outside 1..={}", u16::MAX)]
85pub struct InvalidKeyGroupCount {
86    value: u32,
87}
88
89impl InvalidKeyGroupCount {
90    /// Rejected input value.
91    #[must_use]
92    pub const fn value(self) -> u32 {
93        self.value
94    }
95}
96
97impl TryFrom<u16> for KeyGroupCount {
98    type Error = InvalidKeyGroupCount;
99
100    fn try_from(value: u16) -> Result<Self, Self::Error> {
101        NonZeroU16::new(value)
102            .map(Self)
103            .ok_or(InvalidKeyGroupCount {
104                value: u32::from(value),
105            })
106    }
107}
108
109impl TryFrom<u32> for KeyGroupCount {
110    type Error = InvalidKeyGroupCount;
111
112    fn try_from(value: u32) -> Result<Self, Self::Error> {
113        let narrowed = u16::try_from(value).map_err(|_| InvalidKeyGroupCount { value })?;
114        Self::try_from(narrowed).map_err(|_| InvalidKeyGroupCount { value })
115    }
116}
117
118impl From<NonZeroU16> for KeyGroupCount {
119    fn from(value: NonZeroU16) -> Self {
120        Self::new(value)
121    }
122}
123
124impl From<KeyGroupCount> for NonZeroU16 {
125    fn from(value: KeyGroupCount) -> Self {
126        value.into_non_zero()
127    }
128}
129
130impl From<KeyGroupCount> for u16 {
131    fn from(value: KeyGroupCount) -> Self {
132        value.get()
133    }
134}
135
136impl From<KeyGroupCount> for u32 {
137    fn from(value: KeyGroupCount) -> Self {
138        u32::from(value.get())
139    }
140}
141
142impl From<KeyGroupCount> for usize {
143    fn from(value: KeyGroupCount) -> Self {
144        usize::from(value.get())
145    }
146}
147
148/// Unique identifier for a node. Also the owner id for vnodes; cluster
149/// membership and vnode ownership identify the same thing.
150#[derive(
151    Debug,
152    Clone,
153    Copy,
154    PartialEq,
155    Eq,
156    PartialOrd,
157    Ord,
158    Hash,
159    Serialize,
160    Deserialize,
161    rkyv::Archive,
162    rkyv::Serialize,
163    rkyv::Deserialize,
164)]
165pub struct NodeId(pub u64);
166
167impl NodeId {
168    /// Sentinel meaning "unassigned".
169    pub const UNASSIGNED: Self = Self(0);
170
171    /// True if this is the unassigned sentinel.
172    #[must_use]
173    pub const fn is_unassigned(&self) -> bool {
174        self.0 == 0
175    }
176}
177
178impl fmt::Display for NodeId {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        write!(f, "node-{}", self.0)
181    }
182}
183
184/// Per-vnode lifecycle state. Distinct from ownership: a vnode this node
185/// owns can still be [`Restoring`](Self::Restoring) while its committed
186/// state is being rehydrated from durable storage after a rebalance.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum VnodeLifecycleState {
189    /// Fully owned and serving — state is consistent.
190    Active,
191    /// Newly acquired in a rebalance; durable state is still being
192    /// applied. Operators suppress emission for keys in this vnode until
193    /// it flips back to [`Active`](Self::Active).
194    Restoring,
195}
196
197impl VnodeLifecycleState {
198    const ACTIVE: u8 = 0;
199    const RESTORING: u8 = 1;
200
201    const fn to_u8(self) -> u8 {
202        match self {
203            Self::Active => Self::ACTIVE,
204            Self::Restoring => Self::RESTORING,
205        }
206    }
207}
208
209enum SourceHandoffPublication {
210    Clear,
211    Replace(Arc<CommittedSourceHandoff>),
212    Carry,
213}
214
215/// One immutable publication of vnode ownership and the source cursors that
216/// belong to the same assignment version.
217#[derive(Clone)]
218pub struct VnodeAssignmentSnapshot {
219    version: u64,
220    owners: Arc<[NodeId]>,
221    owner_changed_versions: Arc<[u64]>,
222    source_handoff: Option<Arc<CommittedSourceHandoff>>,
223    source_handoff_installed_version: Option<u64>,
224}
225
226impl VnodeAssignmentSnapshot {
227    /// Monotonic assignment version for this publication.
228    #[must_use]
229    pub const fn version(&self) -> u64 {
230        self.version
231    }
232
233    /// Immutable vnode-owner vector for this publication.
234    #[must_use]
235    pub fn owners(&self) -> &[NodeId] {
236        &self.owners
237    }
238
239    /// Version of the last ownership change for `vnode`.
240    #[must_use]
241    pub fn owner_changed_version(&self, vnode: u32) -> Option<u64> {
242        self.owner_changed_versions.get(vnode as usize).copied()
243    }
244
245    /// Whether this publication carries a durable decided checkpoint cut.
246    #[must_use]
247    pub const fn has_committed_handoff(&self) -> bool {
248        self.source_handoff.is_some()
249    }
250
251    /// Validated committed state for one source, without cloning its checkpoint maps.
252    #[must_use]
253    pub fn source_handoff(&self, source: &str) -> Option<&SourceHandoffState> {
254        self.source_handoff
255            .as_deref()
256            .and_then(|handoff| handoff.source(source))
257    }
258
259    /// Complete committed source cut carried by this publication, if present.
260    #[must_use]
261    pub fn committed_source_handoff(&self) -> Option<&CommittedSourceHandoff> {
262        self.source_handoff.as_deref()
263    }
264
265    /// Assignment version that installed the current handoff. Carry-only
266    /// publications preserve the earlier version so runtimes do not restore
267    /// the same recovery cut for an unrelated roster update.
268    #[must_use]
269    pub const fn source_handoff_installed_version(&self) -> Option<u64> {
270        self.source_handoff_installed_version
271    }
272
273    /// Exact checkpoint attempt supplying the committed handoff, if present.
274    #[must_use]
275    pub fn source_handoff_attempt(&self) -> Option<CheckpointAttempt> {
276        self.source_handoff
277            .as_deref()
278            .map(CommittedSourceHandoff::attempt)
279    }
280
281    /// Assignment version sealed by the committed handoff, if present.
282    #[must_use]
283    pub fn source_handoff_assignment_version(&self) -> Option<u64> {
284        self.source_handoff
285            .as_deref()
286            .map(CommittedSourceHandoff::checkpoint_assignment_version)
287    }
288
289    /// Explicit cluster event-time status at the committed cut, if present.
290    #[must_use]
291    pub fn source_handoff_cluster_watermark(&self) -> Option<CheckpointWatermark> {
292        self.source_handoff
293            .as_deref()
294            .map(CommittedSourceHandoff::cluster_watermark)
295    }
296}
297
298/// Read guard that pins one assignment publication while a source captures a
299/// data batch or checkpoint cursor.
300pub struct VnodeAssignmentReadGuard<'a>(RwLockReadGuard<'a, VnodeAssignmentSnapshot>);
301
302impl std::ops::Deref for VnodeAssignmentReadGuard<'_> {
303    type Target = VnodeAssignmentSnapshot;
304
305    fn deref(&self) -> &Self::Target {
306        &self.0
307    }
308}
309
310fn changed_owner_versions(
311    current: &VnodeAssignmentSnapshot,
312    next_owners: &[NodeId],
313    next_version: u64,
314) -> Arc<[u64]> {
315    let skipped_generation = next_version > current.version.saturating_add(1);
316    current
317        .owners
318        .iter()
319        .zip(next_owners)
320        .enumerate()
321        .map(|(vnode, (current_owner, next_owner))| {
322            if current_owner == next_owner && !skipped_generation {
323                current.owner_changed_versions[vnode]
324            } else {
325                next_version
326            }
327        })
328        .collect::<Vec<_>>()
329        .into()
330}
331
332/// Runtime registry of vnode topology and assignment.
333pub struct VnodeRegistry {
334    vnode_count: u32,
335    assignment: RwLock<VnodeAssignmentSnapshot>,
336    /// Lock-free observation fence for hot runtime readiness checks. The
337    /// authoritative version also lives inside `assignment`, where it is bound
338    /// to owners and handoff cursors.
339    assignment_version: AtomicU64,
340    /// Per-vnode lifecycle, indexed by vnode id. `0` = `Active`,
341    /// `1` = `Restoring`. Lock-free: rebalance flips individual entries
342    /// and the hot emission gate reads them without taking a lock. Not
343    /// serialized — rebuilt (all `Active`) from the `AssignmentSnapshot`
344    /// on boot, so adding it never touches a wire format.
345    lifecycle: Arc<[AtomicU8]>,
346}
347
348impl std::fmt::Debug for VnodeRegistry {
349    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350        f.debug_struct("VnodeRegistry")
351            .field("vnode_count", &self.vnode_count)
352            .field(
353                "assignment_version",
354                &self.assignment_version.load(Ordering::Relaxed),
355            )
356            .finish_non_exhaustive()
357    }
358}
359
360impl VnodeRegistry {
361    /// Create a registry sized for `vnode_count` vnodes, all marked
362    /// as [`NodeId::UNASSIGNED`]. The assignment version starts at 1.
363    ///
364    /// # Panics
365    /// Panics if `vnode_count == 0`.
366    #[must_use]
367    pub fn new(vnode_count: u32) -> Self {
368        assert!(vnode_count > 0, "vnode_count must be > 0");
369        let assignment: Arc<[NodeId]> =
370            std::iter::repeat_n(NodeId::UNASSIGNED, vnode_count as usize)
371                .collect::<Vec<_>>()
372                .into();
373        Self {
374            vnode_count,
375            assignment: RwLock::new(VnodeAssignmentSnapshot {
376                version: 1,
377                owners: assignment,
378                owner_changed_versions: std::iter::repeat_n(1, vnode_count as usize)
379                    .collect::<Vec<_>>()
380                    .into(),
381                source_handoff: None,
382                source_handoff_installed_version: None,
383            }),
384            assignment_version: AtomicU64::new(1),
385            lifecycle: new_lifecycle(vnode_count),
386        }
387    }
388
389    /// Create a registry with every vnode unassigned at version 0, so the first stored
390    /// assignment snapshot (version >= 1) adopts through the standard rotation path.
391    ///
392    /// # Panics
393    /// Panics if `vnode_count == 0`.
394    #[must_use]
395    pub fn new_unassigned(vnode_count: u32) -> Self {
396        let registry = Self::new(vnode_count);
397        {
398            let mut initial = registry.assignment.write();
399            initial.version = 0;
400            initial.owner_changed_versions = std::iter::repeat_n(0, vnode_count as usize)
401                .collect::<Vec<_>>()
402                .into();
403        }
404        registry.assignment_version.store(0, Ordering::Release);
405        registry
406    }
407
408    /// Create a registry where every vnode is owned by the same node.
409    ///
410    /// Used by single-instance / embedded deployments.
411    ///
412    /// # Panics
413    /// Panics if `vnode_count == 0`.
414    #[must_use]
415    pub fn single_owner(vnode_count: u32, owner: NodeId) -> Self {
416        assert!(vnode_count > 0, "vnode_count must be > 0");
417        let assignment: Arc<[NodeId]> = std::iter::repeat_n(owner, vnode_count as usize)
418            .collect::<Vec<_>>()
419            .into();
420        Self {
421            vnode_count,
422            assignment: RwLock::new(VnodeAssignmentSnapshot {
423                version: 1,
424                owners: assignment,
425                owner_changed_versions: std::iter::repeat_n(1, vnode_count as usize)
426                    .collect::<Vec<_>>()
427                    .into(),
428                source_handoff: None,
429                source_handoff_installed_version: None,
430            }),
431            assignment_version: AtomicU64::new(1),
432            lifecycle: new_lifecycle(vnode_count),
433        }
434    }
435
436    /// Number of vnodes.
437    #[must_use]
438    pub fn vnode_count(&self) -> u32 {
439        self.vnode_count
440    }
441
442    /// Current monotonic assignment version.
443    #[must_use]
444    pub fn assignment_version(&self) -> u64 {
445        self.assignment_version.load(Ordering::Acquire)
446    }
447
448    /// Owner of a given vnode. Returns [`NodeId::UNASSIGNED`] if the
449    /// vnode is out of range or unassigned.
450    #[must_use]
451    pub fn owner(&self, vnode: u32) -> NodeId {
452        if vnode >= self.vnode_count {
453            return NodeId::UNASSIGNED;
454        }
455        self.assignment.read().owners[vnode as usize]
456    }
457
458    /// Snapshot the current assignment vector. Cheap — internally an
459    /// `Arc::clone`.
460    #[must_use]
461    pub fn snapshot(&self) -> Arc<[NodeId]> {
462        Arc::clone(&self.assignment.read().owners)
463    }
464
465    /// Consistent ownership, version, and source-handoff publication.
466    #[must_use]
467    pub fn versioned_snapshot(&self) -> VnodeAssignmentSnapshot {
468        self.assignment.read().clone()
469    }
470
471    /// Pin the current publication for a short, non-blocking source capture.
472    #[must_use]
473    pub fn read_assignment(&self) -> VnodeAssignmentReadGuard<'_> {
474        VnodeAssignmentReadGuard(self.assignment.read())
475    }
476
477    /// Replace the full assignment and bump the version.
478    ///
479    /// # Panics
480    /// Panics if `new_assignment.len() != self.vnode_count`.
481    pub fn set_assignment(&self, new_assignment: Arc<[NodeId]>) {
482        assert_eq!(
483            new_assignment.len(),
484            self.vnode_count as usize,
485            "assignment length mismatch: got {}, expected {}",
486            new_assignment.len(),
487            self.vnode_count,
488        );
489        let mut current = self.assignment.write();
490        let version = current
491            .version
492            .checked_add(1)
493            .expect("assignment version overflow");
494        let owner_changed_versions = changed_owner_versions(&current, &new_assignment, version);
495        *current = VnodeAssignmentSnapshot {
496            version,
497            owners: new_assignment,
498            owner_changed_versions,
499            source_handoff: None,
500            source_handoff_installed_version: None,
501        };
502        self.assignment_version
503            .store(current.version, Ordering::Release);
504    }
505
506    /// Replace the full assignment and set the version to `version`
507    /// atomically. For recovery paths that must restore the registry to
508    /// a persisted fence generation, not a fresh bump.
509    ///
510    /// # Panics
511    /// Panics on length mismatch, or if `version` is less than the
512    /// current one (assignment versions are monotonic).
513    pub fn set_assignment_and_version(&self, new_assignment: Arc<[NodeId]>, version: u64) {
514        self.publish_assignment(new_assignment, version, SourceHandoffPublication::Clear);
515    }
516
517    /// Atomically publish ownership, its monotonic version, and the sealed
518    /// connector cursors used by sources acquiring partitions in that version.
519    ///
520    /// # Panics
521    /// Panics on length mismatch or version regression.
522    pub fn set_assignment_and_version_with_source_handoff(
523        &self,
524        new_assignment: Arc<[NodeId]>,
525        version: u64,
526        source_handoff: Arc<CommittedSourceHandoff>,
527    ) {
528        self.publish_assignment(
529            new_assignment,
530            version,
531            SourceHandoffPublication::Replace(source_handoff),
532        );
533    }
534
535    /// Publish a version that does not acquire local ownership while retaining
536    /// the prior version-bound handoff until the source has reconciled it.
537    pub fn set_assignment_and_version_carrying_source_handoff(
538        &self,
539        new_assignment: Arc<[NodeId]>,
540        version: u64,
541    ) {
542        self.publish_assignment(new_assignment, version, SourceHandoffPublication::Carry);
543    }
544
545    fn publish_assignment(
546        &self,
547        new_assignment: Arc<[NodeId]>,
548        version: u64,
549        source_handoff: SourceHandoffPublication,
550    ) {
551        assert_eq!(
552            new_assignment.len(),
553            self.vnode_count as usize,
554            "assignment length mismatch: got {}, expected {}",
555            new_assignment.len(),
556            self.vnode_count,
557        );
558        let mut guard = self.assignment.write();
559        let current = guard.version;
560        assert!(
561            version > current,
562            "assignment version must advance: got {version}, current {current}",
563        );
564        let owner_changed_versions = changed_owner_versions(&guard, &new_assignment, version);
565        let (source_handoff, source_handoff_installed_version) = match source_handoff {
566            SourceHandoffPublication::Clear => (None, None),
567            SourceHandoffPublication::Replace(source_handoff) => {
568                (Some(source_handoff), Some(version))
569            }
570            SourceHandoffPublication::Carry => (
571                guard.source_handoff.clone(),
572                guard.source_handoff_installed_version,
573            ),
574        };
575        *guard = VnodeAssignmentSnapshot {
576            version,
577            owners: new_assignment,
578            owner_changed_versions,
579            source_handoff,
580            source_handoff_installed_version,
581        };
582        self.assignment_version.store(version, Ordering::Release);
583    }
584
585    /// Map a primary key to a vnode.
586    #[must_use]
587    pub fn vnode_for_key(&self, key: &[u8]) -> u32 {
588        #[allow(clippy::cast_possible_truncation)]
589        let h = (key_hash(key) % u64::from(self.vnode_count)) as u32;
590        h
591    }
592
593    /// Mark `vnodes` as [`Restoring`](VnodeLifecycleState::Restoring).
594    ///
595    /// Called during a rebalance for the vnodes a node newly acquires,
596    /// before their committed state has been applied. Out-of-range ids
597    /// are ignored.
598    pub fn mark_restoring(&self, vnodes: &[u32]) {
599        self.set_lifecycle(vnodes, VnodeLifecycleState::Restoring);
600    }
601
602    /// Mark `vnodes` as [`Active`](VnodeLifecycleState::Active).
603    ///
604    /// Called once a newly-acquired vnode's state has been applied (or
605    /// immediately for vnodes that had no durable state to restore).
606    /// Out-of-range ids are ignored.
607    pub fn mark_active(&self, vnodes: &[u32]) {
608        self.set_lifecycle(vnodes, VnodeLifecycleState::Active);
609    }
610
611    fn set_lifecycle(&self, vnodes: &[u32], state: VnodeLifecycleState) {
612        let byte = state.to_u8();
613        for &v in vnodes {
614            if let Some(slot) = self.lifecycle.get(v as usize) {
615                slot.store(byte, Ordering::Release);
616            }
617        }
618    }
619
620    /// Whether `vnode` is currently [`Restoring`](VnodeLifecycleState::Restoring).
621    /// Out-of-range ids are reported as not restoring.
622    #[must_use]
623    pub fn is_restoring(&self, vnode: u32) -> bool {
624        self.lifecycle
625            .get(vnode as usize)
626            .is_some_and(|s| s.load(Ordering::Acquire) == VnodeLifecycleState::RESTORING)
627    }
628
629    /// Whether any vnode is currently restoring. Cheap pre-check the
630    /// emission gate uses to skip per-row work in the common case.
631    #[must_use]
632    pub fn any_restoring(&self) -> bool {
633        self.lifecycle
634            .iter()
635            .any(|s| s.load(Ordering::Acquire) == VnodeLifecycleState::RESTORING)
636    }
637
638    /// Vnodes currently [`Restoring`](VnodeLifecycleState::Restoring), ascending.
639    #[must_use]
640    #[allow(clippy::cast_possible_truncation)] // index < vnode_count, which is u32
641    pub fn restoring_vnodes(&self) -> Vec<u32> {
642        self.lifecycle
643            .iter()
644            .enumerate()
645            .filter_map(|(i, s)| {
646                (s.load(Ordering::Acquire) == VnodeLifecycleState::RESTORING).then_some(i as u32)
647            })
648            .collect()
649    }
650}
651
652/// Build a fresh lifecycle array with every vnode [`Active`].
653fn new_lifecycle(vnode_count: u32) -> Arc<[AtomicU8]> {
654    std::iter::repeat_with(|| AtomicU8::new(VnodeLifecycleState::ACTIVE))
655        .take(vnode_count as usize)
656        .collect::<Vec<_>>()
657        .into()
658}
659
660/// Hash a key to a 64-bit value. Used to derive vnode IDs and for any
661/// other keyed-partitioning decisions.
662///
663/// Fixed to xxh3 so all pipeline stages produce the same vnode for the
664/// same key without needing to share a hasher instance.
665#[must_use]
666pub fn key_hash(key: &[u8]) -> u64 {
667    xxhash_rust::xxh3::xxh3_64(key)
668}
669
670/// Build a vnode-to-owner assignment using Rendezvous Hashing (Highest Random Weight).
671///
672/// Deterministic for a given `(vnode_count, peers)` input. Minimizes partition
673/// reshuffling on membership changes (node joins/leaves).
674///
675/// # Panics
676/// Panics if `peers` is empty.
677#[must_use]
678pub fn rendezvous_assignment(vnode_count: u32, peers: &[NodeId]) -> Arc<[NodeId]> {
679    assert!(
680        !peers.is_empty(),
681        "rendezvous_assignment needs at least one peer"
682    );
683    let mut sorted_peers = peers.to_vec();
684    sorted_peers.sort_by_key(|n| n.0);
685
686    let mut assignment = Vec::with_capacity(vnode_count as usize);
687    for v in 0..vnode_count {
688        let mut max_weight = 0;
689        let mut selected_node = sorted_peers[0];
690
691        for &node in &sorted_peers {
692            // Hash the combination of vnode ID and node ID
693            let mut buf = [0u8; 16];
694            buf[0..8].copy_from_slice(&u64::from(v).to_le_bytes());
695            buf[8..16].copy_from_slice(&node.0.to_le_bytes());
696            let weight = xxhash_rust::xxh3::xxh3_64(&buf);
697
698            // Highest weight wins, tie-break by NodeId
699            if weight > max_weight || (weight == max_weight && node.0 > selected_node.0) {
700                max_weight = weight;
701                selected_node = node;
702            }
703        }
704        assignment.push(selected_node);
705    }
706    assignment.into()
707}
708
709/// A node's failure-domain locality: ordered tier values, coarsest first
710/// (e.g. `["us-east-1", "us-east-1a", "r17"]`). Parsed from the node's
711/// `failure_domain` gossip string.
712#[derive(Debug, Clone, Default, PartialEq, Eq)]
713pub struct Locality {
714    tiers: Vec<String>,
715}
716
717impl Locality {
718    /// Build from ordered tier values, coarsest first.
719    #[must_use]
720    pub fn new(tiers: Vec<String>) -> Self {
721        Self { tiers }
722    }
723
724    /// Parse `"region=us-east-1;zone=us-east-1a;rack=r17"` (or a bare label
725    /// `"rack17"`) into its tier values. Tier names are ignored.
726    #[must_use]
727    pub fn parse(s: &str) -> Self {
728        let tiers = s
729            .split(';')
730            .map(str::trim)
731            .filter(|seg| !seg.is_empty())
732            .map(|seg| {
733                seg.split_once('=')
734                    .map_or(seg, |(_, v)| v.trim())
735                    .to_string()
736            })
737            .collect();
738        Self { tiers }
739    }
740
741    /// Failure-domain key at `tier`: the `;`-joined value prefix `0..=tier`.
742    /// Two nodes share a domain iff equal; an unlabeled node yields the empty key.
743    #[must_use]
744    pub fn domain_at(&self, tier: usize) -> String {
745        if self.tiers.is_empty() {
746            return String::new();
747        }
748        let end = tier.min(self.tiers.len() - 1);
749        self.tiers[..=end].join(";")
750    }
751}
752
753/// Resolve each node's failure-domain key at `isolation_tier`.
754fn resolve_domains(nodes: &[(NodeId, Locality)], isolation_tier: usize) -> Vec<(NodeId, String)> {
755    nodes
756        .iter()
757        .map(|(id, loc)| (*id, loc.domain_at(isolation_tier)))
758        .collect()
759}
760
761/// Owner counts per failure domain at `isolation_tier`. The largest value over
762/// `vnode_count` is the blast radius — the share of state that goes `Restoring`
763/// if that one domain fails at once.
764#[must_use]
765pub fn owners_per_domain(
766    owners: &[NodeId],
767    nodes: &[(NodeId, Locality)],
768    isolation_tier: usize,
769) -> BTreeMap<String, u32> {
770    let dom: BTreeMap<NodeId, String> =
771        resolve_domains(nodes, isolation_tier).into_iter().collect();
772    let mut counts = BTreeMap::new();
773    for &o in owners {
774        *counts
775            .entry(dom.get(&o).cloned().unwrap_or_default())
776            .or_default() += 1;
777    }
778    counts
779}
780
781/// Vnodes currently assigned to `owner`.
782///
783/// Used by the checkpoint coordinator to decide which vnodes' durability
784/// markers it is responsible for writing each epoch, and by the leader's
785/// `seal_checkpoint` gate to know the full set to check.
786#[must_use]
787pub fn owned_vnodes(registry: &VnodeRegistry, owner: NodeId) -> Vec<u32> {
788    let assignment = registry.snapshot();
789    assignment
790        .iter()
791        .enumerate()
792        .filter(|(_, assigned)| **assigned == owner)
793        .filter_map(|(vnode, _)| u32::try_from(vnode).ok())
794        .collect()
795}
796
797/// Distinct assigned nodes other than `self_id`, sorted by id — the peer set a
798/// node fans checkpoint barriers and shuffle data out to.
799#[must_use]
800pub fn peer_owners(registry: &VnodeRegistry, self_id: NodeId) -> Vec<NodeId> {
801    let assignment = registry.snapshot();
802    let mut peers: Vec<NodeId> = assignment
803        .iter()
804        .copied()
805        .filter(|o| !o.is_unassigned() && *o != self_id)
806        .collect();
807    peers.sort_unstable();
808    peers.dedup();
809    peers
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815    use crate::checkpoint::{
816        CheckpointAssignmentFence, CheckpointParticipant, ClusterRecoveryCapsule,
817        ConnectorCheckpoint, ParticipantRecoveryRef, PipelineIdentity,
818        CLUSTER_RECOVERY_CAPSULE_VERSION, PIPELINE_IDENTITY_VERSION,
819    };
820
821    fn digest(byte: u8) -> String {
822        format!("{byte:02x}").repeat(32)
823    }
824
825    #[test]
826    fn partitioning_key_group_count_rejects_every_out_of_range_value() {
827        assert_eq!(KeyGroupCount::try_from(0_u16).unwrap_err().value(), 0);
828        assert_eq!(KeyGroupCount::try_from(0_u32).unwrap_err().value(), 0);
829        assert_eq!(
830            KeyGroupCount::try_from(u32::from(u16::MAX) + 1)
831                .unwrap_err()
832                .value(),
833            u32::from(u16::MAX) + 1
834        );
835
836        let one = KeyGroupCount::try_from(1_u16).unwrap();
837        let max = KeyGroupCount::try_from(u32::from(u16::MAX)).unwrap();
838        assert_eq!(u16::from(one), 1);
839        assert_eq!(u32::from(max), u32::from(u16::MAX));
840        assert_eq!(usize::from(max), usize::from(u16::MAX));
841        assert_eq!(NonZeroU16::from(one), NonZeroU16::MIN);
842    }
843
844    #[test]
845    fn partitioning_abi_v1_raw_key_hash_golden_vectors() {
846        assert_eq!(PARTITIONING_ABI_VERSION, 1);
847        let actual = [
848            key_hash(b""),
849            key_hash(b"a"),
850            key_hash(b"laminardb"),
851            key_hash(&[0, 1, 0xff]),
852            key_hash("key-☃".as_bytes()),
853        ];
854        assert_eq!(
855            actual,
856            [
857                3_244_421_341_483_603_138,
858                16_629_034_431_890_738_719,
859                16_801_042_214_008_847_674,
860                10_014_172_824_849_140_082,
861                17_604_077_472_932_801_374,
862            ]
863        );
864    }
865
866    #[test]
867    fn rendezvous_placement_policy_golden_vector() {
868        // Placement can evolve through an assignment transition; this vector
869        // detects accidental churn in the current policy but is not ABI v1.
870        let actual = rendezvous_assignment(12, &[NodeId(7), NodeId(3), NodeId(5)]);
871        assert_eq!(
872            actual.as_ref(),
873            &[
874                NodeId(5),
875                NodeId(7),
876                NodeId(3),
877                NodeId(5),
878                NodeId(5),
879                NodeId(7),
880                NodeId(5),
881                NodeId(5),
882                NodeId(5),
883                NodeId(5),
884                NodeId(5),
885                NodeId(5),
886            ]
887        );
888    }
889
890    fn committed_handoff(
891        source: Option<(&str, ConnectorCheckpoint, Option<i64>)>,
892        cluster_watermark: CheckpointWatermark,
893    ) -> Arc<CommittedSourceHandoff> {
894        let mut source_offsets = BTreeMap::new();
895        let mut source_metadata = BTreeMap::new();
896        let mut source_assignment_versions = BTreeMap::new();
897        let mut source_watermarks = BTreeMap::new();
898        if let Some((source, checkpoint, watermark)) = source {
899            let ConnectorCheckpoint {
900                offsets,
901                metadata,
902                source_assignment_version,
903            } = checkpoint;
904            source_offsets.insert(source.to_string(), offsets.into_iter().collect());
905            source_metadata.insert(source.to_string(), metadata.into_iter().collect());
906            if let Some(assignment_version) = source_assignment_version {
907                source_assignment_versions.insert(source.to_string(), assignment_version);
908            }
909            if let Some(watermark) = watermark {
910                source_watermarks.insert(source.to_string(), watermark);
911            }
912        }
913
914        let participant = CheckpointParticipant {
915            node_id: 7,
916            boot_incarnation: uuid::Uuid::from_u128(77),
917        };
918        let capsule = ClusterRecoveryCapsule {
919            version: CLUSTER_RECOVERY_CAPSULE_VERSION,
920            attempt: CheckpointAttempt::canonical(9),
921            deployment_id: uuid::Uuid::from_u128(99).to_string(),
922            pipeline_identity: PipelineIdentity {
923                canonical_version: PIPELINE_IDENTITY_VERSION,
924                sha256: digest(1),
925            },
926            assignment_fence: CheckpointAssignmentFence::from_owner_map(
927                17,
928                &[7],
929                vec![participant],
930            )
931            .unwrap(),
932            seal_inventory_sha256: digest(2),
933            participants: vec![ParticipantRecoveryRef {
934                participant_id: 7,
935                readiness_sha256: digest(3),
936                manifest_sha256: digest(4),
937                portable_state_sha256: digest(5),
938            }],
939            source_offsets,
940            source_metadata,
941            source_assignment_versions,
942            source_watermarks,
943            cluster_watermark,
944            recovery_watermark_frontier: cluster_watermark.active_value(),
945            portable_state_sha256: digest(5),
946        };
947        Arc::new(CommittedSourceHandoff::try_from(&capsule).unwrap())
948    }
949
950    #[test]
951    fn new_registry_is_unassigned() {
952        let r = VnodeRegistry::new(8);
953        assert_eq!(r.vnode_count(), 8);
954        for v in 0..8 {
955            assert!(r.owner(v).is_unassigned());
956        }
957    }
958
959    #[test]
960    fn single_owner_populates_all_slots() {
961        let r = VnodeRegistry::single_owner(4, NodeId(42));
962        for v in 0..4 {
963            assert_eq!(r.owner(v), NodeId(42));
964        }
965    }
966
967    #[test]
968    fn set_assignment_bumps_version() {
969        let r = VnodeRegistry::new(4);
970        let v0 = r.assignment_version();
971        let new_assign: Arc<[NodeId]> = vec![NodeId(1), NodeId(2), NodeId(1), NodeId(2)].into();
972        r.set_assignment(new_assign);
973        assert!(r.assignment_version() > v0);
974        assert_eq!(r.owner(0), NodeId(1));
975        assert_eq!(r.owner(1), NodeId(2));
976    }
977
978    #[test]
979    fn vnode_for_key_in_range() {
980        let r = VnodeRegistry::new(16);
981        for i in 0..100 {
982            let v = r.vnode_for_key(format!("k-{i}").as_bytes());
983            assert!(v < 16);
984        }
985    }
986
987    #[test]
988    #[should_panic(expected = "assignment length mismatch")]
989    fn set_assignment_rejects_wrong_length() {
990        let r = VnodeRegistry::new(4);
991        let bad: Arc<[NodeId]> = vec![NodeId(1)].into();
992        r.set_assignment(bad);
993    }
994
995    #[test]
996    fn owner_out_of_range_returns_unassigned() {
997        let r = VnodeRegistry::single_owner(4, NodeId(1));
998        assert!(r.owner(10).is_unassigned());
999    }
1000
1001    #[test]
1002    fn committed_empty_source_is_distinct_from_a_missing_source() {
1003        let r = VnodeRegistry::new_unassigned(4);
1004        let owners = r.snapshot();
1005        let orders = ConnectorCheckpoint {
1006            offsets: [("orders:0".into(), "41".into())].into_iter().collect(),
1007            metadata: [("topic".into(), "orders".into())].into_iter().collect(),
1008            source_assignment_version: std::num::NonZeroU64::new(17),
1009        };
1010        let version_one_handoff = committed_handoff(
1011            Some(("orders", orders, Some(700))),
1012            CheckpointWatermark::Uninitialized,
1013        );
1014        r.set_assignment_and_version_with_source_handoff(
1015            Arc::clone(&owners),
1016            1,
1017            version_one_handoff,
1018        );
1019        let version_one = r.versioned_snapshot();
1020        assert_eq!(version_one.version(), 1);
1021        assert!(version_one.has_committed_handoff());
1022        assert_eq!(
1023            version_one
1024                .source_handoff("orders")
1025                .unwrap()
1026                .checkpoint()
1027                .offsets
1028                .get("orders:0")
1029                .map(String::as_str),
1030            Some("41")
1031        );
1032        assert_eq!(
1033            version_one
1034                .source_handoff("orders")
1035                .unwrap()
1036                .checkpoint()
1037                .metadata
1038                .get("topic")
1039                .map(String::as_str),
1040            Some("orders")
1041        );
1042        assert_eq!(
1043            version_one
1044                .source_handoff("orders")
1045                .unwrap()
1046                .checkpoint()
1047                .source_assignment_version,
1048            std::num::NonZeroU64::new(17)
1049        );
1050        assert_eq!(
1051            version_one.source_handoff("orders").unwrap().watermark(),
1052            Some(700)
1053        );
1054        assert_eq!(
1055            version_one.source_handoff_cluster_watermark(),
1056            Some(CheckpointWatermark::Uninitialized)
1057        );
1058        assert_eq!(
1059            version_one.source_handoff_attempt(),
1060            Some(CheckpointAttempt::canonical(9))
1061        );
1062        assert_eq!(version_one.source_handoff_assignment_version(), Some(17));
1063        assert_eq!(version_one.source_handoff_installed_version(), Some(1));
1064
1065        let committed_empty_source = committed_handoff(
1066            Some(("orders", ConnectorCheckpoint::new(), None)),
1067            CheckpointWatermark::Idle,
1068        );
1069        r.set_assignment_and_version_with_source_handoff(owners, 2, committed_empty_source);
1070        let published = r.versioned_snapshot();
1071        assert_eq!(published.version(), 2);
1072        assert_eq!(published.source_handoff_installed_version(), Some(2));
1073        assert!(published.has_committed_handoff());
1074        let orders = published.source_handoff("orders").unwrap();
1075        assert!(orders.checkpoint().offsets.is_empty());
1076        assert!(orders.checkpoint().metadata.is_empty());
1077        assert_eq!(orders.watermark(), None);
1078        assert!(published.source_handoff("missing").is_none());
1079        assert_eq!(
1080            published.source_handoff_cluster_watermark(),
1081            Some(CheckpointWatermark::Idle)
1082        );
1083        assert_eq!(
1084            version_one
1085                .source_handoff("orders")
1086                .unwrap()
1087                .checkpoint()
1088                .offsets
1089                .get("orders:0")
1090                .map(String::as_str),
1091            Some("41"),
1092            "older immutable publications keep their version-bound handoff"
1093        );
1094    }
1095
1096    #[test]
1097    fn owner_generations_and_handoff_survive_skipped_publications() {
1098        let r = VnodeRegistry::new_unassigned(1);
1099        let self_id = NodeId(7);
1100        let other = NodeId(8);
1101        let handoff = committed_handoff(
1102            Some((
1103                "events",
1104                ConnectorCheckpoint::with_offsets(
1105                    [("events:0".to_string(), "41".to_string())]
1106                        .into_iter()
1107                        .collect(),
1108                ),
1109                None,
1110            )),
1111            CheckpointWatermark::Idle,
1112        );
1113        r.set_assignment_and_version_with_source_handoff(
1114            vec![self_id].into(),
1115            1,
1116            Arc::clone(&handoff),
1117        );
1118        r.set_assignment_and_version_carrying_source_handoff(vec![other].into(), 2);
1119        r.set_assignment_and_version_carrying_source_handoff(vec![self_id].into(), 3);
1120
1121        let published = r.versioned_snapshot();
1122        assert_eq!(published.owner_changed_version(0), Some(3));
1123        assert!(published.has_committed_handoff());
1124        assert_eq!(published.source_handoff_installed_version(), Some(1));
1125        assert!(std::ptr::eq(
1126            published.committed_source_handoff().unwrap(),
1127            handoff.as_ref()
1128        ));
1129        assert_eq!(
1130            published
1131                .source_handoff("events")
1132                .unwrap()
1133                .checkpoint()
1134                .offsets
1135                .get("events:0")
1136                .map(String::as_str),
1137            Some("41")
1138        );
1139    }
1140
1141    #[test]
1142    fn skipped_assignment_generation_forces_owner_reconciliation() {
1143        let r = VnodeRegistry::new_unassigned(1);
1144        let self_id = NodeId(7);
1145        r.set_assignment_and_version(vec![self_id].into(), 1);
1146        assert_eq!(r.versioned_snapshot().owner_changed_version(0), Some(1));
1147
1148        r.set_assignment_and_version(vec![self_id].into(), 3);
1149        assert_eq!(
1150            r.versioned_snapshot().owner_changed_version(0),
1151            Some(3),
1152            "a missed intermediate generation may have transferred ownership"
1153        );
1154    }
1155
1156    #[test]
1157    #[should_panic(expected = "assignment version must advance")]
1158    fn assignment_publication_rejects_equal_version_mutation() {
1159        let r = VnodeRegistry::new(1);
1160        r.set_assignment_and_version(vec![NodeId(9)].into(), 1);
1161    }
1162
1163    #[test]
1164    fn vnode_for_key_is_deterministic() {
1165        let r = VnodeRegistry::new(16);
1166        assert_eq!(r.vnode_for_key(b"key-x"), r.vnode_for_key(b"key-x"));
1167    }
1168
1169    #[test]
1170    fn owned_vnodes_filters_by_owner() {
1171        let r = VnodeRegistry::new(4);
1172        r.set_assignment(vec![NodeId(1), NodeId(2), NodeId(1), NodeId(2)].into());
1173        assert_eq!(owned_vnodes(&r, NodeId(1)), vec![0, 2]);
1174        assert_eq!(owned_vnodes(&r, NodeId(2)), vec![1, 3]);
1175        assert!(owned_vnodes(&r, NodeId(99)).is_empty());
1176    }
1177
1178    #[test]
1179    fn owned_vnodes_single_owner_returns_all() {
1180        let r = VnodeRegistry::single_owner(8, NodeId(42));
1181        assert_eq!(owned_vnodes(&r, NodeId(42)), (0..8).collect::<Vec<_>>());
1182    }
1183
1184    #[test]
1185    fn rendezvous_is_deterministic() {
1186        let peers = vec![NodeId(7), NodeId(3), NodeId(5)];
1187        let assignment = rendezvous_assignment(8, &peers);
1188        // Input order doesn't matter.
1189        let reversed = vec![NodeId(3), NodeId(5), NodeId(7)];
1190        assert_eq!(rendezvous_assignment(8, &reversed), assignment);
1191    }
1192
1193    #[test]
1194    fn rendezvous_single_peer_owns_everything() {
1195        let assignment = rendezvous_assignment(4, &[NodeId(99)]);
1196        assert!(assignment.iter().all(|&n| n == NodeId(99)));
1197    }
1198
1199    #[test]
1200    #[should_panic(expected = "needs at least one peer")]
1201    fn rendezvous_rejects_empty_peer_list() {
1202        let _ = rendezvous_assignment(4, &[]);
1203    }
1204
1205    #[test]
1206    fn rendezvous_minimizes_state_movement() {
1207        let peers3 = vec![NodeId(1), NodeId(2), NodeId(3)];
1208        let peers4 = vec![NodeId(1), NodeId(2), NodeId(3), NodeId(4)];
1209
1210        let a3 = rendezvous_assignment(256, &peers3);
1211        let a4 = rendezvous_assignment(256, &peers4);
1212
1213        let mut moved = 0;
1214        let mut moved_between_existing = 0;
1215
1216        for v in 0..256usize {
1217            let o3 = a3[v];
1218            let o4 = a4[v];
1219            if o3 != o4 {
1220                moved += 1;
1221                if o4 != NodeId(4) {
1222                    moved_between_existing += 1;
1223                }
1224            }
1225        }
1226
1227        assert_eq!(
1228            moved_between_existing, 0,
1229            "No vnode should move between existing peers on a node join"
1230        );
1231        assert!(
1232            moved > 40 && moved < 90,
1233            "Expected roughly 25% of vnodes to move to the new peer, got {moved}"
1234        );
1235
1236        for v in 0..256usize {
1237            if a3[v] != a4[v] {
1238                assert_eq!(a4[v], NodeId(4));
1239            }
1240        }
1241    }
1242
1243    #[test]
1244    fn vnodes_start_active() {
1245        let r = VnodeRegistry::new(4);
1246        assert!(!r.any_restoring());
1247        for v in 0..4 {
1248            assert!(!r.is_restoring(v));
1249        }
1250        assert!(r.restoring_vnodes().is_empty());
1251    }
1252
1253    #[test]
1254    fn mark_restoring_and_active_round_trip() {
1255        let r = VnodeRegistry::new(4);
1256        r.mark_restoring(&[1, 3]);
1257        assert!(r.any_restoring());
1258        assert!(r.is_restoring(1));
1259        assert!(r.is_restoring(3));
1260        assert!(!r.is_restoring(0));
1261        assert_eq!(r.restoring_vnodes(), vec![1, 3]);
1262
1263        r.mark_active(&[1]);
1264        assert!(!r.is_restoring(1));
1265        assert_eq!(r.restoring_vnodes(), vec![3]);
1266
1267        r.mark_active(&[3]);
1268        assert!(!r.any_restoring());
1269    }
1270
1271    #[test]
1272    fn lifecycle_ignores_out_of_range() {
1273        let r = VnodeRegistry::new(2);
1274        r.mark_restoring(&[5, 99]); // no panic
1275        assert!(!r.is_restoring(5));
1276        assert!(!r.any_restoring());
1277    }
1278
1279    #[test]
1280    fn lifecycle_independent_of_assignment() {
1281        // Reassigning ownership must not clear lifecycle state — the two
1282        // are orthogonal and the caller drives the Restoring→Active flip.
1283        let r = VnodeRegistry::new(4);
1284        r.mark_restoring(&[2]);
1285        r.set_assignment(vec![NodeId(1), NodeId(1), NodeId(1), NodeId(1)].into());
1286        assert!(r.is_restoring(2));
1287    }
1288
1289    // -- Topology-aware placement --------------------------------------------
1290
1291    /// A node at (region, zone, rack).
1292    fn node(id: u64, region: &str, zone: &str, rack: &str) -> (NodeId, Locality) {
1293        (
1294            NodeId(id),
1295            Locality::new(vec![region.into(), zone.into(), rack.into()]),
1296        )
1297    }
1298
1299    const TIER_ZONE: usize = 1;
1300
1301    #[test]
1302    fn locality_parse_and_domain_at() {
1303        let l = Locality::parse("region=us-east-1;zone=us-east-1a;rack=r17");
1304        assert_eq!(l.domain_at(0), "us-east-1");
1305        assert_eq!(l.domain_at(1), "us-east-1;us-east-1a");
1306        assert_eq!(l.domain_at(2), "us-east-1;us-east-1a;r17");
1307        assert_eq!(l.domain_at(99), "us-east-1;us-east-1a;r17"); // clamps to finest
1308        assert_eq!(Locality::parse("rack17").domain_at(0), "rack17"); // bare label
1309        assert_eq!(Locality::parse("").domain_at(0), ""); // unknown → empty domain
1310    }
1311
1312    #[test]
1313    fn owners_per_domain_counts_by_zone() {
1314        let nodes = vec![node(1, "r", "z1", "a"), node(2, "r", "z2", "a")];
1315        // z1 owns 2, z2 owns 1, and an unassigned owner folds into the empty domain.
1316        let owners = [NodeId(1), NodeId(1), NodeId(2), NodeId::UNASSIGNED];
1317        let counts = owners_per_domain(&owners, &nodes, TIER_ZONE);
1318        assert_eq!(counts["r;z1"], 2);
1319        assert_eq!(counts["r;z2"], 1);
1320        assert_eq!(counts[""], 1);
1321    }
1322}