use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use nodedb_cluster::routing::VSHARD_COUNT;
use nodedb_cluster::rpc_codec::{ExecuteRequest, ExecuteResponse, RaftRpc, TypedClusterError};
use nodedb_types::backup_envelope::{EnvelopeMeta, EnvelopeWriter};
use crate::Error;
use crate::bridge::envelope::PhysicalPlan;
use crate::control::server::shared::ddl::sync_dispatch;
use crate::control::state::SharedState;
use crate::types::{DatabaseId, TenantId, TraceId};
use nodedb_physical::physical_plan::{MetaOp, wire as plan_wire};
const NODE_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(120);
pub async fn backup_tenant(state: &Arc<SharedState>, tenant_id: u64) -> Result<Bytes, Error> {
let assignment = source_assignment(state);
let snapshot_plan = PhysicalPlan::Meta(MetaOp::CreateTenantSnapshot { tenant_id });
let mut sections = Vec::with_capacity(assignment.len());
for (node_id, source_vshards) in assignment {
let body = if is_self(state, node_id) {
snapshot_self(state, tenant_id, &snapshot_plan).await?
} else {
snapshot_remote(state, node_id, tenant_id, &snapshot_plan).await?
};
let body = filter_node_snapshot(body, tenant_id, &source_vshards)?;
sections.push((node_id, body));
}
let snapshot_watermark = state.hlc_clock.now().wall_ns;
let meta = EnvelopeMeta {
tenant_id,
source_vshard_count: VSHARD_COUNT as u16,
hash_seed: 0, snapshot_watermark,
};
let mut writer = EnvelopeWriter::new(meta);
for (node_id, body) in sections {
writer
.push_section(node_id, body)
.map_err(|e| Error::Internal {
detail: format!("backup envelope: {e}"),
})?;
}
{
let catalog = state.credentials.catalog();
if let Ok(all) = catalog.load_all_collections(DatabaseId::DEFAULT) {
let mut blobs: Vec<nodedb_types::backup_envelope::StoredCollectionBlob> = Vec::new();
for coll in all.iter().filter(|c| c.tenant_id == tenant_id) {
if let Ok(bytes) = zerompk::to_msgpack_vec(coll) {
blobs.push(nodedb_types::backup_envelope::StoredCollectionBlob {
name: coll.name.clone(),
bytes,
});
}
}
if !blobs.is_empty()
&& let Ok(body) = zerompk::to_msgpack_vec(&blobs)
{
writer
.push_section(
nodedb_types::backup_envelope::SECTION_ORIGIN_CATALOG_ROWS,
body,
)
.map_err(|e| Error::Internal {
detail: format!("backup envelope (catalog rows): {e}"),
})?;
}
}
if let Ok(all) = catalog.load_all_collections(DatabaseId::DEFAULT) {
let mut binds: Vec<nodedb_types::backup_envelope::SurrogateBindBlob> = Vec::new();
for coll in all.iter().filter(|c| c.tenant_id == tenant_id) {
if let Ok(rows) = catalog.scan_surrogates_for_collection(
DatabaseId::DEFAULT,
TenantId::new(tenant_id),
&coll.name,
) {
for (pk, surrogate) in rows {
binds.push(nodedb_types::backup_envelope::SurrogateBindBlob {
tenant_id,
collection: coll.name.clone(),
pk,
surrogate: surrogate.as_u32(),
});
}
}
}
if !binds.is_empty()
&& let Ok(body) = zerompk::to_msgpack_vec(&binds)
{
writer
.push_section(
nodedb_types::backup_envelope::SECTION_ORIGIN_SURROGATE_PK,
body,
)
.map_err(|e| Error::Internal {
detail: format!("backup envelope (surrogate pk): {e}"),
})?;
}
}
if let Ok(tset) = catalog.load_wal_tombstones() {
let mut tombs: Vec<nodedb_types::backup_envelope::SourceTombstoneEntry> = Vec::new();
for (database_id, tid, name, purge_lsn) in tset.iter() {
if database_id == DatabaseId::DEFAULT.as_u64() && tid == tenant_id {
tombs.push(nodedb_types::backup_envelope::SourceTombstoneEntry {
collection: name.to_string(),
purge_lsn,
});
}
}
if !tombs.is_empty()
&& let Ok(body) = zerompk::to_msgpack_vec(&tombs)
{
writer
.push_section(
nodedb_types::backup_envelope::SECTION_ORIGIN_SOURCE_TOMBSTONES,
body,
)
.map_err(|e| Error::Internal {
detail: format!("backup envelope (source tombstones): {e}"),
})?;
}
}
}
let envelope_bytes = match &state.backup_kek {
Some(kek) => writer
.finalize_encrypted(kek)
.map_err(|e| Error::Internal {
detail: format!("backup envelope encryption: {e}"),
})?,
None => {
return Err(Error::Internal {
detail: "backup: no [backup_encryption] KEK configured; \
plaintext backup envelopes are no longer supported"
.into(),
});
}
};
Ok(Bytes::from(envelope_bytes))
}
fn source_assignment(state: &SharedState) -> Vec<(u64, HashSet<u32>)> {
let Some(routing) = state.cluster_routing.as_ref() else {
return vec![(state.node_id, (0..VSHARD_COUNT).collect())];
};
let table = routing.read().unwrap_or_else(|p| p.into_inner());
let mut by_node: BTreeMap<u64, HashSet<u32>> = BTreeMap::new();
for vshard in 0..VSHARD_COUNT {
let Ok(group_id) = table.group_for_vshard(vshard) else {
continue;
};
let Some(info) = table.group_info(group_id) else {
continue;
};
let source = if info.leader != 0 {
info.leader
} else {
match info.members.iter().copied().min() {
Some(m) => m,
None => continue,
}
};
by_node.entry(source).or_default().insert(vshard);
}
if by_node.is_empty() {
return vec![(state.node_id, (0..VSHARD_COUNT).collect())];
}
by_node.into_iter().collect()
}
fn filter_node_snapshot(
body: Vec<u8>,
tenant_id: u64,
source_vshards: &HashSet<u32>,
) -> Result<Vec<u8>, Error> {
let mut snap: crate::types::TenantDataSnapshot =
zerompk::from_msgpack(&body).map_err(|e| Error::Internal {
detail: format!("backup: decode per-node snapshot: {e}"),
})?;
crate::control::backup::snapshot_keys::retain_tenant_data_for_vshards(
&mut snap,
tenant_id,
source_vshards,
|collection| {
nodedb_cluster::routing::vshard_for_collection(DatabaseId::DEFAULT, collection)
},
);
zerompk::to_msgpack_vec(&snap).map_err(|e| Error::Internal {
detail: format!("backup: re-encode filtered snapshot: {e}"),
})
}
fn is_self(state: &SharedState, node_id: u64) -> bool {
node_id == state.node_id || node_id == 0 || state.cluster_transport.is_none()
}
async fn snapshot_self(
state: &Arc<SharedState>,
tenant_id: u64,
plan: &PhysicalPlan,
) -> Result<Vec<u8>, Error> {
sync_dispatch::dispatch_async(
state,
TenantId::new(tenant_id),
DatabaseId::DEFAULT,
"__system",
plan.clone(),
NODE_SNAPSHOT_TIMEOUT,
)
.await
}
async fn snapshot_remote(
state: &Arc<SharedState>,
node_id: u64,
tenant_id: u64,
plan: &PhysicalPlan,
) -> Result<Vec<u8>, Error> {
let transport = state
.cluster_transport
.as_ref()
.ok_or_else(|| Error::Internal {
detail: format!("backup: cluster_transport unavailable but node {node_id} is remote"),
})?;
let plan_bytes = plan_wire::encode(plan).map_err(|e| Error::Internal {
detail: format!("backup: plan encode failed: {e}"),
})?;
let req = RaftRpc::ExecuteRequest(ExecuteRequest {
plan_bytes,
tenant_id,
database_id: DatabaseId::DEFAULT.as_u64(),
deadline_remaining_ms: NODE_SNAPSHOT_TIMEOUT.as_millis() as u64,
trace_id: TraceId::generate().0,
descriptor_versions: Vec::new(),
txn_id: None,
});
let resp = transport
.send_rpc(node_id, req)
.await
.map_err(|e| Error::Internal {
detail: format!("backup: snapshot RPC to node {node_id} failed: {e}"),
})?;
match resp {
RaftRpc::ExecuteResponse(ExecuteResponse {
success: true,
mut payloads,
..
}) => {
if payloads.len() != 1 {
return Err(Error::Internal {
detail: format!(
"backup: expected 1 payload from node {node_id}, got {}",
payloads.len()
),
});
}
Ok(payloads.remove(0))
}
RaftRpc::ExecuteResponse(ExecuteResponse {
error: Some(err), ..
}) => Err(map_typed_error(err, node_id)),
RaftRpc::ExecuteResponse(_) => Err(Error::Internal {
detail: format!("backup: empty error response from node {node_id}"),
}),
other => Err(Error::Internal {
detail: format!(
"backup: unexpected RPC response variant from node {node_id}: {other:?}"
),
}),
}
}
fn map_typed_error(err: TypedClusterError, node_id: u64) -> Error {
match err {
TypedClusterError::Internal { message, .. } => Error::Internal {
detail: format!("backup node {node_id}: {message}"),
},
TypedClusterError::DeadlineExceeded { elapsed_ms } => Error::Internal {
detail: format!("backup node {node_id}: deadline exceeded after {elapsed_ms}ms"),
},
TypedClusterError::NotLeader { .. } => Error::Internal {
detail: format!("backup node {node_id}: snapshot RPC routed to non-leader"),
},
TypedClusterError::DescriptorMismatch { collection, .. } => Error::Internal {
detail: format!(
"backup node {node_id}: descriptor mismatch on collection {collection}"
),
},
}
}