use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use mongreldb_consensus::error::ConsensusError;
use mongreldb_consensus::group::{ConsensusGroup, GroupCommitReceipt, GroupConfig};
use mongreldb_consensus::identity::{raft_node_id, CommandKind, RaftNodeId, ReplicatedCommand};
use mongreldb_consensus::network::RaftTransport;
use mongreldb_consensus::state_machine::{AppliedCommand, ApplySink, StateMachineError};
use mongreldb_consensus::storage::StorageConfig;
use mongreldb_log::commit_log::{ExecutionControl, LogPosition};
use mongreldb_log::envelope::CommandEnvelope;
use mongreldb_types::hlc::HlcTimestamp;
use mongreldb_types::ids::{
DatabaseId, MetadataVersion, NodeId, RaftGroupId, SchemaVersion, TableId, TabletId,
};
use serde::{Deserialize, Serialize};
use crate::merge::MergePublishCommand;
use crate::node::{Incompatibility, NodeDescriptor, NodeState, VersionInfo};
use crate::split::SplitPublishCommand;
use crate::tablet::{Bound, Key};
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
pub struct ClusterFeatureLevel(pub u64);
impl ClusterFeatureLevel {
pub const ZERO: Self = Self(0);
}
impl fmt::Display for ClusterFeatureLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FeatureRegistry {
required_level: BTreeMap<String, u64>,
}
impl FeatureRegistry {
pub fn current() -> Self {
Self::default()
}
pub fn declare(&mut self, feature: impl Into<String>, level: ClusterFeatureLevel) {
self.required_level.insert(feature.into(), level.0);
}
pub fn required_level(&self, feature: &str) -> Option<ClusterFeatureLevel> {
self.required_level
.get(feature)
.copied()
.map(ClusterFeatureLevel)
}
pub fn feature_supported(&self, level: ClusterFeatureLevel, feature: &str) -> bool {
self.required_level(feature)
.is_some_and(|required| level >= required)
}
pub fn feature_names(&self) -> BTreeSet<String> {
self.required_level.keys().cloned().collect()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeatureActivation {
pub feature: String,
pub level: ClusterFeatureLevel,
pub activated_at: HlcTimestamp,
pub activated_by: NodeId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
pub enum FeatureActivationError {
#[error("feature `{feature}` is not declared in the feature registry")]
UnknownFeature {
feature: String,
},
#[error(
"feature `{feature}` requires cluster feature level {required}; \
activation attempted at {attempted}"
)]
LevelBelowRequirement {
feature: String,
required: ClusterFeatureLevel,
attempted: ClusterFeatureLevel,
},
#[error(
"cluster feature level never lowers: current level {current}, \
activation attempted at {attempted}"
)]
LevelRegression {
current: ClusterFeatureLevel,
attempted: ClusterFeatureLevel,
},
#[error("feature `{feature}` cannot activate: voter {node} does not support it")]
UnsupportedByVoter {
feature: String,
node: NodeId,
},
#[error("feature activation requires at least one voter")]
NoVoters,
}
impl FeatureActivation {
pub fn validate(
&self,
registry: &FeatureRegistry,
current_level: ClusterFeatureLevel,
voters: &[NodeDescriptor],
) -> Result<(), FeatureActivationError> {
let required = registry.required_level(&self.feature).ok_or_else(|| {
FeatureActivationError::UnknownFeature {
feature: self.feature.clone(),
}
})?;
if self.level < required {
return Err(FeatureActivationError::LevelBelowRequirement {
feature: self.feature.clone(),
required,
attempted: self.level,
});
}
if self.level < current_level {
return Err(FeatureActivationError::LevelRegression {
current: current_level,
attempted: self.level,
});
}
if voters.is_empty() {
return Err(FeatureActivationError::NoVoters);
}
for voter in voters {
if !voter.version_info.feature_set.contains(&self.feature) {
return Err(FeatureActivationError::UnsupportedByVoter {
feature: self.feature.clone(),
node: voter.node_id,
});
}
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UpgradeStep {
UpgradeFollower {
node_id: NodeId,
},
TransferLeadership {
from: NodeId,
},
UpgradeFormerLeader {
node_id: NodeId,
},
EnableNewFeatures,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpgradePlan {
pub target: VersionInfo,
pub steps: Vec<UpgradeStep>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UpgradePlanError {
#[error("cannot plan a rolling upgrade for an empty membership")]
EmptyMembership,
#[error("current leader {leader} is not present in the supplied membership")]
LeaderNotInMembership {
leader: NodeId,
},
#[error("node {node} appears more than once in the supplied membership")]
DuplicateNode {
node: NodeId,
},
#[error("node {node} is not compatible with the upgrade target: {incompatibility}")]
IncompatibleNode {
node: NodeId,
incompatibility: Incompatibility,
},
}
pub fn plan_rolling_upgrade(
nodes: &[NodeDescriptor],
current_leader: NodeId,
target: &VersionInfo,
) -> Result<UpgradePlan, UpgradePlanError> {
if nodes.is_empty() {
return Err(UpgradePlanError::EmptyMembership);
}
for (index, node) in nodes.iter().enumerate() {
if nodes[..index]
.iter()
.any(|prior| prior.node_id == node.node_id)
{
return Err(UpgradePlanError::DuplicateNode { node: node.node_id });
}
}
if !nodes.iter().any(|node| node.node_id == current_leader) {
return Err(UpgradePlanError::LeaderNotInMembership {
leader: current_leader,
});
}
for node in nodes {
if let Err(incompatibility) = node.version_info.is_compatible_with(target) {
return Err(UpgradePlanError::IncompatibleNode {
node: node.node_id,
incompatibility,
});
}
}
let mut steps = Vec::with_capacity(nodes.len() + 2);
for node in nodes {
if node.node_id != current_leader {
steps.push(UpgradeStep::UpgradeFollower {
node_id: node.node_id,
});
}
}
if nodes.len() > 1 {
steps.push(UpgradeStep::TransferLeadership {
from: current_leader,
});
}
steps.push(UpgradeStep::UpgradeFormerLeader {
node_id: current_leader,
});
steps.push(UpgradeStep::EnableNewFeatures);
Ok(UpgradePlan {
target: target.clone(),
steps,
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RollbackPath {
BinaryDowngrade,
RestoreFromBackup,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RollbackAssessment {
pub path: RollbackPath,
pub activated_features: Vec<String>,
}
pub fn assess_rollback(activations: &[FeatureActivation]) -> RollbackAssessment {
let activated_features: Vec<String> = activations
.iter()
.map(|activation| activation.feature.clone())
.collect();
let path = if activated_features.is_empty() {
RollbackPath::BinaryDowngrade
} else {
RollbackPath::RestoreFromBackup
};
RollbackAssessment {
path,
activated_features,
}
}
pub const META_COMMAND_FORMAT_VERSION: u32 = 2;
pub const MIN_SUPPORTED_META_COMMAND_FORMAT_VERSION: u32 = 1;
pub const META_STATE_FORMAT_VERSION: u32 = 2;
pub const MIN_SUPPORTED_META_STATE_FORMAT_VERSION: u32 = 1;
pub const COMMAND_TYPE_META_COMMAND: u32 = 4;
pub const META_REJECTION_LIMIT: usize = 256;
pub const FIRST_RAFT_NODE_ID: u64 = 1;
pub const MAX_RAFT_NODE_ID_ALLOCATION: u32 = 4096;
pub const RAFT_ID_ALLOCATION_RECORD_LIMIT: usize = 1024;
pub const RAFT_ID_ALLOCATION_SCAN_LIMIT: u64 = 1 << 20;
pub const SECRET_SETTING_KEY_DENYLIST: &[&str] = &[
"secret",
"password",
"passwd",
"private_key",
"api_key",
"token",
"credential",
];
pub const KNOWN_SETTING_KEYS: &[&str] = &[
"history_retention_epochs",
"backup.enabled",
"backup.interval_seconds",
"backup.retention_count",
"default_consistency",
"ai.max_concurrent_requests",
"ai.max_memory_bytes",
"jobs.max_concurrent",
];
pub const DEFAULT_MAX_CONCURRENT_JOBS: u32 = 2;
pub const DEFAULT_BACKUP_INTERVAL_SECONDS: u64 = 86_400;
pub const DEFAULT_BACKUP_RETENTION_COUNT: u32 = 7;
pub const DEFAULT_AI_MAX_CONCURRENT_REQUESTS: u32 = 16;
pub const DEFAULT_AI_MAX_MEMORY_BYTES: u64 = 512 * 1024 * 1024;
fn zero_metadata_version() -> MetadataVersion {
MetadataVersion::ZERO
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
#[derive(Debug, thiserror::Error)]
pub enum MetaError {
#[error(transparent)]
Consensus(#[from] ConsensusError),
#[error("meta command encoding failed: {0}")]
Encode(String),
#[error("meta command refused at apply: {0}")]
Rejected(MetaRejectionReason),
#[error("invalid meta group request: {0}")]
InvalidRequest(String),
#[error("meta group I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("operating-system CSPRNG failed: {0}")]
Rng(String),
#[error("corrupt meta state checkpoint: {0}")]
CorruptCheckpoint(String),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MetaDecodeError {
#[error("meta command decode failed: {0}")]
Malformed(String),
#[error("unsupported meta command format version {found} (supported {min}..={max})")]
UnsupportedVersion {
found: u32,
min: u32,
max: u32,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DatabaseState {
Online,
Dropping,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DatabaseDescriptor {
pub database_id: DatabaseId,
pub name: String,
pub created_at: HlcTimestamp,
pub state: DatabaseState,
#[serde(default = "zero_metadata_version")]
pub metadata_version: MetadataVersion,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TableSchemaRecord {
pub table_id: TableId,
pub database_id: DatabaseId,
pub schema_version: SchemaVersion,
pub schema: serde_json::Value,
#[serde(default = "zero_metadata_version")]
pub metadata_version: MetadataVersion,
}
pub use crate::placement::LocalityConstraint;
pub use crate::placement::PlacementPolicy;
pub use crate::tablet::PartitionBounds;
pub use crate::tablet::ReplicaDescriptor;
pub use crate::tablet::ReplicaRole;
pub use crate::tablet::TabletDescriptor;
pub use crate::tablet::TabletState;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplicaPlacement {
pub raft_group_id: RaftGroupId,
pub replicas: Vec<ReplicaDescriptor>,
#[serde(default = "zero_metadata_version")]
pub metadata_version: MetadataVersion,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabletRecord {
pub descriptor: TabletDescriptor,
#[serde(default = "zero_metadata_version")]
pub metadata_version: MetadataVersion,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlacementPolicyRecord {
pub policy: PlacementPolicy,
#[serde(default = "zero_metadata_version")]
pub metadata_version: MetadataVersion,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SchemaJobKind {
IndexBuild,
ColumnBackfill,
SchemaValidation,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SchemaJobState {
Pending,
Running,
Paused,
Cancelling,
Succeeded,
Failed,
RollingBack,
}
impl SchemaJobState {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Succeeded | Self::Failed)
}
pub fn can_transition(self, next: Self) -> bool {
use SchemaJobState::{
Cancelling, Failed, Paused, Pending, RollingBack, Running, Succeeded,
};
matches!(
(self, next),
(Pending, Running)
| (Pending, Cancelling)
| (Running, Paused)
| (Running, Cancelling)
| (Running, RollingBack)
| (Running, Succeeded)
| (Paused, Pending)
| (Paused, Cancelling)
| (Cancelling, RollingBack)
| (Cancelling, Failed)
| (RollingBack, Failed)
)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaJobRecord {
pub job_id: u64,
pub database_id: DatabaseId,
pub table_id: TableId,
pub kind: SchemaJobKind,
pub state: SchemaJobState,
pub submitted_at: HlcTimestamp,
pub updated_at: HlcTimestamp,
pub error: Option<String>,
#[serde(default = "zero_metadata_version")]
pub metadata_version: MetadataVersion,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TxnStatusPartition {
pub partition_id: u32,
pub home_raft_group: RaftGroupId,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ResourceGroupSetting {
pub max_memory_bytes: u64,
pub max_concurrent_queries: u32,
pub temp_disk_budget_bytes: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct BackupPolicy {
pub enabled: bool,
pub interval_seconds: u64,
pub retention_count: u32,
}
impl Default for BackupPolicy {
fn default() -> Self {
Self {
enabled: false,
interval_seconds: DEFAULT_BACKUP_INTERVAL_SECONDS,
retention_count: DEFAULT_BACKUP_RETENTION_COUNT,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AiLimits {
pub max_concurrent_requests: u32,
pub max_memory_bytes: u64,
}
impl Default for AiLimits {
fn default() -> Self {
Self {
max_concurrent_requests: DEFAULT_AI_MAX_CONCURRENT_REQUESTS,
max_memory_bytes: DEFAULT_AI_MAX_MEMORY_BYTES,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum DefaultConsistency {
#[default]
Linearizable,
ReadYourWrites,
Snapshot,
BoundedStaleness {
max_lag_ms: u64,
},
Eventual,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ClusterSettings {
pub resource_groups: BTreeMap<String, ResourceGroupSetting>,
pub history_retention_epochs: u64,
pub backup: BackupPolicy,
pub default_consistency: DefaultConsistency,
pub ai: AiLimits,
pub max_concurrent_jobs: u32,
}
impl ClusterSettings {
fn apply(&mut self, key: &str, value: &serde_json::Value) -> Result<(), MetaRejectionReason> {
let lowered = key.to_ascii_lowercase();
if SECRET_SETTING_KEY_DENYLIST
.iter()
.any(|needle| lowered.contains(needle))
{
return Err(MetaRejectionReason::SecretSettingKey {
key: key.to_owned(),
});
}
fn invalid(key: &str, reason: impl Into<String>) -> MetaRejectionReason {
MetaRejectionReason::InvalidSettingValue {
key: key.to_owned(),
reason: reason.into(),
}
}
match key {
"history_retention_epochs" => {
self.history_retention_epochs = value
.as_u64()
.ok_or_else(|| invalid(key, "expected an unsigned integer"))?;
}
"backup.enabled" => {
self.backup.enabled = value
.as_bool()
.ok_or_else(|| invalid(key, "expected a boolean"))?;
}
"backup.interval_seconds" => {
let interval = value
.as_u64()
.ok_or_else(|| invalid(key, "expected an unsigned integer"))?;
if interval == 0 {
return Err(invalid(key, "interval must be positive"));
}
self.backup.interval_seconds = interval;
}
"backup.retention_count" => {
let retention = value
.as_u64()
.ok_or_else(|| invalid(key, "expected an unsigned integer"))?;
self.backup.retention_count = u32::try_from(retention)
.map_err(|_| invalid(key, "retention count exceeds u32"))?;
}
"default_consistency" => {
self.default_consistency =
serde_json::from_value(value.clone()).map_err(|error| {
invalid(
key,
format!(
"expected one of \"Linearizable\", \"ReadYourWrites\", \
\"Snapshot\", \"Eventual\", or \
{{\"BoundedStaleness\": {{\"max_lag_ms\": ..}}}}: {error}"
),
)
})?;
}
"ai.max_concurrent_requests" => {
let cap = value
.as_u64()
.ok_or_else(|| invalid(key, "expected an unsigned integer"))?;
self.ai.max_concurrent_requests =
u32::try_from(cap).map_err(|_| invalid(key, "cap exceeds u32"))?;
}
"ai.max_memory_bytes" => {
self.ai.max_memory_bytes = value
.as_u64()
.ok_or_else(|| invalid(key, "expected an unsigned integer"))?;
}
"jobs.max_concurrent" => {
let cap = value
.as_u64()
.ok_or_else(|| invalid(key, "expected an unsigned integer"))?;
if cap == 0 {
return Err(invalid(key, "job concurrency must be positive"));
}
self.max_concurrent_jobs =
u32::try_from(cap).map_err(|_| invalid(key, "cap exceeds u32"))?;
}
_ => {
let Some(name) = key.strip_prefix("resource_groups.") else {
return Err(MetaRejectionReason::UnknownSettingKey {
key: key.to_owned(),
});
};
if name.is_empty() {
return Err(MetaRejectionReason::UnknownSettingKey {
key: key.to_owned(),
});
}
if value.is_null() {
self.resource_groups.remove(name);
} else {
let group: ResourceGroupSetting = serde_json::from_value(value.clone())
.map_err(|error| {
invalid(key, format!("expected a resource-group object: {error}"))
})?;
self.resource_groups.insert(name.to_owned(), group);
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
pub enum MetaRejectionReason {
#[error(transparent)]
FeatureActivation(#[from] FeatureActivationError),
#[error(
"cluster setting key `{key}` is denied: secrets are never stored as \
plaintext cluster settings"
)]
SecretSettingKey {
key: String,
},
#[error("unknown cluster setting key `{key}`")]
UnknownSettingKey {
key: String,
},
#[error("invalid value for cluster setting `{key}`: {reason}")]
InvalidSettingValue {
key: String,
reason: String,
},
#[error("stale write to {resource}: current version {current}, attempted {attempted}")]
StaleWrite {
resource: String,
current: MetadataVersion,
attempted: MetadataVersion,
},
#[error("conflicting {resource}: {reason}")]
Conflict {
resource: String,
reason: String,
},
#[error("{resource} not found")]
NotFound {
resource: String,
},
#[error("invalid meta command: {reason}")]
Invalid {
reason: String,
},
#[error("meta proposal failed: {reason}")]
ProposalFailed {
reason: String,
},
#[error("not the meta leader (current leader: {leader:?})")]
NotLeader {
leader: Option<u64>,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MetaRejection {
pub command_id: Option<[u8; 16]>,
pub reason: MetaRejectionReason,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum MetaCommand {
RegisterNode {
descriptor: NodeDescriptor,
},
UpdateNodeState {
node_id: NodeId,
state: NodeState,
expected_version: Option<MetadataVersion>,
},
RemoveNode {
node_id: NodeId,
},
CreateDatabase {
descriptor: DatabaseDescriptor,
},
DropDatabase {
database_id: DatabaseId,
},
SetTableSchema {
record: TableSchemaRecord,
},
SetTabletDescriptor {
descriptor: TabletDescriptor,
},
RemoveTabletDescriptor {
tablet_id: TabletId,
generation: u64,
},
SetReplicaPlacement {
placement: ReplicaPlacement,
},
SetPlacementPolicy {
name: String,
policy: PlacementPolicy,
},
SubmitSchemaJob {
job: SchemaJobRecord,
},
UpdateSchemaJob {
job_id: u64,
state: SchemaJobState,
updated_at: HlcTimestamp,
error: Option<String>,
expected_version: Option<MetadataVersion>,
},
SetClusterSetting {
key: String,
value: serde_json::Value,
},
ActivateFeature {
activation: FeatureActivation,
},
SetTxnStatusPartition {
partition: TxnStatusPartition,
},
PublishSplit {
command: SplitPublishCommand,
},
PublishMerge {
command: MergePublishCommand,
},
AllocateRaftNodeIds {
count: u32,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MetaCommandRecord {
pub format_version: u32,
pub command: MetaCommand,
}
impl MetaCommandRecord {
pub fn new(command: MetaCommand) -> Self {
Self {
format_version: META_COMMAND_FORMAT_VERSION,
command,
}
}
pub fn encode(&self) -> Result<Vec<u8>, MetaError> {
serde_json::to_vec(self).map_err(|error| MetaError::Encode(error.to_string()))
}
pub fn decode(payload: &[u8]) -> Result<Self, MetaDecodeError> {
let probe: FormatVersionProbe = serde_json::from_slice(payload)
.map_err(|error| MetaDecodeError::Malformed(error.to_string()))?;
if probe.format_version < MIN_SUPPORTED_META_COMMAND_FORMAT_VERSION
|| probe.format_version > META_COMMAND_FORMAT_VERSION
{
return Err(MetaDecodeError::UnsupportedVersion {
found: probe.format_version,
min: MIN_SUPPORTED_META_COMMAND_FORMAT_VERSION,
max: META_COMMAND_FORMAT_VERSION,
});
}
if probe.format_version == 1 {
let record: v1::MetaCommandRecord = serde_json::from_slice(payload)
.map_err(|error| MetaDecodeError::Malformed(error.to_string()))?;
if record.format_version != probe.format_version {
return Err(MetaDecodeError::Malformed(
"inconsistent format version".to_owned(),
));
}
return Ok(Self {
format_version: META_COMMAND_FORMAT_VERSION,
command: migrate_command(record.command),
});
}
serde_json::from_slice(payload)
.map_err(|error| MetaDecodeError::Malformed(error.to_string()))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeRecord {
pub descriptor: NodeDescriptor,
#[serde(default = "zero_metadata_version")]
pub metadata_version: MetadataVersion,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct MetaState {
pub format_version: u32,
pub metadata_version: MetadataVersion,
pub nodes: BTreeMap<NodeId, NodeRecord>,
pub databases: BTreeMap<DatabaseId, DatabaseDescriptor>,
pub tables: BTreeMap<TableId, TableSchemaRecord>,
pub tablets: BTreeMap<TabletId, TabletRecord>,
pub placements: BTreeMap<RaftGroupId, ReplicaPlacement>,
pub placement_policies: BTreeMap<String, PlacementPolicyRecord>,
pub schema_jobs: BTreeMap<u64, SchemaJobRecord>,
pub txn_status_partitions: BTreeMap<u32, TxnStatusPartition>,
pub settings: ClusterSettings,
pub feature_level: ClusterFeatureLevel,
pub feature_activations: Vec<FeatureActivation>,
pub rejections: VecDeque<MetaRejection>,
pub next_raft_node_id: u64,
pub raft_id_allocations: BTreeMap<String, u64>,
pub raft_id_allocation_order: VecDeque<String>,
}
impl Default for MetaState {
fn default() -> Self {
Self {
format_version: META_STATE_FORMAT_VERSION,
metadata_version: MetadataVersion::ZERO,
nodes: BTreeMap::new(),
databases: BTreeMap::new(),
tables: BTreeMap::new(),
tablets: BTreeMap::new(),
placements: BTreeMap::new(),
placement_policies: BTreeMap::new(),
schema_jobs: BTreeMap::new(),
txn_status_partitions: BTreeMap::new(),
settings: ClusterSettings::default(),
feature_level: ClusterFeatureLevel::ZERO,
feature_activations: Vec::new(),
rejections: VecDeque::new(),
next_raft_node_id: FIRST_RAFT_NODE_ID,
raft_id_allocations: BTreeMap::new(),
raft_id_allocation_order: VecDeque::new(),
}
}
}
impl MetaState {
pub fn apply(
&mut self,
command: &MetaCommand,
command_id: Option<[u8; 16]>,
commit_ts: HlcTimestamp,
registry: &FeatureRegistry,
) -> Result<(), MetaRejectionReason> {
self.metadata_version = MetadataVersion(self.metadata_version.get() + 1);
let version = self.metadata_version;
let result = self.dispatch(command, command_id, commit_ts, registry, version);
if let Err(reason) = &result {
self.rejections.push_back(MetaRejection {
command_id,
reason: reason.clone(),
});
while self.rejections.len() > META_REJECTION_LIMIT {
self.rejections.pop_front();
}
}
result
}
fn dispatch(
&mut self,
command: &MetaCommand,
command_id: Option<[u8; 16]>,
commit_ts: HlcTimestamp,
registry: &FeatureRegistry,
version: MetadataVersion,
) -> Result<(), MetaRejectionReason> {
match command {
MetaCommand::RegisterNode { descriptor } => {
if descriptor.node_id == NodeId::ZERO {
return Err(MetaRejectionReason::Invalid {
reason: "reserved all-zero node id".to_owned(),
});
}
match self.nodes.get(&descriptor.node_id) {
Some(existing) if existing.descriptor == *descriptor => Ok(()),
_ => {
let projected = raft_node_id(&descriptor.node_id);
let assigned_to_replica = self.tablets.values().any(|record| {
record
.descriptor
.replicas
.iter()
.any(|replica| replica.raft_node_id == projected)
}) || self.placements.values().any(|placement| {
placement
.replicas
.iter()
.any(|replica| replica.raft_node_id == projected)
});
if assigned_to_replica {
return Err(MetaRejectionReason::Conflict {
resource: format!("node {}", descriptor.node_id),
reason: format!(
"raft id projection {projected} is already assigned to a \
tablet or placement replica; re-mint the node id"
),
});
}
self.nodes.insert(
descriptor.node_id,
NodeRecord {
descriptor: descriptor.clone(),
metadata_version: version,
},
);
Ok(())
}
}
}
MetaCommand::UpdateNodeState {
node_id,
state,
expected_version,
} => {
let Some(record) = self.nodes.get_mut(node_id) else {
return Err(MetaRejectionReason::NotFound {
resource: format!("node {node_id}"),
});
};
if let Some(expected) = expected_version {
if *expected != record.metadata_version {
return Err(MetaRejectionReason::StaleWrite {
resource: format!("node {node_id}"),
current: record.metadata_version,
attempted: *expected,
});
}
}
if record.descriptor.state == NodeState::Decommissioned {
return Err(MetaRejectionReason::Conflict {
resource: format!("node {node_id}"),
reason: "decommissioned is terminal".to_owned(),
});
}
if record.descriptor.state == *state {
return Ok(());
}
record.descriptor.state = *state;
record.metadata_version = version;
Ok(())
}
MetaCommand::RemoveNode { node_id } => {
if !self.nodes.contains_key(node_id) {
return Ok(());
}
let referenced_by_placement = self
.placements
.values()
.any(|placement| placement.replicas.iter().any(|r| r.node_id == *node_id));
let referenced_by_tablet = self.tablets.values().any(|record| {
record
.descriptor
.replicas
.iter()
.any(|r| r.node_id == *node_id)
|| record.descriptor.leader_hint == Some(*node_id)
});
if referenced_by_placement || referenced_by_tablet {
return Err(MetaRejectionReason::Conflict {
resource: format!("node {node_id}"),
reason: "node still hosts a tablet or placement replica".to_owned(),
});
}
self.nodes.remove(node_id);
Ok(())
}
MetaCommand::CreateDatabase { descriptor } => {
if descriptor.database_id == DatabaseId::ZERO {
return Err(MetaRejectionReason::Invalid {
reason: "reserved all-zero database id".to_owned(),
});
}
if descriptor.name.trim().is_empty() {
return Err(MetaRejectionReason::Invalid {
reason: "database name is empty".to_owned(),
});
}
if let Some(existing) = self.databases.get(&descriptor.database_id) {
return if existing.name == descriptor.name
&& existing.state == descriptor.state
&& existing.created_at == descriptor.created_at
{
Ok(())
} else {
Err(MetaRejectionReason::Conflict {
resource: format!("database {}", descriptor.database_id),
reason: "database id already exists with different content".to_owned(),
})
};
}
if self
.databases
.values()
.any(|database| database.name == descriptor.name)
{
return Err(MetaRejectionReason::Conflict {
resource: format!("database `{}`", descriptor.name),
reason: "database name already taken".to_owned(),
});
}
let mut descriptor = descriptor.clone();
descriptor.metadata_version = version;
self.databases.insert(descriptor.database_id, descriptor);
Ok(())
}
MetaCommand::DropDatabase { database_id } => {
if !self.databases.contains_key(database_id) {
return Ok(());
}
if self
.tables
.values()
.any(|table| table.database_id == *database_id)
{
return Err(MetaRejectionReason::Conflict {
resource: format!("database {database_id}"),
reason: "database still has tables".to_owned(),
});
}
self.databases.remove(database_id);
Ok(())
}
MetaCommand::SetTableSchema { record } => {
if !self.databases.contains_key(&record.database_id) {
return Err(MetaRejectionReason::NotFound {
resource: format!("database {}", record.database_id),
});
}
if record.schema_version == SchemaVersion::ZERO {
return Err(MetaRejectionReason::Invalid {
reason: "reserved zero schema version".to_owned(),
});
}
match self.tables.get(&record.table_id) {
Some(existing) => {
if record.schema_version > existing.schema_version {
let mut record = record.clone();
record.metadata_version = version;
self.tables.insert(record.table_id, record);
Ok(())
} else if record.schema_version == existing.schema_version {
if existing.database_id == record.database_id
&& existing.schema == record.schema
{
Ok(())
} else {
Err(MetaRejectionReason::Conflict {
resource: format!("table {}", record.table_id),
reason: "schema version already used for different content"
.to_owned(),
})
}
} else {
Err(MetaRejectionReason::StaleWrite {
resource: format!("table {}", record.table_id),
current: MetadataVersion(existing.schema_version.get()),
attempted: MetadataVersion(record.schema_version.get()),
})
}
}
None => {
let mut record = record.clone();
record.metadata_version = version;
self.tables.insert(record.table_id, record);
Ok(())
}
}
}
MetaCommand::SetTabletDescriptor { descriptor } => {
if let Err(error) = descriptor.validate() {
return Err(MetaRejectionReason::Invalid {
reason: error.to_string(),
});
}
if !self.tables.contains_key(&descriptor.table_id) {
return Err(MetaRejectionReason::NotFound {
resource: format!("table {}", descriptor.table_id),
});
}
match self.tablets.get(&descriptor.tablet_id) {
Some(existing) => {
if descriptor.generation > existing.descriptor.generation {
self.tablets.insert(
descriptor.tablet_id,
TabletRecord {
descriptor: descriptor.clone(),
metadata_version: version,
},
);
Ok(())
} else if descriptor.generation == existing.descriptor.generation {
if existing.descriptor == *descriptor {
Ok(())
} else {
Err(MetaRejectionReason::Conflict {
resource: format!("tablet {}", descriptor.tablet_id),
reason: "generation already used for different content"
.to_owned(),
})
}
} else {
Err(MetaRejectionReason::StaleWrite {
resource: format!("tablet {}", descriptor.tablet_id),
current: MetadataVersion(existing.descriptor.generation),
attempted: MetadataVersion(descriptor.generation),
})
}
}
None => {
self.tablets.insert(
descriptor.tablet_id,
TabletRecord {
descriptor: descriptor.clone(),
metadata_version: version,
},
);
Ok(())
}
}
}
MetaCommand::RemoveTabletDescriptor {
tablet_id,
generation,
} => match self.tablets.get(tablet_id) {
None => Ok(()),
Some(existing) => {
if *generation >= existing.descriptor.generation {
self.tablets.remove(tablet_id);
Ok(())
} else {
Err(MetaRejectionReason::StaleWrite {
resource: format!("tablet {tablet_id}"),
current: MetadataVersion(existing.descriptor.generation),
attempted: MetadataVersion(*generation),
})
}
}
},
MetaCommand::SetReplicaPlacement { placement } => {
if placement.replicas.is_empty() {
return Err(MetaRejectionReason::Invalid {
reason: "placement has no replicas".to_owned(),
});
}
for (index, replica) in placement.replicas.iter().enumerate() {
if !self.nodes.contains_key(&replica.node_id) {
return Err(MetaRejectionReason::NotFound {
resource: format!("node {}", replica.node_id),
});
}
if placement.replicas[..index]
.iter()
.any(|prior| prior.node_id == replica.node_id)
{
return Err(MetaRejectionReason::Conflict {
resource: format!("raft group {}", placement.raft_group_id),
reason: format!("node {} appears twice", replica.node_id),
});
}
}
match self.placements.get(&placement.raft_group_id) {
Some(existing) if existing.replicas == placement.replicas => Ok(()),
_ => {
let mut placement = placement.clone();
placement.metadata_version = version;
self.placements.insert(placement.raft_group_id, placement);
Ok(())
}
}
}
MetaCommand::SetPlacementPolicy { name, policy } => {
if name.trim().is_empty() {
return Err(MetaRejectionReason::Invalid {
reason: "placement policy name is empty".to_owned(),
});
}
if policy.replicas == 0 {
return Err(MetaRejectionReason::Invalid {
reason: format!("placement policy `{name}` requests zero replicas"),
});
}
match self.placement_policies.get(name) {
Some(existing) if existing.policy == *policy => Ok(()),
_ => {
self.placement_policies.insert(
name.clone(),
PlacementPolicyRecord {
policy: policy.clone(),
metadata_version: version,
},
);
Ok(())
}
}
}
MetaCommand::SubmitSchemaJob { job } => {
if job.job_id == 0 {
return Err(MetaRejectionReason::Invalid {
reason: "reserved zero job id".to_owned(),
});
}
if !self.databases.contains_key(&job.database_id) {
return Err(MetaRejectionReason::NotFound {
resource: format!("database {}", job.database_id),
});
}
if !self.tables.contains_key(&job.table_id) {
return Err(MetaRejectionReason::NotFound {
resource: format!("table {}", job.table_id),
});
}
if job.state != SchemaJobState::Pending {
return Err(MetaRejectionReason::Invalid {
reason: "submitted jobs start Pending".to_owned(),
});
}
match self.schema_jobs.get(&job.job_id) {
Some(existing) => {
let mut comparable = existing.clone();
comparable.metadata_version = job.metadata_version;
if comparable == *job {
Ok(())
} else {
Err(MetaRejectionReason::Conflict {
resource: format!("schema job {}", job.job_id),
reason: "job id already exists with different content".to_owned(),
})
}
}
None => {
let mut job = job.clone();
job.metadata_version = version;
self.schema_jobs.insert(job.job_id, job);
Ok(())
}
}
}
MetaCommand::UpdateSchemaJob {
job_id,
state,
updated_at,
error,
expected_version,
} => {
let Some(record) = self.schema_jobs.get_mut(job_id) else {
return Err(MetaRejectionReason::NotFound {
resource: format!("schema job {job_id}"),
});
};
if let Some(expected) = expected_version {
if *expected != record.metadata_version {
return Err(MetaRejectionReason::StaleWrite {
resource: format!("schema job {job_id}"),
current: record.metadata_version,
attempted: *expected,
});
}
}
if record.state.is_terminal() {
return Err(MetaRejectionReason::Conflict {
resource: format!("schema job {job_id}"),
reason: format!("terminal state {:?}", record.state),
});
}
if record.state != *state && !record.state.can_transition(*state) {
return Err(MetaRejectionReason::Conflict {
resource: format!("schema job {job_id}"),
reason: format!("illegal transition {:?} -> {:?}", record.state, state),
});
}
if record.state == *state && record.error == *error {
return Ok(());
}
record.state = *state;
record.updated_at = *updated_at;
record.error = error.clone();
record.metadata_version = version;
Ok(())
}
MetaCommand::SetClusterSetting { key, value } => self.settings.apply(key, value),
MetaCommand::ActivateFeature { activation } => {
let voters: Vec<NodeDescriptor> = self
.nodes
.values()
.filter(|record| record.descriptor.state != NodeState::Decommissioned)
.map(|record| record.descriptor.clone())
.collect();
activation.validate(registry, self.feature_level, &voters)?;
let already = self.feature_activations.iter().any(|applied| {
applied.feature == activation.feature && applied.level == activation.level
});
if already {
return Ok(());
}
if activation.level > self.feature_level {
self.feature_level = activation.level;
}
let mut applied = activation.clone();
if applied.activated_at == HlcTimestamp::ZERO {
applied.activated_at = commit_ts;
}
self.feature_activations.push(applied);
Ok(())
}
MetaCommand::SetTxnStatusPartition { partition } => {
match self.txn_status_partitions.get(&partition.partition_id) {
Some(existing) if existing == partition => Ok(()),
_ => {
self.txn_status_partitions
.insert(partition.partition_id, partition.clone());
Ok(())
}
}
}
MetaCommand::PublishSplit { command } => self.apply_split_publish(command, version),
MetaCommand::PublishMerge { command } => self.apply_merge_publish(command, version),
MetaCommand::AllocateRaftNodeIds { count } => {
self.apply_allocate_raft_node_ids(*count, command_id)
}
}
}
fn apply_split_publish(
&mut self,
command: &SplitPublishCommand,
version: MetadataVersion,
) -> Result<(), MetaRejectionReason> {
command
.validate()
.map_err(|error| MetaRejectionReason::Invalid {
reason: error.to_string(),
})?;
if !self.tables.contains_key(&command.source.table_id) {
return Err(MetaRejectionReason::NotFound {
resource: format!("table {}", command.source.table_id),
});
}
let publish = command.publish_generation();
let replay = self.tablet(command.source.tablet_id) == Some(&command.source)
&& command
.children
.iter()
.all(|child| self.tablet(child.tablet_id) == Some(child));
if replay {
return Ok(());
}
let precursor_generation =
publish
.checked_sub(1)
.ok_or_else(|| MetaRejectionReason::Invalid {
reason: "publication generation is zero".to_owned(),
})?;
self.require_stored_precursor(
&command.source,
TabletState::Splitting,
precursor_generation,
false,
)?;
for child in &command.children {
self.require_stored_precursor(
child,
TabletState::Creating,
precursor_generation,
true,
)?;
}
let participants: BTreeSet<TabletId> = command
.children
.iter()
.map(|child| child.tablet_id)
.chain([command.source.tablet_id])
.collect();
for child in &command.children {
self.require_no_routable_overlap(child, &participants)?;
}
for child in &command.children {
self.tablets.insert(
child.tablet_id,
TabletRecord {
descriptor: child.clone(),
metadata_version: version,
},
);
}
self.tablets.insert(
command.source.tablet_id,
TabletRecord {
descriptor: command.source.clone(),
metadata_version: version,
},
);
Ok(())
}
fn apply_merge_publish(
&mut self,
command: &MergePublishCommand,
version: MetadataVersion,
) -> Result<(), MetaRejectionReason> {
command
.validate()
.map_err(|error| MetaRejectionReason::Invalid {
reason: error.to_string(),
})?;
if !self.tables.contains_key(&command.replacement.table_id) {
return Err(MetaRejectionReason::NotFound {
resource: format!("table {}", command.replacement.table_id),
});
}
let publish = command.publish_generation();
let replay = self.tablet(command.replacement.tablet_id) == Some(&command.replacement)
&& command
.sources
.iter()
.all(|source| self.tablet(source.tablet_id) == Some(source));
if replay {
return Ok(());
}
let precursor_generation =
publish
.checked_sub(1)
.ok_or_else(|| MetaRejectionReason::Invalid {
reason: "publication generation is zero".to_owned(),
})?;
self.require_stored_precursor(
&command.replacement,
TabletState::Creating,
precursor_generation,
true,
)?;
for source in &command.sources {
let stored = self.tablet(source.tablet_id).cloned().ok_or_else(|| {
MetaRejectionReason::NotFound {
resource: format!("tablet {}", source.tablet_id),
}
})?;
let mut precursor = stored.clone();
precursor.state = TabletState::Retiring;
precursor.generation = publish;
if stored.state != TabletState::Merging
|| stored.generation >= publish
|| precursor != *source
{
return Err(MetaRejectionReason::Conflict {
resource: format!("tablet {}", source.tablet_id),
reason: format!(
"stored descriptor (state {}, generation {}) is not the Merging \
precursor of the publication (generation {publish})",
stored.state, stored.generation
),
});
}
}
let participants: BTreeSet<TabletId> = command
.sources
.iter()
.map(|source| source.tablet_id)
.chain([command.replacement.tablet_id])
.collect();
self.require_no_routable_overlap(&command.replacement, &participants)?;
self.tablets.insert(
command.replacement.tablet_id,
TabletRecord {
descriptor: command.replacement.clone(),
metadata_version: version,
},
);
for source in &command.sources {
self.tablets.insert(
source.tablet_id,
TabletRecord {
descriptor: source.clone(),
metadata_version: version,
},
);
}
Ok(())
}
fn require_stored_precursor(
&self,
published: &TabletDescriptor,
precursor_state: TabletState,
precursor_generation: u64,
learners_promoted: bool,
) -> Result<TabletDescriptor, MetaRejectionReason> {
let stored = self.tablet(published.tablet_id).cloned().ok_or_else(|| {
MetaRejectionReason::NotFound {
resource: format!("tablet {}", published.tablet_id),
}
})?;
let mut precursor = stored.clone();
precursor.state = published.state;
precursor.generation = published.generation;
if learners_promoted {
for replica in &mut precursor.replicas {
replica.role = ReplicaRole::Voter;
}
}
if stored.state != precursor_state
|| stored.generation != precursor_generation
|| precursor != *published
{
return Err(MetaRejectionReason::Conflict {
resource: format!("tablet {}", published.tablet_id),
reason: format!(
"stored descriptor (state {}, generation {}) is not the {} precursor of \
the publication (state {}, generation {})",
stored.state,
stored.generation,
precursor_state,
published.state,
published.generation
),
});
}
Ok(stored)
}
fn require_no_routable_overlap(
&self,
published: &TabletDescriptor,
participants: &BTreeSet<TabletId>,
) -> Result<(), MetaRejectionReason> {
let overlapping =
self.tablets
.values()
.map(|record| &record.descriptor)
.find(|descriptor| {
!participants.contains(&descriptor.tablet_id)
&& descriptor.table_id == published.table_id
&& descriptor.state.is_routable()
&& descriptor.partition.overlaps(&published.partition)
});
if let Some(other) = overlapping {
return Err(MetaRejectionReason::Conflict {
resource: format!("tablet {}", published.tablet_id),
reason: format!("bounds overlap routable tablet {}", other.tablet_id),
});
}
Ok(())
}
fn apply_allocate_raft_node_ids(
&mut self,
count: u32,
command_id: Option<[u8; 16]>,
) -> Result<(), MetaRejectionReason> {
if count == 0 || count > MAX_RAFT_NODE_ID_ALLOCATION {
return Err(MetaRejectionReason::Invalid {
reason: format!(
"raft node id allocation count {count} is outside \
1..={MAX_RAFT_NODE_ID_ALLOCATION}"
),
});
}
if let Some(id) = command_id {
if self.raft_id_allocations.contains_key(&hex_encode(&id)) {
return Ok(());
}
}
let mut used = BTreeSet::new();
for record in self.nodes.values() {
used.insert(raft_node_id(&record.descriptor.node_id));
}
for record in self.tablets.values() {
for replica in &record.descriptor.replicas {
used.insert(replica.raft_node_id);
}
}
for placement in self.placements.values() {
for replica in &placement.replicas {
used.insert(replica.raft_node_id);
}
}
let count = u64::from(count);
let mut base = self.next_raft_node_id.max(FIRST_RAFT_NODE_ID);
let mut skips = 0u64;
loop {
let end = base
.checked_add(count)
.ok_or_else(|| MetaRejectionReason::Conflict {
resource: "raft node id allocator".to_owned(),
reason: "allocation overflows u64".to_owned(),
})?;
match used.range(base..end).next() {
None => break,
Some(conflicting) => {
skips += 1;
if skips > RAFT_ID_ALLOCATION_SCAN_LIMIT {
return Err(MetaRejectionReason::Conflict {
resource: "raft node id allocator".to_owned(),
reason: format!(
"collision skip scan exceeded {RAFT_ID_ALLOCATION_SCAN_LIMIT} \
allocated ids"
),
});
}
base = conflicting + 1;
}
}
}
let end = base + count;
self.next_raft_node_id = end;
if let Some(id) = command_id {
let key = hex_encode(&id);
self.raft_id_allocations.insert(key.clone(), base);
self.raft_id_allocation_order.push_back(key);
while self.raft_id_allocation_order.len() > RAFT_ID_ALLOCATION_RECORD_LIMIT {
if let Some(oldest) = self.raft_id_allocation_order.pop_front() {
self.raft_id_allocations.remove(&oldest);
}
}
}
Ok(())
}
pub fn node(&self, node_id: NodeId) -> Option<&NodeDescriptor> {
self.nodes.get(&node_id).map(|record| &record.descriptor)
}
pub fn node_record(&self, node_id: NodeId) -> Option<&NodeRecord> {
self.nodes.get(&node_id)
}
pub fn node_descriptors(&self) -> Vec<NodeDescriptor> {
self.nodes
.values()
.map(|record| record.descriptor.clone())
.collect()
}
pub fn database(&self, database_id: DatabaseId) -> Option<&DatabaseDescriptor> {
self.databases.get(&database_id)
}
pub fn database_by_name(&self, name: &str) -> Option<&DatabaseDescriptor> {
self.databases
.values()
.find(|database| database.name == name)
}
pub fn table(&self, table_id: TableId) -> Option<&TableSchemaRecord> {
self.tables.get(&table_id)
}
pub fn tablet(&self, tablet_id: TabletId) -> Option<&TabletDescriptor> {
self.tablets
.get(&tablet_id)
.map(|record| &record.descriptor)
}
pub fn tablet_record(&self, tablet_id: TabletId) -> Option<&TabletRecord> {
self.tablets.get(&tablet_id)
}
pub fn placement(&self, raft_group_id: RaftGroupId) -> Option<&ReplicaPlacement> {
self.placements.get(&raft_group_id)
}
pub fn placement_policy(&self, name: &str) -> Option<&PlacementPolicy> {
self.placement_policies
.get(name)
.map(|record| &record.policy)
}
pub fn placement_policy_record(&self, name: &str) -> Option<&PlacementPolicyRecord> {
self.placement_policies.get(name)
}
pub fn schema_job(&self, job_id: u64) -> Option<&SchemaJobRecord> {
self.schema_jobs.get(&job_id)
}
pub fn txn_status_partition(&self, partition_id: u32) -> Option<&TxnStatusPartition> {
self.txn_status_partitions.get(&partition_id)
}
pub fn settings(&self) -> &ClusterSettings {
&self.settings
}
pub fn feature_level(&self) -> ClusterFeatureLevel {
self.feature_level
}
pub fn feature_active(&self, registry: &FeatureRegistry, feature: &str) -> bool {
registry.feature_supported(self.feature_level, feature)
}
pub fn feature_activations(&self) -> &[FeatureActivation] {
&self.feature_activations
}
pub fn rejections(&self) -> &VecDeque<MetaRejection> {
&self.rejections
}
pub fn next_raft_node_id(&self) -> u64 {
self.next_raft_node_id
}
pub fn raft_id_allocation(&self, command_id: &[u8; 16]) -> Option<u64> {
self.raft_id_allocations
.get(&hex_encode(command_id))
.copied()
}
}
mod v1 {
use super::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplicaRole {
Voter,
Learner,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplicaDescriptor {
pub node_id: NodeId,
pub role: ReplicaRole,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PartitionBounds {
pub start: Option<Vec<u8>>,
pub end: Option<Vec<u8>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TabletState {
Creating,
Online,
Offline,
}
fn zero_database_id() -> DatabaseId {
DatabaseId::ZERO
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabletDescriptor {
pub tablet_id: TabletId,
pub table_id: TableId,
#[serde(default = "zero_database_id")]
pub database_id: DatabaseId,
pub raft_group_id: RaftGroupId,
pub partition: PartitionBounds,
pub replicas: Vec<ReplicaDescriptor>,
pub leader_hint: Option<NodeId>,
pub generation: u64,
pub state: TabletState,
#[serde(default = "zero_metadata_version")]
pub metadata_version: MetadataVersion,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplicaPlacement {
pub raft_group_id: RaftGroupId,
pub replicas: Vec<ReplicaDescriptor>,
#[serde(default = "zero_metadata_version")]
pub metadata_version: MetadataVersion,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalityConstraint {
pub key: String,
pub value: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlacementPolicy {
pub replicas: u8,
pub voter_constraints: Vec<LocalityConstraint>,
pub leader_preferences: Vec<LocalityConstraint>,
pub prohibited_nodes: Vec<NodeId>,
#[serde(default = "zero_metadata_version")]
pub metadata_version: MetadataVersion,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum MetaCommand {
RegisterNode {
descriptor: NodeDescriptor,
},
UpdateNodeState {
node_id: NodeId,
state: NodeState,
expected_version: Option<MetadataVersion>,
},
RemoveNode {
node_id: NodeId,
},
CreateDatabase {
descriptor: DatabaseDescriptor,
},
DropDatabase {
database_id: DatabaseId,
},
SetTableSchema {
record: TableSchemaRecord,
},
SetTabletDescriptor {
descriptor: TabletDescriptor,
},
RemoveTabletDescriptor {
tablet_id: TabletId,
generation: u64,
},
SetReplicaPlacement {
placement: ReplicaPlacement,
},
SetPlacementPolicy {
name: String,
policy: PlacementPolicy,
},
SubmitSchemaJob {
job: SchemaJobRecord,
},
UpdateSchemaJob {
job_id: u64,
state: SchemaJobState,
updated_at: HlcTimestamp,
error: Option<String>,
expected_version: Option<MetadataVersion>,
},
SetClusterSetting {
key: String,
value: serde_json::Value,
},
ActivateFeature {
activation: FeatureActivation,
},
SetTxnStatusPartition {
partition: TxnStatusPartition,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MetaCommandRecord {
pub format_version: u32,
pub command: MetaCommand,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct MetaState {
pub format_version: u32,
pub metadata_version: MetadataVersion,
pub nodes: BTreeMap<NodeId, NodeRecord>,
pub databases: BTreeMap<DatabaseId, DatabaseDescriptor>,
pub tables: BTreeMap<TableId, TableSchemaRecord>,
pub tablets: BTreeMap<TabletId, TabletDescriptor>,
pub placements: BTreeMap<RaftGroupId, ReplicaPlacement>,
pub placement_policies: BTreeMap<String, PlacementPolicy>,
pub schema_jobs: BTreeMap<u64, SchemaJobRecord>,
pub txn_status_partitions: BTreeMap<u32, TxnStatusPartition>,
pub settings: ClusterSettings,
pub feature_level: ClusterFeatureLevel,
pub feature_activations: Vec<FeatureActivation>,
pub rejections: VecDeque<MetaRejection>,
}
impl Default for MetaState {
fn default() -> Self {
Self {
format_version: 1,
metadata_version: MetadataVersion::ZERO,
nodes: BTreeMap::new(),
databases: BTreeMap::new(),
tables: BTreeMap::new(),
tablets: BTreeMap::new(),
placements: BTreeMap::new(),
placement_policies: BTreeMap::new(),
schema_jobs: BTreeMap::new(),
txn_status_partitions: BTreeMap::new(),
settings: ClusterSettings::default(),
feature_level: ClusterFeatureLevel::ZERO,
feature_activations: Vec::new(),
rejections: VecDeque::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MetaStateCheckpoint {
pub format_version: u32,
pub position: LogPosition,
pub command_id: Option<[u8; 16]>,
pub state: MetaState,
}
}
impl From<v1::ReplicaRole> for ReplicaRole {
fn from(role: v1::ReplicaRole) -> Self {
match role {
v1::ReplicaRole::Voter => Self::Voter,
v1::ReplicaRole::Learner => Self::Learner,
}
}
}
fn migrate_replica(replica: v1::ReplicaDescriptor) -> ReplicaDescriptor {
ReplicaDescriptor {
node_id: replica.node_id,
role: replica.role.into(),
raft_node_id: raft_node_id(&replica.node_id),
}
}
fn migrate_bounds(bounds: v1::PartitionBounds) -> PartitionBounds {
PartitionBounds {
low: match bounds.start {
None => Bound::Unbounded,
Some(start) => Bound::Included(Key::from_bytes(start)),
},
high: match bounds.end {
None => Bound::Unbounded,
Some(end) => Bound::Excluded(Key::from_bytes(end)),
},
}
}
fn migrate_tablet_state(state: v1::TabletState) -> TabletState {
match state {
v1::TabletState::Creating => TabletState::Creating,
v1::TabletState::Online => TabletState::Active,
v1::TabletState::Offline => TabletState::Retiring,
}
}
fn migrate_tablet(tablet: v1::TabletDescriptor) -> TabletRecord {
TabletRecord {
descriptor: TabletDescriptor {
tablet_id: tablet.tablet_id,
table_id: tablet.table_id,
database_id: tablet.database_id,
raft_group_id: tablet.raft_group_id,
partition: migrate_bounds(tablet.partition),
replicas: tablet.replicas.into_iter().map(migrate_replica).collect(),
leader_hint: tablet.leader_hint,
generation: tablet.generation,
state: migrate_tablet_state(tablet.state),
},
metadata_version: tablet.metadata_version,
}
}
fn migrate_placement(placement: v1::ReplicaPlacement) -> ReplicaPlacement {
ReplicaPlacement {
raft_group_id: placement.raft_group_id,
replicas: placement
.replicas
.into_iter()
.map(migrate_replica)
.collect(),
metadata_version: placement.metadata_version,
}
}
fn migrate_policy(policy: v1::PlacementPolicy) -> PlacementPolicyRecord {
PlacementPolicyRecord {
policy: PlacementPolicy {
replicas: policy.replicas,
voter_constraints: policy
.voter_constraints
.into_iter()
.map(|constraint| LocalityConstraint {
key: constraint.key,
value: constraint.value,
required: true,
})
.collect(),
leader_preferences: policy
.leader_preferences
.into_iter()
.map(|constraint| LocalityConstraint {
key: constraint.key,
value: constraint.value,
required: false,
})
.collect(),
prohibited_nodes: policy.prohibited_nodes,
},
metadata_version: policy.metadata_version,
}
}
fn migrate_command(command: v1::MetaCommand) -> MetaCommand {
match command {
v1::MetaCommand::RegisterNode { descriptor } => MetaCommand::RegisterNode { descriptor },
v1::MetaCommand::UpdateNodeState {
node_id,
state,
expected_version,
} => MetaCommand::UpdateNodeState {
node_id,
state,
expected_version,
},
v1::MetaCommand::RemoveNode { node_id } => MetaCommand::RemoveNode { node_id },
v1::MetaCommand::CreateDatabase { descriptor } => {
MetaCommand::CreateDatabase { descriptor }
}
v1::MetaCommand::DropDatabase { database_id } => MetaCommand::DropDatabase { database_id },
v1::MetaCommand::SetTableSchema { record } => MetaCommand::SetTableSchema { record },
v1::MetaCommand::SetTabletDescriptor { descriptor } => MetaCommand::SetTabletDescriptor {
descriptor: migrate_tablet(descriptor).descriptor,
},
v1::MetaCommand::RemoveTabletDescriptor {
tablet_id,
generation,
} => MetaCommand::RemoveTabletDescriptor {
tablet_id,
generation,
},
v1::MetaCommand::SetReplicaPlacement { placement } => MetaCommand::SetReplicaPlacement {
placement: migrate_placement(placement),
},
v1::MetaCommand::SetPlacementPolicy { name, policy } => MetaCommand::SetPlacementPolicy {
name,
policy: migrate_policy(policy).policy,
},
v1::MetaCommand::SubmitSchemaJob { job } => MetaCommand::SubmitSchemaJob { job },
v1::MetaCommand::UpdateSchemaJob {
job_id,
state,
updated_at,
error,
expected_version,
} => MetaCommand::UpdateSchemaJob {
job_id,
state,
updated_at,
error,
expected_version,
},
v1::MetaCommand::SetClusterSetting { key, value } => {
MetaCommand::SetClusterSetting { key, value }
}
v1::MetaCommand::ActivateFeature { activation } => {
MetaCommand::ActivateFeature { activation }
}
v1::MetaCommand::SetTxnStatusPartition { partition } => {
MetaCommand::SetTxnStatusPartition { partition }
}
}
}
fn migrate_state(state: v1::MetaState) -> MetaState {
MetaState {
format_version: META_STATE_FORMAT_VERSION,
metadata_version: state.metadata_version,
nodes: state.nodes,
databases: state.databases,
tables: state.tables,
tablets: state
.tablets
.into_iter()
.map(|(tablet_id, tablet)| (tablet_id, migrate_tablet(tablet)))
.collect(),
placements: state
.placements
.into_iter()
.map(|(group_id, placement)| (group_id, migrate_placement(placement)))
.collect(),
placement_policies: state
.placement_policies
.into_iter()
.map(|(name, policy)| (name, migrate_policy(policy)))
.collect(),
schema_jobs: state.schema_jobs,
txn_status_partitions: state.txn_status_partitions,
settings: state.settings,
feature_level: state.feature_level,
feature_activations: state.feature_activations,
rejections: state.rejections,
next_raft_node_id: FIRST_RAFT_NODE_ID,
raft_id_allocations: BTreeMap::new(),
raft_id_allocation_order: VecDeque::new(),
}
}
fn migrate_checkpoint(checkpoint: v1::MetaStateCheckpoint) -> MetaStateCheckpoint {
MetaStateCheckpoint {
format_version: META_STATE_CHECKPOINT_FORMAT_VERSION,
position: checkpoint.position,
command_id: checkpoint.command_id,
state: migrate_state(checkpoint.state),
}
}
#[derive(Deserialize)]
struct FormatVersionProbe {
format_version: u32,
}
fn decode_meta_checkpoint(bytes: &[u8]) -> Result<MetaStateCheckpoint, String> {
let probe: FormatVersionProbe =
serde_json::from_slice(bytes).map_err(|error| format!("decode: {error}"))?;
if probe.format_version < MIN_SUPPORTED_META_STATE_CHECKPOINT_FORMAT_VERSION
|| probe.format_version > META_STATE_CHECKPOINT_FORMAT_VERSION
{
return Err(format!(
"unsupported format version {} (supported \
{MIN_SUPPORTED_META_STATE_CHECKPOINT_FORMAT_VERSION}..=\
{META_STATE_CHECKPOINT_FORMAT_VERSION})",
probe.format_version
));
}
if probe.format_version == 1 {
let checkpoint: v1::MetaStateCheckpoint =
serde_json::from_slice(bytes).map_err(|error| format!("decode v1: {error}"))?;
if checkpoint.format_version != probe.format_version {
return Err("inconsistent format version".to_owned());
}
return Ok(migrate_checkpoint(checkpoint));
}
serde_json::from_slice(bytes).map_err(|error| format!("decode: {error}"))
}
pub const META_STATE_CHECKPOINT_FILENAME: &str = "meta-state.json";
pub const META_STATE_CHECKPOINT_FORMAT_VERSION: u32 = 2;
pub const MIN_SUPPORTED_META_STATE_CHECKPOINT_FORMAT_VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MetaStateCheckpoint {
pub format_version: u32,
pub position: LogPosition,
pub command_id: Option<[u8; 16]>,
pub state: MetaState,
}
pub struct MetaApplySink {
state: MetaState,
position: LogPosition,
command_id: Option<[u8; 16]>,
registry: FeatureRegistry,
state_dir: PathBuf,
}
impl MetaApplySink {
pub fn open(group_dir: &Path, registry: FeatureRegistry) -> Result<Self, MetaError> {
let state_dir = group_dir.join("raft").join("state");
std::fs::create_dir_all(&state_dir).map_err(MetaError::Io)?;
let checkpoint_path = state_dir.join(META_STATE_CHECKPOINT_FILENAME);
let Some(bytes) =
crate::node::read_meta_file(&checkpoint_path).map_err(|error| match error {
crate::node::ClusterError::Io(error) => MetaError::Io(error),
other => MetaError::CorruptCheckpoint(other.to_string()),
})?
else {
return Ok(MetaApplySink {
state: MetaState::default(),
position: LogPosition::ZERO,
command_id: None,
registry,
state_dir,
});
};
let checkpoint = decode_meta_checkpoint(&bytes).map_err(MetaError::CorruptCheckpoint)?;
if checkpoint.state.format_version < MIN_SUPPORTED_META_STATE_FORMAT_VERSION
|| checkpoint.state.format_version > META_STATE_FORMAT_VERSION
{
return Err(MetaError::CorruptCheckpoint(format!(
"unsupported meta state format version {} (supported \
{MIN_SUPPORTED_META_STATE_FORMAT_VERSION}..={META_STATE_FORMAT_VERSION})",
checkpoint.state.format_version
)));
}
Ok(MetaApplySink {
state: checkpoint.state,
position: checkpoint.position,
command_id: checkpoint.command_id,
registry,
state_dir,
})
}
pub fn state(&self) -> &MetaState {
&self.state
}
pub fn metadata_version(&self) -> MetadataVersion {
self.state.metadata_version
}
pub fn applied_position(&self) -> LogPosition {
self.position
}
fn checkpoint(&self) -> MetaStateCheckpoint {
MetaStateCheckpoint {
format_version: META_STATE_CHECKPOINT_FORMAT_VERSION,
position: self.position,
command_id: self.command_id,
state: self.state.clone(),
}
}
fn persist(&self) -> Result<(), StateMachineError> {
let bytes = serde_json::to_vec(&self.checkpoint())
.map_err(|error| StateMachineError::Sink(format!("meta checkpoint encode: {error}")))?;
crate::node::write_meta_atomic(&self.state_dir, META_STATE_CHECKPOINT_FILENAME, &bytes)
.map_err(|error| StateMachineError::Sink(format!("meta checkpoint write: {error}")))
}
}
impl ApplySink for MetaApplySink {
fn apply(&mut self, command: &AppliedCommand) -> Result<(), StateMachineError> {
if command.position.index <= self.position.index {
return Ok(());
}
match &command.command {
ReplicatedCommand::Catalog(catalog) => {
catalog.envelope.verify().map_err(|error| {
StateMachineError::Corrupt(format!("meta envelope: {error}"))
})?;
if catalog.envelope.command_type != COMMAND_TYPE_META_COMMAND {
return Err(StateMachineError::Corrupt(format!(
"meta command_type {} is not COMMAND_TYPE_META_COMMAND",
catalog.envelope.command_type
)));
}
let record = MetaCommandRecord::decode(&catalog.envelope.payload)
.map_err(|error| StateMachineError::Corrupt(error.to_string()))?;
let commit_ts = command.commit_ts().unwrap_or(HlcTimestamp::ZERO);
let _ = self.state.apply(
&record.command,
command.command_id(),
commit_ts,
&self.registry,
);
}
ReplicatedCommand::Maintenance(_) | ReplicatedCommand::Noop => {}
ReplicatedCommand::Transaction(_) => {
return Err(StateMachineError::Corrupt(
"transaction command on the meta group: the meta group owns \
control-plane state only (spec section 12.1)"
.to_owned(),
));
}
}
self.position = command.position;
if let Some(command_id) = command.command_id() {
self.command_id = Some(command_id);
}
self.persist()
}
fn snapshot(&self) -> Result<Vec<u8>, StateMachineError> {
serde_json::to_vec(&self.checkpoint())
.map_err(|error| StateMachineError::Sink(format!("meta snapshot encode: {error}")))
}
fn install(&mut self, data: &[u8]) -> Result<(), StateMachineError> {
let checkpoint = decode_meta_checkpoint(data)
.map_err(|error| StateMachineError::Corrupt(format!("meta snapshot {error}")))?;
if checkpoint.state.format_version < MIN_SUPPORTED_META_STATE_FORMAT_VERSION
|| checkpoint.state.format_version > META_STATE_FORMAT_VERSION
{
return Err(StateMachineError::Corrupt(format!(
"unsupported meta state format version {} (supported \
{MIN_SUPPORTED_META_STATE_FORMAT_VERSION}..={META_STATE_FORMAT_VERSION})",
checkpoint.state.format_version
)));
}
self.state = checkpoint.state;
self.position = checkpoint.position;
self.command_id = checkpoint.command_id;
self.persist()
}
}
impl fmt::Debug for MetaApplySink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MetaApplySink")
.field("metadata_version", &self.state.metadata_version)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct MetaGroupConfig {
pub node_data: PathBuf,
pub meta_group_id: RaftGroupId,
pub node_id: NodeId,
pub registry: FeatureRegistry,
pub storage: StorageConfig,
pub idempotency_retention: usize,
}
impl MetaGroupConfig {
pub fn new(node_data: PathBuf, meta_group_id: RaftGroupId, node_id: NodeId) -> Self {
MetaGroupConfig {
node_data,
meta_group_id,
node_id,
registry: FeatureRegistry::current(),
storage: StorageConfig::default(),
idempotency_retention:
mongreldb_consensus::state_machine::DEFAULT_IDEMPOTENCY_RETENTION,
}
}
pub fn group_dir(&self) -> PathBuf {
self.node_data
.join("groups")
.join(self.meta_group_id.to_hex())
}
pub fn cluster_name(&self) -> String {
format!("meta-{}", self.meta_group_id.to_hex())
}
pub fn group_config(&self) -> GroupConfig {
let mut config = GroupConfig::new(
self.cluster_name(),
raft_node_id(&self.node_id),
self.group_dir(),
);
config.storage = self.storage.clone();
config.idempotency_retention = self.idempotency_retention;
config
}
}
#[derive(Debug, Clone)]
pub struct MetaCommandReceipt {
pub receipt: GroupCommitReceipt,
pub metadata_version: MetadataVersion,
}
pub struct MetaGroup<T: RaftTransport> {
group: ConsensusGroup<T>,
sink: Arc<Mutex<MetaApplySink>>,
config: MetaGroupConfig,
}
fn basic_node<N>(address: &str) -> Result<N, MetaError>
where
N: for<'de> Deserialize<'de>,
{
serde_json::from_value(serde_json::json!({ "addr": address }))
.map_err(|error| MetaError::InvalidRequest(format!("member address `{address}`: {error}")))
}
pub(crate) fn new_command_id() -> Result<[u8; 16], MetaError> {
let mut id = [0u8; 16];
getrandom::getrandom(&mut id).map_err(|error| MetaError::Rng(error.to_string()))?;
Ok(id)
}
impl<T: RaftTransport> MetaGroup<T> {
pub async fn create(
config: MetaGroupConfig,
group_config: GroupConfig,
transport: Arc<T>,
) -> Result<Self, MetaError> {
if group_config.node_id != raft_node_id(&config.node_id) {
return Err(MetaError::InvalidRequest(format!(
"group config raft id {} does not match the node id projection {}",
group_config.node_id,
raft_node_id(&config.node_id)
)));
}
if group_config.dir != config.group_dir() {
return Err(MetaError::InvalidRequest(format!(
"group config dir {:?} is not the meta group dir {:?}",
group_config.dir,
config.group_dir()
)));
}
let sink = Arc::new(Mutex::new(MetaApplySink::open(
&config.group_dir(),
config.registry.clone(),
)?));
let group = ConsensusGroup::create(
group_config,
transport,
sink.clone() as Arc<Mutex<dyn ApplySink>>,
)
.await?;
Ok(MetaGroup {
group,
sink,
config,
})
}
pub fn group(&self) -> &ConsensusGroup<T> {
&self.group
}
pub fn node_id(&self) -> NodeId {
self.config.node_id
}
pub fn meta_group_id(&self) -> RaftGroupId {
self.config.meta_group_id
}
pub async fn bootstrap(&self, members: &[(NodeId, String)]) -> Result<(), MetaError> {
let mut projected: BTreeMap<RaftNodeId, NodeId> = BTreeMap::new();
let mut map = BTreeMap::new();
for (node_id, address) in members {
let raft_id = raft_node_id(node_id);
if let Some(prior) = projected.insert(raft_id, *node_id) {
if prior != *node_id {
return Err(MetaError::InvalidRequest(format!(
"node id projection collision: {prior} and {node_id} both project to \
raft id {raft_id}; re-mint one of the node ids"
)));
}
}
map.insert(raft_id, basic_node(address)?);
}
self.group
.bootstrap(map)
.await
.map_err(MetaError::Consensus)
}
pub async fn is_initialized(&self) -> Result<bool, MetaError> {
self.group
.is_initialized()
.await
.map_err(MetaError::Consensus)
}
pub async fn add_member(
&self,
descriptor: &NodeDescriptor,
control: &ExecutionControl,
) -> Result<MetaCommandReceipt, MetaError> {
let raft_id = raft_node_id(&descriptor.node_id);
let (voters, learners) = self.group.members();
if voters.contains(&raft_id) || learners.contains(&raft_id) {
return Err(MetaError::InvalidRequest(format!(
"node {} is already a meta group member",
descriptor.node_id
)));
}
self.group
.add_learner(raft_id, basic_node(&descriptor.rpc_address)?)
.await?;
self.group.promote(raft_id).await?;
self.propose(
new_command_id()?,
MetaCommand::RegisterNode {
descriptor: descriptor.clone(),
},
control,
)
.await
}
pub async fn remove_member(
&self,
node_id: NodeId,
control: &ExecutionControl,
) -> Result<MetaCommandReceipt, MetaError> {
let raft_id = raft_node_id(&node_id);
let metrics = self.group.metrics();
if metrics.current_leader == Some(raft_id) {
return Err(MetaError::InvalidRequest(
"transfer leadership off the node before removing it".to_owned(),
));
}
let receipt = self
.propose(
new_command_id()?,
MetaCommand::RemoveNode { node_id },
control,
)
.await?;
self.group.remove(raft_id).await?;
Ok(receipt)
}
pub async fn propose(
&self,
command_id: [u8; 16],
command: MetaCommand,
control: &ExecutionControl,
) -> Result<MetaCommandReceipt, MetaError> {
let payload = MetaCommandRecord::new(command).encode()?;
let envelope = CommandEnvelope::new(COMMAND_TYPE_META_COMMAND, command_id, payload);
let receipt = self
.group
.propose(CommandKind::Catalog, envelope, control)
.await?;
let (metadata_version, rejection) = {
let sink = self
.sink
.lock()
.map_err(|_| MetaError::InvalidRequest("meta sink lock poisoned".to_owned()))?;
let rejection = sink
.state()
.rejections()
.iter()
.rev()
.find(|entry| entry.command_id == Some(command_id))
.map(|entry| entry.reason.clone());
(sink.metadata_version(), rejection)
};
if let Some(reason) = rejection {
return Err(MetaError::Rejected(reason));
}
Ok(MetaCommandReceipt {
receipt,
metadata_version,
})
}
pub fn state(&self) -> MetaState {
self.sink
.lock()
.expect("meta sink lock poisoned")
.state()
.clone()
}
pub async fn allocate_raft_node_ids(
&self,
count: u32,
control: &ExecutionControl,
) -> Result<Vec<RaftNodeId>, MetaError> {
let command_id = new_command_id()?;
self.propose(
command_id,
MetaCommand::AllocateRaftNodeIds { count },
control,
)
.await?;
let state = self.state();
let base = state.raft_id_allocation(&command_id).ok_or_else(|| {
MetaError::InvalidRequest(
"raft node id allocation record missing after commit".to_owned(),
)
})?;
Ok((base..base + u64::from(count)).collect())
}
pub fn metadata_version(&self) -> MetadataVersion {
self.sink
.lock()
.expect("meta sink lock poisoned")
.metadata_version()
}
pub async fn shutdown(&self) -> Result<(), MetaError> {
self.group.shutdown().await.map_err(MetaError::Consensus)
}
pub async fn crash(self) {
self.group.crash().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::{BuildVersion, Locality, NodeCapacity, NodeState};
fn node_id(byte: u8) -> NodeId {
NodeId::from_bytes([byte; 16])
}
fn descriptor(byte: u8, features: &[&str]) -> NodeDescriptor {
let mut version_info = VersionInfo::current();
version_info.feature_set = features.iter().map(|feature| feature.to_string()).collect();
NodeDescriptor {
node_id: node_id(byte),
rpc_address: format!("127.0.0.1:{}", 7000 + u16::from(byte)),
locality: Locality::default(),
capacity: NodeCapacity::default(),
state: NodeState::Up,
version: BuildVersion::current(),
version_info,
}
}
fn registry_with(feature: &str, level: u64) -> FeatureRegistry {
let mut registry = FeatureRegistry::current();
registry.declare(feature, ClusterFeatureLevel(level));
registry
}
fn activation(feature: &str, level: u64) -> FeatureActivation {
FeatureActivation {
feature: feature.to_owned(),
level: ClusterFeatureLevel(level),
activated_at: HlcTimestamp::ZERO,
activated_by: node_id(1),
}
}
#[test]
fn feature_supported_only_at_or_above_registered_level() {
let registry = registry_with("ann-v2", 7);
assert!(!registry.feature_supported(ClusterFeatureLevel(6), "ann-v2"));
assert!(registry.feature_supported(ClusterFeatureLevel(7), "ann-v2"));
assert!(registry.feature_supported(ClusterFeatureLevel(8), "ann-v2"));
assert!(!registry.feature_supported(ClusterFeatureLevel(u64::MAX), "nope"));
}
#[test]
fn activation_refused_until_every_voter_supports_the_feature() {
let registry = registry_with("ann-v2", 7);
let voters = vec![
descriptor(1, &["ann-v2"]),
descriptor(2, &[]),
descriptor(3, &["ann-v2"]),
];
let error = activation("ann-v2", 7)
.validate(®istry, ClusterFeatureLevel::ZERO, &voters)
.unwrap_err();
assert_eq!(
error,
FeatureActivationError::UnsupportedByVoter {
feature: "ann-v2".to_owned(),
node: node_id(2),
}
);
let voters = vec![
descriptor(1, &["ann-v2"]),
descriptor(2, &["ann-v2"]),
descriptor(3, &["ann-v2"]),
];
activation("ann-v2", 7)
.validate(®istry, ClusterFeatureLevel::ZERO, &voters)
.unwrap();
}
#[test]
fn activation_rejects_unknown_features() {
let registry = FeatureRegistry::current();
let voters = vec![descriptor(1, &["ann-v2"])];
let error = activation("ann-v2", 7)
.validate(®istry, ClusterFeatureLevel::ZERO, &voters)
.unwrap_err();
assert_eq!(
error,
FeatureActivationError::UnknownFeature {
feature: "ann-v2".to_owned(),
}
);
}
#[test]
fn activation_rejects_a_level_below_the_registered_minimum() {
let registry = registry_with("ann-v2", 7);
let voters = vec![descriptor(1, &["ann-v2"])];
let error = activation("ann-v2", 6)
.validate(®istry, ClusterFeatureLevel::ZERO, &voters)
.unwrap_err();
assert_eq!(
error,
FeatureActivationError::LevelBelowRequirement {
feature: "ann-v2".to_owned(),
required: ClusterFeatureLevel(7),
attempted: ClusterFeatureLevel(6),
}
);
}
#[test]
fn feature_level_never_regresses() {
let registry = registry_with("ann-v2", 7);
let voters = vec![descriptor(1, &["ann-v2"])];
let error = activation("ann-v2", 7)
.validate(®istry, ClusterFeatureLevel(9), &voters)
.unwrap_err();
assert_eq!(
error,
FeatureActivationError::LevelRegression {
current: ClusterFeatureLevel(9),
attempted: ClusterFeatureLevel(7),
}
);
let registry = registry_with("ai-hybrid", 9);
let voters = vec![descriptor(1, &["ai-hybrid"])];
activation("ai-hybrid", 9)
.validate(®istry, ClusterFeatureLevel(9), &voters)
.unwrap();
}
#[test]
fn activation_requires_at_least_one_voter() {
let registry = registry_with("ann-v2", 7);
let error = activation("ann-v2", 7)
.validate(®istry, ClusterFeatureLevel::ZERO, &[])
.unwrap_err();
assert_eq!(error, FeatureActivationError::NoVoters);
}
#[test]
fn activation_record_round_trips_serde() {
let record = activation("ann-v2", 7);
let json = serde_json::to_vec(&record).unwrap();
let back: FeatureActivation = serde_json::from_slice(&json).unwrap();
assert_eq!(back, record);
}
#[test]
fn upgrade_plan_upgrades_followers_first_and_the_leader_last() {
let target = VersionInfo::current();
let nodes = vec![descriptor(1, &[]), descriptor(2, &[]), descriptor(3, &[])];
let plan = plan_rolling_upgrade(&nodes, node_id(1), &target).unwrap();
assert_eq!(plan.target, target);
assert_eq!(
plan.steps,
vec![
UpgradeStep::UpgradeFollower {
node_id: node_id(2)
},
UpgradeStep::UpgradeFollower {
node_id: node_id(3)
},
UpgradeStep::TransferLeadership { from: node_id(1) },
UpgradeStep::UpgradeFormerLeader {
node_id: node_id(1)
},
UpgradeStep::EnableNewFeatures,
]
);
}
#[test]
fn upgrade_plan_for_a_single_node_skips_leadership_transfer() {
let target = VersionInfo::current();
let nodes = vec![descriptor(1, &[])];
let plan = plan_rolling_upgrade(&nodes, node_id(1), &target).unwrap();
assert_eq!(
plan.steps,
vec![
UpgradeStep::UpgradeFormerLeader {
node_id: node_id(1)
},
UpgradeStep::EnableNewFeatures,
]
);
}
#[test]
fn upgrade_plan_verifies_compatibility_first() {
let mut target = VersionInfo::current();
target.protocol_min = target.protocol_max + 1;
let nodes = vec![descriptor(1, &[]), descriptor(2, &[])];
let error = plan_rolling_upgrade(&nodes, node_id(1), &target).unwrap_err();
assert!(matches!(
error,
UpgradePlanError::IncompatibleNode {
node,
incompatibility: Incompatibility::ProtocolVersion { .. },
} if node == node_id(1)
));
}
#[test]
fn upgrade_plan_rejects_malformed_membership() {
let target = VersionInfo::current();
assert_eq!(
plan_rolling_upgrade(&[], node_id(1), &target).unwrap_err(),
UpgradePlanError::EmptyMembership,
);
let nodes = vec![descriptor(1, &[])];
assert_eq!(
plan_rolling_upgrade(&nodes, node_id(9), &target).unwrap_err(),
UpgradePlanError::LeaderNotInMembership { leader: node_id(9) },
);
let nodes = vec![descriptor(1, &[]), descriptor(1, &[])];
assert_eq!(
plan_rolling_upgrade(&nodes, node_id(1), &target).unwrap_err(),
UpgradePlanError::DuplicateNode { node: node_id(1) },
);
}
#[test]
fn rollback_is_a_binary_downgrade_until_the_first_feature_activates() {
let before_activation = assess_rollback(&[]);
assert_eq!(before_activation.path, RollbackPath::BinaryDowngrade);
assert!(before_activation.activated_features.is_empty());
let after_activation = assess_rollback(&[activation("ann-v2", 7)]);
assert_eq!(after_activation.path, RollbackPath::RestoreFromBackup);
assert_eq!(
after_activation.activated_features,
vec!["ann-v2".to_owned()]
);
}
}
#[cfg(test)]
mod stage3a_tests {
use super::*;
use crate::node::{BuildVersion, Locality, NodeCapacity};
use mongreldb_consensus::network::InMemoryTransport;
use mongreldb_log::commit_log::LogPosition;
use std::path::Path;
use std::time::{Duration, Instant};
const LEADER_TIMEOUT: Duration = Duration::from_secs(10);
const META_GID: RaftGroupId = RaftGroupId::from_bytes([0xAA; 16]);
fn node_id(byte: u8) -> NodeId {
NodeId::from_bytes([byte; 16])
}
fn raft_id(byte: u8) -> RaftNodeId {
raft_node_id(&node_id(byte))
}
fn group_id(byte: u8) -> RaftGroupId {
RaftGroupId::from_bytes([byte; 16])
}
fn ts(micros: u64) -> HlcTimestamp {
HlcTimestamp {
physical_micros: micros,
logical: 0,
node_tiebreaker: 0,
}
}
fn cmd_id(byte: u8) -> [u8; 16] {
[byte; 16]
}
fn descriptor(byte: u8, features: &[&str]) -> NodeDescriptor {
let mut version_info = VersionInfo::current();
version_info.feature_set = features.iter().map(|feature| feature.to_string()).collect();
NodeDescriptor {
node_id: node_id(byte),
rpc_address: format!("127.0.0.1:{}", 7100 + u16::from(byte)),
locality: Locality::default(),
capacity: NodeCapacity::default(),
state: NodeState::Up,
version: BuildVersion::current(),
version_info,
}
}
fn registry_with(feature: &str, level: u64) -> FeatureRegistry {
let mut registry = FeatureRegistry::current();
registry.declare(feature, ClusterFeatureLevel(level));
registry
}
fn database(byte: u8, name: &str) -> DatabaseDescriptor {
DatabaseDescriptor {
database_id: DatabaseId::from_bytes([byte; 16]),
name: name.to_owned(),
created_at: ts(1_000),
state: DatabaseState::Online,
metadata_version: MetadataVersion::ZERO,
}
}
fn schema_record(table: u64, database_byte: u8, version: u64) -> TableSchemaRecord {
TableSchemaRecord {
table_id: TableId(table),
database_id: DatabaseId::from_bytes([database_byte; 16]),
schema_version: SchemaVersion(version),
schema: serde_json::json!({"columns": [{"name": "pk", "type": "u64"}]}),
metadata_version: MetadataVersion::ZERO,
}
}
fn tablet(byte: u8, table: u64, generation: u64) -> TabletDescriptor {
TabletDescriptor {
tablet_id: TabletId::from_bytes([byte; 16]),
table_id: TableId(table),
database_id: mongreldb_types::ids::DatabaseId::ZERO,
raft_group_id: group_id(9),
partition: PartitionBounds {
low: Bound::Unbounded,
high: Bound::Excluded(Key::from_bytes(b"m".to_vec())),
},
replicas: vec![ReplicaDescriptor {
node_id: node_id(1),
role: ReplicaRole::Voter,
raft_node_id: raft_id(1),
}],
leader_hint: None,
generation,
state: TabletState::Active,
}
}
fn placement(byte: u8, members: &[(u8, ReplicaRole)]) -> ReplicaPlacement {
ReplicaPlacement {
raft_group_id: group_id(byte),
replicas: members
.iter()
.map(|(node, role)| ReplicaDescriptor {
node_id: node_id(*node),
role: *role,
raft_node_id: raft_id(*node),
})
.collect(),
metadata_version: MetadataVersion::ZERO,
}
}
fn policy(replicas: u8) -> PlacementPolicy {
PlacementPolicy {
replicas,
voter_constraints: vec![LocalityConstraint {
key: "region".to_owned(),
value: "us-central".to_owned(),
required: true,
}],
leader_preferences: Vec::new(),
prohibited_nodes: Vec::new(),
}
}
fn schema_job(job_id: u64, table: u64, database_byte: u8) -> SchemaJobRecord {
SchemaJobRecord {
job_id,
database_id: DatabaseId::from_bytes([database_byte; 16]),
table_id: TableId(table),
kind: SchemaJobKind::IndexBuild,
state: SchemaJobState::Pending,
submitted_at: ts(1_000),
updated_at: ts(1_000),
error: None,
metadata_version: MetadataVersion::ZERO,
}
}
fn activation(feature: &str, level: u64) -> FeatureActivation {
FeatureActivation {
feature: feature.to_owned(),
level: ClusterFeatureLevel(level),
activated_at: HlcTimestamp::ZERO,
activated_by: node_id(1),
}
}
fn apply(
state: &mut MetaState,
registry: &FeatureRegistry,
id: u8,
command: MetaCommand,
) -> Result<(), MetaRejectionReason> {
state.apply(&command, Some(cmd_id(id)), ts(1_000), registry)
}
fn fast_group_config(config: &MetaGroupConfig) -> GroupConfig {
let mut group = config.group_config();
group.heartbeat_interval = Duration::from_millis(50);
group.election_timeout_min = Duration::from_millis(150);
group.election_timeout_max = Duration::from_millis(300);
group.install_snapshot_timeout = Duration::from_millis(1_000);
group
}
fn meta_config(dir: &Path, node: u8, registry: FeatureRegistry) -> MetaGroupConfig {
let mut config = MetaGroupConfig::new(dir.to_path_buf(), META_GID, node_id(node));
config.registry = registry;
config
}
async fn wait_consensus_leader(among: &[&MetaGroup<InMemoryTransport>]) -> RaftNodeId {
let allowed: BTreeSet<RaftNodeId> = among
.iter()
.map(|group| raft_node_id(&group.node_id()))
.collect();
let deadline = Instant::now() + LEADER_TIMEOUT;
loop {
let mut leaders = BTreeSet::new();
let mut seen = 0_usize;
for group in among {
if let Some(leader) = group.group().metrics().current_leader {
leaders.insert(leader);
seen += 1;
}
}
if seen == among.len() && leaders.len() == 1 {
let leader = *leaders.iter().next().expect("one leader");
if allowed.contains(&leader) {
return leader;
}
}
assert!(
Instant::now() < deadline,
"no consensus leader (saw {leaders:?})"
);
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
#[test]
fn meta_command_record_round_trips_every_variant() {
let commands = vec![
MetaCommand::RegisterNode {
descriptor: descriptor(1, &["ann-v2"]),
},
MetaCommand::UpdateNodeState {
node_id: node_id(1),
state: NodeState::Draining,
expected_version: Some(MetadataVersion(3)),
},
MetaCommand::RemoveNode {
node_id: node_id(1),
},
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
MetaCommand::DropDatabase {
database_id: DatabaseId::from_bytes([1; 16]),
},
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 1),
},
MetaCommand::SetTabletDescriptor {
descriptor: tablet(1, 1, 1),
},
MetaCommand::RemoveTabletDescriptor {
tablet_id: TabletId::from_bytes([1; 16]),
generation: 1,
},
MetaCommand::SetReplicaPlacement {
placement: placement(9, &[(1, ReplicaRole::Voter)]),
},
MetaCommand::SetPlacementPolicy {
name: "default".to_owned(),
policy: policy(3),
},
MetaCommand::SubmitSchemaJob {
job: schema_job(7, 1, 1),
},
MetaCommand::UpdateSchemaJob {
job_id: 7,
state: SchemaJobState::Running,
updated_at: ts(2_000),
error: None,
expected_version: None,
},
MetaCommand::SetClusterSetting {
key: "jobs.max_concurrent".to_owned(),
value: serde_json::json!(4),
},
MetaCommand::ActivateFeature {
activation: activation("ann-v2", 7),
},
MetaCommand::SetTxnStatusPartition {
partition: TxnStatusPartition {
partition_id: 0,
home_raft_group: group_id(9),
},
},
MetaCommand::PublishSplit {
command: split_publish_command(),
},
MetaCommand::PublishMerge {
command: merge_publish_command(),
},
MetaCommand::AllocateRaftNodeIds { count: 3 },
];
for command in commands {
let record = MetaCommandRecord::new(command);
let bytes = record.encode().unwrap();
assert_eq!(MetaCommandRecord::decode(&bytes).unwrap(), record);
}
assert!(matches!(
MetaCommandRecord::decode(b"not json"),
Err(MetaDecodeError::Malformed(_))
));
let future = MetaCommandRecord {
format_version: META_COMMAND_FORMAT_VERSION + 1,
command: MetaCommand::RemoveNode {
node_id: node_id(1),
},
};
assert_eq!(
MetaCommandRecord::decode(&future.encode().unwrap()).unwrap_err(),
MetaDecodeError::UnsupportedVersion {
found: META_COMMAND_FORMAT_VERSION + 1,
min: MIN_SUPPORTED_META_COMMAND_FORMAT_VERSION,
max: META_COMMAND_FORMAT_VERSION,
}
);
}
fn every_command_sequence() -> Vec<MetaCommand> {
vec![
MetaCommand::RegisterNode {
descriptor: descriptor(1, &["ann-v2"]),
},
MetaCommand::UpdateNodeState {
node_id: node_id(1),
state: NodeState::Draining,
expected_version: None,
},
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 1),
},
MetaCommand::SetTabletDescriptor {
descriptor: tablet(1, 1, 1),
},
MetaCommand::RemoveTabletDescriptor {
tablet_id: TabletId::from_bytes([1; 16]),
generation: 1,
},
MetaCommand::SetReplicaPlacement {
placement: placement(9, &[(1, ReplicaRole::Voter)]),
},
MetaCommand::SetPlacementPolicy {
name: "default".to_owned(),
policy: policy(3),
},
MetaCommand::SubmitSchemaJob {
job: schema_job(7, 1, 1),
},
MetaCommand::UpdateSchemaJob {
job_id: 7,
state: SchemaJobState::Running,
updated_at: ts(2_000),
error: None,
expected_version: None,
},
MetaCommand::SetClusterSetting {
key: "jobs.max_concurrent".to_owned(),
value: serde_json::json!(4),
},
MetaCommand::ActivateFeature {
activation: activation("ann-v2", 7),
},
MetaCommand::SetTxnStatusPartition {
partition: TxnStatusPartition {
partition_id: 0,
home_raft_group: group_id(9),
},
},
MetaCommand::RemoveNode {
node_id: node_id(99),
},
MetaCommand::DropDatabase {
database_id: DatabaseId::from_bytes([0xEE; 16]),
},
MetaCommand::AllocateRaftNodeIds { count: 3 },
]
}
#[test]
fn apply_round_trips_every_command() {
let registry = registry_with("ann-v2", 7);
let mut state = MetaState::default();
for (index, command) in every_command_sequence().into_iter().enumerate() {
let id = u8::try_from(index + 1).unwrap();
apply(&mut state, ®istry, id, command).unwrap();
}
assert_eq!(state.metadata_version, MetadataVersion(16));
assert!(state.rejections().is_empty());
assert_eq!(state.next_raft_node_id(), FIRST_RAFT_NODE_ID + 3);
let node = state.node_record(node_id(1)).unwrap();
assert_eq!(node.descriptor.state, NodeState::Draining);
assert_eq!(node.metadata_version, MetadataVersion(2));
assert_eq!(state.database_by_name("app").unwrap().name, "app");
assert_eq!(
state.table(TableId(1)).unwrap().schema_version,
SchemaVersion(1)
);
assert!(state.tablets.is_empty());
assert_eq!(state.placement(group_id(9)).unwrap().replicas.len(), 1);
assert_eq!(state.placement_policy("default").unwrap().replicas, 3);
assert_eq!(state.schema_job(7).unwrap().state, SchemaJobState::Running);
assert_eq!(state.settings().max_concurrent_jobs, 4);
assert_eq!(state.feature_level(), ClusterFeatureLevel(7));
assert!(state.feature_active(®istry, "ann-v2"));
assert_eq!(state.feature_activations()[0].activated_at, ts(1_000));
assert_eq!(
state.txn_status_partition(0).unwrap().home_raft_group,
group_id(9)
);
}
#[test]
fn apply_is_idempotent_for_record_replays() {
let registry = registry_with("ann-v2", 7);
let mut state = MetaState::default();
apply(
&mut state,
®istry,
1,
MetaCommand::RegisterNode {
descriptor: descriptor(1, &["ann-v2"]),
},
)
.unwrap();
apply(
&mut state,
®istry,
2,
MetaCommand::RegisterNode {
descriptor: descriptor(1, &["ann-v2"]),
},
)
.unwrap();
assert_eq!(state.nodes.len(), 1);
assert_eq!(
state.node_record(node_id(1)).unwrap().metadata_version,
MetadataVersion(1)
);
assert_eq!(state.metadata_version, MetadataVersion(2));
apply(
&mut state,
®istry,
3,
MetaCommand::ActivateFeature {
activation: activation("ann-v2", 7),
},
)
.unwrap();
apply(
&mut state,
®istry,
4,
MetaCommand::ActivateFeature {
activation: activation("ann-v2", 7),
},
)
.unwrap();
assert_eq!(state.feature_activations().len(), 1);
assert_eq!(state.feature_level(), ClusterFeatureLevel(7));
}
#[test]
fn apply_is_deterministic_across_states() {
let registry = registry_with("ann-v2", 7);
let commands = every_command_sequence();
let mut a = MetaState::default();
let mut b = MetaState::default();
for (index, command) in commands.iter().enumerate() {
let id = u8::try_from(index + 1).unwrap();
a.apply(command, Some(cmd_id(id)), ts(1_000), ®istry)
.unwrap();
b.apply(command, Some(cmd_id(id)), ts(1_000), ®istry)
.unwrap();
}
assert_eq!(a, b);
assert_eq!(
serde_json::to_vec(&a).unwrap(),
serde_json::to_vec(&b).unwrap()
);
}
fn key(bytes: &[u8]) -> Key {
Key::from_bytes(bytes.to_vec())
}
fn voter_on(node: u8, raft: u64) -> ReplicaDescriptor {
ReplicaDescriptor {
node_id: node_id(node),
role: ReplicaRole::Voter,
raft_node_id: raft,
}
}
fn split_source() -> TabletDescriptor {
TabletDescriptor {
tablet_id: TabletId::from_bytes([0x51; 16]),
table_id: TableId(1),
database_id: mongreldb_types::ids::DatabaseId::ZERO,
raft_group_id: group_id(0x51),
partition: PartitionBounds::new(Bound::Included(key(b"a")), Bound::Excluded(key(b"z")))
.unwrap(),
replicas: vec![voter_on(1, 101), voter_on(2, 102)],
leader_hint: None,
generation: 5,
state: TabletState::Active,
}
}
fn split_plan() -> crate::split::SplitPlan {
crate::split::TabletSplitPlanner::new("/unused")
.plan(
&split_source(),
crate::split::SplitKeySelection::Explicit(key(b"m")),
ts(150),
[
crate::split::ChildAllocation {
tablet_id: TabletId::from_bytes([0x52; 16]),
raft_group_id: group_id(0x52),
replicas: vec![voter_on(1, 201), voter_on(2, 202)],
},
crate::split::ChildAllocation {
tablet_id: TabletId::from_bytes([0x53; 16]),
raft_group_id: group_id(0x53),
replicas: vec![voter_on(1, 301), voter_on(2, 302)],
},
],
)
.unwrap()
}
fn split_publish_command() -> SplitPublishCommand {
SplitPublishCommand::from_plan(&split_plan()).unwrap()
}
fn seed_split_publish(
state: &mut MetaState,
registry: &FeatureRegistry,
) -> crate::split::SplitPlan {
apply(
state,
registry,
1,
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
)
.unwrap();
apply(
state,
registry,
2,
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 1),
},
)
.unwrap();
let plan = split_plan();
apply(
state,
registry,
3,
MetaCommand::SetTabletDescriptor {
descriptor: plan.source.clone(),
},
)
.unwrap();
let marked = plan
.source
.published_transition(TabletState::Splitting)
.unwrap();
apply(
state,
registry,
4,
MetaCommand::SetTabletDescriptor { descriptor: marked },
)
.unwrap();
for (index, child) in plan.child_descriptors().into_iter().enumerate() {
apply(
state,
registry,
5 + u8::try_from(index).unwrap(),
MetaCommand::SetTabletDescriptor { descriptor: child },
)
.unwrap();
}
plan
}
fn merge_sources() -> [TabletDescriptor; 2] {
let source =
|byte: u8, low: Bound<Key>, high: Bound<Key>, generation: u64, raft_base: u64| {
TabletDescriptor {
tablet_id: TabletId::from_bytes([byte; 16]),
table_id: TableId(1),
database_id: mongreldb_types::ids::DatabaseId::ZERO,
raft_group_id: group_id(byte),
partition: PartitionBounds::new(low, high).unwrap(),
replicas: vec![voter_on(1, raft_base), voter_on(2, raft_base + 1)],
leader_hint: None,
generation,
state: TabletState::Active,
}
};
[
source(
0x61,
Bound::Included(key(b"a")),
Bound::Excluded(key(b"m")),
4,
101,
),
source(
0x62,
Bound::Included(key(b"m")),
Bound::Excluded(key(b"z")),
6,
201,
),
]
}
fn merge_plan() -> crate::merge::MergePlan {
let [first, second] = merge_sources();
crate::merge::MergePlanner::new("/unused")
.plan(
crate::merge::MergeInputs {
first,
second,
first_schema: SchemaVersion(1),
second_schema: SchemaVersion(1),
active_schema_job: None,
first_size_bytes: 1_000,
second_size_bytes: 2_000,
max_merged_size_bytes: 1_000_000,
},
ts(150),
crate::split::ChildAllocation {
tablet_id: TabletId::from_bytes([0x63; 16]),
raft_group_id: group_id(0x63),
replicas: vec![voter_on(1, 301), voter_on(2, 302)],
},
)
.unwrap()
}
fn merge_publish_command() -> MergePublishCommand {
MergePublishCommand::from_plan(&merge_plan()).unwrap()
}
fn seed_merge_publish(
state: &mut MetaState,
registry: &FeatureRegistry,
) -> crate::merge::MergePlan {
apply(
state,
registry,
1,
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
)
.unwrap();
apply(
state,
registry,
2,
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 1),
},
)
.unwrap();
let plan = merge_plan();
for (index, source) in plan.sources.iter().enumerate() {
let id = 3 + 2 * u8::try_from(index).unwrap();
apply(
state,
registry,
id,
MetaCommand::SetTabletDescriptor {
descriptor: source.clone(),
},
)
.unwrap();
let marked = source.published_transition(TabletState::Merging).unwrap();
apply(
state,
registry,
id + 1,
MetaCommand::SetTabletDescriptor { descriptor: marked },
)
.unwrap();
}
apply(
state,
registry,
7,
MetaCommand::SetTabletDescriptor {
descriptor: plan.replacement_descriptor(),
},
)
.unwrap();
plan
}
#[test]
fn publish_split_applies_atomically_and_replays_idempotently() {
let registry = registry_with("ann-v2", 7);
let mut state = MetaState::default();
let plan = seed_split_publish(&mut state, ®istry);
let command = SplitPublishCommand::from_plan(&plan).unwrap();
apply(
&mut state,
®istry,
10,
MetaCommand::PublishSplit {
command: command.clone(),
},
)
.unwrap();
let source = state.tablet(plan.source.tablet_id).unwrap().clone();
assert_eq!(source.state, TabletState::Retiring);
assert_eq!(source.generation, 7);
for child in &command.children {
let stored = state.tablet(child.tablet_id).unwrap();
assert_eq!(stored, child);
assert_eq!(stored.state, TabletState::Active);
assert_eq!(stored.generation, 7);
assert!(stored.replicas.iter().all(|r| r.role == ReplicaRole::Voter));
}
assert!(state.rejections().is_empty());
let version = state.metadata_version;
apply(
&mut state,
®istry,
11,
MetaCommand::PublishSplit { command },
)
.unwrap();
assert!(state.rejections().is_empty());
assert_eq!(state.tablet(plan.source.tablet_id).unwrap(), &source);
assert_eq!(
state
.tablet_record(plan.source.tablet_id)
.unwrap()
.metadata_version,
version
);
}
#[test]
fn publish_split_rejection_matrix() {
let registry = registry_with("ann-v2", 7);
let mut state = MetaState::default();
apply(
&mut state,
®istry,
1,
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
)
.unwrap();
apply(
&mut state,
®istry,
2,
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 1),
},
)
.unwrap();
apply(
&mut state,
®istry,
3,
MetaCommand::SetTabletDescriptor {
descriptor: split_source(),
},
)
.unwrap();
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishSplit {
command: split_publish_command(),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
assert_eq!(state.rejections().len(), 1);
assert_eq!(state.rejections()[0].command_id, Some(cmd_id(21)));
let mut state = MetaState::default();
let plan = seed_split_publish(&mut state, ®istry);
apply(
&mut state,
®istry,
20,
MetaCommand::RemoveTabletDescriptor {
tablet_id: plan.child_descriptors()[1].tablet_id,
generation: 6,
},
)
.unwrap();
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishSplit {
command: split_publish_command(),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::NotFound { .. }));
let mut state = MetaState::default();
let plan = seed_split_publish(&mut state, ®istry);
let mut rogue = plan.child_descriptors()[0].clone();
rogue.state = TabletState::Active;
rogue.generation = 7;
for replica in &mut rogue.replicas {
replica.role = ReplicaRole::Voter;
}
apply(
&mut state,
®istry,
20,
MetaCommand::SetTabletDescriptor { descriptor: rogue },
)
.unwrap();
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishSplit {
command: split_publish_command(),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
let mut state = MetaState::default();
seed_split_publish(&mut state, ®istry);
let mut command = split_publish_command();
command.source.generation += 1;
for child in &mut command.children {
child.generation += 1;
}
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishSplit { command },
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
let mut state = MetaState::default();
seed_split_publish(&mut state, ®istry);
let mut command = split_publish_command();
command.split_key = key(b"n");
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishSplit { command },
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Invalid { .. }));
let mut state = MetaState::default();
seed_split_publish(&mut state, ®istry);
let overlapping = TabletDescriptor {
tablet_id: TabletId::from_bytes([0x70; 16]),
table_id: TableId(1),
database_id: mongreldb_types::ids::DatabaseId::ZERO,
raft_group_id: group_id(0x70),
partition: PartitionBounds::new(Bound::Included(key(b"a")), Bound::Excluded(key(b"b")))
.unwrap(),
replicas: vec![voter_on(1, 901)],
leader_hint: None,
generation: 1,
state: TabletState::Active,
};
apply(
&mut state,
®istry,
20,
MetaCommand::SetTabletDescriptor {
descriptor: overlapping,
},
)
.unwrap();
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishSplit {
command: split_publish_command(),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
let mut state = MetaState::default();
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishSplit {
command: split_publish_command(),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::NotFound { .. }));
}
#[test]
fn publish_merge_applies_atomically_with_lagging_generations_and_replays() {
let registry = registry_with("ann-v2", 7);
let mut state = MetaState::default();
let plan = seed_merge_publish(&mut state, ®istry);
let command = MergePublishCommand::from_plan(&plan).unwrap();
apply(
&mut state,
®istry,
10,
MetaCommand::PublishMerge {
command: command.clone(),
},
)
.unwrap();
let replacement = state.tablet(command.replacement.tablet_id).unwrap();
assert_eq!(replacement.state, TabletState::Active);
assert_eq!(replacement.generation, 8);
for source in &command.sources {
let stored = state.tablet(source.tablet_id).unwrap();
assert_eq!(stored.state, TabletState::Retiring);
assert_eq!(stored.generation, 8);
}
assert!(state.rejections().is_empty());
apply(
&mut state,
®istry,
11,
MetaCommand::PublishMerge { command },
)
.unwrap();
assert!(state.rejections().is_empty());
}
#[test]
fn publish_merge_rejection_matrix() {
let registry = registry_with("ann-v2", 7);
let mut state = MetaState::default();
let plan = seed_merge_publish(&mut state, ®istry);
let mut active = plan.sources[0].clone();
active.generation = 6;
apply(
&mut state,
®istry,
20,
MetaCommand::SetTabletDescriptor { descriptor: active },
)
.unwrap();
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishMerge {
command: merge_publish_command(),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
let mut state = MetaState::default();
let plan = seed_merge_publish(&mut state, ®istry);
apply(
&mut state,
®istry,
20,
MetaCommand::RemoveTabletDescriptor {
tablet_id: plan.replacement_descriptor().tablet_id,
generation: 7,
},
)
.unwrap();
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishMerge {
command: merge_publish_command(),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::NotFound { .. }));
let mut state = MetaState::default();
let plan = seed_merge_publish(&mut state, ®istry);
let mut rogue = plan.replacement_descriptor();
rogue.state = TabletState::Active;
rogue.generation = 8;
for replica in &mut rogue.replicas {
replica.role = ReplicaRole::Voter;
}
apply(
&mut state,
®istry,
20,
MetaCommand::SetTabletDescriptor { descriptor: rogue },
)
.unwrap();
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishMerge {
command: merge_publish_command(),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
let mut state = MetaState::default();
let plan = seed_merge_publish(&mut state, ®istry);
let mut rogue = plan.replacement_descriptor();
rogue.generation -= 1;
apply(
&mut state,
®istry,
19,
MetaCommand::RemoveTabletDescriptor {
tablet_id: rogue.tablet_id,
generation: 7,
},
)
.unwrap();
apply(
&mut state,
®istry,
20,
MetaCommand::SetTabletDescriptor { descriptor: rogue },
)
.unwrap();
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishMerge {
command: merge_publish_command(),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
let mut state = MetaState::default();
seed_merge_publish(&mut state, ®istry);
let mut command = merge_publish_command();
command.replacement.partition = command.sources[0].partition.clone();
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishMerge { command },
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Invalid { .. }));
let mut state = MetaState::default();
seed_merge_publish(&mut state, ®istry);
let overlapping = TabletDescriptor {
tablet_id: TabletId::from_bytes([0x70; 16]),
table_id: TableId(1),
database_id: mongreldb_types::ids::DatabaseId::ZERO,
raft_group_id: group_id(0x70),
partition: PartitionBounds::new(Bound::Included(key(b"b")), Bound::Excluded(key(b"c")))
.unwrap(),
replicas: vec![voter_on(1, 901)],
leader_hint: None,
generation: 1,
state: TabletState::Active,
};
apply(
&mut state,
®istry,
20,
MetaCommand::SetTabletDescriptor {
descriptor: overlapping,
},
)
.unwrap();
let error = apply(
&mut state,
®istry,
21,
MetaCommand::PublishMerge {
command: merge_publish_command(),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
}
#[test]
fn allocate_raft_node_ids_is_monotonic_unique_and_replay_safe() {
let registry = registry_with("ann-v2", 7);
let mut state = MetaState::default();
apply(
&mut state,
®istry,
1,
MetaCommand::AllocateRaftNodeIds { count: 3 },
)
.unwrap();
assert_eq!(
state.raft_id_allocation(&cmd_id(1)),
Some(FIRST_RAFT_NODE_ID)
);
assert_eq!(state.next_raft_node_id(), FIRST_RAFT_NODE_ID + 3);
apply(
&mut state,
®istry,
2,
MetaCommand::AllocateRaftNodeIds { count: 2 },
)
.unwrap();
assert_eq!(
state.raft_id_allocation(&cmd_id(2)),
Some(FIRST_RAFT_NODE_ID + 3)
);
assert_eq!(state.next_raft_node_id(), FIRST_RAFT_NODE_ID + 5);
apply(
&mut state,
®istry,
1,
MetaCommand::AllocateRaftNodeIds { count: 3 },
)
.unwrap();
assert_eq!(state.next_raft_node_id(), FIRST_RAFT_NODE_ID + 5);
assert_eq!(state.raft_id_allocations.len(), 2);
let error = apply(
&mut state,
®istry,
3,
MetaCommand::AllocateRaftNodeIds { count: 0 },
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Invalid { .. }));
let error = apply(
&mut state,
®istry,
4,
MetaCommand::AllocateRaftNodeIds {
count: MAX_RAFT_NODE_ID_ALLOCATION + 1,
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Invalid { .. }));
assert_eq!(state.rejections().len(), 2);
assert_eq!(state.next_raft_node_id(), FIRST_RAFT_NODE_ID + 5);
}
#[test]
fn allocator_skips_ids_in_use_and_survives_restarts() {
let registry = registry_with("ann-v2", 7);
let mut state = MetaState::default();
apply(
&mut state,
®istry,
1,
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
)
.unwrap();
apply(
&mut state,
®istry,
2,
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 1),
},
)
.unwrap();
for (id, node) in [(3, 1), (4, 2)] {
apply(
&mut state,
®istry,
id,
MetaCommand::RegisterNode {
descriptor: descriptor(node, &[]),
},
)
.unwrap();
}
let mut tablet = tablet(1, 1, 1);
tablet.replicas = vec![voter_on(1, 1), voter_on(2, 3)];
apply(
&mut state,
®istry,
5,
MetaCommand::SetTabletDescriptor { descriptor: tablet },
)
.unwrap();
apply(
&mut state,
®istry,
6,
MetaCommand::SetReplicaPlacement {
placement: ReplicaPlacement {
raft_group_id: group_id(8),
replicas: vec![voter_on(1, 5)],
metadata_version: MetadataVersion::ZERO,
},
},
)
.unwrap();
apply(
&mut state,
®istry,
7,
MetaCommand::AllocateRaftNodeIds { count: 3 },
)
.unwrap();
assert_eq!(state.raft_id_allocation(&cmd_id(7)), Some(6));
assert_eq!(state.next_raft_node_id(), 9);
apply(
&mut state,
®istry,
8,
MetaCommand::RegisterNode {
descriptor: descriptor(9, &[]),
},
)
.unwrap();
state.next_raft_node_id = raft_node_id(&node_id(9));
apply(
&mut state,
®istry,
9,
MetaCommand::AllocateRaftNodeIds { count: 2 },
)
.unwrap();
assert_eq!(
state.raft_id_allocation(&cmd_id(9)),
Some(raft_node_id(&node_id(9)) + 1)
);
let tmp = tempfile::tempdir().unwrap();
let mut sink = MetaApplySink::open(tmp.path(), registry.clone()).unwrap();
ApplySink::apply(
&mut sink,
&applied(
1,
meta_envelope(1, MetaCommand::AllocateRaftNodeIds { count: 4 }),
),
)
.unwrap();
drop(sink);
let mut reopened = MetaApplySink::open(tmp.path(), registry).unwrap();
assert_eq!(reopened.state().next_raft_node_id(), FIRST_RAFT_NODE_ID + 4);
ApplySink::apply(
&mut reopened,
&applied(
2,
meta_envelope(1, MetaCommand::AllocateRaftNodeIds { count: 4 }),
),
)
.unwrap();
assert_eq!(reopened.state().next_raft_node_id(), FIRST_RAFT_NODE_ID + 4);
ApplySink::apply(
&mut reopened,
&applied(
3,
meta_envelope(2, MetaCommand::AllocateRaftNodeIds { count: 1 }),
),
)
.unwrap();
assert_eq!(
reopened.state().raft_id_allocation(&cmd_id(2)),
Some(FIRST_RAFT_NODE_ID + 4)
);
}
#[test]
fn register_node_rejects_a_projection_collision_with_replica_raft_ids() {
let registry = registry_with("ann-v2", 7);
let mut state = MetaState::default();
apply(
&mut state,
®istry,
1,
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
)
.unwrap();
apply(
&mut state,
®istry,
2,
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 1),
},
)
.unwrap();
let mut tablet = tablet(1, 1, 1);
tablet.replicas = vec![voter_on(1, 4242)];
apply(
&mut state,
®istry,
3,
MetaCommand::SetTabletDescriptor { descriptor: tablet },
)
.unwrap();
let mut colliding = descriptor(7, &[]);
let mut bytes = [0xAB; 16];
bytes[..8].copy_from_slice(&4242_u64.to_le_bytes());
colliding.node_id = NodeId::from_bytes(bytes);
let error = apply(
&mut state,
®istry,
4,
MetaCommand::RegisterNode {
descriptor: colliding.clone(),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
assert_eq!(state.rejections().len(), 1);
apply(
&mut state,
®istry,
5,
MetaCommand::RegisterNode {
descriptor: descriptor(8, &[]),
},
)
.unwrap();
}
#[test]
fn set_tablet_descriptor_rejects_structurally_invalid_descriptors() {
let registry = registry_with("ann-v2", 7);
let mut state = MetaState::default();
apply(
&mut state,
®istry,
1,
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
)
.unwrap();
apply(
&mut state,
®istry,
2,
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 1),
},
)
.unwrap();
let mut tablet = tablet(1, 1, 1);
tablet.replicas = vec![voter_on(1, 7), voter_on(2, 7)];
let error = apply(
&mut state,
®istry,
3,
MetaCommand::SetTabletDescriptor { descriptor: tablet },
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Invalid { .. }));
assert_eq!(state.rejections().len(), 1);
}
#[test]
fn activation_refused_at_apply_until_every_voter_supports_it() {
let registry = registry_with("ann-v2", 7);
let mut state = MetaState::default();
for (id, byte, features) in [
(1_u8, 1_u8, vec!["ann-v2"]),
(2, 2, vec![]),
(3, 3, vec!["ann-v2"]),
] {
apply(
&mut state,
®istry,
id,
MetaCommand::RegisterNode {
descriptor: descriptor(byte, &features),
},
)
.unwrap();
}
let error = apply(
&mut state,
®istry,
4,
MetaCommand::ActivateFeature {
activation: activation("ann-v2", 7),
},
)
.unwrap_err();
assert_eq!(
error,
MetaRejectionReason::FeatureActivation(FeatureActivationError::UnsupportedByVoter {
feature: "ann-v2".to_owned(),
node: node_id(2),
})
);
assert_eq!(state.feature_level(), ClusterFeatureLevel::ZERO);
assert_eq!(state.rejections().len(), 1);
assert_eq!(state.rejections()[0].command_id, Some(cmd_id(4)));
assert_eq!(state.rejections()[0].reason, error);
apply(
&mut state,
®istry,
5,
MetaCommand::RegisterNode {
descriptor: descriptor(2, &["ann-v2"]),
},
)
.unwrap();
apply(
&mut state,
®istry,
6,
MetaCommand::ActivateFeature {
activation: activation("ann-v2", 7),
},
)
.unwrap();
assert_eq!(state.feature_level(), ClusterFeatureLevel(7));
}
#[test]
fn activation_apply_rechecks_registry_level_and_voters() {
let mut registry = registry_with("ann-v2", 7);
registry.declare("ai-hybrid", ClusterFeatureLevel(5));
let mut state = MetaState::default();
let error = apply(
&mut state,
®istry,
1,
MetaCommand::ActivateFeature {
activation: activation("ann-v2", 7),
},
)
.unwrap_err();
assert_eq!(
error,
MetaRejectionReason::FeatureActivation(FeatureActivationError::NoVoters)
);
apply(
&mut state,
®istry,
2,
MetaCommand::RegisterNode {
descriptor: descriptor(1, &["ann-v2", "ai-hybrid"]),
},
)
.unwrap();
let error = apply(
&mut state,
®istry,
3,
MetaCommand::ActivateFeature {
activation: activation("ann-v2", 6),
},
)
.unwrap_err();
assert!(matches!(
error,
MetaRejectionReason::FeatureActivation(
FeatureActivationError::LevelBelowRequirement { .. }
)
));
let error = apply(
&mut state,
®istry,
4,
MetaCommand::ActivateFeature {
activation: activation("nope", 1),
},
)
.unwrap_err();
assert!(matches!(
error,
MetaRejectionReason::FeatureActivation(FeatureActivationError::UnknownFeature { .. })
));
apply(
&mut state,
®istry,
5,
MetaCommand::ActivateFeature {
activation: activation("ann-v2", 7),
},
)
.unwrap();
let error = apply(
&mut state,
®istry,
6,
MetaCommand::ActivateFeature {
activation: activation("ai-hybrid", 5),
},
)
.unwrap_err();
assert!(matches!(
error,
MetaRejectionReason::FeatureActivation(FeatureActivationError::LevelRegression { .. })
));
assert_eq!(state.rejections().len(), 4);
}
#[test]
fn settings_denylist_rejects_plaintext_secrets() {
let registry = FeatureRegistry::current();
let mut state = MetaState::default();
for (id, key) in [
(1_u8, "backup.private_key_pem"),
(2, "ai.api_key"),
(3, "admin.password"),
(4, "tls.secret"),
(5, "auth.token_endpoint"),
(6, "service.credential"),
(7, "secret.unknown"),
] {
let error = apply(
&mut state,
®istry,
id,
MetaCommand::SetClusterSetting {
key: key.to_owned(),
value: serde_json::json!("x"),
},
)
.unwrap_err();
assert_eq!(
error,
MetaRejectionReason::SecretSettingKey {
key: key.to_owned()
}
);
}
assert!(state.rejections().len() == 7);
assert_eq!(state.settings(), &ClusterSettings::default());
}
#[test]
fn settings_unknown_keys_and_bad_values_are_refused() {
let registry = FeatureRegistry::current();
let mut state = MetaState::default();
let error = apply(
&mut state,
®istry,
1,
MetaCommand::SetClusterSetting {
key: "no.such.key".to_owned(),
value: serde_json::json!(1),
},
)
.unwrap_err();
assert_eq!(
error,
MetaRejectionReason::UnknownSettingKey {
key: "no.such.key".to_owned()
}
);
for (id, key, value) in [
(2_u8, "jobs.max_concurrent", serde_json::json!("four")),
(3, "jobs.max_concurrent", serde_json::json!(0)),
(4, "backup.enabled", serde_json::json!(1)),
(
5,
"default_consistency",
serde_json::json!("EventuallyConsistent"),
),
] {
let error = apply(
&mut state,
®istry,
id,
MetaCommand::SetClusterSetting {
key: key.to_owned(),
value,
},
)
.unwrap_err();
assert!(matches!(
error,
MetaRejectionReason::InvalidSettingValue { .. }
));
}
}
#[test]
fn settings_apply_typed_values() {
let registry = FeatureRegistry::current();
let mut state = MetaState::default();
for (id, key, value) in [
(1_u8, "history_retention_epochs", serde_json::json!(12)),
(2, "backup.enabled", serde_json::json!(true)),
(3, "backup.interval_seconds", serde_json::json!(3_600)),
(4, "backup.retention_count", serde_json::json!(3)),
(
5,
"default_consistency",
serde_json::json!({"BoundedStaleness": {"max_lag_ms": 250}}),
),
(6, "ai.max_concurrent_requests", serde_json::json!(8)),
(7, "ai.max_memory_bytes", serde_json::json!(1 << 20)),
(8, "jobs.max_concurrent", serde_json::json!(4)),
(
9,
"resource_groups.etl",
serde_json::json!({"max_memory_bytes": 1024, "max_concurrent_queries": 2, "temp_disk_budget_bytes": 4096}),
),
] {
apply(
&mut state,
®istry,
id,
MetaCommand::SetClusterSetting {
key: key.to_owned(),
value,
},
)
.unwrap();
}
let settings = state.settings();
assert_eq!(settings.history_retention_epochs, 12);
assert!(settings.backup.enabled);
assert_eq!(settings.backup.interval_seconds, 3_600);
assert_eq!(settings.backup.retention_count, 3);
assert_eq!(
settings.default_consistency,
DefaultConsistency::BoundedStaleness { max_lag_ms: 250 }
);
assert_eq!(settings.ai.max_concurrent_requests, 8);
assert_eq!(settings.ai.max_memory_bytes, 1 << 20);
assert_eq!(settings.max_concurrent_jobs, 4);
assert_eq!(settings.resource_groups["etl"].max_memory_bytes, 1_024);
apply(
&mut state,
®istry,
10,
MetaCommand::SetClusterSetting {
key: "resource_groups.etl".to_owned(),
value: serde_json::Value::Null,
},
)
.unwrap();
assert!(state.settings().resource_groups.is_empty());
}
#[test]
fn metadata_version_ticks_once_per_applied_command() {
let registry = FeatureRegistry::current();
let mut state = MetaState::default();
assert_eq!(state.metadata_version, MetadataVersion::ZERO);
apply(
&mut state,
®istry,
1,
MetaCommand::RegisterNode {
descriptor: descriptor(1, &[]),
},
)
.unwrap();
assert_eq!(state.metadata_version, MetadataVersion(1));
apply(
&mut state,
®istry,
2,
MetaCommand::UpdateNodeState {
node_id: node_id(42),
state: NodeState::Down,
expected_version: None,
},
)
.unwrap_err();
assert_eq!(state.metadata_version, MetadataVersion(2));
apply(
&mut state,
®istry,
3,
MetaCommand::RemoveNode {
node_id: node_id(1),
},
)
.unwrap();
assert_eq!(state.metadata_version, MetadataVersion(3));
}
#[test]
fn stale_and_conflicting_writes_are_refused() {
let registry = FeatureRegistry::current();
let mut state = MetaState::default();
apply(
&mut state,
®istry,
1,
MetaCommand::RegisterNode {
descriptor: descriptor(1, &[]),
},
)
.unwrap();
apply(
&mut state,
®istry,
2,
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
)
.unwrap();
let node_version = state.node_record(node_id(1)).unwrap().metadata_version;
let error = apply(
&mut state,
®istry,
3,
MetaCommand::UpdateNodeState {
node_id: node_id(1),
state: NodeState::Down,
expected_version: Some(MetadataVersion(node_version.get() + 9)),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::StaleWrite { .. }));
apply(
&mut state,
®istry,
4,
MetaCommand::UpdateNodeState {
node_id: node_id(1),
state: NodeState::Down,
expected_version: Some(node_version),
},
)
.unwrap();
apply(
&mut state,
®istry,
5,
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 2),
},
)
.unwrap();
let error = apply(
&mut state,
®istry,
6,
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 1),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::StaleWrite { .. }));
let mut conflicting = schema_record(1, 1, 2);
conflicting.schema = serde_json::json!({"columns": []});
let error = apply(
&mut state,
®istry,
7,
MetaCommand::SetTableSchema {
record: conflicting,
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
apply(
&mut state,
®istry,
8,
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 2),
},
)
.unwrap();
apply(
&mut state,
®istry,
9,
MetaCommand::SetTabletDescriptor {
descriptor: tablet(1, 1, 5),
},
)
.unwrap();
let mut conflicted = tablet(1, 1, 5);
conflicted.leader_hint = Some(node_id(1));
let error = apply(
&mut state,
®istry,
10,
MetaCommand::SetTabletDescriptor {
descriptor: conflicted,
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
let error = apply(
&mut state,
®istry,
11,
MetaCommand::SetTabletDescriptor {
descriptor: tablet(1, 1, 4),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::StaleWrite { .. }));
let error = apply(
&mut state,
®istry,
12,
MetaCommand::RemoveTabletDescriptor {
tablet_id: TabletId::from_bytes([1; 16]),
generation: 4,
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::StaleWrite { .. }));
apply(
&mut state,
®istry,
13,
MetaCommand::SetTabletDescriptor {
descriptor: tablet(1, 1, 6),
},
)
.unwrap();
let error = apply(
&mut state,
®istry,
14,
MetaCommand::CreateDatabase {
descriptor: database(2, "app"),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
let error = apply(
&mut state,
®istry,
15,
MetaCommand::CreateDatabase {
descriptor: database(1, "other"),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
apply(
&mut state,
®istry,
16,
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
)
.unwrap();
}
#[test]
fn referential_integrity_is_enforced() {
let registry = FeatureRegistry::current();
let mut state = MetaState::default();
apply(
&mut state,
®istry,
1,
MetaCommand::RegisterNode {
descriptor: descriptor(1, &[]),
},
)
.unwrap();
apply(
&mut state,
®istry,
2,
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
)
.unwrap();
apply(
&mut state,
®istry,
3,
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 1),
},
)
.unwrap();
apply(
&mut state,
®istry,
4,
MetaCommand::SetReplicaPlacement {
placement: placement(9, &[(1, ReplicaRole::Voter)]),
},
)
.unwrap();
let error = apply(
&mut state,
®istry,
5,
MetaCommand::RemoveNode {
node_id: node_id(1),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
let error = apply(
&mut state,
®istry,
6,
MetaCommand::DropDatabase {
database_id: DatabaseId::from_bytes([1; 16]),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
let error = apply(
&mut state,
®istry,
7,
MetaCommand::SetReplicaPlacement {
placement: placement(8, &[(7, ReplicaRole::Voter)]),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::NotFound { .. }));
let error = apply(
&mut state,
®istry,
8,
MetaCommand::SetReplicaPlacement {
placement: placement(8, &[(1, ReplicaRole::Voter), (1, ReplicaRole::Learner)]),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
let error = apply(
&mut state,
®istry,
9,
MetaCommand::SetTabletDescriptor {
descriptor: tablet(2, 999, 1),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::NotFound { .. }));
let error = apply(
&mut state,
®istry,
10,
MetaCommand::SetTableSchema {
record: schema_record(2, 42, 1),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::NotFound { .. }));
apply(
&mut state,
®istry,
11,
MetaCommand::SetReplicaPlacement {
placement: placement(9, &[(1, ReplicaRole::Learner)]),
},
)
.unwrap();
let error = apply(
&mut state,
®istry,
12,
MetaCommand::RemoveNode {
node_id: node_id(1),
},
)
.unwrap_err();
assert!(
matches!(error, MetaRejectionReason::Conflict { .. }),
"learner replicas still reference the node"
);
}
#[test]
fn schema_job_graph_is_enforced() {
let registry = FeatureRegistry::current();
let mut state = MetaState::default();
apply(
&mut state,
®istry,
1,
MetaCommand::RegisterNode {
descriptor: descriptor(1, &[]),
},
)
.unwrap();
apply(
&mut state,
®istry,
2,
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
)
.unwrap();
apply(
&mut state,
®istry,
3,
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 1),
},
)
.unwrap();
let mut running = schema_job(7, 1, 1);
running.state = SchemaJobState::Running;
let error = apply(
&mut state,
®istry,
4,
MetaCommand::SubmitSchemaJob { job: running },
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Invalid { .. }));
apply(
&mut state,
®istry,
5,
MetaCommand::SubmitSchemaJob {
job: schema_job(7, 1, 1),
},
)
.unwrap();
let error = apply(
&mut state,
®istry,
6,
MetaCommand::UpdateSchemaJob {
job_id: 7,
state: SchemaJobState::Succeeded,
updated_at: ts(2_000),
error: None,
expected_version: None,
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
for (id, job_state) in [
(7_u8, SchemaJobState::Running),
(8, SchemaJobState::Succeeded),
] {
apply(
&mut state,
®istry,
id,
MetaCommand::UpdateSchemaJob {
job_id: 7,
state: job_state,
updated_at: ts(2_000),
error: None,
expected_version: None,
},
)
.unwrap();
}
let error = apply(
&mut state,
®istry,
9,
MetaCommand::UpdateSchemaJob {
job_id: 7,
state: SchemaJobState::Running,
updated_at: ts(3_000),
error: None,
expected_version: None,
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::Conflict { .. }));
let error = apply(
&mut state,
®istry,
10,
MetaCommand::UpdateSchemaJob {
job_id: 7,
state: SchemaJobState::Failed,
updated_at: ts(3_000),
error: None,
expected_version: Some(MetadataVersion(1)),
},
)
.unwrap_err();
assert!(matches!(error, MetaRejectionReason::StaleWrite { .. }));
}
fn applied(byte: u8, command: ReplicatedCommand) -> AppliedCommand {
AppliedCommand {
position: LogPosition {
term: 1,
index: u64::from(byte),
},
command,
}
}
fn meta_envelope(id: u8, command: MetaCommand) -> ReplicatedCommand {
let payload = MetaCommandRecord::new(command).encode().unwrap();
ReplicatedCommand::new(
CommandKind::Catalog,
CommandEnvelope::new(COMMAND_TYPE_META_COMMAND, cmd_id(id), payload),
ts(1_000),
)
}
#[test]
fn sink_rejects_non_meta_payloads_and_transactions() {
let tmp = tempfile::tempdir().unwrap();
let mut sink = MetaApplySink::open(tmp.path(), FeatureRegistry::current()).unwrap();
let foreign = ReplicatedCommand::new(
CommandKind::Catalog,
CommandEnvelope::new(999, cmd_id(1), b"payload".to_vec()),
ts(1_000),
);
assert!(ApplySink::apply(&mut sink, &applied(1, foreign)).is_err());
let transaction = ReplicatedCommand::new(
CommandKind::Transaction,
CommandEnvelope::new(1, cmd_id(2), b"rows".to_vec()),
ts(1_000),
);
assert!(ApplySink::apply(&mut sink, &applied(2, transaction)).is_err());
let maintenance = ReplicatedCommand::new(
CommandKind::Maintenance,
CommandEnvelope::new(3, cmd_id(3), b"directive".to_vec()),
ts(1_000),
);
ApplySink::apply(&mut sink, &applied(3, maintenance)).unwrap();
ApplySink::apply(&mut sink, &applied(4, ReplicatedCommand::Noop)).unwrap();
assert_eq!(sink.metadata_version(), MetadataVersion::ZERO);
}
#[test]
fn sink_snapshot_install_preserves_state() {
let tmp_a = tempfile::tempdir().unwrap();
let tmp_b = tempfile::tempdir().unwrap();
let registry = registry_with("ann-v2", 7);
let mut sink = MetaApplySink::open(tmp_a.path(), registry.clone()).unwrap();
for (id, command) in every_command_sequence().into_iter().enumerate() {
let id = u8::try_from(id + 1).unwrap();
ApplySink::apply(&mut sink, &applied(id, meta_envelope(id, command))).unwrap();
}
ApplySink::apply(
&mut sink,
&applied(
42,
meta_envelope(
42,
MetaCommand::SetClusterSetting {
key: "ai.api_key".to_owned(),
value: serde_json::json!("x"),
},
),
),
)
.unwrap();
let bytes = sink.snapshot().unwrap();
let mut restored = MetaApplySink::open(tmp_b.path(), registry).unwrap();
restored.install(&bytes).unwrap();
assert_eq!(restored.state(), sink.state());
assert_eq!(restored.metadata_version(), sink.metadata_version());
assert_eq!(restored.applied_position(), sink.applied_position());
assert_eq!(restored.state().rejections().len(), 1);
assert!(restored.install(b"junk").is_err());
let mut future = restored.snapshot().unwrap();
let mut checkpoint: MetaStateCheckpoint = serde_json::from_slice(&future).unwrap();
checkpoint.format_version = META_STATE_CHECKPOINT_FORMAT_VERSION + 1;
future = serde_json::to_vec(&checkpoint).unwrap();
assert!(restored.install(&future).is_err());
let mut checkpoint: MetaStateCheckpoint =
serde_json::from_slice(&restored.snapshot().unwrap()).unwrap();
checkpoint.state.format_version = META_STATE_FORMAT_VERSION + 1;
assert!(restored
.install(&serde_json::to_vec(&checkpoint).unwrap())
.is_err());
assert_eq!(restored.state(), sink.state());
}
#[test]
fn sink_restart_recovers_from_its_checkpoint() {
let tmp = tempfile::tempdir().unwrap();
let registry = registry_with("ann-v2", 7);
let mut sink = MetaApplySink::open(tmp.path(), registry.clone()).unwrap();
for (id, command) in every_command_sequence().into_iter().enumerate() {
let id = u8::try_from(id + 1).unwrap();
ApplySink::apply(&mut sink, &applied(id, meta_envelope(id, command))).unwrap();
}
let before = sink.state().clone();
let position = sink.applied_position();
drop(sink);
let mut reopened = MetaApplySink::open(tmp.path(), registry).unwrap();
assert_eq!(reopened.state(), &before);
assert_eq!(reopened.metadata_version(), MetadataVersion(16));
assert_eq!(reopened.applied_position(), position);
let replay = meta_envelope(
15,
MetaCommand::DropDatabase {
database_id: DatabaseId::from_bytes([0xEE; 16]),
},
);
ApplySink::apply(&mut reopened, &applied(15, replay)).unwrap();
assert_eq!(reopened.state(), &before);
let next = meta_envelope(
17,
MetaCommand::SetClusterSetting {
key: "jobs.max_concurrent".to_owned(),
value: serde_json::json!(8),
},
);
ApplySink::apply(&mut reopened, &applied(17, next)).unwrap();
assert_eq!(reopened.metadata_version(), MetadataVersion(17));
assert_eq!(reopened.state().settings().max_concurrent_jobs, 8);
std::fs::write(
tmp.path()
.join("raft")
.join("state")
.join(META_STATE_CHECKPOINT_FILENAME),
b"junk",
)
.unwrap();
assert!(matches!(
MetaApplySink::open(tmp.path(), FeatureRegistry::current()),
Err(MetaError::CorruptCheckpoint(_))
));
}
async fn single_node_group(
dir: &Path,
node: u8,
registry: FeatureRegistry,
transport: Arc<InMemoryTransport>,
) -> MetaGroup<InMemoryTransport> {
let config = meta_config(dir, node, registry);
let group_config = fast_group_config(&config);
let meta = MetaGroup::create(config, group_config, transport)
.await
.unwrap();
meta.bootstrap(&[(
node_id(node),
format!("127.0.0.1:{}", 7100 + u16::from(node)),
)])
.await
.unwrap();
meta.group().wait_leader(LEADER_TIMEOUT).await.unwrap();
meta
}
#[tokio::test]
async fn single_node_meta_group_round_trips_every_command() {
let tmp = tempfile::tempdir().unwrap();
let transport = Arc::new(InMemoryTransport::new());
let meta = single_node_group(
&tmp.path().join("node-1"),
1,
registry_with("ann-v2", 7),
transport,
)
.await;
assert!(tmp
.path()
.join("node-1/groups")
.join(META_GID.to_hex())
.join("raft")
.is_dir());
let control = ExecutionControl::default();
let mut last_version = MetadataVersion::ZERO;
for (index, command) in every_command_sequence().into_iter().enumerate() {
let id = u8::try_from(index + 1).unwrap();
let receipt = meta.propose(cmd_id(id), command, &control).await.unwrap();
assert!(receipt.metadata_version > last_version);
last_version = receipt.metadata_version;
}
assert_eq!(last_version, MetadataVersion(16));
assert_eq!(meta.metadata_version(), MetadataVersion(16));
let state = meta.state();
assert_eq!(state.feature_level(), ClusterFeatureLevel(7));
assert_eq!(state.settings().max_concurrent_jobs, 4);
assert_eq!(state.schema_job(7).unwrap().state, SchemaJobState::Running);
assert!(state.rejections().is_empty());
meta.shutdown().await.unwrap();
}
#[tokio::test]
async fn idempotent_replay_through_the_group() {
let tmp = tempfile::tempdir().unwrap();
let transport = Arc::new(InMemoryTransport::new());
let meta = single_node_group(
&tmp.path().join("node-1"),
1,
FeatureRegistry::current(),
transport,
)
.await;
let control = ExecutionControl::default();
let command = MetaCommand::RegisterNode {
descriptor: descriptor(1, &[]),
};
let first = meta
.propose(cmd_id(1), command.clone(), &control)
.await
.unwrap();
assert_eq!(first.metadata_version, MetadataVersion(1));
assert!(!first.receipt.response.duplicate);
let retry = meta.propose(cmd_id(1), command, &control).await.unwrap();
assert!(retry.receipt.response.duplicate);
assert_eq!(retry.metadata_version, MetadataVersion(1));
assert_eq!(meta.state().nodes.len(), 1);
meta.shutdown().await.unwrap();
}
#[tokio::test]
async fn refused_commands_surface_typed_errors_through_the_group() {
let tmp = tempfile::tempdir().unwrap();
let transport = Arc::new(InMemoryTransport::new());
let meta = single_node_group(
&tmp.path().join("node-1"),
1,
registry_with("ann-v2", 7),
transport,
)
.await;
let control = ExecutionControl::default();
meta.propose(
cmd_id(1),
MetaCommand::RegisterNode {
descriptor: descriptor(1, &[]),
},
&control,
)
.await
.unwrap();
let error = meta
.propose(
cmd_id(2),
MetaCommand::ActivateFeature {
activation: activation("ann-v2", 7),
},
&control,
)
.await
.unwrap_err();
let MetaError::Rejected(reason) = error else {
panic!("expected a typed rejection, got {error}");
};
assert_eq!(
reason,
MetaRejectionReason::FeatureActivation(FeatureActivationError::UnsupportedByVoter {
feature: "ann-v2".to_owned(),
node: node_id(1),
})
);
let error = meta
.propose(
cmd_id(3),
MetaCommand::SetClusterSetting {
key: "backup.private_key_pem".to_owned(),
value: serde_json::json!("pem"),
},
&control,
)
.await
.unwrap_err();
assert!(matches!(
error,
MetaError::Rejected(MetaRejectionReason::SecretSettingKey { .. })
));
assert_eq!(meta.metadata_version(), MetadataVersion(3));
assert_eq!(meta.state().rejections().len(), 2);
meta.shutdown().await.unwrap();
}
#[tokio::test]
async fn add_and_remove_member_workflow() {
let tmp = tempfile::tempdir().unwrap();
let transport = Arc::new(InMemoryTransport::new());
let meta1 = single_node_group(
&tmp.path().join("node-1"),
1,
FeatureRegistry::current(),
transport.clone(),
)
.await;
let config2 = meta_config(&tmp.path().join("node-2"), 2, FeatureRegistry::current());
let group_config2 = fast_group_config(&config2);
let meta2 = MetaGroup::create(config2, group_config2, transport.clone())
.await
.unwrap();
let control = ExecutionControl::default();
meta1
.propose(
cmd_id(1),
MetaCommand::RegisterNode {
descriptor: descriptor(1, &[]),
},
&control,
)
.await
.unwrap();
let descriptor2 = descriptor(2, &[]);
let receipt = meta1.add_member(&descriptor2, &control).await.unwrap();
let (voters, _) = meta1.group().members();
assert!(voters.contains(&raft_id(2)));
meta2
.group()
.wait_applied_index(receipt.receipt.position.index, LEADER_TIMEOUT)
.await
.unwrap();
assert_eq!(
meta2.state().node(node_id(2)).unwrap().rpc_address,
descriptor2.rpc_address
);
meta1
.propose(
cmd_id(10),
MetaCommand::SetReplicaPlacement {
placement: placement(9, &[(2, ReplicaRole::Voter)]),
},
&control,
)
.await
.unwrap();
let error = meta1.remove_member(node_id(2), &control).await.unwrap_err();
assert!(matches!(
error,
MetaError::Rejected(MetaRejectionReason::Conflict { .. })
));
let (voters, _) = meta1.group().members();
assert!(
voters.contains(&raft_id(2)),
"a refused removal leaves raft membership untouched"
);
meta1
.propose(
cmd_id(11),
MetaCommand::SetReplicaPlacement {
placement: placement(9, &[(1, ReplicaRole::Voter)]),
},
&control,
)
.await
.unwrap();
meta1.remove_member(node_id(2), &control).await.unwrap();
let (voters, _) = meta1.group().members();
assert!(!voters.contains(&raft_id(2)));
assert!(meta1.state().node(node_id(2)).is_none());
let error = meta1.remove_member(node_id(1), &control).await.unwrap_err();
assert!(matches!(error, MetaError::InvalidRequest(_)));
meta2.shutdown().await.unwrap();
meta1.shutdown().await.unwrap();
}
#[tokio::test]
async fn bootstrap_rejects_raft_id_projection_collisions() {
let tmp = tempfile::tempdir().unwrap();
let transport = Arc::new(InMemoryTransport::new());
let config = meta_config(&tmp.path().join("node-1"), 1, FeatureRegistry::current());
let group_config = fast_group_config(&config);
let meta = MetaGroup::create(config, group_config, transport)
.await
.unwrap();
let colliding = NodeId::from_bytes([1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 9, 9, 9, 9, 9]);
let first = NodeId::from_bytes([1, 2, 3, 4, 5, 6, 7, 8, 8, 8, 8, 8, 8, 8, 8, 8]);
let error = meta
.bootstrap(&[
(first, "127.0.0.1:7101".to_owned()),
(colliding, "127.0.0.1:7102".to_owned()),
])
.await
.unwrap_err();
assert!(matches!(error, MetaError::InvalidRequest(_)));
meta.shutdown().await.unwrap();
}
#[tokio::test]
async fn create_rejects_a_mismatched_group_config() {
let tmp = tempfile::tempdir().unwrap();
let transport = Arc::new(InMemoryTransport::new());
let config = meta_config(&tmp.path().join("node-1"), 1, FeatureRegistry::current());
let mut group_config = fast_group_config(&config);
group_config.dir = tmp.path().join("elsewhere");
let result = MetaGroup::<InMemoryTransport>::create(config, group_config, transport).await;
match result {
Err(error) => assert!(matches!(error, MetaError::InvalidRequest(_))),
Ok(meta) => {
meta.shutdown().await.unwrap();
panic!("expected create to reject a mismatched group config");
}
}
}
#[tokio::test]
async fn three_node_meta_group_converges_after_leader_failover() {
let tmp = tempfile::tempdir().unwrap();
let transport = Arc::new(InMemoryTransport::new());
let registry = || registry_with("ann-v2", 7);
let mut groups: BTreeMap<u8, MetaGroup<InMemoryTransport>> = BTreeMap::new();
for byte in [1_u8, 2, 3] {
let config = meta_config(&tmp.path().join(format!("node-{byte}")), byte, registry());
let group_config = fast_group_config(&config);
groups.insert(
byte,
MetaGroup::create(config, group_config, transport.clone())
.await
.unwrap(),
);
}
let members: Vec<(NodeId, String)> = [1_u8, 2, 3]
.iter()
.map(|byte| (node_id(*byte), format!("127.0.0.1:710{byte}")))
.collect();
groups[&1].bootstrap(&members).await.unwrap();
let leader_byte = {
let leader = wait_consensus_leader(&[&groups[&1], &groups[&2], &groups[&3]]).await;
[1_u8, 2, 3]
.into_iter()
.find(|byte| raft_id(*byte) == leader)
.unwrap()
};
let control = ExecutionControl::default();
let mut proposals: Vec<MetaCommand> = [1_u8, 2, 3]
.iter()
.map(|byte| MetaCommand::RegisterNode {
descriptor: descriptor(*byte, &["ann-v2"]),
})
.collect();
proposals.extend([
MetaCommand::CreateDatabase {
descriptor: database(1, "app"),
},
MetaCommand::SetTableSchema {
record: schema_record(1, 1, 1),
},
MetaCommand::SetReplicaPlacement {
placement: placement(
9,
&[
(1, ReplicaRole::Voter),
(2, ReplicaRole::Voter),
(3, ReplicaRole::Voter),
],
),
},
MetaCommand::SetPlacementPolicy {
name: "default".to_owned(),
policy: policy(3),
},
MetaCommand::ActivateFeature {
activation: activation("ann-v2", 7),
},
MetaCommand::SetClusterSetting {
key: "jobs.max_concurrent".to_owned(),
value: serde_json::json!(4),
},
MetaCommand::SetTxnStatusPartition {
partition: TxnStatusPartition {
partition_id: 0,
home_raft_group: group_id(9),
},
},
]);
let mut last_index = 0_u64;
for (seq, command) in proposals.into_iter().enumerate() {
let id = u8::try_from(seq + 1).unwrap();
let receipt = groups[&leader_byte]
.propose(cmd_id(id), command, &control)
.await
.unwrap();
last_index = receipt.receipt.position.index;
}
for byte in [1_u8, 2, 3] {
groups[&byte]
.group()
.wait_applied_index(last_index, LEADER_TIMEOUT)
.await
.unwrap();
}
assert_eq!(groups[&1].state(), groups[&2].state());
assert_eq!(groups[&2].state(), groups[&3].state());
groups[&leader_byte].shutdown().await.unwrap();
let survivors: Vec<u8> = [1_u8, 2, 3]
.into_iter()
.filter(|byte| *byte != leader_byte)
.collect();
let new_leader_byte = {
let leader =
wait_consensus_leader(&[&groups[&survivors[0]], &groups[&survivors[1]]]).await;
survivors
.iter()
.copied()
.find(|byte| raft_id(*byte) == leader)
.unwrap()
};
let mut new_index = last_index;
for (seq, command) in [
MetaCommand::SubmitSchemaJob {
job: schema_job(7, 1, 1),
},
MetaCommand::UpdateSchemaJob {
job_id: 7,
state: SchemaJobState::Running,
updated_at: ts(9_000),
error: None,
expected_version: None,
},
]
.into_iter()
.enumerate()
{
let id = u8::try_from(seq + 100).unwrap();
let receipt = groups[&new_leader_byte]
.propose(cmd_id(id), command, &control)
.await
.unwrap();
new_index = receipt.receipt.position.index;
}
for byte in &survivors {
groups[byte]
.group()
.wait_applied_index(new_index, LEADER_TIMEOUT)
.await
.unwrap();
}
assert_eq!(groups[&survivors[0]].state(), groups[&survivors[1]].state());
let config = meta_config(
&tmp.path().join(format!("node-{leader_byte}")),
leader_byte,
registry(),
);
let group_config = fast_group_config(&config);
let rejoined = MetaGroup::create(config, group_config, transport.clone())
.await
.unwrap();
rejoined
.group()
.wait_applied_index(new_index, LEADER_TIMEOUT)
.await
.unwrap();
assert_eq!(rejoined.state(), groups[&survivors[0]].state());
assert_eq!(
rejoined.metadata_version(),
groups[&survivors[0]].metadata_version()
);
for group in groups.values() {
group.shutdown().await.unwrap();
}
rejoined.shutdown().await.unwrap();
}
#[tokio::test]
async fn snapshot_install_preserves_group_state() {
let tmp = tempfile::tempdir().unwrap();
let transport = Arc::new(InMemoryTransport::new());
let meta = single_node_group(
&tmp.path().join("node-1"),
1,
registry_with("ann-v2", 7),
transport.clone(),
)
.await;
let control = ExecutionControl::default();
for (index, command) in every_command_sequence().into_iter().enumerate() {
let id = u8::try_from(index + 1).unwrap();
meta.propose(cmd_id(id), command, &control).await.unwrap();
}
let snapshot = meta.group().snapshot().await.unwrap();
let config = meta_config(&tmp.path().join("node-2"), 2, registry_with("ann-v2", 7));
let group_config = fast_group_config(&config);
let fresh = MetaGroup::create(config, group_config, transport)
.await
.unwrap();
fresh.group().install_snapshot(&snapshot).unwrap();
assert_eq!(fresh.state(), meta.state());
assert_eq!(fresh.metadata_version(), meta.metadata_version());
assert_eq!(fresh.state().feature_level(), ClusterFeatureLevel(7));
meta.shutdown().await.unwrap();
fresh.shutdown().await.unwrap();
}
}
#[cfg(test)]
mod reconciliation_tests {
use super::*;
use crate::node::{BuildVersion, Locality, NodeCapacity};
use mongreldb_log::commit_log::LogPosition;
fn node_id(byte: u8) -> NodeId {
NodeId::from_bytes([byte; 16])
}
fn group_id(byte: u8) -> RaftGroupId {
RaftGroupId::from_bytes([byte; 16])
}
fn tablet_id(byte: u8) -> TabletId {
TabletId::from_bytes([byte; 16])
}
fn ts(micros: u64) -> HlcTimestamp {
HlcTimestamp {
physical_micros: micros,
logical: 0,
node_tiebreaker: 0,
}
}
fn descriptor(byte: u8) -> NodeDescriptor {
NodeDescriptor {
node_id: node_id(byte),
rpc_address: format!("127.0.0.1:{}", 7200 + u16::from(byte)),
locality: Locality::default(),
capacity: NodeCapacity::default(),
state: NodeState::Up,
version: BuildVersion::current(),
version_info: VersionInfo::current(),
}
}
fn v1_tablet(byte: u8, state: v1::TabletState) -> v1::TabletDescriptor {
v1::TabletDescriptor {
tablet_id: tablet_id(byte),
table_id: TableId(3),
database_id: mongreldb_types::ids::DatabaseId::ZERO,
raft_group_id: group_id(9),
partition: v1::PartitionBounds {
start: Some(b"a".to_vec()),
end: Some(b"m".to_vec()),
},
replicas: vec![
v1::ReplicaDescriptor {
node_id: node_id(1),
role: v1::ReplicaRole::Voter,
},
v1::ReplicaDescriptor {
node_id: node_id(2),
role: v1::ReplicaRole::Learner,
},
],
leader_hint: Some(node_id(1)),
generation: 4,
state,
metadata_version: MetadataVersion(11),
}
}
fn v1_policy() -> v1::PlacementPolicy {
v1::PlacementPolicy {
replicas: 3,
voter_constraints: vec![v1::LocalityConstraint {
key: "region".to_owned(),
value: "us-central".to_owned(),
}],
leader_preferences: vec![v1::LocalityConstraint {
key: "zone".to_owned(),
value: "a".to_owned(),
}],
prohibited_nodes: vec![node_id(9)],
metadata_version: MetadataVersion(12),
}
}
fn v1_encode(command: v1::MetaCommand) -> Vec<u8> {
serde_json::to_vec(&v1::MetaCommandRecord {
format_version: 1,
command,
})
.unwrap()
}
#[test]
fn v1_tablet_command_decodes_and_migrates_to_the_canonical_shapes() {
let decoded = MetaCommandRecord::decode(&v1_encode(v1::MetaCommand::SetTabletDescriptor {
descriptor: v1_tablet(1, v1::TabletState::Online),
}))
.unwrap();
assert_eq!(decoded.format_version, META_COMMAND_FORMAT_VERSION);
let MetaCommand::SetTabletDescriptor { descriptor } = decoded.command else {
panic!("unexpected command variant: {:?}", decoded.command);
};
assert_eq!(
descriptor.partition,
PartitionBounds {
low: Bound::Included(Key::from_bytes(b"a".to_vec())),
high: Bound::Excluded(Key::from_bytes(b"m".to_vec())),
}
);
assert_eq!(descriptor.state, TabletState::Active);
assert_eq!(descriptor.leader_hint, Some(node_id(1)));
assert_eq!(descriptor.generation, 4);
assert_eq!(descriptor.replicas.len(), 2);
assert_eq!(descriptor.replicas[0].role, ReplicaRole::Voter);
assert_eq!(descriptor.replicas[1].role, ReplicaRole::Learner);
assert_eq!(
descriptor.replicas[0].raft_node_id,
raft_node_id(&node_id(1))
);
assert_eq!(
descriptor.replicas[1].raft_node_id,
raft_node_id(&node_id(2))
);
}
#[test]
fn v1_placement_and_policy_commands_decode_and_migrate() {
let decoded = MetaCommandRecord::decode(&v1_encode(v1::MetaCommand::SetReplicaPlacement {
placement: v1::ReplicaPlacement {
raft_group_id: group_id(9),
replicas: vec![v1::ReplicaDescriptor {
node_id: node_id(3),
role: v1::ReplicaRole::Voter,
}],
metadata_version: MetadataVersion(5),
},
}))
.unwrap();
let MetaCommand::SetReplicaPlacement { placement } = decoded.command else {
panic!("unexpected command variant: {:?}", decoded.command);
};
assert_eq!(
placement.replicas[0].raft_node_id,
raft_node_id(&node_id(3))
);
assert_eq!(placement.metadata_version, MetadataVersion(5));
let decoded = MetaCommandRecord::decode(&v1_encode(v1::MetaCommand::SetPlacementPolicy {
name: "default".to_owned(),
policy: v1_policy(),
}))
.unwrap();
let MetaCommand::SetPlacementPolicy { name, policy } = decoded.command else {
panic!("unexpected command variant: {:?}", decoded.command);
};
assert_eq!(name, "default");
assert_eq!(policy.replicas, 3);
assert_eq!(
policy.voter_constraints,
vec![LocalityConstraint {
key: "region".to_owned(),
value: "us-central".to_owned(),
required: true,
}]
);
assert_eq!(
policy.leader_preferences,
vec![LocalityConstraint {
key: "zone".to_owned(),
value: "a".to_owned(),
required: false,
}]
);
assert_eq!(policy.prohibited_nodes, vec![node_id(9)]);
}
#[test]
fn v1_unaffected_command_variants_pass_through() {
let decoded = MetaCommandRecord::decode(&v1_encode(v1::MetaCommand::RegisterNode {
descriptor: descriptor(1),
}))
.unwrap();
assert_eq!(
decoded.command,
MetaCommand::RegisterNode {
descriptor: descriptor(1)
}
);
let decoded =
MetaCommandRecord::decode(&v1_encode(v1::MetaCommand::RemoveTabletDescriptor {
tablet_id: tablet_id(1),
generation: 9,
}))
.unwrap();
assert_eq!(
decoded.command,
MetaCommand::RemoveTabletDescriptor {
tablet_id: tablet_id(1),
generation: 9,
}
);
}
#[test]
fn v1_checkpoint_loads_and_migrates_meta_state() {
let tmp = tempfile::tempdir().unwrap();
let state_dir = tmp.path().join("raft").join("state");
std::fs::create_dir_all(&state_dir).unwrap();
let mut state = v1::MetaState {
metadata_version: MetadataVersion(20),
..v1::MetaState::default()
};
state
.tablets
.insert(tablet_id(1), v1_tablet(1, v1::TabletState::Offline));
state.placements.insert(
group_id(9),
v1::ReplicaPlacement {
raft_group_id: group_id(9),
replicas: vec![v1::ReplicaDescriptor {
node_id: node_id(1),
role: v1::ReplicaRole::Voter,
}],
metadata_version: MetadataVersion(6),
},
);
state
.placement_policies
.insert("default".to_owned(), v1_policy());
let checkpoint = v1::MetaStateCheckpoint {
format_version: 1,
position: LogPosition { term: 2, index: 20 },
command_id: Some([7; 16]),
state,
};
std::fs::write(
state_dir.join(META_STATE_CHECKPOINT_FILENAME),
serde_json::to_vec(&checkpoint).unwrap(),
)
.unwrap();
let sink = MetaApplySink::open(tmp.path(), FeatureRegistry::current()).unwrap();
assert_eq!(sink.applied_position(), LogPosition { term: 2, index: 20 });
assert_eq!(sink.metadata_version(), MetadataVersion(20));
let state = sink.state().clone();
assert_eq!(state.format_version, META_STATE_FORMAT_VERSION);
let record = state.tablet_record(tablet_id(1)).unwrap();
assert_eq!(record.descriptor.state, TabletState::Retiring);
assert_eq!(record.metadata_version, MetadataVersion(11));
let placement = state.placement(group_id(9)).unwrap();
assert_eq!(
placement.replicas[0].raft_node_id,
raft_node_id(&node_id(1))
);
assert_eq!(placement.metadata_version, MetadataVersion(6));
let policy = state.placement_policy_record("default").unwrap();
assert!(policy.policy.voter_constraints[0].required);
assert!(!policy.policy.leader_preferences[0].required);
assert_eq!(policy.metadata_version, MetadataVersion(12));
drop(sink);
let mut reopened = MetaApplySink::open(tmp.path(), FeatureRegistry::current()).unwrap();
assert_eq!(reopened.state(), &state);
ApplySink::apply(
&mut reopened,
&AppliedCommand {
position: LogPosition { term: 2, index: 21 },
command: ReplicatedCommand::Noop,
},
)
.unwrap();
let on_disk = std::fs::read(state_dir.join(META_STATE_CHECKPOINT_FILENAME)).unwrap();
let checkpoint: MetaStateCheckpoint = serde_json::from_slice(&on_disk).unwrap();
assert_eq!(
checkpoint.format_version,
META_STATE_CHECKPOINT_FORMAT_VERSION
);
assert_eq!(checkpoint.state, *reopened.state());
}
#[test]
fn literal_v1_checkpoint_json_loads_through_serde_defaults() {
let tablet_hex = tablet_id(2).to_hex();
let group_hex = group_id(9).to_hex();
let node_hex = node_id(1).to_hex();
let json = serde_json::json!({
"format_version": 1,
"position": {"term": 1, "index": 9},
"command_id": null,
"state": {
"format_version": 1,
"metadata_version": 9,
"tablets": {
tablet_hex.as_str(): {
"tablet_id": tablet_hex.as_str(),
"table_id": 3,
"raft_group_id": group_hex.as_str(),
"partition": {"start": null, "end": [109]},
"replicas": [{"node_id": node_hex.as_str(), "role": "Voter"}],
"leader_hint": null,
"generation": 4,
"state": "Online",
"metadata_version": 7
}
},
"placement_policies": {
"default": {
"replicas": 3,
"voter_constraints": [{"key": "region", "value": "us-central"}],
"leader_preferences": [],
"prohibited_nodes": [],
"metadata_version": 8
}
}
}
});
let tmp = tempfile::tempdir().unwrap();
let state_dir = tmp.path().join("raft").join("state");
std::fs::create_dir_all(&state_dir).unwrap();
std::fs::write(
state_dir.join(META_STATE_CHECKPOINT_FILENAME),
serde_json::to_vec(&json).unwrap(),
)
.unwrap();
let sink = MetaApplySink::open(tmp.path(), FeatureRegistry::current()).unwrap();
let record = sink.state().tablet_record(tablet_id(2)).unwrap();
assert_eq!(
record.descriptor.partition,
PartitionBounds {
low: Bound::Unbounded,
high: Bound::Excluded(Key::from_bytes(b"m".to_vec())),
}
);
assert_eq!(record.descriptor.state, TabletState::Active);
assert_eq!(record.metadata_version, MetadataVersion(7));
let policy = sink.state().placement_policy_record("default").unwrap();
assert_eq!(policy.metadata_version, MetadataVersion(8));
assert_eq!(sink.metadata_version(), MetadataVersion(9));
}
#[test]
fn unsupported_future_versions_fail_closed() {
let future = serde_json::json!({
"format_version": META_COMMAND_FORMAT_VERSION + 1,
"command": {"RemoveNode": {"node_id": node_id(1)}},
});
assert_eq!(
MetaCommandRecord::decode(&serde_json::to_vec(&future).unwrap()).unwrap_err(),
MetaDecodeError::UnsupportedVersion {
found: META_COMMAND_FORMAT_VERSION + 1,
min: MIN_SUPPORTED_META_COMMAND_FORMAT_VERSION,
max: META_COMMAND_FORMAT_VERSION,
}
);
let tmp = tempfile::tempdir().unwrap();
let state_dir = tmp.path().join("raft").join("state");
std::fs::create_dir_all(&state_dir).unwrap();
let future = serde_json::json!({
"format_version": META_STATE_CHECKPOINT_FORMAT_VERSION + 1,
"position": {"term": 1, "index": 1},
"command_id": null,
"state": {"format_version": 1},
});
std::fs::write(
state_dir.join(META_STATE_CHECKPOINT_FILENAME),
serde_json::to_vec(&future).unwrap(),
)
.unwrap();
assert!(matches!(
MetaApplySink::open(tmp.path(), FeatureRegistry::current()),
Err(MetaError::CorruptCheckpoint(_))
));
}
#[test]
fn v1_command_replays_through_the_sink_apply_path() {
let tmp = tempfile::tempdir().unwrap();
let mut sink = MetaApplySink::open(tmp.path(), FeatureRegistry::current()).unwrap();
let register = MetaCommandRecord::new(MetaCommand::SetTableSchema {
record: TableSchemaRecord {
table_id: TableId(3),
database_id: DatabaseId::from_bytes([4; 16]),
schema_version: SchemaVersion(1),
schema: serde_json::json!({"columns": []}),
metadata_version: MetadataVersion::ZERO,
},
});
let database = MetaCommandRecord::new(MetaCommand::CreateDatabase {
descriptor: DatabaseDescriptor {
database_id: DatabaseId::from_bytes([4; 16]),
name: "app".to_owned(),
created_at: ts(1_000),
state: DatabaseState::Online,
metadata_version: MetadataVersion::ZERO,
},
});
let envelopes = [
(1_u64, database.encode().unwrap()),
(2, register.encode().unwrap()),
(
3,
v1_encode(v1::MetaCommand::SetTabletDescriptor {
descriptor: v1_tablet(1, v1::TabletState::Online),
}),
),
];
for (index, payload) in envelopes {
let command = ReplicatedCommand::new(
CommandKind::Catalog,
CommandEnvelope::new(COMMAND_TYPE_META_COMMAND, [index as u8; 16], payload),
ts(1_000),
);
ApplySink::apply(
&mut sink,
&AppliedCommand {
position: LogPosition { term: 1, index },
command,
},
)
.unwrap();
}
let record = sink.state().tablet_record(tablet_id(1)).unwrap();
assert_eq!(record.descriptor.state, TabletState::Active);
assert_eq!(
record.descriptor.replicas[0].raft_node_id,
raft_node_id(&node_id(1))
);
assert_eq!(record.metadata_version, MetadataVersion(3));
let mut descriptor = record.descriptor.clone();
descriptor.generation = 5;
let record_v2 = MetaCommandRecord::new(MetaCommand::SetTabletDescriptor {
descriptor: descriptor.clone(),
});
let command = ReplicatedCommand::new(
CommandKind::Catalog,
CommandEnvelope::new(
COMMAND_TYPE_META_COMMAND,
[9; 16],
record_v2.encode().unwrap(),
),
ts(1_000),
);
ApplySink::apply(
&mut sink,
&AppliedCommand {
position: LogPosition { term: 1, index: 4 },
command,
},
)
.unwrap();
assert_eq!(sink.state().tablet(tablet_id(1)).unwrap(), &descriptor);
assert!(sink.state().rejections().is_empty());
}
#[test]
fn reconciled_records_round_trip_serde() {
let record = TabletRecord {
descriptor: migrate_tablet(v1_tablet(1, v1::TabletState::Online)).descriptor,
metadata_version: MetadataVersion(3),
};
let json = serde_json::to_vec(&record).unwrap();
assert_eq!(
serde_json::from_slice::<TabletRecord>(&json).unwrap(),
record
);
let policy = PlacementPolicyRecord {
policy: migrate_policy(v1_policy()).policy,
metadata_version: MetadataVersion(4),
};
let json = serde_json::to_vec(&policy).unwrap();
assert_eq!(
serde_json::from_slice::<PlacementPolicyRecord>(&json).unwrap(),
policy
);
let placement = ReplicaPlacement {
raft_group_id: group_id(9),
replicas: vec![ReplicaDescriptor {
node_id: node_id(1),
role: ReplicaRole::Voter,
raft_node_id: raft_node_id(&node_id(1)),
}],
metadata_version: MetadataVersion(5),
};
let json = serde_json::to_vec(&placement).unwrap();
assert_eq!(
serde_json::from_slice::<ReplicaPlacement>(&json).unwrap(),
placement
);
}
}