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