BREAKING CHANGE(core): replace the TypeScript database engine with a Rust-backed embedded server and bridge
This commit is contained in:
35
rust/crates/rustdb-txn/src/error.rs
Normal file
35
rust/crates/rustdb-txn/src/error.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during transaction or session operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TransactionError {
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("transaction already active for session: {0}")]
|
||||
AlreadyActive(String),
|
||||
|
||||
#[error("write conflict detected (code 112): {0}")]
|
||||
WriteConflict(String),
|
||||
|
||||
#[error("session expired: {0}")]
|
||||
SessionExpired(String),
|
||||
|
||||
#[error("invalid transaction state: {0}")]
|
||||
InvalidState(String),
|
||||
}
|
||||
|
||||
impl TransactionError {
|
||||
/// Returns the error code.
|
||||
pub fn code(&self) -> i32 {
|
||||
match self {
|
||||
TransactionError::NotFound(_) => 251,
|
||||
TransactionError::AlreadyActive(_) => 256,
|
||||
TransactionError::WriteConflict(_) => 112,
|
||||
TransactionError::SessionExpired(_) => 6100,
|
||||
TransactionError::InvalidState(_) => 263,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type TransactionResult<T> = Result<T, TransactionError>;
|
||||
9
rust/crates/rustdb-txn/src/lib.rs
Normal file
9
rust/crates/rustdb-txn/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod error;
|
||||
mod session;
|
||||
mod transaction;
|
||||
|
||||
pub use error::{TransactionError, TransactionResult};
|
||||
pub use session::{Session, SessionEngine};
|
||||
pub use transaction::{
|
||||
TransactionEngine, TransactionState, TransactionStatus, WriteEntry, WriteOp,
|
||||
};
|
||||
205
rust/crates/rustdb-txn/src/session.rs
Normal file
205
rust/crates/rustdb-txn/src/session.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bson::Bson;
|
||||
use dashmap::DashMap;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::error::{TransactionError, TransactionResult};
|
||||
|
||||
/// Represents a logical session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub created_at: Instant,
|
||||
pub last_activity_at: Instant,
|
||||
pub txn_id: Option<String>,
|
||||
pub in_transaction: bool,
|
||||
}
|
||||
|
||||
/// Engine that manages logical sessions with timeout and cleanup.
|
||||
pub struct SessionEngine {
|
||||
sessions: DashMap<String, Session>,
|
||||
timeout: Duration,
|
||||
_cleanup_interval: Duration,
|
||||
}
|
||||
|
||||
impl SessionEngine {
|
||||
/// Create a new session engine.
|
||||
///
|
||||
/// * `timeout_ms` - Session timeout in milliseconds (default: 30 minutes = 1_800_000).
|
||||
/// * `cleanup_interval_ms` - How often to run the cleanup task in milliseconds (default: 60_000).
|
||||
pub fn new(timeout_ms: u64, cleanup_interval_ms: u64) -> Self {
|
||||
Self {
|
||||
sessions: DashMap::new(),
|
||||
timeout: Duration::from_millis(timeout_ms),
|
||||
_cleanup_interval: Duration::from_millis(cleanup_interval_ms),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get an existing session or create a new one. Returns the session id.
|
||||
pub fn get_or_create_session(&self, id: &str) -> String {
|
||||
if let Some(mut session) = self.sessions.get_mut(id) {
|
||||
session.last_activity_at = Instant::now();
|
||||
return session.id.clone();
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let session = Session {
|
||||
id: id.to_string(),
|
||||
created_at: now,
|
||||
last_activity_at: now,
|
||||
txn_id: None,
|
||||
in_transaction: false,
|
||||
};
|
||||
self.sessions.insert(id.to_string(), session);
|
||||
debug!(session_id = %id, "created new session");
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
/// Update the last activity timestamp for a session.
|
||||
pub fn touch_session(&self, id: &str) {
|
||||
if let Some(mut session) = self.sessions.get_mut(id) {
|
||||
session.last_activity_at = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// End a session. If a transaction is active, it will be marked for abort.
|
||||
pub fn end_session(&self, id: &str) {
|
||||
if let Some((_, session)) = self.sessions.remove(id) {
|
||||
if session.in_transaction {
|
||||
warn!(
|
||||
session_id = %id,
|
||||
txn_id = ?session.txn_id,
|
||||
"ending session with active transaction, transaction should be aborted"
|
||||
);
|
||||
}
|
||||
debug!(session_id = %id, "session ended");
|
||||
}
|
||||
}
|
||||
|
||||
/// Associate a transaction with a session.
|
||||
pub fn start_transaction(&self, session_id: &str, txn_id: &str) -> TransactionResult<()> {
|
||||
let mut session = self
|
||||
.sessions
|
||||
.get_mut(session_id)
|
||||
.ok_or_else(|| TransactionError::NotFound(format!("session {}", session_id)))?;
|
||||
|
||||
if session.in_transaction {
|
||||
return Err(TransactionError::AlreadyActive(session_id.to_string()));
|
||||
}
|
||||
|
||||
session.txn_id = Some(txn_id.to_string());
|
||||
session.in_transaction = true;
|
||||
session.last_activity_at = Instant::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disassociate the transaction from a session (after commit or abort).
|
||||
pub fn end_transaction(&self, session_id: &str) {
|
||||
if let Some(mut session) = self.sessions.get_mut(session_id) {
|
||||
session.txn_id = None;
|
||||
session.in_transaction = false;
|
||||
session.last_activity_at = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a session is currently in a transaction.
|
||||
pub fn is_in_transaction(&self, session_id: &str) -> bool {
|
||||
self.sessions
|
||||
.get(session_id)
|
||||
.map(|s| s.in_transaction)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get the active transaction id for a session, if any.
|
||||
pub fn get_transaction_id(&self, session_id: &str) -> Option<String> {
|
||||
self.sessions
|
||||
.get(session_id)
|
||||
.and_then(|s| s.txn_id.clone())
|
||||
}
|
||||
|
||||
/// Extract a session id from a BSON `lsid` value.
|
||||
///
|
||||
/// Handles the following formats:
|
||||
/// - `{ "id": UUID }` (standard driver format)
|
||||
/// - `{ "id": "string" }` (string shorthand)
|
||||
/// - `{ "id": Binary(base64) }` (binary UUID)
|
||||
pub fn extract_session_id(lsid: &Bson) -> Option<String> {
|
||||
match lsid {
|
||||
Bson::Document(doc) => {
|
||||
if let Some(id_val) = doc.get("id") {
|
||||
match id_val {
|
||||
Bson::Binary(bin) => {
|
||||
// UUID stored as Binary subtype 4.
|
||||
let bytes = &bin.bytes;
|
||||
if bytes.len() == 16 {
|
||||
let uuid = uuid::Uuid::from_slice(bytes).ok()?;
|
||||
Some(uuid.to_string())
|
||||
} else {
|
||||
// Fall back to base64 representation.
|
||||
Some(base64_encode(bytes))
|
||||
}
|
||||
}
|
||||
Bson::String(s) => Some(s.clone()),
|
||||
_ => Some(format!("{}", id_val)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Bson::String(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up expired sessions. Returns the number of sessions removed.
|
||||
pub fn cleanup_expired(&self) -> usize {
|
||||
let now = Instant::now();
|
||||
let timeout = self.timeout;
|
||||
let expired: Vec<String> = self
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|entry| now.duration_since(entry.last_activity_at) > timeout)
|
||||
.map(|entry| entry.id.clone())
|
||||
.collect();
|
||||
|
||||
let count = expired.len();
|
||||
for id in &expired {
|
||||
debug!(session_id = %id, "cleaning up expired session");
|
||||
self.sessions.remove(id);
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionEngine {
|
||||
fn default() -> Self {
|
||||
// 30 minutes timeout, 60 seconds cleanup interval.
|
||||
Self::new(1_800_000, 60_000)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple base64 encoding for binary data (no external dependency needed).
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut result = String::with_capacity((data.len() + 2) / 3 * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
||||
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
||||
let triple = (b0 << 16) | (b1 << 8) | b2;
|
||||
result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
|
||||
result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
|
||||
if chunk.len() > 1 {
|
||||
result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
|
||||
} else {
|
||||
result.push('=');
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
result.push(CHARS[(triple & 0x3F) as usize] as char);
|
||||
} else {
|
||||
result.push('=');
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
279
rust/crates/rustdb-txn/src/transaction.rs
Normal file
279
rust/crates/rustdb-txn/src/transaction.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bson::Document;
|
||||
use dashmap::DashMap;
|
||||
use tracing::{debug, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use rustdb_storage::StorageAdapter;
|
||||
|
||||
use crate::error::{TransactionError, TransactionResult};
|
||||
|
||||
/// The status of a transaction.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TransactionStatus {
|
||||
Active,
|
||||
Committed,
|
||||
Aborted,
|
||||
}
|
||||
|
||||
/// Describes a write operation within a transaction.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WriteOp {
|
||||
Insert,
|
||||
Update,
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// A single write entry recorded within a transaction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WriteEntry {
|
||||
pub op: WriteOp,
|
||||
pub doc: Option<Document>,
|
||||
pub original_doc: Option<Document>,
|
||||
}
|
||||
|
||||
/// Full state of an in-flight transaction.
|
||||
#[derive(Debug)]
|
||||
pub struct TransactionState {
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub status: TransactionStatus,
|
||||
/// Tracks which documents were read: namespace -> set of doc ids.
|
||||
pub read_set: HashMap<String, HashSet<String>>,
|
||||
/// Tracks writes: namespace -> (doc_id -> WriteEntry).
|
||||
pub write_set: HashMap<String, HashMap<String, WriteEntry>>,
|
||||
/// Snapshot of collections at transaction start: namespace -> documents.
|
||||
pub snapshots: HashMap<String, Vec<Document>>,
|
||||
}
|
||||
|
||||
/// Engine that manages transaction lifecycle and conflict detection.
|
||||
pub struct TransactionEngine {
|
||||
transactions: DashMap<String, TransactionState>,
|
||||
}
|
||||
|
||||
impl TransactionEngine {
|
||||
/// Create a new transaction engine.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
transactions: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new transaction for the given session.
|
||||
/// Returns a unique transaction id (UUID v4).
|
||||
pub fn start_transaction(&self, session_id: &str) -> TransactionResult<String> {
|
||||
let txn_id = Uuid::new_v4().to_string();
|
||||
debug!(txn_id = %txn_id, session_id = %session_id, "starting transaction");
|
||||
|
||||
let state = TransactionState {
|
||||
id: txn_id.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
status: TransactionStatus::Active,
|
||||
read_set: HashMap::new(),
|
||||
write_set: HashMap::new(),
|
||||
snapshots: HashMap::new(),
|
||||
};
|
||||
|
||||
self.transactions.insert(txn_id.clone(), state);
|
||||
Ok(txn_id)
|
||||
}
|
||||
|
||||
/// Commit a transaction: check for conflicts, then apply buffered writes
|
||||
/// to the underlying storage adapter.
|
||||
pub async fn commit_transaction(
|
||||
&self,
|
||||
txn_id: &str,
|
||||
storage: &dyn StorageAdapter,
|
||||
) -> TransactionResult<()> {
|
||||
// Remove the transaction so we own it exclusively.
|
||||
let mut state = self
|
||||
.transactions
|
||||
.remove(txn_id)
|
||||
.map(|(_, s)| s)
|
||||
.ok_or_else(|| TransactionError::NotFound(txn_id.to_string()))?;
|
||||
|
||||
if state.status != TransactionStatus::Active {
|
||||
return Err(TransactionError::InvalidState(format!(
|
||||
"transaction {} is {:?}, cannot commit",
|
||||
txn_id, state.status
|
||||
)));
|
||||
}
|
||||
|
||||
// Conflict detection: check if any documents in the read set have
|
||||
// been modified since the snapshot was taken.
|
||||
// (Simplified: we skip real snapshot timestamps for now.)
|
||||
|
||||
// Apply buffered writes to storage.
|
||||
for (ns, writes) in &state.write_set {
|
||||
let parts: Vec<&str> = ns.splitn(2, '.').collect();
|
||||
if parts.len() != 2 {
|
||||
warn!(namespace = %ns, "invalid namespace format, skipping");
|
||||
continue;
|
||||
}
|
||||
let (db, coll) = (parts[0], parts[1]);
|
||||
|
||||
for (doc_id, entry) in writes {
|
||||
match entry.op {
|
||||
WriteOp::Insert => {
|
||||
if let Some(ref doc) = entry.doc {
|
||||
let _ = storage.insert_one(db, coll, doc.clone()).await;
|
||||
}
|
||||
}
|
||||
WriteOp::Update => {
|
||||
if let Some(ref doc) = entry.doc {
|
||||
let _ = storage.update_by_id(db, coll, doc_id, doc.clone()).await;
|
||||
}
|
||||
}
|
||||
WriteOp::Delete => {
|
||||
let _ = storage.delete_by_id(db, coll, doc_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.status = TransactionStatus::Committed;
|
||||
debug!(txn_id = %txn_id, "transaction committed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Abort a transaction, discarding all buffered writes.
|
||||
pub fn abort_transaction(&self, txn_id: &str) -> TransactionResult<()> {
|
||||
let mut state = self
|
||||
.transactions
|
||||
.get_mut(txn_id)
|
||||
.ok_or_else(|| TransactionError::NotFound(txn_id.to_string()))?;
|
||||
|
||||
if state.status != TransactionStatus::Active {
|
||||
return Err(TransactionError::InvalidState(format!(
|
||||
"transaction {} is {:?}, cannot abort",
|
||||
txn_id, state.status
|
||||
)));
|
||||
}
|
||||
|
||||
state.status = TransactionStatus::Aborted;
|
||||
debug!(txn_id = %txn_id, "transaction aborted");
|
||||
|
||||
// Drop the mutable ref before removing.
|
||||
drop(state);
|
||||
self.transactions.remove(txn_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether a transaction is currently active.
|
||||
pub fn is_active(&self, txn_id: &str) -> bool {
|
||||
self.transactions
|
||||
.get(txn_id)
|
||||
.map(|s| s.status == TransactionStatus::Active)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Record a document read within a transaction (for conflict detection).
|
||||
pub fn record_read(&self, txn_id: &str, ns: &str, doc_id: &str) {
|
||||
if let Some(mut state) = self.transactions.get_mut(txn_id) {
|
||||
state
|
||||
.read_set
|
||||
.entry(ns.to_string())
|
||||
.or_default()
|
||||
.insert(doc_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a document write within a transaction (buffered until commit).
|
||||
pub fn record_write(
|
||||
&self,
|
||||
txn_id: &str,
|
||||
ns: &str,
|
||||
doc_id: &str,
|
||||
op: WriteOp,
|
||||
doc: Option<Document>,
|
||||
original: Option<Document>,
|
||||
) {
|
||||
if let Some(mut state) = self.transactions.get_mut(txn_id) {
|
||||
let entry = WriteEntry {
|
||||
op,
|
||||
doc,
|
||||
original_doc: original,
|
||||
};
|
||||
state
|
||||
.write_set
|
||||
.entry(ns.to_string())
|
||||
.or_default()
|
||||
.insert(doc_id.to_string(), entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a snapshot of documents for a namespace within a transaction,
|
||||
/// applying the write overlay (inserts, updates, deletes) on top.
|
||||
pub fn get_snapshot(&self, txn_id: &str, ns: &str) -> Option<Vec<Document>> {
|
||||
let state = self.transactions.get(txn_id)?;
|
||||
|
||||
// Start with the base snapshot.
|
||||
let mut docs: Vec<Document> = state
|
||||
.snapshots
|
||||
.get(ns)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Apply write overlay.
|
||||
if let Some(writes) = state.write_set.get(ns) {
|
||||
// Collect ids to delete.
|
||||
let delete_ids: HashSet<&String> = writes
|
||||
.iter()
|
||||
.filter(|(_, e)| e.op == WriteOp::Delete)
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
|
||||
// Remove deleted docs.
|
||||
docs.retain(|d| {
|
||||
if let Some(id) = d.get_object_id("_id").ok().map(|oid| oid.to_hex()) {
|
||||
!delete_ids.contains(&id)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
// Apply updates.
|
||||
for (doc_id, entry) in writes {
|
||||
if entry.op == WriteOp::Update {
|
||||
if let Some(ref new_doc) = entry.doc {
|
||||
// Replace existing doc with updated version.
|
||||
let hex_id = doc_id.clone();
|
||||
if let Some(pos) = docs.iter().position(|d| {
|
||||
d.get_object_id("_id")
|
||||
.ok()
|
||||
.map(|oid| oid.to_hex()) == Some(hex_id.clone())
|
||||
}) {
|
||||
docs[pos] = new_doc.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply inserts.
|
||||
for (_doc_id, entry) in writes {
|
||||
if entry.op == WriteOp::Insert {
|
||||
if let Some(ref doc) = entry.doc {
|
||||
docs.push(doc.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(docs)
|
||||
}
|
||||
|
||||
/// Store a base snapshot for a namespace within a transaction.
|
||||
pub fn set_snapshot(&self, txn_id: &str, ns: &str, docs: Vec<Document>) {
|
||||
if let Some(mut state) = self.transactions.get_mut(txn_id) {
|
||||
state.snapshots.insert(ns.to_string(), docs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TransactionEngine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user