use rust_rocksdb::WriteBatch;
use serde::{Deserialize, Serialize};
use super::engine::META_CF;
use super::RocksDb as DB;
use crate::error::{DbError, DbResult};
pub(crate) const ENTRY_PREFIX: &str = "coll:";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectionRecord {
#[serde(default = "default_type")]
pub type_: String,
#[serde(default)]
pub created_ms: u64,
}
fn default_type() -> String {
"document".to_string()
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn available(db: &DB) -> bool {
db.cf_handle(META_CF).is_some()
}
pub(crate) fn entry_key(cf_name: &str) -> String {
format!("{}{}", ENTRY_PREFIX, cf_name)
}
pub(crate) fn database_prefix(db_name: &str) -> String {
format!("{}{}:", ENTRY_PREFIX, db_name)
}
pub fn record(db: &DB, cf_name: &str, type_: &str) -> DbResult<()> {
let meta_cf = db
.cf_handle(META_CF)
.ok_or_else(|| DbError::InternalError("_meta column family missing".to_string()))?;
let value = serde_json::to_vec(&CollectionRecord {
type_: type_.to_string(),
created_ms: now_ms(),
})
.map_err(|e| DbError::InternalError(format!("Failed to encode collection record: {e}")))?;
db.put_cf(&meta_cf, entry_key(cf_name).as_bytes(), value)
.map_err(|e| DbError::InternalError(format!("Failed to record collection: {e}")))
}
pub fn forget(db: &DB, cf_name: &str) -> DbResult<()> {
let meta_cf = db
.cf_handle(META_CF)
.ok_or_else(|| DbError::InternalError("_meta column family missing".to_string()))?;
db.delete_cf(&meta_cf, entry_key(cf_name).as_bytes())
.map_err(|e| DbError::InternalError(format!("Failed to forget collection: {e}")))
}
pub fn forget_database_in_batch(db: &DB, batch: &mut WriteBatch, db_name: &str) {
let Some(meta_cf) = db.cf_handle(META_CF) else {
return;
};
let prefix = database_prefix(db_name);
for key in scan_keys(db, &prefix) {
batch.delete_cf(&meta_cf, key.as_bytes());
}
}
pub fn list(db: &DB, db_name: &str) -> Vec<String> {
let prefix = database_prefix(db_name);
scan_keys(db, &prefix)
.into_iter()
.filter_map(|key| key.strip_prefix(&prefix).map(|s| s.to_string()))
.collect()
}
pub fn list_all(db: &DB) -> Vec<String> {
scan_keys(db, ENTRY_PREFIX)
.into_iter()
.filter_map(|key| key.strip_prefix(ENTRY_PREFIX).map(|s| s.to_string()))
.collect()
}
fn scan_keys(db: &DB, prefix: &str) -> Vec<String> {
let Some(meta_cf) = db.cf_handle(META_CF) else {
return Vec::new();
};
db.prefix_iterator_cf(&meta_cf, prefix.as_bytes())
.filter_map(|result| {
let (key, _) = result.ok()?;
let key = String::from_utf8(key.to_vec()).ok()?;
key.starts_with(prefix).then_some(key)
})
.collect()
}
pub fn backfill(db: &DB, is_pending: impl Fn(&str) -> bool) -> usize {
let Some(meta_cf) = db.cf_handle(META_CF) else {
return 0;
};
let known: std::collections::HashSet<String> = list_all(db).into_iter().collect();
let mut batch = WriteBatch::default();
let mut adopted = 0usize;
for cf_name in db.cf_names() {
if cf_name == "default" || cf_name == META_CF {
continue;
}
if !cf_name.contains(':') || known.contains(&cf_name) || is_pending(&cf_name) {
continue;
}
let type_ = db
.cf_handle(&cf_name)
.and_then(|cf| db.get_cf(&cf, b"_stats:type").ok().flatten())
.map(|bytes| String::from_utf8_lossy(&bytes).to_string())
.unwrap_or_else(default_type);
if let Ok(value) = serde_json::to_vec(&CollectionRecord {
type_,
created_ms: 0, }) {
batch.put_cf(&meta_cf, entry_key(&cf_name).as_bytes(), value);
adopted += 1;
}
}
if adopted > 0 {
if let Err(e) = db.write(&batch) {
tracing::warn!("Collection registry backfill failed: {}", e);
return 0;
}
tracing::info!(
"Adopted {} column families into the collection registry",
adopted
);
}
adopted
}
#[cfg(test)]
mod tests {
use super::*;
use rust_rocksdb::Options;
use tempfile::TempDir;
fn open() -> (DB, TempDir) {
let dir = TempDir::new().unwrap();
let mut opts = Options::default();
opts.create_if_missing(true);
opts.create_missing_column_families(true);
let db = DB::open_cf(&opts, dir.path(), [META_CF]).unwrap();
(db, dir)
}
#[test]
fn record_then_list_round_trips() {
let (db, _dir) = open();
record(&db, "shop:orders", "document").unwrap();
record(&db, "shop:edges", "edge").unwrap();
record(&db, "other:orders", "document").unwrap();
let mut listed = list(&db, "shop");
listed.sort();
assert_eq!(listed, vec!["edges".to_string(), "orders".to_string()]);
forget(&db, "shop:edges").unwrap();
assert_eq!(list(&db, "shop"), vec!["orders".to_string()]);
}
#[test]
fn list_does_not_leak_across_similar_database_names() {
let (db, _dir) = open();
record(&db, "app:things", "document").unwrap();
record(&db, "app_test:things", "document").unwrap();
assert_eq!(list(&db, "app"), vec!["things".to_string()]);
assert_eq!(list(&db, "app_test"), vec!["things".to_string()]);
}
#[test]
fn backfill_adopts_unregistered_column_families() {
let (db, _dir) = open();
db.create_cf("shop:orders", &Options::default()).unwrap();
db.create_cf("shop:edges", &Options::default()).unwrap();
let cf = db.cf_handle("shop:edges").unwrap();
db.put_cf(&cf, b"_stats:type", b"edge").unwrap();
assert!(list(&db, "shop").is_empty());
assert_eq!(backfill(&db, |_| false), 2);
let mut listed = list(&db, "shop");
listed.sort();
assert_eq!(listed, vec!["edges".to_string(), "orders".to_string()]);
assert_eq!(backfill(&db, |_| false), 0);
}
#[test]
fn backfill_skips_doomed_column_families() {
let (db, _dir) = open();
db.create_cf("shop:doomed", &Options::default()).unwrap();
assert_eq!(backfill(&db, |cf| cf == "shop:doomed"), 0);
assert!(list(&db, "shop").is_empty());
}
}