feat(smartdb): add operation log APIs, point-in-time revert support, and a web-based debug dashboard

This commit is contained in:
2026-04-02 17:02:03 +00:00
parent 943302f789
commit d34b8673e1
27 changed files with 3536 additions and 64 deletions

View File

@@ -13,7 +13,7 @@ use tokio_util::sync::CancellationToken;
use rustdb_config::{RustDbOptions, StorageType};
use rustdb_wire::{WireCodec, OP_QUERY};
use rustdb_wire::{encode_op_msg_response, encode_op_reply_response};
use rustdb_storage::{StorageAdapter, MemoryStorageAdapter, FileStorageAdapter};
use rustdb_storage::{StorageAdapter, MemoryStorageAdapter, FileStorageAdapter, OpLog};
// IndexEngine is used indirectly via CommandContext
use rustdb_txn::{TransactionEngine, SessionEngine};
use rustdb_commands::{CommandRouter, CommandContext};
@@ -56,6 +56,7 @@ impl RustDb {
sessions: Arc::new(SessionEngine::new(30 * 60 * 1000, 60 * 1000)),
cursors: Arc::new(DashMap::new()),
start_time: std::time::Instant::now(),
oplog: Arc::new(OpLog::new()),
});
let router = Arc::new(CommandRouter::new(ctx.clone()));
@@ -166,6 +167,11 @@ impl RustDb {
pub fn connection_uri(&self) -> String {
self.options.connection_uri()
}
/// Get a reference to the shared command context (for management IPC access to oplog, storage, etc.).
pub fn ctx(&self) -> &Arc<CommandContext> {
&self.ctx
}
}
/// Handle a single client connection using the wire protocol codec.

View File

