use std::collections::BTreeMap;
use mongreldb_types::ids::{
MetadataVersion, NodeId, QueryId, RaftGroupId, TableId, TabletId, TransactionId,
};
use serde::{Deserialize, Serialize};
use crate::routing::{Endpoint, GroupKey, LeaderHint, RoutingCache, RoutingEntry};
use crate::tablet::{TabletDescriptor, TabletState};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum GatewayError {
#[error("stale metadata: request pinned {pinned}, layout is {current}; refresh routing cache")]
StaleMetadata {
pinned: MetadataVersion,
current: MetadataVersion,
},
#[error("unknown tablet {0} in plan fragment")]
UnknownTablet(TabletId),
#[error("tablet {tablet_id} is not routable (state {state})")]
NotRoutable {
tablet_id: TabletId,
state: String,
},
#[error("invalid admin SQL: {0}")]
InvalidAdminSql(String),
#[error("admin command refused: {0}")]
AdminRefused(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GatewayFragment {
pub fragment_id: u32,
pub tablet_ids: Vec<TabletId>,
pub table_id: Option<TableId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GatewayPlan {
pub query_id: QueryId,
pub metadata_version: MetadataVersion,
pub fragments: Vec<GatewayFragment>,
}
#[derive(Debug, Clone)]
pub struct TabletLayoutSnapshot {
pub metadata_version: MetadataVersion,
pub tablets: BTreeMap<TabletId, TabletDescriptor>,
}
impl TabletLayoutSnapshot {
pub fn from_descriptors(
metadata_version: MetadataVersion,
descriptors: impl IntoIterator<Item = TabletDescriptor>,
) -> Self {
let mut tablets = BTreeMap::new();
for d in descriptors {
tablets.insert(d.tablet_id, d);
}
Self {
metadata_version,
tablets,
}
}
pub fn routable_for_table(&self, table_id: TableId) -> Vec<&TabletDescriptor> {
self.tablets
.values()
.filter(|t| t.table_id == table_id && is_routable(t.state))
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BoundFragment {
pub fragment_id: u32,
pub targets: Vec<BoundTabletTarget>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BoundTabletTarget {
pub tablet_id: TabletId,
pub raft_group_id: RaftGroupId,
pub generation: u64,
pub preferred_endpoint: Option<Endpoint>,
pub endpoints: Vec<Endpoint>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BoundPlan {
pub query_id: QueryId,
pub metadata_version: MetadataVersion,
pub fragments: Vec<BoundFragment>,
}
pub fn bind_plan_to_tablets(
plan: &GatewayPlan,
layout: &TabletLayoutSnapshot,
routing: &RoutingCache,
resolve_endpoint: &dyn Fn(NodeId) -> Option<Endpoint>,
) -> Result<BoundPlan, GatewayError> {
if plan.metadata_version < layout.metadata_version {
return Err(GatewayError::StaleMetadata {
pinned: plan.metadata_version,
current: layout.metadata_version,
});
}
let mut bound_fragments = Vec::with_capacity(plan.fragments.len());
for frag in &plan.fragments {
let tablet_ids: Vec<TabletId> = if frag.tablet_ids.is_empty() {
let table_id = frag.table_id.ok_or_else(|| {
GatewayError::AdminRefused(
"fragment with empty tablet_ids requires table_id".into(),
)
})?;
layout
.routable_for_table(table_id)
.into_iter()
.map(|t| t.tablet_id)
.collect()
} else {
frag.tablet_ids.clone()
};
let mut targets = Vec::with_capacity(tablet_ids.len());
for tablet_id in tablet_ids {
let desc = layout
.tablets
.get(&tablet_id)
.ok_or(GatewayError::UnknownTablet(tablet_id))?;
if !is_routable(desc.state) {
return Err(GatewayError::NotRoutable {
tablet_id,
state: desc.state.to_string(),
});
}
let endpoints: Vec<Endpoint> = desc
.replicas
.iter()
.filter_map(|r| resolve_endpoint(r.node_id))
.collect();
let preferred = preferred_endpoint(desc, routing, &endpoints);
let _ = routing.refresh(
GroupKey::Tablet(tablet_id),
layout.metadata_version,
endpoints.clone(),
desc.leader_hint.map(|leader| LeaderHint {
term: 0, leader,
}),
);
targets.push(BoundTabletTarget {
tablet_id,
raft_group_id: desc.raft_group_id,
generation: desc.generation,
preferred_endpoint: preferred,
endpoints,
});
}
targets.sort_by_key(|t| t.tablet_id);
bound_fragments.push(BoundFragment {
fragment_id: frag.fragment_id,
targets,
});
}
Ok(BoundPlan {
query_id: plan.query_id,
metadata_version: layout.metadata_version,
fragments: bound_fragments,
})
}
fn is_routable(state: TabletState) -> bool {
matches!(
state,
TabletState::Active | TabletState::Splitting | TabletState::Merging
)
}
fn preferred_endpoint(
desc: &TabletDescriptor,
routing: &RoutingCache,
endpoints: &[Endpoint],
) -> Option<Endpoint> {
if let Some(entry) = routing.get(GroupKey::Tablet(desc.tablet_id)) {
if let Some(hint) = entry.leader_hint {
if let Some(ep) = endpoints.iter().find(|e| e.node_id == hint.leader) {
return Some(ep.clone());
}
}
}
if let Some(leader) = desc.leader_hint {
if let Some(ep) = endpoints.iter().find(|e| e.node_id == leader) {
return Some(ep.clone());
}
}
endpoints.first().cloned()
}
pub fn refresh_routing_from_layout(
routing: &RoutingCache,
layout: &TabletLayoutSnapshot,
resolve_endpoint: &dyn Fn(NodeId) -> Option<Endpoint>,
) -> usize {
let mut refreshed = 0;
for desc in layout.tablets.values() {
if !is_routable(desc.state) {
continue;
}
let endpoints: Vec<Endpoint> = desc
.replicas
.iter()
.filter_map(|r| resolve_endpoint(r.node_id))
.collect();
if routing.refresh(
GroupKey::Tablet(desc.tablet_id),
layout.metadata_version,
endpoints,
desc.leader_hint
.map(|leader| LeaderHint { term: 0, leader }),
) {
refreshed += 1;
}
}
refreshed
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdminCommand {
ShowCluster,
ShowNodes,
ShowTablets {
table: Option<String>,
},
ShowReplicas {
tablet_id: Option<TabletId>,
},
ShowTransactions,
ShowQueries,
ShowJobs,
ShowResourceGroups,
ShowBackups,
AlterNodeDrain {
node_id: NodeId,
},
TransferLeader {
tablet_id: TabletId,
to: NodeId,
},
MoveReplica {
tablet_id: TabletId,
from: NodeId,
to: NodeId,
},
SplitTablet {
tablet_id: TabletId,
at_key_hex: Option<String>,
},
MergeTablets {
left: TabletId,
right: TabletId,
},
JobControl {
action: JobAction,
job_id: String,
},
BackupDatabase {
destination: Option<String>,
},
RestoreDatabase {
source: String,
disaster_recovery: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum JobAction {
Pause,
Resume,
Cancel,
}
pub fn parse_admin_sql(sql: &str) -> Result<Option<AdminCommand>, GatewayError> {
let trimmed = sql.trim().trim_end_matches(';').trim();
if trimmed.is_empty() {
return Ok(None);
}
let upper = trimmed.to_ascii_uppercase();
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if tokens.is_empty() {
return Ok(None);
}
if upper.starts_with("SHOW ") {
return parse_show(&tokens);
}
if upper.starts_with("ALTER NODE DRAIN") {
return parse_alter_node_drain(&tokens).map(Some);
}
if upper.starts_with("TRANSFER LEADER") {
return parse_transfer_leader(&tokens).map(Some);
}
if upper.starts_with("MOVE REPLICA") {
return parse_move_replica(&tokens).map(Some);
}
if upper.starts_with("SPLIT TABLET") {
return parse_split_tablet(&tokens).map(Some);
}
if upper.starts_with("MERGE TABLETS") {
return parse_merge_tablets(&tokens).map(Some);
}
if upper.starts_with("PAUSE JOB")
|| upper.starts_with("RESUME JOB")
|| upper.starts_with("CANCEL JOB")
{
return parse_job_control(&tokens).map(Some);
}
if upper.starts_with("BACKUP DATABASE") {
return parse_backup(&tokens).map(Some);
}
if upper.starts_with("RESTORE DATABASE") {
return parse_restore(&tokens).map(Some);
}
Ok(None)
}
fn parse_show(tokens: &[&str]) -> Result<Option<AdminCommand>, GatewayError> {
let kind = tokens
.get(1)
.ok_or_else(|| GatewayError::InvalidAdminSql("SHOW requires a target".into()))?
.to_ascii_uppercase();
match kind.as_str() {
"CLUSTER" => Ok(Some(AdminCommand::ShowCluster)),
"NODES" => Ok(Some(AdminCommand::ShowNodes)),
"TABLETS" => {
let table = if tokens.len() >= 4 && tokens[2].eq_ignore_ascii_case("FOR") {
if !tokens[3].eq_ignore_ascii_case("TABLE") {
return Err(GatewayError::InvalidAdminSql(
"SHOW TABLETS FOR TABLE <name>".into(),
));
}
tokens.get(4).map(|s| (*s).to_owned())
} else {
None
};
Ok(Some(AdminCommand::ShowTablets { table }))
}
"REPLICAS" => {
let tablet_id = if tokens.len() >= 4 && tokens[2].eq_ignore_ascii_case("FOR") {
if !tokens[3].eq_ignore_ascii_case("TABLET") {
return Err(GatewayError::InvalidAdminSql(
"SHOW REPLICAS FOR TABLET <id>".into(),
));
}
Some(parse_tablet_id(tokens.get(4).copied().unwrap_or(""))?)
} else {
None
};
Ok(Some(AdminCommand::ShowReplicas { tablet_id }))
}
"TRANSACTIONS" => Ok(Some(AdminCommand::ShowTransactions)),
"QUERIES" => Ok(Some(AdminCommand::ShowQueries)),
"JOBS" => Ok(Some(AdminCommand::ShowJobs)),
"RESOURCE" => {
if tokens
.get(2)
.is_some_and(|t| t.eq_ignore_ascii_case("GROUPS"))
{
Ok(Some(AdminCommand::ShowResourceGroups))
} else {
Err(GatewayError::InvalidAdminSql("SHOW RESOURCE GROUPS".into()))
}
}
"BACKUPS" => Ok(Some(AdminCommand::ShowBackups)),
_ => Ok(None),
}
}
fn parse_alter_node_drain(tokens: &[&str]) -> Result<AdminCommand, GatewayError> {
let id = tokens
.get(3)
.ok_or_else(|| GatewayError::InvalidAdminSql("ALTER NODE DRAIN <node_id>".into()))?;
Ok(AdminCommand::AlterNodeDrain {
node_id: parse_node_id(id)?,
})
}
fn parse_transfer_leader(tokens: &[&str]) -> Result<AdminCommand, GatewayError> {
if tokens.len() < 5 || !tokens[3].eq_ignore_ascii_case("TO") {
return Err(GatewayError::InvalidAdminSql(
"TRANSFER LEADER <tablet_id> TO <node_id>".into(),
));
}
Ok(AdminCommand::TransferLeader {
tablet_id: parse_tablet_id(tokens[2])?,
to: parse_node_id(tokens[4])?,
})
}
fn parse_move_replica(tokens: &[&str]) -> Result<AdminCommand, GatewayError> {
if tokens.len() < 7
|| !tokens[3].eq_ignore_ascii_case("FROM")
|| !tokens[5].eq_ignore_ascii_case("TO")
{
return Err(GatewayError::InvalidAdminSql(
"MOVE REPLICA <tablet_id> FROM <node_id> TO <node_id>".into(),
));
}
Ok(AdminCommand::MoveReplica {
tablet_id: parse_tablet_id(tokens[2])?,
from: parse_node_id(tokens[4])?,
to: parse_node_id(tokens[6])?,
})
}
fn parse_split_tablet(tokens: &[&str]) -> Result<AdminCommand, GatewayError> {
let tablet_id = parse_tablet_id(
tokens
.get(2)
.ok_or_else(|| GatewayError::InvalidAdminSql("SPLIT TABLET <id>".into()))?,
)?;
let at_key_hex = if tokens.get(3).is_some_and(|t| t.eq_ignore_ascii_case("AT")) {
Some(
tokens
.get(4)
.ok_or_else(|| GatewayError::InvalidAdminSql("SPLIT TABLET <id> AT <key>".into()))?
.to_string(),
)
} else {
None
};
Ok(AdminCommand::SplitTablet {
tablet_id,
at_key_hex,
})
}
fn parse_merge_tablets(tokens: &[&str]) -> Result<AdminCommand, GatewayError> {
if tokens.len() < 4 {
return Err(GatewayError::InvalidAdminSql(
"MERGE TABLETS <left_id> <right_id>".into(),
));
}
Ok(AdminCommand::MergeTablets {
left: parse_tablet_id(tokens[2])?,
right: parse_tablet_id(tokens[3])?,
})
}
fn parse_job_control(tokens: &[&str]) -> Result<AdminCommand, GatewayError> {
let action = match tokens[0].to_ascii_uppercase().as_str() {
"PAUSE" => JobAction::Pause,
"RESUME" => JobAction::Resume,
"CANCEL" => JobAction::Cancel,
_ => {
return Err(GatewayError::InvalidAdminSql(
"PAUSE|RESUME|CANCEL JOB <id>".into(),
))
}
};
if !tokens.get(1).is_some_and(|t| t.eq_ignore_ascii_case("JOB")) {
return Err(GatewayError::InvalidAdminSql(
"PAUSE|RESUME|CANCEL JOB <id>".into(),
));
}
let job_id = tokens
.get(2)
.ok_or_else(|| GatewayError::InvalidAdminSql("missing job id".into()))?
.to_string();
Ok(AdminCommand::JobControl { action, job_id })
}
fn parse_backup(tokens: &[&str]) -> Result<AdminCommand, GatewayError> {
let destination = if tokens.get(2).is_some_and(|t| t.eq_ignore_ascii_case("TO")) {
Some(
tokens
.get(3)
.ok_or_else(|| GatewayError::InvalidAdminSql("BACKUP DATABASE TO <path>".into()))?
.trim_matches('\'')
.trim_matches('"')
.to_owned(),
)
} else {
None
};
Ok(AdminCommand::BackupDatabase { destination })
}
fn parse_restore(tokens: &[&str]) -> Result<AdminCommand, GatewayError> {
if tokens.len() < 4 || !tokens[2].eq_ignore_ascii_case("FROM") {
return Err(GatewayError::InvalidAdminSql(
"RESTORE DATABASE FROM <path> [DISASTER RECOVERY]".into(),
));
}
let source = tokens[3].trim_matches('\'').trim_matches('"').to_owned();
let disaster_recovery = tokens
.get(4)
.is_some_and(|t| t.eq_ignore_ascii_case("DISASTER"));
Ok(AdminCommand::RestoreDatabase {
source,
disaster_recovery,
})
}
fn parse_tablet_id(text: &str) -> Result<TabletId, GatewayError> {
text.parse()
.map_err(|e| GatewayError::InvalidAdminSql(format!("invalid tablet id: {e}")))
}
fn parse_node_id(text: &str) -> Result<NodeId, GatewayError> {
text.parse()
.map_err(|e| GatewayError::InvalidAdminSql(format!("invalid node id: {e}")))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClusterSession {
pub metadata_version: MetadataVersion,
pub dist_txn_id: Option<TransactionId>,
pub consistency: String,
}
impl Default for ClusterSession {
fn default() -> Self {
Self {
metadata_version: MetadataVersion::ZERO,
dist_txn_id: None,
consistency: "linearizable".into(),
}
}
}
pub fn routing_entry(
metadata_version: MetadataVersion,
endpoints: Vec<Endpoint>,
leader: Option<NodeId>,
term: u64,
) -> RoutingEntry {
RoutingEntry {
leader_hint: leader.map(|leader| LeaderHint { term, leader }),
term,
metadata_version,
endpoints,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tablet::{ReplicaDescriptor, ReplicaRole, TabletState};
use mongreldb_types::ids::{ClusterId, NodeId, RaftGroupId, TableId};
fn tid(n: u8) -> TabletId {
TabletId::from_bytes({
let mut b = [0u8; 16];
b[15] = n;
b
})
}
fn nid(n: u8) -> NodeId {
NodeId::from_bytes({
let mut b = [0u8; 16];
b[15] = n;
b
})
}
fn rid(n: u8) -> RaftGroupId {
RaftGroupId::from_bytes({
let mut b = [0u8; 16];
b[15] = n;
b
})
}
fn qid() -> QueryId {
QueryId::from_bytes([0xAB; 16])
}
fn desc(tablet: u8, table: u64, state: TabletState) -> TabletDescriptor {
TabletDescriptor {
tablet_id: tid(tablet),
table_id: TableId::new(table),
database_id: mongreldb_types::ids::DatabaseId::ZERO,
raft_group_id: rid(tablet),
partition: crate::tablet::PartitionBounds::unbounded(),
replicas: vec![
ReplicaDescriptor {
node_id: nid(1),
role: ReplicaRole::Voter,
raft_node_id: 1,
},
ReplicaDescriptor {
node_id: nid(2),
role: ReplicaRole::Voter,
raft_node_id: 2,
},
],
leader_hint: Some(nid(1)),
generation: 3,
state,
}
}
fn resolve(node: NodeId) -> Option<Endpoint> {
Some(Endpoint {
node_id: node,
address: format!("127.0.0.1:{}", 8000 + node.as_bytes()[15] as u16),
})
}
#[test]
fn bind_plan_routes_through_real_tablet_groups() {
let layout = TabletLayoutSnapshot::from_descriptors(
MetadataVersion::new(5),
vec![
desc(1, 10, TabletState::Active),
desc(2, 10, TabletState::Active),
],
);
let routing = RoutingCache::new();
let plan = GatewayPlan {
query_id: qid(),
metadata_version: MetadataVersion::new(5),
fragments: vec![GatewayFragment {
fragment_id: 0,
tablet_ids: vec![],
table_id: Some(TableId::new(10)),
}],
};
let bound = bind_plan_to_tablets(&plan, &layout, &routing, &resolve).unwrap();
assert_eq!(bound.fragments.len(), 1);
assert_eq!(bound.fragments[0].targets.len(), 2);
assert_eq!(bound.fragments[0].targets[0].tablet_id, tid(1));
assert_eq!(bound.fragments[0].targets[1].tablet_id, tid(2));
assert_eq!(
bound.fragments[0].targets[0]
.preferred_endpoint
.as_ref()
.map(|e| e.node_id),
Some(nid(1))
);
assert!(routing.get(GroupKey::Tablet(tid(1))).is_some());
assert!(!routing.is_stale(GroupKey::Tablet(tid(1)), MetadataVersion::new(5)));
}
#[test]
fn bind_refuses_stale_metadata() {
let layout = TabletLayoutSnapshot::from_descriptors(
MetadataVersion::new(9),
vec![desc(1, 1, TabletState::Active)],
);
let plan = GatewayPlan {
query_id: qid(),
metadata_version: MetadataVersion::new(3),
fragments: vec![GatewayFragment {
fragment_id: 0,
tablet_ids: vec![tid(1)],
table_id: None,
}],
};
let err = bind_plan_to_tablets(&plan, &layout, &RoutingCache::new(), &resolve).unwrap_err();
assert!(matches!(err, GatewayError::StaleMetadata { .. }));
}
#[test]
fn bind_refuses_non_routable_tablet() {
let layout = TabletLayoutSnapshot::from_descriptors(
MetadataVersion::new(1),
vec![desc(1, 1, TabletState::Creating)],
);
let plan = GatewayPlan {
query_id: qid(),
metadata_version: MetadataVersion::new(1),
fragments: vec![GatewayFragment {
fragment_id: 0,
tablet_ids: vec![tid(1)],
table_id: None,
}],
};
let err = bind_plan_to_tablets(&plan, &layout, &RoutingCache::new(), &resolve).unwrap_err();
assert!(matches!(err, GatewayError::NotRoutable { .. }));
}
#[test]
fn parse_show_and_alter_admin_sql() {
assert_eq!(
parse_admin_sql("SHOW CLUSTER").unwrap(),
Some(AdminCommand::ShowCluster)
);
assert_eq!(parse_admin_sql("SHOW TABLES").unwrap(), None);
assert_eq!(
parse_admin_sql("show nodes;").unwrap(),
Some(AdminCommand::ShowNodes)
);
assert_eq!(
parse_admin_sql("SHOW TABLETS FOR TABLE orders").unwrap(),
Some(AdminCommand::ShowTablets {
table: Some("orders".into())
})
);
assert_eq!(
parse_admin_sql("SHOW RESOURCE GROUPS").unwrap(),
Some(AdminCommand::ShowResourceGroups)
);
assert_eq!(
parse_admin_sql("SHOW BACKUPS").unwrap(),
Some(AdminCommand::ShowBackups)
);
let node = nid(7);
let cmd = parse_admin_sql(&format!("ALTER NODE DRAIN {node}")).unwrap();
assert_eq!(cmd, Some(AdminCommand::AlterNodeDrain { node_id: node }));
let t = tid(3);
let cmd = parse_admin_sql(&format!("TRANSFER LEADER {t} TO {node}")).unwrap();
assert_eq!(
cmd,
Some(AdminCommand::TransferLeader {
tablet_id: t,
to: node
})
);
let from = nid(1);
let to = nid(2);
let cmd = parse_admin_sql(&format!("MOVE REPLICA {t} FROM {from} TO {to}")).unwrap();
assert_eq!(
cmd,
Some(AdminCommand::MoveReplica {
tablet_id: t,
from,
to
})
);
let cmd = parse_admin_sql(&format!("SPLIT TABLET {t}")).unwrap();
assert_eq!(
cmd,
Some(AdminCommand::SplitTablet {
tablet_id: t,
at_key_hex: None
})
);
let left = tid(1);
let right = tid(2);
let cmd = parse_admin_sql(&format!("MERGE TABLETS {left} {right}")).unwrap();
assert_eq!(cmd, Some(AdminCommand::MergeTablets { left, right }));
assert_eq!(
parse_admin_sql("PAUSE JOB job-42").unwrap(),
Some(AdminCommand::JobControl {
action: JobAction::Pause,
job_id: "job-42".into()
})
);
assert_eq!(
parse_admin_sql("BACKUP DATABASE TO '/var/backups/x'").unwrap(),
Some(AdminCommand::BackupDatabase {
destination: Some("/var/backups/x".into())
})
);
assert_eq!(
parse_admin_sql("RESTORE DATABASE FROM '/var/backups/x' DISASTER RECOVERY").unwrap(),
Some(AdminCommand::RestoreDatabase {
source: "/var/backups/x".into(),
disaster_recovery: true
})
);
assert_eq!(parse_admin_sql("SELECT 1").unwrap(), None);
}
#[test]
fn refresh_routing_installs_entries() {
let layout = TabletLayoutSnapshot::from_descriptors(
MetadataVersion::new(2),
vec![
desc(1, 1, TabletState::Active),
desc(2, 1, TabletState::Retired),
],
);
let routing = RoutingCache::new();
let n = refresh_routing_from_layout(&routing, &layout, &resolve);
assert_eq!(n, 1); assert!(routing.get(GroupKey::Tablet(tid(1))).is_some());
assert!(routing.get(GroupKey::Tablet(tid(2))).is_none());
}
#[test]
fn _cluster_id_unused_silence() {
let _ = ClusterId::from_bytes([0; 16]);
}
}