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