@@ -139,7 +139,12 @@ async fn handle_request(
"start" => handle_start(&id, &request.params, db).await,
"stop" => handle_stop(&id, db).await,
"getStatus" => handle_get_status(&id, db),
"getMetrics" => handle_get_metrics(&id, db),
"getMetrics" => handle_get_metrics(&id, db).await,
"getOpLog" => handle_get_oplog(&id, &request.params, db),
"getOpLogStats" => handle_get_oplog_stats(&id, db),
"revertToSeq" => handle_revert_to_seq(&id, &request.params, db).await,
"getCollections" => handle_get_collections(&id, &request.params, db).await,
"getDocuments" => handle_get_documents(&id, &request.params, db).await,
_ => ManagementResponse::err(id, format!("Unknown method: {}", request.method)),
}
}
@@ -223,18 +228,333 @@ fn handle_get_status(
}
}
fn handle_get_metrics(
async fn handle_get_metrics(
id: &str,
db: &Option<RustDb>,
) -> ManagementResponse {
match db.as_ref() {
Some(_d) => ManagementResponse::ok(
id.to_string(),
serde_json::json!({
"connections": 0,
"databases": 0,
}),
),
Some(d) => {
let ctx = d.ctx();
let db_list = ctx.storage.list_databases().await.unwrap_or_default();
let mut total_collections = 0u64;
for db_name in &db_list {
if let Ok(colls) = ctx.storage.list_collections(db_name).await {
total_collections += colls.len() as u64;
}
}
let oplog_stats = ctx.oplog.stats();
let uptime_secs = ctx.start_time.elapsed().as_secs();
ManagementResponse::ok(
id.to_string(),
serde_json::json!({
"databases": db_list.len(),
"collections": total_collections,
"oplogEntries": oplog_stats.total_entries,
"oplogCurrentSeq": oplog_stats.current_seq,
"uptimeSeconds": uptime_secs,
}),
)
}
None => ManagementResponse::err(id.to_string(), "Server is not running".to_string()),
}
}
fn handle_get_oplog(
id: &str,
params: &serde_json::Value,
db: &Option<RustDb>,
) -> ManagementResponse {
let d = match db.as_ref() {
Some(d) => d,
None => return ManagementResponse::err(id.to_string(), "Server is not running".to_string()),
};
let ctx = d.ctx();
let since_seq = params.get("sinceSeq").and_then(|v| v.as_u64()).unwrap_or(1);
let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
let filter_db = params.get("db").and_then(|v| v.as_str());
let filter_coll = params.get("collection").and_then(|v| v.as_str());
let mut entries = ctx.oplog.entries_since(since_seq);
// Apply filters.
if let Some(fdb) = filter_db {
entries.retain(|e| e.db == fdb);
}
if let Some(fcoll) = filter_coll {
entries.retain(|e| e.collection == fcoll);
}
let total = entries.len();
entries.truncate(limit);
// Serialize entries to JSON.
let entries_json: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
let doc_json = e.document.as_ref().map(|d| bson_doc_to_json(d));
let prev_json = e.previous_document.as_ref().map(|d| bson_doc_to_json(d));
serde_json::json!({
"seq": e.seq,
"timestampMs": e.timestamp_ms,
"op": match e.op {
rustdb_storage::OpType::Insert => "insert",
rustdb_storage::OpType::Update => "update",
rustdb_storage::OpType::Delete => "delete",
},
"db": e.db,
"collection": e.collection,
"documentId": e.document_id,
"document": doc_json,
"previousDocument": prev_json,
})
})
.collect();
ManagementResponse::ok(
id.to_string(),
serde_json::json!({
"entries": entries_json,
"currentSeq": ctx.oplog.current_seq(),
"totalEntries": total,
}),
)
}
fn handle_get_oplog_stats(
id: &str,
db: &Option<RustDb>,
) -> ManagementResponse {
let d = match db.as_ref() {
Some(d) => d,
None => return ManagementResponse::err(id.to_string(), "Server is not running".to_string()),
};
let stats = d.ctx().oplog.stats();
ManagementResponse::ok(
id.to_string(),
serde_json::json!({
"currentSeq": stats.current_seq,
"totalEntries": stats.total_entries,
"oldestSeq": stats.oldest_seq,
"entriesByOp": {
"insert": stats.inserts,
"update": stats.updates,
"delete": stats.deletes,
},
}),
)
}
async fn handle_revert_to_seq(
id: &str,
params: &serde_json::Value,
db: &Option<RustDb>,
) -> ManagementResponse {
let d = match db.as_ref() {
Some(d) => d,
None => return ManagementResponse::err(id.to_string(), "Server is not running".to_string()),
};
let target_seq = match params.get("seq").and_then(|v| v.as_u64()) {
Some(s) => s,
None => return ManagementResponse::err(id.to_string(), "Missing 'seq' parameter".to_string()),
};
let dry_run = params.get("dryRun").and_then(|v| v.as_bool()).unwrap_or(false);
let ctx = d.ctx();
let current = ctx.oplog.current_seq();
if target_seq > current {
return ManagementResponse::err(
id.to_string(),
format!("Target seq {} is beyond current seq {}", target_seq, current),
);
}
// Collect entries to revert (from target+1 to current), sorted descending for reverse processing.
let mut entries_to_revert = ctx.oplog.entries_range(target_seq + 1, current);
entries_to_revert.reverse();
if dry_run {
let entries_json: Vec<serde_json::Value> = entries_to_revert
.iter()
.map(|e| {
serde_json::json!({
"seq": e.seq,
"op": match e.op {
rustdb_storage::OpType::Insert => "insert",
rustdb_storage::OpType::Update => "update",
rustdb_storage::OpType::Delete => "delete",
},
"db": e.db,
"collection": e.collection,
"documentId": e.document_id,
})
})
.collect();
return ManagementResponse::ok(
id.to_string(),
serde_json::json!({
"dryRun": true,
"reverted": entries_to_revert.len(),
"entries": entries_json,
}),
);
}
// Execute revert: process each entry in reverse, using storage directly.
let mut reverted = 0u64;
let mut errors: Vec<String> = Vec::new();
for entry in &entries_to_revert {
let result = match entry.op {
rustdb_storage::OpType::Insert => {
// Undo insert -> delete the document.
ctx.storage.delete_by_id(&entry.db, &entry.collection, &entry.document_id).await
}
rustdb_storage::OpType::Update => {
// Undo update -> restore the previous document.
if let Some(ref prev_doc) = entry.previous_document {
ctx.storage
.update_by_id(&entry.db, &entry.collection, &entry.document_id, prev_doc.clone())
.await
} else {
errors.push(format!("seq {}: update entry missing previous_document", entry.seq));
continue;
}
}
rustdb_storage::OpType::Delete => {
// Undo delete -> re-insert the previous document.
if let Some(ref prev_doc) = entry.previous_document {
ctx.storage
.insert_one(&entry.db, &entry.collection, prev_doc.clone())
.await
.map(|_| ())
} else {
errors.push(format!("seq {}: delete entry missing previous_document", entry.seq));
continue;
}
}
};
match result {
Ok(()) => reverted += 1,
Err(e) => errors.push(format!("seq {}: {}", entry.seq, e)),
}
}
// Truncate the oplog to the target sequence.
ctx.oplog.truncate_after(target_seq);
let mut response = serde_json::json!({
"dryRun": false,
"reverted": reverted,
"targetSeq": target_seq,
});
if !errors.is_empty() {
response["errors"] = serde_json::json!(errors);
}
ManagementResponse::ok(id.to_string(), response)
}
async fn handle_get_collections(
id: &str,
params: &serde_json::Value,
db: &Option<RustDb>,
) -> ManagementResponse {
let d = match db.as_ref() {
Some(d) => d,
None => return ManagementResponse::err(id.to_string(), "Server is not running".to_string()),
};
let ctx = d.ctx();
let filter_db = params.get("db").and_then(|v| v.as_str());
let databases = match ctx.storage.list_databases().await {
Ok(dbs) => dbs,
Err(e) => return ManagementResponse::err(id.to_string(), format!("Failed to list databases: {}", e)),
};
let mut collections: Vec<serde_json::Value> = Vec::new();
for db_name in &databases {
if let Some(fdb) = filter_db {
if db_name != fdb {
continue;
}
}
if let Ok(colls) = ctx.storage.list_collections(db_name).await {
for coll_name in colls {
let count = ctx.storage.count(db_name, &coll_name).await.unwrap_or(0);
collections.push(serde_json::json!({
"db": db_name,
"name": coll_name,
"count": count,
}));
}
}
}
ManagementResponse::ok(
id.to_string(),
serde_json::json!({ "collections": collections }),
)
}
async fn handle_get_documents(
id: &str,
params: &serde_json::Value,
db: &Option<RustDb>,
) -> ManagementResponse {
let d = match db.as_ref() {
Some(d) => d,
None => return ManagementResponse::err(id.to_string(), "Server is not running".to_string()),
};
let db_name = match params.get("db").and_then(|v| v.as_str()) {
Some(s) => s,
None => return ManagementResponse::err(id.to_string(), "Missing 'db' parameter".to_string()),
};
let coll_name = match params.get("collection").and_then(|v| v.as_str()) {
Some(s) => s,
None => return ManagementResponse::err(id.to_string(), "Missing 'collection' parameter".to_string()),
};
let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let skip = params.get("skip").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let ctx = d.ctx();
let all_docs = match ctx.storage.find_all(db_name, coll_name).await {
Ok(docs) => docs,
Err(e) => return ManagementResponse::err(id.to_string(), format!("Failed to find documents: {}", e)),
};
let total = all_docs.len();
let docs: Vec<serde_json::Value> = all_docs
.into_iter()
.skip(skip)
.take(limit)
.map(|d| bson_doc_to_json(&d))
.collect();
ManagementResponse::ok(
id.to_string(),
serde_json::json!({
"documents": docs,
"total": total,
}),
)
}
/// Convert a BSON Document to a serde_json::Value.
fn bson_doc_to_json(doc: &bson::Document) -> serde_json::Value {
// Use bson's built-in relaxed extended JSON serialization.
let bson_val = bson::Bson::Document(doc.clone());
bson_val.into_relaxed_extjson()
}