use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use nodedb_types::{DatabaseId, TenantId};
use tracing::{debug, warn};
use crate::control::security::catalog::{StoredCollection, SystemCatalog, collection_constraints};
use crate::control::state::SharedState;
use crate::control::wal_replication::{
ConstraintChangeOp, ReplicatedEntry, ReplicatedWrite, propose_replicated_entry,
};
const MAX_RECONCILE_PROPOSALS_PER_PASS: usize = 64;
pub fn spawn_constraint_reconcile(shared: Arc<SharedState>) {
let task_shared = Arc::clone(&shared);
crate::control::shutdown::spawn_loop(
&shared.loop_registry,
&shared.shutdown,
"constraint_reconcile",
move |mut shutdown| async move {
let shared = task_shared;
let mut delivered: HashMap<(TenantId, String), u64> = HashMap::new();
let interval_ms = std::env::var("NODEDB_CONSTRAINT_RECONCILE_INTERVAL_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(1000);
let mut tick = tokio::time::interval(Duration::from_millis(interval_ms));
loop {
tokio::select! {
_ = shutdown.wait_cancelled() => break,
_ = tick.tick() => {}
}
if shutdown.is_cancelled() {
break;
}
reconcile_once(&shared, &mut delivered).await;
}
},
);
}
pub async fn reconcile_once(
shared: &Arc<SharedState>,
delivered: &mut HashMap<(TenantId, String), u64>,
) -> usize {
if !shared.is_metadata_leader() {
return 0;
}
let catalog = shared.credentials.catalog().clone();
let loaded = match tokio::task::spawn_blocking(move || load_collections(&catalog)).await {
Ok(Ok(rows)) => rows,
Ok(Err(e)) => {
warn!(error = %e, "constraint reconcile: catalog read failed");
return 0;
}
Err(e) => {
warn!(error = %e, "constraint reconcile: catalog read task panicked");
return 0;
}
};
let Some(proposer) = shared.async_raft_proposer.get() else {
return 0;
};
let proposer = Arc::clone(proposer);
let mut proposed = 0usize;
for (database_id, stored) in loaded {
if proposed >= MAX_RECONCILE_PROPOSALS_PER_PASS {
break;
}
let key = (TenantId::new(stored.tenant_id), stored.name.clone());
if delivered
.get(&key)
.is_some_and(|&v| v >= stored.constraint_version)
{
continue;
}
let constraints = collection_constraints(&stored);
let mut blobs = Vec::with_capacity(constraints.len());
let mut encode_failed = false;
for constraint in &constraints {
match zerompk::to_msgpack_vec(constraint) {
Ok(bytes) => blobs.push(bytes),
Err(e) => {
warn!(
collection = %stored.name,
error = %e,
"constraint reconcile: encode failed; skipping collection"
);
encode_failed = true;
break;
}
}
}
if encode_failed {
continue;
}
let vshard_id = nodedb_cluster::routing::vshard_for_collection(database_id, &stored.name);
let entry = ReplicatedEntry::new(
stored.tenant_id,
database_id.as_u64(),
vshard_id,
ReplicatedWrite::ConstraintChange {
collection: stored.name.clone(),
op: ConstraintChangeOp::Set,
constraint_version: stored.constraint_version,
constraints: blobs,
},
);
match propose_replicated_entry(shared, &proposer, entry).await {
Ok(_) => {
delivered.insert(key, stored.constraint_version);
proposed += 1;
}
Err(e) => {
debug!(
collection = %stored.name,
error = %e,
"constraint reconcile: propose failed; will retry next tick"
);
}
}
}
proposed
}
pub(crate) fn load_collections(
catalog: &SystemCatalog,
) -> crate::Result<Vec<(DatabaseId, StoredCollection)>> {
let mut db_ids: Vec<DatabaseId> = vec![DatabaseId::DEFAULT];
for db in catalog.list_databases()? {
if db.id != DatabaseId::DEFAULT {
db_ids.push(db.id);
}
}
let mut out = Vec::new();
for db_id in db_ids {
for stored in catalog.load_all_collections(db_id)? {
out.push((db_id, stored));
}
}
Ok(out)
}