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;
10use super::{CompactionStats, KWayMerger, MergePolicy, WriteEngine};
11use crate::error::{Error, Result};
12use crate::schema::TableSchema;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::Ordering;
15use std::time::{Duration, Instant};
16
17/// Maintenance report from a maintenance_step() call (M5.2, Issue #384)
18#[derive(Debug, Clone)]
19pub struct MaintenanceReport {
20 /// Time spent in this maintenance step
21 pub time_spent: Duration,
22 /// Completed merge output files (if any merge completed)
23 pub completed_merges: Vec<PathBuf>,
24 /// Number of rows merged in this step
25 pub rows_merged: u64,
26 /// Number of bytes written in this step
27 pub bytes_written: u64,
28 /// Whether there is pending compaction work
29 pub pending_compaction: bool,
30 /// SSTables DROPPED WHOLE by the fully-expired fast path in the merge that
31 /// completed this step (issue #1388), distinct from the merged inputs: each was
32 /// proven fully expired by authoritative `Statistics.db` metadata and
33 /// overlap-safe, so it was excluded from the K-way merger (never read/decoded)
34 /// and its components were reclaimed after the merged output published. Empty
35 /// when nothing was dropped. Paths are input Data.db paths.
36 pub dropped_whole: Vec<PathBuf>,
37}
38
39/// Active merge state for incremental compaction (M5.2, Issue #384)
40#[derive(Debug)]
41pub(crate) struct ActiveMerge {
42 /// K-way merger performing the compaction
43 pub(crate) merger: KWayMerger,
44 /// Output SSTable writer (writes to `tmp_dir/keyspace/table/`)
45 pub(crate) writer: crate::storage::sstable::writer::SSTableWriter,
46 /// Input SSTable paths being merged (these remain intact until atomic rename succeeds)
47 pub(crate) input_paths: Vec<PathBuf>,
48 /// Root of the temporary directory tree used for this compaction output.
49 ///
50 /// The SSTableWriter appends `keyspace/table/` to this path, so component
51 /// files land at `tmp_dir/keyspace/table/nb-{gen}-big-*.{ext}`.
52 ///
53 /// After `writer.finish()` the files are atomically renamed to the final
54 /// SSTable directory. Only then are the inputs deleted.
55 ///
56 /// Invariant: if the process crashes before the renames complete, `tmp_dir`
57 /// may contain partial output but the input SSTables remain intact.
58 pub(crate) tmp_dir: PathBuf,
59 /// Final SSTable directory (`data_dir/keyspace/table/`)
60 ///
61 /// Stored here so `finalize_merge_async` doesn't have to recompute it.
62 pub(crate) sstable_dir: PathBuf,
63 /// Number of rows merged so far (updated per partition)
64 pub(crate) rows_merged: u64,
65 /// Total bytes read from input SSTables (approximate: sum of Data.db file sizes)
66 pub(crate) bytes_read: u64,
67 /// When this merge started
68 pub(crate) started_at: Instant,
69 /// Effective compaction schema (#850): the configured schema augmented with
70 /// any static columns that appear in the input SSTables' SerializationHeaders
71 /// but were dropped from the current schema. Used to convert merged entries to
72 /// mutations so the writer still emits the static-row prelude (static-column
73 /// presence is read from the input headers, not the current schema only).
74 pub(crate) effective_schema: TableSchema,
75 /// SSTables DROPPED WHOLE for this compaction (issue #1388): proven fully
76 /// expired by authoritative `Statistics.db` metadata and overlap-safe, EXCLUDED
77 /// from `input_paths` (never read into the merger). Reclaimed in
78 /// `finalize_merge_async` AFTER the merged output publishes, via the same
79 /// component-delete path as the merged inputs, and surfaced in the
80 /// `MaintenanceReport`. Empty when nothing was dropped.
81 pub(crate) dropped_whole: Vec<PathBuf>,
82}
83
84impl WriteEngine {
85 /// Set the merge policy for background compaction (M5.2, Issue #383)
86 ///
87 /// # Arguments
88 ///
89 /// * `policy` - Merge policy implementation (e.g., STCS, LCS, TWCS)
90 pub fn set_merge_policy(&mut self, policy: Box<dyn MergePolicy>) -> Result<()> {
91 self.merge_policy = Some(policy);
92 Ok(())
93 }
94
95 /// Return cumulative compaction statistics (M5.2, Issue #474)
96 ///
97 /// Returns a snapshot of the lifetime totals accumulated across all compaction
98 /// cycles that have completed since the `WriteEngine` was created. The snapshot
99 /// is cheaply cloneable and safe to inspect from any thread (no lock required,
100 /// because `WriteEngine` itself is not `Sync`).
101 ///
102 /// # Example
103 ///
104 /// ```rust,ignore
105 /// let stats = engine.maintenance_stats();
106 /// println!(
107 /// "Completed {} compactions, merged {} rows, wrote {} bytes",
108 /// stats.compactions_completed,
109 /// stats.rows_merged,
110 /// stats.bytes_written,
111 /// );
112 /// ```
113 pub fn maintenance_stats(&self) -> CompactionStats {
114 self.cumulative_stats.clone()
115 }
116
117 /// Perform incremental maintenance work (M5.2, Issue #384)
118 ///
119 /// This method performs background compaction work within a time budget.
120 /// It can be called repeatedly from a background thread or task scheduler
121 /// to make incremental progress on compaction.
122 ///
123 /// ## Runtime contexts
124 ///
125 /// This is a synchronous method, but its internal async-to-sync bridge is
126 /// runtime-aware (see [`merge::block_on_async`]), so it is safe to call from
127 /// **either** a plain synchronous context **or** from within an active Tokio
128 /// runtime — including `#[tokio::main]`/`#[tokio::test]` worker threads and
129 /// `async fn` callers. Prior to Issue #587 calling it from inside a runtime
130 /// panicked with "Cannot start a runtime from within a runtime" once a merge
131 /// had input SSTables to read. The sync signature is preserved so the CLI and
132 /// Python bindings can keep calling it directly. (The Node binding wraps it in
133 /// `spawn_blocking`, which remains correct.)
134 ///
135 /// ## Behavior
136 ///
137 /// 1. If no active merge exists, consult the merge policy for work
138 /// 2. If merge work is available, start a new merge
139 /// 3. Process the active merge until budget is exhausted
140 /// 4. Return progress report
141 ///
142 /// ## Invariants
143 ///
144 /// - Budget is honored within 10% tolerance
145 /// - At least one partition is processed per call (minimum progress guarantee)
146 /// - Merge state is preserved across calls for resumption
147 ///
148 /// ## Budget Enforcement
149 ///
150 /// The budget is honored within approximately 10% tolerance. This tolerance
151 /// exists to avoid interrupting partition processing mid-stream, which would
152 /// require complex state management to resume. The tolerance ensures forward
153 /// progress on each call while remaining responsive to time constraints.
154 ///
155 /// # Arguments
156 ///
157 /// * `budget` - Maximum time to spend in this call
158 ///
159 /// # Returns
160 ///
161 /// A report containing progress metrics and whether more work is pending.
162 ///
163 /// # Errors
164 ///
165 /// Returns an error if:
166 /// - Engine has been closed
167 /// - Merge policy returns an error
168 /// - SSTable reading or writing fails
169 ///
170 /// # Example
171 ///
172 /// ```rust,ignore
173 /// use std::time::Duration;
174 ///
175 /// // Background compaction loop
176 /// loop {
177 /// let report = engine.maintenance_step(Duration::from_millis(100))?;
178 ///
179 /// if !report.pending_compaction {
180 /// // No more work, sleep or exit
181 /// break;
182 /// }
183 ///
184 /// // Log progress
185 /// println!("Merged {} rows in {:?}", report.rows_merged, report.time_spent);
186 /// }
187 /// ```
188 #[tracing::instrument(name = "compaction.maintenance_step", skip(self))]
189 pub fn maintenance_step(&mut self, budget: Duration) -> Result<MaintenanceReport> {
190 // Budget requested for this step (issue #1037). Compared with the
191 // consumed budget below (the scheduler honors a ~10% tolerance).
192 crate::observability::record_histogram(
193 crate::observability::catalog::COMPACTION_BUDGET_REQUESTED,
194 budget.as_secs_f64(),
195 &[],
196 );
197
198 let result = self.maintenance_step_inner(budget);
199
200 // Budget consumed + lifetime-throughput counters (issue #1037). Recorded
201 // for every step (even a no-op one) so the budget-tolerance signal is
202 // complete; rows-merged is per-step and feeds the throughput rate when
203 // combined with COMPACTION_DURATION at finalize.
204 if let Ok(report) = &result {
205 use crate::observability::{self as obs, catalog};
206 obs::record_histogram(
207 catalog::COMPACTION_BUDGET_CONSUMED,
208 report.time_spent.as_secs_f64(),
209 &[],
210 );
211 obs::add_counter(catalog::COMPACTION_ROWS_MERGED, report.rows_merged, &[]);
212 obs::record_gauge(catalog::COMPACTION_LAG, self.l0_count as i64, &[]);
213 }
214
215 crate::observability::record_result("compaction", result)
216 }
217
218 fn maintenance_step_inner(&mut self, budget: Duration) -> Result<MaintenanceReport> {
219 if self.closed.load(Ordering::SeqCst) {
220 return Err(Error::InvalidInput(
221 "WriteEngine has been closed".to_string(),
222 ));
223 }
224
225 let start = Instant::now();
226 let mut report = MaintenanceReport {
227 time_spent: Duration::from_secs(0),
228 completed_merges: Vec::new(),
229 rows_merged: 0,
230 bytes_written: 0,
231 pending_compaction: false,
232 dropped_whole: Vec::new(),
233 };
234
235 // If no merge policy is set, no maintenance work to do
236 let merge_policy = match &self.merge_policy {
237 Some(policy) => policy,
238 None => {
239 report.time_spent = start.elapsed();
240 return Ok(report);
241 }
242 };
243
244 // If no active merge exists, check if we should start one
245 if self.active_merge.is_none() {
246 // SCOPE TO THIS TABLE (#935 branch review): `scan_sstable_candidates`
247 // walks the whole `data_dir` recursively, so it can include SSTables
248 // of OTHER keyspaces/tables. This WriteEngine is single-table
249 // (`config.schema`) and always publishes output to
250 // `data_dir/keyspace/table/`, so restrict the candidate set to THIS
251 // table's directory BEFORE any policy or purge-safety decision.
252 // Otherwise a full compaction of this table is misclassified as
253 // partial whenever a foreign table's SSTable exists under `data_dir`
254 // (`selected_set != candidate_set`), which both lets the policy see
255 // foreign-table inputs and disables tombstone purging that is actually
256 // safe. Every published SSTable for this table lives under
257 // `table_dir`, so the scoping never drops a real input.
258 let table_dir = self
259 .config
260 .data_dir
261 .join(&self.config.schema.keyspace)
262 .join(&self.config.schema.table);
263 let candidates: Vec<PathBuf> = self
264 .scan_sstable_candidates()?
265 .into_iter()
266 .filter(|p| p.starts_with(&table_dir))
267 .collect();
268 let selected = merge_policy.select_merge(&candidates)?;
269
270 if !selected.is_empty() {
271 // Overlap-safety gate for tombstone purging (#921 finding 1): a
272 // compaction may purge tombstones ONLY when it spans EVERY
273 // candidate SSTable for the table (a major/full compaction).
274 // Otherwise a tombstone could be purged while a non-included
275 // overlapping SSTable still holds data it shadows, resurrecting
276 // that data on the next read. A partial selection (the common
277 // background-compaction case) is therefore purge-UNSAFE: it
278 // retains tombstones. Compare as sets so input ordering does not
279 // affect the decision.
280 let selected_set: std::collections::HashSet<&PathBuf> = selected.iter().collect();
281 let candidate_set: std::collections::HashSet<&PathBuf> =
282 candidates.iter().collect();
283 let purge_safe = !candidate_set.is_empty() && selected_set == candidate_set;
284
285 // Overlap-aware partial-compaction purging (#935): when this is a
286 // PARTIAL compaction (some candidate SSTables are NOT included),
287 // compute the min write timestamp across those non-included
288 // SSTables. A tombstone older than every one of them shadows
289 // nothing outside the set and can be purged even here. For a full
290 // compaction (`purge_safe == true`) there are no non-included
291 // SSTables, so the bound is `None` and the merger uses its +inf
292 // full-compaction fast path. `candidates` is already scoped to
293 // this table's directory (see above), so the non-included set is
294 // exactly this table's outside SSTables.
295 // The non-included (outside) overlapping set for this table. Empty
296 // for a full compaction (`purge_safe == true`). Used both for the
297 // #935 overlap-purge bound below AND for the #1388 fully-expired
298 // drop-set overlap gate (see `start_merge`).
299 let non_included: Vec<PathBuf> = candidates
300 .iter()
301 .filter(|p| !selected_set.contains(*p))
302 .cloned()
303 .collect();
304 let max_purgeable_timestamp = if purge_safe {
305 None
306 } else {
307 merge::compute_max_purgeable_timestamp(&non_included)
308 };
309
310 // Start a new merge. `non_included` is threaded through so
311 // `start_merge` can compute the fully-expired drop-set (issue #1388)
312 // with the correct overlap gate.
313 self.start_merge(selected, purge_safe, max_purgeable_timestamp, non_included)?;
314 } else {
315 // No work selected by policy
316 report.time_spent = start.elapsed();
317 report.pending_compaction = false;
318 return Ok(report);
319 }
320 }
321
322 // Process active merge within budget
323 let budget_tolerance = budget.mul_f32(1.1); // 10% tolerance
324 let mut partitions_processed = 0;
325
326 while let Some(merge) = &mut self.active_merge {
327 // Check budget (but always process at least one partition)
328 if partitions_processed > 0 && start.elapsed() >= budget_tolerance {
329 break;
330 }
331
332 // Process one partition from the merge
333 let step = merge.merger.step()?;
334
335 match step {
336 merge::MergeStep::Partition { key, rows } => {
337 partitions_processed += 1;
338
339 // Convert MergeEntry rows to Mutation format
340 // (collect into a vec first to release the borrow on merge)
341 let entries_vec: Vec<_> = rows.into_iter().collect();
342
343 // Now we can call self methods without conflict.
344 // Skip metadata-only entries (#886/#899 branch-review): they
345 // carry complex/range deletion metadata through the merge
346 // stream but have no writer-emittable content yet, so writing
347 // them would produce a phantom live empty (pure-PK) row at
348 // timestamp 0. See `MergeEntry::is_metadata_only_no_op`.
349 // #850: convert with the effective compaction schema so any
350 // static column re-added from the input headers is preserved
351 // (partition-key decoding is identical; only static columns
352 // differ). Falls back to the config schema if (impossibly) no
353 // active merge is present.
354 let conversion_schema = self
355 .active_merge
356 .as_ref()
357 .map(|m| m.effective_schema.clone())
358 .unwrap_or_else(|| self.config.schema.clone());
359 let mutations = entries_vec
360 .into_iter()
361 .filter(|entry| !entry.is_metadata_only_no_op())
362 .map(|entry| {
363 merge::KWayMerger::merge_entry_to_mutation(entry, &conversion_schema)
364 })
365 .collect::<Result<Vec<_>>>()?;
366
367 // If every merged row was metadata-only, the partition has no
368 // writer-emittable content. Skip `write_partition` to avoid a
369 // phantom EMPTY partition (header/end marker + Index/Filter/
370 // Summary/statistics registration) in the output SSTable, and
371 // do not count it as an output partition or row (#886
372 // branch-review).
373 if mutations.is_empty() {
374 continue;
375 }
376
377 // Count rows actually written (skipped metadata-only entries
378 // produce no row, so they must not inflate the stats). A pure
379 // range-tombstone carrier (#933) or a pure partition-tombstone
380 // carrier (#1072) emits a marker / partition-header deletion,
381 // not a row — exclude them, matching KWayMerger::merge.
382 let row_count = mutations
383 .iter()
384 .filter(|m| {
385 let is_range_only = m.operations.is_empty()
386 && m.partition_tombstone.is_none()
387 && m.row_tombstone.is_none()
388 && !m.range_tombstones.is_empty();
389 let is_partition_only = m.operations.is_empty()
390 && m.partition_tombstone.is_some()
391 && m.row_tombstone.is_none()
392 && m.range_tombstones.is_empty();
393 !(is_range_only || is_partition_only)
394 })
395 .count() as u64;
396
397 // Write partition to output SSTable
398 // Re-borrow active_merge to write
399 if let Some(merge) = &mut self.active_merge {
400 merge.writer.write_partition(key, mutations)?;
401 merge.rows_merged += row_count;
402 }
403
404 // Update stats
405 report.rows_merged += row_count;
406 }
407 merge::MergeStep::Complete => {
408 // Merge is complete - finalize and clean up
409 // Use blocking call to handle async finalization
410 self.finalize_merge_blocking(&mut report)?;
411 break;
412 }
413 }
414 }
415
416 // Check if more work is pending
417 report.pending_compaction = self.active_merge.is_some();
418 report.time_spent = start.elapsed();
419
420 Ok(report)
421 }
422
423 #[tracing::instrument(name = "compaction.scan_candidates", skip(self))]
424 fn scan_sstable_candidates(&self) -> Result<Vec<PathBuf>> {
425 let mut candidates = Vec::new();
426
427 if !self.config.data_dir.exists() {
428 return Ok(candidates);
429 }
430
431 Self::scan_data_files(
432 &self.config.data_dir,
433 &mut candidates,
434 crate::storage::sstable::MAX_SSTABLE_SCAN_DEPTH,
435 )?;
436 Ok(candidates)
437 }
438
439 /// Recursively scan for Data.db files
440 fn scan_data_files(dir: &Path, candidates: &mut Vec<PathBuf>, depth: usize) -> Result<()> {
441 for entry in std::fs::read_dir(dir)
442 .map_err(|e| Error::Storage(format!("Failed to read data directory: {}", e)))?
443 {
444 let entry = entry
445 .map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?;
446
447 let path = entry.path();
448 let filename = path.file_name().unwrap_or_default().to_string_lossy();
449
450 // Only consider Data.db files
451 if filename.starts_with("nb-") && filename.ends_with("-big-Data.db") {
452 // Honor the TOC.txt publication barrier (Issue #591). A Data.db
453 // without a sibling TOC.txt is NOT a published SSTable: it is
454 // either a crash-interrupted partial rename or a deferred-delete
455 // orphan whose TOC was removed first while its data file stayed
456 // pinned by an open/mapped reader (Windows). Feeding such a file
457 // to the merger would re-compact an unpublished input and could
458 // produce garbled output, so it is skipped here just as the
459 // read path discovers SSTables by TOC.txt. The startup orphan
460 // sweep reclaims the leftover components.
461 let base = filename.trim_end_matches("-Data.db");
462 let toc_path = path.with_file_name(format!("{base}-TOC.txt"));
463 if toc_path.exists() {
464 candidates.push(path);
465 } else {
466 log::debug!(
467 "scan_data_files: skipping unpublished SSTable (no TOC.txt): {:?}",
468 path
469 );
470 }
471 } else if depth > 0 && path.is_dir() {
472 Self::scan_data_files(&path, candidates, depth - 1)?;
473 }
474 }
475 Ok(())
476 }
477
478 /// Delete all component files for an SSTable (M5.2 helper)
479 pub(crate) fn delete_sstable_files(&self, data_path: &Path) -> Result<()> {
480 Self::delete_sstable_files_static(data_path)
481 }
482
483 /// Static helper that deletes all component files for an SSTable given the
484 /// Data.db path. Called from both `delete_sstable_files` and the startup
485 /// orphan sweep, which runs before `self` is fully constructed.
486 ///
487 /// ## Deferred-delete / Windows policy (Issue #591)
488 ///
489 /// `TOC.txt` is removed **first**. TOC.txt is the publication barrier — both
490 /// the read path (`SSTableManager`) and the compaction candidate scan
491 /// (`scan_data_files`, since #591) treat a Data.db without a sibling TOC.txt
492 /// as unpublished. Removing TOC.txt first therefore *unpublishes* the SSTable
493 /// atomically, before any data component is touched, so it can never be
494 /// observed (no duplicate rows, never re-fed to the merger) even if the
495 /// remaining components cannot be removed yet.
496 ///
497 /// The remaining components are then deleted **best-effort**: a failure on
498 /// any one of them (most plausibly a Windows sharing violation when a
499 /// concurrent reader still has the file open or memory-mapped) is logged but
500 /// does NOT abort the rest or fail the operation. Such a leftover is a
501 /// harmless orphan — invisible because its TOC.txt is gone — and is reclaimed
502 /// by [`Self::sweep_orphaned_partial_sstables`] on the next engine startup,
503 /// by which time the reader's handle has been released. This is the
504 /// "deferred delete" half of the policy; Unix removes the inode immediately
505 /// while any mapping keeps the bytes alive until it is dropped.
506 pub(crate) fn delete_sstable_files_static(data_path: &Path) -> Result<()> {
507 // Extract base path: nb-{gen}-big
508 let filename = data_path
509 .file_name()
510 .and_then(|s| s.to_str())
511 .ok_or_else(|| Error::Storage("Invalid SSTable path".to_string()))?;
512
513 let base = filename
514 .strip_suffix("-Data.db")
515 .ok_or_else(|| Error::Storage("Invalid Data.db filename".to_string()))?;
516
517 let parent_dir = data_path.parent().ok_or_else(|| {
518 Error::Storage(format!(
519 "Data.db path has no parent directory: {:?}",
520 data_path
521 ))
522 })?;
523
524 // TOC.txt FIRST — the publication barrier (Issue #591). Once it is gone
525 // the SSTable is unpublished regardless of whether the data components
526 // can be removed. Remaining components follow, best-effort.
527 let components = [
528 "TOC.txt",
529 "Data.db",
530 "Index.db",
531 "Summary.db",
532 "Statistics.db",
533 "CompressionInfo.db",
534 // CRC.db is the per-chunk CRC for uncompressed BIG SSTables
535 // (Issue #1197); without it deletion/compaction would leave an
536 // orphan file. Best-effort like the other optional components.
537 "CRC.db",
538 "Filter.db",
539 "Digest.crc32",
540 ];
541
542 let mut failures: Vec<String> = Vec::new();
543 for component in &components {
544 let component_path = parent_dir.join(format!("{}-{}", base, component));
545 if component_path.exists() {
546 match std::fs::remove_file(&component_path) {
547 Ok(()) => log::debug!("Deleted compaction input: {:?}", component_path),
548 Err(e) => {
549 // Best-effort: do not abort. A leftover data component
550 // whose TOC.txt is already gone is an invisible orphan
551 // reclaimed by the startup sweep (Issue #591).
552 log::warn!(
553 "Deferred delete of {:?}: {} (component left as orphan; \
554 unpublished via TOC.txt removal, reclaimed on next startup)",
555 component_path,
556 e
557 );
558 failures.push(format!("{:?}: {}", component_path, e));
559 }
560 }
561 }
562 }
563
564 if failures.is_empty() {
565 Ok(())
566 } else {
567 // Surface a non-fatal error so callers can log it. The SSTable is
568 // already unpublished (TOC.txt removed first), so callers treat this
569 // as a deferred reclamation, not a correctness failure.
570 Err(Error::Storage(format!(
571 "Deferred delete left {} orphaned component(s) (unpublished, reclaimed on \
572 next startup): {}",
573 failures.len(),
574 failures.join("; ")
575 )))
576 }
577 }
578}
579
580#[cfg(all(test, feature = "write-support"))]
581mod tests {
582 use super::*;
583 use crate::storage::write_engine::test_support::{create_test_schema, flush_n_sstables_sync};
584 use crate::storage::write_engine::WriteEngineConfig;
585 use std::path::PathBuf;
586 use std::time::Duration;
587 use tempfile::TempDir;
588
589 // Mock merge policy that selects specific files for testing
590 #[derive(Debug)]
591 #[allow(dead_code)] // Used in multiple test functions below
592 struct TestMergePolicy {
593 files_to_select: Vec<PathBuf>,
594 }
595
596 impl MergePolicy for TestMergePolicy {
597 fn select_merge(&self, _candidates: &[PathBuf]) -> Result<Vec<PathBuf>> {
598 Ok(self.files_to_select.clone())
599 }
600 }
601
602 #[test]
603 fn test_set_merge_policy() {
604 let temp_dir = TempDir::new().unwrap();
605 let schema = create_test_schema();
606
607 let config = WriteEngineConfig::new(
608 temp_dir.path().join("data"),
609 temp_dir.path().join("wal"),
610 schema,
611 );
612
613 let mut engine = WriteEngine::new(config).unwrap();
614
615 // Should succeed now (was previously returning error)
616 let policy = Box::new(crate::storage::write_engine::STCSPolicy::default());
617 engine.set_merge_policy(policy).unwrap();
618
619 // With policy set but no SSTables, should return quickly with no work
620 let report = engine
621 .maintenance_step(std::time::Duration::from_millis(100))
622 .unwrap();
623 assert!(!report.pending_compaction);
624 assert_eq!(report.rows_merged, 0);
625 }
626
627 // M5.2 maintenance_step() tests (Issue #384)
628
629 #[test]
630 fn test_maintenance_step_no_policy() {
631 // Without a merge policy, maintenance_step should do nothing.
632 // Since #1619 makes STCS the default, disable auto_compaction so this
633 // test still validates the None branch (no-policy -> no work).
634 let temp_dir = TempDir::new().unwrap();
635 let schema = create_test_schema();
636
637 let mut config = WriteEngineConfig::new(
638 temp_dir.path().join("data"),
639 temp_dir.path().join("wal"),
640 schema,
641 );
642 config.auto_compaction = false;
643
644 let mut engine = WriteEngine::new(config).unwrap();
645
646 // Call maintenance_step without setting a policy
647 let report = engine.maintenance_step(Duration::from_millis(100)).unwrap();
648
649 // Should return immediately with no work done
650 assert_eq!(report.rows_merged, 0);
651 assert_eq!(report.bytes_written, 0);
652 assert_eq!(report.completed_merges.len(), 0);
653 assert!(!report.pending_compaction);
654 assert!(report.time_spent < Duration::from_millis(50));
655 }
656
657 #[test]
658 fn test_maintenance_step_with_closed_engine() {
659 let temp_dir = TempDir::new().unwrap();
660 let schema = create_test_schema();
661
662 let config = WriteEngineConfig::new(
663 temp_dir.path().join("data"),
664 temp_dir.path().join("wal"),
665 schema,
666 );
667
668 let mut engine = WriteEngine::new(config).unwrap();
669
670 // Close the engine
671 tokio::runtime::Runtime::new()
672 .unwrap()
673 .block_on(engine.close())
674 .unwrap();
675
676 // maintenance_step should fail on closed engine
677 let result = engine.maintenance_step(Duration::from_millis(100));
678 assert!(result.is_err());
679 match result {
680 Err(Error::InvalidInput(msg)) => {
681 assert!(msg.contains("closed"));
682 }
683 _ => panic!("Expected InvalidInput error"),
684 }
685 }
686
687 #[test]
688 fn test_maintenance_report_creation() {
689 let report = MaintenanceReport {
690 time_spent: Duration::from_millis(250),
691 completed_merges: vec![PathBuf::from("data/nb-5-big-Data.db")],
692 rows_merged: 1000,
693 bytes_written: 1024 * 1024,
694 pending_compaction: true,
695 dropped_whole: Vec::new(),
696 };
697
698 assert_eq!(report.time_spent.as_millis(), 250);
699 assert_eq!(report.completed_merges.len(), 1);
700 assert_eq!(report.rows_merged, 1000);
701 assert_eq!(report.bytes_written, 1024 * 1024);
702 assert!(report.pending_compaction);
703 }
704
705 #[test]
706 fn test_scan_sstable_candidates_empty_dir() {
707 let temp_dir = TempDir::new().unwrap();
708 let schema = create_test_schema();
709
710 let config = WriteEngineConfig::new(
711 temp_dir.path().join("data"),
712 temp_dir.path().join("wal"),
713 schema,
714 );
715
716 let engine = WriteEngine::new(config).unwrap();
717
718 let candidates = engine.scan_sstable_candidates().unwrap();
719 assert_eq!(candidates.len(), 0);
720 }
721
722 #[test]
723 fn test_scan_sstable_candidates_with_sstables() {
724 let temp_dir = TempDir::new().unwrap();
725 let schema = create_test_schema();
726
727 let config = WriteEngineConfig::new(
728 temp_dir.path().join("data"),
729 temp_dir.path().join("wal"),
730 schema,
731 );
732
733 let engine = WriteEngine::new(config).unwrap();
734
735 // Create dummy SSTable files. Each Data.db needs a sibling TOC.txt to
736 // count as a *published* SSTable (the publication barrier, Issue #591) —
737 // a Data.db without TOC.txt is an unpublished partial/orphan and must be
738 // skipped by the candidate scan.
739 let data_dir = temp_dir.path().join("data");
740 std::fs::create_dir_all(&data_dir).unwrap();
741 std::fs::write(data_dir.join("nb-1-big-Data.db"), b"").unwrap();
742 std::fs::write(data_dir.join("nb-1-big-TOC.txt"), b"").unwrap();
743 std::fs::write(data_dir.join("nb-2-big-Data.db"), b"").unwrap();
744 std::fs::write(data_dir.join("nb-2-big-TOC.txt"), b"").unwrap();
745 std::fs::write(data_dir.join("nb-3-big-Index.db"), b"").unwrap(); // Not a Data.db
746 std::fs::write(data_dir.join("other-file.txt"), b"").unwrap(); // Not an SSTable
747 // An unpublished Data.db (no TOC.txt) must NOT be picked up (Issue #591).
748 std::fs::write(data_dir.join("nb-4-big-Data.db"), b"").unwrap();
749
750 let candidates = engine.scan_sstable_candidates().unwrap();
751
752 // Should only find the two PUBLISHED Data.db files (TOC.txt present);
753 // nb-4 is excluded because it has no TOC.txt.
754 assert_eq!(candidates.len(), 2);
755 assert!(candidates
756 .iter()
757 .all(|p| p.to_string_lossy().contains("Data.db")));
758 assert!(
759 !candidates
760 .iter()
761 .any(|p| p.to_string_lossy().contains("nb-4-big")),
762 "unpublished Data.db (no TOC.txt) must be excluded (Issue #591)"
763 );
764 }
765
766 #[test]
767 fn test_delete_sstable_files() {
768 let temp_dir = TempDir::new().unwrap();
769 let schema = create_test_schema();
770
771 let config = WriteEngineConfig::new(
772 temp_dir.path().join("data"),
773 temp_dir.path().join("wal"),
774 schema,
775 );
776
777 let engine = WriteEngine::new(config).unwrap();
778
779 // Create dummy SSTable component files
780 let data_dir = temp_dir.path().join("data");
781 std::fs::create_dir_all(&data_dir).unwrap();
782
783 let components = [
784 "nb-5-big-Data.db",
785 "nb-5-big-Index.db",
786 "nb-5-big-Summary.db",
787 "nb-5-big-Statistics.db",
788 ];
789
790 for component in &components {
791 std::fs::write(data_dir.join(component), b"dummy").unwrap();
792 }
793
794 // Verify files exist
795 for component in &components {
796 assert!(data_dir.join(component).exists());
797 }
798
799 // Delete SSTable files
800 let data_path = data_dir.join("nb-5-big-Data.db");
801 engine.delete_sstable_files(&data_path).unwrap();
802
803 // Verify files are deleted
804 for component in &components {
805 assert!(!data_dir.join(component).exists());
806 }
807 }
808
809 /// Issue #591: deletion removes TOC.txt FIRST so the SSTable is unpublished
810 /// before any data component is touched. This guarantees the read path and
811 /// the compaction candidate scan stop seeing it immediately, even if a data
812 /// component cannot be removed yet (e.g. pinned by a mapped reader on
813 /// Windows).
814 #[test]
815 fn test_delete_removes_toc_first_unpublishing_atomically() {
816 let temp_dir = TempDir::new().unwrap();
817 let data_dir = temp_dir.path().join("data");
818 std::fs::create_dir_all(&data_dir).unwrap();
819
820 // A full published SSTable component set including TOC.txt.
821 for comp in &[
822 "nb-7-big-Data.db",
823 "nb-7-big-Index.db",
824 "nb-7-big-Statistics.db",
825 "nb-7-big-TOC.txt",
826 ] {
827 std::fs::write(data_dir.join(comp), b"x").unwrap();
828 }
829
830 let data_path = data_dir.join("nb-7-big-Data.db");
831 WriteEngine::delete_sstable_files_static(&data_path).unwrap();
832
833 // Everything gone on the happy path.
834 assert!(!data_dir.join("nb-7-big-TOC.txt").exists());
835 assert!(!data_path.exists());
836
837 // And critically: scan_data_files (the compaction candidate discovery)
838 // never surfaces a Data.db without a TOC.txt, so a deferred-delete orphan
839 // is not re-fed to the merger. Recreate a TOC-less leftover to prove it.
840 std::fs::write(data_dir.join("nb-8-big-Data.db"), b"x").unwrap();
841 let mut candidates = Vec::new();
842 WriteEngine::scan_data_files(&data_dir, &mut candidates, 1).unwrap();
843 assert!(
844 candidates.is_empty(),
845 "a Data.db without a sibling TOC.txt must NOT be a compaction candidate \
846 (publication barrier, Issue #591); got {:?}",
847 candidates
848 );
849
850 // Add the matching TOC.txt and it becomes a valid candidate again.
851 std::fs::write(data_dir.join("nb-8-big-TOC.txt"), b"x").unwrap();
852 let mut candidates = Vec::new();
853 WriteEngine::scan_data_files(&data_dir, &mut candidates, 1).unwrap();
854 assert_eq!(
855 candidates.len(),
856 1,
857 "a published Data.db (TOC.txt present) must be discovered"
858 );
859 }
860
861 #[test]
862 fn test_maintenance_step_with_policy_no_work() {
863 // Policy that returns empty selection (no work to do)
864 let temp_dir = TempDir::new().unwrap();
865 let schema = create_test_schema();
866
867 let config = WriteEngineConfig::new(
868 temp_dir.path().join("data"),
869 temp_dir.path().join("wal"),
870 schema,
871 );
872
873 let mut engine = WriteEngine::new(config).unwrap();
874
875 // Set a policy that selects nothing
876 let policy = TestMergePolicy {
877 files_to_select: vec![],
878 };
879 engine.set_merge_policy(Box::new(policy)).unwrap();
880
881 // Call maintenance_step - policy selects no work
882 let report = engine.maintenance_step(Duration::from_millis(100)).unwrap();
883
884 // Should return with no work done
885 assert_eq!(report.rows_merged, 0);
886 assert_eq!(report.bytes_written, 0);
887 assert_eq!(report.completed_merges.len(), 0);
888 assert!(!report.pending_compaction);
889 }
890
891 #[test]
892 fn test_maintenance_step_budget_honored() {
893 // Test that budget is approximately honored
894 let temp_dir = TempDir::new().unwrap();
895 let schema = create_test_schema();
896
897 let config = WriteEngineConfig::new(
898 temp_dir.path().join("data"),
899 temp_dir.path().join("wal"),
900 schema,
901 );
902
903 let mut engine = WriteEngine::new(config).unwrap();
904
905 // Set a policy that selects nothing
906 let policy = TestMergePolicy {
907 files_to_select: vec![],
908 };
909 engine.set_merge_policy(Box::new(policy)).unwrap();
910
911 // Call with small budget - policy selects no work, should return quickly
912 let budget = Duration::from_millis(10);
913 let report = engine.maintenance_step(budget).unwrap();
914
915 // Should return quickly when there's no compaction work
916 assert!(
917 report.time_spent < budget.mul_f32(1.5),
918 "Time spent {:?} exceeded budget {:?} by >50%",
919 report.time_spent,
920 budget
921 );
922 }
923
924 #[test]
925 fn test_maintenance_stats_initial_zero() {
926 // Before any maintenance work, all stats should be zero
927 let temp_dir = TempDir::new().unwrap();
928 let schema = create_test_schema();
929
930 let config = WriteEngineConfig::new(
931 temp_dir.path().join("data"),
932 temp_dir.path().join("wal"),
933 schema,
934 );
935
936 let engine = WriteEngine::new(config).unwrap();
937
938 let stats = engine.maintenance_stats();
939 assert_eq!(stats.compactions_completed, 0);
940 assert_eq!(stats.sstables_merged_in, 0);
941 assert_eq!(stats.sstables_produced, 0);
942 assert_eq!(stats.bytes_read, 0);
943 assert_eq!(stats.bytes_written, 0);
944 assert_eq!(stats.rows_merged, 0);
945 assert_eq!(stats.total_time, Duration::ZERO);
946 }
947
948 #[test]
949 fn test_stcs_selects_expected_group_by_size() {
950 // Verify that STCSPolicy groups four same-sized SSTables into one candidate set.
951 // We do this without actually running a merge (just test the policy selection).
952 let policy = crate::storage::write_engine::STCSPolicy::default();
953
954 // Create 4 temp files of equal size to satisfy min_threshold=4
955 let temp_dir = TempDir::new().unwrap();
956 let mut paths = Vec::new();
957 for i in 1..=4 {
958 let path = temp_dir.path().join(format!("nb-{}-big-Data.db", i));
959 // 60 MB each (above min_sstable_size threshold)
960 let size_bytes = 60 * 1024 * 1024u64;
961 let file = std::fs::File::create(&path).unwrap();
962 file.set_len(size_bytes).unwrap();
963 paths.push(path);
964 }
965
966 // Policy should select all 4 as a candidate group
967 let selected = policy.select_merge(&paths).unwrap();
968 assert_eq!(
969 selected.len(),
970 4,
971 "STCS should select all 4 same-sized SSTables as one compaction group"
972 );
973
974 // All selected paths should be from our input set
975 for sel in &selected {
976 assert!(
977 paths.contains(sel),
978 "Selected path {:?} not in input set",
979 sel
980 );
981 }
982 }
983
984 #[test]
985 fn test_stcs_does_not_select_below_threshold() {
986 // With only 3 SSTables, STCS (min_threshold=4) should select nothing.
987 let policy = crate::storage::write_engine::STCSPolicy::default();
988
989 let temp_dir = TempDir::new().unwrap();
990 let mut paths = Vec::new();
991 for i in 1..=3 {
992 let path = temp_dir.path().join(format!("nb-{}-big-Data.db", i));
993 let file = std::fs::File::create(&path).unwrap();
994 file.set_len(60 * 1024 * 1024).unwrap();
995 paths.push(path);
996 }
997
998 let selected = policy.select_merge(&paths).unwrap();
999 assert!(
1000 selected.is_empty(),
1001 "STCS should NOT select when fewer than min_threshold SSTables exist"
1002 );
1003 }
1004
1005 #[test]
1006 fn test_maintenance_step_compacts_sstables_atomically() {
1007 // Create an engine, flush 4 SSTables, then run maintenance_step with STCS.
1008 // After the step: input files must be gone, output file must exist,
1009 // and maintenance_stats() must reflect the completed compaction.
1010 //
1011 // Uses a sync wrapper so maintenance_step's internal block_on works without
1012 // nesting inside a pre-existing async runtime.
1013 let temp_dir = TempDir::new().unwrap();
1014 let schema = create_test_schema();
1015
1016 // Use a LOW min_sstable_size so small test files pass bucket grouping
1017 let policy = crate::storage::write_engine::STCSPolicy::new(
1018 4, // min_threshold
1019 32, // max_threshold
1020 0.5, // bucket_low
1021 1.5, // bucket_high
1022 0, // min_sstable_size = 0 so tiny files group together
1023 )
1024 .unwrap();
1025
1026 let config = WriteEngineConfig::new(
1027 temp_dir.path().join("data"),
1028 temp_dir.path().join("wal"),
1029 schema,
1030 );
1031
1032 let mut engine = WriteEngine::new(config).unwrap();
1033
1034 // Flush 4 distinct SSTables (sync helper creates its own single-threaded runtime)
1035 let input_paths = flush_n_sstables_sync(&mut engine, 4);
1036 assert_eq!(input_paths.len(), 4, "Expected 4 flushed SSTables");
1037
1038 // Verify all input Data.db files exist before compaction
1039 for p in &input_paths {
1040 assert!(
1041 p.exists(),
1042 "Input file {:?} should exist before compaction",
1043 p
1044 );
1045 }
1046
1047 // Attach the policy and run maintenance
1048 engine.set_merge_policy(Box::new(policy)).unwrap();
1049 let report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
1050
1051 // The report must indicate a completed merge
1052 assert_eq!(
1053 report.completed_merges.len(),
1054 1,
1055 "Expected exactly 1 completed merge, got: {:?}",
1056 report.completed_merges
1057 );
1058 // bytes_written is u64 and always non-negative, so no assertion needed here.
1059
1060 // The merged output file must exist in the final SSTable directory
1061 let merged_path = &report.completed_merges[0];
1062 assert!(
1063 merged_path.exists(),
1064 "Merged output file {:?} must exist after compaction",
1065 merged_path
1066 );
1067
1068 // All input files must be gone (consumed by compaction)
1069 for p in &input_paths {
1070 assert!(
1071 !p.exists(),
1072 "Input file {:?} should have been deleted after compaction",
1073 p
1074 );
1075 }
1076
1077 // maintenance_stats() must reflect the operation
1078 let stats = engine.maintenance_stats();
1079 assert_eq!(
1080 stats.compactions_completed, 1,
1081 "compactions_completed must be 1"
1082 );
1083 assert_eq!(
1084 stats.sstables_merged_in, 4,
1085 "Should have consumed 4 input SSTables"
1086 );
1087 assert_eq!(stats.sstables_produced, 1, "sstables_produced must be 1");
1088 // bytes_written may be 0 if the merged output is empty (reader/writer compatibility),
1089 // but total_time must be non-zero
1090 assert!(stats.total_time > Duration::ZERO, "total_time must be > 0");
1091 }
1092
1093 /// #935 branch-review regression: `scan_sstable_candidates` walks the whole
1094 /// `data_dir` recursively, so a foreign keyspace/table's SSTable sitting under
1095 /// `data_dir` must NOT be treated as a candidate for this table's compaction.
1096 /// Before the fix the foreign SSTable inflated `candidate_set`, so a full
1097 /// compaction of this table was misclassified as partial (the policy could
1098 /// also see the foreign input). After the fix candidates are scoped to
1099 /// `data_dir/keyspace/table/`, so only this table's SSTables are merged and
1100 /// the foreign file is left untouched.
1101 #[test]
1102 fn test_maintenance_step_ignores_foreign_table_sstables() {
1103 let temp_dir = TempDir::new().unwrap();
1104 let schema = create_test_schema();
1105
1106 let policy = crate::storage::write_engine::STCSPolicy::new(
1107 4, // min_threshold
1108 32, // max_threshold
1109 0.5, // bucket_low
1110 1.5, // bucket_high
1111 0, // min_sstable_size = 0 so tiny files group together
1112 )
1113 .unwrap();
1114
1115 let data_dir = temp_dir.path().join("data");
1116 let config = WriteEngineConfig::new(data_dir.clone(), temp_dir.path().join("wal"), schema);
1117
1118 let mut engine = WriteEngine::new(config).unwrap();
1119
1120 // Flush 4 SSTables for THIS table (data/test_ks/test_table/).
1121 let input_paths = flush_n_sstables_sync(&mut engine, 4);
1122 assert_eq!(input_paths.len(), 4, "Expected 4 flushed SSTables");
1123
1124 // Plant a foreign keyspace/table SSTable under the same data_dir, with a
1125 // sibling TOC.txt so it passes the publication barrier and would be
1126 // discovered by the recursive scan.
1127 let foreign_dir = data_dir.join("other_ks").join("other_tbl");
1128 std::fs::create_dir_all(&foreign_dir).unwrap();
1129 let foreign_data = foreign_dir.join("nb-1-big-Data.db");
1130 std::fs::write(&foreign_data, b"not a real sstable").unwrap();
1131 std::fs::write(foreign_dir.join("nb-1-big-TOC.txt"), b"Data.db\nTOC.txt\n").unwrap();
1132
1133 engine.set_merge_policy(Box::new(policy)).unwrap();
1134 let report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
1135
1136 // The merge must complete using ONLY this table's 4 inputs.
1137 assert_eq!(
1138 report.completed_merges.len(),
1139 1,
1140 "Expected exactly 1 completed merge, got: {:?}",
1141 report.completed_merges
1142 );
1143 let stats = engine.maintenance_stats();
1144 assert_eq!(
1145 stats.sstables_merged_in, 4,
1146 "Only this table's 4 SSTables must be merged; the foreign SSTable must be excluded"
1147 );
1148
1149 // The foreign SSTable must be left completely untouched.
1150 assert!(
1151 foreign_data.exists(),
1152 "Foreign-table SSTable {:?} must not be consumed by this table's compaction",
1153 foreign_data
1154 );
1155
1156 // This table's inputs are consumed as usual.
1157 for p in &input_paths {
1158 assert!(
1159 !p.exists(),
1160 "Input file {:?} should have been deleted after compaction",
1161 p
1162 );
1163 }
1164 }
1165
1166 #[test]
1167 fn test_maintenance_stats_accumulate_across_cycles() {
1168 // Run two compaction cycles and verify that stats accumulate.
1169 let temp_dir = TempDir::new().unwrap();
1170 let schema = create_test_schema();
1171
1172 let policy = crate::storage::write_engine::STCSPolicy::new(
1173 4, 32, 0.5, 1.5, 0, // min_sstable_size=0 for small test files
1174 )
1175 .unwrap();
1176
1177 let config = WriteEngineConfig::new(
1178 temp_dir.path().join("data"),
1179 temp_dir.path().join("wal"),
1180 schema,
1181 );
1182
1183 let mut engine = WriteEngine::new(config).unwrap();
1184 engine.set_merge_policy(Box::new(policy)).unwrap();
1185
1186 // First cycle: flush 4, compact
1187 flush_n_sstables_sync(&mut engine, 4);
1188 engine.maintenance_step(Duration::from_secs(60)).unwrap();
1189
1190 let stats_after_first = engine.maintenance_stats();
1191 assert_eq!(stats_after_first.compactions_completed, 1);
1192
1193 // Second cycle: flush 4 more, compact again
1194 // Row IDs must not collide with the first cycle so each cycle produces 4 SSTables.
1195 // flush_n_sstables_sync uses batch * 100 + row, so offset the start batch.
1196 // We re-use the helper but note generation counter now starts at a higher value,
1197 // so the output SSTable won't conflict with input paths from cycle 1.
1198 flush_n_sstables_sync(&mut engine, 4);
1199 engine.maintenance_step(Duration::from_secs(60)).unwrap();
1200
1201 let stats_after_second = engine.maintenance_stats();
1202 assert_eq!(
1203 stats_after_second.compactions_completed, 2,
1204 "Stats must accumulate across compaction cycles"
1205 );
1206 assert_eq!(
1207 stats_after_second.sstables_merged_in, 8,
1208 "Should have consumed 8 total input SSTables (2 cycles × 4 each)"
1209 );
1210 assert_eq!(
1211 stats_after_second.sstables_produced, 2,
1212 "Should have produced 2 output SSTables"
1213 );
1214 assert!(
1215 stats_after_second.total_time >= stats_after_first.total_time,
1216 "Cumulative total_time must only increase"
1217 );
1218 }
1219
1220 #[test]
1221 fn test_maintenance_step_inputs_intact_on_unwriteable_tmp_dir() {
1222 // Failure injection: make the data_dir read-only so creating the tmp
1223 // compaction directory fails. All input SSTables must remain intact.
1224 //
1225 // Note: This test relies on filesystem permissions and is skipped when
1226 // running as root (where permissions are not enforced).
1227
1228 // Skip if running as root (CI containers sometimes run as root)
1229 #[cfg(unix)]
1230 {
1231 use std::os::unix::fs::MetadataExt;
1232 // Try /proc/self first (Linux), fall back to checking euid via libc
1233 let is_root = std::fs::metadata("/proc/self")
1234 .map(|m| m.uid() == 0)
1235 .unwrap_or_else(|_| {
1236 // On macOS, /proc/self doesn't exist; use a writable sentinel
1237 false
1238 });
1239 // Also check by trying to write to /etc/cqlite-test-root-check
1240 let is_root_macos = std::fs::write("/etc/cqlite-test-root-check", b"")
1241 .map(|_| {
1242 let _ = std::fs::remove_file("/etc/cqlite-test-root-check");
1243 true
1244 })
1245 .unwrap_or(false);
1246 if is_root || is_root_macos {
1247 // Running as root — permission denial won't work; skip.
1248 return;
1249 }
1250 }
1251
1252 let temp_dir = TempDir::new().unwrap();
1253 let schema = create_test_schema();
1254
1255 let config = WriteEngineConfig::new(
1256 temp_dir.path().join("data"),
1257 temp_dir.path().join("wal"),
1258 schema,
1259 );
1260
1261 let mut engine = WriteEngine::new(config).unwrap();
1262
1263 // Flush 4 SSTables so STCS can select them
1264 let input_paths = flush_n_sstables_sync(&mut engine, 4);
1265 for p in &input_paths {
1266 assert!(
1267 p.exists(),
1268 "Input file {:?} should exist before failure test",
1269 p
1270 );
1271 }
1272
1273 // Make data_dir read-only so creating tmp dir fails
1274 let data_dir = temp_dir.path().join("data");
1275 #[cfg(unix)]
1276 {
1277 use std::os::unix::fs::PermissionsExt;
1278 std::fs::set_permissions(
1279 &data_dir,
1280 std::fs::Permissions::from_mode(0o555), // read+execute, no write
1281 )
1282 .unwrap();
1283 }
1284
1285 let policy = crate::storage::write_engine::STCSPolicy::new(4, 32, 0.5, 1.5, 0).unwrap();
1286 engine.set_merge_policy(Box::new(policy)).unwrap();
1287
1288 // maintenance_step should fail because it cannot create the tmp directory
1289 let result = engine.maintenance_step(Duration::from_secs(60));
1290
1291 // Restore permissions before asserting (so TempDir can clean up)
1292 #[cfg(unix)]
1293 {
1294 use std::os::unix::fs::PermissionsExt;
1295 std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1296 }
1297
1298 assert!(
1299 result.is_err(),
1300 "maintenance_step should return an error when the tmp dir cannot be created"
1301 );
1302
1303 // All input files must still exist (atomicity guarantee)
1304 for p in &input_paths {
1305 assert!(
1306 p.exists(),
1307 "Input file {:?} must remain intact after failed compaction",
1308 p
1309 );
1310 }
1311
1312 // Stats must NOT have incremented (no successful compaction)
1313 let stats = engine.maintenance_stats();
1314 assert_eq!(
1315 stats.compactions_completed, 0,
1316 "compactions_completed must not increment on failure"
1317 );
1318 }
1319
1320 #[test]
1321 fn test_no_tmp_dir_remains_after_successful_merge() {
1322 // After a successful compaction, the .compaction-tmp-* directory must be cleaned up.
1323 let temp_dir = TempDir::new().unwrap();
1324 let schema = create_test_schema();
1325
1326 let policy = crate::storage::write_engine::STCSPolicy::new(4, 32, 0.5, 1.5, 0).unwrap();
1327
1328 let config = WriteEngineConfig::new(
1329 temp_dir.path().join("data"),
1330 temp_dir.path().join("wal"),
1331 schema,
1332 );
1333
1334 let mut engine = WriteEngine::new(config).unwrap();
1335 flush_n_sstables_sync(&mut engine, 4);
1336
1337 engine.set_merge_policy(Box::new(policy)).unwrap();
1338 engine.maintenance_step(Duration::from_secs(60)).unwrap();
1339
1340 // Scan data_dir for any leftover .compaction-tmp-* directories
1341 let data_dir = temp_dir.path().join("data");
1342 let leftover_tmp: Vec<_> = std::fs::read_dir(&data_dir)
1343 .unwrap()
1344 .filter_map(|e| e.ok())
1345 .filter(|e| {
1346 e.file_name()
1347 .to_string_lossy()
1348 .starts_with(".compaction-tmp-")
1349 })
1350 .collect();
1351
1352 assert!(
1353 leftover_tmp.is_empty(),
1354 "No .compaction-tmp-* directories should remain after successful compaction, \
1355 found: {:?}",
1356 leftover_tmp.iter().map(|e| e.path()).collect::<Vec<_>>()
1357 );
1358 }
1359
1360 /// Issue #1619 WIRING EVIDENCE: `WriteEngine::new` must install a default
1361 /// STCS policy so `maintenance_step` compacts WITHOUT any `set_merge_policy`
1362 /// call. This test uses ONLY the public constructor — that is the whole
1363 /// point of the fix (STCS on by default). Before the fix `merge_policy` was
1364 /// hard-coded to `None`, so `rows_merged == 0` and no L0 reduction occurred.
1365 #[test]
1366 fn test_maintenance_step_default_policy_compacts_via_public_ctor() {
1367 let temp_dir = TempDir::new().unwrap();
1368 let schema = create_test_schema();
1369
1370 // Default config: auto_compaction = true (no set_merge_policy call).
1371 let config = WriteEngineConfig::new(
1372 temp_dir.path().join("data"),
1373 temp_dir.path().join("wal"),
1374 schema,
1375 );
1376
1377 let mut engine = WriteEngine::new(config).unwrap();
1378
1379 // Flush 4 distinct L0 SSTables (>= min_threshold = 4). The tiny test
1380 // files are all below DEFAULT_MIN_SSTABLE_SIZE, so STCS groups them via
1381 // the "both small" rule into one eligible bucket.
1382 let input_paths = flush_n_sstables_sync(&mut engine, 4);
1383 assert_eq!(input_paths.len(), 4, "Expected 4 flushed SSTables");
1384
1385 let before = engine.scan_sstable_candidates().unwrap().len();
1386 assert_eq!(before, 4, "Expected 4 L0 SSTables before compaction");
1387
1388 // NO set_merge_policy call — the public ctor must have wired STCS.
1389 let report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
1390
1391 assert!(
1392 report.rows_merged > 0,
1393 "default STCS policy must merge rows via the public ctor (rows_merged = {})",
1394 report.rows_merged
1395 );
1396
1397 let after = engine.scan_sstable_candidates().unwrap().len();
1398 assert!(
1399 after < before,
1400 "on-disk L0 SSTable count must drop after compaction (before = {}, after = {})",
1401 before,
1402 after
1403 );
1404 }
1405
1406 /// Issue #1619 OFF-SWITCH: with `auto_compaction = false`, `WriteEngine::new`
1407 /// installs NO policy, so `maintenance_step` is a no-op even with enough L0
1408 /// SSTables to trigger a compaction. Proves the documented off-switch works.
1409 #[test]
1410 fn test_maintenance_step_auto_compaction_disabled_is_noop() {
1411 let temp_dir = TempDir::new().unwrap();
1412 let schema = create_test_schema();
1413
1414 let mut config = WriteEngineConfig::new(
1415 temp_dir.path().join("data"),
1416 temp_dir.path().join("wal"),
1417 schema,
1418 );
1419 config.auto_compaction = false;
1420
1421 let mut engine = WriteEngine::new(config).unwrap();
1422
1423 let input_paths = flush_n_sstables_sync(&mut engine, 4);
1424 assert_eq!(input_paths.len(), 4, "Expected 4 flushed SSTables");
1425
1426 let before = engine.scan_sstable_candidates().unwrap().len();
1427 assert_eq!(before, 4, "Expected 4 L0 SSTables before compaction");
1428
1429 let report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
1430
1431 assert_eq!(
1432 report.rows_merged, 0,
1433 "off-switch: no policy means no rows merged"
1434 );
1435 assert!(
1436 !report.pending_compaction,
1437 "off-switch: no policy means no pending compaction"
1438 );
1439
1440 let after = engine.scan_sstable_candidates().unwrap().len();
1441 assert_eq!(
1442 after, before,
1443 "off-switch: L0 SSTable count must be unchanged (before = {}, after = {})",
1444 before, after
1445 );
1446 }
1447
1448 /// Issue #1619 AH1: `Config.storage.compaction` must be non-decorative.
1449 /// A `CompactionConfig` with `auto_compaction = false` mapped onto the
1450 /// WriteEngineConfig must disable the default policy end-to-end (no rows
1451 /// merged, no L0 reduction) — proving the config wiring reaches behavior.
1452 #[test]
1453 fn test_compaction_config_disables_default_policy() {
1454 let temp_dir = TempDir::new().unwrap();
1455 let schema = create_test_schema();
1456
1457 let compaction = crate::config::CompactionConfig {
1458 auto_compaction: false,
1459 };
1460 let config = WriteEngineConfig::new(
1461 temp_dir.path().join("data"),
1462 temp_dir.path().join("wal"),
1463 schema,
1464 )
1465 .with_compaction_config(&compaction);
1466 assert!(
1467 !config.auto_compaction,
1468 "config mapping must disable compaction"
1469 );
1470
1471 let mut engine = WriteEngine::new(config).unwrap();
1472 flush_n_sstables_sync(&mut engine, 4);
1473 let before = engine.scan_sstable_candidates().unwrap().len();
1474
1475 let report = engine.maintenance_step(Duration::from_secs(60)).unwrap();
1476 assert_eq!(report.rows_merged, 0, "disabled config: no rows merged");
1477
1478 let after = engine.scan_sstable_candidates().unwrap().len();
1479 assert_eq!(after, before, "disabled config: L0 count unchanged");
1480 }
1481
1482 // Startup orphan-sweep coverage lives in `write_engine::sweep` (issue #1393),
1483 // which owns the sweep implementation and its thorough acceptance tests
1484 // (true-orphan removal, never-delete-live-data, non-fatal surfaced failures,
1485 // idempotence, and the crash-mid-compaction e2e).
1486}