Skip to main content

cqlite_core/storage/write_engine/
maintenance.rs

1//! SSTable maintenance and STCS compaction for the write engine.
2//!
3//! Extracted verbatim from `write_engine/mod.rs` (issue #1120, epic #1116) as a
4//! behavior-preserving split. Owns the incremental K-way merge state machine
5//! (`maintenance_step`), candidate scanning, startup orphan sweeps, atomic
6//! input deletion, and the public `MaintenanceReport` type. `WriteEngine`'s
7//! fields are reachable here because this is a sibling module in the same crate.
8
9use super::merge;
10// `Mutation` is named in production again (issue #1383 fix): a partition's
11// surviving clustering rows are BUFFERED as `Vec<Mutation>` in
12// `PartitionStreamState` and written in one session at partition end, once
13// the full range-tombstone set is known — see that type's doc.
14use super::mutation::{DecoratedKey, Mutation, PartitionTombstone, RangeTombstone};
15use super::{CompactionStats, KWayMerger, MergePolicy, WriteEngine};
16use crate::error::{Error, Result};
17use crate::schema::TableSchema;
18use crate::storage::sstable::writer::data_writer::StaticOpsTracker;
19use crate::storage::sstable::writer::stats_fold;
20use crate::storage::sstable::writer::StatisticsMetadata;
21use std::path::{Path, PathBuf};
22use std::sync::atomic::Ordering;
23use std::time::{Duration, Instant};
24
25/// Per-partition streaming state (issue #1668, stage 5c-iv part 3; buffering
26/// reworked for the issue #1383 range-tombstone-boundary fix) — mirrors
27/// `KWayMerger::merge()`'s per-partition loop state exactly.
28///
29/// A bounded `clustering_key: None` prefix (partition/range-tombstone
30/// carriers, static-row carrier) is accumulated as it arrives; every
31/// surviving `Some(ck)` clustering row is BUFFERED into `buffered_rows`
32/// rather than fed to a writer session as it arrives. The
33/// [`StreamingPartitionSession`] is opened only at `StreamingStep::PartitionEnd`
34/// — once the partition's COMPLETE, coalesced range-tombstone set is known —
35/// and every buffered row is fed through it in one pass, then finished.
36///
37/// This is required for correctness (issue #1383): a range tombstone's
38/// coalesced marker is only surfaced by `StreamingMerger` once its CLOSE
39/// bound is parsed, which — for a range covering rows in a different (e.g.
40/// newer) generation — is strictly AFTER those rows have already streamed
41/// (issue #1668 stage 5d's "range tombstones can arrive AFTER the rows they
42/// cover" finding). Opening the session on the first `Some(ck)` row therefore
43/// fixed an often-EMPTY range-tombstone set, and every late range-only marker
44/// mutation was then dropped as a `feed_streaming_row` no-op — silently
45/// losing the shadowing/boundary. Buffering until the full set is known lets
46/// `feed_streaming_row` shadow every covered row and interleave the markers
47/// correctly, exactly as `KWayMerger::merge()`'s `buffered_rows` does for the
48/// direct-call path.
49///
50/// The whole struct (including `buffered_rows`) owns everything it needs, so
51/// it can be stashed on [`ActiveMerge::pending_partition`] and survive a
52/// mid-partition budget pause between `maintenance_step_inner` calls: the
53/// budget check still fires between cluster groups (each iteration buffers one
54/// group cheaply, then may pause), so a fat partition still yields control —
55/// only the WRITE is deferred to partition end. The buffer is
56/// whole-partition-width for range-tombstone-bearing partitions (a genuinely
57/// bounded-memory streaming write there needs the out-of-scope two-pass
58/// reader; the reader already materializes the decompressed section anyway),
59/// while `StreamingMerger`'s own reconciliation stays row-streamed — the
60/// memory bound the dhat proof actually exercises.
61pub(crate) struct PartitionStreamState {
62    partition_tombstone: Option<PartitionTombstone>,
63    range_tombstones: Vec<RangeTombstone>,
64    static_tracker: StaticOpsTracker,
65    static_first_ts: i64,
66    saw_carrier_or_static: bool,
67    /// This partition's stats fold accumulated so far. Threaded through the
68    /// state rather than folded straight into the writer's own `stats`
69    /// because the writer is borrowed per-call only at partition end.
70    partition_stats: StatisticsMetadata,
71    /// Mirrors `KWayMerger::merge()`'s loop-local `row_count`: incremented
72    /// once per buffered clustering row AND once per static-row carrier (not
73    /// for pure partition/range-tombstone carriers — issue #1668 stage 5c-iv
74    /// part 2's row-count parity finding), so it equals the number of
75    /// emittable rows regardless of pauses.
76    row_count: u64,
77    /// Surviving `Some(ck)` clustering-row mutations, buffered in arrival
78    /// (clustering) order and written in one session at partition end (see
79    /// the type doc for why buffering is required).
80    buffered_rows: Vec<Mutation>,
81}
82
83impl PartitionStreamState {
84    /// A fresh, empty accumulator — the starting state for any new partition
85    /// (never resumed from a pause).
86    fn fresh() -> Self {
87        PartitionStreamState {
88            partition_tombstone: None,
89            range_tombstones: Vec::new(),
90            static_tracker: StaticOpsTracker::new(),
91            static_first_ts: 0,
92            saw_carrier_or_static: false,
93            partition_stats: StatisticsMetadata::new(),
94            row_count: 0,
95            buffered_rows: Vec::new(),
96        }
97    }
98
99    /// This partition's stats-fold accumulator — every mutation (carrier,
100    /// static, or clustering row) folds through the SAME chokepoint before
101    /// classification, mirroring `KWayMerger::merge()`'s single fold point
102    /// (issue #1668 stage 5c-iv part 2).
103    fn partition_stats_mut(&mut self) -> &mut StatisticsMetadata {
104        &mut self.partition_stats
105    }
106}
107
108// `StaticOpsTracker`/`StreamingPartitionSession` do not implement `Debug`
109// (the former holds a non-`Debug` cell-value map; the latter is a plain
110// bookkeeping struct never printed in production), so `ActiveMerge`'s
111// `#[derive(Debug)]` (used only incidentally — no code actually formats an
112// `ActiveMerge`, confirmed by grep) needs a hand-written `Debug` here rather
113// than pulling `Debug` onto `StaticOpsTracker` just to satisfy a derive
114// nothing exercises.
115impl std::fmt::Debug for PartitionStreamState {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.debug_struct("PartitionStreamState")
118            .field("saw_carrier_or_static", &self.saw_carrier_or_static)
119            .field("row_count", &self.row_count)
120            .field("buffered_rows", &self.buffered_rows.len())
121            .finish_non_exhaustive()
122    }
123}
124
125/// Maintenance report from a maintenance_step() call (M5.2, Issue #384)
126#[derive(Debug, Clone)]
127pub struct MaintenanceReport {
128    /// Time spent in this maintenance step
129    pub time_spent: Duration,
130    /// Completed merge output files (if any merge completed)
131    pub completed_merges: Vec<PathBuf>,
132    /// Number of rows merged in this step
133    pub rows_merged: u64,
134    /// Number of bytes written in this step
135    pub bytes_written: u64,
136    /// Whether there is pending compaction work
137    pub pending_compaction: bool,
138    /// SSTables DROPPED WHOLE by the fully-expired fast path in the merge that
139    /// completed this step (issue #1388), distinct from the merged inputs: each was
140    /// proven fully expired by authoritative `Statistics.db` metadata and
141    /// overlap-safe, so it was excluded from the K-way merger (never read/decoded)
142    /// and its components were reclaimed after the merged output published. Empty
143    /// when nothing was dropped. Paths are input Data.db paths.
144    pub dropped_whole: Vec<PathBuf>,
145}
146
147/// Active merge state for incremental compaction (M5.2, Issue #384)
148#[derive(Debug)]
149pub(crate) struct ActiveMerge {
150    /// K-way merger performing the compaction
151    pub(crate) merger: KWayMerger,
152    /// Output SSTable writer (writes to `tmp_dir/keyspace/table/`)
153    pub(crate) writer: crate::storage::sstable::writer::SSTableWriter,
154    /// Input SSTable paths being merged (these remain intact until atomic rename succeeds)
155    pub(crate) input_paths: Vec<PathBuf>,
156    /// Root of the temporary directory tree used for this compaction output.
157    ///
158    /// The SSTableWriter appends `keyspace/table/` to this path, so component
159    /// files land at `tmp_dir/keyspace/table/nb-{gen}-big-*.{ext}`.
160    ///
161    /// After `writer.finish()` the files are atomically renamed to the final
162    /// SSTable directory. Only then are the inputs deleted.
163    ///
164    /// Invariant: if the process crashes before the renames complete, `tmp_dir`
165    /// may contain partial output but the input SSTables remain intact.
166    pub(crate) tmp_dir: PathBuf,
167    /// Final SSTable directory (`data_dir/keyspace/table/`)
168    ///
169    /// Stored here so `finalize_merge_async` doesn't have to recompute it.
170    pub(crate) sstable_dir: PathBuf,
171    /// Number of rows merged so far (updated per partition)
172    pub(crate) rows_merged: u64,
173    /// Total bytes read from input SSTables (approximate: sum of Data.db file sizes)
174    pub(crate) bytes_read: u64,
175    /// When this merge started
176    pub(crate) started_at: Instant,
177    /// Effective compaction schema (#850): the configured schema augmented with
178    /// any static columns that appear in the input SSTables' SerializationHeaders
179    /// but were dropped from the current schema. Used to convert merged entries to
180    /// mutations so the writer still emits the static-row prelude (static-column
181    /// presence is read from the input headers, not the current schema only).
182    pub(crate) effective_schema: TableSchema,
183    /// SSTables DROPPED WHOLE for this compaction (issue #1388): proven fully
184    /// expired by authoritative `Statistics.db` metadata and overlap-safe, EXCLUDED
185    /// from `input_paths` (never read into the merger). Reclaimed in
186    /// `finalize_merge_async` AFTER the merged output publishes, via the same
187    /// component-delete path as the merged inputs, and surfaced in the
188    /// `MaintenanceReport`. Empty when nothing was dropped.
189    pub(crate) dropped_whole: Vec<PathBuf>,
190    /// Mid-partition budget-check resumption state (issue #1668, stage 4 —
191    /// the "Q4 unlock"). `Some((key, remaining_rows, mutations_so_far))` when
192    /// a partition's cluster-group drain was PAUSED because the budget was
193    /// exceeded before the whole partition could be converted+written;
194    /// `None` when no partition is mid-drain (the common case — and the
195    /// ONLY case before this stage, when only whole partitions were ever
196    /// paused between, never within one).
197    ///
198    /// - `.0` the partition key (needed both to resume draining and for the
199    ///   eventual `write_partition` call).
200    /// - `.1` rows `KWayMerger::step()` ALREADY fully reconciled for this
201    ///   partition's raw-entry reconciliation state (carrier accumulator,
202    ///   in-progress cluster, already-reconciled-but-not-yet-popped rows —
203    ///   issue #1668 stage 5d widened this from a plain
204    ///   `VecDeque<MergeEntry>` once `StreamingMerger` stopped buffering a
205    ///   whole partition via `KWayMerger::step`). Extracted via
206    ///   `StreamingMerger::into_paused_state`, restored via
207    ///   `StreamingMerger::resume` — nothing here is re-computed by a
208    ///   resumed call, and nothing is lost. Held OPAQUELY: this file never
209    ///   inspects a field of it.
210    /// - `.2` this partition's [`PartitionStreamState`] (issue #1668, stage
211    ///   5c-iv part 3; buffering reworked for the issue #1383 fix): the
212    ///   accumulated partition tombstone / range-tombstone set / static row
213    ///   plus the buffered surviving clustering rows seen so far. The writer
214    ///   session is opened only at `PartitionEnd` (once the full
215    ///   range-tombstone set is known — see [`PartitionStreamState`]'s doc),
216    ///   so a paused partition stashes plain owned accumulators here.
217    ///   Resuming is just "keep buffering into this same value" — no
218    ///   re-computation, no lost rows. The buffer is whole-partition-width
219    ///   only for range-tombstone-bearing partitions (the documented
220    ///   residual); the budget pause still fires between cluster groups, so a
221    ///   fat partition still yields control mid-drain.
222    pub(crate) pending_partition: Option<(
223        DecoratedKey,
224        merge::PartitionReconcileCheckpoint,
225        PartitionStreamState,
226    )>,
227}
228
229impl WriteEngine {
230    /// Set the merge policy for background compaction (M5.2, Issue #383)
231    ///
232    /// # Arguments
233    ///
234    /// * `policy` - Merge policy implementation (e.g., STCS, LCS, TWCS)
235    pub fn set_merge_policy(&mut self, policy: Box<dyn MergePolicy>) -> Result<()> {
236        self.merge_policy = Some(policy);
237        Ok(())
238    }
239
240    /// Return cumulative compaction statistics (M5.2, Issue #474)
241    ///
242    /// Returns a snapshot of the lifetime totals accumulated across all compaction
243    /// cycles that have completed since the `WriteEngine` was created. The snapshot
244    /// is cheaply cloneable and safe to inspect from any thread (no lock required,
245    /// because `WriteEngine` itself is not `Sync`).
246    ///
247    /// # Example
248    ///
249    /// ```rust,ignore
250    /// let stats = engine.maintenance_stats();
251    /// println!(
252    ///     "Completed {} compactions, merged {} rows, wrote {} bytes",
253    ///     stats.compactions_completed,
254    ///     stats.rows_merged,
255    ///     stats.bytes_written,
256    /// );
257    /// ```
258    pub fn maintenance_stats(&self) -> CompactionStats {
259        self.cumulative_stats.clone()
260    }
261
262    /// Perform incremental maintenance work (M5.2, Issue #384)
263    ///
264    /// This method performs background compaction work within a time budget.
265    /// It can be called repeatedly from a background thread or task scheduler
266    /// to make incremental progress on compaction.
267    ///
268    /// ## Runtime contexts
269    ///
270    /// This is a synchronous method, but its internal async-to-sync bridge is
271    /// runtime-aware (see [`merge::block_on_async`]), so it is safe to call from
272    /// **either** a plain synchronous context **or** from within an active Tokio
273    /// runtime — including `#[tokio::main]`/`#[tokio::test]` worker threads and
274    /// `async fn` callers. Prior to Issue #587 calling it from inside a runtime
275    /// panicked with "Cannot start a runtime from within a runtime" once a merge
276    /// had input SSTables to read. The sync signature is preserved so the CLI and
277    /// Python bindings can keep calling it directly. (The Node binding wraps it in
278    /// `spawn_blocking`, which remains correct.)
279    ///
280    /// ## Behavior
281    ///
282    /// 1. If no active merge exists, consult the merge policy for work
283    /// 2. If merge work is available, start a new merge
284    /// 3. Process the active merge until budget is exhausted
285    /// 4. Return progress report
286    ///
287    /// ## Invariants
288    ///
289    /// - Budget is honored within 10% tolerance
290    /// - At least one CLUSTER GROUP is processed per call (minimum progress
291    ///   guarantee; issue #1668 stage 4 loosened this from "at least one
292    ///   partition" — a single oversized partition no longer blocks the
293    ///   budget for its entire duration)
294    /// - Merge state is preserved across calls for resumption, INCLUDING a
295    ///   partition whose cluster-group drain was paused mid-way
296    ///   (`ActiveMerge::pending_partition`, issue #1668 stage 4) — resuming
297    ///   never re-computes or loses a row, and the writer always still
298    ///   receives one partition's mutations in one `write_partition` call.
299    ///
300    /// ## Budget Enforcement
301    ///
302    /// The budget is honored within approximately 10% tolerance, checked
303    /// BETWEEN CLUSTER GROUPS (issue #1668 stage 4) rather than only between
304    /// whole partitions — a fat partition can now yield control back to the
305    /// budget check partway through its own drain instead of running to
306    /// completion regardless of elapsed time. The tolerance ensures forward
307    /// progress on each call while remaining responsive to time constraints.
308    ///
309    /// # Arguments
310    ///
311    /// * `budget` - Maximum time to spend in this call
312    ///
313    /// # Returns
314    ///
315    /// A report containing progress metrics and whether more work is pending.
316    ///
317    /// # Errors
318    ///
319    /// Returns an error if:
320    /// - Engine has been closed
321    /// - Merge policy returns an error
322    /// - SSTable reading or writing fails
323    ///
324    /// # Example
325    ///
326    /// ```rust,ignore
327    /// use std::time::Duration;
328    ///
329    /// // Background compaction loop
330    /// loop {
331    ///     let report = engine.maintenance_step(Duration::from_millis(100))?;
332    ///
333    ///     if !report.pending_compaction {
334    ///         // No more work, sleep or exit
335    ///         break;
336    ///     }
337    ///
338    ///     // Log progress
339    ///     println!("Merged {} rows in {:?}", report.rows_merged, report.time_spent);
340    /// }
341    /// ```
342    #[tracing::instrument(name = "compaction.maintenance_step", level = "debug", skip(self))]
343    pub fn maintenance_step(&mut self, budget: Duration) -> Result<MaintenanceReport> {
344        // Budget requested for this step (issue #1037). Compared with the
345        // consumed budget below (the scheduler honors a ~10% tolerance).
346        crate::observability::record_histogram(
347            crate::observability::catalog::COMPACTION_BUDGET_REQUESTED,
348            budget.as_secs_f64(),
349            &[],
350        );
351
352        let result = self.maintenance_step_inner(budget);
353
354        // Budget consumed + lifetime-throughput counters (issue #1037). Recorded
355        // for every step (even a no-op one) so the budget-tolerance signal is
356        // complete; rows-merged is per-step and feeds the throughput rate when
357        // combined with COMPACTION_DURATION at finalize.
358        if let Ok(report) = &result {
359            use crate::observability::{self as obs, catalog};
360            obs::record_histogram(
361                catalog::COMPACTION_BUDGET_CONSUMED,
362                report.time_spent.as_secs_f64(),
363                &[],
364            );
365            obs::add_counter(catalog::COMPACTION_ROWS_MERGED, report.rows_merged, &[]);
366            obs::record_gauge(catalog::COMPACTION_LAG, self.l0_count as i64, &[]);
367        }
368
369        crate::observability::record_result("compaction", result)
370    }
371
372    fn maintenance_step_inner(&mut self, budget: Duration) -> Result<MaintenanceReport> {
373        if self.closed.load(Ordering::SeqCst) {
374            return Err(Error::InvalidInput(
375                "WriteEngine has been closed".to_string(),
376            ));
377        }
378
379        let start = Instant::now();
380        let mut report = MaintenanceReport {
381            time_spent: Duration::from_secs(0),
382            completed_merges: Vec::new(),
383            rows_merged: 0,
384            bytes_written: 0,
385            pending_compaction: false,
386            dropped_whole: Vec::new(),
387        };
388
389        // If no merge policy is set, no maintenance work to do
390        let merge_policy = match &self.merge_policy {
391            Some(policy) => policy,
392            None => {
393                report.time_spent = start.elapsed();
394                return Ok(report);
395            }
396        };
397
398        // If no active merge exists, check if we should start one
399        if self.active_merge.is_none() {
400            // SCOPE TO THIS TABLE (#935 branch review): `scan_sstable_candidates`
401            // walks the whole `data_dir` recursively, so it can include SSTables
402            // of OTHER keyspaces/tables. This WriteEngine is single-table
403            // (`config.schema`) and always publishes output to
404            // `data_dir/keyspace/table/`, so restrict the candidate set to THIS
405            // table's directory BEFORE any policy or purge-safety decision.
406            // Otherwise a full compaction of this table is misclassified as
407            // partial whenever a foreign table's SSTable exists under `data_dir`
408            // (`selected_set != candidate_set`), which both lets the policy see
409            // foreign-table inputs and disables tombstone purging that is actually
410            // safe. Every published SSTable for this table lives under
411            // `table_dir`, so the scoping never drops a real input.
412            let table_dir = self
413                .config
414                .data_dir
415                .join(&self.config.schema.keyspace)
416                .join(&self.config.schema.table);
417            let candidates: Vec<PathBuf> = self
418                .scan_sstable_candidates()?
419                .into_iter()
420                .filter(|p| p.starts_with(&table_dir))
421                .collect();
422            let selected = merge_policy.select_merge(&candidates)?;
423
424            if !selected.is_empty() {
425                // Overlap-safety gate for tombstone purging (#921 finding 1): a
426                // compaction may purge tombstones ONLY when it spans EVERY
427                // candidate SSTable for the table (a major/full compaction).
428                // Otherwise a tombstone could be purged while a non-included
429                // overlapping SSTable still holds data it shadows, resurrecting
430                // that data on the next read. A partial selection (the common
431                // background-compaction case) is therefore purge-UNSAFE: it
432                // retains tombstones. Compare as sets so input ordering does not
433                // affect the decision.
434                let selected_set: std::collections::HashSet<&PathBuf> = selected.iter().collect();
435                let candidate_set: std::collections::HashSet<&PathBuf> =
436                    candidates.iter().collect();
437                let purge_safe = !candidate_set.is_empty() && selected_set == candidate_set;
438
439                // Overlap-aware partial-compaction purging (#935): when this is a
440                // PARTIAL compaction (some candidate SSTables are NOT included),
441                // compute the min write timestamp across those non-included
442                // SSTables. A tombstone older than every one of them shadows
443                // nothing outside the set and can be purged even here. For a full
444                // compaction (`purge_safe == true`) there are no non-included
445                // SSTables, so the bound is `None` and the merger uses its +inf
446                // full-compaction fast path. `candidates` is already scoped to
447                // this table's directory (see above), so the non-included set is
448                // exactly this table's outside SSTables.
449                // The non-included (outside) overlapping set for this table. Empty
450                // for a full compaction (`purge_safe == true`). Used both for the
451                // #935 overlap-purge bound below AND for the #1388 fully-expired
452                // drop-set overlap gate (see `start_merge`).
453                let non_included: Vec<PathBuf> = candidates
454                    .iter()
455                    .filter(|p| !selected_set.contains(*p))
456                    .cloned()
457                    .collect();
458                let max_purgeable_timestamp = if purge_safe {
459                    None
460                } else {
461                    merge::compute_max_purgeable_timestamp(&non_included)
462                };
463
464                // Start a new merge. `non_included` is threaded through so
465                // `start_merge` can compute the fully-expired drop-set (issue #1388)
466                // with the correct overlap gate.
467                self.start_merge(selected, purge_safe, max_purgeable_timestamp, non_included)?;
468            } else {
469                // No work selected by policy
470                report.time_spent = start.elapsed();
471                report.pending_compaction = false;
472                return Ok(report);
473            }
474        }
475
476        // Process active merge within budget.
477        //
478        // Stage 4 (#1668, the "Q4 unlock"): the budget is checked BETWEEN
479        // CLUSTER GROUPS, not only between whole partitions. When a partition
480        // is too fat to finish within one call's remaining budget, the
481        // accumulated `PartitionStreamState` (carrier/static prefix + the
482        // buffered surviving clustering rows so far) is stashed on
483        // `ActiveMerge.pending_partition` and resumed unchanged on the NEXT
484        // `maintenance_step` call. The writer session is opened only at
485        // `PartitionEnd`, once the partition's FULL range-tombstone set is
486        // known (issue #1383 — see `PartitionStreamState`'s doc for why a late
487        // range tombstone would otherwise drop a boundary), so the writer
488        // still receives one partition as one on-disk unit (opened once,
489        // finished once) and output stays byte-identical (see `#921`).
490        // Nothing is re-computed across a pause, nothing is lost. The
491        // minimum-progress guarantee remains "at least one cluster group per
492        // call"; the budget still fires between cluster groups, so a fat
493        // partition yields control mid-drain even though its WRITE is
494        // deferred to the end.
495        let budget_tolerance = budget.mul_f32(1.1); // 10% tolerance
496        let mut partitions_processed = 0u64;
497
498        /// Outcome of draining one partition's cluster groups, bounded by
499        /// the mid-partition budget check.
500        enum DrainOutcome {
501            /// The partition fully drained; write the buffered
502            /// `PartitionStreamState` now (open session, feed static + rows,
503            /// finish). Boxed to keep `DrainOutcome` cheap to move regardless
504            /// of `PartitionStreamState`'s size (clippy::large_enum_variant).
505            Ready(DecoratedKey, Box<PartitionStreamState>),
506            /// Budget exceeded mid-drain; progress was stashed on
507            /// `ActiveMerge.pending_partition` for the next call.
508            Paused,
509            /// No more partitions in any run.
510            MergeComplete,
511        }
512
513        while let Some(merge) = &mut self.active_merge {
514            // Resume a partition paused by a PRIOR call's budget check, if
515            // any (issue #1668, stage 4). `resume_state` is
516            // `StreamingMerger`'s own raw-entry reconciliation checkpoint
517            // (issue #1668 stage 5d); `stream_state` is this partition's
518            // `PartitionStreamState` from EARLIER in this same partition
519            // (this or a prior call) — the carrier/static prefix plus the
520            // clustering rows buffered so far (no session is open yet; it
521            // opens only at PartitionEnd, see that type's doc).
522            let (resume_state, mut stream_state): (
523                Option<(DecoratedKey, merge::PartitionReconcileCheckpoint)>,
524                PartitionStreamState,
525            ) = match merge.pending_partition.take() {
526                Some((key, checkpoint, state)) => (Some((key, checkpoint)), state),
527                None => (None, PartitionStreamState::fresh()),
528            };
529
530            // #850: convert with the effective compaction schema (DECODE
531            // time — needed to identify/purge-evaluate a dropped column's
532            // cells) so any static column re-added from the input headers is
533            // preserved. Cloned ONCE here (before `stream` borrows
534            // `merge.merger`) so per-row conversion below never needs to
535            // borrow `self`/`merge` while that borrow is alive.
536            let decode_schema = merge.effective_schema.clone();
537            // ENCODE time: the writer's OWN schema (dropped columns already
538            // stripped, if this compaction drops any) — required by every
539            // `begin_streaming_partition`/`feed_streaming_row`/
540            // `feed_streaming_static_row` call. Using `decode_schema` here
541            // instead would repeat the exact #1019 regression
542            // `KWayMerger::merge` hit in stage 5c-iv part 2 (a header/row
543            // encoding mismatch that silently produced zero readable rows) —
544            // see that stage's fix for the full incident writeup.
545            let write_schema = merge.writer.schema().clone();
546            let schema_has_static = write_schema.columns.iter().any(|c| c.is_static);
547            let mut stream = merge::StreamingMerger::resume(&mut merge.merger, resume_state);
548            // True once THIS call has popped at least one cluster group —
549            // guarantees forward progress within a call even when resuming
550            // an already-in-progress `stream_state` from a prior call
551            // (mirrors the pre-stage-4 "always process at least one
552            // partition" floor, now at cluster-group granularity).
553            let mut progressed_this_call = false;
554
555            let outcome = loop {
556                if (partitions_processed > 0 || progressed_this_call)
557                    && start.elapsed() >= budget_tolerance
558                {
559                    break DrainOutcome::Paused;
560                }
561                match stream.step_streaming()? {
562                    merge::StreamingStep::ClusterGroup { key: _, row } => {
563                        progressed_this_call = true;
564                        // Skip metadata-only entries (#886/#899 branch-review):
565                        // they carry complex/range deletion metadata through
566                        // the merge stream but have no writer-emittable
567                        // content yet. See `MergeEntry::is_metadata_only_no_op`.
568                        if row.is_metadata_only_no_op() {
569                            continue;
570                        }
571                        let mutation =
572                            merge::KWayMerger::merge_entry_to_mutation(*row, &decode_schema)?;
573                        // Single fold point for EVERY mutation of this
574                        // partition, mirroring `KWayMerger::merge`'s stage
575                        // 5c-iv part 2 design — folds unconditionally,
576                        // regardless of classification, before it happens.
577                        stats_fold::fold_mutation_stats(
578                            stream_state.partition_stats_mut(),
579                            &mutation,
580                        );
581
582                        // Classify the (None-keyed) carriers and the static
583                        // row; everything else is a real clustering row that
584                        // is BUFFERED (see `PartitionStreamState`'s doc). No
585                        // writer session is opened here — a range tombstone
586                        // can still arrive AFTER some rows are buffered (issue
587                        // #1383), so the session opens only at PartitionEnd,
588                        // once the full range-tombstone set is resolved.
589                        let is_partition_only = mutation.operations.is_empty()
590                            && mutation.partition_tombstone.is_some()
591                            && mutation.row_tombstone.is_none()
592                            && mutation.range_tombstones.is_empty();
593                        let is_range_only = mutation.operations.is_empty()
594                            && mutation.partition_tombstone.is_none()
595                            && mutation.row_tombstone.is_none()
596                            && !mutation.range_tombstones.is_empty();
597                        // A `clustering_key: None` mutation is the resolved
598                        // static-row carrier ONLY when the schema actually
599                        // declares static columns — see
600                        // `KWayMerger::merge`'s identical gate (issue #1668
601                        // stage 5c-iv part 2) for the unclustered-table
602                        // rationale.
603                        let is_static_carrier =
604                            mutation.clustering_key.is_none() && schema_has_static;
605
606                        if is_partition_only {
607                            stream_state.partition_tombstone = mutation.partition_tombstone;
608                            stream_state.saw_carrier_or_static = true;
609                            continue;
610                        }
611                        if is_range_only {
612                            stream_state
613                                .range_tombstones
614                                .extend(mutation.range_tombstones.iter().cloned());
615                            stream_state.saw_carrier_or_static = true;
616                            continue;
617                        }
618                        if is_static_carrier {
619                            // Roborev blocker #2 (issue #1668): only the
620                            // resolved static-row carrier increments
621                            // `row_count` — a pure partition/range-tombstone
622                            // carrier emits a marker/header deletion, not a
623                            // row.
624                            if !stream_state.saw_carrier_or_static {
625                                stream_state.static_first_ts = mutation.timestamp_micros;
626                            }
627                            stream_state
628                                .static_tracker
629                                .feed(&mutation, &write_schema, None);
630                            stream_state.saw_carrier_or_static = true;
631                            stream_state.row_count += 1;
632                            continue;
633                        }
634
635                        // A real clustering row (or an unclustered table's
636                        // sole `clustering_key: None` row): buffer it for the
637                        // single PartitionEnd write.
638                        stream_state.buffered_rows.push(mutation);
639                        stream_state.row_count += 1;
640                    }
641                    merge::StreamingStep::PartitionEnd { key } => {
642                        break DrainOutcome::Ready(
643                            key,
644                            Box::new(std::mem::replace(
645                                &mut stream_state,
646                                PartitionStreamState::fresh(),
647                            )),
648                        );
649                    }
650                    merge::StreamingStep::Complete => break DrainOutcome::MergeComplete,
651                }
652            };
653
654            match outcome {
655                DrainOutcome::Ready(key, state) => {
656                    partitions_processed += 1;
657                    let state = *state;
658
659                    // Truly empty partition (every entry was
660                    // metadata-only-no-op, and no carrier/static survived) —
661                    // skip entirely, matching `KWayMerger::merge`'s
662                    // `buffered_rows.is_empty() && !saw_carrier_or_static`
663                    // skip. A partition with only carriers/statics but no
664                    // `Some(ck)` row is still emittable (#933/#1072).
665                    if !state.buffered_rows.is_empty() || state.saw_carrier_or_static {
666                        // Open the session NOW, with the partition tombstone,
667                        // the COMPLETE coalesced range-tombstone set, the
668                        // static row, and every buffered clustering row all
669                        // known (issue #1383): `feed_streaming_row` shadows
670                        // every covered row and interleaves the markers in
671                        // clustering order.
672                        if let Some(merge) = &mut self.active_merge {
673                            let mut session = merge.writer.begin_streaming_partition(
674                                &key,
675                                state.partition_tombstone.as_ref(),
676                                &state.range_tombstones,
677                            )?;
678                            if schema_has_static {
679                                let merged = state.static_tracker.finish();
680                                merge.writer.feed_streaming_static_row(
681                                    &mut session,
682                                    &merged,
683                                    state.static_first_ts,
684                                )?;
685                            }
686                            for mutation in &state.buffered_rows {
687                                merge.writer.feed_streaming_row(&mut session, mutation)?;
688                            }
689                            let (offset, blocks, emit) =
690                                merge.writer.finish_streaming_partition(session)?;
691                            merge.writer.complete_partition_incremental(
692                                &key,
693                                state.partition_tombstone.as_ref(),
694                                offset,
695                                &blocks,
696                                emit,
697                                &state.partition_stats,
698                            )?;
699                            merge.rows_merged += state.row_count;
700                        }
701                        report.rows_merged += state.row_count;
702                    }
703                }
704                DrainOutcome::Paused => {
705                    // Stash progress for the NEXT maintenance_step call
706                    // (issue #1668, stage 4). `into_paused_state` returns
707                    // `None` when no partition is currently in progress (the
708                    // pause landed between partitions, e.g. on the very first
709                    // inner-loop iteration for a NEW partition with
710                    // `partitions_processed > 0` carried over from an earlier
711                    // one in this same call) — there is nothing to resume in
712                    // that case, so skipping the stash below is correct.
713                    if let Some((key, checkpoint)) = stream.into_paused_state() {
714                        if let Some(merge) = &mut self.active_merge {
715                            merge.pending_partition = Some((key, checkpoint, stream_state));
716                        }
717                    }
718                    break;
719                }
720                DrainOutcome::MergeComplete => {
721                    // Merge is complete - finalize and clean up
722                    // Use blocking call to handle async finalization
723                    self.finalize_merge_blocking(&mut report)?;
724                    break;
725                }
726            }
727        }
728
729        // Check if more work is pending
730        report.pending_compaction = self.active_merge.is_some();
731        report.time_spent = start.elapsed();
732
733        Ok(report)
734    }
735
736    #[tracing::instrument(name = "compaction.scan_candidates", level = "debug", skip(self))]
737    fn scan_sstable_candidates(&self) -> Result<Vec<PathBuf>> {
738        let mut candidates = Vec::new();
739
740        if !self.config.data_dir.exists() {
741            return Ok(candidates);
742        }
743
744        Self::scan_data_files(
745            &self.config.data_dir,
746            &mut candidates,
747            crate::storage::sstable::MAX_SSTABLE_SCAN_DEPTH,
748        )?;
749        Ok(candidates)
750    }
751
752    /// Recursively scan for Data.db files
753    fn scan_data_files(dir: &Path, candidates: &mut Vec<PathBuf>, depth: usize) -> Result<()> {
754        for entry in std::fs::read_dir(dir)
755            .map_err(|e| Error::Storage(format!("Failed to read data directory: {}", e)))?
756        {
757            let entry = entry
758                .map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?;
759
760            let path = entry.path();
761            let filename = path.file_name().unwrap_or_default().to_string_lossy();
762
763            // Only consider Data.db files
764            if filename.starts_with("nb-") && filename.ends_with("-big-Data.db") {
765                // Honor the TOC.txt publication barrier (Issue #591). A Data.db
766                // without a sibling TOC.txt is NOT a published SSTable: it is
767                // either a crash-interrupted partial rename or a deferred-delete
768                // orphan whose TOC was removed first while its data file stayed
769                // pinned by an open/mapped reader (Windows). Feeding such a file
770                // to the merger would re-compact an unpublished input and could
771                // produce garbled output, so it is skipped here just as the
772                // read path discovers SSTables by TOC.txt. The startup orphan
773                // sweep reclaims the leftover components.
774                let base = filename.trim_end_matches("-Data.db");
775                let toc_path = path.with_file_name(format!("{base}-TOC.txt"));
776                if toc_path.exists() {
777                    candidates.push(path);
778                } else {
779                    tracing::debug!(
780                        "scan_data_files: skipping unpublished SSTable (no TOC.txt): {:?}",
781                        path
782                    );
783                }
784            } else if depth > 0 && path.is_dir() {
785                Self::scan_data_files(&path, candidates, depth - 1)?;
786            }
787        }
788        Ok(())
789    }
790
791    /// Delete all component files for an SSTable (M5.2 helper)
792    pub(crate) fn delete_sstable_files(&self, data_path: &Path) -> Result<()> {
793        Self::delete_sstable_files_static(data_path)
794    }
795
796    /// Static helper that deletes all component files for an SSTable given the
797    /// Data.db path.  Called from both `delete_sstable_files` and the startup
798    /// orphan sweep, which runs before `self` is fully constructed.
799    ///
800    /// ## Deferred-delete / Windows policy (Issue #591)
801    ///
802    /// `TOC.txt` is removed **first**. TOC.txt is the publication barrier — both
803    /// the read path (`SSTableManager`) and the compaction candidate scan
804    /// (`scan_data_files`, since #591) treat a Data.db without a sibling TOC.txt
805    /// as unpublished. Removing TOC.txt first therefore *unpublishes* the SSTable
806    /// atomically, before any data component is touched, so it can never be
807    /// observed (no duplicate rows, never re-fed to the merger) even if the
808    /// remaining components cannot be removed yet.
809    ///
810    /// The remaining components are then deleted **best-effort**: a failure on
811    /// any one of them (most plausibly a Windows sharing violation when a
812    /// concurrent reader still has the file open or memory-mapped) is logged but
813    /// does NOT abort the rest or fail the operation. Such a leftover is a
814    /// harmless orphan — invisible because its TOC.txt is gone — and is reclaimed
815    /// by [`Self::sweep_orphaned_partial_sstables`] on the next engine startup,
816    /// by which time the reader's handle has been released. This is the
817    /// "deferred delete" half of the policy; Unix removes the inode immediately
818    /// while any mapping keeps the bytes alive until it is dropped.
819    pub(crate) fn delete_sstable_files_static(data_path: &Path) -> Result<()> {
820        // Extract base path: nb-{gen}-big
821        let filename = data_path
822            .file_name()
823            .and_then(|s| s.to_str())
824            .ok_or_else(|| Error::Storage("Invalid SSTable path".to_string()))?;
825
826        let base = filename
827            .strip_suffix("-Data.db")
828            .ok_or_else(|| Error::Storage("Invalid Data.db filename".to_string()))?;
829
830        let parent_dir = data_path.parent().ok_or_else(|| {
831            Error::Storage(format!(
832                "Data.db path has no parent directory: {:?}",
833                data_path
834            ))
835        })?;
836
837        // TOC.txt FIRST — the publication barrier (Issue #591). Once it is gone
838        // the SSTable is unpublished regardless of whether the data components
839        // can be removed. Remaining components follow, best-effort.
840        let components = [
841            "TOC.txt",
842            "Data.db",
843            "Index.db",
844            "Summary.db",
845            "Statistics.db",
846            "CompressionInfo.db",
847            // CRC.db is the per-chunk CRC for uncompressed BIG SSTables
848            // (Issue #1197); without it deletion/compaction would leave an
849            // orphan file. Best-effort like the other optional components.
850            "CRC.db",
851            "Filter.db",
852            "Digest.crc32",
853        ];
854
855        let mut failures: Vec<String> = Vec::new();
856        for component in &components {
857            let component_path = parent_dir.join(format!("{}-{}", base, component));
858            if component_path.exists() {
859                match std::fs::remove_file(&component_path) {
860                    Ok(()) => tracing::debug!("Deleted compaction input: {:?}", component_path),
861                    Err(e) => {
862                        // Best-effort: do not abort. A leftover data component
863                        // whose TOC.txt is already gone is an invisible orphan
864                        // reclaimed by the startup sweep (Issue #591).
865                        tracing::warn!(
866                            "Deferred delete of {:?}: {} (component left as orphan; \
867                             unpublished via TOC.txt removal, reclaimed on next startup)",
868                            component_path,
869                            e
870                        );
871                        failures.push(format!("{:?}: {}", component_path, e));
872                    }
873                }
874            }
875        }
876
877        if failures.is_empty() {
878            Ok(())
879        } else {
880            // Surface a non-fatal error so callers can log it. The SSTable is
881            // already unpublished (TOC.txt removed first), so callers treat this
882            // as a deferred reclamation, not a correctness failure.
883            Err(Error::Storage(format!(
884                "Deferred delete left {} orphaned component(s) (unpublished, reclaimed on \
885                 next startup): {}",
886                failures.len(),
887                failures.join("; ")
888            )))
889        }
890    }
891}
892
893#[cfg(all(test, feature = "write-support"))]
894mod tests {
895    use super::*;
896    use crate::storage::write_engine::test_support::{create_test_schema, flush_n_sstables_sync};
897    use crate::storage::write_engine::WriteEngineConfig;
898    use std::path::PathBuf;
899    use std::time::Duration;
900    use tempfile::TempDir;
901
902    // Mock merge policy that selects specific files for testing
903    #[derive(Debug)]
904    #[allow(dead_code)] // Used in multiple test functions below
905    struct TestMergePolicy {
906        files_to_select: Vec<PathBuf>,
907    }
908
909    impl MergePolicy for TestMergePolicy {
910        fn select_merge(&self, _candidates: &[PathBuf]) -> Result<Vec<PathBuf>> {
911            Ok(self.files_to_select.clone())
912        }
913    }
914
915    #[test]
916    fn test_set_merge_policy() {
917        let temp_dir = TempDir::new().unwrap();
918        let schema = create_test_schema();
919
920        let config = WriteEngineConfig::new(
921            temp_dir.path().join("data"),
922            temp_dir.path().join("wal"),
923            schema,
924        );
925
926        let mut engine = WriteEngine::new(config).unwrap();
927
928        // Should succeed now (was previously returning error)
929        let policy = Box::new(crate::storage::write_engine::STCSPolicy::default());
930        engine.set_merge_policy(policy).unwrap();
931
932        // With policy set but no SSTables, should return quickly with no work
933        let report = engine
934            .maintenance_step(std::time::Duration::from_millis(100))
935            .unwrap();
936        assert!(!report.pending_compaction);
937        assert_eq!(report.rows_merged, 0);
938    }
939
940    // M5.2 maintenance_step() tests (Issue #384)
941
942    #[test]
943    fn test_maintenance_step_no_policy() {
944        // Without a merge policy, maintenance_step should do nothing.
945        // Since #1619 makes STCS the default, disable auto_compaction so this
946        // test still validates the None branch (no-policy -> no work).
947        let temp_dir = TempDir::new().unwrap();
948        let schema = create_test_schema();
949
950        let mut config = WriteEngineConfig::new(
951            temp_dir.path().join("data"),
952            temp_dir.path().join("wal"),
953            schema,
954        );
955        config.auto_compaction = false;
956
957        let mut engine = WriteEngine::new(config).unwrap();
958
959        // Call maintenance_step without setting a policy
960        let report = engine.maintenance_step(Duration::from_millis(100)).unwrap();
961
962        // Should return immediately with no work done
963        assert_eq!(report.rows_merged, 0);
964        assert_eq!(report.bytes_written, 0);
965        assert_eq!(report.completed_merges.len(), 0);
966        assert!(!report.pending_compaction);
967        assert!(report.time_spent < Duration::from_millis(50));
968    }
969
970    /// Roborev blocker #2 (issue #1668): `PartitionStreamState::Prefix`'s
971    /// carrier-classification block must NOT count a pure partition-
972    /// tombstone carrier toward `row_count` — mirroring
973    /// `KWayMerger::merge`'s reference implementation exactly (only the
974    /// resolved static-row carrier increments it). Before the fix, EVERY
975    /// carrier classification (partition-only, range-only, static)
976    /// incremented `row_count`, inflating `report.rows_merged` (and the
977    /// `COMPACTION_ROWS_MERGED` observability counter) whenever a
978    /// partition's carrier prefix included a pure tombstone.
979    ///
980    /// Fixture: one partition (`id = 1`) with a CLUSTERED schema (so a
981    /// `clustering_key: None` carrier always sorts strictly before every
982    /// `Some(ck)` row — `SchemaOrderedEntry`'s `(None, Some(_)) =>
983    /// Ordering::Less`, unconditional on `run_index` — unlike an unclustered
984    /// table where a real row ALSO carries `clustering_key: None` and could
985    /// race the carrier on a `run_index` tie-break). Source 1 carries the
986    /// partition tombstone alongside one real row (`ck=0`, postdating the
987    /// tombstone, so it is NOT itself shadowed — this keeps source 1 non-
988    /// empty, avoiding any confound from a truly zero-row source); source 2
989    /// carries a second real row (`ck=1`, also postdating the tombstone).
990    /// Compacting the two must merge to exactly 2 counted rows — the
991    /// re-emitted tombstone carrier is a marker/header deletion, not a row.
992    #[test]
993    fn test_maintenance_step_does_not_count_partition_tombstone_carrier_as_a_row() {
994        use crate::schema::{ClusteringColumn, ClusteringOrder, Column, KeyColumn};
995        use crate::storage::write_engine::mutation::{
996            CellOperation, ClusteringKey, PartitionKey, PartitionTombstone, TableId,
997        };
998        use crate::types::Value;
999
1000        let temp_dir = TempDir::new().unwrap();
1001        let schema = TableSchema {
1002            keyspace: "test_ks".to_string(),
1003            table: "test_table".to_string(),
1004            partition_keys: vec![KeyColumn {
1005                name: "id".to_string(),
1006                data_type: "int".to_string(),
1007                position: 0,
1008            }],
1009            clustering_keys: vec![ClusteringColumn {
1010                name: "ck".to_string(),
1011                data_type: "int".to_string(),
1012                position: 0,
1013                order: ClusteringOrder::Asc,
1014            }],
1015            columns: vec![
1016                Column {
1017                    name: "id".to_string(),
1018                    data_type: "int".to_string(),
1019                    nullable: false,
1020                    default: None,
1021                    is_static: false,
1022                },
1023                Column {
1024                    name: "ck".to_string(),
1025                    data_type: "int".to_string(),
1026                    nullable: false,
1027                    default: None,
1028                    is_static: false,
1029                },
1030                Column {
1031                    name: "name".to_string(),
1032                    data_type: "text".to_string(),
1033                    nullable: true,
1034                    default: None,
1035                    is_static: false,
1036                },
1037            ],
1038            comments: std::collections::HashMap::new(),
1039            dropped_columns: std::collections::HashMap::new(),
1040        };
1041        let config = WriteEngineConfig::new(
1042            temp_dir.path().join("data"),
1043            temp_dir.path().join("wal"),
1044            schema.clone(),
1045        );
1046        let mut engine = WriteEngine::new(config).unwrap();
1047        let rt = tokio::runtime::Builder::new_current_thread()
1048            .enable_all()
1049            .build()
1050            .unwrap();
1051
1052        let table_id = TableId::new(&schema.keyspace, &schema.table);
1053        let pk = PartitionKey::single("id", Value::Integer(1));
1054        let row_mutation = |ck: i32, ts: i64| {
1055            Mutation::new(
1056                table_id.clone(),
1057                pk.clone(),
1058                Some(ClusteringKey::single("ck", Value::Integer(ck))),
1059                vec![CellOperation::Write {
1060                    column: "name".to_string(),
1061                    value: Value::Text(format!("row-{ck}")),
1062                }],
1063                ts,
1064                None,
1065            )
1066        };
1067
1068        // `local_deletion_time` must be a REALISTIC (near-"now") GC-clock
1069        // timestamp, not a tiny literal — otherwise this full/overlap-safe
1070        // 2-source compaction correctly gc-grace-purges the tombstone
1071        // outright (it would be`local_deletion_time < gc_before`, decades
1072        // expired), never reaching the row-count classification this test
1073        // targets at all.
1074        let now_secs = std::time::SystemTime::now()
1075            .duration_since(std::time::UNIX_EPOCH)
1076            .unwrap()
1077            .as_secs() as i32;
1078
1079        // Source 1: the partition tombstone (deletion_time=100) alongside
1080        // ck=0 (ts=5_000, postdates the tombstone — survives).
1081        let mut ck0_with_tombstone = row_mutation(0, 5_000);
1082        ck0_with_tombstone.partition_tombstone = Some(PartitionTombstone {
1083            deletion_time: 100,
1084            local_deletion_time: now_secs,
1085        });
1086        engine.write(ck0_with_tombstone).unwrap();
1087        rt.block_on(engine.flush()).unwrap().unwrap();
1088
1089        // Source 2: ck=1 (ts=6_000, also postdates the tombstone — survives).
1090        engine.write(row_mutation(1, 6_000)).unwrap();
1091        rt.block_on(engine.flush()).unwrap().unwrap();
1092
1093        // `min_sstable_size` large enough that both tiny files count as
1094        // "small" and bucket together regardless of their size ratio.
1095        let policy =
1096            crate::storage::write_engine::STCSPolicy::new(2, 32, 0.5, 1.5, 1024 * 1024).unwrap();
1097        engine.set_merge_policy(Box::new(policy)).unwrap();
1098
1099        // `MaintenanceReport::rows_merged` is per-CALL, not cumulative
1100        // (`test_maintenance_paused_and_unpaused_compaction_produce_byte_identical_output`'s
1101        // `total_rows_merged` pattern above) — a merge can finish its own
1102        // work in one call yet still report `pending_compaction: true` for
1103        // an unrelated reason, so sum across every call rather than trusting
1104        // only the last one.
1105        let mut report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
1106        let mut total_rows_merged = report.rows_merged;
1107        let mut calls = 1u32;
1108        while report.pending_compaction {
1109            report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
1110            total_rows_merged += report.rows_merged;
1111            calls += 1;
1112            assert!(calls < 100_000, "compaction never completed");
1113        }
1114
1115        assert_eq!(
1116            total_rows_merged, 2,
1117            "only the 2 real surviving rows (ck=0, ck=1) must be counted — \
1118             the re-emitted partition-tombstone carrier is a marker, not a row"
1119        );
1120    }
1121
1122    #[test]
1123    fn test_maintenance_step_with_closed_engine() {
1124        let temp_dir = TempDir::new().unwrap();
1125        let schema = create_test_schema();
1126
1127        let config = WriteEngineConfig::new(
1128            temp_dir.path().join("data"),
1129            temp_dir.path().join("wal"),
1130            schema,
1131        );
1132
1133        let mut engine = WriteEngine::new(config).unwrap();
1134
1135        // Close the engine
1136        tokio::runtime::Runtime::new()
1137            .unwrap()
1138            .block_on(engine.close())
1139            .unwrap();
1140
1141        // maintenance_step should fail on closed engine
1142        let result = engine.maintenance_step(Duration::from_millis(100));
1143        assert!(result.is_err());
1144        match result {
1145            Err(Error::InvalidInput(msg)) => {
1146                assert!(msg.contains("closed"));
1147            }
1148            _ => panic!("Expected InvalidInput error"),
1149        }
1150    }
1151
1152    #[test]
1153    fn test_maintenance_report_creation() {
1154        let report = MaintenanceReport {
1155            time_spent: Duration::from_millis(250),
1156            completed_merges: vec![PathBuf::from("data/nb-5-big-Data.db")],
1157            rows_merged: 1000,
1158            bytes_written: 1024 * 1024,
1159            pending_compaction: true,
1160            dropped_whole: Vec::new(),
1161        };
1162
1163        assert_eq!(report.time_spent.as_millis(), 250);
1164        assert_eq!(report.completed_merges.len(), 1);
1165        assert_eq!(report.rows_merged, 1000);
1166        assert_eq!(report.bytes_written, 1024 * 1024);
1167        assert!(report.pending_compaction);
1168    }
1169
1170    #[test]
1171    fn test_scan_sstable_candidates_empty_dir() {
1172        let temp_dir = TempDir::new().unwrap();
1173        let schema = create_test_schema();
1174
1175        let config = WriteEngineConfig::new(
1176            temp_dir.path().join("data"),
1177            temp_dir.path().join("wal"),
1178            schema,
1179        );
1180
1181        let engine = WriteEngine::new(config).unwrap();
1182
1183        let candidates = engine.scan_sstable_candidates().unwrap();
1184        assert_eq!(candidates.len(), 0);
1185    }
1186
1187    #[test]
1188    fn test_scan_sstable_candidates_with_sstables() {
1189        let temp_dir = TempDir::new().unwrap();
1190        let schema = create_test_schema();
1191
1192        let config = WriteEngineConfig::new(
1193            temp_dir.path().join("data"),
1194            temp_dir.path().join("wal"),
1195            schema,
1196        );
1197
1198        let engine = WriteEngine::new(config).unwrap();
1199
1200        // Create dummy SSTable files. Each Data.db needs a sibling TOC.txt to
1201        // count as a *published* SSTable (the publication barrier, Issue #591) —
1202        // a Data.db without TOC.txt is an unpublished partial/orphan and must be
1203        // skipped by the candidate scan.
1204        let data_dir = temp_dir.path().join("data");
1205        std::fs::create_dir_all(&data_dir).unwrap();
1206        std::fs::write(data_dir.join("nb-1-big-Data.db"), b"").unwrap();
1207        std::fs::write(data_dir.join("nb-1-big-TOC.txt"), b"").unwrap();
1208        std::fs::write(data_dir.join("nb-2-big-Data.db"), b"").unwrap();
1209        std::fs::write(data_dir.join("nb-2-big-TOC.txt"), b"").unwrap();
1210        std::fs::write(data_dir.join("nb-3-big-Index.db"), b"").unwrap(); // Not a Data.db
1211        std::fs::write(data_dir.join("other-file.txt"), b"").unwrap(); // Not an SSTable
1212                                                                       // An unpublished Data.db (no TOC.txt) must NOT be picked up (Issue #591).
1213        std::fs::write(data_dir.join("nb-4-big-Data.db"), b"").unwrap();
1214
1215        let candidates = engine.scan_sstable_candidates().unwrap();
1216
1217        // Should only find the two PUBLISHED Data.db files (TOC.txt present);
1218        // nb-4 is excluded because it has no TOC.txt.
1219        assert_eq!(candidates.len(), 2);
1220        assert!(candidates
1221            .iter()
1222            .all(|p| p.to_string_lossy().contains("Data.db")));
1223        assert!(
1224            !candidates
1225                .iter()
1226                .any(|p| p.to_string_lossy().contains("nb-4-big")),
1227            "unpublished Data.db (no TOC.txt) must be excluded (Issue #591)"
1228        );
1229    }
1230
1231    #[test]
1232    fn test_delete_sstable_files() {
1233        let temp_dir = TempDir::new().unwrap();
1234        let schema = create_test_schema();
1235
1236        let config = WriteEngineConfig::new(
1237            temp_dir.path().join("data"),
1238            temp_dir.path().join("wal"),
1239            schema,
1240        );
1241
1242        let engine = WriteEngine::new(config).unwrap();
1243
1244        // Create dummy SSTable component files
1245        let data_dir = temp_dir.path().join("data");
1246        std::fs::create_dir_all(&data_dir).unwrap();
1247
1248        let components = [
1249            "nb-5-big-Data.db",
1250            "nb-5-big-Index.db",
1251            "nb-5-big-Summary.db",
1252            "nb-5-big-Statistics.db",
1253        ];
1254
1255        for component in &components {
1256            std::fs::write(data_dir.join(component), b"dummy").unwrap();
1257        }
1258
1259        // Verify files exist
1260        for component in &components {
1261            assert!(data_dir.join(component).exists());
1262        }
1263
1264        // Delete SSTable files
1265        let data_path = data_dir.join("nb-5-big-Data.db");
1266        engine.delete_sstable_files(&data_path).unwrap();
1267
1268        // Verify files are deleted
1269        for component in &components {
1270            assert!(!data_dir.join(component).exists());
1271        }
1272    }
1273
1274    /// Issue #591: deletion removes TOC.txt FIRST so the SSTable is unpublished
1275    /// before any data component is touched. This guarantees the read path and
1276    /// the compaction candidate scan stop seeing it immediately, even if a data
1277    /// component cannot be removed yet (e.g. pinned by a mapped reader on
1278    /// Windows).
1279    #[test]
1280    fn test_delete_removes_toc_first_unpublishing_atomically() {
1281        let temp_dir = TempDir::new().unwrap();
1282        let data_dir = temp_dir.path().join("data");
1283        std::fs::create_dir_all(&data_dir).unwrap();
1284
1285        // A full published SSTable component set including TOC.txt.
1286        for comp in &[
1287            "nb-7-big-Data.db",
1288            "nb-7-big-Index.db",
1289            "nb-7-big-Statistics.db",
1290            "nb-7-big-TOC.txt",
1291        ] {
1292            std::fs::write(data_dir.join(comp), b"x").unwrap();
1293        }
1294
1295        let data_path = data_dir.join("nb-7-big-Data.db");
1296        WriteEngine::delete_sstable_files_static(&data_path).unwrap();
1297
1298        // Everything gone on the happy path.
1299        assert!(!data_dir.join("nb-7-big-TOC.txt").exists());
1300        assert!(!data_path.exists());
1301
1302        // And critically: scan_data_files (the compaction candidate discovery)
1303        // never surfaces a Data.db without a TOC.txt, so a deferred-delete orphan
1304        // is not re-fed to the merger. Recreate a TOC-less leftover to prove it.
1305        std::fs::write(data_dir.join("nb-8-big-Data.db"), b"x").unwrap();
1306        let mut candidates = Vec::new();
1307        WriteEngine::scan_data_files(&data_dir, &mut candidates, 1).unwrap();
1308        assert!(
1309            candidates.is_empty(),
1310            "a Data.db without a sibling TOC.txt must NOT be a compaction candidate \
1311             (publication barrier, Issue #591); got {:?}",
1312            candidates
1313        );
1314
1315        // Add the matching TOC.txt and it becomes a valid candidate again.
1316        std::fs::write(data_dir.join("nb-8-big-TOC.txt"), b"x").unwrap();
1317        let mut candidates = Vec::new();
1318        WriteEngine::scan_data_files(&data_dir, &mut candidates, 1).unwrap();
1319        assert_eq!(
1320            candidates.len(),
1321            1,
1322            "a published Data.db (TOC.txt present) must be discovered"
1323        );
1324    }
1325
1326    #[test]
1327    fn test_maintenance_step_with_policy_no_work() {
1328        // Policy that returns empty selection (no work to do)
1329        let temp_dir = TempDir::new().unwrap();
1330        let schema = create_test_schema();
1331
1332        let config = WriteEngineConfig::new(
1333            temp_dir.path().join("data"),
1334            temp_dir.path().join("wal"),
1335            schema,
1336        );
1337
1338        let mut engine = WriteEngine::new(config).unwrap();
1339
1340        // Set a policy that selects nothing
1341        let policy = TestMergePolicy {
1342            files_to_select: vec![],
1343        };
1344        engine.set_merge_policy(Box::new(policy)).unwrap();
1345
1346        // Call maintenance_step - policy selects no work
1347        let report = engine.maintenance_step(Duration::from_millis(100)).unwrap();
1348
1349        // Should return with no work done
1350        assert_eq!(report.rows_merged, 0);
1351        assert_eq!(report.bytes_written, 0);
1352        assert_eq!(report.completed_merges.len(), 0);
1353        assert!(!report.pending_compaction);
1354    }
1355
1356    #[test]
1357    fn test_maintenance_step_budget_honored() {
1358        // Test that budget is approximately honored
1359        let temp_dir = TempDir::new().unwrap();
1360        let schema = create_test_schema();
1361
1362        let config = WriteEngineConfig::new(
1363            temp_dir.path().join("data"),
1364            temp_dir.path().join("wal"),
1365            schema,
1366        );
1367
1368        let mut engine = WriteEngine::new(config).unwrap();
1369
1370        // Set a policy that selects nothing
1371        let policy = TestMergePolicy {
1372            files_to_select: vec![],
1373        };
1374        engine.set_merge_policy(Box::new(policy)).unwrap();
1375
1376        // Call with small budget - policy selects no work, should return quickly
1377        let budget = Duration::from_millis(10);
1378        let report = engine.maintenance_step(budget).unwrap();
1379
1380        // Should return quickly when there's no compaction work
1381        assert!(
1382            report.time_spent < budget.mul_f32(1.5),
1383            "Time spent {:?} exceeded budget {:?} by >50%",
1384            report.time_spent,
1385            budget
1386        );
1387    }
1388
1389    #[test]
1390    fn test_maintenance_stats_initial_zero() {
1391        // Before any maintenance work, all stats should be zero
1392        let temp_dir = TempDir::new().unwrap();
1393        let schema = create_test_schema();
1394
1395        let config = WriteEngineConfig::new(
1396            temp_dir.path().join("data"),
1397            temp_dir.path().join("wal"),
1398            schema,
1399        );
1400
1401        let engine = WriteEngine::new(config).unwrap();
1402
1403        let stats = engine.maintenance_stats();
1404        assert_eq!(stats.compactions_completed, 0);
1405        assert_eq!(stats.sstables_merged_in, 0);
1406        assert_eq!(stats.sstables_produced, 0);
1407        assert_eq!(stats.bytes_read, 0);
1408        assert_eq!(stats.bytes_written, 0);
1409        assert_eq!(stats.rows_merged, 0);
1410        assert_eq!(stats.total_time, Duration::ZERO);
1411    }
1412
1413    #[test]
1414    fn test_stcs_selects_expected_group_by_size() {
1415        // Verify that STCSPolicy groups four same-sized SSTables into one candidate set.
1416        // We do this without actually running a merge (just test the policy selection).
1417        let policy = crate::storage::write_engine::STCSPolicy::default();
1418
1419        // Create 4 temp files of equal size to satisfy min_threshold=4
1420        let temp_dir = TempDir::new().unwrap();
1421        let mut paths = Vec::new();
1422        for i in 1..=4 {
1423            let path = temp_dir.path().join(format!("nb-{}-big-Data.db", i));
1424            // 60 MB each (above min_sstable_size threshold)
1425            let size_bytes = 60 * 1024 * 1024u64;
1426            let file = std::fs::File::create(&path).unwrap();
1427            file.set_len(size_bytes).unwrap();
1428            paths.push(path);
1429        }
1430
1431        // Policy should select all 4 as a candidate group
1432        let selected = policy.select_merge(&paths).unwrap();
1433        assert_eq!(
1434            selected.len(),
1435            4,
1436            "STCS should select all 4 same-sized SSTables as one compaction group"
1437        );
1438
1439        // All selected paths should be from our input set
1440        for sel in &selected {
1441            assert!(
1442                paths.contains(sel),
1443                "Selected path {:?} not in input set",
1444                sel
1445            );
1446        }
1447    }
1448
1449    #[test]
1450    fn test_stcs_does_not_select_below_threshold() {
1451        // With only 3 SSTables, STCS (min_threshold=4) should select nothing.
1452        let policy = crate::storage::write_engine::STCSPolicy::default();
1453
1454        let temp_dir = TempDir::new().unwrap();
1455        let mut paths = Vec::new();
1456        for i in 1..=3 {
1457            let path = temp_dir.path().join(format!("nb-{}-big-Data.db", i));
1458            let file = std::fs::File::create(&path).unwrap();
1459            file.set_len(60 * 1024 * 1024).unwrap();
1460            paths.push(path);
1461        }
1462
1463        let selected = policy.select_merge(&paths).unwrap();
1464        assert!(
1465            selected.is_empty(),
1466            "STCS should NOT select when fewer than min_threshold SSTables exist"
1467        );
1468    }
1469
1470    #[test]
1471    fn test_maintenance_step_compacts_sstables_atomically() {
1472        // Create an engine, flush 4 SSTables, then run maintenance_step with STCS.
1473        // After the step: input files must be gone, output file must exist,
1474        // and maintenance_stats() must reflect the completed compaction.
1475        //
1476        // Uses a sync wrapper so maintenance_step's internal block_on works without
1477        // nesting inside a pre-existing async runtime.
1478        let temp_dir = TempDir::new().unwrap();
1479        let schema = create_test_schema();
1480
1481        // Use a LOW min_sstable_size so small test files pass bucket grouping
1482        let policy = crate::storage::write_engine::STCSPolicy::new(
1483            4,   // min_threshold
1484            32,  // max_threshold
1485            0.5, // bucket_low
1486            1.5, // bucket_high
1487            0,   // min_sstable_size = 0 so tiny files group together
1488        )
1489        .unwrap();
1490
1491        let config = WriteEngineConfig::new(
1492            temp_dir.path().join("data"),
1493            temp_dir.path().join("wal"),
1494            schema,
1495        );
1496
1497        let mut engine = WriteEngine::new(config).unwrap();
1498
1499        // Flush 4 distinct SSTables (sync helper creates its own single-threaded runtime)
1500        let input_paths = flush_n_sstables_sync(&mut engine, 4);
1501        assert_eq!(input_paths.len(), 4, "Expected 4 flushed SSTables");
1502
1503        // Verify all input Data.db files exist before compaction
1504        for p in &input_paths {
1505            assert!(
1506                p.exists(),
1507                "Input file {:?} should exist before compaction",
1508                p
1509            );
1510        }
1511
1512        // Attach the policy and run maintenance
1513        engine.set_merge_policy(Box::new(policy)).unwrap();
1514        let report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
1515
1516        // The report must indicate a completed merge
1517        assert_eq!(
1518            report.completed_merges.len(),
1519            1,
1520            "Expected exactly 1 completed merge, got: {:?}",
1521            report.completed_merges
1522        );
1523        // bytes_written is u64 and always non-negative, so no assertion needed here.
1524
1525        // The merged output file must exist in the final SSTable directory
1526        let merged_path = &report.completed_merges[0];
1527        assert!(
1528            merged_path.exists(),
1529            "Merged output file {:?} must exist after compaction",
1530            merged_path
1531        );
1532
1533        // All input files must be gone (consumed by compaction)
1534        for p in &input_paths {
1535            assert!(
1536                !p.exists(),
1537                "Input file {:?} should have been deleted after compaction",
1538                p
1539            );
1540        }
1541
1542        // maintenance_stats() must reflect the operation
1543        let stats = engine.maintenance_stats();
1544        assert_eq!(
1545            stats.compactions_completed, 1,
1546            "compactions_completed must be 1"
1547        );
1548        assert_eq!(
1549            stats.sstables_merged_in, 4,
1550            "Should have consumed 4 input SSTables"
1551        );
1552        assert_eq!(stats.sstables_produced, 1, "sstables_produced must be 1");
1553        // bytes_written may be 0 if the merged output is empty (reader/writer compatibility),
1554        // but total_time must be non-zero
1555        assert!(stats.total_time > Duration::ZERO, "total_time must be > 0");
1556    }
1557
1558    /// Stage 4 (#1668, the "Q4 unlock"): the mid-partition budget check fires
1559    /// BETWEEN CLUSTER GROUPS, not only between whole partitions. A synthetic
1560    /// FAT partition (many clustering rows split across 2 input SSTables, so
1561    /// a real K-way merge reconciles them into ONE partition) drained with a
1562    /// near-zero budget must PAUSE mid-drain — proven directly by observing
1563    /// `ActiveMerge::pending_partition` populated after the FIRST call: with
1564    /// only ONE partition (`id = 1`) in the whole merge, a pause is ONLY
1565    /// possible mid-partition, never between partitions. The pre-stage-4
1566    /// design always finished a whole partition within a single call
1567    /// regardless of budget, so this also proves the fat partition's drain
1568    /// spans MULTIPLE `maintenance_step` calls, and that no row is lost or
1569    /// duplicated across the pause/resume cycle.
1570    #[test]
1571    fn test_maintenance_step_pauses_mid_partition_on_tiny_budget() {
1572        use crate::schema::{ClusteringColumn, ClusteringOrder, Column, KeyColumn};
1573        use crate::storage::write_engine::mutation::{
1574            CellOperation, ClusteringKey, PartitionKey, TableId,
1575        };
1576        use crate::types::Value;
1577        use std::collections::HashMap;
1578
1579        let temp_dir = TempDir::new().unwrap();
1580        // A schema with ONE clustering column so many rows can share ONE
1581        // partition key (id=1) — the "fat partition" fixture.
1582        let schema = TableSchema {
1583            keyspace: "test_ks".to_string(),
1584            table: "test_table".to_string(),
1585            partition_keys: vec![KeyColumn {
1586                name: "id".to_string(),
1587                data_type: "int".to_string(),
1588                position: 0,
1589            }],
1590            clustering_keys: vec![ClusteringColumn {
1591                name: "ck".to_string(),
1592                data_type: "int".to_string(),
1593                position: 0,
1594                order: ClusteringOrder::Asc,
1595            }],
1596            columns: vec![
1597                Column {
1598                    name: "id".to_string(),
1599                    data_type: "int".to_string(),
1600                    nullable: false,
1601                    default: None,
1602                    is_static: false,
1603                },
1604                Column {
1605                    name: "ck".to_string(),
1606                    data_type: "int".to_string(),
1607                    nullable: false,
1608                    default: None,
1609                    is_static: false,
1610                },
1611                Column {
1612                    name: "name".to_string(),
1613                    data_type: "text".to_string(),
1614                    nullable: true,
1615                    default: None,
1616                    is_static: false,
1617                },
1618            ],
1619            comments: HashMap::new(),
1620            dropped_columns: HashMap::new(),
1621        };
1622
1623        const ROWS_PER_FLUSH: i32 = 15;
1624        const TOTAL_ROWS: u64 = (ROWS_PER_FLUSH * 2) as u64;
1625
1626        let config = WriteEngineConfig::new(
1627            temp_dir.path().join("data"),
1628            temp_dir.path().join("wal"),
1629            schema,
1630        );
1631        let mut engine = WriteEngine::new(config).unwrap();
1632
1633        let rt = tokio::runtime::Builder::new_current_thread()
1634            .enable_all()
1635            .build()
1636            .unwrap();
1637
1638        // Two flushes, ONE shared partition key (id=1), DISJOINT clustering
1639        // ranges — a real K-way merge reconciles them into ONE fat partition.
1640        let mut input_paths = Vec::new();
1641        for flush_idx in 0..2 {
1642            for i in 0..ROWS_PER_FLUSH {
1643                let ck = flush_idx * ROWS_PER_FLUSH + i;
1644                let table_id = TableId::new("test_ks", "test_table");
1645                let pk = PartitionKey::single("id", Value::Integer(1));
1646                let ck_key = ClusteringKey::single("ck", Value::Integer(ck));
1647                let ops = vec![CellOperation::Write {
1648                    column: "name".to_string(),
1649                    value: Value::Text(format!("row-{ck}")),
1650                }];
1651                let mutation = Mutation::new(
1652                    table_id,
1653                    pk,
1654                    Some(ck_key),
1655                    ops,
1656                    1_000_000 + i64::from(ck),
1657                    None,
1658                );
1659                engine.write(mutation).unwrap();
1660            }
1661            let info = rt.block_on(engine.flush()).unwrap().unwrap();
1662            input_paths.push(info.data_path);
1663        }
1664        assert_eq!(input_paths.len(), 2, "expected 2 flushed SSTables");
1665
1666        let policy = crate::storage::write_engine::STCSPolicy::new(
1667            2,   // min_threshold
1668            32,  // max_threshold
1669            0.5, // bucket_low
1670            1.5, // bucket_high
1671            0,   // min_sstable_size = 0 so tiny files group together
1672        )
1673        .unwrap();
1674        engine.set_merge_policy(Box::new(policy)).unwrap();
1675
1676        // A near-zero budget: `budget_tolerance` (budget * 1.1) is ~1ns, so
1677        // the elapsed-time check trips on the very next iteration after the
1678        // first cluster group is popped — any realistic per-iteration
1679        // overhead vastly exceeds 1ns.
1680        let tiny_budget = Duration::from_nanos(1);
1681        let first_report = engine.maintenance_step(tiny_budget).unwrap();
1682
1683        // With only ONE partition (id=1) across the whole merge, a pause is
1684        // ONLY possible mid-partition — prove it directly.
1685        let paused_mid_partition = engine
1686            .active_merge
1687            .as_ref()
1688            .and_then(|m| m.pending_partition.as_ref())
1689            .is_some();
1690        assert!(
1691            paused_mid_partition,
1692            "expected ActiveMerge::pending_partition to be populated after a \
1693             tiny-budget call against a single fat partition — the mid-partition \
1694             budget check (issue #1668 stage 4) must have fired DURING the \
1695             partition's cluster-group drain, not just between partitions"
1696        );
1697        assert!(
1698            first_report.pending_compaction,
1699            "compaction must still be pending after a tiny-budget call"
1700        );
1701        assert_eq!(
1702            first_report.completed_merges.len(),
1703            0,
1704            "the fat partition must not have finished writing in one tiny-budget call"
1705        );
1706
1707        // Keep calling (generous budget now) until the merge completes.
1708        let mut calls = 1u32;
1709        let mut total_rows_merged = first_report.rows_merged;
1710        let mut final_report = first_report;
1711        while final_report.pending_compaction {
1712            final_report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
1713            total_rows_merged += final_report.rows_merged;
1714            calls += 1;
1715            assert!(
1716                calls < 10_000,
1717                "compaction never completed — possible stall/starvation"
1718            );
1719        }
1720        assert!(
1721            calls > 1,
1722            "expected the fat partition's drain to span MULTIPLE maintenance_step \
1723             calls (mid-partition pause/resume) — got just {calls} call(s), which \
1724             would only be possible if the pre-stage-4 (whole-partition-per-call) \
1725             behavior were still in effect"
1726        );
1727
1728        // Completeness: every one of the TOTAL_ROWS distinct clustering rows
1729        // for partition id=1 must have been merged exactly once — proving no
1730        // row was lost or duplicated across the pause/resume cycle.
1731        assert_eq!(
1732            total_rows_merged, TOTAL_ROWS,
1733            "expected all {TOTAL_ROWS} clustering rows to be merged exactly \
1734             once across the paused/resumed drain"
1735        );
1736        assert_eq!(
1737            final_report.completed_merges.len(),
1738            1,
1739            "expected exactly one completed merge output"
1740        );
1741        let stats = engine.maintenance_stats();
1742        assert_eq!(
1743            stats.sstables_merged_in, 2,
1744            "must have consumed both input SSTables"
1745        );
1746    }
1747
1748    /// Shared fixture for the byte-identity proof below: builds the SAME
1749    /// 2-generation fat-partition fixture as
1750    /// `test_maintenance_step_pauses_mid_partition_on_tiny_budget` (a fresh,
1751    /// independent `WriteEngine`/temp dir each call — deterministic content,
1752    /// so two calls produce byte-identical INPUT SSTables too), compacts it
1753    /// under `budget`, and returns the produced output's raw Data.db bytes.
1754    fn compact_fat_partition_fixture_with_budget(budget: Duration) -> Vec<u8> {
1755        use crate::schema::{ClusteringColumn, ClusteringOrder, Column, KeyColumn};
1756        use crate::storage::write_engine::mutation::{
1757            CellOperation, ClusteringKey, PartitionKey, TableId,
1758        };
1759        use crate::types::Value;
1760        use std::collections::HashMap;
1761
1762        let temp_dir = TempDir::new().unwrap();
1763        let schema = TableSchema {
1764            keyspace: "test_ks".to_string(),
1765            table: "test_table".to_string(),
1766            partition_keys: vec![KeyColumn {
1767                name: "id".to_string(),
1768                data_type: "int".to_string(),
1769                position: 0,
1770            }],
1771            clustering_keys: vec![ClusteringColumn {
1772                name: "ck".to_string(),
1773                data_type: "int".to_string(),
1774                position: 0,
1775                order: ClusteringOrder::Asc,
1776            }],
1777            columns: vec![
1778                Column {
1779                    name: "id".to_string(),
1780                    data_type: "int".to_string(),
1781                    nullable: false,
1782                    default: None,
1783                    is_static: false,
1784                },
1785                Column {
1786                    name: "ck".to_string(),
1787                    data_type: "int".to_string(),
1788                    nullable: false,
1789                    default: None,
1790                    is_static: false,
1791                },
1792                Column {
1793                    name: "name".to_string(),
1794                    data_type: "text".to_string(),
1795                    nullable: true,
1796                    default: None,
1797                    is_static: false,
1798                },
1799            ],
1800            comments: HashMap::new(),
1801            dropped_columns: HashMap::new(),
1802        };
1803
1804        const ROWS_PER_FLUSH: i32 = 15;
1805
1806        let config = WriteEngineConfig::new(
1807            temp_dir.path().join("data"),
1808            temp_dir.path().join("wal"),
1809            schema,
1810        );
1811        let mut engine = WriteEngine::new(config).unwrap();
1812        let rt = tokio::runtime::Builder::new_current_thread()
1813            .enable_all()
1814            .build()
1815            .unwrap();
1816
1817        for flush_idx in 0..2 {
1818            for i in 0..ROWS_PER_FLUSH {
1819                let ck = flush_idx * ROWS_PER_FLUSH + i;
1820                let table_id = TableId::new("test_ks", "test_table");
1821                let pk = PartitionKey::single("id", Value::Integer(1));
1822                let ck_key = ClusteringKey::single("ck", Value::Integer(ck));
1823                let ops = vec![CellOperation::Write {
1824                    column: "name".to_string(),
1825                    value: Value::Text(format!("row-{ck}")),
1826                }];
1827                let mutation = Mutation::new(
1828                    table_id,
1829                    pk,
1830                    Some(ck_key),
1831                    ops,
1832                    1_000_000 + i64::from(ck),
1833                    None,
1834                );
1835                engine.write(mutation).unwrap();
1836            }
1837            rt.block_on(engine.flush()).unwrap();
1838        }
1839
1840        let policy = crate::storage::write_engine::STCSPolicy::new(2, 32, 0.5, 1.5, 0).unwrap();
1841        engine.set_merge_policy(Box::new(policy)).unwrap();
1842
1843        let mut report = engine.maintenance_step(budget).unwrap();
1844        let mut calls = 1u32;
1845        while report.pending_compaction {
1846            report = engine.maintenance_step(budget).unwrap();
1847            calls += 1;
1848            assert!(
1849                calls < 100_000,
1850                "compaction never completed — possible stall/starvation"
1851            );
1852        }
1853        assert_eq!(
1854            report.completed_merges.len(),
1855            1,
1856            "expected exactly one completed merge output"
1857        );
1858        std::fs::read(&report.completed_merges[0]).expect("read compacted Data.db")
1859    }
1860
1861    /// Byte-identity proof (issue #1668, stage 5c-iv part 3): pausing and
1862    /// resuming a partition's drain mid-stream — via the
1863    /// `PartitionStreamState` (carrier/static prefix + buffered clustering
1864    /// rows) stashed on `ActiveMerge::pending_partition` across many
1865    /// `maintenance_step` calls — must produce EXACTLY the same Data.db bytes
1866    /// as a single unbroken drain. Same rigor as every
1867    /// `IncrementalPartitionWriter`/`StreamingPartitionSession` byte-identity
1868    /// test in `data_writer/tests/`, but exercised through the REAL
1869    /// production entry point (`WriteEngine::maintenance_step`) rather than
1870    /// the session type directly — proving the INTEGRATION (budget checks,
1871    /// buffering/resume plumbing, the single PartitionEnd write) introduces no
1872    /// divergence of its own.
1873    #[test]
1874    fn test_maintenance_paused_and_unpaused_compaction_produce_byte_identical_output() {
1875        // Generous budget: the fat partition drains in ONE
1876        // `maintenance_step` call (no pause) — the unbroken-batch baseline.
1877        let unpaused_bytes = compact_fat_partition_fixture_with_budget(Duration::from_secs(60));
1878
1879        // Near-zero budget: forces the SAME fat partition's drain to pause
1880        // and resume across MANY `maintenance_step` calls (issue #1668 stage
1881        // 4's mid-partition budget check).
1882        let paused_bytes = compact_fat_partition_fixture_with_budget(Duration::from_nanos(1));
1883
1884        assert_eq!(
1885            unpaused_bytes, paused_bytes,
1886            "pausing and resuming mid-partition must produce byte-identical \
1887             Data.db output to a single unbroken drain"
1888        );
1889    }
1890
1891    /// #935 branch-review regression: `scan_sstable_candidates` walks the whole
1892    /// `data_dir` recursively, so a foreign keyspace/table's SSTable sitting under
1893    /// `data_dir` must NOT be treated as a candidate for this table's compaction.
1894    /// Before the fix the foreign SSTable inflated `candidate_set`, so a full
1895    /// compaction of this table was misclassified as partial (the policy could
1896    /// also see the foreign input). After the fix candidates are scoped to
1897    /// `data_dir/keyspace/table/`, so only this table's SSTables are merged and
1898    /// the foreign file is left untouched.
1899    #[test]
1900    fn test_maintenance_step_ignores_foreign_table_sstables() {
1901        let temp_dir = TempDir::new().unwrap();
1902        let schema = create_test_schema();
1903
1904        let policy = crate::storage::write_engine::STCSPolicy::new(
1905            4,   // min_threshold
1906            32,  // max_threshold
1907            0.5, // bucket_low
1908            1.5, // bucket_high
1909            0,   // min_sstable_size = 0 so tiny files group together
1910        )
1911        .unwrap();
1912
1913        let data_dir = temp_dir.path().join("data");
1914        let config = WriteEngineConfig::new(data_dir.clone(), temp_dir.path().join("wal"), schema);
1915
1916        let mut engine = WriteEngine::new(config).unwrap();
1917
1918        // Flush 4 SSTables for THIS table (data/test_ks/test_table/).
1919        let input_paths = flush_n_sstables_sync(&mut engine, 4);
1920        assert_eq!(input_paths.len(), 4, "Expected 4 flushed SSTables");
1921
1922        // Plant a foreign keyspace/table SSTable under the same data_dir, with a
1923        // sibling TOC.txt so it passes the publication barrier and would be
1924        // discovered by the recursive scan.
1925        let foreign_dir = data_dir.join("other_ks").join("other_tbl");
1926        std::fs::create_dir_all(&foreign_dir).unwrap();
1927        let foreign_data = foreign_dir.join("nb-1-big-Data.db");
1928        std::fs::write(&foreign_data, b"not a real sstable").unwrap();
1929        std::fs::write(foreign_dir.join("nb-1-big-TOC.txt"), b"Data.db\nTOC.txt\n").unwrap();
1930
1931        engine.set_merge_policy(Box::new(policy)).unwrap();
1932        let report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
1933
1934        // The merge must complete using ONLY this table's 4 inputs.
1935        assert_eq!(
1936            report.completed_merges.len(),
1937            1,
1938            "Expected exactly 1 completed merge, got: {:?}",
1939            report.completed_merges
1940        );
1941        let stats = engine.maintenance_stats();
1942        assert_eq!(
1943            stats.sstables_merged_in, 4,
1944            "Only this table's 4 SSTables must be merged; the foreign SSTable must be excluded"
1945        );
1946
1947        // The foreign SSTable must be left completely untouched.
1948        assert!(
1949            foreign_data.exists(),
1950            "Foreign-table SSTable {:?} must not be consumed by this table's compaction",
1951            foreign_data
1952        );
1953
1954        // This table's inputs are consumed as usual.
1955        for p in &input_paths {
1956            assert!(
1957                !p.exists(),
1958                "Input file {:?} should have been deleted after compaction",
1959                p
1960            );
1961        }
1962    }
1963
1964    #[test]
1965    fn test_maintenance_stats_accumulate_across_cycles() {
1966        // Run two compaction cycles and verify that stats accumulate.
1967        let temp_dir = TempDir::new().unwrap();
1968        let schema = create_test_schema();
1969
1970        let policy = crate::storage::write_engine::STCSPolicy::new(
1971            4, 32, 0.5, 1.5, 0, // min_sstable_size=0 for small test files
1972        )
1973        .unwrap();
1974
1975        let config = WriteEngineConfig::new(
1976            temp_dir.path().join("data"),
1977            temp_dir.path().join("wal"),
1978            schema,
1979        );
1980
1981        let mut engine = WriteEngine::new(config).unwrap();
1982        engine.set_merge_policy(Box::new(policy)).unwrap();
1983
1984        // First cycle: flush 4, compact
1985        flush_n_sstables_sync(&mut engine, 4);
1986        engine.maintenance_step(Duration::from_secs(60)).unwrap();
1987
1988        let stats_after_first = engine.maintenance_stats();
1989        assert_eq!(stats_after_first.compactions_completed, 1);
1990
1991        // Second cycle: flush 4 more, compact again
1992        // Row IDs must not collide with the first cycle so each cycle produces 4 SSTables.
1993        // flush_n_sstables_sync uses batch * 100 + row, so offset the start batch.
1994        // We re-use the helper but note generation counter now starts at a higher value,
1995        // so the output SSTable won't conflict with input paths from cycle 1.
1996        flush_n_sstables_sync(&mut engine, 4);
1997        engine.maintenance_step(Duration::from_secs(60)).unwrap();
1998
1999        let stats_after_second = engine.maintenance_stats();
2000        assert_eq!(
2001            stats_after_second.compactions_completed, 2,
2002            "Stats must accumulate across compaction cycles"
2003        );
2004        assert_eq!(
2005            stats_after_second.sstables_merged_in, 8,
2006            "Should have consumed 8 total input SSTables (2 cycles × 4 each)"
2007        );
2008        assert_eq!(
2009            stats_after_second.sstables_produced, 2,
2010            "Should have produced 2 output SSTables"
2011        );
2012        assert!(
2013            stats_after_second.total_time >= stats_after_first.total_time,
2014            "Cumulative total_time must only increase"
2015        );
2016    }
2017
2018    #[test]
2019    fn test_maintenance_step_inputs_intact_on_unwriteable_tmp_dir() {
2020        // Failure injection: make the data_dir read-only so creating the tmp
2021        // compaction directory fails. All input SSTables must remain intact.
2022        //
2023        // Note: This test relies on filesystem permissions and is skipped when
2024        // running as root (where permissions are not enforced).
2025
2026        // Skip if running as root (CI containers sometimes run as root)
2027        #[cfg(unix)]
2028        {
2029            use std::os::unix::fs::MetadataExt;
2030            // Try /proc/self first (Linux), fall back to checking euid via libc
2031            let is_root = std::fs::metadata("/proc/self")
2032                .map(|m| m.uid() == 0)
2033                .unwrap_or_else(|_| {
2034                    // On macOS, /proc/self doesn't exist; use a writable sentinel
2035                    false
2036                });
2037            // Also check by trying to write to /etc/cqlite-test-root-check
2038            let is_root_macos = std::fs::write("/etc/cqlite-test-root-check", b"")
2039                .map(|_| {
2040                    let _ = std::fs::remove_file("/etc/cqlite-test-root-check");
2041                    true
2042                })
2043                .unwrap_or(false);
2044            if is_root || is_root_macos {
2045                // Running as root — permission denial won't work; skip.
2046                return;
2047            }
2048        }
2049
2050        let temp_dir = TempDir::new().unwrap();
2051        let schema = create_test_schema();
2052
2053        let config = WriteEngineConfig::new(
2054            temp_dir.path().join("data"),
2055            temp_dir.path().join("wal"),
2056            schema,
2057        );
2058
2059        let mut engine = WriteEngine::new(config).unwrap();
2060
2061        // Flush 4 SSTables so STCS can select them
2062        let input_paths = flush_n_sstables_sync(&mut engine, 4);
2063        for p in &input_paths {
2064            assert!(
2065                p.exists(),
2066                "Input file {:?} should exist before failure test",
2067                p
2068            );
2069        }
2070
2071        // Make data_dir read-only so creating tmp dir fails
2072        let data_dir = temp_dir.path().join("data");
2073        #[cfg(unix)]
2074        {
2075            use std::os::unix::fs::PermissionsExt;
2076            std::fs::set_permissions(
2077                &data_dir,
2078                std::fs::Permissions::from_mode(0o555), // read+execute, no write
2079            )
2080            .unwrap();
2081        }
2082
2083        let policy = crate::storage::write_engine::STCSPolicy::new(4, 32, 0.5, 1.5, 0).unwrap();
2084        engine.set_merge_policy(Box::new(policy)).unwrap();
2085
2086        // maintenance_step should fail because it cannot create the tmp directory
2087        let result = engine.maintenance_step(Duration::from_secs(60));
2088
2089        // Restore permissions before asserting (so TempDir can clean up)
2090        #[cfg(unix)]
2091        {
2092            use std::os::unix::fs::PermissionsExt;
2093            std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
2094        }
2095
2096        assert!(
2097            result.is_err(),
2098            "maintenance_step should return an error when the tmp dir cannot be created"
2099        );
2100
2101        // All input files must still exist (atomicity guarantee)
2102        for p in &input_paths {
2103            assert!(
2104                p.exists(),
2105                "Input file {:?} must remain intact after failed compaction",
2106                p
2107            );
2108        }
2109
2110        // Stats must NOT have incremented (no successful compaction)
2111        let stats = engine.maintenance_stats();
2112        assert_eq!(
2113            stats.compactions_completed, 0,
2114            "compactions_completed must not increment on failure"
2115        );
2116    }
2117
2118    #[test]
2119    fn test_no_tmp_dir_remains_after_successful_merge() {
2120        // After a successful compaction, the .compaction-tmp-* directory must be cleaned up.
2121        let temp_dir = TempDir::new().unwrap();
2122        let schema = create_test_schema();
2123
2124        let policy = crate::storage::write_engine::STCSPolicy::new(4, 32, 0.5, 1.5, 0).unwrap();
2125
2126        let config = WriteEngineConfig::new(
2127            temp_dir.path().join("data"),
2128            temp_dir.path().join("wal"),
2129            schema,
2130        );
2131
2132        let mut engine = WriteEngine::new(config).unwrap();
2133        flush_n_sstables_sync(&mut engine, 4);
2134
2135        engine.set_merge_policy(Box::new(policy)).unwrap();
2136        engine.maintenance_step(Duration::from_secs(60)).unwrap();
2137
2138        // Scan data_dir for any leftover .compaction-tmp-* directories
2139        let data_dir = temp_dir.path().join("data");
2140        let leftover_tmp: Vec<_> = std::fs::read_dir(&data_dir)
2141            .unwrap()
2142            .filter_map(|e| e.ok())
2143            .filter(|e| {
2144                e.file_name()
2145                    .to_string_lossy()
2146                    .starts_with(".compaction-tmp-")
2147            })
2148            .collect();
2149
2150        assert!(
2151            leftover_tmp.is_empty(),
2152            "No .compaction-tmp-* directories should remain after successful compaction, \
2153             found: {:?}",
2154            leftover_tmp.iter().map(|e| e.path()).collect::<Vec<_>>()
2155        );
2156    }
2157
2158    /// Issue #1619 WIRING EVIDENCE: `WriteEngine::new` must install a default
2159    /// STCS policy so `maintenance_step` compacts WITHOUT any `set_merge_policy`
2160    /// call. This test uses ONLY the public constructor — that is the whole
2161    /// point of the fix (STCS on by default). Before the fix `merge_policy` was
2162    /// hard-coded to `None`, so `rows_merged == 0` and no L0 reduction occurred.
2163    #[test]
2164    fn test_maintenance_step_default_policy_compacts_via_public_ctor() {
2165        let temp_dir = TempDir::new().unwrap();
2166        let schema = create_test_schema();
2167
2168        // Default config: auto_compaction = true (no set_merge_policy call).
2169        let config = WriteEngineConfig::new(
2170            temp_dir.path().join("data"),
2171            temp_dir.path().join("wal"),
2172            schema,
2173        );
2174
2175        let mut engine = WriteEngine::new(config).unwrap();
2176
2177        // Flush 4 distinct L0 SSTables (>= min_threshold = 4). The tiny test
2178        // files are all below DEFAULT_MIN_SSTABLE_SIZE, so STCS groups them via
2179        // the "both small" rule into one eligible bucket.
2180        let input_paths = flush_n_sstables_sync(&mut engine, 4);
2181        assert_eq!(input_paths.len(), 4, "Expected 4 flushed SSTables");
2182
2183        let before = engine.scan_sstable_candidates().unwrap().len();
2184        assert_eq!(before, 4, "Expected 4 L0 SSTables before compaction");
2185
2186        // NO set_merge_policy call — the public ctor must have wired STCS.
2187        let report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
2188
2189        assert!(
2190            report.rows_merged > 0,
2191            "default STCS policy must merge rows via the public ctor (rows_merged = {})",
2192            report.rows_merged
2193        );
2194
2195        let after = engine.scan_sstable_candidates().unwrap().len();
2196        assert!(
2197            after < before,
2198            "on-disk L0 SSTable count must drop after compaction (before = {}, after = {})",
2199            before,
2200            after
2201        );
2202    }
2203
2204    /// Issue #1619 OFF-SWITCH: with `auto_compaction = false`, `WriteEngine::new`
2205    /// installs NO policy, so `maintenance_step` is a no-op even with enough L0
2206    /// SSTables to trigger a compaction. Proves the documented off-switch works.
2207    #[test]
2208    fn test_maintenance_step_auto_compaction_disabled_is_noop() {
2209        let temp_dir = TempDir::new().unwrap();
2210        let schema = create_test_schema();
2211
2212        let mut config = WriteEngineConfig::new(
2213            temp_dir.path().join("data"),
2214            temp_dir.path().join("wal"),
2215            schema,
2216        );
2217        config.auto_compaction = false;
2218
2219        let mut engine = WriteEngine::new(config).unwrap();
2220
2221        let input_paths = flush_n_sstables_sync(&mut engine, 4);
2222        assert_eq!(input_paths.len(), 4, "Expected 4 flushed SSTables");
2223
2224        let before = engine.scan_sstable_candidates().unwrap().len();
2225        assert_eq!(before, 4, "Expected 4 L0 SSTables before compaction");
2226
2227        let report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
2228
2229        assert_eq!(
2230            report.rows_merged, 0,
2231            "off-switch: no policy means no rows merged"
2232        );
2233        assert!(
2234            !report.pending_compaction,
2235            "off-switch: no policy means no pending compaction"
2236        );
2237
2238        let after = engine.scan_sstable_candidates().unwrap().len();
2239        assert_eq!(
2240            after, before,
2241            "off-switch: L0 SSTable count must be unchanged (before = {}, after = {})",
2242            before, after
2243        );
2244    }
2245
2246    /// Issue #1619 AH1: `Config.storage.compaction` must be non-decorative.
2247    /// A `CompactionConfig` with `auto_compaction = false` mapped onto the
2248    /// WriteEngineConfig must disable the default policy end-to-end (no rows
2249    /// merged, no L0 reduction) — proving the config wiring reaches behavior.
2250    #[test]
2251    fn test_compaction_config_disables_default_policy() {
2252        let temp_dir = TempDir::new().unwrap();
2253        let schema = create_test_schema();
2254
2255        let compaction = crate::config::CompactionConfig {
2256            auto_compaction: false,
2257        };
2258        let config = WriteEngineConfig::new(
2259            temp_dir.path().join("data"),
2260            temp_dir.path().join("wal"),
2261            schema,
2262        )
2263        .with_compaction_config(&compaction);
2264        assert!(
2265            !config.auto_compaction,
2266            "config mapping must disable compaction"
2267        );
2268
2269        let mut engine = WriteEngine::new(config).unwrap();
2270        flush_n_sstables_sync(&mut engine, 4);
2271        let before = engine.scan_sstable_candidates().unwrap().len();
2272
2273        let report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
2274        assert_eq!(report.rows_merged, 0, "disabled config: no rows merged");
2275
2276        let after = engine.scan_sstable_candidates().unwrap().len();
2277        assert_eq!(after, before, "disabled config: L0 count unchanged");
2278    }
2279
2280    // Startup orphan-sweep coverage lives in `write_engine::sweep` (issue #1393),
2281    // which owns the sweep implementation and its thorough acceptance tests
2282    // (true-orphan removal, never-delete-live-data, non-fatal surfaced failures,
2283    // idempotence, and the crash-mid-compaction e2e).
2284}