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    /// Deduplicated by timestamp, **newest write wins** — the active memtable
360    /// first, then the frozen queue newest to oldest.
361    ///
362    /// The result is a *set*, in no particular order.
363    /// [`ShardRouter`](crate::memtable::ShardRouter) sorts by timestamp across
364    /// shards, and every caller reaches this through it.
365    ///
366    /// # Consistency
367    ///
368    /// Both memtable references are obtained under a single scope
369    /// following frozen→active lock order (matching `freeze_and_swap`).
370    /// This prevents a race where `freeze_and_swap()` moves data
371    /// between the two reads.
372    #[must_use]
373    pub fn scan(
374        &self,
375        series_key: &chronix_core::SeriesKey,
376        min_ts: i64,
377        max_ts: i64,
378    ) -> Vec<Point> {
379        let (active, frozen_list) = self.snapshot_memtables();
380
381        // Newest first: the active memtable, then the frozen queue from its
382        // back — `freeze_and_swap` pushes to the back, so the front is oldest.
383        let mut sources = Vec::with_capacity(frozen_list.len() + 1);
384        sources.push(active.scan(series_key, min_ts, max_ts));
385        for frozen_mt in frozen_list.iter().rev() {
386            sources.push(frozen_mt.scan(series_key, min_ts, max_ts));
387        }
388        merge_newest_first(sources)
389    }
390
391    /// Scan all points across both active and frozen memtables.
392    ///
393    /// Deduplicated by `(series, timestamp)`, newest write wins. Unordered —
394    /// see [`scan_measurement`](Self::scan_measurement).
395    #[must_use]
396    pub fn scan_all(&self) -> Vec<Point> {
397        let (active, frozen_list) = self.snapshot_memtables();
398
399        let mut sources = Vec::with_capacity(frozen_list.len() + 1);
400        sources.push(active.scan_all());
401        for frozen_mt in frozen_list.iter().rev() {
402            sources.push(frozen_mt.scan_all());
403        }
404        merge_newest_first(sources)
405    }
406
407    /// Scan points for a specific measurement across active and frozen
408    /// memtables.
409    ///
410    /// Deduplicated by `(series, timestamp)`, **newest write wins** — the
411    /// active memtable first, then the frozen queue newest to oldest.
412    ///
413    /// The result is a *set*, in no particular order.
414    /// [`ShardRouter`](crate::memtable::ShardRouter) sorts by timestamp across
415    /// shards, and every caller reaches this through it.
416    #[must_use]
417    pub fn scan_measurement(&self, measurement: &str, min_ts: i64, max_ts: i64) -> Vec<Point> {
418        let (active, frozen_list) = self.snapshot_memtables();
419
420        let mut sources = Vec::with_capacity(frozen_list.len() + 1);
421        sources.push(active.scan_measurement(measurement, min_ts, max_ts));
422        for frozen_mt in frozen_list.iter().rev() {
423            sources.push(frozen_mt.scan_measurement(measurement, min_ts, max_ts));
424        }
425        merge_newest_first(sources)
426    }
427
428    /// Returns the total estimated memory across active and all frozen memtables.
429    #[must_use]
430    pub fn total_memory(&self) -> usize {
431        let active_size = self.active.read().estimated_size();
432        let frozen_size: usize = self.frozen.read().iter().map(|m| m.estimated_size()).sum();
433        active_size + frozen_size
434    }
435
436    /// Heap bytes held by the interners of the active and frozen memtables.
437    #[must_use]
438    pub fn total_interner_memory(&self) -> usize {
439        let active = self.active.read().interner_bytes();
440        let frozen: usize = self.frozen.read().iter().map(|m| m.interner_bytes()).sum();
441        active + frozen
442    }
443
444    /// The smallest WAL sequence number held by a memtable that is not yet
445    /// in a segment — active **or** frozen.
446    ///
447    /// This is what the WAL floor is capped by. It used to consult the
448    /// active memtable only, so a frozen memtable still being written — or
449    /// left behind by a failed flush — was invisible, and a concurrent
450    /// flush of another shard raised the floor past its records.
451    #[must_use]
452    pub fn min_unflushed_wal_seq(&self) -> Option<u64> {
453        let frozen = self.frozen.read();
454        let active = self.active.read();
455        active
456            .min_wal_seq()
457            .into_iter()
458            .chain(frozen.iter().filter_map(|m| m.min_wal_seq()))
459            .min()
460    }
461
462    /// Check capacity and reject if over limit.
463    fn check_capacity(&self) -> Result<()> {
464        let total = self.total_memory();
465        if total >= self.config.max_memory {
466            return Err(MemtableError::CapacityExceeded {
467                current: total,
468                limit: self.config.max_memory,
469            });
470        }
471        Ok(())
472    }
473
474    /// Obtain a consistent snapshot of both active and frozen
475    /// memtables. Acquires locks in frozen→active order (matching
476    /// `freeze_and_swap`) to avoid ABBA deadlock.
477    fn snapshot_memtables(&self) -> (Arc<Memtable>, Vec<Arc<Memtable>>) {
478        let frozen_guard = self.frozen.read();
479        let active_guard = self.active.read();
480        (
481            Arc::clone(&active_guard),
482            frozen_guard.iter().cloned().collect(),
483        )
484    }
485
486    /// Compute graduated write stall delay based on memory pressure.
487    ///
488    /// Returns `Duration::ZERO` when memory usage is below the soft limit.
489    /// Between the soft limit and the hard limit, returns a linearly
490    /// increasing delay up to `max_stall_delay`. At or above the hard
491    /// limit, callers should use `check_capacity` which returns an error.
492    ///
493    /// This implements RocksDB-style graduated backpressure: as the memtable
494    /// fills up, writes slow down proportionally, giving the flush thread
495    /// time to drain without triggering hard rejections.
496    #[must_use]
497    pub fn write_stall_delay(&self) -> std::time::Duration {
498        let total = self.total_memory();
499        let soft_limit = (self.config.max_memory as f64 * self.config.stall_soft_fraction) as usize;
500
501        if total <= soft_limit {
502            return std::time::Duration::ZERO;
503        }
504
505        let hard = self.config.max_memory;
506        let range = hard.saturating_sub(soft_limit);
507        if range == 0 {
508            return self.config.max_stall_delay;
509        }
510
511        let pressure = total.saturating_sub(soft_limit) as f64 / range as f64;
512        let pressure = pressure.min(1.0);
513        self.config.max_stall_delay.mul_f64(pressure)
514    }
515
516    /// Generate the next unique segment file path.
517    fn next_segment_path(&self) -> PathBuf {
518        let counter = self
519            .segment_counter
520            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
521        let now_ns = std::time::SystemTime::now()
522            .duration_since(std::time::UNIX_EPOCH)
523            .map(|d| d.as_nanos())
524            .unwrap_or(0);
525        self.config
526            .segment_dir
527            .join(format!("seg_{now_ns}_{counter:06}.csx"))
528    }
529}
530
531/// Result of a flush operation for a single measurement.
532#[derive(Debug)]
533pub struct FlushResult {
534    /// Measurement name this segment contains.
535    pub measurement: String,
536    /// Metadata about the written segment file.
537    pub segment_meta: SegmentMeta,
538    /// Maximum WAL sequence number of the flushed memtable
539    /// (for WAL truncation).
540    pub max_wal_seq: Option<u64>,
541    /// Number of points flushed.
542    pub points_flushed: usize,
543    /// The distinct series in this segment, as the writer saw them.
544    pub series_keys: Vec<chronix_core::SeriesKey>,
545}
546
547/// Merge memtable scans into one deduplicated result, **newest source first**.
548///
549/// `sources` must be ordered newest to oldest — the active memtable, then the
550/// frozen queue from its back (newest frozen) to its front (oldest). The first
551/// copy of a `(series, timestamp)` encountered wins, so the newest write does.
552///
553/// # One pass, not a fold
554///
555/// Folding a two-argument merge over the frozen queue needs the precedence
556/// spelled out at each step, and a fold that took the accumulated result as
557/// the winner let the *oldest* frozen memtable beat the newest — invisible,
558/// because the active memtable was still right. One pass has a single
559/// ordering rule in a single place. It is also linear rather than quadratic,
560/// and keys on the borrowed canonical form, so no `String` is allocated.
561///
562/// The output is in source order, not key order: callers reach this through
563/// `ShardRouter`, which sorts by timestamp across shards.
564fn merge_newest_first(sources: Vec<Vec<Point>>) -> Vec<Point> {
565    let total: usize = sources.iter().map(Vec::len).sum();
566    let mut all = Vec::with_capacity(total);
567    for src in sources {
568        all.extend(src);
569    }
570
571    // The key is the canonical form itself, **borrowed**, not a hash of it:
572    // exact, and with no per-point `String`. The old form allocated one
573    // `String` per point *per merge round* and used a `BTreeMap`, so it also
574    // compared those strings O(log n) times each.
575    let mut seen = std::collections::HashSet::with_capacity(total);
576    let mut keep = Vec::with_capacity(total);
577    for p in &all {
578        keep.push(seen.insert((p.series_key().canonical_form(), p.timestamp())));
579    }
580    drop(seen);
581
582    let mut keep = keep.into_iter();
583    all.retain(|_| keep.next().unwrap_or(false));
584    all
585}
586
587/// Estimate the maximum number of rows that fit within a target segment size.
588///
589/// Uses a conservative heuristic: count the number of columns (timestamp +
590/// tags + fields) and multiply by an estimated compressed bytes-per-column
591/// value (4 bytes — typical for delta/Gorilla-encoded + LZ4 time-series
592/// data). The result is clamped to at least `min_rows` to avoid producing
593/// trivially small segments.
594fn estimate_max_rows(
595    num_columns: usize,
596    target_segment_size_bytes: usize,
597    min_rows: usize,
598) -> usize {
599    // Conservative estimate: ~4 compressed bytes per column per row.
600    // Real-world compression typically achieves 2–6 bytes/column/row for
601    // numeric columns, 4–10 for string tags.
602    let est_bytes_per_row = (num_columns * 4).max(8);
603    let max_rows = target_segment_size_bytes / est_bytes_per_row;
604    // At minimum one row-group worth.
605    max_rows.max(min_rows)
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use std::collections::BTreeMap;
612    use std::path::Path;
613
614    use chronix_core::types::{FieldValue, SeriesKey};
615
616    fn make_point(host: &str, ts: i64, value: f64) -> Point {
617        make_point_with_measurement("cpu", host, ts, value)
618    }
619
620    fn make_point_with_measurement(measurement: &str, host: &str, ts: i64, value: f64) -> Point {
621        let tags: BTreeMap<String, String> = [("host".to_string(), host.to_string())]
622            .into_iter()
623            .collect();
624        let series_key = SeriesKey::new(measurement.to_string(), tags).unwrap();
625        let fields: BTreeMap<String, FieldValue> = [("value".to_string(), FieldValue::F64(value))]
626            .into_iter()
627            .collect();
628        Point::new(series_key, fields, ts).unwrap()
629    }
630
631    fn test_config(dir: &Path) -> FlushConfig {
632        FlushConfig {
633            flush_threshold: 1024, // low threshold for testing
634            max_memory: 1024 * 1024,
635            segment_dir: dir.to_path_buf(),
636            segment_writer_config: SegmentWriterConfig {
637                compress: false,
638                ..Default::default()
639            },
640            ..Default::default()
641        }
642    }
643
644    #[test]
645    fn insert_and_read_back() {
646        let dir = tempfile::tempdir().unwrap();
647        let fc = FlushController::new(test_config(dir.path()));
648
649        fc.insert(&make_point("a", 100, 1.0)).unwrap();
650        fc.insert(&make_point("a", 200, 2.0)).unwrap();
651
652        let all = fc.scan_all();
653        assert_eq!(all.len(), 2);
654    }
655
656    #[test]
657    fn freeze_and_swap() {
658        let dir = tempfile::tempdir().unwrap();
659        let fc = FlushController::new(test_config(dir.path()));
660
661        fc.insert(&make_point("a", 100, 1.0)).unwrap();
662        fc.insert(&make_point("a", 200, 2.0)).unwrap();
663
664        fc.freeze_and_swap().unwrap();
665
666        // Active should be empty, frozen should have data
667        assert!(fc.active_memtable().is_empty());
668        assert!(fc.frozen_memtable().is_some());
669        assert_eq!(fc.frozen_memtable().unwrap().len(), 2);
670
671        // Can still insert into the new active
672        fc.insert(&make_point("a", 300, 3.0)).unwrap();
673        assert_eq!(fc.active_memtable().len(), 1);
674    }
675
676    #[test]
677    fn scan_merges_active_and_frozen() {
678        let dir = tempfile::tempdir().unwrap();
679        let fc = FlushController::new(test_config(dir.path()));
680
681        fc.insert(&make_point("a", 100, 1.0)).unwrap();
682        fc.insert(&make_point("a", 200, 2.0)).unwrap();
683        fc.freeze_and_swap().unwrap();
684        fc.insert(&make_point("a", 300, 3.0)).unwrap();
685
686        let all = fc.scan_all();
687        assert_eq!(all.len(), 3);
688    }
689
690    #[test]
691    fn active_wins_on_duplicate() {
692        let dir = tempfile::tempdir().unwrap();
693        let fc = FlushController::new(test_config(dir.path()));
694
695        fc.insert(&make_point("a", 100, 1.0)).unwrap();
696        fc.freeze_and_swap().unwrap();
697        fc.insert(&make_point("a", 100, 99.0)).unwrap();
698
699        let all = fc.scan_all();
700        assert_eq!(all.len(), 1);
701        assert_eq!(all[0].field("value"), Some(&FieldValue::F64(99.0)));
702    }
703
704    #[test]
705    fn flush_frozen_writes_segment() {
706        let dir = tempfile::tempdir().unwrap();
707        let fc = FlushController::new(test_config(dir.path()));
708
709        for i in 0..10 {
710            fc.insert(&make_point("a", i * 100, i as f64)).unwrap();
711        }
712
713        fc.freeze_and_swap().unwrap();
714        let results = fc.flush_frozen().unwrap();
715
716        assert_eq!(results.len(), 1);
717        let result = &results[0];
718        assert_eq!(result.measurement, "cpu");
719        assert_eq!(result.points_flushed, 10);
720        assert!(result.segment_meta.path.exists());
721        assert_eq!(result.segment_meta.row_count, 10);
722
723        // Frozen slot should be cleared
724        assert!(fc.frozen_memtable().is_none());
725    }
726
727    #[test]
728    fn should_flush_threshold() {
729        let dir = tempfile::tempdir().unwrap();
730        let mut config = test_config(dir.path());
731        config.flush_threshold = 100; // very low
732        let fc = FlushController::new(config);
733
734        assert!(!fc.should_flush());
735
736        // Insert enough data to exceed threshold
737        for i in 0..20 {
738            let _ = fc.insert(&make_point("a", i, i as f64));
739        }
740        assert!(fc.should_flush());
741    }
742
743    #[test]
744    fn multi_freeze_respects_limit() {
745        let dir = tempfile::tempdir().unwrap();
746        let fc = FlushController::new(test_config(dir.path()));
747
748        // With max_frozen_memtables=2, two freezes should succeed.
749        fc.insert(&make_point("a", 100, 1.0)).unwrap();
750        fc.freeze_and_swap().unwrap();
751
752        fc.insert(&make_point("a", 200, 2.0)).unwrap();
753        fc.freeze_and_swap()
754            .expect("second freeze should succeed with multi-slot frozen queue");
755
756        // Third freeze should fail: frozen queue is at capacity (2).
757        fc.insert(&make_point("a", 300, 3.0)).unwrap();
758        let result = fc.freeze_and_swap();
759        assert!(result.is_err(), "should reject third freeze at capacity");
760    }
761
762    #[test]
763    fn capacity_rejection() {
764        let dir = tempfile::tempdir().unwrap();
765        let mut config = test_config(dir.path());
766        config.max_memory = 1; // impossibly low
767        let fc = FlushController::new(config);
768
769        // First insert might work (no data yet), but subsequent should fail
770        // due to capacity check. The first insert sets estimated_size > 0.
771        let _ = fc.insert(&make_point("a", 100, 1.0));
772        let result = fc.insert(&make_point("a", 200, 2.0));
773        assert!(result.is_err());
774    }
775
776    #[test]
777    fn wal_seq_in_flush_result() {
778        let dir = tempfile::tempdir().unwrap();
779        let fc = FlushController::new(test_config(dir.path()));
780
781        fc.insert_with_wal_seq(&make_point("a", 100, 1.0), 5)
782            .unwrap();
783        fc.insert_with_wal_seq(&make_point("a", 200, 2.0), 10)
784            .unwrap();
785
786        fc.freeze_and_swap().unwrap();
787        let results = fc.flush_frozen().unwrap();
788
789        assert_eq!(results.len(), 1);
790        assert_eq!(results[0].max_wal_seq, Some(10));
791    }
792
793    #[test]
794    fn merging_keeps_the_first_source_that_has_a_key() {
795        // Sources are newest first, so the earlier source wins a duplicate —
796        // and a *third* source cannot beat the second, which is where the
797        // fold this replaced went wrong.
798        let merged = merge_newest_first(vec![
799            vec![make_point("a", 100, 10.0), make_point("a", 300, 30.0)],
800            vec![make_point("a", 100, 1.0), make_point("a", 200, 2.0)],
801            vec![make_point("a", 200, 99.0)],
802        ]);
803        assert_eq!(merged.len(), 3);
804
805        let ts100 = merged.iter().find(|p| p.timestamp() == 100).unwrap();
806        assert_eq!(ts100.field("value"), Some(&FieldValue::F64(10.0)));
807        let ts200 = merged.iter().find(|p| p.timestamp() == 200).unwrap();
808        assert_eq!(
809            ts200.field("value"),
810            Some(&FieldValue::F64(2.0)),
811            "the second source wins over the third"
812        );
813    }
814
815    /// The **newest** value of a `(series, timestamp)` wins, including when
816    /// both copies are in frozen memtables.
817    ///
818    /// The three read paths merge the frozen queue oldest-first with the
819    /// accumulated result as the *winner*, so the first frozen memtable beat
820    /// every one after it — older data beating newer, which is the one thing
821    /// last-write-wins may not do. `active` was always right, which is what
822    /// the doc comment said and what every test checked.
823    #[test]
824    fn the_newest_frozen_memtable_wins_a_duplicate() {
825        let dir = tempfile::tempdir().unwrap();
826        let fc = FlushController::new(test_config(dir.path()));
827
828        // Write, freeze, rewrite the same (series, timestamp), freeze again:
829        // now two *frozen* memtables hold the same point.
830        fc.insert(&make_point("a", 100, 1.0)).unwrap();
831        fc.freeze_and_swap().unwrap();
832        fc.insert(&make_point("a", 100, 2.0)).unwrap();
833        fc.freeze_and_swap().unwrap();
834
835        let key = make_point("a", 100, 0.0).series_key().clone();
836        for (path, points) in [
837            ("scan", fc.scan(&key, 0, i64::MAX)),
838            ("scan_all", fc.scan_all()),
839            (
840                "scan_measurement",
841                fc.scan_measurement(key.measurement(), 0, i64::MAX),
842            ),
843        ] {
844            assert_eq!(points.len(), 1, "{path}: one point survives dedup");
845            assert_eq!(
846                points[0].field("value"),
847                Some(&FieldValue::F64(2.0)),
848                "{path}: the newer write wins"
849            );
850        }
851    }
852
853    /// …and the active memtable still beats every frozen one.
854    #[test]
855    fn the_active_memtable_wins_a_duplicate() {
856        let dir = tempfile::tempdir().unwrap();
857        let fc = FlushController::new(test_config(dir.path()));
858        fc.insert(&make_point("a", 100, 1.0)).unwrap();
859        fc.freeze_and_swap().unwrap();
860        fc.insert(&make_point("a", 100, 9.0)).unwrap();
861
862        let points = fc.scan_all();
863        assert_eq!(points.len(), 1);
864        assert_eq!(points[0].field("value"), Some(&FieldValue::F64(9.0)));
865    }
866
867    #[test]
868    fn scan_measurement_filters_active() {
869        let dir = tempfile::tempdir().unwrap();
870        let fc = FlushController::new(test_config(dir.path()));
871
872        fc.insert(&make_point_with_measurement("cpu", "a", 100, 1.0))
873            .unwrap();
874        fc.insert(&make_point_with_measurement("mem", "a", 200, 2.0))
875            .unwrap();
876        fc.insert(&make_point_with_measurement("cpu", "b", 300, 3.0))
877            .unwrap();
878
879        let cpu = fc.scan_measurement("cpu", 0, i64::MAX);
880        assert_eq!(cpu.len(), 2);
881        assert!(cpu.iter().all(|p| p.series_key().measurement() == "cpu"));
882
883        let mem = fc.scan_measurement("mem", 0, i64::MAX);
884        assert_eq!(mem.len(), 1);
885        assert_eq!(mem[0].series_key().measurement(), "mem");
886
887        let none = fc.scan_measurement("disk", 0, i64::MAX);
888        assert!(none.is_empty());
889    }
890
891    #[test]
892    fn scan_measurement_merges_active_and_frozen() {
893        let dir = tempfile::tempdir().unwrap();
894        let fc = FlushController::new(test_config(dir.path()));
895
896        // Insert into active, then freeze
897        fc.insert(&make_point_with_measurement("cpu", "a", 100, 1.0))
898            .unwrap();
899        fc.insert(&make_point_with_measurement("mem", "a", 150, 9.0))
900            .unwrap();
901        fc.freeze_and_swap().unwrap();
902
903        // Insert more into new active
904        fc.insert(&make_point_with_measurement("cpu", "a", 200, 2.0))
905            .unwrap();
906        fc.insert(&make_point_with_measurement("mem", "b", 250, 8.0))
907            .unwrap();
908
909        // A `FlushController` scan is a *set*: it is `ShardRouter` that orders
910        // by timestamp, across shards, and every caller reaches it that way.
911        let mut cpu: Vec<i64> = fc
912            .scan_measurement("cpu", 0, i64::MAX)
913            .iter()
914            .map(Point::timestamp)
915            .collect();
916        cpu.sort_unstable();
917        assert_eq!(cpu, vec![100, 200]);
918
919        let mem = fc.scan_measurement("mem", 0, i64::MAX);
920        assert_eq!(mem.len(), 2);
921    }
922
923    #[test]
924    fn min_unflushed_wal_seq_empty() {
925        let fc = FlushController::new(FlushConfig::default());
926        assert!(fc.min_unflushed_wal_seq().is_none());
927    }
928
929    #[test]
930    fn min_unflushed_wal_seq_after_insert() {
931        let fc = FlushController::new(FlushConfig::default());
932        let p = make_point("host", 100, 1.0);
933        fc.insert_with_wal_seq(&p, 42).unwrap();
934        fc.insert_with_wal_seq(&p, 50).unwrap();
935        assert_eq!(fc.min_unflushed_wal_seq(), Some(42));
936    }
937
938    /// A frozen memtable is unflushed until its segment is registered, so
939    /// its records must keep capping the WAL floor. This used to report
940    /// `None` after a freeze, and a concurrent flush of another shard then
941    /// truncated the WAL files holding the frozen records.
942    #[test]
943    fn a_frozen_memtable_still_counts_as_unflushed() {
944        let fc = FlushController::new(FlushConfig::default());
945        let p = make_point("host", 100, 1.0);
946        fc.insert_with_wal_seq(&p, 10).unwrap();
947        fc.freeze_and_swap().unwrap();
948        assert_eq!(
949            fc.min_unflushed_wal_seq(),
950            Some(10),
951            "the frozen memtable holds seq 10 and nothing flushed it"
952        );
953        fc.insert_with_wal_seq(&make_point("host", 200, 2.0), 20)
954            .unwrap();
955        assert_eq!(fc.min_unflushed_wal_seq(), Some(10));
956    }
957
958    /// A flush that fails leaves the memtable frozen for a retry and removes
959    /// the files it managed to write; it used to pop the memtable first and
960    /// drop the only copy of the data on any error.
961    #[test]
962    fn a_failed_flush_keeps_the_frozen_memtable() {
963        let dir = tempfile::tempdir().unwrap();
964        let mut config = test_config(dir.path());
965        // The segment directory is a regular file: every write fails.
966        let blocked = dir.path().join("blocked");
967        std::fs::write(&blocked, b"not a directory").unwrap();
968        config.segment_dir = blocked.clone();
969        let fc = FlushController::new(config);
970
971        fc.insert_with_wal_seq(&make_point("a", 100, 1.0), 7)
972            .unwrap();
973        fc.freeze_and_swap().unwrap();
974        assert!(fc.flush_frozen().is_err());
975        assert!(
976            fc.frozen_memtable().is_some(),
977            "the memtable must survive a failed flush"
978        );
979        assert_eq!(fc.min_unflushed_wal_seq(), Some(7));
980
981        // A registration failure is the same: nothing is lost, and the
982        // segment written for the attempt is removed.
983        std::fs::remove_file(&blocked).unwrap();
984        std::fs::create_dir_all(&blocked).unwrap();
985        let err = fc.flush_frozen_with(|results| {
986            assert_eq!(results.len(), 1);
987            assert!(results[0].segment_meta.path.exists());
988            Err(MemtableError::NoFrozenMemtable)
989        });
990        assert!(err.is_err());
991        assert!(fc.frozen_memtable().is_some());
992        assert_eq!(
993            std::fs::read_dir(&blocked).unwrap().count(),
994            0,
995            "a segment nothing registered is removed, not left as an orphan"
996        );
997
998        // And the retry succeeds and releases it.
999        let results = fc.flush_frozen().unwrap();
1000        assert_eq!(results[0].points_flushed, 1);
1001        assert!(fc.frozen_memtable().is_none());
1002        assert!(fc.min_unflushed_wal_seq().is_none());
1003    }
1004
1005    #[test]
1006    fn write_stall_zero_below_soft_limit() {
1007        let dir = tempfile::tempdir().unwrap();
1008        let config = FlushConfig {
1009            max_memory: 1024 * 1024,
1010            stall_soft_fraction: 0.75,
1011            max_stall_delay: std::time::Duration::from_millis(100),
1012            segment_dir: dir.path().to_path_buf(),
1013            ..Default::default()
1014        };
1015        let fc = FlushController::new(config);
1016
1017        // Empty memtable — no stall
1018        assert_eq!(fc.write_stall_delay(), std::time::Duration::ZERO);
1019    }
1020
1021    #[test]
1022    fn write_stall_increases_with_pressure() {
1023        let dir = tempfile::tempdir().unwrap();
1024        let config = FlushConfig {
1025            max_memory: 10_000,       // 10 KB
1026            stall_soft_fraction: 0.5, // stall starts at 5 KB
1027            max_stall_delay: std::time::Duration::from_millis(100),
1028            flush_threshold: 10_000, // won't auto-flush
1029            segment_dir: dir.path().to_path_buf(),
1030            ..Default::default()
1031        };
1032        let fc = FlushController::new(config);
1033
1034        // Insert data to push past soft limit
1035        for i in 0..100 {
1036            let _ = fc.insert(&make_point("a", i, i as f64));
1037        }
1038
1039        let delay = fc.write_stall_delay();
1040        // Should have non-zero delay since we're over the soft limit
1041        let mem = fc.total_memory();
1042        if mem > 5000 {
1043            assert!(
1044                delay > std::time::Duration::ZERO,
1045                "should stall when over soft limit (mem={mem})"
1046            );
1047            assert!(
1048                delay <= std::time::Duration::from_millis(100),
1049                "should not exceed max stall delay"
1050            );
1051        }
1052    }
1053}