Skip to main content

chronix_engine/memtable/
flush.rs

1//! Memtable freeze-and-flush mechanism.
2//!
3//! This module implements the lifecycle of a memtable: freeze the active
4//! memtable (making it immutable), swap in a fresh one, flush the frozen
5//! memtable to a segment file via [`SegmentWriter`], and optionally truncate
6//! the WAL up to the flushed sequence number.
7//!
8//! # Flush triggers
9//!
10//! Flush is triggered when:
11//! - `estimated_size > memtable_flush_threshold`
12//! - A time-shard boundary is crossed
13//!
14//! # Memory bounds
15//!
16//! If the combined size of active + frozen memtables exceeds
17//! `max_memtable_memory`, new inserts are rejected with
18//! [`MemtableError::CapacityExceeded`].
19
20use std::collections::VecDeque;
21use std::path::PathBuf;
22use std::sync::Arc;
23
24use parking_lot::RwLock;
25
26use crate::segment::writer::{SegmentMeta, SegmentWriter, SegmentWriterConfig};
27use chronix_core::types::Point;
28
29use crate::memtable::error::{MemtableError, Result};
30use crate::memtable::memtable::Memtable;
31
32/// Configuration for the flush controller.
33#[derive(Debug, Clone)]
34pub struct FlushConfig {
35    /// Memory threshold (bytes) that triggers a flush.
36    pub flush_threshold: usize,
37    /// Maximum total memory across active + frozen memtables.
38    pub max_memory: usize,
39    /// Soft limit as a fraction of `max_memory` (0.0–1.0) at which
40    /// graduated write stall begins. Below this, writes proceed without delay.
41    /// Between soft limit and hard limit, writes are delayed proportionally.
42    /// Default: 0.75 (delays begin at 75% of max_memory).
43    pub stall_soft_fraction: f64,
44    /// Maximum write stall delay applied at the hard limit boundary.
45    /// Default: 100ms.
46    pub max_stall_delay: std::time::Duration,
47    /// Maximum number of frozen memtables queued for flush.
48    /// When the queue is full, `freeze_and_swap` rejects with `CapacityExceeded`.
49    /// Default: 2.
50    pub max_frozen_memtables: usize,
51    /// Directory to write segment files.
52    pub segment_dir: PathBuf,
53    /// Configuration for the segment writer.
54    pub segment_writer_config: SegmentWriterConfig,
55}
56
57impl Default for FlushConfig {
58    fn default() -> Self {
59        Self {
60            flush_threshold: 64 * 1024 * 1024, // 64 MB
61            max_memory: 256 * 1024 * 1024,     // 256 MB
62            stall_soft_fraction: 0.75,
63            max_stall_delay: std::time::Duration::from_millis(100),
64            max_frozen_memtables: 2,
65            segment_dir: PathBuf::from("/tmp/chronix/segments"),
66            segment_writer_config: SegmentWriterConfig::default(),
67        }
68    }
69}
70
71/// Manages the active and frozen memtable lifecycle.
72///
73/// The controller holds an active memtable that accepts writes, and
74/// optionally a frozen memtable that is being flushed. Writes go to the
75/// active memtable; when the flush threshold is reached, the active
76/// memtable is frozen and swapped for a fresh one.
77pub struct FlushController {
78    /// The currently active (writable) memtable.
79    active: Arc<RwLock<Arc<Memtable>>>,
80    /// Queue of frozen (immutable) memtables awaiting flush.
81    frozen: Arc<RwLock<VecDeque<Arc<Memtable>>>>,
82    /// Flush configuration.
83    config: FlushConfig,
84    /// Monotonically increasing segment counter for unique filenames.
85    segment_counter: std::sync::atomic::AtomicU64,
86    /// Serialises `flush_frozen_with`: the head of the frozen queue must not
87    /// change between the peek that starts a flush and the pop that ends it.
88    flush_lock: parking_lot::Mutex<()>,
89}
90
91impl std::fmt::Debug for FlushController {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        // Read values into locals BEFORE the debug_struct chain to avoid
94        // holding both `active` and `frozen` read locks simultaneously.
95        // freeze_and_swap acquires locks in reverse order (frozen → active),
96        // so holding both reads would create an ABBA deadlock risk with
97        // parking_lot's write-preferring RwLock.
98        let active_len = self.active.read().len();
99        let has_frozen = !self.frozen.read().is_empty();
100        let total_memory = self.total_memory();
101        let counter = self
102            .segment_counter
103            .load(std::sync::atomic::Ordering::Relaxed);
104        f.debug_struct("FlushController")
105            .field("config", &self.config)
106            .field("active_len", &active_len)
107            .field("has_frozen", &has_frozen)
108            .field("frozen_count", &self.frozen.read().len())
109            .field("total_memory", &total_memory)
110            .field("segment_counter", &counter)
111            .finish()
112    }
113}
114
115impl FlushController {
116    /// Create a new flush controller with the given configuration.
117    #[must_use]
118    pub fn new(config: FlushConfig) -> Self {
119        Self {
120            active: Arc::new(RwLock::new(Arc::new(Memtable::new()))),
121            frozen: Arc::new(RwLock::new(VecDeque::new())),
122            config,
123            segment_counter: std::sync::atomic::AtomicU64::new(0),
124            flush_lock: parking_lot::Mutex::new(()),
125        }
126    }
127
128    /// Get a reference to the active memtable.
129    #[must_use]
130    pub fn active_memtable(&self) -> Arc<Memtable> {
131        Arc::clone(&self.active.read())
132    }
133
134    /// Get a reference to the frozen memtable, if any.
135    #[must_use]
136    pub fn frozen_memtable(&self) -> Option<Arc<Memtable>> {
137        self.frozen.read().front().cloned()
138    }
139
140    /// Insert a point into the active memtable.
141    ///
142    /// # Errors
143    ///
144    /// Returns [`MemtableError::CapacityExceeded`] if the total memory
145    /// (active + frozen) exceeds the configured maximum.
146    pub fn insert(&self, point: &Point) -> Result<()> {
147        self.check_capacity()?;
148        self.active.read().insert(point)
149    }
150
151    /// Insert a point that is already durable in the WAL at `wal_seq`.
152    ///
153    /// **No capacity check.** Admission control runs *before* the WAL
154    /// append; what the WAL holds is inserted unconditionally, because a
155    /// record that is durable but in no memtable is a record the next
156    /// flush's WAL floor may pass — and then it is lost. The same applies
157    /// to replay at open: rejecting an acknowledged record for being over
158    /// a memory cap is silent data loss, not backpressure.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`MemtableError::Frozen`] if the active memtable was frozen
163    /// concurrently — which the freeze/insert lock order makes impossible
164    /// from this method.
165    pub fn insert_with_wal_seq(&self, point: &Point, wal_seq: u64) -> Result<()> {
166        self.active.read().insert_with_wal_seq(point, wal_seq)
167    }
168
169    /// How many memtables are frozen and waiting to be flushed.
170    #[must_use]
171    pub fn frozen_count(&self) -> usize {
172        self.frozen.read().len()
173    }
174
175    /// `true` when neither the active memtable nor any frozen one holds a
176    /// point.
177    #[must_use]
178    pub fn is_idle(&self) -> bool {
179        self.active.read().is_empty() && self.frozen.read().is_empty()
180    }
181
182    /// Check whether the flush threshold has been reached.
183    #[must_use]
184    pub fn should_flush(&self) -> bool {
185        self.active.read().estimated_size() >= self.config.flush_threshold
186    }
187
188    /// Freeze the active memtable and swap in a fresh one.
189    ///
190    /// The frozen memtable is stored for later flushing.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if there is already a frozen memtable that hasn't
195    /// been flushed yet.
196    pub fn freeze_and_swap(&self) -> Result<()> {
197        let mut frozen_guard = self.frozen.write();
198        // Nothing to freeze: an empty memtable in the queue would only take
199        // a slot from a real one (after a failed flush, every retry used to
200        // queue one until the queue was full and the retry itself failed).
201        if self.active.read().is_empty() {
202            return Ok(());
203        }
204        // Allow multiple frozen memtables up to the configured limit.
205        if frozen_guard.len() >= self.config.max_frozen_memtables {
206            let frozen_size: usize = frozen_guard.iter().map(|m| m.estimated_size()).sum();
207            return Err(MemtableError::CapacityExceeded {
208                current: frozen_size,
209                limit: self.config.max_memory,
210            });
211        }
212
213        let mut active_guard = self.active.write();
214
215        // Freeze the current active memtable
216        active_guard.freeze();
217        let old_active = Arc::clone(&active_guard);
218
219        // Replace with a fresh memtable
220        *active_guard = Arc::new(Memtable::new());
221
222        // Push to back of frozen queue.
223        frozen_guard.push_back(old_active);
224
225        Ok(())
226    }
227
228    /// Flush the oldest frozen memtable to segment files.
229    ///
230    /// Equivalent to [`flush_frozen_with`](Self::flush_frozen_with) with a
231    /// registration step that does nothing.
232    ///
233    /// # Errors
234    ///
235    /// See [`flush_frozen_with`](Self::flush_frozen_with).
236    pub fn flush_frozen(&self) -> Result<Vec<FlushResult>> {
237        self.flush_frozen_with(|_| Ok(()))
238    }
239
240    /// Flush the oldest frozen memtable to segment files and hand the
241    /// results to `register` before letting the memtable go.
242    ///
243    /// Groups points by measurement and writes one `.csx` segment per
244    /// measurement, so each segment holds exactly one measurement.
245    ///
246    /// **The frozen memtable is released only after every segment is
247    /// written and `register` has returned `Ok`.** If either fails, the
248    /// files written so far are removed and the memtable stays at the head
249    /// of the frozen queue for the next attempt — otherwise a failed write
250    /// drops the only in-memory copy while the next flush raises the WAL
251    /// floor past its records. Registration is inside the same window: a
252    /// segment on disk but not in the catalog is an orphan the next open
253    /// deletes.
254    ///
255    /// One flush per controller at a time; a concurrent caller waits.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`MemtableError::NoFrozenMemtable`] if nothing is frozen or
260    /// the frozen memtable is empty (it is discarded), the segment writer's
261    /// error, or `register`'s error.
262    pub fn flush_frozen_with<F>(&self, mut register: F) -> Result<Vec<FlushResult>>
263    where
264        F: FnMut(&[FlushResult]) -> Result<()>,
265    {
266        let _one_at_a_time = self.flush_lock.lock();
267
268        let frozen_mt = self
269            .frozen
270            .read()
271            .front()
272            .cloned()
273            .ok_or(MemtableError::NoFrozenMemtable)?;
274
275        let batches = frozen_mt.to_record_batches();
276        if batches.is_empty() {
277            // An empty memtable has nothing to flush and nothing to lose.
278            self.frozen.write().pop_front();
279            return Err(MemtableError::NoFrozenMemtable);
280        }
281
282        let max_wal_seq = frozen_mt.max_wal_seq();
283        let flush_started = std::time::Instant::now();
284
285        let mut results = Vec::with_capacity(batches.len());
286        let mut written_paths: Vec<std::path::PathBuf> = Vec::new();
287        let outcome = (|| -> Result<()> {
288            for mb in batches {
289                // Estimate compressed bytes per row from the column count and
290                // derive the number of rows that fit `target_segment_size_bytes`;
291                // a measurement wider than that is written as several segments.
292                let max_rows_per_segment = estimate_max_rows(
293                    mb.batch.num_columns(),
294                    self.config.segment_writer_config.target_segment_size_bytes,
295                    self.config.segment_writer_config.max_row_group_size,
296                );
297
298                let total = mb.batch.num_rows();
299                let mut offset = 0;
300                while offset < total {
301                    let len = max_rows_per_segment.min(total - offset);
302                    let slice = mb.batch.slice(offset, len);
303                    offset += len;
304
305                    let segment_path = self.next_segment_path();
306                    let mut writer = SegmentWriter::new(
307                        &segment_path,
308                        self.config.segment_writer_config.clone(),
309                    )?;
310                    let segment_meta =
311                        writer.finalize_batch(&slice, &mb.measurement, &mb.tag_columns)?;
312                    written_paths.push(segment_path);
313                    results.push(FlushResult {
314                        measurement: mb.measurement.clone(),
315                        series_keys: segment_meta.series_keys.clone(),
316                        segment_meta,
317                        max_wal_seq,
318                        points_flushed: len,
319                    });
320                }
321            }
322            register(&results)
323        })();
324
325        match outcome {
326            Ok(()) => {
327                let popped = self.frozen.write().pop_front();
328                debug_assert!(
329                    popped.is_some_and(|m| Arc::ptr_eq(&m, &frozen_mt)),
330                    "the flush lock keeps the head of the frozen queue stable"
331                );
332                // How often memtables reach disk, and how long that takes,
333                // is the first thing to look at when writes stall — and
334                // only the *emergency* flush was counted, which by
335                // definition is the case that has already gone wrong.
336                metrics::counter!("chronix_memtable_flushes_total", "outcome" => "ok").increment(1);
337                metrics::histogram!("chronix_memtable_flush_duration_seconds")
338                    .record(flush_started.elapsed().as_secs_f64());
339                Ok(results)
340            }
341            Err(e) => {
342                metrics::counter!("chronix_memtable_flushes_total", "outcome" => "error")
343                    .increment(1);
344                tracing::error!(
345                    error = %e,
346                    segments_written = written_paths.len(),
347                    "flush failed — the memtable stays frozen for the next attempt"
348                );
349                for path in written_paths {
350                    let _ = std::fs::remove_file(&path);
351                }
352                Err(e)
353            }
354        }
355    }
356
357    /// Scan both active and frozen memtables for a specific series.
358    ///
359    /// Results are merged and deduplicated by timestamp (active wins if
360    /// both contain the same timestamp, since active is newer).
361    ///
362    /// # Consistency
363    ///
364    /// Both memtable references are obtained under a single scope
365    /// following frozen→active lock order (matching `freeze_and_swap`).
366    /// This prevents a race where `freeze_and_swap()` moves data
367    /// between the two reads.
368    #[must_use]
369    pub fn scan(
370        &self,
371        series_key: &chronix_core::SeriesKey,
372        min_ts: i64,
373        max_ts: i64,
374    ) -> Vec<Point> {
375        let (active, frozen_list) = self.snapshot_memtables();
376
377        let mut points = active.scan(series_key, min_ts, max_ts);
378
379        // Merge from all frozen memtables (oldest first).
380        for frozen_mt in &frozen_list {
381            let frozen_points = frozen_mt.scan(series_key, min_ts, max_ts);
382            points = merge_points(points, frozen_points);
383        }
384
385        points
386    }
387
388    /// Scan all points across both active and frozen memtables.
389    #[must_use]
390    pub fn scan_all(&self) -> Vec<Point> {
391        let (active, frozen_list) = self.snapshot_memtables();
392
393        let mut all_points = active.scan_all();
394
395        for frozen_mt in &frozen_list {
396            let frozen_points = frozen_mt.scan_all();
397            all_points = merge_points(all_points, frozen_points);
398        }
399
400        all_points
401    }
402
403    /// Scan points for a specific measurement across active and frozen memtables.
404    ///
405    /// Results are merged and deduplicated (active wins for same timestamp).
406    #[must_use]
407    pub fn scan_measurement(&self, measurement: &str, min_ts: i64, max_ts: i64) -> Vec<Point> {
408        let (active, frozen_list) = self.snapshot_memtables();
409
410        let mut points = active.scan_measurement(measurement, min_ts, max_ts);
411
412        for frozen_mt in &frozen_list {
413            let frozen_points = frozen_mt.scan_measurement(measurement, min_ts, max_ts);
414            points = merge_points(points, frozen_points);
415        }
416
417        points
418    }
419
420    /// Returns the total estimated memory across active and all frozen memtables.
421    #[must_use]
422    pub fn total_memory(&self) -> usize {
423        let active_size = self.active.read().estimated_size();
424        let frozen_size: usize = self.frozen.read().iter().map(|m| m.estimated_size()).sum();
425        active_size + frozen_size
426    }
427
428    /// Heap bytes held by the interners of the active and frozen memtables.
429    #[must_use]
430    pub fn total_interner_memory(&self) -> usize {
431        let active = self.active.read().interner_bytes();
432        let frozen: usize = self.frozen.read().iter().map(|m| m.interner_bytes()).sum();
433        active + frozen
434    }
435
436    /// The smallest WAL sequence number held by a memtable that is not yet
437    /// in a segment — active **or** frozen.
438    ///
439    /// This is what the WAL floor is capped by. It used to consult the
440    /// active memtable only, so a frozen memtable still being written — or
441    /// left behind by a failed flush — was invisible, and a concurrent
442    /// flush of another shard raised the floor past its records.
443    #[must_use]
444    pub fn min_unflushed_wal_seq(&self) -> Option<u64> {
445        let frozen = self.frozen.read();
446        let active = self.active.read();
447        active
448            .min_wal_seq()
449            .into_iter()
450            .chain(frozen.iter().filter_map(|m| m.min_wal_seq()))
451            .min()
452    }
453
454    /// Check capacity and reject if over limit.
455    fn check_capacity(&self) -> Result<()> {
456        let total = self.total_memory();
457        if total >= self.config.max_memory {
458            return Err(MemtableError::CapacityExceeded {
459                current: total,
460                limit: self.config.max_memory,
461            });
462        }
463        Ok(())
464    }
465
466    /// Obtain a consistent snapshot of both active and frozen
467    /// memtables. Acquires locks in frozen→active order (matching
468    /// `freeze_and_swap`) to avoid ABBA deadlock.
469    fn snapshot_memtables(&self) -> (Arc<Memtable>, Vec<Arc<Memtable>>) {
470        let frozen_guard = self.frozen.read();
471        let active_guard = self.active.read();
472        (
473            Arc::clone(&active_guard),
474            frozen_guard.iter().cloned().collect(),
475        )
476    }
477
478    /// Compute graduated write stall delay based on memory pressure.
479    ///
480    /// Returns `Duration::ZERO` when memory usage is below the soft limit.
481    /// Between the soft limit and the hard limit, returns a linearly
482    /// increasing delay up to `max_stall_delay`. At or above the hard
483    /// limit, callers should use `check_capacity` which returns an error.
484    ///
485    /// This implements RocksDB-style graduated backpressure: as the memtable
486    /// fills up, writes slow down proportionally, giving the flush thread
487    /// time to drain without triggering hard rejections.
488    #[must_use]
489    pub fn write_stall_delay(&self) -> std::time::Duration {
490        let total = self.total_memory();
491        let soft_limit = (self.config.max_memory as f64 * self.config.stall_soft_fraction) as usize;
492
493        if total <= soft_limit {
494            return std::time::Duration::ZERO;
495        }
496
497        let hard = self.config.max_memory;
498        let range = hard.saturating_sub(soft_limit);
499        if range == 0 {
500            return self.config.max_stall_delay;
501        }
502
503        let pressure = total.saturating_sub(soft_limit) as f64 / range as f64;
504        let pressure = pressure.min(1.0);
505        self.config.max_stall_delay.mul_f64(pressure)
506    }
507
508    /// Generate the next unique segment file path.
509    fn next_segment_path(&self) -> PathBuf {
510        let counter = self
511            .segment_counter
512            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
513        let now_ns = std::time::SystemTime::now()
514            .duration_since(std::time::UNIX_EPOCH)
515            .map(|d| d.as_nanos())
516            .unwrap_or(0);
517        self.config
518            .segment_dir
519            .join(format!("seg_{now_ns}_{counter:06}.csx"))
520    }
521}
522
523/// Result of a flush operation for a single measurement.
524#[derive(Debug)]
525pub struct FlushResult {
526    /// Measurement name this segment contains.
527    pub measurement: String,
528    /// Metadata about the written segment file.
529    pub segment_meta: SegmentMeta,
530    /// Maximum WAL sequence number of the flushed memtable
531    /// (for WAL truncation).
532    pub max_wal_seq: Option<u64>,
533    /// Number of points flushed.
534    pub points_flushed: usize,
535    /// The distinct series in this segment, as the writer saw them.
536    pub series_keys: Vec<chronix_core::SeriesKey>,
537}
538
539/// Merge two sorted point vectors, deduplicating by
540/// `(series_canonical, timestamp)`. When both contain the same key, the
541/// point from `primary` (active memtable) wins.
542///
543/// Uses the canonical form (`measurement\0tag1=v1\0tag2=v2`) as part of
544/// the key instead of hash-only identity, preventing hash-collision data
545/// loss where two different series with the same FNV-1a hash and timestamp
546/// would otherwise silently overwrite each other.
547fn merge_points(primary: Vec<Point>, secondary: Vec<Point>) -> Vec<Point> {
548    use std::collections::BTreeMap as Map;
549
550    // Build a map keyed by (series_hash, timestamp, canonical_form).
551    // The hash is kept as the first component for fast BTree ordering;
552    // the canonical form is the collision-proof tiebreaker.
553    let mut map: Map<(u64, i64, String), Point> = Map::new();
554
555    for p in secondary {
556        let key = (
557            p.series_key().hash_fnv(),
558            p.timestamp(),
559            p.series_key().canonical_form().to_string(),
560        );
561        map.insert(key, p);
562    }
563    // Primary overwrites secondary
564    for p in primary {
565        let key = (
566            p.series_key().hash_fnv(),
567            p.timestamp(),
568            p.series_key().canonical_form().to_string(),
569        );
570        map.insert(key, p);
571    }
572
573    map.into_values().collect()
574}
575
576/// Estimate the maximum number of rows that fit within a target segment size.
577///
578/// Uses a conservative heuristic: count the number of columns (timestamp +
579/// tags + fields) and multiply by an estimated compressed bytes-per-column
580/// value (4 bytes — typical for delta/Gorilla-encoded + LZ4 time-series
581/// data). The result is clamped to at least `min_rows` to avoid producing
582/// trivially small segments.
583fn estimate_max_rows(
584    num_columns: usize,
585    target_segment_size_bytes: usize,
586    min_rows: usize,
587) -> usize {
588    // Conservative estimate: ~4 compressed bytes per column per row.
589    // Real-world compression typically achieves 2–6 bytes/column/row for
590    // numeric columns, 4–10 for string tags.
591    let est_bytes_per_row = (num_columns * 4).max(8);
592    let max_rows = target_segment_size_bytes / est_bytes_per_row;
593    // At minimum one row-group worth.
594    max_rows.max(min_rows)
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use std::collections::BTreeMap;
601    use std::path::Path;
602
603    use chronix_core::types::{FieldValue, SeriesKey};
604
605    fn make_point(host: &str, ts: i64, value: f64) -> Point {
606        make_point_with_measurement("cpu", host, ts, value)
607    }
608
609    fn make_point_with_measurement(measurement: &str, host: &str, ts: i64, value: f64) -> Point {
610        let tags: BTreeMap<String, String> = [("host".to_string(), host.to_string())]
611            .into_iter()
612            .collect();
613        let series_key = SeriesKey::new(measurement.to_string(), tags).unwrap();
614        let fields: BTreeMap<String, FieldValue> = [("value".to_string(), FieldValue::F64(value))]
615            .into_iter()
616            .collect();
617        Point::new(series_key, fields, ts).unwrap()
618    }
619
620    fn test_config(dir: &Path) -> FlushConfig {
621        FlushConfig {
622            flush_threshold: 1024, // low threshold for testing
623            max_memory: 1024 * 1024,
624            segment_dir: dir.to_path_buf(),
625            segment_writer_config: SegmentWriterConfig {
626                compress: false,
627                ..Default::default()
628            },
629            ..Default::default()
630        }
631    }
632
633    #[test]
634    fn insert_and_read_back() {
635        let dir = tempfile::tempdir().unwrap();
636        let fc = FlushController::new(test_config(dir.path()));
637
638        fc.insert(&make_point("a", 100, 1.0)).unwrap();
639        fc.insert(&make_point("a", 200, 2.0)).unwrap();
640
641        let all = fc.scan_all();
642        assert_eq!(all.len(), 2);
643    }
644
645    #[test]
646    fn freeze_and_swap() {
647        let dir = tempfile::tempdir().unwrap();
648        let fc = FlushController::new(test_config(dir.path()));
649
650        fc.insert(&make_point("a", 100, 1.0)).unwrap();
651        fc.insert(&make_point("a", 200, 2.0)).unwrap();
652
653        fc.freeze_and_swap().unwrap();
654
655        // Active should be empty, frozen should have data
656        assert!(fc.active_memtable().is_empty());
657        assert!(fc.frozen_memtable().is_some());
658        assert_eq!(fc.frozen_memtable().unwrap().len(), 2);
659
660        // Can still insert into the new active
661        fc.insert(&make_point("a", 300, 3.0)).unwrap();
662        assert_eq!(fc.active_memtable().len(), 1);
663    }
664
665    #[test]
666    fn scan_merges_active_and_frozen() {
667        let dir = tempfile::tempdir().unwrap();
668        let fc = FlushController::new(test_config(dir.path()));
669
670        fc.insert(&make_point("a", 100, 1.0)).unwrap();
671        fc.insert(&make_point("a", 200, 2.0)).unwrap();
672        fc.freeze_and_swap().unwrap();
673        fc.insert(&make_point("a", 300, 3.0)).unwrap();
674
675        let all = fc.scan_all();
676        assert_eq!(all.len(), 3);
677    }
678
679    #[test]
680    fn active_wins_on_duplicate() {
681        let dir = tempfile::tempdir().unwrap();
682        let fc = FlushController::new(test_config(dir.path()));
683
684        fc.insert(&make_point("a", 100, 1.0)).unwrap();
685        fc.freeze_and_swap().unwrap();
686        fc.insert(&make_point("a", 100, 99.0)).unwrap();
687
688        let all = fc.scan_all();
689        assert_eq!(all.len(), 1);
690        assert_eq!(all[0].field("value"), Some(&FieldValue::F64(99.0)));
691    }
692
693    #[test]
694    fn flush_frozen_writes_segment() {
695        let dir = tempfile::tempdir().unwrap();
696        let fc = FlushController::new(test_config(dir.path()));
697
698        for i in 0..10 {
699            fc.insert(&make_point("a", i * 100, i as f64)).unwrap();
700        }
701
702        fc.freeze_and_swap().unwrap();
703        let results = fc.flush_frozen().unwrap();
704
705        assert_eq!(results.len(), 1);
706        let result = &results[0];
707        assert_eq!(result.measurement, "cpu");
708        assert_eq!(result.points_flushed, 10);
709        assert!(result.segment_meta.path.exists());
710        assert_eq!(result.segment_meta.row_count, 10);
711
712        // Frozen slot should be cleared
713        assert!(fc.frozen_memtable().is_none());
714    }
715
716    #[test]
717    fn should_flush_threshold() {
718        let dir = tempfile::tempdir().unwrap();
719        let mut config = test_config(dir.path());
720        config.flush_threshold = 100; // very low
721        let fc = FlushController::new(config);
722
723        assert!(!fc.should_flush());
724
725        // Insert enough data to exceed threshold
726        for i in 0..20 {
727            let _ = fc.insert(&make_point("a", i, i as f64));
728        }
729        assert!(fc.should_flush());
730    }
731
732    #[test]
733    fn multi_freeze_respects_limit() {
734        let dir = tempfile::tempdir().unwrap();
735        let fc = FlushController::new(test_config(dir.path()));
736
737        // With max_frozen_memtables=2, two freezes should succeed.
738        fc.insert(&make_point("a", 100, 1.0)).unwrap();
739        fc.freeze_and_swap().unwrap();
740
741        fc.insert(&make_point("a", 200, 2.0)).unwrap();
742        fc.freeze_and_swap()
743            .expect("second freeze should succeed with multi-slot frozen queue");
744
745        // Third freeze should fail: frozen queue is at capacity (2).
746        fc.insert(&make_point("a", 300, 3.0)).unwrap();
747        let result = fc.freeze_and_swap();
748        assert!(result.is_err(), "should reject third freeze at capacity");
749    }
750
751    #[test]
752    fn capacity_rejection() {
753        let dir = tempfile::tempdir().unwrap();
754        let mut config = test_config(dir.path());
755        config.max_memory = 1; // impossibly low
756        let fc = FlushController::new(config);
757
758        // First insert might work (no data yet), but subsequent should fail
759        // due to capacity check. The first insert sets estimated_size > 0.
760        let _ = fc.insert(&make_point("a", 100, 1.0));
761        let result = fc.insert(&make_point("a", 200, 2.0));
762        assert!(result.is_err());
763    }
764
765    #[test]
766    fn wal_seq_in_flush_result() {
767        let dir = tempfile::tempdir().unwrap();
768        let fc = FlushController::new(test_config(dir.path()));
769
770        fc.insert_with_wal_seq(&make_point("a", 100, 1.0), 5)
771            .unwrap();
772        fc.insert_with_wal_seq(&make_point("a", 200, 2.0), 10)
773            .unwrap();
774
775        fc.freeze_and_swap().unwrap();
776        let results = fc.flush_frozen().unwrap();
777
778        assert_eq!(results.len(), 1);
779        assert_eq!(results[0].max_wal_seq, Some(10));
780    }
781
782    #[test]
783    fn merge_points_dedup() {
784        let primary = vec![make_point("a", 100, 10.0), make_point("a", 300, 30.0)];
785        let secondary = vec![
786            make_point("a", 100, 1.0), // dup — primary wins
787            make_point("a", 200, 2.0),
788        ];
789
790        let merged = merge_points(primary, secondary);
791        assert_eq!(merged.len(), 3);
792
793        // The ts=100 entry should have primary's value (10.0)
794        let ts100 = merged.iter().find(|p| p.timestamp() == 100).unwrap();
795        assert_eq!(ts100.field("value"), Some(&FieldValue::F64(10.0)));
796    }
797
798    #[test]
799    fn scan_measurement_filters_active() {
800        let dir = tempfile::tempdir().unwrap();
801        let fc = FlushController::new(test_config(dir.path()));
802
803        fc.insert(&make_point_with_measurement("cpu", "a", 100, 1.0))
804            .unwrap();
805        fc.insert(&make_point_with_measurement("mem", "a", 200, 2.0))
806            .unwrap();
807        fc.insert(&make_point_with_measurement("cpu", "b", 300, 3.0))
808            .unwrap();
809
810        let cpu = fc.scan_measurement("cpu", 0, i64::MAX);
811        assert_eq!(cpu.len(), 2);
812        assert!(cpu.iter().all(|p| p.series_key().measurement() == "cpu"));
813
814        let mem = fc.scan_measurement("mem", 0, i64::MAX);
815        assert_eq!(mem.len(), 1);
816        assert_eq!(mem[0].series_key().measurement(), "mem");
817
818        let none = fc.scan_measurement("disk", 0, i64::MAX);
819        assert!(none.is_empty());
820    }
821
822    #[test]
823    fn scan_measurement_merges_active_and_frozen() {
824        let dir = tempfile::tempdir().unwrap();
825        let fc = FlushController::new(test_config(dir.path()));
826
827        // Insert into active, then freeze
828        fc.insert(&make_point_with_measurement("cpu", "a", 100, 1.0))
829            .unwrap();
830        fc.insert(&make_point_with_measurement("mem", "a", 150, 9.0))
831            .unwrap();
832        fc.freeze_and_swap().unwrap();
833
834        // Insert more into new active
835        fc.insert(&make_point_with_measurement("cpu", "a", 200, 2.0))
836            .unwrap();
837        fc.insert(&make_point_with_measurement("mem", "b", 250, 8.0))
838            .unwrap();
839
840        let cpu = fc.scan_measurement("cpu", 0, i64::MAX);
841        assert_eq!(cpu.len(), 2);
842        assert_eq!(cpu[0].timestamp(), 100);
843        assert_eq!(cpu[1].timestamp(), 200);
844
845        let mem = fc.scan_measurement("mem", 0, i64::MAX);
846        assert_eq!(mem.len(), 2);
847    }
848
849    #[test]
850    fn min_unflushed_wal_seq_empty() {
851        let fc = FlushController::new(FlushConfig::default());
852        assert!(fc.min_unflushed_wal_seq().is_none());
853    }
854
855    #[test]
856    fn min_unflushed_wal_seq_after_insert() {
857        let fc = FlushController::new(FlushConfig::default());
858        let p = make_point("host", 100, 1.0);
859        fc.insert_with_wal_seq(&p, 42).unwrap();
860        fc.insert_with_wal_seq(&p, 50).unwrap();
861        assert_eq!(fc.min_unflushed_wal_seq(), Some(42));
862    }
863
864    /// A frozen memtable is unflushed until its segment is registered, so
865    /// its records must keep capping the WAL floor. This used to report
866    /// `None` after a freeze, and a concurrent flush of another shard then
867    /// truncated the WAL files holding the frozen records.
868    #[test]
869    fn a_frozen_memtable_still_counts_as_unflushed() {
870        let fc = FlushController::new(FlushConfig::default());
871        let p = make_point("host", 100, 1.0);
872        fc.insert_with_wal_seq(&p, 10).unwrap();
873        fc.freeze_and_swap().unwrap();
874        assert_eq!(
875            fc.min_unflushed_wal_seq(),
876            Some(10),
877            "the frozen memtable holds seq 10 and nothing flushed it"
878        );
879        fc.insert_with_wal_seq(&make_point("host", 200, 2.0), 20)
880            .unwrap();
881        assert_eq!(fc.min_unflushed_wal_seq(), Some(10));
882    }
883
884    /// A flush that fails leaves the memtable frozen for a retry and removes
885    /// the files it managed to write; it used to pop the memtable first and
886    /// drop the only copy of the data on any error.
887    #[test]
888    fn a_failed_flush_keeps_the_frozen_memtable() {
889        let dir = tempfile::tempdir().unwrap();
890        let mut config = test_config(dir.path());
891        // The segment directory is a regular file: every write fails.
892        let blocked = dir.path().join("blocked");
893        std::fs::write(&blocked, b"not a directory").unwrap();
894        config.segment_dir = blocked.clone();
895        let fc = FlushController::new(config);
896
897        fc.insert_with_wal_seq(&make_point("a", 100, 1.0), 7)
898            .unwrap();
899        fc.freeze_and_swap().unwrap();
900        assert!(fc.flush_frozen().is_err());
901        assert!(
902            fc.frozen_memtable().is_some(),
903            "the memtable must survive a failed flush"
904        );
905        assert_eq!(fc.min_unflushed_wal_seq(), Some(7));
906
907        // A registration failure is the same: nothing is lost, and the
908        // segment written for the attempt is removed.
909        std::fs::remove_file(&blocked).unwrap();
910        std::fs::create_dir_all(&blocked).unwrap();
911        let err = fc.flush_frozen_with(|results| {
912            assert_eq!(results.len(), 1);
913            assert!(results[0].segment_meta.path.exists());
914            Err(MemtableError::NoFrozenMemtable)
915        });
916        assert!(err.is_err());
917        assert!(fc.frozen_memtable().is_some());
918        assert_eq!(
919            std::fs::read_dir(&blocked).unwrap().count(),
920            0,
921            "a segment nothing registered is removed, not left as an orphan"
922        );
923
924        // And the retry succeeds and releases it.
925        let results = fc.flush_frozen().unwrap();
926        assert_eq!(results[0].points_flushed, 1);
927        assert!(fc.frozen_memtable().is_none());
928        assert!(fc.min_unflushed_wal_seq().is_none());
929    }
930
931    #[test]
932    fn write_stall_zero_below_soft_limit() {
933        let dir = tempfile::tempdir().unwrap();
934        let config = FlushConfig {
935            max_memory: 1024 * 1024,
936            stall_soft_fraction: 0.75,
937            max_stall_delay: std::time::Duration::from_millis(100),
938            segment_dir: dir.path().to_path_buf(),
939            ..Default::default()
940        };
941        let fc = FlushController::new(config);
942
943        // Empty memtable — no stall
944        assert_eq!(fc.write_stall_delay(), std::time::Duration::ZERO);
945    }
946
947    #[test]
948    fn write_stall_increases_with_pressure() {
949        let dir = tempfile::tempdir().unwrap();
950        let config = FlushConfig {
951            max_memory: 10_000,       // 10 KB
952            stall_soft_fraction: 0.5, // stall starts at 5 KB
953            max_stall_delay: std::time::Duration::from_millis(100),
954            flush_threshold: 10_000, // won't auto-flush
955            segment_dir: dir.path().to_path_buf(),
956            ..Default::default()
957        };
958        let fc = FlushController::new(config);
959
960        // Insert data to push past soft limit
961        for i in 0..100 {
962            let _ = fc.insert(&make_point("a", i, i as f64));
963        }
964
965        let delay = fc.write_stall_delay();
966        // Should have non-zero delay since we're over the soft limit
967        let mem = fc.total_memory();
968        if mem > 5000 {
969            assert!(
970                delay > std::time::Duration::ZERO,
971                "should stall when over soft limit (mem={mem})"
972            );
973            assert!(
974                delay <= std::time::Duration::from_millis(100),
975                "should not exceed max stall delay"
976            );
977        }
978    }
979}