use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use nodedb_types::id::DatabaseId;
use crate::Error;
use crate::bridge::envelope::PhysicalPlan;
use crate::control::backup::snapshot_keys::{
extract_db_scoped_collection, extract_db_tenant_scoped_collection,
};
use crate::control::security::catalog::SystemCatalog;
use crate::control::state::SharedState;
use crate::engine::graph::edge_store::parse_versioned_edge_key;
use crate::types::{SurrogateBindEntry, TenantDataSnapshot, TenantId};
use nodedb_physical::physical_plan::MetaOp;
const TENANT_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(120);
pub struct DataPlaneSnapshotBuilder {
shared: Arc<SharedState>,
}
impl DataPlaneSnapshotBuilder {
pub fn new(shared: Arc<SharedState>) -> Self {
Self { shared }
}
fn vshard_of(collection: &str) -> u32 {
nodedb_cluster::routing::vshard_for_collection(DatabaseId::DEFAULT, collection)
}
fn capture_surrogates(
catalog: &SystemCatalog,
tenants: &[u64],
group_vshards: &HashSet<u32>,
merged: &mut TenantDataSnapshot,
) -> Result<(), Error> {
let collections = catalog.load_all_collections(DatabaseId::DEFAULT)?;
let tenant_set: HashSet<u64> = tenants.iter().copied().collect();
for coll in collections
.iter()
.filter(|c| c.is_active && tenant_set.contains(&c.tenant_id))
.filter(|c| group_vshards.contains(&Self::vshard_of(&c.name)))
{
let bindings = catalog.scan_surrogates_for_collection(
DatabaseId::DEFAULT,
TenantId::new(coll.tenant_id),
&coll.name,
)?;
for (pk, surrogate) in bindings {
merged.surrogate_pk.push(SurrogateBindEntry {
tenant_id: coll.tenant_id,
collection: coll.name.clone(),
pk,
surrogate: surrogate.as_u32(),
});
}
}
Ok(())
}
async fn build_tenant_filtered(
&self,
tenant_id: u64,
group_vshards: &HashSet<u32>,
merged: &mut TenantDataSnapshot,
) -> Result<(), Error> {
let plan = PhysicalPlan::Meta(MetaOp::CreateTenantSnapshot { tenant_id });
let bytes = crate::control::server::shared::ddl::sync_dispatch::dispatch_async(
&self.shared,
TenantId::new(tenant_id),
DatabaseId::DEFAULT,
"__system",
plan,
TENANT_SNAPSHOT_TIMEOUT,
)
.await?;
let snap: TenantDataSnapshot =
zerompk::from_msgpack(&bytes).map_err(|e| Error::Internal {
detail: format!("snapshot build: decode tenant {tenant_id} snapshot: {e}"),
})?;
let in_group_db_tenant_scoped = |key: &str| {
extract_db_tenant_scoped_collection(key, tenant_id)
.map(|c| group_vshards.contains(&Self::vshard_of(c)))
.unwrap_or(false)
};
let in_group_db_scoped = |key: &str| {
extract_db_scoped_collection(key, tenant_id)
.map(|c| group_vshards.contains(&Self::vshard_of(c)))
.unwrap_or(false)
};
for (k, v) in snap.documents {
if in_group_db_tenant_scoped(&k) {
merged.documents.push((k, v));
}
}
for (k, v) in snap.indexes {
if in_group_db_tenant_scoped(&k) {
merged.indexes.push((k, v));
}
}
for (k, v) in snap.vectors {
if in_group_db_tenant_scoped(&k) {
merged.vectors.push((k, v));
}
}
for (k, v) in snap.timeseries {
if in_group_db_tenant_scoped(&k) {
merged.timeseries.push((k, v));
}
}
for (k, v) in snap.kv_tables {
if group_vshards.contains(&Self::vshard_of(&k)) {
merged.kv_tables.push((k, v));
}
}
for blob in snap.flushed_ts_segments {
if in_group_db_scoped(&blob.collection_key) {
merged.flushed_ts_segments.push(blob);
}
}
for (k, v) in snap.columnar_engines {
if in_group_db_scoped(&k) {
merged.columnar_engines.push((k, v));
}
}
for (k, v) in snap.vector_params {
if in_group_db_tenant_scoped(&k) {
merged.vector_params.push((k, v));
}
}
for (k, v) in snap.index_configs {
if in_group_db_tenant_scoped(&k) {
merged.index_configs.push((k, v));
}
}
for (key, value) in snap.edges {
match parse_versioned_edge_key(&key) {
Some((collection, ..)) => {
if group_vshards.contains(&Self::vshard_of(collection)) {
merged.tenant_edges.push((tenant_id, key, value));
}
}
None => {
let key_prefix: String = key.chars().take(32).collect();
tracing::warn!(key_prefix, "snapshot build: unparseable edge key, skipping");
}
}
}
for (database_id, tid, collection, bytes) in snap.crdt_state {
if group_vshards.contains(&Self::vshard_of(&collection)) {
merged
.crdt_state
.push((database_id, tid, collection, bytes));
}
}
for entry in snap.crdt_constraints {
if group_vshards.contains(&Self::vshard_of(&entry.collection)) {
merged.crdt_constraints.push(entry);
}
}
Ok(())
}
}
#[async_trait::async_trait]
impl nodedb_cluster::SnapshotBuilder for DataPlaneSnapshotBuilder {
async fn build_group_snapshot(
&self,
group_id: u64,
_last_included_index: u64,
_last_included_term: u64,
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
let group_vshards: HashSet<u32> = match self.shared.cluster_routing.as_ref() {
Some(routing) => {
let table = routing.read().map_err(|_| {
Box::new(Error::Internal {
detail: "snapshot build: cluster_routing RwLock poisoned".into(),
}) as Box<dyn std::error::Error + Send + Sync>
})?;
table.vshards_for_group(group_id).into_iter().collect()
}
None => return Ok(Vec::new()),
};
if group_vshards.is_empty() {
return Ok(Vec::new());
}
let tenants: Vec<u64> = {
let catalog = self.shared.credentials.catalog();
let collections = catalog
.load_all_collections(DatabaseId::DEFAULT)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
let mut set: HashSet<u64> = HashSet::new();
for coll in collections.iter().filter(|c| c.is_active) {
set.insert(coll.tenant_id);
}
let mut v: Vec<u64> = set.into_iter().collect();
v.sort_unstable();
v
};
let mut merged = TenantDataSnapshot::default();
for tenant_id in &tenants {
self.build_tenant_filtered(*tenant_id, &group_vshards, &mut merged)
.await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
}
{
let catalog = self.shared.credentials.catalog();
Self::capture_surrogates(catalog, &tenants, &group_vshards, &mut merged)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
}
let out = zerompk::to_msgpack_vec(&merged).map_err(|e| {
Box::new(Error::Internal {
detail: format!("snapshot build: encode merged group {group_id} snapshot: {e}"),
}) as Box<dyn std::error::Error + Send + Sync>
})?;
Ok(out)
}
}