feat: ADR-0006 §8 step 3 — wire the snapshot ring into the db worker
- snapshot_then() brackets all 19 mutating dispatch arms: stage a
pre-op snapshot, finalise on success / discard on rollback; gated
on a user command source (internal ops like open-time rebuild are
not snapshotted) and on undo being enabled
- BatchState + BeginBatch/EndBatch requests: a batch takes one
boundary snapshot, suppresses per-command snapshots, and finalises
iff a mutation committed (one undo step per replay/batch)
- Undo/Redo/PeekUndo/PeekRedo requests handled in worker_loop with
&mut conn for the restore; cleanup() sweeps crash leftovers on open
- Database::{undo,redo,peek_undo,peek_redo,begin_batch,end_batch} +
open_with_persistence_and_undo(); snapshot failures are non-fatal
(logged), restore failures surface
- 6 Tier-3 integration tests through the real worker
1680 passed / 0 failed / 1 ignored; clippy clean.
This commit is contained in:
@@ -28,7 +28,7 @@ use std::thread;
|
|||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::dsl::action::ReferentialAction;
|
use crate::dsl::action::ReferentialAction;
|
||||||
use crate::dsl::command::{
|
use crate::dsl::command::{
|
||||||
@@ -46,6 +46,7 @@ use crate::persistence::{
|
|||||||
SchemaSnapshot, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema,
|
SchemaSnapshot, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema,
|
||||||
};
|
};
|
||||||
use crate::project::{DATA_DIR, PROJECT_YAML};
|
use crate::project::{DATA_DIR, PROJECT_YAML};
|
||||||
|
use crate::undo::{DEFAULT_RING_CAPACITY, SnapshotError, SnapshotMeta, SnapshotStore, Staged};
|
||||||
|
|
||||||
/// Inbox capacity. The worker is fast enough that this rarely
|
/// Inbox capacity. The worker is fast enough that this rarely
|
||||||
/// matters; `64` is a generous head-room for bursts.
|
/// matters; `64` is a generous head-room for bursts.
|
||||||
@@ -681,6 +682,40 @@ enum Request {
|
|||||||
source: crate::dsl::grammar::IdentSource,
|
source: crate::dsl::grammar::IdentSource,
|
||||||
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
|
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
|
||||||
},
|
},
|
||||||
|
/// Restore the most recent undo snapshot (ADR-0006 Amendment 1).
|
||||||
|
/// Replies with the metadata of the command that was undone, or
|
||||||
|
/// `None` if there is nothing to undo (or undo is disabled).
|
||||||
|
Undo {
|
||||||
|
reply: oneshot::Sender<Result<Option<SnapshotMeta>, DbError>>,
|
||||||
|
},
|
||||||
|
/// Re-apply the most recently undone snapshot. `None` if there is
|
||||||
|
/// nothing to redo.
|
||||||
|
Redo {
|
||||||
|
reply: oneshot::Sender<Result<Option<SnapshotMeta>, DbError>>,
|
||||||
|
},
|
||||||
|
/// Read — without restoring — the snapshot `undo` would restore.
|
||||||
|
/// Used to build the confirmation prompt. `None` if the ring is
|
||||||
|
/// empty or undo is disabled.
|
||||||
|
PeekUndo {
|
||||||
|
reply: oneshot::Sender<Result<Option<SnapshotMeta>, DbError>>,
|
||||||
|
},
|
||||||
|
/// Read — without restoring — the snapshot `redo` would restore.
|
||||||
|
PeekRedo {
|
||||||
|
reply: oneshot::Sender<Result<Option<SnapshotMeta>, DbError>>,
|
||||||
|
},
|
||||||
|
/// Open a batch (ADR-0006 Amendment 1): take one boundary
|
||||||
|
/// snapshot for the whole batch and suppress per-command
|
||||||
|
/// snapshots until `EndBatch`. Used by `replay` so a multi-command
|
||||||
|
/// replay is a single undo step. `source` is the batch command.
|
||||||
|
BeginBatch {
|
||||||
|
source: Option<String>,
|
||||||
|
reply: oneshot::Sender<()>,
|
||||||
|
},
|
||||||
|
/// Close a batch: finalise the boundary snapshot into the ring if
|
||||||
|
/// any mutation committed during the batch, else discard it.
|
||||||
|
EndBatch {
|
||||||
|
reply: oneshot::Sender<()>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
@@ -693,7 +728,7 @@ impl Database {
|
|||||||
/// are skipped — useful for unit tests that exercise the
|
/// are skipped — useful for unit tests that exercise the
|
||||||
/// SQLite layer in isolation.
|
/// SQLite layer in isolation.
|
||||||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, DbError> {
|
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, DbError> {
|
||||||
Self::open_inner(path, None)
|
Self::open_inner(path, None, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a database with per-command persistence wired in
|
/// Open a database with per-command persistence wired in
|
||||||
@@ -705,12 +740,24 @@ impl Database {
|
|||||||
path: P,
|
path: P,
|
||||||
persistence: Persistence,
|
persistence: Persistence,
|
||||||
) -> Result<Self, DbError> {
|
) -> Result<Self, DbError> {
|
||||||
Self::open_inner(path, Some(persistence))
|
Self::open_inner(path, Some(persistence), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open with per-command persistence *and* the undo/snapshot ring
|
||||||
|
/// (ADR-0006 Amendment 1). `undo_enabled` is `false` under the
|
||||||
|
/// `--no-undo` CLI flag, in which case no snapshots are taken.
|
||||||
|
pub fn open_with_persistence_and_undo<P: AsRef<Path>>(
|
||||||
|
path: P,
|
||||||
|
persistence: Persistence,
|
||||||
|
undo_enabled: bool,
|
||||||
|
) -> Result<Self, DbError> {
|
||||||
|
Self::open_inner(path, Some(persistence), undo_enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_inner<P: AsRef<Path>>(
|
fn open_inner<P: AsRef<Path>>(
|
||||||
path: P,
|
path: P,
|
||||||
persistence: Option<Persistence>,
|
persistence: Option<Persistence>,
|
||||||
|
undo_enabled: bool,
|
||||||
) -> Result<Self, DbError> {
|
) -> Result<Self, DbError> {
|
||||||
let path_display = path.as_ref().to_string_lossy().into_owned();
|
let path_display = path.as_ref().to_string_lossy().into_owned();
|
||||||
let conn = match path.as_ref().to_str() {
|
let conn = match path.as_ref().to_str() {
|
||||||
@@ -721,10 +768,27 @@ impl Database {
|
|||||||
info!(path = %path_display, "opened database");
|
info!(path = %path_display, "opened database");
|
||||||
configure_connection(&conn).map_err(DbError::from_rusqlite)?;
|
configure_connection(&conn).map_err(DbError::from_rusqlite)?;
|
||||||
|
|
||||||
|
// The undo ring needs the project directory; it is only
|
||||||
|
// available when persistence is wired and undo is enabled.
|
||||||
|
let snapshots = if undo_enabled {
|
||||||
|
persistence
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| SnapshotStore::new(p.project_path(), DEFAULT_RING_CAPACITY))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(store) = &snapshots {
|
||||||
|
// Sweep crash leftovers (`.staging/`, orphan payloads).
|
||||||
|
if let Err(e) = store.cleanup() {
|
||||||
|
warn!(error = %e, "undo snapshot cleanup on open failed");
|
||||||
|
}
|
||||||
|
info!("undo snapshots enabled");
|
||||||
|
}
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel::<Request>(REQUEST_CHANNEL_CAPACITY);
|
let (tx, rx) = mpsc::channel::<Request>(REQUEST_CHANNEL_CAPACITY);
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("rdbms-db-worker".to_string())
|
.name("rdbms-db-worker".to_string())
|
||||||
.spawn(move || worker_loop(conn, persistence, rx))
|
.spawn(move || worker_loop(conn, persistence, snapshots, rx))
|
||||||
.map_err(|e| DbError::Io(e.to_string()))?;
|
.map_err(|e| DbError::Io(e.to_string()))?;
|
||||||
|
|
||||||
Ok(Self { inbox: tx })
|
Ok(Self { inbox: tx })
|
||||||
@@ -1238,6 +1302,57 @@ impl Database {
|
|||||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restore the most recent undo snapshot (ADR-0006 Amendment 1).
|
||||||
|
/// `Ok(Some(meta))` reports the command that was undone;
|
||||||
|
/// `Ok(None)` means nothing to undo (or undo is disabled).
|
||||||
|
pub async fn undo(&self) -> Result<Option<SnapshotMeta>, DbError> {
|
||||||
|
let (reply, recv) = oneshot::channel();
|
||||||
|
self.send(Request::Undo { reply }).await?;
|
||||||
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-apply the most recently undone snapshot. `Ok(None)` means
|
||||||
|
/// nothing to redo.
|
||||||
|
pub async fn redo(&self) -> Result<Option<SnapshotMeta>, DbError> {
|
||||||
|
let (reply, recv) = oneshot::channel();
|
||||||
|
self.send(Request::Redo { reply }).await?;
|
||||||
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata of the snapshot `undo` would restore, without
|
||||||
|
/// restoring it — for the confirmation prompt.
|
||||||
|
pub async fn peek_undo(&self) -> Result<Option<SnapshotMeta>, DbError> {
|
||||||
|
let (reply, recv) = oneshot::channel();
|
||||||
|
self.send(Request::PeekUndo { reply }).await?;
|
||||||
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata of the snapshot `redo` would restore.
|
||||||
|
pub async fn peek_redo(&self) -> Result<Option<SnapshotMeta>, DbError> {
|
||||||
|
let (reply, recv) = oneshot::channel();
|
||||||
|
self.send(Request::PeekRedo { reply }).await?;
|
||||||
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a batch so a multi-command operation (`replay`, future
|
||||||
|
/// batch commands) records a single undo step (ADR-0006
|
||||||
|
/// Amendment 1). Pair with [`Database::end_batch`]. `source` is
|
||||||
|
/// the batch command text recorded on the boundary snapshot.
|
||||||
|
pub async fn begin_batch(&self, source: Option<String>) -> Result<(), DbError> {
|
||||||
|
let (reply, recv) = oneshot::channel();
|
||||||
|
self.send(Request::BeginBatch { source, reply }).await?;
|
||||||
|
recv.await.map_err(|_| DbError::WorkerGone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a batch opened with [`Database::begin_batch`]. The
|
||||||
|
/// boundary snapshot is kept iff a mutation committed during the
|
||||||
|
/// batch.
|
||||||
|
pub async fn end_batch(&self) -> Result<(), DbError> {
|
||||||
|
let (reply, recv) = oneshot::channel();
|
||||||
|
self.send(Request::EndBatch { reply }).await?;
|
||||||
|
recv.await.map_err(|_| DbError::WorkerGone)
|
||||||
|
}
|
||||||
|
|
||||||
async fn send(&self, req: Request) -> Result<(), DbError> {
|
async fn send(&self, req: Request) -> Result<(), DbError> {
|
||||||
self.inbox.send(req).await.map_err(|_| DbError::WorkerGone)
|
self.inbox.send(req).await.map_err(|_| DbError::WorkerGone)
|
||||||
}
|
}
|
||||||
@@ -1325,16 +1440,192 @@ fn iso8601_now() -> String {
|
|||||||
fn worker_loop(
|
fn worker_loop(
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
persistence: Option<Persistence>,
|
persistence: Option<Persistence>,
|
||||||
|
snapshots: Option<SnapshotStore>,
|
||||||
mut rx: mpsc::Receiver<Request>,
|
mut rx: mpsc::Receiver<Request>,
|
||||||
) {
|
) {
|
||||||
debug!("db worker started");
|
debug!("db worker started");
|
||||||
|
// `conn` must be mutable: restoring a snapshot (undo/redo) writes
|
||||||
|
// into the live connection via the backup API (`&mut`).
|
||||||
|
let mut conn = conn;
|
||||||
|
let snap = snapshots.as_ref();
|
||||||
|
let mut batch = BatchState::default();
|
||||||
while let Some(req) = rx.blocking_recv() {
|
while let Some(req) = rx.blocking_recv() {
|
||||||
handle_request(&conn, persistence.as_ref(), req);
|
// Undo/redo/peek/batch are handled here: undo/redo need
|
||||||
|
// `&mut conn` for the restore, and batch state lives across
|
||||||
|
// requests. Everything else goes to `handle_request`, which
|
||||||
|
// brackets mutations with a pre-op snapshot.
|
||||||
|
match req {
|
||||||
|
Request::Undo { reply } => {
|
||||||
|
let _ = reply.send(do_undo(snap, &mut conn));
|
||||||
|
}
|
||||||
|
Request::Redo { reply } => {
|
||||||
|
let _ = reply.send(do_redo(snap, &mut conn));
|
||||||
|
}
|
||||||
|
Request::PeekUndo { reply } => {
|
||||||
|
let _ = reply.send(peek_undo_op(snap));
|
||||||
|
}
|
||||||
|
Request::PeekRedo { reply } => {
|
||||||
|
let _ = reply.send(peek_redo_op(snap));
|
||||||
|
}
|
||||||
|
Request::BeginBatch { source, reply } => {
|
||||||
|
begin_batch(snap, &conn, &mut batch, source.as_deref());
|
||||||
|
let _ = reply.send(());
|
||||||
|
}
|
||||||
|
Request::EndBatch { reply } => {
|
||||||
|
end_batch(snap, &mut batch);
|
||||||
|
let _ = reply.send(());
|
||||||
|
}
|
||||||
|
other => handle_request(&conn, persistence.as_ref(), snap, &mut batch, other),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
debug!("db worker exiting");
|
debug!("db worker exiting");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Request) {
|
/// Worker-side undo bracketing state for the request stream.
|
||||||
|
/// `active` is set between `BeginBatch`/`EndBatch` so per-command
|
||||||
|
/// snapshots are suppressed in favour of one boundary snapshot for
|
||||||
|
/// the whole batch (ADR-0006 Amendment 1).
|
||||||
|
#[derive(Default)]
|
||||||
|
struct BatchState {
|
||||||
|
active: bool,
|
||||||
|
dirty: bool,
|
||||||
|
staged: Option<Staged>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_to_db_error(e: SnapshotError) -> DbError {
|
||||||
|
DbError::Io(e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage a pre-mutation snapshot, never failing the user's command if
|
||||||
|
/// the snapshot itself can't be taken (the real persistence is the
|
||||||
|
/// durable state — the snapshot is a best-effort safety net). Returns
|
||||||
|
/// `None` when undo is off, the command has no user `source` (an
|
||||||
|
/// internal op, e.g. open-time rebuild — not undoable), or staging
|
||||||
|
/// failed.
|
||||||
|
fn stage_pre_mutation(
|
||||||
|
snap: Option<&SnapshotStore>,
|
||||||
|
conn: &Connection,
|
||||||
|
source: Option<&str>,
|
||||||
|
) -> Option<Staged> {
|
||||||
|
let store = snap?;
|
||||||
|
let src = source?;
|
||||||
|
match store.stage(conn, src) {
|
||||||
|
Ok(staged) => Some(staged),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "could not stage undo snapshot; command proceeds without undo");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a mutating handler with undo bracketing: stage before, then
|
||||||
|
/// finalise on success / discard on failure — or, inside a batch,
|
||||||
|
/// just mark the batch dirty so its single boundary snapshot is kept.
|
||||||
|
/// The command result is always sent; snapshot bookkeeping never
|
||||||
|
/// fails the user's actual work.
|
||||||
|
fn snapshot_then<T>(
|
||||||
|
snap: Option<&SnapshotStore>,
|
||||||
|
batch: &mut BatchState,
|
||||||
|
conn: &Connection,
|
||||||
|
source: Option<&str>,
|
||||||
|
reply: oneshot::Sender<Result<T, DbError>>,
|
||||||
|
run: impl FnOnce() -> Result<T, DbError>,
|
||||||
|
) {
|
||||||
|
let staged = if batch.active {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
stage_pre_mutation(snap, conn, source)
|
||||||
|
};
|
||||||
|
let result = run();
|
||||||
|
let committed = result.is_ok();
|
||||||
|
if batch.active {
|
||||||
|
if committed {
|
||||||
|
batch.dirty = true;
|
||||||
|
}
|
||||||
|
} else if let (Some(store), Some(st)) = (snap, staged) {
|
||||||
|
let outcome = if committed {
|
||||||
|
store.finalize(st).map(|_| ())
|
||||||
|
} else {
|
||||||
|
store.discard(st)
|
||||||
|
};
|
||||||
|
if let Err(e) = outcome {
|
||||||
|
warn!(error = %e, "undo snapshot bookkeeping failed (command already applied)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a batch: one boundary snapshot, then suppress per-command
|
||||||
|
/// snapshots until `end_batch`.
|
||||||
|
fn begin_batch(
|
||||||
|
snap: Option<&SnapshotStore>,
|
||||||
|
conn: &Connection,
|
||||||
|
batch: &mut BatchState,
|
||||||
|
source: Option<&str>,
|
||||||
|
) {
|
||||||
|
if batch.active {
|
||||||
|
warn!("BeginBatch while a batch is active; ignoring (no nested batches)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
batch.staged = stage_pre_mutation(snap, conn, source);
|
||||||
|
batch.active = true;
|
||||||
|
batch.dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a batch: keep the boundary snapshot iff a mutation committed
|
||||||
|
/// during it, else discard it (an all-skips batch leaves no undo step).
|
||||||
|
fn end_batch(snap: Option<&SnapshotStore>, batch: &mut BatchState) {
|
||||||
|
if !batch.active {
|
||||||
|
warn!("EndBatch with no active batch; ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let (Some(store), Some(st)) = (snap, batch.staged.take()) {
|
||||||
|
let outcome = if batch.dirty {
|
||||||
|
store.finalize(st).map(|_| ())
|
||||||
|
} else {
|
||||||
|
store.discard(st)
|
||||||
|
};
|
||||||
|
if let Err(e) = outcome {
|
||||||
|
warn!(error = %e, "batch undo snapshot bookkeeping failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batch.active = false;
|
||||||
|
batch.dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_undo(
|
||||||
|
snap: Option<&SnapshotStore>,
|
||||||
|
conn: &mut Connection,
|
||||||
|
) -> Result<Option<SnapshotMeta>, DbError> {
|
||||||
|
snap.map_or(Ok(None), |store| {
|
||||||
|
store.undo(conn).map_err(snapshot_to_db_error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_redo(
|
||||||
|
snap: Option<&SnapshotStore>,
|
||||||
|
conn: &mut Connection,
|
||||||
|
) -> Result<Option<SnapshotMeta>, DbError> {
|
||||||
|
snap.map_or(Ok(None), |store| {
|
||||||
|
store.redo(conn).map_err(snapshot_to_db_error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek_undo_op(snap: Option<&SnapshotStore>) -> Result<Option<SnapshotMeta>, DbError> {
|
||||||
|
snap.map_or(Ok(None), |s| s.peek_undo().map_err(snapshot_to_db_error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek_redo_op(snap: Option<&SnapshotStore>) -> Result<Option<SnapshotMeta>, DbError> {
|
||||||
|
snap.map_or(Ok(None), |s| s.peek_redo().map_err(snapshot_to_db_error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_request(
|
||||||
|
conn: &Connection,
|
||||||
|
persistence: Option<&Persistence>,
|
||||||
|
snap: Option<&SnapshotStore>,
|
||||||
|
batch: &mut BatchState,
|
||||||
|
req: Request,
|
||||||
|
) {
|
||||||
match req {
|
match req {
|
||||||
Request::CreateTable {
|
Request::CreateTable {
|
||||||
name,
|
name,
|
||||||
@@ -1343,7 +1634,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_create_table(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_create_table(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1357,7 +1648,9 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_drop_table(conn, persistence, source.as_deref(), &name));
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||||
|
do_drop_table(conn, persistence, source.as_deref(), &name)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Request::AddColumn {
|
Request::AddColumn {
|
||||||
table,
|
table,
|
||||||
@@ -1365,7 +1658,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_add_column(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_column(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1380,7 +1673,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_drop_column(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_column(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1396,7 +1689,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_rename_column(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_column(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1413,7 +1706,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_change_column_type(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_change_column_type(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1450,7 +1743,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_add_relationship(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_relationship(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1469,7 +1762,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_drop_relationship(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_relationship(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1483,7 +1776,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_add_index(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_index(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1497,7 +1790,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_drop_index(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_index(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1511,7 +1804,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_add_constraint(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_constraint(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1527,7 +1820,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_drop_constraint(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_constraint(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1543,7 +1836,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_insert(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_insert(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1559,7 +1852,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_update(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_update(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1574,7 +1867,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_delete(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_delete(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1615,7 +1908,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
returning,
|
returning,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_sql_insert(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_insert(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1633,7 +1926,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
returning,
|
returning,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_sql_update(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_update(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1649,7 +1942,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
returning,
|
returning,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_sql_delete(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_delete(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1663,7 +1956,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
let _ = reply.send(do_rebuild_from_text(
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rebuild_from_text(
|
||||||
conn,
|
conn,
|
||||||
persistence,
|
persistence,
|
||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
@@ -1691,6 +1984,18 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
let result = do_list_names_for(conn, source);
|
let result = do_list_names_for(conn, source);
|
||||||
let _ = reply.send(result);
|
let _ = reply.send(result);
|
||||||
}
|
}
|
||||||
|
// Undo/redo/peek/batch are intercepted in `worker_loop` (they
|
||||||
|
// need `&mut conn` or persistent batch state) and never reach
|
||||||
|
// here. Listed explicitly so a new variant still forces a
|
||||||
|
// decision at compile time.
|
||||||
|
Request::Undo { .. }
|
||||||
|
| Request::Redo { .. }
|
||||||
|
| Request::PeekUndo { .. }
|
||||||
|
| Request::PeekRedo { .. }
|
||||||
|
| Request::BeginBatch { .. }
|
||||||
|
| Request::EndBatch { .. } => {
|
||||||
|
unreachable!("undo/redo/peek/batch are handled in worker_loop")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
//! Tier-3 integration tests for the undo/snapshot ring wired into
|
||||||
|
//! the db worker (ADR-0006 Amendment 1, §8 step 3).
|
||||||
|
//!
|
||||||
|
//! These drive the real `Database` worker: a mutation takes a
|
||||||
|
//! pre-op snapshot, `undo` restores it through the live connection,
|
||||||
|
//! `redo` re-applies, a batch collapses to a single undo step, and
|
||||||
|
//! `--no-undo` (undo disabled) takes no snapshots at all.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use rdbms_playground::db::Database;
|
||||||
|
use rdbms_playground::dsl::{ColumnSpec, RowFilter, Type, Value};
|
||||||
|
use rdbms_playground::persistence::Persistence;
|
||||||
|
use rdbms_playground::project;
|
||||||
|
|
||||||
|
fn tempdir() -> tempfile::TempDir {
|
||||||
|
tempfile::tempdir().expect("create tempdir")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rt() -> tokio::runtime::Runtime {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("tokio rt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a fresh temp project with undo enabled (or not).
|
||||||
|
fn open_project(
|
||||||
|
data: &tempfile::TempDir,
|
||||||
|
undo_enabled: bool,
|
||||||
|
) -> (project::Project, Database, std::path::PathBuf) {
|
||||||
|
let project = project::open_or_create(None, Some(data.path())).expect("open project");
|
||||||
|
let path = project.path().to_path_buf();
|
||||||
|
let persistence = Persistence::new(path.clone());
|
||||||
|
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo_enabled)
|
||||||
|
.expect("open db");
|
||||||
|
(project, db, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn make_customers(db: &Database) {
|
||||||
|
db.create_table(
|
||||||
|
"Customers".to_string(),
|
||||||
|
vec![
|
||||||
|
ColumnSpec::new("id".to_string(), Type::Serial),
|
||||||
|
ColumnSpec::new("Name".to_string(), Type::Text),
|
||||||
|
],
|
||||||
|
vec!["id".to_string()],
|
||||||
|
Some("create table Customers with pk id(serial)".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_named(db: &Database, name: &str) {
|
||||||
|
db.insert(
|
||||||
|
"Customers".to_string(),
|
||||||
|
None,
|
||||||
|
vec![Value::Text(name.to_string())],
|
||||||
|
Some(format!("insert into Customers ('{name}')")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn row_count(db: &Database) -> usize {
|
||||||
|
db.query_data("Customers".to_string(), None, None, None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.rows
|
||||||
|
.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshots_dir(path: &Path) -> std::path::PathBuf {
|
||||||
|
path.join(".snapshots")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mutation_snapshots_and_undo_restores_through_the_worker() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (_p, db, path) = open_project(&data, true);
|
||||||
|
|
||||||
|
rt().block_on(async {
|
||||||
|
make_customers(&db).await;
|
||||||
|
insert_named(&db, "Alice").await;
|
||||||
|
insert_named(&db, "Bob").await;
|
||||||
|
assert_eq!(row_count(&db).await, 2);
|
||||||
|
|
||||||
|
// Destructive op: delete Bob (id = 2).
|
||||||
|
db.delete(
|
||||||
|
"Customers".to_string(),
|
||||||
|
RowFilter::eq("id", Value::Number("2".to_string())),
|
||||||
|
Some("delete from Customers where id = 2".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(row_count(&db).await, 1);
|
||||||
|
|
||||||
|
// The pending undo names the delete.
|
||||||
|
let peek = db.peek_undo().await.unwrap().expect("an undo entry");
|
||||||
|
assert_eq!(peek.command, "delete from Customers where id = 2");
|
||||||
|
|
||||||
|
// Undo restores Bob.
|
||||||
|
let undone = db.undo().await.unwrap().expect("undo applied");
|
||||||
|
assert_eq!(undone.command, "delete from Customers where id = 2");
|
||||||
|
assert_eq!(row_count(&db).await, 2, "Bob restored by undo");
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(snapshots_dir(&path).exists(), "snapshots dir created");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redo_reapplies_the_undone_command() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (_p, db, _path) = open_project(&data, true);
|
||||||
|
|
||||||
|
rt().block_on(async {
|
||||||
|
make_customers(&db).await;
|
||||||
|
insert_named(&db, "Alice").await;
|
||||||
|
insert_named(&db, "Bob").await;
|
||||||
|
db.delete(
|
||||||
|
"Customers".to_string(),
|
||||||
|
RowFilter::eq("id", Value::Number("2".to_string())),
|
||||||
|
Some("delete from Customers where id = 2".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(row_count(&db).await, 1);
|
||||||
|
|
||||||
|
db.undo().await.unwrap();
|
||||||
|
assert_eq!(row_count(&db).await, 2);
|
||||||
|
|
||||||
|
let redone = db.redo().await.unwrap().expect("redo applied");
|
||||||
|
assert_eq!(redone.command, "delete from Customers where id = 2");
|
||||||
|
assert_eq!(row_count(&db).await, 1, "delete re-applied by redo");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_work_after_undo_clears_redo() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (_p, db, _path) = open_project(&data, true);
|
||||||
|
|
||||||
|
rt().block_on(async {
|
||||||
|
make_customers(&db).await;
|
||||||
|
insert_named(&db, "Alice").await;
|
||||||
|
db.delete(
|
||||||
|
"Customers".to_string(),
|
||||||
|
RowFilter::AllRows,
|
||||||
|
Some("delete from Customers --all-rows".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
db.undo().await.unwrap();
|
||||||
|
assert!(db.peek_redo().await.unwrap().is_some(), "redo available");
|
||||||
|
|
||||||
|
// New destructive work.
|
||||||
|
insert_named(&db, "Carol").await;
|
||||||
|
assert!(
|
||||||
|
db.peek_redo().await.unwrap().is_none(),
|
||||||
|
"new work discards the redo stack"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undo_disabled_takes_no_snapshots() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (_p, db, path) = open_project(&data, false);
|
||||||
|
|
||||||
|
rt().block_on(async {
|
||||||
|
make_customers(&db).await;
|
||||||
|
insert_named(&db, "Alice").await;
|
||||||
|
db.delete(
|
||||||
|
"Customers".to_string(),
|
||||||
|
RowFilter::AllRows,
|
||||||
|
Some("delete from Customers --all-rows".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Nothing to undo, and no snapshot machinery on disk.
|
||||||
|
assert!(db.undo().await.unwrap().is_none(), "undo is a no-op when disabled");
|
||||||
|
assert!(db.peek_undo().await.unwrap().is_none());
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!snapshots_dir(&path).exists(),
|
||||||
|
"no .snapshots dir when undo is disabled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_records_a_single_undo_step() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (_p, db, _path) = open_project(&data, true);
|
||||||
|
|
||||||
|
rt().block_on(async {
|
||||||
|
make_customers(&db).await; // one undo entry (the create)
|
||||||
|
|
||||||
|
// A batch of three inserts → one boundary snapshot.
|
||||||
|
db.begin_batch(Some("replay history.log".to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
insert_named(&db, "Alice").await;
|
||||||
|
insert_named(&db, "Bob").await;
|
||||||
|
insert_named(&db, "Carol").await;
|
||||||
|
db.end_batch().await.unwrap();
|
||||||
|
assert_eq!(row_count(&db).await, 3);
|
||||||
|
|
||||||
|
// The single batch undo names the batch command.
|
||||||
|
let peek = db.peek_undo().await.unwrap().expect("batch undo entry");
|
||||||
|
assert_eq!(peek.command, "replay history.log");
|
||||||
|
|
||||||
|
// One undo rolls the whole batch back to the pre-batch state
|
||||||
|
// (table exists, no rows).
|
||||||
|
db.undo().await.unwrap();
|
||||||
|
assert_eq!(row_count(&db).await, 0, "whole batch undone in one step");
|
||||||
|
|
||||||
|
// The next undo is the create_table (table gone).
|
||||||
|
let next = db.peek_undo().await.unwrap().expect("create entry");
|
||||||
|
assert_eq!(next.command, "create table Customers with pk id(serial)");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_undo_and_redo_are_no_ops() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (_p, db, _path) = open_project(&data, true);
|
||||||
|
|
||||||
|
rt().block_on(async {
|
||||||
|
assert!(db.undo().await.unwrap().is_none());
|
||||||
|
assert!(db.redo().await.unwrap().is_none());
|
||||||
|
assert!(db.peek_undo().await.unwrap().is_none());
|
||||||
|
assert!(db.peek_redo().await.unwrap().is_none());
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user