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