Skip to main content

cqlite_core/storage/write_engine/
maintenance.rs

1//! SSTable maintenance and STCS compaction for the write engine.
2//!
3//! Extracted verbatim from `write_engine/mod.rs` (issue #1120, epic #1116) as a
4//! behavior-preserving split. Owns the incremental K-way merge state machine
5//! (`maintenance_step`), candidate scanning, startup orphan sweeps, atomic
6//! input deletion, and the public `MaintenanceReport` type. `WriteEngine`'s
7//! fields are reachable here because this is a sibling module in the same crate.
8
9use super::merge;
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}