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}