Skip to main content

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}