Skip to main content

mongreldb_cluster/
runtime.rs

1//! Cluster node runtime (spec sections 11.1, 12.1-12.4, 12.7 integration).
2//!
3//! [`NodeRuntime`] is the library form of a running cluster node. Starting a
4//! runtime:
5//!
6//! 1. loads the node's persisted [`NodeIdentity`] (spec section 11.1 — the
7//!    node must have been provisioned by `cluster init` / `cluster join`
8//!    first);
9//! 2. builds the [`TcpTransport`] client and the [`TransportServer`] listener
10//!    (spec section 6.7) and feeds the configured membership directory into
11//!    the transport's peer table;
12//! 3. starts the meta control-plane group when this node is a meta member
13//!    ([`MetaMembership`]; [`MetaGroup::create`] / [`MetaGroup::bootstrap`],
14//!    spec section 12.1);
15//! 4. scans the local tablet layout (spec section 12.3:
16//!    `tablets/<tablet-id>/tablet.json`) and reopens every tablet group this
17//!    node hosts, one [`ConsensusGroup`] over the consensus engine sink per
18//!    group, each registered in the transport's dispatch registry.
19//!
20//! # Tablet group raft ids
21//!
22//! A node's meta-group raft id is the projection [`raft_node_id`] of its
23//! durable [`NodeId`], so tablet groups must **not** use that projection: a
24//! node hosting the meta group and tablet groups would attach two raft nodes
25//! under one id to the transport registry. Tablet replica raft ids come from
26//! the canonical [`crate::tablet::ReplicaDescriptor::raft_node_id`] (allocated
27//! by the meta control plane, spec section 12.1); opening a group fails
28//! closed when a replica's raft id is already attached locally.
29//!
30//! # Engine binding of tablet groups
31//!
32//! The consensus engine sink binds each group to a `ClusterReplica` storage
33//! core keyed by `(cluster_id, node_id, database_id)`. Resolution
34//! ([`resolve_tablet_database_id`]): non-zero descriptor-carried
35//! `database_id` when stamped into tablet metadata, else a deterministic
36//! raft-group-derived id — stable across restarts and identical on every
37//! replica without consulting meta (non-meta nodes must not diverge).
38//!
39//! # Split/merge integration (spec sections 12.5-12.6)
40//!
41//! [`NodeRuntime::split_tablet`] / [`NodeRuntime::merge_tablets`] drive the
42//! full safe-split/safe-merge protocols against the real seams: the meta
43//! plane is the node's [`MetaGroup`] (the atomic publications ride the
44//! single [`MetaCommand::PublishSplit`] / [`MetaCommand::PublishMerge`]
45//! raft commands), child tablet ids and replica raft ids are minted by the
46//! meta-owned allocator ([`MetaGroup::allocate_raft_node_ids`]), and
47//! [`NodeRuntime::abort_split`] unwinds an unpublished split.
48//!
49//! Tablet row commands apply into a hidden clustered table owned by the
50//! consensus [`EngineApplySink`]. Split/merge reads pinned core MVCC views,
51//! catches up by diffing pinned and current engine generations, and installs
52//! children through their Raft groups. The cluster crate stays core-free:
53//! the consensus adapter exposes only encoded key/row-image bytes.
54//!
55//! # Graceful shutdown
56//!
57//! [`NodeRuntime::shutdown`] stops accepting RPCs first (the listener drains
58//! in-flight connections within its configured grace), then shuts down every
59//! tablet group and the meta group (each detaches from the transport
60//! registry, fsyncs, and stops its raft task), and finally releases the
61//! process-local tablet ownership guards (spec section 12.3).
62
63use std::collections::BTreeMap;
64use std::path::{Path, PathBuf};
65use std::sync::{Arc, Mutex};
66use std::time::Duration;
67
68use mongreldb_consensus::engine_sink::{
69    open_engine_sink, EngineApplySink, EngineGroupConfig, EngineSinkError, EngineTabletPin,
70    TabletDataCommand, TabletDataCommandRecord, TabletDataMutation, COMMAND_TYPE_TABLET_DATA,
71};
72use mongreldb_consensus::error::ConsensusError;
73use mongreldb_consensus::group::{ConsensusGroup, GroupCommitReceipt, GroupConfig, GroupMetrics};
74use mongreldb_consensus::identity::{raft_node_id, CommandKind, RaftNodeId};
75use mongreldb_consensus::raft_log::RaftCommitLog;
76use mongreldb_consensus::state_machine::ApplySink;
77use mongreldb_log::commit_log::{CommitLog, ExecutionControl, LogPosition};
78use mongreldb_log::envelope::CommandEnvelope;
79use mongreldb_types::hlc::HlcTimestamp;
80use mongreldb_types::ids::{DatabaseId, MetadataVersion, NodeId, RaftGroupId, TabletId};
81use openraft::BasicNode;
82#[cfg(test)]
83use serde::{Deserialize, Serialize};
84
85use crate::merge::{
86    merge_progress, MergeExecutor, MergeInputs, MergeMetaPlane, MergePhase, MergePlan,
87    MergePlanner, MergePublishCommand,
88};
89use crate::meta::{MetaCommand, MetaError, MetaGroup, MetaGroupConfig, MetaRejectionReason};
90use crate::network::{
91    InternalRpcHandler, PeerEndpoint, TcpTransport, TransportConfig, TransportError,
92    TransportSecurity, TransportServer,
93};
94use crate::node::{ClusterError, NodeIdentity};
95use crate::split::{
96    abort_split, split_progress, ChildAllocation, ChildStateSink, SnapshotPin, SplitAbortReport,
97    SplitError, SplitExecutor, SplitKeySelection, SplitPhase, SplitPlan, SplitPublishCommand,
98    TabletDataError, TabletKeyspace, TabletMetaPlane, TabletMutation, TabletSplitPlanner,
99};
100use crate::tablet::{
101    Key, ReplicaDescriptor, TablePartitioningRecord, TabletDescriptor, TabletError, TabletLayout,
102    TabletOwnershipGuard, TabletOwnershipRegistry, TabletState, TABLETS_DIR, TABLET_META_FILENAME,
103};
104
105/// Errors of the node runtime surface.
106#[derive(Debug, thiserror::Error)]
107pub enum RuntimeError {
108    /// Node identity / bootstrap-layer failure.
109    #[error(transparent)]
110    Cluster(#[from] ClusterError),
111    /// Meta control-plane failure.
112    #[error(transparent)]
113    Meta(#[from] MetaError),
114    /// Consensus group failure.
115    #[error(transparent)]
116    Consensus(#[from] ConsensusError),
117    /// Transport failure.
118    #[error(transparent)]
119    Transport(#[from] TransportError),
120    /// Tablet layout / descriptor failure.
121    #[error(transparent)]
122    Tablet(#[from] TabletError),
123    /// Engine sink (applied tablet core) failure.
124    #[error(transparent)]
125    EngineSink(#[from] EngineSinkError),
126    /// Split protocol failure.
127    #[error(transparent)]
128    Split(#[from] SplitError),
129    /// Merge protocol failure.
130    #[error(transparent)]
131    Merge(#[from] crate::merge::MergeError),
132    /// Runtime I/O failure.
133    #[error("runtime I/O error: {0}")]
134    Io(#[from] std::io::Error),
135    /// The caller's request was malformed for this node (identity mismatch,
136    /// missing replica, raft-id collision, invalid workflow order).
137    #[error("invalid runtime request: {0}")]
138    InvalidRequest(String),
139}
140
141/// Optional raft timing overrides, applied to every group the runtime opens
142/// (meta and tablets). `None` keeps the production defaults of
143/// [`GroupConfig::new`]; tests install fast elections through this knob.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub struct GroupTiming {
146    /// Leader heartbeat interval.
147    pub heartbeat_interval: Duration,
148    /// Minimum election timeout.
149    pub election_timeout_min: Duration,
150    /// Maximum election timeout.
151    pub election_timeout_max: Duration,
152    /// Timeout for one snapshot-send/install round.
153    pub install_snapshot_timeout: Duration,
154}
155
156impl GroupTiming {
157    fn apply(&self, config: &mut GroupConfig) {
158        config.heartbeat_interval = self.heartbeat_interval;
159        config.election_timeout_min = self.election_timeout_min;
160        config.election_timeout_max = self.election_timeout_max;
161        config.install_snapshot_timeout = self.install_snapshot_timeout;
162    }
163}
164
165/// This node's membership of the meta control-plane group (spec section
166/// 12.1). Present in [`NodeRuntimeConfig::meta`] exactly when the node is a
167/// meta member.
168#[derive(Clone, Debug)]
169pub struct MetaMembership {
170    /// The dedicated meta group's durable identifier (minted at cluster
171    /// bootstrap; identical on every meta member).
172    pub meta_group_id: RaftGroupId,
173    /// The voter set to bootstrap the pristine group with, on the one
174    /// bootstrap node only; `None` on every other member (it joins through
175    /// [`MetaGroup::add_member`]). Reopening an already-initialized group
176    /// never re-bootstraps.
177    pub bootstrap_voters: Option<Vec<(NodeId, String)>>,
178}
179
180/// Static configuration of a [`NodeRuntime`].
181#[derive(Clone, Debug)]
182pub struct NodeRuntimeConfig {
183    /// The node's local data root (`node-data`).
184    pub node_data: PathBuf,
185    /// How the transport authenticates its peers (production: mTLS).
186    pub security: TransportSecurity,
187    /// Transport bounds (shared by the client and the listener).
188    pub transport: TransportConfig,
189    /// Address the [`TransportServer`] binds (`host:port`; port 0 picks a
190    /// free port, reported through [`NodeRuntime::rpc_address`]).
191    pub listen_address: String,
192    /// Address advertised to peers and stamped into node descriptors;
193    /// defaults to the bound listen address.
194    pub rpc_address: Option<String>,
195    /// Static membership directory: every cluster node's durable id and RPC
196    /// address. Feeds the transport's peer table and the resolution of
197    /// tablet replica raft ids to endpoints.
198    pub peers: Vec<(NodeId, String)>,
199    /// Meta group membership, when this node is a meta member.
200    pub meta: Option<MetaMembership>,
201    /// Raft timing overrides (tests); `None` for production defaults.
202    pub timing: Option<GroupTiming>,
203}
204
205impl NodeRuntimeConfig {
206    /// A configuration with production transport bounds, plaintext security,
207    /// and no meta membership; callers adjust the fields they need.
208    pub fn new(node_data: PathBuf, listen_address: String) -> Self {
209        Self {
210            node_data,
211            security: TransportSecurity::PlaintextForTesting,
212            transport: TransportConfig::default(),
213            listen_address,
214            rpc_address: None,
215            peers: Vec::new(),
216            meta: None,
217            timing: None,
218        }
219    }
220}
221
222/// Status of the meta group member on this node.
223#[derive(Clone, Debug)]
224pub struct MetaGroupStatus {
225    /// The meta group's durable id.
226    pub meta_group_id: RaftGroupId,
227    /// Local applied watermark of the replicated meta state.
228    pub metadata_version: MetadataVersion,
229    /// Raft observability metrics (spec section 14.4).
230    pub metrics: GroupMetrics,
231}
232
233/// Status of one tablet group hosted on this node.
234#[derive(Clone, Debug)]
235pub struct TabletGroupStatus {
236    /// The tablet's durable id.
237    pub tablet_id: TabletId,
238    /// The consensus group replicating the tablet.
239    pub raft_group_id: RaftGroupId,
240    /// Lifecycle state of the descriptor the group was opened with.
241    pub state: TabletState,
242    /// Replicas of the descriptor the group was opened with.
243    pub replicas: Vec<ReplicaDescriptor>,
244    /// Local applied watermark of the group.
245    pub applied: LogPosition,
246    /// Raft observability metrics (spec section 14.4).
247    pub metrics: GroupMetrics,
248}
249
250/// Point-in-time view of a running node: identity, groups with roles, and
251/// applied watermarks (spec sections 11.1, 14.4).
252#[derive(Clone, Debug)]
253pub struct RuntimeStatus {
254    /// This node's persisted identity.
255    pub identity: NodeIdentity,
256    /// The advertised RPC address.
257    pub rpc_address: String,
258    /// Meta group status, when this node is a meta member.
259    pub meta: Option<MetaGroupStatus>,
260    /// Tablet groups hosted on this node, in tablet-id order.
261    pub tablets: Vec<TabletGroupStatus>,
262}
263
264/// Engine-sink database id for a tablet group.
265///
266/// Order: (1) non-zero `descriptor.database_id` when the publisher stamped it
267/// into tablet metadata (identical on every replica), (2) deterministic
268/// derivation from the raft group id — stable across restarts and **identical
269/// on every replica without consulting meta** (meta is not available on all
270/// nodes; live table→database lookup would diverge engine identities).
271fn resolve_tablet_database_id(descriptor: &crate::tablet::TabletDescriptor) -> DatabaseId {
272    if descriptor.database_id != DatabaseId::ZERO {
273        return descriptor.database_id;
274    }
275    DatabaseId::from_bytes(*descriptor.raft_group_id.as_bytes())
276}
277
278/// The text identifier of a tablet group (session tokens carry it, spec
279/// section 11.4); identical on every replica of the group.
280fn tablet_group_name(tablet_id: TabletId) -> String {
281    format!("tablet-{}", tablet_id.to_hex())
282}
283
284/// v0.60.3 ledger filename, retained only for one-way migration.
285pub const TABLET_LEDGER_FILENAME: &str = "tablet-ledger.json";
286/// v0.60.3 ledger fixture format.
287#[cfg(test)]
288pub const TABLET_LEDGER_FORMAT_VERSION: u32 = 1;
289/// Oldest v0.60.3 ledger fixture format.
290#[cfg(test)]
291pub const MIN_SUPPORTED_TABLET_LEDGER_FORMAT_VERSION: u32 = 1;
292
293/// The ceiling timestamp ("visible at any time"): one above every legal
294/// physical-micros value.
295#[cfg(test)]
296const MAX_TIMESTAMP: HlcTimestamp = HlcTimestamp {
297    physical_micros: u64::MAX,
298    logical: u32::MAX,
299    node_tiebreaker: u32::MAX,
300};
301
302/// v0.60.3 migration fixture.
303#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
304#[cfg(test)]
305struct TabletLedgerCheckpoint {
306    /// Checkpoint format version; see [`TABLET_LEDGER_FORMAT_VERSION`].
307    format_version: u32,
308    /// Log position the keyspace reflects.
309    position: LogPosition,
310    /// Per-key version chains, ascending commit timestamps.
311    rows: BTreeMap<Key, Vec<(HlcTimestamp, Vec<u8>)>>,
312}
313
314/// Test-only model of the v0.60.3 ledger. Production opens migrate its JSON
315/// checkpoint into the core engine before starting Raft.
316#[derive(Debug)]
317#[cfg(test)]
318pub struct TabletLedger {
319    rows: BTreeMap<Key, Vec<(HlcTimestamp, Vec<u8>)>>,
320    /// Registered snapshot pins (timestamp -> live pin count).
321    pins: BTreeMap<HlcTimestamp, usize>,
322    position: LogPosition,
323    state_dir: PathBuf,
324}
325
326#[cfg(test)]
327impl TabletLedger {
328    /// Opens the v0.60.3 fixture.
329    pub fn open(group_dir: &Path) -> Result<Self, RuntimeError> {
330        let state_dir = group_dir.join("raft").join("state");
331        std::fs::create_dir_all(&state_dir).map_err(RuntimeError::Io)?;
332        let path = state_dir.join(TABLET_LEDGER_FILENAME);
333        let Some(bytes) = crate::node::read_meta_file(&path)? else {
334            return Ok(Self {
335                rows: BTreeMap::new(),
336                pins: BTreeMap::new(),
337                position: LogPosition::ZERO,
338                state_dir,
339            });
340        };
341        let checkpoint: TabletLedgerCheckpoint =
342            crate::node::decode_json(TABLET_LEDGER_FILENAME, &bytes)?;
343        if checkpoint.format_version < MIN_SUPPORTED_TABLET_LEDGER_FORMAT_VERSION
344            || checkpoint.format_version > TABLET_LEDGER_FORMAT_VERSION
345        {
346            return Err(ClusterError::UnsupportedFormatVersion {
347                file: TABLET_LEDGER_FILENAME,
348                found: checkpoint.format_version,
349                min: MIN_SUPPORTED_TABLET_LEDGER_FORMAT_VERSION,
350                max: TABLET_LEDGER_FORMAT_VERSION,
351            }
352            .into());
353        }
354        Ok(Self {
355            rows: checkpoint.rows,
356            pins: BTreeMap::new(),
357            position: checkpoint.position,
358            state_dir,
359        })
360    }
361
362    /// Applies one committed command at `position`. Redelivery at or below
363    /// the durable watermark is skipped (the sink-first/checkpoint-second
364    /// crash window); the checkpoint is persisted before returning.
365    fn apply(
366        &mut self,
367        command: &TabletDataCommand,
368        commit_ts: HlcTimestamp,
369        position: LogPosition,
370    ) -> Result<(), RuntimeError> {
371        if position.index <= self.position.index {
372            return Ok(());
373        }
374        match command {
375            TabletDataCommand::Upsert { entries } => {
376                for (key, value) in entries {
377                    self.insert_version(Key::from_bytes(key.clone()), commit_ts, value.clone());
378                }
379            }
380            TabletDataCommand::Delete { keys } => {
381                if !self.pins.is_empty() {
382                    return Err(RuntimeError::InvalidRequest(
383                        "legacy tablet ledger Delete with live snapshot pins".to_owned(),
384                    ));
385                }
386                for key in keys {
387                    self.rows.remove(&Key::from_bytes(key.clone()));
388                }
389            }
390            TabletDataCommand::Replace { rows } => {
391                // A whole-keyspace install would silently destroy a pinned
392                // snapshot's timeline; fail closed instead. (Child state
393                // installs only ever target child ledgers, which are never
394                // pinned: pins belong to split/merge sources.)
395                if !self.pins.is_empty() {
396                    return Err(RuntimeError::InvalidRequest(
397                        "tablet ledger Replace with live snapshot pins".to_owned(),
398                    ));
399                }
400                self.rows.clear();
401                for (key, value) in rows {
402                    self.insert_version(Key::from_bytes(key.clone()), commit_ts, value.clone());
403                }
404            }
405        }
406        self.position = position;
407        self.persist()
408    }
409
410    /// Inserts one version, compacting the chain against the oldest live
411    /// pin: versions below the pin collapse to the newest-below baseline
412    /// (the pin's at-or-below view); with no pins only the newest survives.
413    fn insert_version(&mut self, key: Key, ts: HlcTimestamp, value: Vec<u8>) {
414        let chain = self.rows.entry(key).or_default();
415        chain.push((ts, value));
416        chain.sort_by_key(|(version, _)| *version);
417        match self.pins.keys().next() {
418            None => {
419                let newest = chain.pop().expect("just inserted");
420                chain.clear();
421                chain.push(newest);
422            }
423            Some(oldest_pin) => {
424                let baseline = chain.partition_point(|(version, _)| *version <= *oldest_pin);
425                if baseline > 1 {
426                    chain.drain(..baseline - 1);
427                }
428            }
429        }
430    }
431
432    /// Persists the checkpoint atomically (temp-write + rename + dir fsync).
433    fn persist(&self) -> Result<(), RuntimeError> {
434        let checkpoint = TabletLedgerCheckpoint {
435            format_version: TABLET_LEDGER_FORMAT_VERSION,
436            position: self.position,
437            rows: self.rows.clone(),
438        };
439        let bytes = crate::node::encode_json(TABLET_LEDGER_FILENAME, &checkpoint)?;
440        crate::node::write_meta_atomic(&self.state_dir, TABLET_LEDGER_FILENAME, &bytes)
441            .map_err(ClusterError::Io)?;
442        Ok(())
443    }
444
445    /// The log position the keyspace reflects.
446    pub fn applied_position(&self) -> LogPosition {
447        self.position
448    }
449
450    fn pin(&mut self, ts: HlcTimestamp) {
451        *self.pins.entry(ts).or_insert(0) += 1;
452    }
453
454    fn unpin(&mut self, ts: HlcTimestamp) {
455        if let Some(count) = self.pins.get_mut(&ts) {
456            *count -= 1;
457            if *count == 0 {
458                self.pins.remove(&ts);
459            }
460        }
461    }
462
463    /// Number of live snapshot pins.
464    pub fn pin_count(&self) -> usize {
465        self.pins.values().sum()
466    }
467
468    /// Every key's newest version at or below `ts`, in key order.
469    pub fn rows_at(&self, ts: HlcTimestamp) -> BTreeMap<Key, Vec<u8>> {
470        self.rows
471            .iter()
472            .filter_map(|(key, chain)| {
473                let visible = chain.iter().rfind(|(version, _)| *version <= ts)?;
474                Some((key.clone(), visible.1.clone()))
475            })
476            .collect()
477    }
478
479    /// The current contents (the newest version of every key).
480    pub fn current_rows(&self) -> BTreeMap<Key, Vec<u8>> {
481        self.rows_at(MAX_TIMESTAMP)
482    }
483
484    /// Mutations committed after `ts`, in commit order (ties broken by key
485    /// for determinism); multiple versions of one key arrive oldest first.
486    pub fn deltas_after(&self, ts: HlcTimestamp) -> Vec<(Key, Vec<u8>)> {
487        let mut deltas: Vec<(HlcTimestamp, Key, Vec<u8>)> = Vec::new();
488        for (key, chain) in &self.rows {
489            for (version, value) in chain {
490                if *version > ts {
491                    deltas.push((*version, key.clone(), value.clone()));
492                }
493            }
494        }
495        deltas.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
496        deltas
497            .into_iter()
498            .map(|(_, key, value)| (key, value))
499            .collect()
500    }
501
502    /// The applied size in bytes (newest versions only), feeding the merge
503    /// planner's combined-size check (spec section 12.6).
504    pub fn size_bytes(&self) -> u64 {
505        self.rows
506            .iter()
507            .map(|(key, chain)| {
508                let newest = chain.last().expect("non-empty chain");
509                (key.as_bytes().len() + newest.1.len()) as u64
510            })
511            .sum()
512    }
513
514    fn snapshot_bytes(
515        &self,
516    ) -> Result<Vec<u8>, mongreldb_consensus::state_machine::StateMachineError> {
517        serde_json::to_vec(&TabletLedgerCheckpoint {
518            format_version: TABLET_LEDGER_FORMAT_VERSION,
519            position: self.position,
520            rows: self.rows.clone(),
521        })
522        .map_err(|error| {
523            mongreldb_consensus::state_machine::StateMachineError::Sink(format!(
524                "tablet ledger snapshot: {error}"
525            ))
526        })
527    }
528
529    fn install_bytes(
530        &mut self,
531        bytes: &[u8],
532    ) -> Result<(), mongreldb_consensus::state_machine::StateMachineError> {
533        use mongreldb_consensus::state_machine::StateMachineError;
534
535        let checkpoint: TabletLedgerCheckpoint =
536            serde_json::from_slice(bytes).map_err(|error| {
537                StateMachineError::Corrupt(format!("tablet ledger snapshot: {error}"))
538            })?;
539        if checkpoint.format_version < MIN_SUPPORTED_TABLET_LEDGER_FORMAT_VERSION
540            || checkpoint.format_version > TABLET_LEDGER_FORMAT_VERSION
541        {
542            return Err(StateMachineError::Corrupt(format!(
543                "tablet ledger snapshot format version {} is outside \
544                 {MIN_SUPPORTED_TABLET_LEDGER_FORMAT_VERSION}..={TABLET_LEDGER_FORMAT_VERSION}",
545                checkpoint.format_version
546            )));
547        }
548        self.rows = checkpoint.rows;
549        self.position = checkpoint.position;
550        self.persist()
551            .map_err(|error| StateMachineError::Sink(error.to_string()))
552    }
553}
554
555#[cfg(test)]
556fn encode_group_snapshot(engine: &[u8], ledger: &[u8]) -> Vec<u8> {
557    let mut out = Vec::with_capacity(12 + engine.len() + ledger.len());
558    out.extend_from_slice(&1_u32.to_le_bytes());
559    out.extend_from_slice(&(engine.len() as u64).to_le_bytes());
560    out.extend_from_slice(engine);
561    out.extend_from_slice(ledger);
562    out
563}
564
565#[cfg(test)]
566fn decode_group_snapshot(
567    bytes: &[u8],
568) -> Result<(Vec<u8>, Vec<u8>), mongreldb_consensus::state_machine::StateMachineError> {
569    use mongreldb_consensus::state_machine::StateMachineError;
570
571    if bytes.len() < 12 {
572        return Err(StateMachineError::Corrupt(
573            "tablet group snapshot: truncated frame".to_owned(),
574        ));
575    }
576    let version = u32::from_le_bytes(bytes[..4].try_into().expect("4 bytes"));
577    if version != 1 {
578        return Err(StateMachineError::Corrupt(format!(
579            "tablet group snapshot format version {version} is not 1"
580        )));
581    }
582    let engine_len = u64::from_le_bytes(bytes[4..12].try_into().expect("8 bytes")) as usize;
583    if bytes.len() < 12 + engine_len {
584        return Err(StateMachineError::Corrupt(
585            "tablet group snapshot: truncated engine payload".to_owned(),
586        ));
587    }
588    Ok((
589        bytes[12..12 + engine_len].to_vec(),
590        bytes[12 + engine_len..].to_vec(),
591    ))
592}
593
594/// One tablet group hosted on this node: its consensus group, engine MVCC
595/// sink, descriptor, and ownership reservation.
596struct TabletGroup {
597    group: Arc<ConsensusGroup<TcpTransport>>,
598    descriptor: TabletDescriptor,
599    sink: Arc<Mutex<EngineApplySink>>,
600    _ownership: TabletOwnershipGuard<'static>,
601}
602
603/// Minimal decode probe discovering a tablet directory's raft group id ahead
604/// of the authoritative [`TabletLayout::validate`] verification (which checks
605/// the versioned, checksummed envelope end to end).
606#[derive(serde::Deserialize)]
607struct TabletFileProbe {
608    tablet: TabletProbe,
609}
610
611#[derive(serde::Deserialize)]
612struct TabletProbe {
613    raft_group_id: RaftGroupId,
614}
615
616/// Scans `node-data/tablets/` for tablet replicas this node hosts, returning
617/// each tablet's id and raft group id (sorted by tablet id). Half-created
618/// tablet directories (no `tablet.json`) fail closed, exactly as
619/// [`TabletLayout::validate`] does.
620fn scan_tablet_layouts(node_data: &Path) -> Result<Vec<(TabletId, RaftGroupId)>, RuntimeError> {
621    let root = node_data.join(TABLETS_DIR);
622    if !root.is_dir() {
623        return Ok(Vec::new());
624    }
625    let mut found = Vec::new();
626    for entry in std::fs::read_dir(&root)? {
627        let entry = entry?;
628        if !entry.file_type()?.is_dir() {
629            continue;
630        }
631        let name = entry.file_name();
632        let Some(name) = name.to_str() else {
633            return Err(RuntimeError::InvalidRequest(format!(
634                "tablet directory name {name:?} is not UTF-8"
635            )));
636        };
637        let tablet_id: TabletId = name.parse().map_err(|_| {
638            RuntimeError::InvalidRequest(format!("tablet directory `{name}` is not a tablet id"))
639        })?;
640        let probe_path = entry.path().join(TABLET_META_FILENAME);
641        let Some(bytes) = crate::node::read_meta_file(&probe_path)? else {
642            return Err(TabletError::MissingMetadata(probe_path).into());
643        };
644        let probe: TabletFileProbe = serde_json::from_slice(&bytes).map_err(|error| {
645            RuntimeError::InvalidRequest(format!(
646                "tablet metadata probe at {} failed: {error}",
647                probe_path.display()
648            ))
649        })?;
650        found.push((tablet_id, probe.tablet.raft_group_id));
651    }
652    found.sort();
653    Ok(found)
654}
655
656fn active_snapshot_timestamp(
657    node_data: &Path,
658    tablet_id: TabletId,
659) -> Result<Option<HlcTimestamp>, RuntimeError> {
660    let mut found = None;
661    for (source_id, raft_group_id) in scan_tablet_layouts(node_data)? {
662        let layout = TabletLayout::new(node_data.to_path_buf(), source_id, raft_group_id);
663        let timestamp = if let Some(progress) = split_progress(&layout)?.filter(|progress| {
664            progress.source.tablet_id == tablet_id && progress.phase < SplitPhase::Published
665        }) {
666            Some(progress.split_ts)
667        } else {
668            merge_progress(&layout)?
669                .filter(|progress| {
670                    progress.phase < MergePhase::Published
671                        && progress
672                            .sources
673                            .iter()
674                            .any(|source| source.tablet_id == tablet_id)
675                })
676                .map(|progress| progress.merge_ts)
677        };
678        if let Some(timestamp) = timestamp {
679            if found
680                .replace(timestamp)
681                .is_some_and(|prior| prior != timestamp)
682            {
683                return Err(RuntimeError::InvalidRequest(format!(
684                    "tablet {tablet_id} appears in conflicting split/merge progress"
685                )));
686            }
687        }
688    }
689    Ok(found)
690}
691
692/// The peer endpoint of one node under the runtime's security mode.
693fn peer_endpoint(security: &TransportSecurity, node_id: NodeId, address: &str) -> PeerEndpoint {
694    match security {
695        TransportSecurity::Mtls(_) => PeerEndpoint::mtls(address, node_id),
696        TransportSecurity::PlaintextForTesting => PeerEndpoint::plaintext(address),
697    }
698}
699
700/// The per-node context every tablet group open needs (kept apart from the
701/// per-tablet parameters of [`NodeRuntime::open_tablet_group`]).
702struct TabletOpenContext<'a> {
703    node_data: &'a Path,
704    security: &'a TransportSecurity,
705    timing: Option<GroupTiming>,
706    identity: &'a NodeIdentity,
707    peers: &'a BTreeMap<NodeId, String>,
708}
709
710/// A running cluster node: identity, transport, meta group, and tablet
711/// groups (see the module docs).
712pub struct NodeRuntime {
713    identity: NodeIdentity,
714    node_data: PathBuf,
715    rpc_address: String,
716    security: TransportSecurity,
717    peers: BTreeMap<NodeId, String>,
718    timing: Option<GroupTiming>,
719    transport: Arc<TcpTransport>,
720    server: Option<TransportServer>,
721    meta: Option<Arc<MetaGroup<TcpTransport>>>,
722    tablets: BTreeMap<TabletId, TabletGroup>,
723}
724
725/// Cloneable client side of the node-internal cluster RPC path.
726#[derive(Clone)]
727pub struct NodeInternalRpcClient {
728    transport: Arc<TcpTransport>,
729}
730
731impl NodeInternalRpcClient {
732    /// Sends one opaque request to `target` under cluster mTLS.
733    pub async fn call(
734        &self,
735        target: NodeId,
736        service_id: u32,
737        body: Vec<u8>,
738    ) -> Result<Vec<u8>, RuntimeError> {
739        self.transport
740            .internal_rpc(raft_node_id(&target), service_id, body)
741            .await
742            .map_err(Into::into)
743    }
744}
745
746impl NodeRuntime {
747    /// Starts the node (see the module docs for the ordered start-up steps).
748    pub async fn start(config: NodeRuntimeConfig) -> Result<Self, RuntimeError> {
749        let identity =
750            NodeIdentity::load(&config.node_data)?.ok_or(ClusterError::NotInitialized)?;
751        let transport = Arc::new(TcpTransport::new(
752            config.transport.clone(),
753            config.security.clone(),
754        ));
755        let mut peers = BTreeMap::new();
756        for (node_id, address) in &config.peers {
757            transport.upsert_peer(
758                raft_node_id(node_id),
759                peer_endpoint(&config.security, *node_id, address),
760            );
761            peers.insert(*node_id, address.clone());
762        }
763        let server = TransportServer::bind_shared(
764            &config.listen_address,
765            transport.security_handle(),
766            transport.registry(),
767            config.transport.clone(),
768        )
769        .await?;
770        let rpc_address = config
771            .rpc_address
772            .clone()
773            .unwrap_or_else(|| server.local_addr().to_string());
774
775        let meta = match &config.meta {
776            Some(membership) => {
777                let group =
778                    Self::start_meta_group(&config, &identity, membership, transport.clone())
779                        .await?;
780                Some(Arc::new(group))
781            }
782            None => None,
783        };
784        let tablets =
785            Self::open_tablet_groups(&config, &identity, &peers, transport.clone()).await?;
786        Ok(Self {
787            identity,
788            node_data: config.node_data.clone(),
789            rpc_address,
790            security: config.security.clone(),
791            peers,
792            timing: config.timing,
793            transport,
794            server: Some(server),
795            meta,
796            tablets,
797        })
798    }
799
800    /// Opens this node's meta group member and bootstraps it when the
801    /// membership names a bootstrap voter set and the group is still pristine.
802    async fn start_meta_group(
803        config: &NodeRuntimeConfig,
804        identity: &NodeIdentity,
805        membership: &MetaMembership,
806        transport: Arc<TcpTransport>,
807    ) -> Result<MetaGroup<TcpTransport>, RuntimeError> {
808        let meta_config = MetaGroupConfig::new(
809            config.node_data.clone(),
810            membership.meta_group_id,
811            identity.node_id,
812        );
813        let mut group_config = meta_config.group_config();
814        if let Some(timing) = &config.timing {
815            timing.apply(&mut group_config);
816        }
817        let group = MetaGroup::create(meta_config, group_config, transport).await?;
818        if let Some(voters) = &membership.bootstrap_voters {
819            if !voters
820                .iter()
821                .any(|(node_id, _)| *node_id == identity.node_id)
822            {
823                return Err(RuntimeError::InvalidRequest(
824                    "meta bootstrap voter set does not include this node".to_owned(),
825                ));
826            }
827            if !group.is_initialized().await? {
828                group.bootstrap(voters).await?;
829            }
830        }
831        Ok(group)
832    }
833
834    /// Reopens every tablet group the local layout lists (restart path).
835    async fn open_tablet_groups(
836        config: &NodeRuntimeConfig,
837        identity: &NodeIdentity,
838        peers: &BTreeMap<NodeId, String>,
839        transport: Arc<TcpTransport>,
840    ) -> Result<BTreeMap<TabletId, TabletGroup>, RuntimeError> {
841        let mut groups = BTreeMap::new();
842        let context = TabletOpenContext {
843            node_data: &config.node_data,
844            security: &config.security,
845            timing: config.timing,
846            identity,
847            peers,
848        };
849        for (tablet_id, raft_group_id) in scan_tablet_layouts(&config.node_data)? {
850            let layout = TabletLayout::new(config.node_data.clone(), tablet_id, raft_group_id);
851            let descriptor = layout.validate()?;
852            let group =
853                Self::open_tablet_group(&context, transport.clone(), &layout, &descriptor).await?;
854            groups.insert(tablet_id, group);
855        }
856        Ok(groups)
857    }
858
859    /// Opens one tablet group over the engine MVCC sink: validates this node's
860    /// replica, reserves the tablet directory, registers replica endpoints,
861    /// and starts the raft task (which attaches itself to the transport
862    /// registry).
863    async fn open_tablet_group(
864        context: &TabletOpenContext<'_>,
865        transport: Arc<TcpTransport>,
866        layout: &TabletLayout,
867        descriptor: &TabletDescriptor,
868    ) -> Result<TabletGroup, RuntimeError> {
869        let replica = *descriptor
870            .replica_on(context.identity.node_id)
871            .ok_or_else(|| {
872                RuntimeError::InvalidRequest(format!(
873                    "tablet {} descriptor does not list this node ({}) as a replica",
874                    descriptor.tablet_id, context.identity.node_id
875                ))
876            })?;
877        if transport.registry().get(replica.raft_node_id).is_some() {
878            return Err(RuntimeError::InvalidRequest(format!(
879                "raft id {} is already attached to this node's transport registry",
880                replica.raft_node_id
881            )));
882        }
883        // The replica raft ids route through the transport's peer table;
884        // every replica must resolve to a cluster node address.
885        for other in &descriptor.replicas {
886            let Some(address) = context.peers.get(&other.node_id) else {
887                return Err(RuntimeError::InvalidRequest(format!(
888                    "no membership-directory address for replica node {} of tablet {}",
889                    other.node_id, descriptor.tablet_id
890                )));
891            };
892            transport.upsert_peer(
893                other.raft_node_id,
894                peer_endpoint(context.security, other.node_id, address),
895            );
896        }
897        let ownership = TabletOwnershipRegistry::global().try_reserve(layout)?;
898        let database_id = resolve_tablet_database_id(descriptor);
899        let engine_config = EngineGroupConfig::new(
900            context.node_data.to_path_buf(),
901            descriptor.raft_group_id,
902            context.identity.cluster_id,
903            context.identity.node_id,
904            database_id,
905        );
906        let sink = open_engine_sink(&engine_config)?;
907        {
908            let mut engine = sink
909                .lock()
910                .map_err(|_| RuntimeError::InvalidRequest("engine sink lock poisoned".into()))?;
911            engine.initialize_tablet_keyspace()?;
912            let legacy_path = engine_config
913                .group_dir()
914                .join("raft")
915                .join("state")
916                .join(TABLET_LEDGER_FILENAME);
917            if legacy_path.is_file() {
918                let bytes = std::fs::read(&legacy_path)?;
919                engine.migrate_legacy_tablet_ledger(
920                    &bytes,
921                    active_snapshot_timestamp(context.node_data, descriptor.tablet_id)?,
922                )?;
923                std::fs::remove_file(&legacy_path)?;
924                if let Some(parent) = legacy_path.parent() {
925                    std::fs::File::open(parent)?.sync_all()?;
926                }
927            }
928            engine.finish_legacy_tablet_migration()?;
929        }
930        let mut group_config = GroupConfig::new(
931            tablet_group_name(descriptor.tablet_id),
932            replica.raft_node_id,
933            engine_config.group_dir(),
934        );
935        group_config.storage = engine_config.storage.clone();
936        group_config.idempotency_retention = engine_config.idempotency_retention;
937        if let Some(timing) = &context.timing {
938            timing.apply(&mut group_config);
939        }
940        let dyn_sink: Arc<Mutex<dyn ApplySink>> = sink.clone();
941        let group = Arc::new(ConsensusGroup::create(group_config, transport, dyn_sink).await?);
942        let commit_log: Arc<dyn CommitLog> = Arc::new(RaftCommitLog::new(group.clone()));
943        sink.lock()
944            .map_err(|_| RuntimeError::InvalidRequest("engine sink lock poisoned".into()))?
945            .bind_commit_log(commit_log)?;
946        Ok(TabletGroup {
947            group,
948            descriptor: descriptor.clone(),
949            sink,
950            _ownership: ownership,
951        })
952    }
953
954    /// This node's persisted identity.
955    pub fn identity(&self) -> &NodeIdentity {
956        &self.identity
957    }
958
959    /// Hot-reload mTLS trust material without restarting the node (N8).
960    /// New inbound/outbound handshakes use the reloaded PEMs; existing TLS
961    /// sessions continue until the peer reconnects.
962    pub fn reload_trust(
963        &mut self,
964        trust: crate::bootstrap::TrustConfig,
965    ) -> Result<(), RuntimeError> {
966        let tls = crate::network::TlsConfig::from_trust(&trust)
967            .map_err(|e| RuntimeError::InvalidRequest(format!("reload trust: {e}")))?;
968        let security = crate::network::TransportSecurity::Mtls(tls);
969        self.transport.reload_security(security.clone());
970        self.security = security;
971        Ok(())
972    }
973
974    /// The advertised RPC address of this node.
975    pub fn rpc_address(&self) -> &str {
976        &self.rpc_address
977    }
978
979    /// Attaches the server's authenticated node-internal service endpoint.
980    ///
981    /// One handler is installed per durable node identity. Reattaching
982    /// replaces the prior endpoint atomically.
983    pub fn attach_internal_rpc_handler(
984        &self,
985        service_id: u32,
986        handler: Arc<dyn InternalRpcHandler>,
987    ) {
988        self.transport.registry().attach_internal(
989            raft_node_id(&self.identity.node_id),
990            service_id,
991            handler,
992        );
993    }
994
995    /// Sends one authenticated node-internal RPC through the cluster
996    /// transport.
997    pub async fn internal_rpc(
998        &self,
999        target: NodeId,
1000        service_id: u32,
1001        body: Vec<u8>,
1002    ) -> Result<Vec<u8>, RuntimeError> {
1003        self.transport
1004            .internal_rpc(raft_node_id(&target), service_id, body)
1005            .await
1006            .map_err(Into::into)
1007    }
1008
1009    /// Cloneable node-internal RPC client for gateway fan-out.
1010    pub fn internal_rpc_client(&self) -> NodeInternalRpcClient {
1011        NodeInternalRpcClient {
1012            transport: Arc::clone(&self.transport),
1013        }
1014    }
1015
1016    /// This node's meta group member, when it is a meta member.
1017    pub fn meta_group(&self) -> Option<&MetaGroup<TcpTransport>> {
1018        self.meta.as_deref()
1019    }
1020
1021    /// The consensus group of one tablet hosted on this node.
1022    pub fn tablet_group(&self, tablet_id: TabletId) -> Option<&ConsensusGroup<TcpTransport>> {
1023        self.tablets.get(&tablet_id).map(|tablet| &*tablet.group)
1024    }
1025
1026    /// The engine MVCC sink of one locally hosted tablet.
1027    fn tablet_sink(&self, tablet_id: TabletId) -> Option<Arc<Mutex<EngineApplySink>>> {
1028        self.tablets
1029            .get(&tablet_id)
1030            .map(|tablet| tablet.sink.clone())
1031    }
1032
1033    /// The descriptor a hosted tablet group was opened with.
1034    pub fn tablet_descriptor(&self, tablet_id: TabletId) -> Option<&TabletDescriptor> {
1035        self.tablets
1036            .get(&tablet_id)
1037            .map(|tablet| &tablet.descriptor)
1038    }
1039
1040    /// Every tablet hosted on this node, in tablet-id order.
1041    pub fn tablet_ids(&self) -> Vec<TabletId> {
1042        self.tablets.keys().copied().collect()
1043    }
1044
1045    /// One hosted tablet group, or an [`RuntimeError::InvalidRequest`] naming
1046    /// the tablet this node does not host.
1047    fn hosted_tablet(&self, tablet_id: TabletId) -> Result<&TabletGroup, RuntimeError> {
1048        self.tablets.get(&tablet_id).ok_or_else(|| {
1049            RuntimeError::InvalidRequest(format!("this node hosts no tablet {tablet_id}"))
1050        })
1051    }
1052
1053    /// Creates one tablet replica on this node (spec sections 12.3, 12.7):
1054    /// allocates the section 12.3 layout, opens a [`ConsensusGroup`] over the
1055    /// engine sink, and registers the group in the transport registry.
1056    ///
1057    /// `partitioning` is the table's section 12.2 partitioning record; it is
1058    /// validated for structural soundness and must name the same table as the
1059    /// descriptor. When `bootstrap_voters` is `Some`, the pristine group is
1060    /// bootstrapped with that voter set (mapped to the descriptor's raft ids;
1061    /// must include this node) — call this on exactly one replica, after the
1062    /// other replicas created their local groups. When `publish_to_meta` is
1063    /// set, the descriptor is published to the meta group through
1064    /// [`NodeRuntime::publish_tablet_descriptor`] (which requires this node
1065    /// to host the meta group and reach its leader).
1066    ///
1067    /// Repeating the call with an identical descriptor is a no-op; a
1068    /// different descriptor for an already-open tablet fails closed.
1069    pub async fn create_tablet(
1070        &mut self,
1071        descriptor: &TabletDescriptor,
1072        partitioning: &TablePartitioningRecord,
1073        bootstrap_voters: Option<&[(NodeId, String)]>,
1074        publish_to_meta: bool,
1075        control: &ExecutionControl,
1076    ) -> Result<(), RuntimeError> {
1077        descriptor.validate()?;
1078        partitioning.validate()?;
1079        if partitioning.table_id != descriptor.table_id {
1080            return Err(RuntimeError::InvalidRequest(format!(
1081                "partitioning record names table {} but the descriptor partitions table {}",
1082                partitioning.table_id, descriptor.table_id
1083            )));
1084        }
1085        self.create_hosted_replica(descriptor, bootstrap_voters)
1086            .await?;
1087        if publish_to_meta {
1088            self.publish_tablet_descriptor(descriptor, control).await?;
1089        }
1090        Ok(())
1091    }
1092
1093    /// Creates (or validates the existing) local replica of `descriptor`:
1094    /// allocates the section 12.3 layout, opens the [`ConsensusGroup`] over
1095    /// the engine sink, registers the group in the
1096    /// transport registry, and — when `bootstrap_voters` is `Some` and the
1097    /// group is still pristine — bootstraps it with that voter set (call on
1098    /// exactly one replica). Idempotent for an identical descriptor; a
1099    /// different descriptor for an already-open tablet fails closed.
1100    async fn create_hosted_replica(
1101        &mut self,
1102        descriptor: &TabletDescriptor,
1103        bootstrap_voters: Option<&[(NodeId, String)]>,
1104    ) -> Result<(), RuntimeError> {
1105        descriptor.validate()?;
1106        if let Some(existing) = self.tablets.get(&descriptor.tablet_id) {
1107            if existing.descriptor == *descriptor {
1108                return Ok(());
1109            }
1110            return Err(RuntimeError::InvalidRequest(format!(
1111                "tablet {} is already open with a different descriptor",
1112                descriptor.tablet_id
1113            )));
1114        }
1115        // Fail closed before touching the layout: this node must be a
1116        // replica of the tablet it is asked to create.
1117        if descriptor.replica_on(self.identity.node_id).is_none() {
1118            return Err(RuntimeError::InvalidRequest(format!(
1119                "tablet {} descriptor does not list this node ({}) as a replica",
1120                descriptor.tablet_id, self.identity.node_id
1121            )));
1122        }
1123        let layout = TabletLayout::new(
1124            self.node_data.clone(),
1125            descriptor.tablet_id,
1126            descriptor.raft_group_id,
1127        );
1128        layout.create(descriptor)?;
1129        let context = TabletOpenContext {
1130            node_data: &self.node_data,
1131            security: &self.security,
1132            timing: self.timing,
1133            identity: &self.identity,
1134            peers: &self.peers,
1135        };
1136        let group =
1137            Self::open_tablet_group(&context, self.transport.clone(), &layout, descriptor).await?;
1138        if let Some(voters) = bootstrap_voters {
1139            let mut members = BTreeMap::new();
1140            for (node_id, address) in voters {
1141                let Some(replica) = descriptor.replica_on(*node_id) else {
1142                    return Err(RuntimeError::InvalidRequest(format!(
1143                        "bootstrap voter {node_id} is not a replica of tablet {}",
1144                        descriptor.tablet_id
1145                    )));
1146                };
1147                members.insert(replica.raft_node_id, BasicNode::new(address.clone()));
1148            }
1149            if !members.contains_key(&group.group.node_id()) {
1150                return Err(RuntimeError::InvalidRequest(
1151                    "tablet bootstrap voter set does not include this node".to_owned(),
1152                ));
1153            }
1154            if !group.group.is_initialized().await? {
1155                group.group.bootstrap(members).await?;
1156            }
1157        }
1158        self.tablets.insert(descriptor.tablet_id, group);
1159        Ok(())
1160    }
1161
1162    /// The section 12.7 replica-join workflow, driven on the node hosting
1163    /// the target group (normally its leader): add the new replica as a
1164    /// learner (blocking until it is line-rate — the snapshot/catch-up step),
1165    /// then promote it to voter through joint consensus. The learner's own
1166    /// node must already have created its local replica
1167    /// ([`NodeRuntime::create_tablet`] with the learner in the descriptor);
1168    /// the descriptor update itself (role flip, generation bump) is published
1169    /// to the meta group separately
1170    /// ([`NodeRuntime::publish_tablet_descriptor`]).
1171    pub async fn add_tablet_replica(
1172        &self,
1173        tablet_id: TabletId,
1174        replica: ReplicaDescriptor,
1175        address: &str,
1176    ) -> Result<(), RuntimeError> {
1177        let tablet = self.hosted_tablet(tablet_id)?;
1178        if tablet.descriptor.replicas.iter().any(|existing| {
1179            existing.node_id == replica.node_id || existing.raft_node_id == replica.raft_node_id
1180        }) {
1181            return Err(RuntimeError::InvalidRequest(format!(
1182                "replica node {} / raft id {} already belongs to tablet {}",
1183                replica.node_id, replica.raft_node_id, tablet_id
1184            )));
1185        }
1186        self.transport.upsert_peer(
1187            replica.raft_node_id,
1188            peer_endpoint(&self.security, replica.node_id, address),
1189        );
1190        // Bounded retries: membership changes are serialized, so wait out
1191        // any in-flight change (bootstrap, an election, or a concurrent admin
1192        // move); a transient `MembershipInProgress` after the wait re-waits.
1193        // Idempotent under §11.7 retries: an earlier attempt may have died
1194        // between the learner add and the promote, so consult the group's
1195        // live membership each attempt and issue only the missing steps.
1196        for _ in 0..10 {
1197            tablet
1198                .group
1199                .wait_uniform_membership(Duration::from_secs(30))
1200                .await?;
1201            let (voters, learners) = tablet.group.members();
1202            if voters.contains(&replica.raft_node_id) {
1203                return Ok(());
1204            }
1205            if !learners.contains(&replica.raft_node_id) {
1206                // `add_learner` blocks until the learner is line-rate: the
1207                // snapshot/catch-up step of the movement protocol (spec
1208                // section 12.7).
1209                match tablet
1210                    .group
1211                    .add_learner(replica.raft_node_id, BasicNode::new(address.to_owned()))
1212                    .await
1213                {
1214                    Ok(()) => {}
1215                    Err(ConsensusError::MembershipInProgress) => continue,
1216                    Err(error) => return Err(error.into()),
1217                }
1218            }
1219            match tablet.group.promote(replica.raft_node_id).await {
1220                Ok(()) => return Ok(()),
1221                Err(ConsensusError::MembershipInProgress) => continue,
1222                Err(error) => return Err(error.into()),
1223            }
1224        }
1225        Err(RuntimeError::InvalidRequest(format!(
1226            "membership change for tablet {tablet_id} did not settle"
1227        )))
1228    }
1229
1230    /// Publishes a tablet descriptor to the meta group (last-writer-wins by
1231    /// `generation`). Requires this node to host the meta group; the proposal
1232    /// rides the meta leader (a follower surfaces the routed
1233    /// [`ConsensusError::NotLeader`] through [`RuntimeError::Meta`]).
1234    pub async fn publish_tablet_descriptor(
1235        &self,
1236        descriptor: &TabletDescriptor,
1237        control: &ExecutionControl,
1238    ) -> Result<MetadataVersion, RuntimeError> {
1239        let meta = self.meta.as_ref().ok_or_else(|| {
1240            RuntimeError::InvalidRequest("this node hosts no meta group".to_owned())
1241        })?;
1242        let receipt = meta
1243            .propose(
1244                crate::meta::new_command_id()?,
1245                MetaCommand::SetTabletDescriptor {
1246                    descriptor: descriptor.clone(),
1247                },
1248                control,
1249            )
1250            .await?;
1251        Ok(receipt.metadata_version)
1252    }
1253
1254    // -----------------------------------------------------------------------
1255    // Split/merge seam bindings (spec sections 12.5-12.6)
1256    // -----------------------------------------------------------------------
1257
1258    /// The meta group this node hosts, or an [`RuntimeError::InvalidRequest`]
1259    /// when it hosts none (the split/merge drivers and the hosted-tablet
1260    /// reconciler require one).
1261    fn meta_group_or_err(&self) -> Result<Arc<MetaGroup<TcpTransport>>, RuntimeError> {
1262        self.meta
1263            .clone()
1264            .ok_or_else(|| RuntimeError::InvalidRequest("this node hosts no meta group".to_owned()))
1265    }
1266
1267    /// Plans a fresh split of `tablet_id` against the meta state (the
1268    /// descriptor/generation authority): the split key, two fresh child
1269    /// tablets on the source's replica nodes, and replica raft ids from the
1270    /// meta-owned allocator.
1271    async fn plan_split(
1272        &self,
1273        tablet_id: TabletId,
1274        split_key: Option<Key>,
1275        control: &ExecutionControl,
1276    ) -> Result<SplitPlan, RuntimeError> {
1277        let meta = self.meta_group_or_err()?;
1278        let source = meta.state().tablet(tablet_id).cloned().ok_or_else(|| {
1279            RuntimeError::InvalidRequest(format!("tablet {tablet_id} is not in the meta state"))
1280        })?;
1281        if source.state != TabletState::Active {
1282            return Err(RuntimeError::InvalidRequest(format!(
1283                "tablet {tablet_id} is in state {}, expected Active",
1284                source.state
1285            )));
1286        }
1287        if source.replica_on(self.identity.node_id).is_none() {
1288            return Err(RuntimeError::InvalidRequest(format!(
1289                "this node hosts no replica of tablet {tablet_id}"
1290            )));
1291        }
1292        let replica_count = source.replicas.len();
1293        let raft_ids = meta
1294            .allocate_raft_node_ids(
1295                2 * u32::try_from(replica_count).unwrap_or(u32::MAX),
1296                control,
1297            )
1298            .await?;
1299        let allocation = |ids: &[RaftNodeId]| ChildAllocation {
1300            tablet_id: TabletId::new_random(),
1301            raft_group_id: RaftGroupId::new_random(),
1302            replicas: source
1303                .replicas
1304                .iter()
1305                .zip(ids)
1306                .map(|(replica, raft_node_id)| ReplicaDescriptor {
1307                    node_id: replica.node_id,
1308                    role: replica.role,
1309                    raft_node_id: *raft_node_id,
1310                })
1311                .collect(),
1312        };
1313        let selection = match split_key {
1314            Some(key) => SplitKeySelection::Explicit(key),
1315            None => SplitKeySelection::Midpoint,
1316        };
1317        let plan = TabletSplitPlanner::new(self.node_data.clone()).plan(
1318            &source,
1319            selection,
1320            now_timestamp(),
1321            [
1322                allocation(&raft_ids[..replica_count]),
1323                allocation(&raft_ids[replica_count..]),
1324            ],
1325        )?;
1326        Ok(plan)
1327    }
1328
1329    /// Creates the local child/replacement replicas of a split (or merge)
1330    /// plan when this node hosts one, bootstrapping each pristine group with
1331    /// its full voter set (peers adopt through
1332    /// [`NodeRuntime::sync_hosted_tablets`] with no bootstrap), and returns
1333    /// the child-state sinks bound to the live groups.
1334    async fn child_sinks(
1335        &mut self,
1336        children: &[TabletDescriptor; 2],
1337        control: &ExecutionControl,
1338    ) -> Result<[RuntimeChildSink; 2], RuntimeError> {
1339        for child in children {
1340            // Already-hosted groups (any generation/state) are reused as
1341            // they are; a resumed split re-publishes nothing here.
1342            if child.replica_on(self.identity.node_id).is_some()
1343                && !self.tablets.contains_key(&child.tablet_id)
1344            {
1345                let voters: Vec<(NodeId, String)> = child
1346                    .replicas
1347                    .iter()
1348                    .filter_map(|replica| {
1349                        self.peers
1350                            .get(&replica.node_id)
1351                            .map(|address| (replica.node_id, address.clone()))
1352                    })
1353                    .collect();
1354                self.create_hosted_replica(child, Some(voters.as_slice()))
1355                    .await?;
1356            }
1357        }
1358        let mut sinks = Vec::with_capacity(2);
1359        for child in children {
1360            let group = self
1361                .tablets
1362                .get(&child.tablet_id)
1363                .ok_or_else(|| {
1364                    RuntimeError::InvalidRequest(format!(
1365                        "child tablet {} is not hosted on this node",
1366                        child.tablet_id
1367                    ))
1368                })?
1369                .group
1370                .clone();
1371            sinks.push(RuntimeChildSink {
1372                group,
1373                handle: tokio::runtime::Handle::current(),
1374                control: control.clone(),
1375                staged: None,
1376            });
1377        }
1378        let [lower, upper]: [RuntimeChildSink; 2] = sinks.try_into().map_err(|_| {
1379            RuntimeError::InvalidRequest("expected exactly two child sinks".to_owned())
1380        })?;
1381        Ok([lower, upper])
1382    }
1383
1384    /// Shuts down and removes one hosted tablet group (the ownership
1385    /// reservation releases with the drop). Absent tablets are a no-op.
1386    async fn drop_hosted_tablet(&mut self, tablet_id: TabletId) -> Result<(), RuntimeError> {
1387        if let Some(tablet) = self.tablets.remove(&tablet_id) {
1388            tablet.group.shutdown().await?;
1389        }
1390        Ok(())
1391    }
1392
1393    /// Adopts the persisted `tablet.json` descriptor into the in-memory copy
1394    /// when its generation advanced (the split/merge executors persist every
1395    /// transition they publish).
1396    fn refresh_local_descriptor(&mut self, layout: &TabletLayout) -> Result<(), RuntimeError> {
1397        let descriptor = layout.load_metadata()?;
1398        if let Some(tablet) = self.tablets.get_mut(&layout.tablet_id()) {
1399            if descriptor.generation > tablet.descriptor.generation {
1400                tablet.descriptor = descriptor;
1401            }
1402        }
1403        Ok(())
1404    }
1405
1406    /// Executes one phase of the split of `tablet_id` (spec section 12.5),
1407    /// resuming from the persisted progress record when one exists
1408    /// (`split_key` is ignored then: the recorded plan rules) and planning a
1409    /// fresh split otherwise (`None` selects the deterministic midpoint of
1410    /// the source bounds). Returns the newly completed phase and, once the
1411    /// atomic publication has happened, its [`SplitPublishCommand`].
1412    ///
1413    /// The driver runs on a node hosting both the meta group and a source
1414    /// replica. Child replicas are created locally with the plan; peers
1415    /// adopt them through [`NodeRuntime::sync_hosted_tablets`] (the child
1416    /// groups elect once a quorum of their replicas is up, so the build and
1417    /// catch-up phases proceed after the peers have synced).
1418    pub async fn split_step(
1419        &mut self,
1420        tablet_id: TabletId,
1421        split_key: Option<Key>,
1422        control: &ExecutionControl,
1423    ) -> Result<(SplitPhase, Option<SplitPublishCommand>), RuntimeError> {
1424        let meta = self.meta_group_or_err()?;
1425        let raft_group_id = match self.tablets.get(&tablet_id) {
1426            Some(tablet) => tablet.descriptor.raft_group_id,
1427            // After the publish step the local source group is already
1428            // dropped; only the retire step remains, and it works off the
1429            // layout and the meta plane alone.
1430            None => meta
1431                .state()
1432                .tablet(tablet_id)
1433                .map(|descriptor| descriptor.raft_group_id)
1434                .ok_or_else(|| {
1435                    RuntimeError::InvalidRequest(format!("this node hosts no tablet {tablet_id}"))
1436                })?,
1437        };
1438        let source_layout = TabletLayout::new(self.node_data.clone(), tablet_id, raft_group_id);
1439        let progress = split_progress(&source_layout)?;
1440        let hosted = self.tablets.contains_key(&tablet_id);
1441        if !hosted
1442            && progress
1443                .as_ref()
1444                .is_none_or(|record| record.phase < SplitPhase::Published)
1445        {
1446            return Err(RuntimeError::InvalidRequest(format!(
1447                "this node hosts no tablet {tablet_id}"
1448            )));
1449        }
1450        let keyspace = RuntimeKeyspace {
1451            sink: if progress
1452                .as_ref()
1453                .is_some_and(|record| record.phase >= SplitPhase::Published)
1454            {
1455                None
1456            } else {
1457                Some(self.tablet_sink(tablet_id).ok_or_else(|| {
1458                    RuntimeError::InvalidRequest(format!("this node hosts no tablet {tablet_id}"))
1459                })?)
1460            },
1461            pinned: None,
1462        };
1463        let plane = RuntimeMetaPlane {
1464            meta: meta.clone(),
1465            handle: tokio::runtime::Handle::current(),
1466            control: control.clone(),
1467        };
1468        let mut executor = match progress {
1469            Some(progress) => {
1470                let children = progress.plan().child_descriptors();
1471                let sinks = self.child_sinks(&children, control).await?;
1472                SplitExecutor::resume(source_layout.clone(), plane, keyspace, sinks)?.ok_or_else(
1473                    || {
1474                        RuntimeError::InvalidRequest(format!(
1475                            "split progress of tablet {tablet_id} vanished mid-resume"
1476                        ))
1477                    },
1478                )?
1479            }
1480            None => {
1481                let plan = self.plan_split(tablet_id, split_key, control).await?;
1482                let sinks = self.child_sinks(&plan.child_descriptors(), control).await?;
1483                SplitExecutor::begin(plan, source_layout.clone(), plane, keyspace, sinks)?
1484            }
1485        };
1486        // The retire step's teardown removes the source directory; the local
1487        // group must not be running when that happens.
1488        if executor.phase() == SplitPhase::Published {
1489            if let Some(sink) = self.tablet_sink(tablet_id) {
1490                sink.lock()
1491                    .map_err(|_| RuntimeError::InvalidRequest("engine sink lock poisoned".into()))?
1492                    .release_tablet_snapshot()?;
1493            }
1494            self.drop_hosted_tablet(tablet_id).await?;
1495        }
1496        let phase = tokio::task::spawn_blocking(move || executor.step())
1497            .await
1498            .map_err(|error| {
1499                RuntimeError::InvalidRequest(format!("split driver task failed: {error}"))
1500            })??;
1501        if phase == SplitPhase::Published {
1502            if let Some(sink) = self.tablet_sink(tablet_id) {
1503                sink.lock()
1504                    .map_err(|_| RuntimeError::InvalidRequest("engine sink lock poisoned".into()))?
1505                    .release_tablet_snapshot()?;
1506            }
1507        }
1508        if matches!(phase, SplitPhase::MarkedSplitting | SplitPhase::Published) {
1509            self.refresh_local_descriptor(&source_layout)?;
1510        }
1511        let published = if phase >= SplitPhase::Published {
1512            // Deterministic from the recorded plan; recomputation after a
1513            // crash in the barrier yields the identical command. (At the
1514            // terminal phase the record is gone with the source teardown;
1515            // `split_tablet` kept the command from the publish step.)
1516            match split_progress(&source_layout)? {
1517                Some(record) => Some(SplitPublishCommand::from_plan(&record.plan())?),
1518                None => None,
1519            }
1520        } else {
1521            None
1522        };
1523        Ok((phase, published))
1524    }
1525
1526    /// Drives the split of `tablet_id` to completion (spec section 12.5),
1527    /// returning the atomic publication. Resumes an in-progress split after
1528    /// a crash. A [`SplitError::SourceRetained`] surfaces with the split
1529    /// parked at [`SplitPhase::Published`]: drop the old-generation pins and
1530    /// call again.
1531    pub async fn split_tablet(
1532        &mut self,
1533        tablet_id: TabletId,
1534        split_key: Option<Key>,
1535        control: &ExecutionControl,
1536    ) -> Result<SplitPublishCommand, RuntimeError> {
1537        let mut published = None;
1538        loop {
1539            let (phase, command) = self
1540                .split_step(tablet_id, split_key.clone(), control)
1541                .await?;
1542            if command.is_some() {
1543                published = command;
1544            }
1545            if phase == SplitPhase::SourceRetired {
1546                break;
1547            }
1548        }
1549        published.ok_or_else(|| {
1550            RuntimeError::InvalidRequest(format!(
1551                "split of tablet {tablet_id} completed without a publication"
1552            ))
1553        })
1554    }
1555
1556    /// Aborts the in-progress split of `tablet_id` (spec section 12.5): the
1557    /// children are removed from the meta state and torn down locally, the
1558    /// source is republished `Active`, and the persisted progress record is
1559    /// cleared. Idempotent — a second call is a no-op; fails closed once the
1560    /// split reached [`SplitPhase::Published`].
1561    pub async fn abort_split(
1562        &mut self,
1563        tablet_id: TabletId,
1564        control: &ExecutionControl,
1565    ) -> Result<SplitAbortReport, RuntimeError> {
1566        let meta = self.meta_group_or_err()?;
1567        let source_layout = {
1568            let source = self.hosted_tablet(tablet_id)?;
1569            TabletLayout::new(
1570                self.node_data.clone(),
1571                tablet_id,
1572                source.descriptor.raft_group_id,
1573            )
1574        };
1575        // The local child groups must not be running when the abort tears
1576        // their directories down.
1577        if let Some(progress) = split_progress(&source_layout)? {
1578            if progress.phase >= SplitPhase::Published {
1579                return Err(SplitError::CannotAbort {
1580                    tablet: tablet_id,
1581                    phase: progress.phase,
1582                }
1583                .into());
1584            }
1585            for child in &progress.children {
1586                self.drop_hosted_tablet(child.tablet_id).await?;
1587            }
1588        }
1589        let mut plane = RuntimeMetaPlane {
1590            meta,
1591            handle: tokio::runtime::Handle::current(),
1592            control: control.clone(),
1593        };
1594        let layout = source_layout.clone();
1595        let report = tokio::task::spawn_blocking(move || abort_split(&layout, &mut plane))
1596            .await
1597            .map_err(|error| {
1598                RuntimeError::InvalidRequest(format!("split abort task failed: {error}"))
1599            })??;
1600        if let Some(sink) = self.tablet_sink(tablet_id) {
1601            sink.lock()
1602                .map_err(|_| RuntimeError::InvalidRequest("engine sink lock poisoned".into()))?
1603                .release_tablet_snapshot()?;
1604        }
1605        // The source's local replica metadata follows the restored
1606        // descriptor (the abort persisted it already).
1607        self.refresh_local_descriptor(&source_layout)?;
1608        Ok(report)
1609    }
1610
1611    /// Executes one phase of the merge of `first` and `second` (spec section
1612    /// 12.6), resuming from the persisted progress record when one exists
1613    /// and planning a fresh merge otherwise. Returns the newly completed
1614    /// phase and, once the atomic publication has happened, its
1615    /// [`MergePublishCommand`]. The driver runs on a node hosting the meta
1616    /// group and a replica of both sources (merge validation requires the
1617    /// sources to share their placement, so one node hosts both).
1618    pub async fn merge_step(
1619        &mut self,
1620        first: TabletId,
1621        second: TabletId,
1622        control: &ExecutionControl,
1623    ) -> Result<(MergePhase, Option<MergePublishCommand>), RuntimeError> {
1624        let meta = self.meta_group_or_err()?;
1625        // After the publish step the local source groups are already
1626        // dropped; the retire step works off the layouts and the meta plane.
1627        let layout_of = |tablet_id: TabletId| -> Result<TabletLayout, RuntimeError> {
1628            let raft_group_id = match self.tablets.get(&tablet_id) {
1629                Some(tablet) => tablet.descriptor.raft_group_id,
1630                None => meta
1631                    .state()
1632                    .tablet(tablet_id)
1633                    .map(|descriptor| descriptor.raft_group_id)
1634                    .ok_or_else(|| {
1635                        RuntimeError::InvalidRequest(format!(
1636                            "this node hosts no tablet {tablet_id}"
1637                        ))
1638                    })?,
1639            };
1640            Ok(TabletLayout::new(
1641                self.node_data.clone(),
1642                tablet_id,
1643                raft_group_id,
1644            ))
1645        };
1646        let first_layout = layout_of(first)?;
1647        let second_layout = layout_of(second)?;
1648        // Everything up to the retire step reads the local keyspaces: both
1649        // sources must be hosted (merge validation requires the sources to
1650        // share their placement, so one node hosts both). At Published only
1651        // the retire step remains, and it works off the layouts alone.
1652        let phase_in_flight = match (
1653            merge_progress(&first_layout)?,
1654            merge_progress(&second_layout)?,
1655        ) {
1656            (Some(progress), None) => Some(progress.phase),
1657            (None, None) => None,
1658            _ => {
1659                return Err(RuntimeError::InvalidRequest(
1660                    "merge progress records on both sources: corrupt state".to_owned(),
1661                ));
1662            }
1663        };
1664        let retired_only = matches!(
1665            phase_in_flight,
1666            Some(MergePhase::Published | MergePhase::SourcesRetired)
1667        );
1668        if !retired_only
1669            && (!self.tablets.contains_key(&first) || !self.tablets.contains_key(&second))
1670        {
1671            return Err(RuntimeError::InvalidRequest(format!(
1672                "this node must host both merge sources ({first}, {second})"
1673            )));
1674        }
1675        let keyspace_of = |layout: &TabletLayout| -> Result<RuntimeKeyspace, RuntimeError> {
1676            Ok(RuntimeKeyspace {
1677                sink: if retired_only {
1678                    None
1679                } else {
1680                    Some(self.tablet_sink(layout.tablet_id()).ok_or_else(|| {
1681                        RuntimeError::InvalidRequest(format!(
1682                            "this node hosts no tablet {}",
1683                            layout.tablet_id()
1684                        ))
1685                    })?)
1686                },
1687                pinned: None,
1688            })
1689        };
1690        let (first_keyspace, second_keyspace) =
1691            (keyspace_of(&first_layout)?, keyspace_of(&second_layout)?);
1692        // The progress record lives in the LOWER source's directory.
1693        let ordered_layouts = |lower_first: bool| {
1694            if lower_first {
1695                [first_layout.clone(), second_layout.clone()]
1696            } else {
1697                [second_layout.clone(), first_layout.clone()]
1698            }
1699        };
1700        let plane = RuntimeMetaPlane {
1701            meta: meta.clone(),
1702            handle: tokio::runtime::Handle::current(),
1703            control: control.clone(),
1704        };
1705        let mut executor = match phase_in_flight {
1706            Some(_) => {
1707                let progress = merge_progress(&first_layout)?
1708                    .or(merge_progress(&second_layout)?)
1709                    .expect("phase_in_flight is Some");
1710                let lower_first = progress.sources[0].tablet_id == first;
1711                let sink = self.replacement_sink(&progress.plan(), control).await?;
1712                let keyspaces = if lower_first {
1713                    [first_keyspace, second_keyspace]
1714                } else {
1715                    [second_keyspace, first_keyspace]
1716                };
1717                MergeExecutor::resume(ordered_layouts(lower_first), plane, keyspaces, sink)?
1718                    .ok_or_else(|| {
1719                        RuntimeError::InvalidRequest(
1720                            "merge progress vanished mid-resume".to_owned(),
1721                        )
1722                    })?
1723            }
1724            None => {
1725                let plan = self.plan_merge(first, second, control).await?;
1726                let lower_first = plan.sources[0].tablet_id == first;
1727                let sink = self.replacement_sink(&plan, control).await?;
1728                let keyspaces = if lower_first {
1729                    [first_keyspace, second_keyspace]
1730                } else {
1731                    [second_keyspace, first_keyspace]
1732                };
1733                MergeExecutor::begin(plan, ordered_layouts(lower_first), plane, keyspaces, sink)?
1734            }
1735        };
1736        // The retire step tears both sources down; neither local group may
1737        // be running then.
1738        if executor.phase() == MergePhase::Published {
1739            for tablet_id in [first, second] {
1740                if let Some(sink) = self.tablet_sink(tablet_id) {
1741                    sink.lock()
1742                        .map_err(|_| {
1743                            RuntimeError::InvalidRequest("engine sink lock poisoned".into())
1744                        })?
1745                        .release_tablet_snapshot()?;
1746                }
1747            }
1748            self.drop_hosted_tablet(first).await?;
1749            self.drop_hosted_tablet(second).await?;
1750        }
1751        let phase = tokio::task::spawn_blocking(move || executor.step())
1752            .await
1753            .map_err(|error| {
1754                RuntimeError::InvalidRequest(format!("merge driver task failed: {error}"))
1755            })??;
1756        if phase == MergePhase::Published {
1757            for tablet_id in [first, second] {
1758                if let Some(sink) = self.tablet_sink(tablet_id) {
1759                    sink.lock()
1760                        .map_err(|_| {
1761                            RuntimeError::InvalidRequest("engine sink lock poisoned".into())
1762                        })?
1763                        .release_tablet_snapshot()?;
1764                }
1765            }
1766        }
1767        if matches!(phase, MergePhase::MarkedMerging | MergePhase::Published) {
1768            self.refresh_local_descriptor(&first_layout)?;
1769            self.refresh_local_descriptor(&second_layout)?;
1770        }
1771        let published = if phase >= MergePhase::Published {
1772            let progress = merge_progress(&first_layout)?.or(merge_progress(&second_layout)?);
1773            match progress {
1774                Some(record) => Some(MergePublishCommand::from_plan(&record.plan())?),
1775                None => None,
1776            }
1777        } else {
1778            None
1779        };
1780        Ok((phase, published))
1781    }
1782
1783    /// Drives the merge of `first` and `second` to completion (spec section
1784    /// 12.6), returning the atomic publication. Resumes an in-progress merge
1785    /// after a crash.
1786    pub async fn merge_tablets(
1787        &mut self,
1788        first: TabletId,
1789        second: TabletId,
1790        control: &ExecutionControl,
1791    ) -> Result<MergePublishCommand, RuntimeError> {
1792        let mut published = None;
1793        loop {
1794            let (phase, command) = self.merge_step(first, second, control).await?;
1795            if command.is_some() {
1796                published = command;
1797            }
1798            if phase == MergePhase::SourcesRetired {
1799                break;
1800            }
1801        }
1802        published.ok_or_else(|| {
1803            RuntimeError::InvalidRequest("merge completed without a publication".to_owned())
1804        })
1805    }
1806
1807    /// Plans a fresh merge of the pair against the meta state and the local
1808    /// engine keyspaces (spec section 12.6's requirement list): same table/schema,
1809    /// adjacent ranges, identical placement, no active schema job, combined
1810    /// size under the threshold. The replacement lands on the sources' node
1811    /// set with fresh meta-allocated raft ids.
1812    async fn plan_merge(
1813        &self,
1814        first: TabletId,
1815        second: TabletId,
1816        control: &ExecutionControl,
1817    ) -> Result<MergePlan, RuntimeError> {
1818        let meta = self.meta_group_or_err()?;
1819        let state = meta.state();
1820        let descriptor_of = |tablet_id: TabletId| -> Result<TabletDescriptor, RuntimeError> {
1821            state.tablet(tablet_id).cloned().ok_or_else(|| {
1822                RuntimeError::InvalidRequest(format!("tablet {tablet_id} is not in the meta state"))
1823            })
1824        };
1825        let (first_desc, second_desc) = (descriptor_of(first)?, descriptor_of(second)?);
1826        let schema = state.table(first_desc.table_id).ok_or_else(|| {
1827            RuntimeError::InvalidRequest(format!(
1828                "table {} of tablet {first} is not in the meta state",
1829                first_desc.table_id
1830            ))
1831        })?;
1832        let active_schema_job = state
1833            .schema_jobs
1834            .values()
1835            .find(|job| job.table_id == first_desc.table_id && !job.state.is_terminal())
1836            .map(|job| job.job_id);
1837        let size_of = |tablet_id: TabletId| -> Result<u64, RuntimeError> {
1838            self.tablet_sink(tablet_id)
1839                .ok_or_else(|| {
1840                    RuntimeError::InvalidRequest(format!("this node hosts no tablet {tablet_id}"))
1841                })?
1842                .lock()
1843                .map_err(|_| RuntimeError::InvalidRequest("engine sink lock poisoned".into()))?
1844                .tablet_size_bytes()
1845                .map_err(RuntimeError::from)
1846        };
1847        let replica_count = first_desc.replicas.len();
1848        let raft_ids = meta
1849            .allocate_raft_node_ids(u32::try_from(replica_count).unwrap_or(u32::MAX), control)
1850            .await?;
1851        let allocation = ChildAllocation {
1852            tablet_id: TabletId::new_random(),
1853            raft_group_id: RaftGroupId::new_random(),
1854            replicas: first_desc
1855                .replicas
1856                .iter()
1857                .zip(&raft_ids)
1858                .map(|(replica, raft_node_id)| ReplicaDescriptor {
1859                    node_id: replica.node_id,
1860                    role: replica.role,
1861                    raft_node_id: *raft_node_id,
1862                })
1863                .collect(),
1864        };
1865        let plan = MergePlanner::new(self.node_data.clone()).plan(
1866            MergeInputs {
1867                first: first_desc,
1868                second: second_desc,
1869                first_schema: schema.schema_version,
1870                second_schema: schema.schema_version,
1871                active_schema_job,
1872                first_size_bytes: size_of(first)?,
1873                second_size_bytes: size_of(second)?,
1874                max_merged_size_bytes: DEFAULT_MAX_MERGED_SIZE_BYTES,
1875            },
1876            now_timestamp(),
1877            allocation,
1878        )?;
1879        Ok(plan)
1880    }
1881
1882    /// The replacement-state sink of a merge plan, creating the local
1883    /// replacement replica (bootstrapped with its full voter set) when this
1884    /// node hosts one.
1885    async fn replacement_sink(
1886        &mut self,
1887        plan: &MergePlan,
1888        control: &ExecutionControl,
1889    ) -> Result<RuntimeChildSink, RuntimeError> {
1890        let descriptor = plan.replacement_descriptor();
1891        if descriptor.replica_on(self.identity.node_id).is_some()
1892            && !self.tablets.contains_key(&descriptor.tablet_id)
1893        {
1894            let voters: Vec<(NodeId, String)> = descriptor
1895                .replicas
1896                .iter()
1897                .filter_map(|replica| {
1898                    self.peers
1899                        .get(&replica.node_id)
1900                        .map(|address| (replica.node_id, address.clone()))
1901                })
1902                .collect();
1903            self.create_hosted_replica(&descriptor, Some(voters.as_slice()))
1904                .await?;
1905        }
1906        let group = self
1907            .tablets
1908            .get(&descriptor.tablet_id)
1909            .ok_or_else(|| {
1910                RuntimeError::InvalidRequest(format!(
1911                    "replacement tablet {} is not hosted on this node",
1912                    descriptor.tablet_id
1913                ))
1914            })?
1915            .group
1916            .clone();
1917        Ok(RuntimeChildSink {
1918            group,
1919            handle: tokio::runtime::Handle::current(),
1920            control: control.clone(),
1921            staged: None,
1922        })
1923    }
1924
1925    /// Reconciles the hosted tablet replicas with the meta state (spec
1926    /// sections 12.3, 12.5-12.6): creates the local replica of every
1927    /// descriptor listing this node that is not yet hosted (split/merge
1928    /// children adopt on their replica nodes), refreshes the persisted
1929    /// `tablet.json` and the in-memory descriptor of every hosted tablet
1930    /// whose generation advanced in meta, and tears down hosted groups
1931    /// whose descriptor left the meta state or no longer lists this node
1932    /// (retired sources). Requires the local meta group; the pass is
1933    /// idempotent and safe to repeat.
1934    pub async fn sync_hosted_tablets(
1935        &mut self,
1936        _control: &ExecutionControl,
1937    ) -> Result<HostedSyncReport, RuntimeError> {
1938        let meta = self.meta_group_or_err()?;
1939        let state = meta.state();
1940        let node_id = self.identity.node_id;
1941        let mut report = HostedSyncReport::default();
1942        // Create: descriptors listing this node, not yet hosted.
1943        for record in state.tablets.values() {
1944            let descriptor = &record.descriptor;
1945            if self.tablets.contains_key(&descriptor.tablet_id)
1946                || descriptor.replica_on(node_id).is_none()
1947            {
1948                continue;
1949            }
1950            self.create_hosted_replica(descriptor, None).await?;
1951            report.created.push(descriptor.tablet_id);
1952        }
1953        // Refresh: meta's generation advanced past the local copy (b2: the
1954        // persisted tablet.json follows first, then the in-memory copy).
1955        for record in state.tablets.values() {
1956            let published = &record.descriptor;
1957            let Some(tablet) = self.tablets.get(&published.tablet_id) else {
1958                continue;
1959            };
1960            if published.generation <= tablet.descriptor.generation {
1961                continue;
1962            }
1963            if published.raft_group_id != tablet.descriptor.raft_group_id {
1964                return Err(RuntimeError::InvalidRequest(format!(
1965                    "meta descriptor for tablet {} names a different raft group than the \
1966                     hosted replica",
1967                    published.tablet_id
1968                )));
1969            }
1970            if published.replica_on(node_id).is_none() {
1971                continue; // torn down below, not refreshed
1972            }
1973            let layout = TabletLayout::new(
1974                self.node_data.clone(),
1975                published.tablet_id,
1976                published.raft_group_id,
1977            );
1978            layout.store_metadata(published)?;
1979            self.tablets
1980                .get_mut(&published.tablet_id)
1981                .expect("checked above")
1982                .descriptor = published.clone();
1983            report.refreshed.push(published.tablet_id);
1984        }
1985        // Teardown: hosted, but the descriptor left the meta state or no
1986        // longer lists this node.
1987        let outgoing: Vec<TabletId> = self
1988            .tablets
1989            .keys()
1990            .filter(|tablet_id| {
1991                state
1992                    .tablets
1993                    .get(tablet_id)
1994                    .is_none_or(|record| record.descriptor.replica_on(node_id).is_none())
1995            })
1996            .copied()
1997            .collect();
1998        for tablet_id in outgoing {
1999            let Some(tablet) = self.tablets.remove(&tablet_id) else {
2000                continue;
2001            };
2002            let layout = TabletLayout::new(
2003                self.node_data.clone(),
2004                tablet_id,
2005                tablet.descriptor.raft_group_id,
2006            );
2007            tablet.group.shutdown().await?;
2008            drop(tablet);
2009            layout.teardown()?;
2010            report.torn_down.push(tablet_id);
2011        }
2012        Ok(report)
2013    }
2014
2015    /// Writes rows into a hosted tablet's engine MVCC keyspace: one
2016    /// raft-replicated upsert, committed and applied on every replica. The
2017    /// proposal rides the local group, so
2018    /// it must be the leader — a [`ConsensusError::NotLeader`] surfaces with
2019    /// the leader hint for the caller to route by.
2020    pub async fn write_tablet_rows(
2021        &self,
2022        tablet_id: TabletId,
2023        entries: &[(Key, Vec<u8>)],
2024        control: &ExecutionControl,
2025    ) -> Result<GroupCommitReceipt, RuntimeError> {
2026        let tablet = self.hosted_tablet(tablet_id)?;
2027        let envelope = CommandEnvelope::new(
2028            COMMAND_TYPE_TABLET_DATA,
2029            new_data_command_id()?,
2030            TabletDataCommandRecord::new(TabletDataCommand::Upsert {
2031                entries: entries
2032                    .iter()
2033                    .map(|(key, value)| (key.as_bytes().to_vec(), value.clone()))
2034                    .collect(),
2035            })
2036            .encode(),
2037        );
2038        let receipt = tablet
2039            .group
2040            .propose(CommandKind::Catalog, envelope, control)
2041            .await?;
2042        Ok(receipt)
2043    }
2044
2045    /// The current applied rows of a hosted tablet (the local replica's
2046    /// point-in-time view; run a read barrier first for linearizability).
2047    pub fn tablet_rows(&self, tablet_id: TabletId) -> Result<BTreeMap<Key, Vec<u8>>, RuntimeError> {
2048        let sink = self.tablet_sink(tablet_id).ok_or_else(|| {
2049            RuntimeError::InvalidRequest(format!("this node hosts no tablet {tablet_id}"))
2050        })?;
2051        let rows = sink
2052            .lock()
2053            .map_err(|_| RuntimeError::InvalidRequest("engine sink lock poisoned".into()))?
2054            .tablet_rows()?;
2055        Ok(rows
2056            .into_iter()
2057            .map(|(key, value)| (Key::from_bytes(key), value))
2058            .collect())
2059    }
2060
2061    /// Point-in-time node status: identity, groups with roles, and applied
2062    /// watermarks (spec section 14.4).
2063    pub fn status(&self) -> RuntimeStatus {
2064        RuntimeStatus {
2065            identity: self.identity.clone(),
2066            rpc_address: self.rpc_address.clone(),
2067            meta: self.meta.as_ref().map(|meta| MetaGroupStatus {
2068                meta_group_id: meta.meta_group_id(),
2069                metadata_version: meta.metadata_version(),
2070                metrics: meta.group().metrics(),
2071            }),
2072            tablets: self
2073                .tablets
2074                .values()
2075                .map(|tablet| TabletGroupStatus {
2076                    tablet_id: tablet.descriptor.tablet_id,
2077                    raft_group_id: tablet.descriptor.raft_group_id,
2078                    state: tablet.descriptor.state,
2079                    replicas: tablet.descriptor.replicas.clone(),
2080                    applied: tablet.group.applied_position(),
2081                    metrics: tablet.group.metrics(),
2082                })
2083                .collect(),
2084        }
2085    }
2086
2087    /// Graceful shutdown (see the module docs): stop accepting RPCs, shut
2088    /// down every group, release the tablet ownership guards. All groups are
2089    /// attempted even when one fails; the first error is reported.
2090    pub async fn shutdown(mut self) -> Result<(), RuntimeError> {
2091        // 1. Stop accepting RPCs; in-flight connections drain within the
2092        //    listener's configured grace.
2093        if let Some(server) = self.server.take() {
2094            server.shutdown().await;
2095        }
2096        let mut first_error: Option<RuntimeError> = None;
2097        // 2. Shut down groups: each detaches from the transport registry,
2098        //    fsyncs its log, and stops its raft task.
2099        for (_, tablet) in std::mem::take(&mut self.tablets) {
2100            if let Err(error) = tablet.group.shutdown().await {
2101                if first_error.is_none() {
2102                    first_error = Some(error.into());
2103                }
2104            }
2105            let close_result = tablet
2106                .sink
2107                .lock()
2108                .map_err(|_| RuntimeError::InvalidRequest("engine sink lock poisoned".to_owned()))
2109                .and_then(|mut sink| sink.close().map_err(RuntimeError::from));
2110            if let Err(error) = close_result {
2111                if first_error.is_none() {
2112                    first_error = Some(error);
2113                }
2114            }
2115        }
2116        if let Some(meta) = self.meta.take() {
2117            if let Err(error) = meta.shutdown().await {
2118                if first_error.is_none() {
2119                    first_error = Some(error.into());
2120                }
2121            }
2122        }
2123        // 3. Tablet ownership guards release as the handles drop.
2124        match first_error {
2125            Some(error) => Err(error),
2126            None => Ok(()),
2127        }
2128    }
2129
2130    /// Process-free crash simulation: stops every group's raft task without
2131    /// the graceful storage close (see [`ConsensusGroup::crash`]) and drops
2132    /// the listener, so a restarted [`NodeRuntime::start`] reopens exactly
2133    /// the durable state a power loss would have left. Tablet ownership
2134    /// guards release with the dropped groups.
2135    pub async fn crash(mut self) {
2136        // Stop accepting RPCs immediately (no drain).
2137        drop(self.server.take());
2138        for (_, tablet) in std::mem::take(&mut self.tablets) {
2139            let TabletGroup { group, sink, .. } = tablet;
2140            match Arc::try_unwrap(group) {
2141                Ok(group) => group.crash().await,
2142                Err(group) => {
2143                    let _ = group.shutdown().await;
2144                }
2145            }
2146            if let Ok(mut sink) = sink.lock() {
2147                let _ = sink.crash();
2148            };
2149        }
2150        if let Some(meta) = self.meta.take() {
2151            match Arc::try_unwrap(meta) {
2152                Ok(meta) => meta.crash().await,
2153                Err(meta) => {
2154                    let _ = meta.shutdown().await;
2155                }
2156            }
2157        }
2158    }
2159}
2160
2161// ---------------------------------------------------------------------------
2162// The split/merge seam implementations (spec sections 12.5-12.6)
2163// ---------------------------------------------------------------------------
2164
2165/// The default merge-size threshold feeding [`MergeInputs`] when the runtime
2166/// plans a merge (spec section 12.6's "combined size under threshold").
2167/// Callers may pass a different threshold into [`MergeInputs`] when planning.
2168pub const DEFAULT_MAX_MERGED_SIZE_BYTES: u64 = 64 * 1024 * 1024;
2169
2170/// The current wall clock as an HLC timestamp (split/merge pin timestamps;
2171/// the runtime is not the cluster's timestamp authority, so these only ever
2172/// order executor-local work).
2173fn now_timestamp() -> HlcTimestamp {
2174    let micros = std::time::SystemTime::now()
2175        .duration_since(std::time::UNIX_EPOCH)
2176        .map(|duration| duration.as_micros() as u64)
2177        .unwrap_or(0);
2178    HlcTimestamp {
2179        physical_micros: micros,
2180        logical: 0,
2181        node_tiebreaker: 0,
2182    }
2183}
2184
2185/// Mints a tablet-data command id.
2186fn new_data_command_id() -> Result<[u8; 16], RuntimeError> {
2187    let mut id = [0u8; 16];
2188    getrandom::getrandom(&mut id)
2189        .map_err(|error| RuntimeError::InvalidRequest(format!("CSPRNG failed: {error}")))?;
2190    Ok(id)
2191}
2192
2193/// The [`TabletMetaPlane`] binding over this node's meta group (spec section
2194/// 12.1): every descriptor write is one quorum-committed meta command, and
2195/// the atomic split/merge publications ride the single
2196/// [`MetaCommand::PublishSplit`] / [`MetaCommand::PublishMerge`] commands —
2197/// never a descriptor-by-descriptor sequence. Constructed per executor step;
2198/// its proposals block on the node's tokio runtime from the executor's
2199/// blocking thread.
2200struct RuntimeMetaPlane {
2201    meta: Arc<MetaGroup<TcpTransport>>,
2202    handle: tokio::runtime::Handle,
2203    control: ExecutionControl,
2204}
2205
2206impl RuntimeMetaPlane {
2207    fn propose(&self, command: MetaCommand) -> Result<(), MetaRejectionReason> {
2208        let meta = self.meta.clone();
2209        let control = self.control.clone();
2210        self.handle
2211            .block_on(async move {
2212                meta.propose(crate::meta::new_command_id()?, command, &control)
2213                    .await
2214            })
2215            .map(|_| ())
2216            .map_err(|error| match error {
2217                MetaError::Rejected(reason) => reason,
2218                MetaError::Consensus(ConsensusError::NotLeader { leader }) => {
2219                    MetaRejectionReason::NotLeader { leader }
2220                }
2221                other => MetaRejectionReason::ProposalFailed {
2222                    reason: other.to_string(),
2223                },
2224            })
2225    }
2226}
2227
2228impl TabletMetaPlane for RuntimeMetaPlane {
2229    fn set_tablet(&mut self, descriptor: &TabletDescriptor) -> Result<(), MetaRejectionReason> {
2230        self.propose(MetaCommand::SetTabletDescriptor {
2231            descriptor: descriptor.clone(),
2232        })
2233    }
2234
2235    fn tablet(&self, tablet_id: TabletId) -> Option<TabletDescriptor> {
2236        self.meta.state().tablet(tablet_id).cloned()
2237    }
2238
2239    fn remove_tablet(
2240        &mut self,
2241        tablet_id: TabletId,
2242        generation: u64,
2243    ) -> Result<(), MetaRejectionReason> {
2244        self.propose(MetaCommand::RemoveTabletDescriptor {
2245            tablet_id,
2246            generation,
2247        })
2248    }
2249
2250    fn publish_split(&mut self, command: &SplitPublishCommand) -> Result<(), MetaRejectionReason> {
2251        self.propose(MetaCommand::PublishSplit {
2252            command: command.clone(),
2253        })
2254    }
2255}
2256
2257impl MergeMetaPlane for RuntimeMetaPlane {
2258    fn publish_merge(&mut self, command: &MergePublishCommand) -> Result<(), MetaRejectionReason> {
2259        self.propose(MetaCommand::PublishMerge {
2260            command: command.clone(),
2261        })
2262    }
2263}
2264
2265/// The [`TabletKeyspace`] binding over one hosted tablet's core MVCC table.
2266struct RuntimeKeyspace {
2267    sink: Option<Arc<Mutex<EngineApplySink>>>,
2268    pinned: Option<(HlcTimestamp, u64)>,
2269}
2270
2271struct RuntimeSnapshotPin {
2272    ts: HlcTimestamp,
2273    _engine: EngineTabletPin,
2274}
2275
2276impl SnapshotPin for RuntimeSnapshotPin {
2277    fn pinned_at(&self) -> HlcTimestamp {
2278        self.ts
2279    }
2280}
2281
2282impl TabletKeyspace for RuntimeKeyspace {
2283    fn pin_snapshot(&mut self, ts: HlcTimestamp) -> Result<Box<dyn SnapshotPin>, TabletDataError> {
2284        let pin = self
2285            .sink
2286            .as_ref()
2287            .ok_or_else(|| TabletDataError::Keyspace("tablet engine is not open".to_owned()))?
2288            .lock()
2289            .map_err(|_| TabletDataError::Keyspace("engine sink lock poisoned".to_owned()))?
2290            .pin_tablet_snapshot(ts)
2291            .map_err(|error| TabletDataError::Keyspace(error.to_string()))?;
2292        self.pinned = Some((ts, pin.epoch()));
2293        Ok(Box::new(RuntimeSnapshotPin { ts, _engine: pin }))
2294    }
2295
2296    fn snapshot_at(
2297        &self,
2298        ts: HlcTimestamp,
2299    ) -> Result<crate::split::RecordStream<'_>, TabletDataError> {
2300        let (_, epoch) = self
2301            .pinned
2302            .filter(|(pinned, _)| *pinned == ts)
2303            .ok_or_else(|| {
2304                TabletDataError::Keyspace(format!("tablet snapshot {ts:?} is not pinned"))
2305            })?;
2306        let rows = self
2307            .sink
2308            .as_ref()
2309            .ok_or_else(|| TabletDataError::Keyspace("tablet engine is not open".to_owned()))?
2310            .lock()
2311            .map_err(|_| TabletDataError::Keyspace("engine sink lock poisoned".to_owned()))?
2312            .tablet_rows_at_epoch(epoch)
2313            .map_err(|error| TabletDataError::Keyspace(error.to_string()))?;
2314        Ok(Box::new(
2315            rows.into_iter()
2316                .map(|(key, value)| (Key::from_bytes(key), value)),
2317        ))
2318    }
2319
2320    fn deltas_after(
2321        &self,
2322        ts: HlcTimestamp,
2323    ) -> Result<crate::split::MutationStream<'_>, TabletDataError> {
2324        let (_, epoch) = self
2325            .pinned
2326            .filter(|(pinned, _)| *pinned == ts)
2327            .ok_or_else(|| {
2328                TabletDataError::Keyspace(format!("tablet snapshot {ts:?} is not pinned"))
2329            })?;
2330        let deltas = self
2331            .sink
2332            .as_ref()
2333            .ok_or_else(|| TabletDataError::Keyspace("tablet engine is not open".to_owned()))?
2334            .lock()
2335            .map_err(|_| TabletDataError::Keyspace("engine sink lock poisoned".to_owned()))?
2336            .tablet_deltas_after_epoch(epoch)
2337            .map_err(|error| TabletDataError::Keyspace(error.to_string()))?;
2338        Ok(Box::new(deltas.into_iter().map(
2339            |mutation| match mutation {
2340                TabletDataMutation::Upsert(key, value) => {
2341                    TabletMutation::Upsert(Key::from_bytes(key), value)
2342                }
2343                TabletDataMutation::Delete(key) => TabletMutation::Delete(Key::from_bytes(key)),
2344            },
2345        )))
2346    }
2347}
2348
2349/// The [`ChildStateSink`] binding over a child/replacement tablet's live Raft
2350/// group.
2351struct RuntimeChildSink {
2352    group: Arc<ConsensusGroup<TcpTransport>>,
2353    handle: tokio::runtime::Handle,
2354    control: ExecutionControl,
2355    staged: Option<BTreeMap<Key, Vec<u8>>>,
2356}
2357
2358impl RuntimeChildSink {
2359    fn propose_data(&self, command: TabletDataCommand) -> Result<(), TabletDataError> {
2360        let envelope = CommandEnvelope::new(
2361            COMMAND_TYPE_TABLET_DATA,
2362            new_data_command_id().map_err(|error| TabletDataError::Sink(error.to_string()))?,
2363            TabletDataCommandRecord::new(command).encode(),
2364        );
2365        self.handle
2366            .block_on(
2367                self.group
2368                    .propose(CommandKind::Catalog, envelope, &self.control),
2369            )
2370            .map(|_| ())
2371            .map_err(|error| TabletDataError::Sink(error.to_string()))
2372    }
2373}
2374
2375impl ChildStateSink for RuntimeChildSink {
2376    fn begin_build(&mut self) -> Result<(), TabletDataError> {
2377        self.staged = Some(BTreeMap::new());
2378        Ok(())
2379    }
2380
2381    fn stage(&mut self, key: &Key, value: &[u8]) -> Result<(), TabletDataError> {
2382        let staged = self.staged.as_mut().ok_or(TabletDataError::NoStagedBuild)?;
2383        staged.insert(key.clone(), value.to_vec());
2384        Ok(())
2385    }
2386
2387    fn install_staged(&mut self) -> Result<(), TabletDataError> {
2388        let rows = self.staged.take().ok_or(TabletDataError::NoStagedBuild)?;
2389        self.propose_data(TabletDataCommand::Replace {
2390            rows: rows
2391                .into_iter()
2392                .map(|(key, value)| (key.into_bytes(), value))
2393                .collect(),
2394        })
2395    }
2396
2397    fn apply_delta(&mut self, mutation: &TabletMutation) -> Result<(), TabletDataError> {
2398        match mutation {
2399            TabletMutation::Upsert(key, value) => self.propose_data(TabletDataCommand::Upsert {
2400                entries: vec![(key.as_bytes().to_vec(), value.clone())],
2401            }),
2402            TabletMutation::Delete(key) => self.propose_data(TabletDataCommand::Delete {
2403                keys: vec![key.as_bytes().to_vec()],
2404            }),
2405        }
2406    }
2407}
2408
2409/// The outcome of one [`NodeRuntime::sync_hosted_tablets`] pass.
2410#[derive(Clone, Debug, Default, PartialEq, Eq)]
2411pub struct HostedSyncReport {
2412    /// Local replicas created for meta descriptors listing this node.
2413    pub created: Vec<TabletId>,
2414    /// Hosted tablets whose persisted `tablet.json` and in-memory
2415    /// descriptor advanced to the meta generation.
2416    pub refreshed: Vec<TabletId>,
2417    /// Hosted groups shut down and torn down (their descriptor left the
2418    /// meta state or no longer lists this node).
2419    pub torn_down: Vec<TabletId>,
2420}
2421
2422#[cfg(test)]
2423mod tests {
2424    use super::*;
2425
2426    #[test]
2427    fn layout_scan_tolerates_a_node_without_tablets() {
2428        let tmp = tempfile::tempdir().unwrap();
2429        assert_eq!(scan_tablet_layouts(tmp.path()).unwrap(), Vec::new());
2430        // An empty tablets directory scans clean too.
2431        std::fs::create_dir_all(tmp.path().join(TABLETS_DIR)).unwrap();
2432        assert_eq!(scan_tablet_layouts(tmp.path()).unwrap(), Vec::new());
2433    }
2434
2435    #[test]
2436    fn layout_scan_fails_closed_on_garbage_directories() {
2437        let tmp = tempfile::tempdir().unwrap();
2438        // A directory that is not a tablet id is rejected.
2439        let garbage = tmp.path().join(TABLETS_DIR).join("not-a-tablet");
2440        std::fs::create_dir_all(&garbage).unwrap();
2441        assert!(matches!(
2442            scan_tablet_layouts(tmp.path()),
2443            Err(RuntimeError::InvalidRequest(_))
2444        ));
2445        std::fs::remove_dir_all(&garbage).unwrap();
2446        // A tablet directory missing its metadata fails closed (spec section
2447        // 12.3), exactly as `TabletLayout::validate` does.
2448        let tablet_id = TabletId::new_random();
2449        std::fs::create_dir_all(tmp.path().join(TABLETS_DIR).join(tablet_id.to_hex())).unwrap();
2450        assert!(matches!(
2451            scan_tablet_layouts(tmp.path()),
2452            Err(RuntimeError::Tablet(TabletError::MissingMetadata(_)))
2453        ));
2454    }
2455
2456    // -- v0.60.3 ledger migration fixtures ------------------------------------
2457
2458    fn ts(micros: u64) -> HlcTimestamp {
2459        HlcTimestamp {
2460            physical_micros: micros,
2461            logical: 0,
2462            node_tiebreaker: 0,
2463        }
2464    }
2465
2466    fn pos(index: u64) -> LogPosition {
2467        LogPosition { term: 1, index }
2468    }
2469
2470    fn upsert(key: &[u8], value: &[u8]) -> TabletDataCommand {
2471        TabletDataCommand::Upsert {
2472            entries: vec![(key.to_vec(), value.to_vec())],
2473        }
2474    }
2475
2476    #[test]
2477    fn tablet_ledger_applies_versions_and_partitions_the_timeline() {
2478        let tmp = tempfile::tempdir().unwrap();
2479        let mut ledger = TabletLedger::open(tmp.path()).unwrap();
2480        ledger
2481            .apply(&upsert(b"a", b"a@1"), ts(100), pos(1))
2482            .unwrap();
2483        ledger
2484            .apply(&upsert(b"b", b"b@1"), ts(100), pos(2))
2485            .unwrap();
2486        // A pin at the snapshot timestamp protects the at-or-below view
2487        // from compaction (the split/merge executor pattern).
2488        ledger.pin(ts(100));
2489        ledger
2490            .apply(&upsert(b"a", b"a@2"), ts(200), pos(3))
2491            .unwrap();
2492        // The at-or-below view splits exactly at the timestamp.
2493        assert_eq!(
2494            ledger.rows_at(ts(100)),
2495            BTreeMap::from([
2496                (Key::from_bytes(b"a".to_vec()), b"a@1".to_vec()),
2497                (Key::from_bytes(b"b".to_vec()), b"b@1".to_vec()),
2498            ])
2499        );
2500        assert_eq!(
2501            ledger.current_rows().get(&Key::from_bytes(b"a".to_vec())),
2502            Some(&b"a@2".to_vec())
2503        );
2504        // Deltas after the pin timestamp arrive in commit order.
2505        assert_eq!(
2506            ledger.deltas_after(ts(100)),
2507            vec![(Key::from_bytes(b"a".to_vec()), b"a@2".to_vec())]
2508        );
2509        assert!(ledger.deltas_after(ts(200)).is_empty());
2510        ledger.unpin(ts(100));
2511        // Redelivery at or below the watermark is skipped.
2512        ledger
2513            .apply(&upsert(b"z", b"z@1"), ts(300), pos(3))
2514            .unwrap();
2515        assert!(!ledger
2516            .current_rows()
2517            .contains_key(&Key::from_bytes(b"z".to_vec())));
2518        // The checkpoint survives a restart; the watermark dedups replay.
2519        let position = ledger.applied_position();
2520        drop(ledger);
2521        let reopened = TabletLedger::open(tmp.path()).unwrap();
2522        assert_eq!(reopened.applied_position(), position);
2523        assert_eq!(
2524            reopened.current_rows().get(&Key::from_bytes(b"a".to_vec())),
2525            Some(&b"a@2".to_vec())
2526        );
2527        // A corrupt checkpoint fails closed.
2528        std::fs::write(
2529            tmp.path()
2530                .join("raft")
2531                .join("state")
2532                .join(TABLET_LEDGER_FILENAME),
2533            b"junk",
2534        )
2535        .unwrap();
2536        assert!(TabletLedger::open(tmp.path()).is_err());
2537    }
2538
2539    #[test]
2540    fn tablet_ledger_compacts_against_the_oldest_pin() {
2541        let tmp = tempfile::tempdir().unwrap();
2542        let mut ledger = TabletLedger::open(tmp.path()).unwrap();
2543        ledger
2544            .apply(&upsert(b"a", b"a@1"), ts(100), pos(1))
2545            .unwrap();
2546        // Pin at 150, then write newer versions: the at-or-below baseline
2547        // (a@1) survives compaction while the pin lives.
2548        ledger.pin(ts(150));
2549        ledger
2550            .apply(&upsert(b"a", b"a@2"), ts(200), pos(2))
2551            .unwrap();
2552        ledger
2553            .apply(&upsert(b"a", b"a@3"), ts(300), pos(3))
2554            .unwrap();
2555        assert_eq!(
2556            ledger.rows_at(ts(150)).get(&Key::from_bytes(b"a".to_vec())),
2557            Some(&b"a@1".to_vec())
2558        );
2559        assert_eq!(
2560            ledger.deltas_after(ts(150)),
2561            vec![
2562                (Key::from_bytes(b"a".to_vec()), b"a@2".to_vec()),
2563                (Key::from_bytes(b"a".to_vec()), b"a@3".to_vec()),
2564            ]
2565        );
2566        // Releasing the pin lets the chain collapse to the newest version.
2567        ledger.unpin(ts(150));
2568        ledger
2569            .apply(&upsert(b"a", b"a@4"), ts(400), pos(4))
2570            .unwrap();
2571        assert_eq!(
2572            ledger.rows_at(ts(150)).get(&Key::from_bytes(b"a".to_vec())),
2573            None
2574        );
2575        assert_eq!(
2576            ledger.current_rows().get(&Key::from_bytes(b"a".to_vec())),
2577            Some(&b"a@4".to_vec())
2578        );
2579        assert_eq!(ledger.pin_count(), 0);
2580    }
2581
2582    #[test]
2583    fn tablet_ledger_replace_is_atomic_and_refused_under_a_live_pin() {
2584        let tmp = tempfile::tempdir().unwrap();
2585        let mut ledger = TabletLedger::open(tmp.path()).unwrap();
2586        ledger
2587            .apply(&upsert(b"a", b"a@1"), ts(100), pos(1))
2588            .unwrap();
2589        ledger.pin(ts(150));
2590        let replace = TabletDataCommand::Replace {
2591            rows: vec![(b"b".to_vec(), b"b@2".to_vec())],
2592        };
2593        assert!(ledger.apply(&replace, ts(200), pos(2)).is_err());
2594        ledger.unpin(ts(150));
2595        ledger.apply(&replace, ts(200), pos(2)).unwrap();
2596        assert_eq!(
2597            ledger.current_rows(),
2598            BTreeMap::from([(Key::from_bytes(b"b".to_vec()), b"b@2".to_vec())])
2599        );
2600        // The snapshot bytes install into a fresh ledger (raft catch-up).
2601        let bytes = ledger.snapshot_bytes().unwrap();
2602        let follower_dir = tempfile::tempdir().unwrap();
2603        let mut follower = TabletLedger::open(follower_dir.path()).unwrap();
2604        follower.install_bytes(&bytes).unwrap();
2605        assert_eq!(follower.current_rows(), ledger.current_rows());
2606        assert!(follower.install_bytes(b"junk").is_err());
2607    }
2608
2609    #[test]
2610    fn group_snapshot_frame_round_trips_and_fails_closed() {
2611        let engine = b"engine-half".to_vec();
2612        let ledger = b"ledger-half".to_vec();
2613        let frame = encode_group_snapshot(&engine, &ledger);
2614        let (engine_back, ledger_back) = decode_group_snapshot(&frame).unwrap();
2615        assert_eq!(engine_back, engine);
2616        assert_eq!(ledger_back, ledger);
2617        // Empty halves frame fine.
2618        let (empty_engine, empty_ledger) =
2619            decode_group_snapshot(&encode_group_snapshot(&[], &[])).unwrap();
2620        assert!(empty_engine.is_empty() && empty_ledger.is_empty());
2621        // Truncations and unknown versions fail closed: a short header or a
2622        // truncated engine half (the frame carries its length) is rejected;
2623        // a short ledger half is rejected at install.
2624        assert!(decode_group_snapshot(&frame[..6]).is_err());
2625        assert!(decode_group_snapshot(&frame[..12 + engine.len() - 1]).is_err());
2626        let mut future = frame.clone();
2627        future[..4].copy_from_slice(&99_u32.to_le_bytes());
2628        assert!(decode_group_snapshot(&future).is_err());
2629    }
2630
2631    #[test]
2632    fn tablet_data_command_record_round_trips_and_fails_closed() {
2633        let record = TabletDataCommandRecord::new(upsert(b"k", b"v"));
2634        let decoded = TabletDataCommandRecord::decode(&record.encode()).unwrap();
2635        assert_eq!(decoded, record);
2636        assert!(TabletDataCommandRecord::decode(b"not json").is_err());
2637        let mut value: serde_json::Value = serde_json::from_slice(&record.encode()).unwrap();
2638        value["format_version"] = serde_json::json!(99);
2639        assert!(TabletDataCommandRecord::decode(&serde_json::to_vec(&value).unwrap()).is_err());
2640    }
2641}