feat(smartdb): add operation log APIs, point-in-time revert support, and a web-based debug dashboard
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user