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