cqlite_core/storage/write_engine/mod.rs
1//! Write engine for SSTable generation and persistence (M5)
2//!
3//! This module provides the write path for CQLite, implementing WAL-backed
4//! memtable flushing and K-way merge for producing valid Cassandra 5.0 SSTables.
5//!
6//! ## Architecture
7//!
8//! The WriteEngine is the public API that coordinates:
9//! 1. WAL (Write-Ahead Log) - Durability
10//! 2. Memtable - In-memory buffer
11//! 3. SSTableWriter - On-disk persistence
12//!
13//! ## Write Flow
14//!
15//! 1. User calls `write(mutation)` or `execute(cql_statement)`
16//! 2. WriteEngine appends to WAL (durability)
17//! 3. WriteEngine inserts into Memtable
18//! 4. When Memtable exceeds threshold → flush to SSTable
19//! 5. After successful flush → truncate WAL
20//!
21//! ## Recovery
22//!
23//! On startup, the WriteEngine replays WAL entries into the memtable.
24
25#[cfg(feature = "write-support")]
26pub mod cql_to_mutation;
27#[cfg(feature = "write-support")]
28pub mod export;
29#[cfg(feature = "write-support")]
30pub mod memtable;
31#[cfg(feature = "write-support")]
32pub mod merge;
33#[cfg(feature = "write-support")]
34pub mod merge_policy;
35#[cfg(feature = "write-support")]
36pub mod mutation;
37#[cfg(feature = "write-support")]
38pub(crate) mod reconcile_rules;
39#[cfg(feature = "write-support")]
40pub mod wal;
41
42#[cfg(feature = "write-support")]
43pub use export::{ExportOptions, ExportReport};
44#[cfg(feature = "write-support")]
45pub use memtable::Memtable;
46#[cfg(feature = "write-support")]
47pub use merge::KWayMerger;
48#[cfg(feature = "write-support")]
49pub use merge_policy::STCSPolicy;
50#[cfg(feature = "write-support")]
51pub use mutation::{
52 CellOperation, ClusteringBound, ClusteringKey, DecoratedKey, Mutation, PartitionKey,
53 PartitionTombstone, RangeTombstone, TableId,
54};
55#[cfg(feature = "write-support")]
56pub use wal::{RecoveryReport, WriteAheadLog};
57
58#[cfg(feature = "write-support")]
59mod compaction;
60#[cfg(feature = "write-support")]
61pub(crate) mod durability;
62#[cfg(feature = "write-support")]
63mod maintenance;
64#[cfg(feature = "write-support")]
65mod stats;
66#[cfg(feature = "write-support")]
67mod sweep;
68#[cfg(all(test, feature = "write-support"))]
69mod test_support;
70// Issue #1625: honest memtable hard-limit admission tests live in a sibling
71// module to avoid growing the already-oversized `mod.rs` (epic #1116/#1135).
72#[cfg(all(test, feature = "write-support"))]
73mod admission_tests;
74
75#[cfg(feature = "write-support")]
76pub use maintenance::MaintenanceReport;
77#[cfg(feature = "write-support")]
78pub use stats::CompactionStats;
79
80use crate::error::{Error, Result};
81use crate::schema::{TableSchema, UdtRegistry};
82use crate::storage::sstable::writer::SSTableInfo;
83#[cfg(feature = "write-support")]
84use maintenance::ActiveMerge;
85use std::path::{Path, PathBuf};
86use std::sync::atomic::{AtomicBool, Ordering};
87use std::time::Instant;
88
89/// Trait for merge policy implementations (M5.2, Issue #383)
90///
91/// A merge policy decides which SSTables should be compacted together.
92/// This trait allows different compaction strategies (STCS, LCS, TWCS, etc.)
93/// to be plugged into the WriteEngine.
94#[cfg(feature = "write-support")]
95pub trait MergePolicy: Send + std::fmt::Debug {
96 /// Select SSTables for the next compaction
97 ///
98 /// # Arguments
99 ///
100 /// * `candidates` - Available SSTable paths in the data directory
101 ///
102 /// # Returns
103 ///
104 /// Paths to SSTables that should be merged, ordered newest to oldest.
105 /// Returns empty Vec if no compaction is needed.
106 fn select_merge(&self, candidates: &[PathBuf]) -> Result<Vec<PathBuf>>;
107}
108
109/// WAL durability mode for the write engine.
110///
111/// Controls whether `write` and `write_async` append to and fsync the
112/// write-ahead log on every call. The default (`SyncEachWrite`) matches the
113/// pre-existing behavior and is the **only safe choice for production
114/// workloads** — a process crash between a successful `write` and a later
115/// `flush` will lose mutations written with `Disabled`.
116///
117/// ## When to use `Disabled`
118///
119/// - **Bulk-load / import pipelines** where the source data is replayable and
120/// you are willing to re-run the load on failure.
121/// - **Benchmarking** where you want to isolate CPU-bound write throughput from
122/// fsync latency. The companion `write/ingest_wal_off` Criterion bench uses
123/// this variant (see `cqlite-core/benches/write.rs`).
124///
125/// In both cases, call [`WriteEngine::flush`] (and, optionally,
126/// [`WriteEngine::close`]) when the load is finished so the data is durably
127/// persisted to SSTables.
128///
129/// ## WAL replay on restart
130///
131/// When `Disabled`, no WAL entries are written. Reopening the engine on the
132/// same `wal_dir` after a crash will replay **zero** mutations, even if
133/// `flush` was never called. If you need crash-safe recovery, use
134/// `SyncEachWrite`.
135///
136/// # Example
137///
138/// ```rust,ignore
139/// use cqlite_core::storage::write_engine::{Durability, WriteEngineConfig};
140///
141/// // Production (default)
142/// let config = WriteEngineConfig::new(data, wal, schema);
143///
144/// // Bulk-load / benchmarking
145/// let config = WriteEngineConfig::new(data, wal, schema)
146/// .with_durability(Durability::Disabled);
147/// ```
148#[cfg(feature = "write-support")]
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum Durability {
151 /// Append to the WAL and call `fsync` on every `write` / `write_async`
152 /// call. A successful return guarantees the mutation is durable on disk.
153 ///
154 /// This is the **default** and the safe choice for all production
155 /// workloads.
156 #[default]
157 SyncEachWrite,
158
159 /// Skip WAL append **and** fsync on every `write` / `write_async` call.
160 /// Mutations are buffered in the memtable only. Data is durable only
161 /// after a successful [`WriteEngine::flush`].
162 ///
163 /// **Use only for bulk-load pipelines and benchmarks where durability can
164 /// be traded for throughput.**
165 Disabled,
166}
167
168/// Write engine configuration
169#[cfg(feature = "write-support")]
170#[derive(Debug, Clone)]
171pub struct WriteEngineConfig {
172 /// Directory for SSTable data files
173 pub data_dir: PathBuf,
174 /// Directory for WAL files
175 pub wal_dir: PathBuf,
176 /// Memtable flush threshold in bytes (default: 64MB)
177 pub memtable_flush_threshold: usize,
178 /// Memtable hard limit in bytes (default: 256MB)
179 /// When this limit is reached, writes will fail with an error
180 pub memtable_hard_limit: usize,
181 /// Table schema for column metadata
182 pub schema: TableSchema,
183 /// WAL durability mode (default: [`Durability::SyncEachWrite`])
184 pub durability: Durability,
185 /// Optional UDT registry for resolving bare CQL UDT column types to their
186 /// `UserType(...)` marshal form at flush time (issue #929). When `None`
187 /// (the default), a column whose `data_type` is a bare UDT name is written
188 /// as a single simple cell (documented fallback).
189 pub udt_registry: Option<UdtRegistry>,
190 /// Whether the engine installs a default STCS compaction policy so that
191 /// [`WriteEngine::maintenance_step`] performs size-tiered compaction
192 /// (issue #1619). Defaults to `true` (compaction on). Set to `false` to
193 /// disable compaction entirely — `maintenance_step` then becomes a no-op.
194 pub auto_compaction: bool,
195 /// Minimum number of SSTables in a size bucket required to trigger a
196 /// compaction (STCS `min_threshold`). Defaults to `4`. Ignored when
197 /// [`WriteEngineConfig::auto_compaction`] is `false`.
198 pub compaction_min_threshold: usize,
199 /// Maximum number of SSTables compacted together in one step (STCS
200 /// `max_threshold`). Defaults to `32`. Ignored when
201 /// [`WriteEngineConfig::auto_compaction`] is `false`.
202 pub compaction_max_threshold: usize,
203}
204
205#[cfg(feature = "write-support")]
206impl WriteEngineConfig {
207 /// Default flush threshold (64 MB)
208 pub const DEFAULT_FLUSH_THRESHOLD: usize = 64 * 1024 * 1024;
209 /// Default hard limit (256 MB)
210 pub const DEFAULT_HARD_LIMIT: usize = 256 * 1024 * 1024;
211 /// Default STCS `min_threshold` (issue #1619)
212 pub const DEFAULT_COMPACTION_MIN_THRESHOLD: usize = 4;
213 /// Default STCS `max_threshold` (issue #1619)
214 pub const DEFAULT_COMPACTION_MAX_THRESHOLD: usize = 32;
215
216 /// Create a new configuration with default flush threshold
217 pub fn new(data_dir: PathBuf, wal_dir: PathBuf, schema: TableSchema) -> Self {
218 Self {
219 data_dir,
220 wal_dir,
221 memtable_flush_threshold: Self::DEFAULT_FLUSH_THRESHOLD,
222 memtable_hard_limit: Self::DEFAULT_HARD_LIMIT,
223 schema,
224 durability: Durability::default(),
225 udt_registry: None,
226 auto_compaction: true,
227 compaction_min_threshold: Self::DEFAULT_COMPACTION_MIN_THRESHOLD,
228 compaction_max_threshold: Self::DEFAULT_COMPACTION_MAX_THRESHOLD,
229 }
230 }
231
232 /// Attach a [`UdtRegistry`] used to resolve bare CQL UDT column types at
233 /// flush time (issue #929). See [`WriteEngineConfig::udt_registry`].
234 pub fn with_udt_registry(mut self, registry: UdtRegistry) -> Self {
235 self.udt_registry = Some(registry);
236 self
237 }
238
239 /// Set a custom flush threshold
240 pub fn with_flush_threshold(mut self, threshold: usize) -> Self {
241 self.memtable_flush_threshold = threshold;
242 self
243 }
244
245 /// Set a custom hard limit
246 pub fn with_hard_limit(mut self, limit: usize) -> Self {
247 self.memtable_hard_limit = limit;
248 self
249 }
250
251 /// Set the WAL durability mode.
252 ///
253 /// Mirrors `with_flush_threshold` in style. See [`Durability`] for the
254 /// trade-offs between [`Durability::SyncEachWrite`] (default, production)
255 /// and [`Durability::Disabled`] (bulk-load / benchmarking).
256 ///
257 /// # Example
258 ///
259 /// ```rust,ignore
260 /// use cqlite_core::storage::write_engine::{Durability, WriteEngineConfig};
261 ///
262 /// let config = WriteEngineConfig::new(data, wal, schema)
263 /// .with_durability(Durability::Disabled);
264 /// ```
265 pub fn with_durability(mut self, durability: Durability) -> Self {
266 self.durability = durability;
267 self
268 }
269
270 /// Apply a [`crate::config::CompactionConfig`] onto this write-engine
271 /// configuration (issue #1619). This makes `Config.storage.compaction`
272 /// authoritative for the write path: `auto_compaction = false` disables the
273 /// default STCS policy so [`WriteEngine::maintenance_step`] becomes a no-op.
274 ///
275 /// The compaction thresholds (`compaction_min_threshold` /
276 /// `compaction_max_threshold`) keep their defaults; only the on/off switch
277 /// is surfaced through the public `Config` today.
278 pub fn with_compaction_config(mut self, compaction: &crate::config::CompactionConfig) -> Self {
279 self.auto_compaction = compaction.auto_compaction;
280 self
281 }
282}
283
284/// Write engine coordinator
285///
286/// Orchestrates WAL, memtable, and SSTable flushing for write operations.
287/// This is the primary public API for all write operations in CQLite.
288///
289/// ## Durability contract: you MUST call [`close`](WriteEngine::close)
290///
291/// **`Drop` is not a flush.** Rows written with [`write`](WriteEngine::write) /
292/// [`execute`](WriteEngine::execute) live in the in-memory memtable (and the
293/// WAL) until a flush turns them into an SSTable. Only
294/// [`close`](WriteEngine::close) (or an explicit flush) guarantees the memtable
295/// is persisted to a Data.db. Because Tokio has no async drop, `Drop` CANNOT
296/// flush — doing so would require a `block_on` inside `drop`, which is
297/// forbidden (issue #1693/AG3). An engine dropped with a non-empty memtable
298/// logs a `warn!` and leaves those rows recoverable only via WAL replay on the
299/// next startup.
300///
301/// Embedders (and every long-lived writer) MUST therefore call
302/// `engine.close().await` for a graceful shutdown — e.g. from a `SIGINT`
303/// handler — before the process exits.
304///
305/// ## Thread Safety
306///
307/// WriteEngine follows a single-writer model. It is NOT thread-safe and
308/// should be used from a single thread or protected by external locking.
309/// The `closed` flag uses atomic operations for safe concurrent access checking.
310///
311/// ## Example
312///
313/// ```rust,ignore
314/// use cqlite_core::storage::write_engine::{WriteEngine, WriteEngineConfig, Mutation};
315/// use std::path::PathBuf;
316///
317/// // Create configuration
318/// let config = WriteEngineConfig::new(
319/// PathBuf::from("data"),
320/// PathBuf::from("wal"),
321/// schema
322/// );
323///
324/// // Create engine
325/// let mut engine = WriteEngine::new(config)?;
326///
327/// // Write a mutation
328/// engine.write(mutation)?;
329///
330/// // Execute CQL statement
331/// engine.execute("INSERT INTO users (id, name) VALUES (1, 'Alice')")?;
332///
333/// // Flush to SSTable
334/// engine.flush()?;
335///
336/// // Close cleanly
337/// engine.close()?;
338/// ```
339#[cfg(feature = "write-support")]
340#[derive(Debug)]
341pub struct WriteEngine {
342 /// Configuration
343 config: WriteEngineConfig,
344 /// Write-ahead log for durability
345 wal: WriteAheadLog,
346 /// In-memory write buffer
347 memtable: Memtable,
348 /// Summary of the WAL crash-recovery replay performed in `new()` (issue
349 /// #1391). Its `mutations` are drained into the memtable, leaving only the
350 /// lossiness metadata (`corrupt_entries`, `stopped_early`, `bytes_skipped`).
351 /// Exposed via [`WriteEngine::wal_recovery`] so a caller can detect that a
352 /// recovery was lossy BEFORE the next flush truncates the WAL. A non-clean
353 /// recovery also preserves the raw WAL segment aside (see `new()`).
354 wal_recovery: RecoveryReport,
355 /// SSTable generation counter (increments on each flush)
356 generation: u64,
357 /// Whether the engine has been closed (atomic for thread safety)
358 closed: AtomicBool,
359 /// Active merge state for incremental compaction (M5.2)
360 active_merge: Option<ActiveMerge>,
361 /// Merge policy for compaction decisions (M5.2)
362 merge_policy: Option<Box<dyn MergePolicy>>,
363 /// Cumulative compaction statistics (M5.2, Issue #474)
364 cumulative_stats: CompactionStats,
365 /// Cumulative count of rows written since the engine was opened (Issue #486).
366 ///
367 /// Incremented for each row that enters the memtable via `write()` or
368 /// `write_async()`. Unlike `memtable.row_count()`, this counter is NOT
369 /// reset when the memtable is flushed, so it reflects the total number of
370 /// rows written across the lifetime of the session.
371 rows_written: u64,
372 /// Number of L0 SSTable files successfully flushed since the engine was
373 /// opened (Issue #486).
374 ///
375 /// Incremented once per successful `flush_internal_async()` call that
376 /// produces a non-empty SSTable. This is an in-process counter; it is
377 /// reset to zero when the engine is re-opened (a directory scan could
378 /// also be used for persistence, but the counter is more robust and avoids
379 /// scanning the filesystem on every stats query).
380 l0_count: u64,
381 /// Cumulative bytes written to flushed L0 SSTables (Data.db + all sibling
382 /// components) since the engine was opened (issue #1620). Incremented once
383 /// per successful `flush_internal_async()` by the flushed SSTable's
384 /// `data_size`. In-process only; reset to zero on re-open. Read via
385 /// [`WriteEngine::total_flushed_bytes`] so binding stats stay accurate for
386 /// automatic flushes (not just explicit `flush()` calls).
387 total_flushed_bytes: u64,
388 /// Advisory exclusive lock on `write_dir` (`.lock` file).
389 ///
390 /// Held for the lifetime of the `WriteEngine`; released on `close()` or
391 /// `Drop`. The lock is acquired in `new()` via
392 /// `fs2::FileExt::try_lock_exclusive`, which returns an error immediately
393 /// if another process already holds it (fail-fast, no blocking).
394 ///
395 /// The lock prevents two `Database` / `WriteEngine` instances from sharing
396 /// the same `write_dir`, which would corrupt WAL files and SSTables
397 /// (Issue #485).
398 dir_lock: std::fs::File,
399 /// Whether the "memtable over flush threshold" warning has already been
400 /// emitted for the current threshold crossing (issue #1620).
401 ///
402 /// The sync `write()` path does NOT auto-flush when a Tokio runtime is
403 /// present, so once the memtable crosses the flush threshold it stays over
404 /// it until an explicit (or async) flush. Without this guard the
405 /// over-threshold `log::warn!` fired on EVERY subsequent write (log spam).
406 /// It is set to `true` the first time the warning is emitted and reset to
407 /// `false` on the next successful flush, so the warning fires at most once
408 /// per threshold crossing.
409 warned_over_threshold: bool,
410}
411
412/// Reject any mutation that contains a counter cell write.
413///
414/// Counter columns require server-side distributed increment semantics and
415/// cannot be expressed as a last-write-wins mutation. Both the sync
416/// `write()` and the async `write_async()` paths call this guard immediately
417/// after the closed-check.
418#[cfg(feature = "write-support")]
419fn reject_counter_cells(mutation: &Mutation) -> Result<()> {
420 for op in &mutation.operations {
421 match op {
422 CellOperation::Write { value, .. } | CellOperation::WriteWithTtl { value, .. } => {
423 if matches!(value, crate::types::Value::Counter(_)) {
424 return Err(Error::invalid_operation(
425 "counter writes are not supported via the standard mutation path; \
426 counter columns require server-side distributed increment semantics",
427 ));
428 }
429 }
430 _ => {}
431 }
432 }
433 Ok(())
434}
435
436#[cfg(feature = "write-support")]
437impl WriteEngine {
438 /// Create `dir` (and any missing ancestors), then fsync the FULL parent
439 /// chain from `dir`'s parent walking UP to and including the filesystem
440 /// root — unconditionally, on every call (issue #1392).
441 ///
442 /// We deliberately do NOT track "what this invocation created." Across a
443 /// crash that is unknowable: a prior startup may have created the whole
444 /// nested `data_dir`/`wal_dir` tree but crashed partway through the
445 /// parent-fsync sequence, leaving higher ancestors' dirents unpersisted. A
446 /// retry that inspected "already exists" and re-fsynced only the immediate
447 /// parent would leave those ancestors un-durable forever — the "which
448 /// ancestor is durable on retry" hole.
449 ///
450 /// Instead we fsync EVERY ancestor unconditionally. Fsyncing an
451 /// already-durable directory is idempotent and cheap, and the walk
452 /// terminates at the filesystem root (`Path::parent()` returns `None`), so
453 /// no ancestor level is ever left unsynced and there is no per-attempt
454 /// state to track. Every ancestor of a successfully-created directory
455 /// exists, so each fsync targets a real, present directory. Together with
456 /// the flush-path barrier (which fsyncs leaf → data_root on every publish),
457 /// this makes the full durability chain persistent on first flush AND on
458 /// any retry after a partial crash.
459 fn create_dir_all_durable(dir: &Path, label: &str) -> Result<()> {
460 std::fs::create_dir_all(dir).map_err(|e| {
461 Error::Storage(format!(
462 "Failed to create {} directory {:?}: {}",
463 label, dir, e
464 ))
465 })?;
466
467 // Ascend the parent chain, fsyncing each ancestor's dirent. The walk
468 // strictly shrinks (one component per step) and ends when `parent()`
469 // yields `None` at the filesystem root, so it always terminates.
470 let mut next = dir.parent();
471 while let Some(cur) = next {
472 if cur.as_os_str().is_empty() {
473 // A relative single-component path (e.g. "data") has an empty
474 // parent whose real directory is the current working
475 // directory. Fsync "." and stop: it has no ancestor to ascend.
476 wal::sync_directory(Path::new("."))?;
477 break;
478 }
479 wal::sync_directory(cur)?;
480 next = cur.parent();
481 }
482
483 Ok(())
484 }
485
486 /// Create a new write engine
487 ///
488 /// This initializes the WAL and memtable. If a WAL exists in the
489 /// wal_dir, it will be replayed to recover in-flight writes.
490 ///
491 /// # Arguments
492 ///
493 /// * `config` - Write engine configuration
494 ///
495 /// # Returns
496 ///
497 /// A new WriteEngine ready to accept writes.
498 ///
499 /// # Errors
500 ///
501 /// Returns an error if:
502 /// - WAL directory doesn't exist
503 /// - Data directory doesn't exist
504 /// - WAL replay fails
505 pub fn new(config: WriteEngineConfig) -> Result<Self> {
506 // Ensure directories exist AND that EVERY ancestor's own dirent is
507 // durable. `create_dir_all` alone persists the entries *inside* a
508 // freshly-created root once we fsync the root, but NOT the dirents of
509 // the newly-created directories themselves in their parents — and it
510 // may create several missing ancestors at once. Fsyncing only the
511 // immediate parent would leave intermediate dirents unpersisted, so a
512 // crash after WAL truncation could still lose the whole data/WAL root.
513 // `create_dir_all_durable` fsyncs the FULL parent chain up to and
514 // including the filesystem root, unconditionally on every call — this
515 // closes the "which ancestor is durable on a partial-crash retry" hole
516 // and completes the durability chain (SSTable component contents →
517 // leaf → ancestors → data_root → every ancestor up to root). #1392.
518 Self::create_dir_all_durable(&config.data_dir, "data")?;
519 Self::create_dir_all_durable(&config.wal_dir, "WAL")?;
520
521 // Acquire an exclusive advisory lock on the WAL directory to prevent
522 // two WriteEngine / Database instances from sharing the same write_dir
523 // and silently corrupting WAL files or SSTables (Issue #485).
524 //
525 // We use `try_lock_exclusive` (non-blocking) so that callers get an
526 // immediate, actionable error rather than hanging forever.
527 let lock_path = config.wal_dir.join(".lock");
528 let dir_lock = std::fs::OpenOptions::new()
529 .create(true)
530 .truncate(false)
531 .write(true)
532 .open(&lock_path)
533 .map_err(|e| {
534 Error::Storage(format!("Failed to create lock file {:?}: {}", lock_path, e))
535 })?;
536 fs2::FileExt::try_lock_exclusive(&dir_lock)
537 .map_err(|_| Error::write_dir_locked(config.wal_dir.to_string_lossy().into_owned()))?;
538
539 // Startup sweep: remove orphaned compaction artifacts left by a previous crash.
540 //
541 // Two kinds of orphans can be left if the process crashes mid-rename in
542 // `finalize_merge_async`:
543 //
544 // (a) A `.compaction-tmp-{gen}/` directory under `data_dir` with partial
545 // component files.
546 //
547 // (b) A partial set of renamed components in `data_dir/{keyspace}/{table}/`
548 // — specifically one or more `nb-{gen}-big-*.db` files without a
549 // matching `TOC.txt`. Because `scan_data_files` discovers SSTables by
550 // `nb-*-big-Data.db` glob, an orphaned Data.db without TOC.txt will be
551 // picked up by the merge policy and fed to `KWayMerger`, which may
552 // produce garbled output.
553 //
554 // Both sweeps are best-effort: individual failures are logged as warnings but
555 // do not abort engine startup.
556 Self::sweep_startup_orphans(&config);
557
558 // Initialize WAL
559 let wal_path = config.wal_dir.join(WriteAheadLog::WAL_FILENAME);
560 let mut wal = if wal_path.exists() {
561 // Recover from existing WAL
562 WriteAheadLog::open_existing(&wal_path)?
563 } else {
564 // Create new WAL
565 WriteAheadLog::create(&config.wal_dir)?
566 };
567
568 // Replay WAL into memtable. The report distinguishes a clean recovery
569 // from a lossy one (issue #1391); a bare Vec cannot.
570 //
571 // Stream each recovered mutation straight into the memtable via
572 // `replay_each` (issue #1661) instead of materialising a whole-log
573 // `Vec<Mutation>` and then copying it in: peak memory is bounded by the
574 // memtable itself, not ~2× it. `recovered` is an authoritative count of
575 // mutations actually recovered (used only for the lossy-recovery log,
576 // preserving its prior `mutations.len()` meaning); the completion log
577 // counts from the memtable. The report still carries all corruption
578 // metadata — mutations stream via the closure, so its `mutations` vec
579 // stays empty.
580 let mut memtable = Memtable::new();
581 let mut recovered = 0usize;
582 // DEFER the first memtable-application error instead of aborting the scan
583 // (issue #1661, roborev). The pre-streaming flow scanned the ENTIRE WAL
584 // first (`replay()`), ran the lossy-recovery preserve/reset, and only THEN
585 // applied mutations — so a schema/key-conversion or insert failure on an
586 // earlier valid mutation could never prevent a LATER corrupt tail from
587 // being detected, preserved aside, and reset. If the streaming callback
588 // returned that error via `?`, replay would stop early and skip
589 // preserve/reset, silently changing the #1390/#1391 recovery semantics
590 // this refactor must preserve. So we record the FIRST apply error, stop
591 // applying (the engine will not be constructed), but keep scanning for the
592 // full RecoveryReport; the error is surfaced only after preserve/reset
593 // below. The streaming memory win is unaffected — no whole-log Vec.
594 let mut apply_error: Option<Error> = None;
595 let wal_recovery = wal.replay_each(|mutation| {
596 if apply_error.is_some() {
597 // An earlier mutation already failed; keep scanning to reach any
598 // later corruption but skip further application.
599 return Ok(());
600 }
601 match mutation.decorated_key(&config.schema) {
602 Ok(decorated_key) => match memtable.insert_with_key(decorated_key, mutation) {
603 Ok(()) => recovered += 1,
604 Err(e) => apply_error = Some(e),
605 },
606 Err(e) => apply_error = Some(e),
607 }
608 Ok(())
609 })?;
610
611 if !wal_recovery.is_clean() {
612 // Preserve the raw WAL segment aside BEFORE anything (a later flush,
613 // or the reset below) can truncate it, so the corruption evidence
614 // survives for manual recovery. The report is also retained on the
615 // engine and exposed via `wal_recovery()` so a caller sees the loss
616 // pre-truncate.
617 let preserved = Self::preserve_corrupt_wal(&wal_path)?;
618
619 // With the evidence safely aside, trim the LIVE WAL back to its last
620 // CRC-valid prefix (issue #1391). Otherwise the engine would remain
621 // writable with the corrupt tail still on disk, and the FIRST synced
622 // write after this lossy recovery would be appended AFTER the corrupt
623 // entry — where the next replay stops — so that acknowledged write
624 // would be silently lost. Resetting to the valid prefix makes
625 // post-recovery appends land at a replayable position. (A torn tail
626 // was already trimmed in `open_existing`, so this only fires for
627 // mid-stream corruption.)
628 let reset_to = wal.reset_to_valid_prefix()?;
629
630 log::error!(
631 "WAL recovery at {:?} was LOSSY: recovered {} mutation(s), {} corrupt entry \
632 (entries), stopped_early={}, {} byte(s) not recovered. Raw segment preserved at \
633 {:?}; live WAL reset to valid prefix ({:?}). Investigate before relying on this \
634 data.",
635 wal_path,
636 recovered,
637 wal_recovery.corrupt_entries,
638 wal_recovery.stopped_early,
639 wal_recovery.bytes_skipped,
640 preserved,
641 reset_to,
642 );
643 }
644
645 // Surface any deferred memtable-application error now — AFTER the
646 // lossy-recovery preserve/reset above has run (issue #1661, roborev).
647 // This restores the pre-streaming ordering: on-disk WAL corruption is
648 // preserved aside and the live WAL is reset to its valid prefix before
649 // engine construction fails on the apply error.
650 if let Some(e) = apply_error {
651 return Err(e);
652 }
653
654 if recovered > 0 {
655 log::info!(
656 "WAL replay complete: replayed {} mutation(s); {} rows in memtable, {} bytes",
657 recovered,
658 memtable.row_count(),
659 memtable.size_bytes()
660 );
661 }
662
663 // Determine next generation number by scanning data directory
664 let generation = Self::determine_next_generation(&config.data_dir)?;
665
666 // Install the default STCS compaction policy so `maintenance_step`
667 // performs size-tiered compaction out of the box (issue #1619). The
668 // off-switch is `WriteEngineConfig::auto_compaction = false`, which
669 // leaves the policy unset and makes `maintenance_step` a no-op. Read the
670 // config values into locals BEFORE the struct-init moves `config`.
671 let merge_policy: Option<Box<dyn MergePolicy>> = if config.auto_compaction {
672 Some(Box::new(STCSPolicy::new(
673 config.compaction_min_threshold,
674 config.compaction_max_threshold,
675 0.5,
676 1.5,
677 STCSPolicy::DEFAULT_MIN_SSTABLE_SIZE,
678 )?))
679 } else {
680 None
681 };
682
683 Ok(Self {
684 config,
685 wal,
686 memtable,
687 wal_recovery,
688 generation,
689 closed: AtomicBool::new(false),
690 active_merge: None,
691 merge_policy,
692 cumulative_stats: CompactionStats::default(),
693 rows_written: 0,
694 l0_count: 0,
695 total_flushed_bytes: 0,
696 dir_lock,
697 warned_over_threshold: false,
698 })
699 }
700
701 /// Summary of the WAL crash-recovery replay performed when this engine was
702 /// opened (issue #1391).
703 ///
704 /// The `mutations` field has been drained into the memtable, so only the
705 /// lossiness metadata remains. Use [`RecoveryReport::is_clean`] to detect a
706 /// lossy recovery. A non-clean report means the raw WAL segment was
707 /// preserved aside (as `commitlog.wal.corrupt.<nanos>`) so a subsequent
708 /// flush cannot destroy the evidence.
709 pub fn wal_recovery(&self) -> &RecoveryReport {
710 &self.wal_recovery
711 }
712
713 /// Copy the raw WAL file aside before any truncation can destroy it, so a
714 /// lossy recovery leaves forensic evidence (issue #1391). Returns the path
715 /// of the preserved copy.
716 ///
717 /// The copy is a sibling `commitlog.wal.corrupt.<unix_nanos>`; the original
718 /// is left untouched (a later flush truncates it, but the aside copy — and
719 /// the retained [`RecoveryReport`] — survive).
720 fn preserve_corrupt_wal(wal_path: &Path) -> Result<PathBuf> {
721 let nanos = std::time::SystemTime::now()
722 .duration_since(std::time::UNIX_EPOCH)
723 .map(|d| d.as_nanos())
724 .unwrap_or(0);
725 let file_name = wal_path
726 .file_name()
727 .map(|n| n.to_string_lossy().into_owned())
728 .unwrap_or_else(|| WriteAheadLog::WAL_FILENAME.to_string());
729 let preserved = match wal_path.parent() {
730 Some(parent) => parent.join(format!("{}.corrupt.{}", file_name, nanos)),
731 None => PathBuf::from(format!("{}.corrupt.{}", file_name, nanos)),
732 };
733 std::fs::copy(wal_path, &preserved).map_err(|e| {
734 Error::Storage(format!(
735 "Failed to preserve corrupt WAL {:?} aside to {:?}: {}",
736 wal_path, preserved, e
737 ))
738 })?;
739
740 // Make the forensic copy durable BEFORE the caller resets/truncates the
741 // live WAL (issue #1391). `std::fs::copy` does not fsync; without this a
742 // crash after the copy but before it reaches disk could leave the live
743 // WAL already trimmed while the preserved copy is missing/incomplete —
744 // destroying the very evidence we set aside. Order: copy → fsync copy →
745 // fsync parent dir → (caller) reset live WAL.
746 {
747 let copy_file = std::fs::File::open(&preserved).map_err(|e| {
748 Error::Storage(format!(
749 "Failed to open preserved corrupt WAL {:?} for fsync: {}",
750 preserved, e
751 ))
752 })?;
753 copy_file.sync_all().map_err(|e| {
754 Error::Storage(format!(
755 "Failed to fsync preserved corrupt WAL {:?}: {}",
756 preserved, e
757 ))
758 })?;
759 }
760 if let Some(parent) = preserved.parent() {
761 wal::sync_directory(parent)?;
762 }
763
764 Ok(preserved)
765 }
766
767 /// Write a mutation to the write engine
768 ///
769 /// This appends the mutation to the WAL for durability, then inserts it
770 /// into the memtable. If the memtable exceeds the flush threshold,
771 /// an automatic flush is triggered.
772 ///
773 /// **Note**: Automatic flush is disabled when called from an async context.
774 /// Use `write_async()` for async contexts with automatic flush support.
775 ///
776 /// # Arguments
777 ///
778 /// * `mutation` - The mutation to write
779 ///
780 /// # Returns
781 ///
782 /// Ok(()) on success, or an error if the write fails.
783 ///
784 /// # Errors
785 ///
786 /// Returns an error if:
787 /// - Engine has been closed
788 /// - WAL append fails
789 /// - Memtable insert fails
790 /// - Automatic flush fails (sync context only)
791 #[tracing::instrument(name = "write.mutation", skip(self, mutation))]
792 pub fn write(&mut self, mutation: Mutation) -> Result<()> {
793 crate::observability::record_result("write", self.write_inner(mutation))
794 }
795
796 fn write_inner(&mut self, mutation: Mutation) -> Result<()> {
797 // Insert into WAL + memtable, then handle sync-context auto-flush.
798 self.write_into_memtable(mutation)?;
799
800 // Check if memtable should be flushed (sync callers only). The binding
801 // write path runs inside a Tokio runtime and does NOT reach here — it
802 // uses `execute_flushing`, which owns its own real async flush and
803 // deliberately skips the "call flush() manually" warning below.
804 if self
805 .memtable
806 .should_flush(self.config.memtable_flush_threshold)
807 {
808 // Rate-limit the over-threshold warning (issue #1620). A sync caller
809 // that lives inside a runtime never auto-flushes below, so the
810 // memtable stays over threshold across many writes; without this
811 // guard the warning fired on EVERY such write. Emit it at most once
812 // per crossing; it is reset on the next flush.
813 if !self.warned_over_threshold {
814 log::warn!(
815 "Memtable size {} exceeds threshold {} - call flush() manually in async context",
816 self.memtable.size_bytes(),
817 self.config.memtable_flush_threshold
818 );
819 self.warned_over_threshold = true;
820 }
821
822 // Try to flush synchronously only if we're not in an async context.
823 if tokio::runtime::Handle::try_current().is_err() {
824 log::info!("Triggering automatic flush");
825 self.flush_internal()?;
826 }
827 }
828
829 Ok(())
830 }
831
832 /// Insert a mutation into the WAL + memtable WITHOUT any auto-flush handling.
833 ///
834 /// Shared by the sync [`write_inner`] path (which then performs the
835 /// sync-context warn + flush) and the async [`execute_flushing`] path (which
836 /// owns its own real async flush and must NOT emit the sync "call flush()
837 /// manually in async context" warning) (issue #1620, roborev job 2879).
838 fn write_into_memtable(&mut self, mutation: Mutation) -> Result<()> {
839 if self.closed.load(Ordering::SeqCst) {
840 return Err(Error::InvalidInput(
841 "WriteEngine has been closed".to_string(),
842 ));
843 }
844
845 reject_counter_cells(&mutation)?;
846
847 // Honest hard-limit admission: gate on current size + the INCOMING
848 // mutation's estimated size, and reject a lone jumbo write (issue #1625).
849 self.check_admission(&mutation)?;
850
851 // 1. Append to WAL (durability) — skipped when Durability::Disabled
852 if self.config.durability == Durability::SyncEachWrite {
853 self.wal.append(&mutation)?;
854 self.wal.sync()?;
855 }
856
857 // 2. Compute decorated key from partition key
858 let decorated_key = mutation.decorated_key(&self.config.schema)?;
859
860 // 3. Insert into memtable
861 self.memtable.insert_with_key(decorated_key, mutation)?;
862
863 // 4. Increment the cumulative rows-written counter (Issue #486).
864 // Done after a successful insert so that failed writes are not counted.
865 self.rows_written += 1;
866 crate::observability::add_counter(crate::observability::catalog::WRITE_MUTATIONS, 1, &[]);
867 self.record_memtable_gauges();
868
869 Ok(())
870 }
871
872 /// Honest memtable hard-limit admission (issue #1625).
873 ///
874 /// The pre-#1625 check compared only the *current* memtable size against the
875 /// hard limit — it never measured the incoming mutation, so (a) a single
876 /// jumbo mutation could be admitted into an empty memtable and blow straight
877 /// past the limit, and (b) a mutation could push a nearly-full memtable over
878 /// the limit before the next write was rejected. This gate measures the
879 /// incoming mutation with the SAME estimator the memtable uses for
880 /// accounting, then:
881 ///
882 /// 1. Rejects any single mutation whose estimate alone exceeds the hard
883 /// limit (the ceiling for one mutation is the hard limit itself) with a
884 /// distinct error, so a lone jumbo write can never be admitted.
885 /// 2. Rejects the write when `current_size + incoming` would exceed the hard
886 /// limit (`saturating_add`, so the sum can never overflow `usize`).
887 fn check_admission(&self, mutation: &Mutation) -> Result<()> {
888 let hard_limit = self.config.memtable_hard_limit;
889 let incoming = self.memtable.estimate_mutation_size(mutation);
890
891 // (0) Fail-closed sentinel: the estimator returns `usize::MAX` when a
892 // mutation is pathological/unmeasurable (node-cap hit). Reject it
893 // EXPLICITLY and unconditionally — a `>` comparison against a
894 // configurable `hard_limit == usize::MAX` would otherwise be false and
895 // admit the very mutation the sentinel is meant to fence off.
896 if incoming == usize::MAX {
897 return Err(Error::Storage(
898 "Write rejected: mutation size could not be bounded (estimator \
899 fail-closed sentinel); refusing admission"
900 .to_string(),
901 ));
902 }
903
904 // (1) Single-mutation ceiling: one mutation may not exceed the hard
905 // limit on its own, regardless of how empty the memtable is.
906 if incoming > hard_limit {
907 return Err(Error::Storage(format!(
908 "Write rejected: single mutation estimated at {incoming} bytes exceeds \
909 memtable hard limit {hard_limit} bytes (a single mutation may not exceed \
910 the hard limit)"
911 )));
912 }
913
914 // (2) Projected sum: never admit a write that would push the memtable
915 // over the hard limit.
916 let projected = self.memtable.size_bytes().saturating_add(incoming);
917 if projected > hard_limit {
918 return Err(Error::Storage(format!(
919 "Write rejected: memtable {} + mutation {incoming} would exceed hard limit {hard_limit}",
920 self.memtable.size_bytes()
921 )));
922 }
923
924 Ok(())
925 }
926
927 /// Write a mutation with async automatic flush support
928 ///
929 /// This is the async version of `write()` that supports automatic flushing
930 /// in async contexts. Use this method when calling from async code.
931 ///
932 /// # Arguments
933 ///
934 /// * `mutation` - The mutation to write
935 ///
936 /// # Returns
937 ///
938 /// Ok(()) on success, or an error if the write fails.
939 ///
940 /// # Errors
941 ///
942 /// Returns an error if:
943 /// - Engine has been closed
944 /// - WAL append fails
945 /// - Memtable insert fails
946 /// - Automatic flush fails
947 #[tracing::instrument(name = "write.mutation", skip(self, mutation))]
948 pub async fn write_async(&mut self, mutation: Mutation) -> Result<()> {
949 crate::observability::record_result("write", self.write_async_inner(mutation).await)
950 }
951
952 async fn write_async_inner(&mut self, mutation: Mutation) -> Result<()> {
953 if self.closed.load(Ordering::SeqCst) {
954 return Err(Error::InvalidInput(
955 "WriteEngine has been closed".to_string(),
956 ));
957 }
958
959 reject_counter_cells(&mutation)?;
960
961 // Honest hard-limit admission: gate on current size + the INCOMING
962 // mutation's estimated size, and reject a lone jumbo write (issue #1625).
963 self.check_admission(&mutation)?;
964
965 // 1. Append to WAL (durability) — skipped when Durability::Disabled
966 if self.config.durability == Durability::SyncEachWrite {
967 self.wal.append(&mutation)?;
968 self.wal.sync()?;
969 }
970
971 // 2. Compute decorated key from partition key
972 let decorated_key = mutation.decorated_key(&self.config.schema)?;
973
974 // 3. Insert into memtable
975 self.memtable.insert_with_key(decorated_key, mutation)?;
976
977 // 4. Increment the cumulative rows-written counter (Issue #486).
978 // Done after a successful insert so that failed writes are not counted.
979 self.rows_written += 1;
980 crate::observability::add_counter(crate::observability::catalog::WRITE_MUTATIONS, 1, &[]);
981 self.record_memtable_gauges();
982
983 // 5. Check if memtable should be flushed
984 if self
985 .memtable
986 .should_flush(self.config.memtable_flush_threshold)
987 {
988 log::info!(
989 "Memtable size {} exceeds threshold {}, triggering flush",
990 self.memtable.size_bytes(),
991 self.config.memtable_flush_threshold
992 );
993 self.flush_internal_async().await?;
994 }
995
996 Ok(())
997 }
998
999 /// Emit the current memtable size/row gauges (issue #1036). No-op when the
1000 /// `observability` feature is off.
1001 fn record_memtable_gauges(&self) {
1002 crate::observability::record_gauge(
1003 crate::observability::catalog::MEMTABLE_SIZE_BYTES,
1004 self.memtable.size_bytes() as i64,
1005 &[],
1006 );
1007 crate::observability::record_gauge(
1008 crate::observability::catalog::MEMTABLE_ROWS,
1009 self.memtable.row_count() as i64,
1010 &[],
1011 );
1012 }
1013
1014 /// Execute a CQL statement (INSERT, UPDATE, DELETE)
1015 ///
1016 /// This parses the CQL statement and converts it to a mutation,
1017 /// then writes it using the `write()` method.
1018 ///
1019 /// # Arguments
1020 ///
1021 /// * `statement` - CQL statement string
1022 ///
1023 /// # Returns
1024 ///
1025 /// Ok(()) on success, or an error if parsing or writing fails.
1026 ///
1027 /// # Errors
1028 ///
1029 /// Returns an error if:
1030 /// - CQL parsing fails
1031 /// - Statement is not a mutation (INSERT/UPDATE/DELETE)
1032 /// - Mutation conversion fails
1033 /// - Write fails
1034 ///
1035 /// # Example
1036 ///
1037 /// ```rust,ignore
1038 /// engine.execute("INSERT INTO users (id, name) VALUES (1, 'Alice')")?;
1039 /// engine.execute("UPDATE users SET name = 'Bob' WHERE id = 1")?;
1040 /// engine.execute("DELETE FROM users WHERE id = 1")?;
1041 /// ```
1042 #[tracing::instrument(name = "write.cql_execute", skip(self, statement))]
1043 pub fn execute(&mut self, statement: &str) -> Result<()> {
1044 crate::observability::record_result("write", self.execute_inner(statement))
1045 }
1046
1047 fn execute_inner(&mut self, statement: &str) -> Result<()> {
1048 // The public sync `execute` signature is unchanged: delegate to the
1049 // counted implementation and discard the mutation count (issue #1620).
1050 self.execute_inner_counted(statement).map(|_| ())
1051 }
1052
1053 /// Parse + write a CQL statement into the memtable, returning the number of
1054 /// mutations applied (N for a BATCH, else 1).
1055 ///
1056 /// This is the shared core of the sync `execute` path and the async
1057 /// `execute_flushing` path (issue #1620). It is *unrecorded* (no
1058 /// `record_result`); callers record at the public boundary.
1059 fn execute_inner_counted(&mut self, statement: &str) -> Result<u64> {
1060 if self.closed.load(Ordering::SeqCst) {
1061 return Err(Error::InvalidInput(
1062 "WriteEngine has been closed".to_string(),
1063 ));
1064 }
1065
1066 // Single-boundary error recording (issue #1036): call the *unrecorded*
1067 // `write_inner` here rather than the public `write`. `execute` already
1068 // wraps this method in `record_result`, so the escaping error is counted
1069 // exactly once at the public boundary instead of twice.
1070 let mutations = self.parse_statement_to_mutations(statement)?;
1071 let n = mutations.len() as u64;
1072 for mutation in mutations {
1073 self.write_inner(mutation)?;
1074 }
1075 Ok(n)
1076 }
1077
1078 /// Parse a CQL DML statement into its mutation(s). A `BEGIN BATCH` yields one
1079 /// mutation per statement in the batch; any other DML yields exactly one.
1080 ///
1081 /// Shared by the sync `execute` path ([`execute_inner_counted`]) and the
1082 /// async [`execute_flushing`] path so both agree on batch semantics
1083 /// (issue #1620).
1084 fn parse_statement_to_mutations(&self, statement: &str) -> Result<Vec<Mutation>> {
1085 let trimmed = statement.trim();
1086 if trimmed.len() >= 5 && trimmed.as_bytes()[..5].eq_ignore_ascii_case(b"BEGIN") {
1087 cql_to_mutation::convert_cql_to_mutations(trimmed, &self.config.schema)
1088 } else {
1089 Ok(vec![self.parse_cql_to_mutation(statement)?])
1090 }
1091 }
1092
1093 /// Execute a DML statement and, if the memtable has crossed the flush
1094 /// threshold, await a REAL async flush (issue #1620, DECIDED: write_async).
1095 ///
1096 /// This is the entry point for the Node/Python binding write path, which runs
1097 /// inside a Tokio runtime where the sync auto-flush in `write()` is
1098 /// intentionally skipped. It restores auto-flush there WITHOUT the surprise
1099 /// inline-flush latency the plain sync `write()`/`execute()` path avoids.
1100 /// Returns the number of mutations applied (N for BATCH, else 1).
1101 #[tracing::instrument(name = "write.cql_execute_flushing", skip(self, statement))]
1102 pub async fn execute_flushing(&mut self, statement: &str) -> Result<u64> {
1103 // Single-boundary error recording (issue #1036): `execute_flushing` is
1104 // the public Node/Python DML entry point, so — like `execute`,
1105 // `write_async`, and `flush` — it records the escaping result exactly
1106 // once here. The inner helper and everything it calls (`write_inner`,
1107 // `flush_internal_async`) are *unrecorded* to avoid double counting.
1108 crate::observability::record_result("write", self.execute_flushing_inner(statement).await)
1109 }
1110
1111 async fn execute_flushing_inner(&mut self, statement: &str) -> Result<u64> {
1112 if self.closed.load(Ordering::SeqCst) {
1113 return Err(Error::InvalidInput(
1114 "WriteEngine has been closed".to_string(),
1115 ));
1116 }
1117
1118 // Parse first, then write each mutation and flush as soon as the memtable
1119 // crosses the threshold. Checking after EVERY mutation (not just once at
1120 // the end) means a large `BEGIN BATCH` cannot accumulate all the way to
1121 // the hard limit before flushing and dead-ending (roborev job 2854,
1122 // issue #1620).
1123 let mutations = self.parse_statement_to_mutations(statement)?;
1124 let n = mutations.len() as u64;
1125 for mutation in mutations {
1126 // Use the warn-free memtable insert: this path owns the real async
1127 // flush below, so it must not emit the sync "call flush() manually"
1128 // warning (roborev job 2879, issue #1620).
1129 self.write_into_memtable(mutation)?;
1130 if self
1131 .memtable
1132 .should_flush(self.config.memtable_flush_threshold)
1133 {
1134 self.flush_internal_async().await?;
1135 }
1136 }
1137 Ok(n)
1138 }
1139
1140 /// Force a flush of the memtable to SSTable
1141 ///
1142 /// This writes all data in the memtable to a new SSTable generation,
1143 /// then truncates the WAL. The memtable is cleared after a successful flush.
1144 ///
1145 /// # Returns
1146 ///
1147 /// Returns `Some(SSTableInfo)` if data was flushed, or `None` if the
1148 /// memtable was empty.
1149 ///
1150 /// # Errors
1151 ///
1152 /// Returns an error if:
1153 /// - Engine has been closed
1154 /// - SSTable write fails
1155 /// - WAL truncate fails
1156 #[tracing::instrument(name = "flush.public", skip(self))]
1157 pub async fn flush(&mut self) -> Result<Option<SSTableInfo>> {
1158 // Single-boundary error recording (issue #1036): `flush` is a public API
1159 // entry point, so it records here. The nested `flush_internal_async`
1160 // helper is intentionally *unrecorded* to avoid double-counting when the
1161 // write/close paths trigger a flush internally.
1162 crate::observability::record_result(
1163 "write",
1164 async {
1165 if self.closed.load(Ordering::SeqCst) {
1166 return Err(Error::InvalidInput(
1167 "WriteEngine has been closed".to_string(),
1168 ));
1169 }
1170
1171 self.flush_internal_async().await
1172 }
1173 .await,
1174 )
1175 }
1176
1177 /// Internal synchronous flush helper.
1178 ///
1179 /// Bridges to the async flush via [`merge::block_on_async`], which is safe to
1180 /// call whether or not a Tokio runtime is already running on this thread
1181 /// (Issue #587).
1182 fn flush_internal(&mut self) -> Result<()> {
1183 merge::block_on_async(self.flush_internal_async())?;
1184 Ok(())
1185 }
1186
1187 /// Internal async flush implementation.
1188 ///
1189 /// This is an *unrecorded* inner helper (issue #1036): it never calls
1190 /// `record_error`/`record_result` itself. Error counting happens exactly once
1191 /// at the public boundary that invoked it (`flush`, `close`, or the
1192 /// `write`/`write_async` auto-flush path, all of which wrap their work in
1193 /// `record_result`).
1194 #[tracing::instrument(name = "flush.memtable", skip(self))]
1195 async fn flush_internal_async(&mut self) -> Result<Option<SSTableInfo>> {
1196 // Check if memtable is empty
1197 if self.memtable.is_empty() {
1198 return Ok(None);
1199 }
1200
1201 let flush_start = Instant::now();
1202 let rows_to_flush = self.memtable.row_count() as u64;
1203
1204 log::info!(
1205 "Flushing memtable: {} partitions, {} rows, {} bytes",
1206 self.memtable.iter().count(),
1207 self.memtable.row_count(),
1208 self.memtable.size_bytes()
1209 );
1210
1211 // Create SSTable writer with hint for Bloom filter sizing
1212 let partition_count_hint = self.memtable.iter().count();
1213 let mut writer =
1214 crate::storage::sstable::writer::SSTableWriter::with_expected_partitions_and_registry(
1215 self.config.data_dir.clone(),
1216 self.generation,
1217 &self.config.schema,
1218 partition_count_hint,
1219 self.config.udt_registry.as_ref(),
1220 )?;
1221
1222 // Two-pass flush (issue #729): compute FINAL encoding baselines from all
1223 // partitions before writing any data. Delta encoding in Data.db must use
1224 // the same baselines that will end up in Statistics.db's EncodingStats.
1225 // Without this, an early partition encoded against a higher baseline becomes
1226 // silently corrupted when a later partition lowers the minimum.
1227 let mut baseline_min_ts = i64::MAX;
1228 let mut baseline_min_ldt = i32::MAX;
1229 let mut baseline_min_ttl = i32::MAX;
1230 for (_, mutations) in self.memtable.iter() {
1231 let (ts, ldt, ttl) =
1232 crate::storage::sstable::writer::SSTableWriter::compute_mutations_baseline_stats(
1233 mutations,
1234 );
1235 baseline_min_ts = baseline_min_ts.min(ts);
1236 baseline_min_ldt = baseline_min_ldt.min(ldt);
1237 baseline_min_ttl = baseline_min_ttl.min(ttl);
1238 }
1239 writer.pre_seed_encoding_baselines(baseline_min_ts, baseline_min_ldt, baseline_min_ttl);
1240
1241 // Write all partitions from memtable (already in token order)
1242 for (decorated_key, mutations) in self.memtable.iter() {
1243 writer.write_partition(decorated_key.clone(), mutations.to_vec())?;
1244 }
1245
1246 // Finalize SSTable
1247 let info = writer.finish().await?;
1248
1249 log::info!(
1250 "SSTable flush complete: generation {}, {} partitions, {} bytes",
1251 self.generation,
1252 info.partition_count,
1253 info.data_size
1254 );
1255
1256 // Durability handoff (issue #1392): fsync the SSTable's parent directory
1257 // BEFORE truncating the WAL. See `durability::finalize_flush_durability`.
1258 //
1259 // A `?`-propagated error here (e.g. a directory fsync failure) happens
1260 // BEFORE the WAL is truncated, so the WAL is still intact and replayable:
1261 // returning early WITHOUT advancing the generation is correct, because a
1262 // retry re-flushes to the same generation and the untouched WAL still
1263 // recovers the data on a crash mid-retry.
1264 let durability_outcome = durability::finalize_flush_durability(
1265 &durability::RealDurabilityBarrier,
1266 &info.data_path,
1267 &self.config.data_dir,
1268 &mut self.wal,
1269 )?;
1270
1271 // The SSTable is now durably published. Commit flush state — clear the
1272 // memtable and advance the generation — for BOTH outcomes, including
1273 // `WalTruncateFailedAfterCommit`. In that case the WAL has already been
1274 // zeroed, so the published SSTable is the ONLY durable copy of these
1275 // mutations; committing state guarantees a retry writes a NEW generation
1276 // and never lets `File::create` overwrite the sole durable copy (which
1277 // would lose the data on a crash mid-retry). See issue #1392.
1278
1279 // Clear memtable
1280 self.memtable.clear();
1281
1282 // Reset the over-threshold warn guard (issue #1620): the memtable is now
1283 // empty, so the next threshold crossing is a fresh event that should warn
1284 // again (at most once).
1285 self.warned_over_threshold = false;
1286
1287 // Increment the L0 SSTable counter (Issue #486).
1288 self.l0_count += 1;
1289
1290 // Accumulate cumulative flushed bytes (issue #1620) so binding write
1291 // stats reflect automatic flushes, not only explicit `flush()` calls.
1292 self.total_flushed_bytes = self.total_flushed_bytes.saturating_add(info.data_size);
1293
1294 // Increment generation for next flush
1295 self.generation += 1;
1296
1297 // Flush metrics (issue #1036): latency in seconds, rows/bytes flushed,
1298 // and one L0 SSTable created. Emit the post-clear memtable gauges (now
1299 // zero) and the current compaction lag (L0 pending) gauge.
1300 {
1301 use crate::observability::{self as obs, catalog};
1302 obs::record_histogram(
1303 catalog::FLUSH_DURATION,
1304 flush_start.elapsed().as_secs_f64(),
1305 &[],
1306 );
1307 obs::add_counter(catalog::FLUSH_ROWS, rows_to_flush, &[]);
1308 obs::add_counter(catalog::FLUSH_BYTES, info.data_size, &[]);
1309 obs::add_counter(catalog::FLUSH_SSTABLES, 1, &[]);
1310 obs::record_gauge(catalog::COMPACTION_LAG, self.l0_count as i64, &[]);
1311 }
1312 self.record_memtable_gauges();
1313
1314 // Surface a post-mutation WAL-truncate failure AFTER state has been
1315 // committed above (issue #1392). The data is durable in the published
1316 // SSTable and the generation has advanced, so a retry cannot overwrite
1317 // it; the error informs the caller that the WAL is no longer a valid
1318 // replay marker.
1319 match durability_outcome {
1320 durability::FlushDurabilityOutcome::Durable => Ok(Some(info)),
1321 durability::FlushDurabilityOutcome::WalTruncateFailedAfterCommit(err) => Err(err),
1322 }
1323 }
1324
1325 /// Close the write engine
1326 ///
1327 /// This flushes any remaining data in the memtable to SSTable,
1328 /// syncs the WAL, then marks the engine as closed. After calling close(),
1329 /// the engine cannot be used for further writes.
1330 ///
1331 /// **This is the durability boundary.** `Drop` does not (and cannot — no
1332 /// async drop in Tokio) flush; callers MUST `close().await` before exit to
1333 /// guarantee written rows reach an SSTable rather than relying on WAL replay
1334 /// (issue #1693). See the type-level docs on [`WriteEngine`].
1335 ///
1336 /// This method is idempotent - calling it multiple times is safe.
1337 ///
1338 /// # Returns
1339 ///
1340 /// Ok(()) on success.
1341 ///
1342 /// # Errors
1343 ///
1344 /// Returns an error if the final flush fails.
1345 ///
1346 /// WAL-truncate handling during that flush is phase-aware (issue #1392):
1347 ///
1348 /// * A truncate failure that leaves the WAL intact (it faulted *before*
1349 /// mutating the WAL) is logged and swallowed — the WAL stays a valid,
1350 /// idempotent replay marker, so no error is surfaced.
1351 /// * A truncate failure *after* `set_len(0)` has already zeroed the WAL
1352 /// (`WalTruncateFailedAfterCommit`) is **propagated**. By then flush state
1353 /// has already been committed — the SSTable is durable and the generation
1354 /// has advanced — so the data is safe, but the error is surfaced so the
1355 /// caller knows the WAL is no longer a replay marker.
1356 ///
1357 /// When the WAL is already empty (e.g. `Durability::Disabled`) the truncate
1358 /// phase is skipped, so no truncate-phase error can arise.
1359 pub async fn close(&mut self) -> Result<()> {
1360 // Check if already closed (idempotent)
1361 if self.closed.swap(true, Ordering::SeqCst) {
1362 return Ok(());
1363 }
1364
1365 log::info!("Closing WriteEngine");
1366
1367 // Flush any remaining data
1368 if !self.memtable.is_empty() {
1369 log::info!("Flushing memtable before close");
1370
1371 // Attempt to flush to SSTable
1372 match self.flush_internal_async().await {
1373 Ok(_) => {
1374 log::info!("Memtable flushed successfully");
1375 }
1376 Err(e) => {
1377 // If flush fails, log error and return it
1378 log::error!("Failed to flush memtable during close: {}", e);
1379 // Single-boundary error recording (issue #1036): `close` is a
1380 // public entry point and `flush_internal_async` is unrecorded,
1381 // so record the escaping flush failure here, exactly once.
1382 crate::observability::record_error(&e, "write");
1383 // Reset closed flag since we failed to close cleanly
1384 self.closed.store(false, Ordering::SeqCst);
1385 return Err(e);
1386 }
1387 }
1388 }
1389
1390 // Sync WAL before closing
1391 if let Err(e) = self.wal.sync() {
1392 log::warn!("Failed to sync WAL during close: {}", e);
1393 // Don't fail close if sync fails - data is already persisted to SSTable
1394 }
1395
1396 // Release the exclusive advisory lock on write_dir so a subsequent
1397 // WriteEngine on the same directory can acquire it immediately.
1398 // On Drop the OS would release it anyway, but explicit unlock is more
1399 // deterministic in async / multi-phase shutdown sequences.
1400 if let Err(e) = fs2::FileExt::unlock(&self.dir_lock) {
1401 log::warn!("Failed to release write_dir advisory lock: {}", e);
1402 }
1403
1404 log::info!("WriteEngine closed");
1405
1406 Ok(())
1407 }
1408
1409 /// Parse a CQL statement to a Mutation
1410 ///
1411 /// Supports INSERT, UPDATE, and DELETE statements.
1412 fn parse_cql_to_mutation(&self, statement: &str) -> Result<Mutation> {
1413 cql_to_mutation::convert_cql_to_mutation(statement, &self.config.schema)
1414 }
1415
1416 /// Determine the next SSTable generation number
1417 ///
1418 /// Scans the data directory for existing SSTable files and returns
1419 /// the next generation number.
1420 fn determine_next_generation(data_dir: &Path) -> Result<u64> {
1421 let mut max_generation = 0u64;
1422
1423 if !data_dir.exists() {
1424 return Ok(1);
1425 }
1426
1427 // Recursively scan for SSTable files (writer places them in keyspace/table/ subdirs)
1428 Self::scan_generations(
1429 data_dir,
1430 &mut max_generation,
1431 crate::storage::sstable::MAX_SSTABLE_SCAN_DEPTH,
1432 )?;
1433
1434 Ok(max_generation + 1)
1435 }
1436
1437 /// Recursively scan directory for SSTable generation numbers
1438 fn scan_generations(dir: &Path, max_generation: &mut u64, depth: usize) -> Result<()> {
1439 for entry in std::fs::read_dir(dir)
1440 .map_err(|e| Error::Storage(format!("Failed to read data directory: {}", e)))?
1441 {
1442 let entry = entry
1443 .map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?;
1444
1445 let filename = entry.file_name();
1446 let filename_str = filename.to_string_lossy();
1447
1448 // Parse generation from filename: nb-{generation}-big-{Component}.db
1449 if filename_str.starts_with("nb-") && filename_str.contains("-big-") {
1450 if let Some(gen_str) = filename_str
1451 .strip_prefix("nb-")
1452 .and_then(|s| s.split('-').next())
1453 {
1454 if let Ok(gen) = gen_str.parse::<u64>() {
1455 *max_generation = (*max_generation).max(gen);
1456 }
1457 }
1458 } else if depth > 0 {
1459 let path = entry.path();
1460 if path.is_dir() {
1461 Self::scan_generations(&path, max_generation, depth - 1)?;
1462 }
1463 }
1464 }
1465 Ok(())
1466 }
1467}
1468
1469/// Safety-net `Drop` implementation for `WriteEngine`.
1470///
1471/// When a `WriteEngine` is dropped without calling `close()` (e.g. due to an
1472/// early return or a panic), the OS would release the advisory lock anyway once
1473/// the file descriptor is closed. This explicit `Drop` makes the release
1474/// deterministic and logs a warning so callers can distinguish a normal shutdown
1475/// from an ungraceful one.
1476#[cfg(feature = "write-support")]
1477impl Drop for WriteEngine {
1478 fn drop(&mut self) {
1479 // Issue #1693 (AG4): `Drop` is NOT a flush — there is no async drop in
1480 // Tokio, and re-introducing a `block_on` here is the AG3 defect. So the
1481 // best we can do on an ungraceful drop is make the silent data-loss mode
1482 // visible: if the memtable still holds rows that `close()` never flushed,
1483 // warn. The `is_empty()` guard is a cheap length check; the format
1484 // arguments (a single `usize`) allocate only when a warn-level logger is
1485 // actually enabled.
1486 if !self.memtable.is_empty() {
1487 // The recovery guidance depends on the durability mode: with WAL
1488 // durability the rows survive in the WAL and replay on next open,
1489 // but with `Durability::Disabled` the WAL was skipped entirely, so
1490 // an ungraceful drop loses the un-flushed rows permanently.
1491 match self.config.durability {
1492 Durability::SyncEachWrite => log::warn!(
1493 "WriteEngine dropped without close(): {} row(s) in the memtable were NOT \
1494 flushed to an SSTable and remain only in the WAL (durability now relies on \
1495 WAL replay at next startup). Call `close().await` for a graceful shutdown.",
1496 self.memtable.row_count()
1497 ),
1498 Durability::Disabled => log::warn!(
1499 "WriteEngine dropped without close(): {} row(s) in the memtable were NOT \
1500 flushed to an SSTable and are LOST — durability is Disabled so these rows \
1501 were never written to the WAL and cannot be recovered (they existed in \
1502 memory only). Call `close().await` for a graceful shutdown.",
1503 self.memtable.row_count()
1504 ),
1505 }
1506 }
1507
1508 // If close() was already called the lock was already released; a second
1509 // unlock is a no-op on most platforms (Linux returns ENOLCK which we
1510 // ignore here). The important invariant is that the lock is always
1511 // released before the file descriptor is closed by the OS on drop.
1512 if let Err(e) = fs2::FileExt::unlock(&self.dir_lock) {
1513 log::debug!(
1514 "WriteEngine drop: advisory lock release returned: {} \
1515 (may have been released by close() already)",
1516 e
1517 );
1518 }
1519 }
1520}
1521
1522#[cfg(all(test, feature = "write-support"))]
1523mod tests {
1524 use super::*;
1525 use crate::storage::write_engine::test_support::{create_test_mutation, create_test_schema};
1526 use tempfile::TempDir;
1527
1528 #[test]
1529 fn test_write_engine_config() {
1530 let temp_dir = TempDir::new().unwrap();
1531 let schema = create_test_schema();
1532
1533 let config = WriteEngineConfig::new(
1534 temp_dir.path().join("data"),
1535 temp_dir.path().join("wal"),
1536 schema,
1537 );
1538
1539 assert_eq!(
1540 config.memtable_flush_threshold,
1541 WriteEngineConfig::DEFAULT_FLUSH_THRESHOLD
1542 );
1543 assert_eq!(
1544 config.memtable_hard_limit,
1545 WriteEngineConfig::DEFAULT_HARD_LIMIT
1546 );
1547
1548 let config = config.with_flush_threshold(128 * 1024 * 1024);
1549 assert_eq!(config.memtable_flush_threshold, 128 * 1024 * 1024);
1550
1551 let config = config.with_hard_limit(512 * 1024 * 1024);
1552 assert_eq!(config.memtable_hard_limit, 512 * 1024 * 1024);
1553 }
1554
1555 #[test]
1556 fn test_write_engine_new() {
1557 let temp_dir = TempDir::new().unwrap();
1558 let schema = create_test_schema();
1559
1560 let config = WriteEngineConfig::new(
1561 temp_dir.path().join("data"),
1562 temp_dir.path().join("wal"),
1563 schema,
1564 );
1565
1566 let engine = WriteEngine::new(config).unwrap();
1567
1568 assert_eq!(engine.generation(), 1);
1569 assert_eq!(engine.memtable_size(), 0);
1570 assert_eq!(engine.memtable_row_count(), 0);
1571 assert!(!engine.closed.load(std::sync::atomic::Ordering::Relaxed));
1572 }
1573
1574 /// Issue #1392: `create_dir_all_durable` is the init-time link in the
1575 /// durability chain — it must create a fresh (possibly nested) root and
1576 /// fsync its parent so the new root's own dirent is persisted. There is no
1577 /// fs-op recorder seam on the init path, so this exercises the helper's
1578 /// call path directly (create + parent fsync succeeds and is idempotent);
1579 /// a crash-injection observation of the fsync would require a syscall seam
1580 /// that does not exist here.
1581 #[test]
1582 fn test_create_dir_all_durable_fsyncs_parent() {
1583 let temp_dir = TempDir::new().unwrap();
1584
1585 // Fresh nested root: parent (`root`) exists, `data` is newly created.
1586 let root = temp_dir.path().join("root");
1587 std::fs::create_dir_all(&root).unwrap();
1588 let data = root.join("data");
1589 assert!(!data.exists());
1590
1591 WriteEngine::create_dir_all_durable(&data, "data").unwrap();
1592 assert!(data.is_dir(), "new root directory must exist");
1593
1594 // Idempotent: re-running on an already-existing dir still succeeds and
1595 // re-fsyncs the parent without error.
1596 WriteEngine::create_dir_all_durable(&data, "data").unwrap();
1597 assert!(data.is_dir());
1598 }
1599
1600 /// Issue #1392 (roborev HIGH): the "track what this attempt created"
1601 /// approach had a retry hole — if a PRIOR startup created the nested tree
1602 /// but crashed partway through its parent-fsync sequence, a retry sees the
1603 /// tree already exists, records an empty newly-created set, and re-fsyncs
1604 /// only the immediate parent, leaving higher ancestors un-durable forever.
1605 ///
1606 /// The definitive fix is to stop tracking anything and fsync the FULL
1607 /// parent chain (up to and including the filesystem root) on EVERY call.
1608 /// This test simulates that partial-crash retry: it pre-creates the entire
1609 /// `base/a/b/data` tree first, so nothing is "newly created" on the call,
1610 /// then asserts every ancestor is still openable/fsyncable up to the root.
1611 ///
1612 /// Red-before: the tracking helper would fsync only `data`'s immediate
1613 /// parent (`b`) on the already-exists path, leaving `a`/`base` unsynced.
1614 /// Green-after: the unconditional full-chain walk fsyncs `b`, `a`, `base`,
1615 /// and every ancestor up to the filesystem root.
1616 #[test]
1617 fn test_create_dir_all_durable_fsyncs_full_chain_on_existing_tree() {
1618 let temp_dir = TempDir::new().unwrap();
1619
1620 // Simulate a prior partial-crash: the ENTIRE nested tree already exists
1621 // before the call, so no directory is "newly created" this invocation.
1622 let base = temp_dir.path().join("base");
1623 let a = base.join("a");
1624 let b = a.join("b");
1625 let data = b.join("data");
1626 std::fs::create_dir_all(&data).unwrap();
1627 assert!(data.is_dir(), "precondition: full tree pre-exists");
1628
1629 // The unconditional full-chain fsync must succeed on the already-exists
1630 // path — it walks `data`'s parent up to the filesystem root, fsyncing
1631 // every ancestor regardless of what (if anything) this call created.
1632 WriteEngine::create_dir_all_durable(&data, "data").unwrap();
1633
1634 // Every ancestor from the leaf's parent up to the root is a real,
1635 // present directory that the walk fsynced (open-for-sync would fail on
1636 // a missing/non-durable dirent). Assert the whole chain is present.
1637 for ancestor in [b.as_path(), a.as_path(), base.as_path()] {
1638 assert!(
1639 ancestor.is_dir(),
1640 "ancestor {:?} must be present and fsyncable up the full chain",
1641 ancestor
1642 );
1643 }
1644
1645 // Idempotent: a second unconditional pass still succeeds.
1646 WriteEngine::create_dir_all_durable(&data, "data").unwrap();
1647 assert!(data.is_dir());
1648 }
1649
1650 /// Issue #1392: the full-chain walk must TERMINATE at the filesystem root
1651 /// (a path whose `parent()` is `None`) rather than looping. Exercising the
1652 /// helper against an absolute nested path implicitly walks to `/`; this
1653 /// test guards the termination contract by confirming the call returns
1654 /// (does not hang) and the root itself has no parent to ascend into.
1655 #[test]
1656 fn test_create_dir_all_durable_walk_terminates_at_root() {
1657 let temp_dir = TempDir::new().unwrap();
1658 let data = temp_dir.path().join("a").join("b").join("data");
1659
1660 // Absolute path: the parent chain ascends through `b`, `a`, the temp
1661 // dir, ... up to `/`. `Path::new("/").parent()` is `None`, so the walk
1662 // stops there — no infinite loop. The call simply returns.
1663 WriteEngine::create_dir_all_durable(&data, "data").unwrap();
1664 assert!(data.is_dir());
1665
1666 // Contract the walk relies on: the filesystem root terminates the walk.
1667 assert!(
1668 std::path::Path::new("/").parent().is_none(),
1669 "filesystem root must have no parent so the fsync walk terminates"
1670 );
1671 }
1672
1673 #[test]
1674 fn test_write_engine_write_single_mutation() {
1675 let temp_dir = TempDir::new().unwrap();
1676 let schema = create_test_schema();
1677
1678 let config = WriteEngineConfig::new(
1679 temp_dir.path().join("data"),
1680 temp_dir.path().join("wal"),
1681 schema,
1682 );
1683
1684 let mut engine = WriteEngine::new(config).unwrap();
1685
1686 let mutation = create_test_mutation(1, "Alice", 1000000);
1687 engine.write(mutation).unwrap();
1688
1689 assert_eq!(engine.memtable_row_count(), 1);
1690 assert!(engine.memtable_size() > 0);
1691 assert!(engine.wal_size() > 0);
1692 }
1693
1694 #[test]
1695 fn test_write_engine_write_multiple_mutations() {
1696 let temp_dir = TempDir::new().unwrap();
1697 let schema = create_test_schema();
1698
1699 let config = WriteEngineConfig::new(
1700 temp_dir.path().join("data"),
1701 temp_dir.path().join("wal"),
1702 schema,
1703 );
1704
1705 let mut engine = WriteEngine::new(config).unwrap();
1706
1707 // Write 10 mutations
1708 for i in 0..10 {
1709 let mutation = create_test_mutation(i, &format!("User{}", i), 1000000 + i as i64);
1710 engine.write(mutation).unwrap();
1711 }
1712
1713 assert_eq!(engine.memtable_row_count(), 10);
1714 assert!(engine.memtable_size() > 0);
1715 }
1716
1717 #[tokio::test]
1718 async fn test_write_engine_flush_empty() {
1719 let temp_dir = TempDir::new().unwrap();
1720 let schema = create_test_schema();
1721
1722 let config = WriteEngineConfig::new(
1723 temp_dir.path().join("data"),
1724 temp_dir.path().join("wal"),
1725 schema,
1726 );
1727
1728 let mut engine = WriteEngine::new(config).unwrap();
1729
1730 // Flush empty memtable
1731 let result = engine.flush().await.unwrap();
1732 assert!(result.is_none());
1733 }
1734
1735 #[tokio::test]
1736 async fn test_write_engine_flush_with_data() {
1737 let temp_dir = TempDir::new().unwrap();
1738 let schema = create_test_schema();
1739
1740 let config = WriteEngineConfig::new(
1741 temp_dir.path().join("data"),
1742 temp_dir.path().join("wal"),
1743 schema,
1744 );
1745
1746 let mut engine = WriteEngine::new(config).unwrap();
1747
1748 // Write mutations
1749 for i in 0..5 {
1750 let mutation = create_test_mutation(i, &format!("User{}", i), 1000000 + i as i64);
1751 engine.write(mutation).unwrap();
1752 }
1753
1754 let initial_generation = engine.generation();
1755
1756 // Flush
1757 let info = engine.flush().await.unwrap();
1758 assert!(info.is_some());
1759
1760 let info = info.unwrap();
1761 assert_eq!(info.partition_count, 5);
1762 assert!(info.data_size > 0);
1763 assert!(info.data_path.exists());
1764
1765 // Memtable should be empty after flush
1766 assert_eq!(engine.memtable_row_count(), 0);
1767 assert_eq!(engine.memtable_size(), 0);
1768
1769 // WAL should be truncated
1770 assert_eq!(engine.wal_size(), 0);
1771
1772 // Generation should increment
1773 assert_eq!(engine.generation(), initial_generation + 1);
1774 }
1775
1776 #[test]
1777 fn test_write_engine_automatic_flush() {
1778 let temp_dir = TempDir::new().unwrap();
1779 let schema = create_test_schema();
1780
1781 // Set very low flush threshold (1KB)
1782 let config = WriteEngineConfig::new(
1783 temp_dir.path().join("data"),
1784 temp_dir.path().join("wal"),
1785 schema,
1786 )
1787 .with_flush_threshold(1024);
1788
1789 let mut engine = WriteEngine::new(config).unwrap();
1790
1791 // Write enough mutations to trigger automatic flush
1792 for i in 0..100 {
1793 let mutation = create_test_mutation(i, &format!("User{}", i), 1000000 + i as i64);
1794 engine.write(mutation).unwrap();
1795 }
1796
1797 // Should have automatically flushed
1798 // Memtable may have some data if writes continued after flush
1799 // But generation should have incremented
1800 assert!(engine.generation() > 1 || engine.memtable_size() < 10000);
1801 }
1802
1803 #[tokio::test]
1804 async fn test_write_engine_close_with_data() {
1805 let temp_dir = TempDir::new().unwrap();
1806 let schema = create_test_schema();
1807
1808 let config = WriteEngineConfig::new(
1809 temp_dir.path().join("data"),
1810 temp_dir.path().join("wal"),
1811 schema,
1812 );
1813
1814 let mut engine = WriteEngine::new(config).unwrap();
1815
1816 // Write mutations
1817 for i in 0..5 {
1818 let mutation = create_test_mutation(i, &format!("User{}", i), 1000000 + i as i64);
1819 engine.write(mutation).unwrap();
1820 }
1821
1822 // Close should flush
1823 engine.close().await.unwrap();
1824
1825 // Verify SSTable was created
1826 let data_dir = temp_dir.path().join("data");
1827 let entries: Vec<_> = std::fs::read_dir(&data_dir).unwrap().collect();
1828 assert!(!entries.is_empty(), "SSTable files should exist");
1829 }
1830
1831 #[tokio::test]
1832 async fn test_write_engine_close_empty() {
1833 let temp_dir = TempDir::new().unwrap();
1834 let schema = create_test_schema();
1835
1836 let config = WriteEngineConfig::new(
1837 temp_dir.path().join("data"),
1838 temp_dir.path().join("wal"),
1839 schema,
1840 );
1841
1842 let mut engine = WriteEngine::new(config).unwrap();
1843
1844 // Close empty engine
1845 engine.close().await.unwrap();
1846 }
1847
1848 #[test]
1849 fn test_write_engine_write_after_close() {
1850 let temp_dir = TempDir::new().unwrap();
1851 let schema = create_test_schema();
1852
1853 let config = WriteEngineConfig::new(
1854 temp_dir.path().join("data"),
1855 temp_dir.path().join("wal"),
1856 schema,
1857 );
1858
1859 let mut engine = WriteEngine::new(config).unwrap();
1860
1861 // Close
1862 tokio::runtime::Runtime::new()
1863 .unwrap()
1864 .block_on(engine.close())
1865 .unwrap();
1866
1867 // Create new engine with same config (simulating restart)
1868 let schema2 = create_test_schema();
1869 let config2 = WriteEngineConfig::new(
1870 temp_dir.path().join("data"),
1871 temp_dir.path().join("wal"),
1872 schema2,
1873 );
1874
1875 let mut engine2 = WriteEngine::new(config2).unwrap();
1876
1877 // Write should fail on closed engine (if we still had reference)
1878 // But new engine should work
1879 let mutation = create_test_mutation(1, "Alice", 1000000);
1880 engine2.write(mutation).unwrap();
1881 assert_eq!(engine2.memtable_row_count(), 1);
1882 }
1883
1884 #[test]
1885 fn test_write_engine_wal_recovery() {
1886 let temp_dir = TempDir::new().unwrap();
1887 let schema = create_test_schema();
1888
1889 let config = WriteEngineConfig::new(
1890 temp_dir.path().join("data"),
1891 temp_dir.path().join("wal"),
1892 schema.clone(),
1893 );
1894
1895 // Write mutations and close without flushing
1896 {
1897 let mut engine = WriteEngine::new(config.clone()).unwrap();
1898
1899 for i in 0..5 {
1900 let mutation = create_test_mutation(i, &format!("User{}", i), 1000000 + i as i64);
1901 engine.write(mutation).unwrap();
1902 }
1903
1904 // Don't flush - just drop engine (simulating crash)
1905 }
1906
1907 // Create new engine - should recover from WAL
1908 let config2 = WriteEngineConfig::new(
1909 temp_dir.path().join("data"),
1910 temp_dir.path().join("wal"),
1911 schema,
1912 );
1913
1914 let engine = WriteEngine::new(config2).unwrap();
1915
1916 // Should have recovered 5 mutations
1917 assert_eq!(engine.memtable_row_count(), 5);
1918 assert!(engine.memtable_size() > 0);
1919 }
1920
1921 /// Issue #1390 criterion 5: engine-level crash simulation through the
1922 /// WriteEngine (not just the WAL unit). Write A (fsync-acknowledged), crash
1923 /// (drop without flush), inject a torn tail into the commitlog, recover in a
1924 /// new engine, write C (fsync-acknowledged), crash again, then recover once
1925 /// more and assert every acknowledged mutation is present exactly once.
1926 ///
1927 /// Before the fix, C lands AFTER the retained torn bytes and is silently
1928 /// lost on the second recovery.
1929 #[test]
1930 fn test_write_engine_recovers_across_torn_tail_crash() {
1931 use std::io::Write as _;
1932
1933 let temp_dir = TempDir::new().unwrap();
1934 let schema = create_test_schema();
1935 let config = WriteEngineConfig::new(
1936 temp_dir.path().join("data"),
1937 temp_dir.path().join("wal"),
1938 schema,
1939 );
1940
1941 // 1. Write A and "crash" (drop without flush). Default durability is
1942 // SyncEachWrite, so A is fsync'd to the WAL.
1943 {
1944 let mut engine = WriteEngine::new(config.clone()).unwrap();
1945 engine
1946 .write(create_test_mutation(1, "Alice", 1_000_000))
1947 .unwrap();
1948 }
1949
1950 // 2. Inject a torn tail: a complete 8-byte header declaring a 100-byte
1951 // payload, followed by only 10 payload bytes (interrupted append).
1952 let wal_file = config.wal_dir.join(WriteAheadLog::WAL_FILENAME);
1953 {
1954 let mut f = std::fs::OpenOptions::new()
1955 .append(true)
1956 .open(&wal_file)
1957 .unwrap();
1958 f.write_all(&100u32.to_le_bytes()).unwrap();
1959 f.write_all(&0u32.to_le_bytes()).unwrap();
1960 f.write_all(&[0xAB; 10]).unwrap();
1961 f.sync_all().unwrap();
1962 }
1963
1964 // 3. Recover: the torn tail must be trimmed. Then write C and crash.
1965 {
1966 let mut engine = WriteEngine::new(config.clone()).unwrap();
1967 assert_eq!(
1968 engine.memtable_row_count(),
1969 1,
1970 "must recover A across the torn tail"
1971 );
1972 engine
1973 .write(create_test_mutation(3, "Carol", 3_000_000))
1974 .unwrap();
1975 }
1976
1977 // 4. Recover again: both acknowledged mutations must be present exactly
1978 // once (2 distinct partition keys => 2 memtable rows).
1979 {
1980 let engine = WriteEngine::new(config).unwrap();
1981 assert_eq!(
1982 engine.memtable_row_count(),
1983 2,
1984 "both A and C must survive the second recovery (C must not be lost)"
1985 );
1986 }
1987 }
1988
1989 // ---- Issue #1391: engine-level flush guard over a lossy WAL replay ----
1990 //
1991 // A lossy WAL recovery must be surfaced to the caller (via the retained
1992 // RecoveryReport) AND its raw segment preserved aside BEFORE the next flush
1993 // truncates the WAL. Otherwise the loss becomes permanent and invisible.
1994
1995 /// Build a 3-entry (A, B, C) WAL in `wal_dir` and return the byte offset at
1996 /// the end of entry A (so B's payload, which starts at `end_a + 8`, can be
1997 /// corrupted). All entries are fsync'd.
1998 fn seed_wal_abc(wal_dir: &Path) -> u64 {
1999 std::fs::create_dir_all(wal_dir).unwrap();
2000 let mut wal = WriteAheadLog::create(wal_dir).unwrap();
2001 wal.append(&create_test_mutation(1, "A", 1_000_000))
2002 .unwrap();
2003 wal.sync().unwrap();
2004 let end_a = wal.size();
2005 wal.append(&create_test_mutation(2, "B", 2_000_000))
2006 .unwrap();
2007 wal.sync().unwrap();
2008 wal.append(&create_test_mutation(3, "C", 3_000_000))
2009 .unwrap();
2010 wal.sync().unwrap();
2011 drop(wal);
2012 end_a
2013 }
2014
2015 fn count_corrupt_aside(wal_dir: &Path) -> usize {
2016 std::fs::read_dir(wal_dir)
2017 .unwrap()
2018 .filter_map(|e| e.ok())
2019 .filter(|e| {
2020 e.file_name()
2021 .to_string_lossy()
2022 .contains("commitlog.wal.corrupt.")
2023 })
2024 .count()
2025 }
2026
2027 /// Criterion 5: a lossy WAL must not be silently truncated by the first
2028 /// flush. Opening the engine over an A,B(corrupt),C WAL must expose a
2029 /// non-clean RecoveryReport and preserve the raw segment aside; a subsequent
2030 /// flush must NOT destroy that evidence.
2031 #[tokio::test]
2032 async fn test_write_engine_lossy_wal_preserved_before_flush_truncate() {
2033 use std::io::{Read, Seek, SeekFrom, Write};
2034
2035 let temp_dir = TempDir::new().unwrap();
2036 let schema = create_test_schema();
2037 let config = WriteEngineConfig::new(
2038 temp_dir.path().join("data"),
2039 temp_dir.path().join("wal"),
2040 schema,
2041 );
2042
2043 let end_a = seed_wal_abc(&config.wal_dir);
2044
2045 // Bit-flip the first payload byte of B (just past its 8-byte header).
2046 let wal_file = config.wal_dir.join(WriteAheadLog::WAL_FILENAME);
2047 {
2048 let mut f = std::fs::OpenOptions::new()
2049 .read(true)
2050 .write(true)
2051 .open(&wal_file)
2052 .unwrap();
2053 f.seek(SeekFrom::Start(end_a + 8)).unwrap();
2054 let mut byte = [0u8; 1];
2055 f.read_exact(&mut byte).unwrap();
2056 f.seek(SeekFrom::Start(end_a + 8)).unwrap();
2057 f.write_all(&[byte[0] ^ 0x01]).unwrap();
2058 f.sync_all().unwrap();
2059 }
2060
2061 // Open the engine: recovery must surface as lossy and preserve evidence.
2062 let mut engine = WriteEngine::new(config.clone()).unwrap();
2063 assert!(
2064 !engine.wal_recovery().is_clean(),
2065 "engine must expose a non-clean RecoveryReport for a lossy WAL"
2066 );
2067 assert_eq!(engine.wal_recovery().corrupt_entries, 1);
2068 assert!(engine.wal_recovery().stopped_early);
2069 assert_eq!(
2070 engine.memtable_row_count(),
2071 1,
2072 "only the valid prefix [A] is recovered"
2073 );
2074 assert_eq!(
2075 count_corrupt_aside(&config.wal_dir),
2076 1,
2077 "the raw corrupt WAL segment must be preserved aside BEFORE any flush"
2078 );
2079
2080 // Flush: this truncates the live WAL, but the preserved evidence must
2081 // survive so the loss is not destroyed.
2082 engine.flush().await.unwrap();
2083 assert_eq!(
2084 count_corrupt_aside(&config.wal_dir),
2085 1,
2086 "flush must NOT destroy the preserved corrupt WAL segment"
2087 );
2088 }
2089
2090 /// Issue #1661 (roborev): a memtable-application error on an EARLIER valid
2091 /// mutation MUST NOT abort the WAL scan before a LATER corrupt tail can be
2092 /// detected, preserved aside, and reset. The streaming refactor applies each
2093 /// mutation as it is scanned; if that apply error propagated immediately, the
2094 /// #1390/#1391 lossy-recovery preserve/reset would be skipped — a forbidden
2095 /// behavior change. Build a WAL of [A(applies-fail), B(corrupt)]: A is a
2096 /// CRC-valid, decodable mutation whose partition key value (`Text`) cannot be
2097 /// encoded against the `int` schema, so `decorated_key` fails at apply time;
2098 /// B is bit-flipped so replay reports the recovery as lossy. `WriteEngine::new`
2099 /// must return the deferred apply error, but ONLY after the corrupt segment was
2100 /// preserved aside and the live WAL reset to its valid prefix [A].
2101 #[test]
2102 fn test_write_engine_apply_error_still_preserves_and_resets_corrupt_tail() {
2103 use crate::storage::write_engine::mutation::{CellOperation, PartitionKey, TableId};
2104 use crate::types::Value;
2105 use std::io::{Read, Seek, SeekFrom, Write};
2106
2107 let temp_dir = TempDir::new().unwrap();
2108 let schema = create_test_schema(); // partition key `id` is `int`
2109 let config = WriteEngineConfig::new(
2110 temp_dir.path().join("data"),
2111 temp_dir.path().join("wal"),
2112 schema,
2113 );
2114
2115 // Seed A = a mutation whose partition key is Text (mismatches the int
2116 // schema) so it serializes/CRCs/deserializes fine but fails at apply
2117 // (`decorated_key`); then B = a valid mutation we will corrupt.
2118 std::fs::create_dir_all(&config.wal_dir).unwrap();
2119 let end_a = {
2120 let mut wal = WriteAheadLog::create(&config.wal_dir).unwrap();
2121 let bad = Mutation::new(
2122 TableId::new("test_ks", "test_table"),
2123 PartitionKey::single("id", Value::Text("not-an-int".to_string())),
2124 None,
2125 vec![CellOperation::Write {
2126 column: "name".to_string(),
2127 value: Value::Text("A".to_string()),
2128 }],
2129 1_000_000,
2130 None,
2131 );
2132 wal.append(&bad).unwrap();
2133 wal.sync().unwrap();
2134 let end_a = wal.size();
2135 wal.append(&create_test_mutation(2, "B", 2_000_000))
2136 .unwrap();
2137 wal.sync().unwrap();
2138 end_a
2139 };
2140
2141 // Bit-flip B's first payload byte (just past its 8-byte header) → CRC
2142 // mismatch → mid-stream corruption.
2143 let wal_file = config.wal_dir.join(WriteAheadLog::WAL_FILENAME);
2144 {
2145 let mut f = std::fs::OpenOptions::new()
2146 .read(true)
2147 .write(true)
2148 .open(&wal_file)
2149 .unwrap();
2150 f.seek(SeekFrom::Start(end_a + 8)).unwrap();
2151 let mut byte = [0u8; 1];
2152 f.read_exact(&mut byte).unwrap();
2153 f.seek(SeekFrom::Start(end_a + 8)).unwrap();
2154 f.write_all(&[byte[0] ^ 0x01]).unwrap();
2155 f.sync_all().unwrap();
2156 }
2157
2158 // Opening must FAIL on the deferred apply error...
2159 let result = WriteEngine::new(config.clone());
2160 assert!(
2161 result.is_err(),
2162 "engine open must surface the deferred memtable-application error"
2163 );
2164
2165 // ...but ONLY after the lossy-recovery preserve/reset ran. Pre-fix, the
2166 // apply error aborted the scan before B's corruption was seen, so neither
2167 // of these held.
2168 assert_eq!(
2169 count_corrupt_aside(&config.wal_dir),
2170 1,
2171 "corrupt segment must be preserved aside even though an earlier apply failed"
2172 );
2173 assert_eq!(
2174 std::fs::metadata(&wal_file).unwrap().len(),
2175 end_a,
2176 "live WAL must be reset to its valid prefix [A] before the apply error surfaces"
2177 );
2178 }
2179
2180 /// Regression guard: a CLEAN recovery is unchanged — no aside file, and the
2181 /// flush truncates the WAL normally.
2182 #[tokio::test]
2183 async fn test_write_engine_clean_wal_recovery_truncates_normally() {
2184 let temp_dir = TempDir::new().unwrap();
2185 let schema = create_test_schema();
2186 let config = WriteEngineConfig::new(
2187 temp_dir.path().join("data"),
2188 temp_dir.path().join("wal"),
2189 schema,
2190 );
2191
2192 seed_wal_abc(&config.wal_dir);
2193
2194 let mut engine = WriteEngine::new(config.clone()).unwrap();
2195 assert!(engine.wal_recovery().is_clean());
2196 assert_eq!(engine.memtable_row_count(), 3, "A, B, C all recovered");
2197 assert_eq!(
2198 count_corrupt_aside(&config.wal_dir),
2199 0,
2200 "a clean recovery must not preserve any aside segment"
2201 );
2202
2203 engine.flush().await.unwrap();
2204 let wal_file = config.wal_dir.join(WriteAheadLog::WAL_FILENAME);
2205 assert_eq!(
2206 std::fs::metadata(&wal_file).unwrap().len(),
2207 0,
2208 "clean recovery: the WAL is truncated normally after flush"
2209 );
2210 assert_eq!(count_corrupt_aside(&config.wal_dir), 0);
2211 }
2212
2213 /// Issue #1391 (Finding B): recovery-write-crash. After a LOSSY recovery the
2214 /// engine stays writable, so a synced write issued right after recovery MUST
2215 /// survive the next replay. Before the fix the live WAL still carried the
2216 /// corrupt tail, so the acknowledged write landed AFTER the corrupt entry —
2217 /// where replay stops — and was silently lost. With the valid-prefix reset,
2218 /// the write lands at a replayable position: reopening the live WAL and
2219 /// replaying must yield the valid prefix PLUS the post-recovery write (never
2220 /// a set missing it), while the corrupt evidence is still preserved aside.
2221 #[tokio::test]
2222 async fn test_write_engine_reset_to_valid_prefix_keeps_post_recovery_write() {
2223 use crate::types::Value;
2224 use std::io::{Read, Seek, SeekFrom, Write};
2225
2226 let temp_dir = TempDir::new().unwrap();
2227 let schema = create_test_schema();
2228 let config = WriteEngineConfig::new(
2229 temp_dir.path().join("data"),
2230 temp_dir.path().join("wal"),
2231 schema,
2232 );
2233
2234 // Seed A, B, C then bit-flip B's payload → mid-stream corruption.
2235 let end_a = seed_wal_abc(&config.wal_dir);
2236 let wal_file = config.wal_dir.join(WriteAheadLog::WAL_FILENAME);
2237 {
2238 let mut f = std::fs::OpenOptions::new()
2239 .read(true)
2240 .write(true)
2241 .open(&wal_file)
2242 .unwrap();
2243 f.seek(SeekFrom::Start(end_a + 8)).unwrap();
2244 let mut byte = [0u8; 1];
2245 f.read_exact(&mut byte).unwrap();
2246 f.seek(SeekFrom::Start(end_a + 8)).unwrap();
2247 f.write_all(&[byte[0] ^ 0x01]).unwrap();
2248 f.sync_all().unwrap();
2249 }
2250
2251 // Open the engine (recovery is lossy → evidence preserved, live WAL reset
2252 // to the valid prefix [A]), then issue a synced post-recovery write D.
2253 {
2254 let mut engine = WriteEngine::new(config.clone()).unwrap();
2255 assert!(!engine.wal_recovery().is_clean());
2256 assert_eq!(engine.memtable_row_count(), 1, "valid prefix [A] recovered");
2257 assert_eq!(
2258 count_corrupt_aside(&config.wal_dir),
2259 1,
2260 "corrupt segment must be preserved aside"
2261 );
2262 // Default durability is SyncEachWrite, so D is fsync'd to the WAL.
2263 engine
2264 .write(create_test_mutation(4, "D", 4_000_000))
2265 .unwrap();
2266 drop(engine);
2267 }
2268
2269 // Reopen the LIVE WAL directly and replay: it must recover [A, D] — the
2270 // acknowledged post-recovery write is present, NOT lost behind the old
2271 // corrupt tail.
2272 let wal = WriteAheadLog::open_existing(&wal_file).unwrap();
2273 let report = wal.replay().unwrap();
2274 assert!(
2275 report.is_clean(),
2276 "the reset live WAL is a clean [A, D] prefix"
2277 );
2278 let names: Vec<&str> = report
2279 .mutations
2280 .iter()
2281 .map(|m| match &m.operations[0] {
2282 CellOperation::Write {
2283 value: Value::Text(name),
2284 ..
2285 } => name.as_str(),
2286 other => panic!("expected Write op, got {other:?}"),
2287 })
2288 .collect();
2289 assert_eq!(
2290 names,
2291 vec!["A", "D"],
2292 "replay must yield the valid prefix AND the post-recovery write D, \
2293 never a set missing D (and never the corrupt B/C)"
2294 );
2295 assert!(
2296 !names.contains(&"B"),
2297 "the corrupt entry B must not resurface"
2298 );
2299 assert!(
2300 !names.contains(&"C"),
2301 "C (behind corruption) must not resurface"
2302 );
2303
2304 // The forensic evidence must still be on disk.
2305 assert_eq!(
2306 count_corrupt_aside(&config.wal_dir),
2307 1,
2308 "the corrupt WAL evidence must remain preserved aside after the reset"
2309 );
2310 }
2311
2312 #[test]
2313 fn test_write_engine_generation_tracking() {
2314 let temp_dir = TempDir::new().unwrap();
2315 let schema = create_test_schema();
2316
2317 let config = WriteEngineConfig::new(
2318 temp_dir.path().join("data"),
2319 temp_dir.path().join("wal"),
2320 schema.clone(),
2321 );
2322
2323 // First engine
2324 {
2325 let mut engine = WriteEngine::new(config.clone()).unwrap();
2326 assert_eq!(engine.generation(), 1);
2327
2328 // Write and flush
2329 let mutation = create_test_mutation(1, "Alice", 1000000);
2330 engine.write(mutation).unwrap();
2331
2332 tokio::runtime::Runtime::new()
2333 .unwrap()
2334 .block_on(engine.flush())
2335 .unwrap();
2336
2337 assert_eq!(engine.generation(), 2);
2338 }
2339
2340 // Second engine - should detect existing generation
2341 let config2 = WriteEngineConfig::new(
2342 temp_dir.path().join("data"),
2343 temp_dir.path().join("wal"),
2344 schema,
2345 );
2346
2347 let engine = WriteEngine::new(config2).unwrap();
2348 assert_eq!(engine.generation(), 2);
2349 }
2350
2351 #[test]
2352 fn test_write_engine_execute_table_mismatch() {
2353 let temp_dir = TempDir::new().unwrap();
2354 let schema = create_test_schema();
2355
2356 let config = WriteEngineConfig::new(
2357 temp_dir.path().join("data"),
2358 temp_dir.path().join("wal"),
2359 schema,
2360 );
2361
2362 let mut engine = WriteEngine::new(config).unwrap();
2363
2364 // Schema defines test_table, but statement targets users → table mismatch
2365 let result = engine.execute("INSERT INTO users (id, name) VALUES (1, 'Alice')");
2366 let err_msg = result.unwrap_err().to_string();
2367 assert!(
2368 err_msg.contains("targets table 'users'")
2369 && err_msg.contains("schema is for 'test_table'"),
2370 "Expected table mismatch error, got: {}",
2371 err_msg
2372 );
2373 }
2374
2375 #[test]
2376 fn test_write_engine_execute_insert_success() {
2377 let temp_dir = TempDir::new().unwrap();
2378 let schema = create_test_schema();
2379
2380 let config = WriteEngineConfig::new(
2381 temp_dir.path().join("data"),
2382 temp_dir.path().join("wal"),
2383 schema,
2384 );
2385
2386 let mut engine = WriteEngine::new(config).unwrap();
2387
2388 assert_eq!(engine.memtable_row_count(), 0);
2389
2390 // INSERT matching the test schema: test_ks.test_table(id int PK, name text)
2391 let result = engine.execute("INSERT INTO test_table (id, name) VALUES (1, 'Alice')");
2392 assert!(
2393 result.is_ok(),
2394 "execute() failed: {:?}",
2395 result.unwrap_err()
2396 );
2397
2398 assert_eq!(engine.memtable_row_count(), 1);
2399 }
2400
2401 #[test]
2402 fn test_determine_next_generation_empty_dir() {
2403 let temp_dir = TempDir::new().unwrap();
2404 let data_dir = temp_dir.path().join("data");
2405 std::fs::create_dir_all(&data_dir).unwrap();
2406
2407 let generation = WriteEngine::determine_next_generation(&data_dir).unwrap();
2408 assert_eq!(generation, 1);
2409 }
2410
2411 #[test]
2412 fn test_determine_next_generation_with_sstables() {
2413 let temp_dir = TempDir::new().unwrap();
2414 let data_dir = temp_dir.path().join("data");
2415 std::fs::create_dir_all(&data_dir).unwrap();
2416
2417 // Create dummy SSTable files
2418 std::fs::write(data_dir.join("nb-1-big-Data.db"), b"").unwrap();
2419 std::fs::write(data_dir.join("nb-2-big-Data.db"), b"").unwrap();
2420 std::fs::write(data_dir.join("nb-5-big-Data.db"), b"").unwrap();
2421
2422 let generation = WriteEngine::determine_next_generation(&data_dir).unwrap();
2423 assert_eq!(generation, 6);
2424 }
2425
2426 #[tokio::test]
2427 async fn test_write_engine_close_idempotent() {
2428 let temp_dir = TempDir::new().unwrap();
2429 let schema = create_test_schema();
2430
2431 let config = WriteEngineConfig::new(
2432 temp_dir.path().join("data"),
2433 temp_dir.path().join("wal"),
2434 schema,
2435 );
2436
2437 let mut engine = WriteEngine::new(config).unwrap();
2438
2439 // Close once
2440 engine.close().await.unwrap();
2441 assert!(engine.closed.load(Ordering::SeqCst));
2442
2443 // Close again - should be idempotent
2444 engine.close().await.unwrap();
2445 assert!(engine.closed.load(Ordering::SeqCst));
2446 }
2447
2448 #[tokio::test]
2449 async fn test_write_engine_close_syncs_wal() {
2450 let temp_dir = TempDir::new().unwrap();
2451 let schema = create_test_schema();
2452
2453 let config = WriteEngineConfig::new(
2454 temp_dir.path().join("data"),
2455 temp_dir.path().join("wal"),
2456 schema,
2457 );
2458
2459 let mut engine = WriteEngine::new(config).unwrap();
2460
2461 // Write a mutation
2462 let mutation = create_test_mutation(1, "Alice", 1000000);
2463 engine.write(mutation).unwrap();
2464
2465 // Close should sync WAL before completing
2466 engine.close().await.unwrap();
2467
2468 // Verify WAL was truncated (because data was flushed to SSTable)
2469 assert_eq!(engine.wal_size(), 0);
2470 }
2471
2472 #[test]
2473 fn test_write_engine_closed_flag_atomic() {
2474 let temp_dir = TempDir::new().unwrap();
2475 let schema = create_test_schema();
2476
2477 let config = WriteEngineConfig::new(
2478 temp_dir.path().join("data"),
2479 temp_dir.path().join("wal"),
2480 schema,
2481 );
2482
2483 let engine = WriteEngine::new(config).unwrap();
2484
2485 // Verify closed flag is atomic
2486 assert!(!engine.closed.load(Ordering::SeqCst));
2487
2488 // Store true
2489 engine.closed.store(true, Ordering::SeqCst);
2490 assert!(engine.closed.load(Ordering::SeqCst));
2491
2492 // Swap back to false
2493 let prev = engine.closed.swap(false, Ordering::SeqCst);
2494 assert!(prev);
2495 assert!(!engine.closed.load(Ordering::SeqCst));
2496 }
2497
2498 #[tokio::test]
2499 async fn test_write_engine_write_after_close_fails() {
2500 let temp_dir = TempDir::new().unwrap();
2501 let schema = create_test_schema();
2502
2503 let config = WriteEngineConfig::new(
2504 temp_dir.path().join("data"),
2505 temp_dir.path().join("wal"),
2506 schema,
2507 );
2508
2509 let mut engine = WriteEngine::new(config).unwrap();
2510
2511 // Close the engine
2512 engine.close().await.unwrap();
2513
2514 // Try to write - should fail
2515 let mutation = create_test_mutation(1, "Alice", 1000000);
2516 let result = engine.write(mutation);
2517
2518 assert!(result.is_err());
2519 match result {
2520 Err(Error::InvalidInput(_)) => {}
2521 _ => panic!("Expected InvalidInput error"),
2522 }
2523 }
2524
2525 #[tokio::test]
2526 async fn test_write_engine_flush_after_close_fails() {
2527 let temp_dir = TempDir::new().unwrap();
2528 let schema = create_test_schema();
2529
2530 let config = WriteEngineConfig::new(
2531 temp_dir.path().join("data"),
2532 temp_dir.path().join("wal"),
2533 schema,
2534 );
2535
2536 let mut engine = WriteEngine::new(config).unwrap();
2537
2538 // Close the engine
2539 engine.close().await.unwrap();
2540
2541 // Try to flush - should fail
2542 let result = engine.flush().await;
2543
2544 assert!(result.is_err());
2545 match result {
2546 Err(Error::InvalidInput(_)) => {}
2547 _ => panic!("Expected InvalidInput error"),
2548 }
2549 }
2550
2551 #[test]
2552 fn test_write_engine_hard_limit_enforcement() {
2553 let temp_dir = TempDir::new().unwrap();
2554 let schema = create_test_schema();
2555
2556 // Set very low hard limit (2KB) with flush threshold higher (to prevent auto-flush)
2557 let config = WriteEngineConfig::new(
2558 temp_dir.path().join("data"),
2559 temp_dir.path().join("wal"),
2560 schema,
2561 )
2562 .with_flush_threshold(10 * 1024) // 10KB flush threshold (higher than hard limit for test)
2563 .with_hard_limit(2048); // 2KB hard limit
2564
2565 let mut engine = WriteEngine::new(config).unwrap();
2566
2567 // Write mutations until we hit the hard limit
2568 let mut write_count = 0;
2569 for i in 0..1000 {
2570 let mutation = create_test_mutation(i, &format!("User{}", i), 1000000 + i as i64);
2571 let result = engine.write(mutation);
2572
2573 match result {
2574 Ok(()) => {
2575 write_count += 1;
2576 }
2577 Err(Error::Storage(msg)) => {
2578 assert!(msg.contains("hard limit"));
2579 break;
2580 }
2581 Err(e) => panic!("Expected Storage error, got: {:?}", e),
2582 }
2583 }
2584
2585 // Should have stopped before 1000 writes due to hard limit
2586 assert!(
2587 write_count < 1000,
2588 "Should have hit hard limit before 1000 writes"
2589 );
2590 assert!(
2591 write_count > 0,
2592 "Should have accepted at least some writes before hitting limit"
2593 );
2594 }
2595
2596 #[tokio::test]
2597 async fn test_write_engine_hard_limit_enforcement_async() {
2598 let temp_dir = TempDir::new().unwrap();
2599 let schema = create_test_schema();
2600
2601 // Set very low hard limit (2KB) with flush threshold higher (to prevent auto-flush)
2602 let config = WriteEngineConfig::new(
2603 temp_dir.path().join("data"),
2604 temp_dir.path().join("wal"),
2605 schema,
2606 )
2607 .with_flush_threshold(10 * 1024) // 10KB flush threshold (higher than hard limit for test)
2608 .with_hard_limit(2048); // 2KB hard limit
2609
2610 let mut engine = WriteEngine::new(config).unwrap();
2611
2612 // Write mutations until we hit the hard limit
2613 let mut write_count = 0;
2614 for i in 0..1000 {
2615 let mutation = create_test_mutation(i, &format!("User{}", i), 1000000 + i as i64);
2616 let result = engine.write_async(mutation).await;
2617
2618 match result {
2619 Ok(()) => {
2620 write_count += 1;
2621 }
2622 Err(Error::Storage(msg)) => {
2623 assert!(msg.contains("hard limit"));
2624 break;
2625 }
2626 Err(e) => panic!("Expected Storage error, got: {:?}", e),
2627 }
2628 }
2629
2630 // Should have stopped before 1000 writes due to hard limit
2631 assert!(
2632 write_count < 1000,
2633 "Should have hit hard limit before 1000 writes"
2634 );
2635 assert!(
2636 write_count > 0,
2637 "Should have accepted at least some writes before hitting limit"
2638 );
2639 }
2640
2641 #[tokio::test]
2642 async fn test_write_engine_hard_limit_recovery_after_flush() {
2643 let temp_dir = TempDir::new().unwrap();
2644 let schema = create_test_schema();
2645
2646 // Set low hard limit
2647 let config = WriteEngineConfig::new(
2648 temp_dir.path().join("data"),
2649 temp_dir.path().join("wal"),
2650 schema,
2651 )
2652 .with_flush_threshold(1024)
2653 .with_hard_limit(2048);
2654
2655 let mut engine = WriteEngine::new(config).unwrap();
2656
2657 // Write until hard limit
2658 let mut first_batch_count = 0;
2659 for i in 0..1000 {
2660 let mutation = create_test_mutation(i, &format!("User{}", i), 1000000 + i as i64);
2661 let result = engine.write(mutation);
2662
2663 if result.is_err() {
2664 break;
2665 }
2666
2667 first_batch_count += 1;
2668 }
2669
2670 assert!(
2671 first_batch_count > 0,
2672 "Should have accepted some writes before limit"
2673 );
2674
2675 // Flush to clear memtable
2676 engine.flush().await.unwrap();
2677
2678 // Should be able to write again after flush
2679 let mutation = create_test_mutation(9999, "After flush", 2000000);
2680 let result = engine.write(mutation);
2681 assert!(result.is_ok(), "Should accept writes after flush");
2682
2683 assert_eq!(engine.memtable_row_count(), 1);
2684 }
2685
2686 /// Issue #1620 (N2 — auto-flush cliff): the binding write path runs INSIDE a
2687 /// Tokio runtime, where the sync `write()`/`execute()` auto-flush is
2688 /// intentionally skipped (`Handle::try_current().is_err()` is false). On main
2689 /// that meant the memtable grew unbounded until the hard limit, then every
2690 /// write dead-ended. `execute_flushing` restores auto-flush via a REAL async
2691 /// flush.
2692 ///
2693 /// Negative control: with the OLD sync `execute()` path in this same
2694 /// runtime-present topology, no flush would ever fire, so `generation()`
2695 /// would stay `1` and eventually a write would hit the hard limit. Here we
2696 /// assert a real flush advanced the generation and no write dead-ended.
2697 #[tokio::test]
2698 async fn test_execute_flushing_auto_flushes_in_runtime() {
2699 let temp_dir = TempDir::new().unwrap();
2700 let schema = create_test_schema();
2701
2702 // Tiny flush threshold (4KB), default 256MB hard limit.
2703 let config = WriteEngineConfig::new(
2704 temp_dir.path().join("data"),
2705 temp_dir.path().join("wal"),
2706 schema,
2707 )
2708 .with_flush_threshold(4096)
2709 .with_durability(Durability::Disabled);
2710
2711 let mut engine = WriteEngine::new(config).unwrap();
2712 assert_eq!(engine.generation(), 1);
2713
2714 // We are inside a runtime (this is #[tokio::test]) — the exact topology
2715 // the bindings hit. Drive enough inserts to cross the tiny threshold
2716 // many times.
2717 for i in 0..2000 {
2718 let stmt = format!(
2719 "INSERT INTO test_ks.test_table (id, name) VALUES ({}, 'User{}')",
2720 i, i
2721 );
2722 let n = engine
2723 .execute_flushing(&stmt)
2724 .await
2725 .expect("execute_flushing must not dead-end at the hard limit");
2726 assert_eq!(n, 1, "single INSERT applies exactly one mutation");
2727 }
2728
2729 // A real async flush happened: generation advanced past 1.
2730 assert!(
2731 engine.generation() > 1,
2732 "expected auto-flush to advance generation, got {} (would stay 1 on the old sync path in a runtime)",
2733 engine.generation()
2734 );
2735 }
2736
2737 /// Issue #1620: the over-threshold `log::warn!` must fire at most once per
2738 /// threshold crossing. With a runtime present the sync `write()` path does
2739 /// NOT auto-flush, so the memtable stays over threshold across many writes;
2740 /// the `warned_over_threshold` guard prevents re-warning until the next
2741 /// flush resets it. This asserts the private flag directly (same module) so
2742 /// it is deterministic without capturing log output.
2743 #[tokio::test]
2744 async fn test_over_threshold_warn_fires_at_most_once_per_crossing() {
2745 let temp_dir = TempDir::new().unwrap();
2746 let schema = create_test_schema();
2747
2748 // Tiny flush threshold so a couple of writes cross it; generous hard
2749 // limit so the sync path keeps accepting writes over threshold.
2750 let config = WriteEngineConfig::new(
2751 temp_dir.path().join("data"),
2752 temp_dir.path().join("wal"),
2753 schema,
2754 )
2755 .with_flush_threshold(256)
2756 .with_hard_limit(64 * 1024)
2757 .with_durability(Durability::Disabled);
2758
2759 let mut engine = WriteEngine::new(config).unwrap();
2760 assert!(
2761 !engine.warned_over_threshold,
2762 "guard starts false before any crossing"
2763 );
2764
2765 // Sync path inside a runtime: no auto-flush, so the memtable climbs and
2766 // stays over threshold. First over-threshold write flips the guard.
2767 let mut crossed = false;
2768 for i in 0..500 {
2769 engine
2770 .write(create_test_mutation(
2771 i,
2772 &format!("User{}", i),
2773 1_000_000 + i as i64,
2774 ))
2775 .unwrap();
2776 if engine.warned_over_threshold {
2777 crossed = true;
2778 break;
2779 }
2780 }
2781 assert!(crossed, "memtable should have crossed the flush threshold");
2782 assert!(
2783 engine.warned_over_threshold,
2784 "guard set after first crossing"
2785 );
2786
2787 // Subsequent over-threshold writes must NOT re-arm/clear the guard.
2788 for i in 500..600 {
2789 engine
2790 .write(create_test_mutation(
2791 i,
2792 &format!("User{}", i),
2793 1_000_000 + i as i64,
2794 ))
2795 .unwrap();
2796 assert!(
2797 engine.warned_over_threshold,
2798 "guard must remain set across subsequent over-threshold writes"
2799 );
2800 }
2801
2802 // An explicit flush clears the memtable and resets the guard, so the
2803 // next crossing warns again.
2804 engine.flush().await.unwrap();
2805 assert!(
2806 !engine.warned_over_threshold,
2807 "guard resets to false after a successful flush"
2808 );
2809 }
2810
2811 #[test]
2812 fn test_generation_counter_is_u64() {
2813 // Verify that generation counter is u64 to prevent overflow (Issue #410)
2814 let temp_dir = TempDir::new().unwrap();
2815 let schema = create_test_schema();
2816
2817 let config = WriteEngineConfig::new(
2818 temp_dir.path().join("data"),
2819 temp_dir.path().join("wal"),
2820 schema,
2821 );
2822
2823 let engine = WriteEngine::new(config).unwrap();
2824
2825 // Verify type by checking the value is within u64 range
2826 let generation: u64 = engine.generation();
2827 assert_eq!(generation, 1u64);
2828
2829 // This compile-time check ensures generation() returns u64
2830 // If it returned u32, this assignment would be a no-op but still compile
2831 let _type_check: u64 = generation;
2832
2833 // Verify that u64 can handle generations beyond u32::MAX
2834 // This would overflow with u32 (max value: 4,294,967,295)
2835 let large_generation: u64 = u32::MAX as u64 + 1000;
2836 assert!(large_generation > u32::MAX as u64);
2837 assert_eq!(large_generation, 4_294_968_295u64);
2838 }
2839
2840 #[test]
2841 fn test_determine_next_generation_large_numbers() {
2842 // Verify that generation parsing handles large u64 values (Issue #410)
2843 let temp_dir = TempDir::new().unwrap();
2844 let data_dir = temp_dir.path().join("data");
2845 std::fs::create_dir_all(&data_dir).unwrap();
2846
2847 // Create dummy SSTable files with large generation numbers
2848 // These would overflow if we used u32 (max: 4,294,967,295)
2849 let large_gen: u64 = u32::MAX as u64 + 100;
2850 std::fs::write(data_dir.join(format!("nb-{}-big-Data.db", large_gen)), b"").unwrap();
2851
2852 let generation = WriteEngine::determine_next_generation(&data_dir).unwrap();
2853 assert_eq!(generation, large_gen + 1);
2854 assert!(generation > u32::MAX as u64);
2855 }
2856
2857 // ============================================================================
2858 // Issue #547: WAL durability toggle tests
2859 // ============================================================================
2860
2861 /// `Durability::SyncEachWrite` is the default variant.
2862 #[test]
2863 fn test_durability_default_is_sync_each_write() {
2864 assert_eq!(Durability::default(), Durability::SyncEachWrite);
2865 }
2866
2867 /// `WriteEngineConfig` defaults to `Durability::SyncEachWrite`.
2868 #[test]
2869 fn test_config_default_durability() {
2870 let temp_dir = TempDir::new().unwrap();
2871 let schema = create_test_schema();
2872 let config = WriteEngineConfig::new(
2873 temp_dir.path().join("data"),
2874 temp_dir.path().join("wal"),
2875 schema,
2876 );
2877 assert_eq!(config.durability, Durability::SyncEachWrite);
2878 }
2879
2880 /// `with_durability` builder sets the field and returns `Self`.
2881 #[test]
2882 fn test_config_with_durability_builder() {
2883 let temp_dir = TempDir::new().unwrap();
2884 let schema = create_test_schema();
2885 let config = WriteEngineConfig::new(
2886 temp_dir.path().join("data"),
2887 temp_dir.path().join("wal"),
2888 schema,
2889 )
2890 .with_durability(Durability::Disabled);
2891 assert_eq!(config.durability, Durability::Disabled);
2892 }
2893
2894 /// With `Durability::SyncEachWrite`, the WAL grows after each `write`.
2895 #[test]
2896 fn test_wal_on_produces_wal_growth() {
2897 let temp_dir = TempDir::new().unwrap();
2898 let schema = create_test_schema();
2899 let config = WriteEngineConfig::new(
2900 temp_dir.path().join("data"),
2901 temp_dir.path().join("wal"),
2902 schema,
2903 )
2904 .with_durability(Durability::SyncEachWrite);
2905
2906 let mut engine = WriteEngine::new(config).unwrap();
2907 assert_eq!(engine.wal_size(), 0, "WAL must start empty");
2908
2909 let mutation = create_test_mutation(1, "Alice", 1_000_000);
2910 engine.write(mutation).unwrap();
2911
2912 assert!(
2913 engine.wal_size() > 0,
2914 "WAL must grow after write with SyncEachWrite"
2915 );
2916 }
2917
2918 /// With `Durability::Disabled`, the WAL is never written — `wal_size()` stays 0.
2919 #[test]
2920 fn test_wal_off_produces_no_wal_growth() {
2921 let temp_dir = TempDir::new().unwrap();
2922 let schema = create_test_schema();
2923 let config = WriteEngineConfig::new(
2924 temp_dir.path().join("data"),
2925 temp_dir.path().join("wal"),
2926 schema,
2927 )
2928 .with_durability(Durability::Disabled);
2929
2930 let mut engine = WriteEngine::new(config).unwrap();
2931 assert_eq!(engine.wal_size(), 0, "WAL must start empty");
2932
2933 // Write several mutations — none should touch the WAL.
2934 for i in 0..10 {
2935 let mutation = create_test_mutation(i, &format!("User{}", i), 1_000_000 + i as i64);
2936 engine.write(mutation).unwrap();
2937 }
2938
2939 assert_eq!(
2940 engine.wal_size(),
2941 0,
2942 "WAL must remain empty with Durability::Disabled"
2943 );
2944 assert_eq!(
2945 engine.memtable_row_count(),
2946 10,
2947 "Mutations must reach the memtable even without WAL"
2948 );
2949 }
2950
2951 /// With `Durability::Disabled`, async writes also skip the WAL.
2952 #[tokio::test]
2953 async fn test_wal_off_write_async_produces_no_wal_growth() {
2954 let temp_dir = TempDir::new().unwrap();
2955 let schema = create_test_schema();
2956 let config = WriteEngineConfig::new(
2957 temp_dir.path().join("data"),
2958 temp_dir.path().join("wal"),
2959 schema,
2960 )
2961 .with_durability(Durability::Disabled);
2962
2963 let mut engine = WriteEngine::new(config).unwrap();
2964
2965 for i in 0..5 {
2966 let mutation = create_test_mutation(i, &format!("User{}", i), 1_000_000 + i as i64);
2967 engine.write_async(mutation).await.unwrap();
2968 }
2969
2970 assert_eq!(
2971 engine.wal_size(),
2972 0,
2973 "WAL must remain empty with Durability::Disabled (async path)"
2974 );
2975 assert_eq!(engine.memtable_row_count(), 5);
2976 }
2977
2978 /// With `Durability::Disabled`, data that was never WAL'd is NOT replayed on
2979 /// restart — confirming the documented durability trade-off.
2980 #[test]
2981 fn test_wal_off_no_replay_on_restart() {
2982 let temp_dir = TempDir::new().unwrap();
2983 let schema = create_test_schema();
2984
2985 {
2986 let config = WriteEngineConfig::new(
2987 temp_dir.path().join("data"),
2988 temp_dir.path().join("wal"),
2989 schema.clone(),
2990 )
2991 .with_durability(Durability::Disabled);
2992
2993 let mut engine = WriteEngine::new(config).unwrap();
2994
2995 for i in 0..5 {
2996 let mutation = create_test_mutation(i, &format!("User{}", i), 1_000_000 + i as i64);
2997 engine.write(mutation).unwrap();
2998 }
2999
3000 // Drop without flushing — simulating crash.
3001 }
3002
3003 // Reopen with default durability. Because the WAL was never written, the
3004 // memtable must be empty.
3005 let config2 = WriteEngineConfig::new(
3006 temp_dir.path().join("data"),
3007 temp_dir.path().join("wal"),
3008 schema,
3009 );
3010 let engine2 = WriteEngine::new(config2).unwrap();
3011
3012 assert_eq!(
3013 engine2.memtable_row_count(),
3014 0,
3015 "No WAL entries were written with Disabled, so no replay is possible"
3016 );
3017 }
3018
3019 /// With `Durability::SyncEachWrite`, mutations ARE replayed after a simulated crash.
3020 #[test]
3021 fn test_wal_on_replays_on_restart() {
3022 let temp_dir = TempDir::new().unwrap();
3023 let schema = create_test_schema();
3024
3025 {
3026 let config = WriteEngineConfig::new(
3027 temp_dir.path().join("data"),
3028 temp_dir.path().join("wal"),
3029 schema.clone(),
3030 )
3031 .with_durability(Durability::SyncEachWrite);
3032
3033 let mut engine = WriteEngine::new(config).unwrap();
3034
3035 for i in 0..5 {
3036 let mutation = create_test_mutation(i, &format!("User{}", i), 1_000_000 + i as i64);
3037 engine.write(mutation).unwrap();
3038 }
3039
3040 // Drop without flushing — WAL entries remain on disk.
3041 }
3042
3043 // Reopen — WAL replay must restore the 5 mutations.
3044 let config2 = WriteEngineConfig::new(
3045 temp_dir.path().join("data"),
3046 temp_dir.path().join("wal"),
3047 schema,
3048 )
3049 .with_durability(Durability::SyncEachWrite);
3050
3051 let engine2 = WriteEngine::new(config2).unwrap();
3052
3053 assert_eq!(
3054 engine2.memtable_row_count(),
3055 5,
3056 "SyncEachWrite must replay mutations durably on restart"
3057 );
3058 }
3059
3060 // ============================================================================
3061 // Issue #485: write_dir file lock tests
3062 // ============================================================================
3063
3064 /// A second WriteEngine on the same write_dir must fail fast with a clear error
3065 /// while the first engine is still open.
3066 #[test]
3067 fn test_write_dir_lock_second_engine_fails_fast() {
3068 let temp_dir = TempDir::new().unwrap();
3069 let schema = create_test_schema();
3070
3071 let config1 = WriteEngineConfig::new(
3072 temp_dir.path().join("data"),
3073 temp_dir.path().join("wal"),
3074 schema.clone(),
3075 );
3076
3077 // First engine acquires the lock.
3078 let _engine1 = WriteEngine::new(config1).unwrap();
3079
3080 // Second engine on the same wal_dir must fail immediately.
3081 let config2 = WriteEngineConfig::new(
3082 temp_dir.path().join("data"),
3083 temp_dir.path().join("wal"),
3084 schema,
3085 );
3086
3087 let result = WriteEngine::new(config2);
3088 assert!(
3089 result.is_err(),
3090 "A second WriteEngine on the same write_dir must fail"
3091 );
3092
3093 let err = result.unwrap_err();
3094 assert!(
3095 matches!(err, Error::WriteDirLocked { .. }),
3096 "Expected WriteDirLocked error, got: {:?}",
3097 err
3098 );
3099
3100 // Error message must contain the path and actionable advice.
3101 let msg = err.to_string();
3102 assert!(
3103 msg.contains("already locked"),
3104 "Error message must mention the lock: {}",
3105 msg
3106 );
3107 assert!(
3108 msg.contains("Only one Database instance"),
3109 "Error message must explain the constraint: {}",
3110 msg
3111 );
3112 }
3113
3114 /// After the first engine is closed, a new engine must successfully acquire the lock.
3115 #[tokio::test]
3116 async fn test_write_dir_lock_reacquired_after_close() {
3117 let temp_dir = TempDir::new().unwrap();
3118 let schema = create_test_schema();
3119
3120 let config1 = WriteEngineConfig::new(
3121 temp_dir.path().join("data"),
3122 temp_dir.path().join("wal"),
3123 schema.clone(),
3124 );
3125
3126 // First engine acquires the lock.
3127 let mut engine1 = WriteEngine::new(config1).unwrap();
3128
3129 // Close releases the lock.
3130 engine1.close().await.unwrap();
3131
3132 // A new engine on the same directory must now succeed.
3133 let config2 = WriteEngineConfig::new(
3134 temp_dir.path().join("data"),
3135 temp_dir.path().join("wal"),
3136 schema,
3137 );
3138
3139 let engine2 = WriteEngine::new(config2);
3140 assert!(
3141 engine2.is_ok(),
3142 "WriteEngine must acquire lock after the previous engine closed: {:?}",
3143 engine2.err()
3144 );
3145 }
3146
3147 /// After the first engine is dropped (without calling close()), a new engine
3148 /// must also successfully acquire the lock.
3149 #[test]
3150 fn test_write_dir_lock_reacquired_after_drop() {
3151 let temp_dir = TempDir::new().unwrap();
3152 let schema = create_test_schema();
3153
3154 let config1 = WriteEngineConfig::new(
3155 temp_dir.path().join("data"),
3156 temp_dir.path().join("wal"),
3157 schema.clone(),
3158 );
3159
3160 // First engine acquires the lock; drop releases it.
3161 {
3162 let _engine1 = WriteEngine::new(config1).unwrap();
3163 // engine1 dropped here, lock released
3164 }
3165
3166 // A new engine on the same directory must now succeed.
3167 let config2 = WriteEngineConfig::new(
3168 temp_dir.path().join("data"),
3169 temp_dir.path().join("wal"),
3170 schema,
3171 );
3172
3173 let engine2 = WriteEngine::new(config2);
3174 assert!(
3175 engine2.is_ok(),
3176 "WriteEngine must acquire lock after the previous engine was dropped: {:?}",
3177 engine2.err()
3178 );
3179 }
3180
3181 // Issue #1392 (roborev r4): when the WAL truncate fails AFTER `set_len(0)`
3182 // has already zeroed the WAL, the just-published SSTable is the ONLY durable
3183 // copy of the flushed mutations. The flush must therefore treat the SSTable
3184 // handoff as COMMITTED for state purposes — clear the memtable and advance
3185 // the generation BEFORE surfacing the error — so that a retry writes a NEW
3186 // generation instead of `File::create`-overwriting the published SSTable
3187 // (which would lose the data on a crash mid-retry).
3188 //
3189 // This drives the real `RealDurabilityBarrier` path via the WAL's test-only
3190 // post-`set_len(0)` sync fault, then proves: (1) the flush surfaces the
3191 // error, (2) state is committed (memtable cleared, generation advanced),
3192 // (3) the published gen-1 SSTable survives byte-for-byte, (4) a subsequent
3193 // flush writes a NEW generation, and (5) a full read returns each mutation
3194 // exactly once — crash-safe, no loss, no duplication.
3195 #[tokio::test]
3196 async fn post_mutation_truncate_failure_commits_and_retry_writes_new_generation() {
3197 use crate::platform::Platform;
3198 use crate::storage::sstable::SSTableManager;
3199 use crate::Config;
3200 use std::sync::Arc;
3201
3202 let temp_dir = TempDir::new().unwrap();
3203 let schema = create_test_schema();
3204 let data_dir = temp_dir.path().join("data");
3205 let config = WriteEngineConfig::new(
3206 data_dir.clone(),
3207 temp_dir.path().join("wal"),
3208 schema.clone(),
3209 );
3210 let mut engine = WriteEngine::new(config).unwrap();
3211
3212 // Write the first mutation, then arm a post-set_len(0) WAL-truncate fault
3213 // so the flush finalize hits the `AfterMutation` path.
3214 engine
3215 .write(create_test_mutation(1, "Alice", 1_000_000))
3216 .unwrap();
3217 let gen_before = engine.generation();
3218 engine.wal.set_fail_sync_after_truncate(true);
3219
3220 let err = engine
3221 .flush()
3222 .await
3223 .expect_err("a post-mutation WAL-truncate failure must surface as an error");
3224 assert!(
3225 matches!(err, Error::Storage(_)),
3226 "post-mutation truncate failure must surface as a storage error, got {err:?}"
3227 );
3228
3229 // State is COMMITTED despite the error: the memtable is cleared and the
3230 // generation has advanced, so a retry cannot reuse the published gen.
3231 assert_eq!(
3232 engine.memtable_row_count(),
3233 0,
3234 "memtable must be cleared once the SSTable is durably published"
3235 );
3236 assert_eq!(
3237 engine.generation(),
3238 gen_before + 1,
3239 "generation must advance so a retry writes a NEW generation"
3240 );
3241
3242 // The published gen-1 SSTable exists on disk. Snapshot its bytes to prove
3243 // a later flush does NOT overwrite it.
3244 let sstable_dir = data_dir.join("test_ks").join("test_table");
3245 let gen1_data = sstable_dir.join("nb-1-big-Data.db");
3246 assert!(
3247 gen1_data.exists(),
3248 "the published gen-1 Data.db must exist on disk after the faulted flush"
3249 );
3250 let gen1_bytes = std::fs::read(&gen1_data).unwrap();
3251
3252 // Disarm the fault and flush a SECOND mutation: it must write a NEW
3253 // generation and leave the first SSTable byte-for-byte intact.
3254 engine.wal.set_fail_sync_after_truncate(false);
3255 engine
3256 .write(create_test_mutation(2, "Bob", 2_000_000))
3257 .unwrap();
3258 engine
3259 .flush()
3260 .await
3261 .expect("second flush must succeed")
3262 .expect("second flush must produce an SSTable");
3263
3264 let gen2_data = sstable_dir.join("nb-2-big-Data.db");
3265 assert!(
3266 gen2_data.exists(),
3267 "the retry must write a NEW generation (nb-2), not overwrite nb-1"
3268 );
3269 assert_eq!(
3270 std::fs::read(&gen1_data).unwrap(),
3271 gen1_bytes,
3272 "the retry must NOT overwrite the published gen-1 SSTable"
3273 );
3274
3275 // Full read across both generations: each mutation appears exactly once.
3276 let cqlite_config = Config::default();
3277 let platform = Arc::new(Platform::new(&cqlite_config).await.unwrap());
3278 let manager = SSTableManager::new(
3279 &data_dir,
3280 &cqlite_config,
3281 platform,
3282 #[cfg(feature = "state_machine")]
3283 None,
3284 )
3285 .await
3286 .expect("SSTableManager must load both published generations");
3287
3288 let table_id = crate::types::TableId::from("test_ks.test_table");
3289 let rows = manager
3290 .scan(&table_id, None, None, None, Some(&schema))
3291 .await
3292 .expect("scan across both generations must succeed");
3293 assert_eq!(
3294 rows.len(),
3295 2,
3296 "both mutations must be readable exactly once (no loss, no duplication)"
3297 );
3298 }
3299
3300 /// Minimal thread-local log capturer for the Drop-warn tests (issue #1693).
3301 ///
3302 /// A `log` logger can only be installed process-wide once and is shared by
3303 /// every test in this binary, so it records into a *thread-local* buffer.
3304 /// cargo runs each test on its own thread, so a thread-local buffer isolates
3305 /// concurrent tests from one another. No external crate is pulled in.
3306 mod drop_warn_capture {
3307 use log::{Level, LevelFilter, Log, Metadata, Record};
3308 use std::cell::RefCell;
3309 use std::sync::Once;
3310
3311 thread_local! {
3312 static BUFFER: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
3313 }
3314
3315 struct ThreadLocalCapture;
3316
3317 impl Log for ThreadLocalCapture {
3318 fn enabled(&self, _metadata: &Metadata) -> bool {
3319 true
3320 }
3321
3322 fn log(&self, record: &Record) {
3323 if record.level() == Level::Warn {
3324 BUFFER.with(|b| {
3325 if let Some(buf) = b.borrow_mut().as_mut() {
3326 buf.push(record.args().to_string());
3327 }
3328 });
3329 }
3330 }
3331
3332 fn flush(&self) {}
3333 }
3334
3335 static INSTALL: Once = Once::new();
3336
3337 /// Install the capturing logger process-wide (idempotent) and begin
3338 /// capturing warnings on the current thread. If another logger was
3339 /// already installed the buffer stays available on this thread but no
3340 /// records arrive, which fails the assertions loudly rather than
3341 /// silently passing.
3342 pub(super) fn start() {
3343 INSTALL.call_once(|| {
3344 let _ = log::set_boxed_logger(Box::new(ThreadLocalCapture));
3345 log::set_max_level(LevelFilter::Trace);
3346 });
3347 BUFFER.with(|b| *b.borrow_mut() = Some(Vec::new()));
3348 }
3349
3350 /// Take the warnings captured on the current thread since `start()`.
3351 pub(super) fn take_warnings() -> Vec<String> {
3352 BUFFER.with(|b| b.borrow_mut().take().unwrap_or_default())
3353 }
3354 }
3355
3356 /// Issue #1693 (AG4): dropping a `WriteEngine` whose memtable still holds
3357 /// un-flushed rows (i.e. `close()` was never called) must emit a `warn!` so
3358 /// the silent data-loss mode is visible in logs.
3359 #[test]
3360 fn test_drop_without_close_warns_on_nonempty_memtable() {
3361 drop_warn_capture::start();
3362
3363 let temp_dir = TempDir::new().unwrap();
3364 let schema = create_test_schema();
3365 let config = WriteEngineConfig::new(
3366 temp_dir.path().join("data"),
3367 temp_dir.path().join("wal"),
3368 schema,
3369 );
3370 let mut engine = WriteEngine::new(config).unwrap();
3371 engine
3372 .write(create_test_mutation(1, "Alice", 1_000_000))
3373 .unwrap();
3374 assert!(
3375 engine.memtable_row_count() > 0,
3376 "precondition: memtable must be non-empty before drop"
3377 );
3378
3379 // Drop WITHOUT close() — the durability-loss path.
3380 drop(engine);
3381
3382 let warnings = drop_warn_capture::take_warnings();
3383 assert!(
3384 warnings
3385 .iter()
3386 .any(|m| m.contains("dropped") && m.contains("without close")),
3387 "expected an unflushed-drop warning, captured warnings: {warnings:?}"
3388 );
3389 // WAL-durable engine: the guidance must point at WAL recovery, NOT loss.
3390 assert!(
3391 warnings.iter().any(|m| m.contains("WAL replay")),
3392 "WAL-durable drop warning must mention WAL replay recovery, captured: {warnings:?}"
3393 );
3394 assert!(
3395 !warnings.iter().any(|m| m.contains("LOST")),
3396 "WAL-durable drop warning must NOT claim data loss, captured: {warnings:?}"
3397 );
3398 }
3399
3400 /// Issue #1693 (roborev): with `Durability::Disabled` the WAL is skipped, so
3401 /// an ungraceful drop loses the un-flushed rows. The warning must say the
3402 /// rows are LOST rather than giving false "recoverable from the WAL" guidance.
3403 #[test]
3404 fn test_drop_without_close_warns_data_loss_when_durability_disabled() {
3405 drop_warn_capture::start();
3406
3407 let temp_dir = TempDir::new().unwrap();
3408 let schema = create_test_schema();
3409 let config = WriteEngineConfig::new(
3410 temp_dir.path().join("data"),
3411 temp_dir.path().join("wal"),
3412 schema,
3413 )
3414 .with_durability(Durability::Disabled);
3415 let mut engine = WriteEngine::new(config).unwrap();
3416 engine
3417 .write(create_test_mutation(1, "Alice", 1_000_000))
3418 .unwrap();
3419 assert!(
3420 engine.memtable_row_count() > 0,
3421 "precondition: memtable must be non-empty before drop"
3422 );
3423
3424 drop(engine);
3425
3426 let warnings = drop_warn_capture::take_warnings();
3427 assert!(
3428 warnings
3429 .iter()
3430 .any(|m| m.contains("dropped") && m.contains("without close")),
3431 "expected an unflushed-drop warning, captured warnings: {warnings:?}"
3432 );
3433 // No-WAL engine: the warning must signal data loss, NOT WAL recovery.
3434 assert!(
3435 warnings.iter().any(|m| m.contains("LOST")),
3436 "Durability::Disabled drop warning must signal data loss, captured: {warnings:?}"
3437 );
3438 assert!(
3439 !warnings.iter().any(|m| m.contains("WAL replay")),
3440 "Durability::Disabled drop warning must NOT promise WAL recovery, captured: {warnings:?}"
3441 );
3442 }
3443
3444 /// Control for issue #1693: after a graceful `close()` the memtable is empty,
3445 /// so `Drop` must NOT emit the unflushed-drop warning.
3446 #[tokio::test]
3447 async fn test_drop_after_close_does_not_warn() {
3448 drop_warn_capture::start();
3449
3450 let temp_dir = TempDir::new().unwrap();
3451 let schema = create_test_schema();
3452 let config = WriteEngineConfig::new(
3453 temp_dir.path().join("data"),
3454 temp_dir.path().join("wal"),
3455 schema,
3456 );
3457 let mut engine = WriteEngine::new(config).unwrap();
3458 engine
3459 .write(create_test_mutation(1, "Alice", 1_000_000))
3460 .unwrap();
3461 engine.close().await.unwrap();
3462
3463 drop(engine);
3464
3465 let warnings = drop_warn_capture::take_warnings();
3466 assert!(
3467 !warnings
3468 .iter()
3469 .any(|m| m.contains("dropped") && m.contains("without close")),
3470 "close() flushed the memtable; Drop must not warn, captured: {warnings:?}"
3471 );
3472 }
3473}