Skip to main content

chronix_engine/memtable/
memtable.rs

1//! Concurrent memtable backed by a lock-free skip list.
2//!
3//! The [`Memtable`] is the primary in-memory write buffer. It accepts
4//! [`Point`] inserts, stores them in a concurrent skip
5//! list ordered by `(series_key_hash, timestamp)`, and supports range scans
6//! for reads.
7//!
8//! # Threading model
9//!
10//! Multiple writers can insert concurrently without external locking.
11//! The skip list (from [`crossbeam_skiplist`]) provides lock-free concurrent
12//! access. An [`AtomicUsize`] tracks the estimated memory footprint, and
13//! [`AtomicU64`] values track the WAL sequence range for flush coordination.
14
15use std::collections::HashSet;
16use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
17use std::sync::Arc;
18
19use crossbeam_skiplist::SkipMap;
20use dashmap::mapref::entry::Entry;
21use dashmap::DashMap;
22
23use chronix_core::types::{Point, SeriesKey, Timestamp};
24
25use crate::memtable::error::{MemtableError, Result};
26use crate::memtable::interner::StringInterner;
27use crate::memtable::key::{MemtableEntry, MemtableKey, NODE_OVERHEAD};
28
29/// One series' tag set, shared by every point of the series in a memtable.
30pub type TagSet = Arc<[(Arc<str>, Arc<str>)]>;
31
32/// A concurrent in-memory write buffer for time-series data.
33///
34/// Points are stored in a [`crossbeam_skiplist::SkipMap`] keyed by
35/// [`MemtableKey`] `(series_key_hash, timestamp)`. The skip list provides
36/// lock-free concurrent inserts and ordered iteration.
37///
38/// # Deduplication
39///
40/// If a point with the same `(series_key_hash, timestamp)` already exists,
41/// the new value overwrites the old one (last-write-wins).
42pub struct Memtable {
43    /// Lock-free concurrent skip list.
44    data: SkipMap<MemtableKey, MemtableEntry>,
45    /// String interner for deduplicating measurement/tag strings.
46    interner: StringInterner,
47    /// Secondary index: measurement name → set of series hashes.
48    ///
49    /// Enables O(S × log N) `scan_measurement()` instead of O(N) full scans,
50    /// where S is the number of distinct series for the target measurement.
51    ///
52    /// Uses [`DashMap`] with per-shard locking instead of a
53    /// global `RwLock<HashMap>` to eliminate write-lock contention on the
54    /// hot insert path.
55    measurement_index: DashMap<Arc<str>, HashSet<u64>>,
56    /// One interned tag set per series (keyed by canonical form), shared by
57    /// every point of the series in this memtable.
58    series_tags: DashMap<Arc<str>, TagSet>,
59    /// Estimated total memory usage in bytes, excluding `measurement_index`.
60    estimated_size: AtomicUsize,
61    /// Estimated `measurement_index` footprint in bytes.
62    ///
63    /// Accumulated at the two sites that write the index, not recomputed on
64    /// read: the flush controller's capacity check calls `estimated_size()`
65    /// once per point, and walking the `DashMap` there locks every shard.
66    index_size: AtomicUsize,
67    /// Minimum WAL sequence number covered by this memtable.
68    min_wal_seq: AtomicU64,
69    /// Maximum WAL sequence number covered by this memtable.
70    max_wal_seq: AtomicU64,
71    /// Whether this memtable has been frozen (read-only).
72    frozen: AtomicBool,
73}
74
75impl std::fmt::Debug for Memtable {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("Memtable")
78            .field("len", &self.data.len())
79            .field(
80                "estimated_size",
81                &self.estimated_size.load(Ordering::Relaxed),
82            )
83            .field("interned_strings", &self.interner.len())
84            .field("min_wal_seq", &self.min_wal_seq())
85            .field("max_wal_seq", &self.max_wal_seq())
86            .field("frozen", &self.frozen.load(Ordering::Relaxed))
87            .finish()
88    }
89}
90
91impl Memtable {
92    /// Create a new empty memtable.
93    #[must_use]
94    pub fn new() -> Self {
95        Self {
96            data: SkipMap::new(),
97            interner: StringInterner::new(),
98            measurement_index: DashMap::new(),
99            series_tags: DashMap::new(),
100            estimated_size: AtomicUsize::new(0),
101            index_size: AtomicUsize::new(0),
102            min_wal_seq: AtomicU64::new(u64::MAX),
103            max_wal_seq: AtomicU64::new(0),
104            frozen: AtomicBool::new(false),
105        }
106    }
107
108    /// Insert a point into the memtable.
109    ///
110    /// The point is stored keyed by `(series_key_hash, timestamp)`. If a
111    /// point with the same key already exists, it is overwritten
112    /// (last-write-wins).
113    ///
114    /// # Errors
115    ///
116    /// Returns [`MemtableError::Frozen`] if the memtable has been frozen.
117    pub fn insert(&self, point: &Point) -> Result<()> {
118        if self.frozen.load(Ordering::Acquire) {
119            return Err(MemtableError::Frozen);
120        }
121
122        let canonical: Arc<str> = self.interner.intern(point.series_key().canonical_form());
123        let key = MemtableKey::new(point.series_key().hash_fnv(), point.timestamp(), canonical);
124
125        let measurement = self.interner.intern(point.series_key().measurement());
126        let measurement_for_index = Arc::clone(&measurement);
127        let tags = self.series_tag_set(&key.series_canonical, point.series_key());
128
129        let entry = MemtableEntry {
130            measurement,
131            tags,
132            fields: point
133                .fields()
134                .iter()
135                .map(|(k, v)| (Arc::clone(k), v.clone()))
136                .collect(),
137        };
138
139        let entry_size =
140            entry.estimated_size() + std::mem::size_of::<MemtableKey>() + NODE_OVERHEAD;
141
142        // If the key already exists, subtract the old entry size to avoid
143        // monotonically inflating the estimate on overwrites.
144        // Use saturating subtraction to prevent underflow when concurrent
145        // overwrites race on the same key (TOCTOU between get and insert).
146        // Crossbeam SkipMap::insert() doesn't return the old value,
147        // so a separate get() is required. The two O(log N) traversals are
148        // inherent to the SkipMap API.
149        if let Some(existing) = self.data.get(&key) {
150            let old_size = existing.value().estimated_size()
151                + std::mem::size_of::<MemtableKey>()
152                + NODE_OVERHEAD;
153            let _ =
154                self.estimated_size
155                    .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
156                        Some(current.saturating_sub(old_size))
157                    });
158        }
159
160        self.data.insert(key, entry);
161        let new_size = self.estimated_size.fetch_add(entry_size, Ordering::Relaxed) + entry_size;
162
163        // Emit memtable memory usage gauge so operators can monitor
164        // memory pressure and flush readiness.
165        metrics::gauge!("chronix_memtable_memory_bytes").set(new_size as f64);
166        metrics::gauge!("chronix_memtable_entries").set(self.data.len() as f64);
167
168        // Update secondary measurement index so scan_measurement() can
169        // use targeted range scans instead of full table iteration.
170        // DashMap entry API — only per-shard lock, no global contention.
171        {
172            let hash = point.series_key().hash_fnv();
173            let delta = self.index_insert(measurement_for_index, hash);
174            if delta > 0 {
175                self.index_size.fetch_add(delta, Ordering::Relaxed);
176            }
177        }
178
179        Ok(())
180    }
181
182    /// Insert a point with an associated WAL sequence number.
183    ///
184    /// Updates the min/max WAL sequence tracking in addition to inserting
185    /// the point.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`MemtableError::Frozen`] if the memtable has been frozen.
190    pub fn insert_with_wal_seq(&self, point: &Point, wal_seq: u64) -> Result<()> {
191        self.insert(point)?;
192        self.update_wal_seq(wal_seq);
193        Ok(())
194    }
195
196    /// Insert a batch of points in one call.
197    ///
198    /// More efficient than calling `insert` in a loop because:
199    /// The frozen check is performed once.
200    /// Strings are pre-interned and the measurement index is updated in
201    ///   a single write-lock acquisition.
202    /// Size adjustments are accumulated locally and flushed with a single
203    ///   `fetch_add`.
204    ///
205    /// Returns the number of points successfully inserted.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`MemtableError::Frozen`] if the memtable has been frozen.
210    pub fn insert_batch(&self, points: &[Point]) -> Result<usize> {
211        if self.frozen.load(Ordering::Acquire) {
212            return Err(MemtableError::Frozen);
213        }
214
215        let mut total_size_delta: i64 = 0;
216        // Collect (measurement, hash) pairs for batch measurement index update.
217        let mut new_index_entries: Vec<(Arc<str>, u64)> = Vec::new();
218
219        // Track previous key to skip overwrite check when
220        // consecutive batch points have different keys (the common case
221        // for append-only time-series ingestion).
222        let mut prev_hash: u64 = 0;
223        let mut prev_ts: i64 = i64::MIN;
224
225        for point in points {
226            let canonical: Arc<str> = self.interner.intern(point.series_key().canonical_form());
227            let key = MemtableKey::new(point.series_key().hash_fnv(), point.timestamp(), canonical);
228
229            let measurement = self.interner.intern(point.series_key().measurement());
230            let tags = self.series_tag_set(&key.series_canonical, point.series_key());
231
232            let entry = MemtableEntry {
233                measurement: Arc::clone(&measurement),
234                tags,
235                fields: point
236                    .fields()
237                    .iter()
238                    .map(|(k, v)| (Arc::clone(k), v.clone()))
239                    .collect(),
240            };
241
242            let entry_size =
243                entry.estimated_size() + std::mem::size_of::<MemtableKey>() + NODE_OVERHEAD;
244
245            // Only check for overwrites when the key could collide
246            // with an existing entry. For strictly increasing timestamps
247            // within the same series or different series, skip the O(log N)
248            // get() entirely — the insert alone is sufficient.
249            let cur_hash = point.series_key().hash_fnv();
250            let cur_ts = point.timestamp();
251            let may_overwrite = cur_hash == prev_hash && cur_ts == prev_ts;
252
253            if may_overwrite {
254                if let Some(existing) = self.data.get(&key) {
255                    let old_size = existing.value().estimated_size()
256                        + std::mem::size_of::<MemtableKey>()
257                        + NODE_OVERHEAD;
258                    total_size_delta -= old_size.min(i64::MAX as usize) as i64;
259                }
260            }
261
262            prev_hash = cur_hash;
263            prev_ts = cur_ts;
264
265            self.data.insert(key, entry);
266            // Saturating cast prevents theoretical i64 overflow.
267            total_size_delta += entry_size.min(i64::MAX as usize) as i64;
268
269            new_index_entries.push((measurement, cur_hash));
270        }
271
272        // Flush accumulated size delta in one atomic operation.
273        if total_size_delta >= 0 {
274            self.estimated_size
275                .fetch_add(total_size_delta as usize, Ordering::Relaxed);
276        } else {
277            let _ =
278                self.estimated_size
279                    .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
280                        Some(current.saturating_sub(total_size_delta.unsigned_abs() as usize))
281                    });
282        }
283
284        // DashMap entry API — per-shard locking, no global contention.
285        let mut index_delta = 0usize;
286        for (measurement, hash) in new_index_entries {
287            index_delta += self.index_insert(measurement, hash);
288        }
289        if index_delta > 0 {
290            self.index_size.fetch_add(index_delta, Ordering::Relaxed);
291        }
292
293        // Emit memtable memory usage gauge after batch insert.
294        metrics::gauge!("chronix_memtable_memory_bytes")
295            .set(self.estimated_size.load(Ordering::Relaxed) as f64);
296        metrics::gauge!("chronix_memtable_entries").set(self.data.len() as f64);
297
298        Ok(points.len())
299    }
300
301    /// Insert a batch of points with an associated WAL sequence number.
302    ///
303    /// Uses the batch to set the WAL sequence range, then delegates to
304    /// `insert_batch`.
305    ///
306    /// # Errors
307    ///
308    /// Returns [`MemtableError::Frozen`] if the memtable has been frozen.
309    pub fn insert_batch_with_wal_seq(&self, points: &[Point], wal_seq: u64) -> Result<usize> {
310        let count = self.insert_batch(points)?;
311        self.update_wal_seq(wal_seq);
312        Ok(count)
313    }
314
315    /// Update the WAL sequence range tracked by this memtable.
316    fn update_wal_seq(&self, seq: u64) {
317        // Update min: keep the smallest
318        let mut current = self.min_wal_seq.load(Ordering::Relaxed);
319        while seq < current {
320            match self.min_wal_seq.compare_exchange_weak(
321                current,
322                seq,
323                Ordering::Release,
324                Ordering::Relaxed,
325            ) {
326                Ok(_) => break,
327                Err(actual) => current = actual,
328            }
329        }
330
331        // Update max: keep the largest
332        let mut current = self.max_wal_seq.load(Ordering::Relaxed);
333        while seq > current {
334            match self.max_wal_seq.compare_exchange_weak(
335                current,
336                seq,
337                Ordering::Release,
338                Ordering::Relaxed,
339            ) {
340                Ok(_) => break,
341                Err(actual) => current = actual,
342            }
343        }
344    }
345
346    /// Scan points for a specific series within a time range.
347    ///
348    /// Returns points sorted by timestamp for the series identified by
349    /// `series_key`. Only points within `[min_ts, max_ts]` are returned.
350    /// Uses canonical form comparison (not hash) to avoid false positives
351    /// from hash collisions.
352    #[must_use]
353    pub fn scan(&self, series_key: &SeriesKey, min_ts: Timestamp, max_ts: Timestamp) -> Vec<Point> {
354        let hash = series_key.hash_fnv();
355        let canonical = series_key.canonical_form();
356        // Use the canonical form for exact range boundaries so the
357        // scan covers only entries for this specific series.
358        let start = MemtableKey::new(hash, min_ts, Arc::from(canonical));
359        let end = MemtableKey::new(hash, max_ts, Arc::from(canonical));
360
361        self.data
362            .range(start..=end)
363            .filter(|entry| &*entry.key().series_canonical == canonical)
364            .filter_map(|entry| Self::entry_to_point(entry.key(), entry.value()))
365            .collect()
366    }
367
368    /// Scan all points across all series within a time range.
369    ///
370    /// Returns all points sorted by `(series_key_hash, timestamp)`.
371    ///
372    /// **Note:** this is intentionally O(N) — it scans all series.
373    /// For measurement-scoped queries (the common case), use
374    /// `scan_measurement` which leverages the measurement index for
375    /// O(S × log N) per-series range scans.
376    #[must_use]
377    pub fn scan_range(&self, min_ts: Timestamp, max_ts: Timestamp) -> Vec<Point> {
378        // Scan the full BTree but filter by timestamp. No sentinel upper
379        // bound needed — just iterate all entries and filter.
380        self.data
381            .iter()
382            .filter(|entry| {
383                let ts = entry.key().timestamp;
384                ts >= min_ts && ts <= max_ts
385            })
386            .filter_map(|entry| Self::entry_to_point(entry.key(), entry.value()))
387            .collect()
388    }
389
390    /// Scan all points in the memtable, sorted by `(series_key_hash,
391    /// timestamp)`.
392    #[must_use]
393    pub fn scan_all(&self) -> Vec<Point> {
394        self.data
395            .iter()
396            .filter_map(|entry| Self::entry_to_point(entry.key(), entry.value()))
397            .collect()
398    }
399
400    /// Scan all points belonging to a specific measurement within a time range.
401    ///
402    /// Uses a secondary measurement index to perform targeted range scans
403    /// per series hash, achieving O(S × log N) complexity instead of O(N)
404    /// full-table iteration, where S is the number of distinct series for
405    /// the measurement.
406    #[must_use]
407    pub fn scan_measurement(
408        &self,
409        measurement: &str,
410        min_ts: Timestamp,
411        max_ts: Timestamp,
412    ) -> Vec<Point> {
413        let hashes = match self.measurement_index.get(measurement) {
414            Some(set) => set.iter().copied().collect::<Vec<_>>(),
415            None => return Vec::new(),
416        };
417
418        let mut results = Vec::new();
419        for hash in hashes {
420            // Build range bounds for this series hash across the timestamp window.
421            let start = MemtableKey::lower_bound(hash, min_ts);
422            let end = MemtableKey::upper_bound(hash, max_ts);
423            results.extend(
424                self.data
425                    .range(start..=end)
426                    .filter(|entry| {
427                        let ts = entry.key().timestamp;
428                        ts >= min_ts && ts <= max_ts && &*entry.value().measurement == measurement
429                    })
430                    .filter_map(|entry| Self::entry_to_point(entry.key(), entry.value())),
431            );
432        }
433        results
434    }
435
436    /// Returns the number of entries in the memtable.
437    #[must_use]
438    pub fn len(&self) -> usize {
439        self.data.len()
440    }
441
442    /// Returns `true` if the memtable contains no entries.
443    #[must_use]
444    pub fn is_empty(&self) -> bool {
445        self.data.is_empty()
446    }
447
448    /// Returns the estimated memory usage in bytes.
449    ///
450    /// Heap bytes held by this memtable's string interner.
451    ///
452    /// Reported separately from [`estimated_size`](Self::estimated_size),
453    /// which counts rows and index entries: the interner holds the tag keys,
454    /// tag values and measurement names, so it grows with *cardinality* rather
455    /// than with row count and is the term a high-cardinality workload sees
456    /// first.
457    #[must_use]
458    pub fn interner_bytes(&self) -> usize {
459        self.interner.memory_bytes()
460    }
461
462    /// Includes the `measurement_index` DashMap overhead.
463    #[must_use]
464    pub fn estimated_size(&self) -> usize {
465        self.estimated_size.load(Ordering::Relaxed) + self.index_size.load(Ordering::Relaxed)
466    }
467
468    /// The interned tag set of a series, created on its first point.
469    ///
470    /// A new series charges its canonical form, its tag slice and the map
471    /// entry to the size estimate once; every later point of the series
472    /// shares the `Arc`.
473    fn series_tag_set(
474        &self,
475        canonical: &Arc<str>,
476        series_key: &SeriesKey,
477    ) -> Arc<[(Arc<str>, Arc<str>)]> {
478        if let Some(existing) = self.series_tags.get(canonical) {
479            return Arc::clone(existing.value());
480        }
481        let tags: Arc<[(Arc<str>, Arc<str>)]> = series_key
482            .tags()
483            .iter()
484            .map(|(k, v)| (self.interner.intern(k), self.interner.intern(v)))
485            .collect();
486        let mut charged = 0usize;
487        let stored = self
488            .series_tags
489            .entry(Arc::clone(canonical))
490            .or_insert_with(|| {
491                // Canonical form bytes (interned once), the tag slice, the
492                // strings the slice points at (interned, charged here on
493                // first sight), and the map entry.
494                charged = canonical.len()
495                    + tags.len() * std::mem::size_of::<(Arc<str>, Arc<str>)>()
496                    + tags.iter().map(|(k, v)| k.len() + v.len()).sum::<usize>()
497                    + 2 * std::mem::size_of::<usize>()
498                    + 64;
499                tags
500            })
501            .clone();
502        if charged > 0 {
503            self.estimated_size.fetch_add(charged, Ordering::Relaxed);
504        }
505        stored
506    }
507
508    /// Add `hash` to the index under `measurement`, returning the bytes the
509    /// index grew by.
510    ///
511    /// The accounting matches what a full walk of the map would produce: an
512    /// `Arc<str>` key (pointer pair + string bytes) plus the `HashSet`'s
513    /// allocation (capacity × 8) and its own overhead. Growth is charged as
514    /// the set's capacity actually changes, so an amortised rehash is
515    /// accounted for on the insert that triggers it.
516    fn index_insert(&self, measurement: Arc<str>, hash: u64) -> usize {
517        const SET_OVERHEAD: usize = 56;
518        match self.measurement_index.entry(measurement) {
519            Entry::Occupied(mut occupied) => {
520                let before = occupied.get().capacity();
521                occupied.get_mut().insert(hash);
522                (occupied.get().capacity() - before) * std::mem::size_of::<u64>()
523            }
524            Entry::Vacant(vacant) => {
525                let key_bytes = std::mem::size_of::<usize>() * 2 + vacant.key().len();
526                let mut set = HashSet::new();
527                set.insert(hash);
528                let set_bytes = set.capacity() * std::mem::size_of::<u64>() + SET_OVERHEAD;
529                vacant.insert(set);
530                key_bytes + set_bytes
531            }
532        }
533    }
534
535    /// Returns the minimum WAL sequence number tracked.
536    ///
537    /// Returns `None` if no WAL sequences have been recorded.
538    #[must_use]
539    pub fn min_wal_seq(&self) -> Option<u64> {
540        let val = self.min_wal_seq.load(Ordering::Acquire);
541        if val == u64::MAX {
542            None
543        } else {
544            Some(val)
545        }
546    }
547
548    /// Returns the maximum WAL sequence number tracked.
549    ///
550    /// Returns `None` if no WAL sequences have been recorded.
551    #[must_use]
552    pub fn max_wal_seq(&self) -> Option<u64> {
553        let val = self.max_wal_seq.load(Ordering::Acquire);
554        if val == 0 && self.min_wal_seq.load(Ordering::Acquire) == u64::MAX {
555            None
556        } else {
557            Some(val)
558        }
559    }
560
561    /// Returns whether this memtable has been frozen.
562    #[must_use]
563    pub fn is_frozen(&self) -> bool {
564        self.frozen.load(Ordering::Acquire)
565    }
566
567    /// Freeze this memtable, preventing any further inserts.
568    ///
569    /// After freezing, all `insert` calls will return
570    /// [`MemtableError::Frozen`].
571    pub fn freeze(&self) {
572        self.frozen.store(true, Ordering::Release);
573    }
574
575    /// Collect all points as a `Vec<Point>` for flushing to a segment.
576    #[must_use]
577    pub fn to_points(&self) -> Vec<Point> {
578        self.scan_all()
579    }
580
581    /// Deprecated: use [`to_points`](Self::to_points) instead.
582    #[must_use]
583    #[deprecated(
584        since = "0.1.0",
585        note = "renamed to `to_points`; this method does not drain"
586    )]
587    pub fn drain_to_points(&self) -> Vec<Point> {
588        self.to_points()
589    }
590
591    /// Convert a skip list entry back into a [`Point`].
592    ///
593    /// Returns `None` if reconstruction fails (e.g. invalid series key),
594    /// which is logged as a warning.
595    fn entry_to_point(key: &MemtableKey, entry: &MemtableEntry) -> Option<Point> {
596        let tags = entry
597            .tags
598            .iter()
599            .map(|(k, v)| (k.to_string(), v.to_string()))
600            .collect();
601        let series_key = SeriesKey::new(entry.measurement.to_string(), tags)
602            .map_err(|e| {
603                // Emit metric so operators can alert on data loss
604                // during flush, not just rely on log scanning.
605                tracing::error!(
606                    hash = key.series_key_hash,
607                    error = %e,
608                    "DATA LOSS: failed to reconstruct series key during flush"
609                );
610                metrics::counter!("chronix_memtable_dropped_points_total").increment(1);
611                e
612            })
613            .ok()?;
614        Point::new(
615            series_key,
616            entry
617                .fields
618                .iter()
619                .map(|(k, v)| (k.to_string(), v.clone()))
620                .collect(),
621            key.timestamp,
622        )
623        .map_err(|e| {
624            tracing::error!(
625                hash = key.series_key_hash,
626                timestamp = key.timestamp,
627                error = %e,
628                "DATA LOSS: failed to reconstruct point during flush"
629            );
630            metrics::counter!("chronix_memtable_dropped_points_total").increment(1);
631            e
632        })
633        .ok()
634    }
635}
636
637impl Default for Memtable {
638    fn default() -> Self {
639        Self::new()
640    }
641}
642
643// SAFETY: `SkipMap` is `Send + Sync`, and all other fields are atomic types.
644// The `Memtable` is safe to share across threads.
645// (This is automatically derived, but we document it explicitly.)
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use std::collections::BTreeMap;
651
652    use chronix_core::FieldValue;
653
654    fn make_point(measurement: &str, host: &str, ts: i64, value: f64) -> Point {
655        let tags: BTreeMap<String, String> = [("host".to_string(), host.to_string())]
656            .into_iter()
657            .collect();
658        let series_key = SeriesKey::new(measurement.to_string(), tags).unwrap();
659        let fields: BTreeMap<String, FieldValue> = [("value".to_string(), FieldValue::F64(value))]
660            .into_iter()
661            .collect();
662        Point::new(series_key, fields, ts).unwrap()
663    }
664
665    #[test]
666    fn insert_and_scan_all() {
667        let mt = Memtable::new();
668        mt.insert(&make_point("cpu", "a", 100, 1.0)).unwrap();
669        mt.insert(&make_point("cpu", "a", 200, 2.0)).unwrap();
670        mt.insert(&make_point("cpu", "a", 300, 3.0)).unwrap();
671
672        let points = mt.scan_all();
673        assert_eq!(points.len(), 3);
674        assert_eq!(points[0].timestamp(), 100);
675        assert_eq!(points[1].timestamp(), 200);
676        assert_eq!(points[2].timestamp(), 300);
677    }
678
679    #[test]
680    fn insert_and_scan_series() {
681        let mt = Memtable::new();
682        mt.insert(&make_point("cpu", "a", 100, 1.0)).unwrap();
683        mt.insert(&make_point("cpu", "a", 200, 2.0)).unwrap();
684        mt.insert(&make_point("cpu", "b", 150, 9.0)).unwrap();
685
686        let key_a = SeriesKey::new(
687            "cpu".to_string(),
688            [("host".to_string(), "a".to_string())]
689                .into_iter()
690                .collect(),
691        )
692        .unwrap();
693
694        let points = mt.scan(&key_a, 0, 300);
695        assert_eq!(points.len(), 2);
696        assert!(points
697            .iter()
698            .all(|p| p.series_key().tag("host") == Some("a")));
699    }
700
701    #[test]
702    fn scan_time_range() {
703        let mt = Memtable::new();
704        mt.insert(&make_point("cpu", "a", 100, 1.0)).unwrap();
705        mt.insert(&make_point("cpu", "a", 200, 2.0)).unwrap();
706        mt.insert(&make_point("cpu", "a", 300, 3.0)).unwrap();
707
708        let key = SeriesKey::new(
709            "cpu".to_string(),
710            [("host".to_string(), "a".to_string())]
711                .into_iter()
712                .collect(),
713        )
714        .unwrap();
715
716        let points = mt.scan(&key, 150, 250);
717        assert_eq!(points.len(), 1);
718        assert_eq!(points[0].timestamp(), 200);
719    }
720
721    #[test]
722    fn last_write_wins() {
723        let mt = Memtable::new();
724        mt.insert(&make_point("cpu", "a", 100, 1.0)).unwrap();
725        mt.insert(&make_point("cpu", "a", 100, 99.0)).unwrap();
726
727        let points = mt.scan_all();
728        assert_eq!(points.len(), 1);
729        assert_eq!(points[0].field("value"), Some(&FieldValue::F64(99.0)));
730    }
731
732    #[test]
733    fn empty_scan() {
734        let mt = Memtable::new();
735        let key = SeriesKey::new("cpu".to_string(), BTreeMap::new()).unwrap();
736        let points = mt.scan(&key, 0, 1000);
737        assert!(points.is_empty());
738    }
739
740    /// `estimated_size` tracks the index incrementally; this pins it to the
741    /// full walk it replaced, so the two cannot drift apart silently.
742    #[test]
743    fn index_accounting_matches_a_full_walk() {
744        let mt = Memtable::new();
745        for i in 0..500u64 {
746            let measurement = format!("m{}", i % 7);
747            let series = format!("s{}", i % 53);
748            mt.insert(&make_point(&measurement, &series, i as i64, 1.0))
749                .unwrap();
750        }
751
752        // The batch path has its own index-write site; cover it too.
753        let batch: Vec<_> = (500..800u64)
754            .map(|i| {
755                make_point(
756                    &format!("m{}", i % 11),
757                    &format!("s{}", i % 97),
758                    i as i64,
759                    2.0,
760                )
761            })
762            .collect();
763        mt.insert_batch(&batch).unwrap();
764
765        let walked: usize = mt
766            .measurement_index
767            .iter()
768            .map(|entry| {
769                let key_bytes = std::mem::size_of::<usize>() * 2 + entry.key().len();
770                let set_bytes = entry.value().capacity() * std::mem::size_of::<u64>() + 56;
771                key_bytes + set_bytes
772            })
773            .sum();
774
775        assert_eq!(
776            mt.index_size.load(Ordering::Relaxed),
777            walked,
778            "incremental index accounting drifted from a full walk"
779        );
780    }
781
782    #[test]
783    fn estimated_size_increases() {
784        let mt = Memtable::new();
785        assert_eq!(mt.estimated_size(), 0);
786        mt.insert(&make_point("cpu", "a", 100, 1.0)).unwrap();
787        assert!(mt.estimated_size() > 0);
788        let size_after_one = mt.estimated_size();
789        mt.insert(&make_point("cpu", "a", 200, 2.0)).unwrap();
790        assert!(mt.estimated_size() > size_after_one);
791    }
792
793    #[test]
794    fn wal_seq_tracking() {
795        let mt = Memtable::new();
796        assert_eq!(mt.min_wal_seq(), None);
797        assert_eq!(mt.max_wal_seq(), None);
798
799        mt.insert_with_wal_seq(&make_point("cpu", "a", 100, 1.0), 10)
800            .unwrap();
801        assert_eq!(mt.min_wal_seq(), Some(10));
802        assert_eq!(mt.max_wal_seq(), Some(10));
803
804        mt.insert_with_wal_seq(&make_point("cpu", "a", 200, 2.0), 5)
805            .unwrap();
806        assert_eq!(mt.min_wal_seq(), Some(5));
807        assert_eq!(mt.max_wal_seq(), Some(10));
808
809        mt.insert_with_wal_seq(&make_point("cpu", "a", 300, 3.0), 20)
810            .unwrap();
811        assert_eq!(mt.min_wal_seq(), Some(5));
812        assert_eq!(mt.max_wal_seq(), Some(20));
813    }
814
815    #[test]
816    fn freeze_prevents_inserts() {
817        let mt = Memtable::new();
818        mt.insert(&make_point("cpu", "a", 100, 1.0)).unwrap();
819        mt.freeze();
820        assert!(mt.is_frozen());
821
822        let result = mt.insert(&make_point("cpu", "a", 200, 2.0));
823        assert!(result.is_err());
824
825        // Existing data is still readable
826        let points = mt.scan_all();
827        assert_eq!(points.len(), 1);
828    }
829
830    #[test]
831    fn len_and_is_empty() {
832        let mt = Memtable::new();
833        assert!(mt.is_empty());
834        assert_eq!(mt.len(), 0);
835
836        mt.insert(&make_point("cpu", "a", 100, 1.0)).unwrap();
837        assert!(!mt.is_empty());
838        assert_eq!(mt.len(), 1);
839    }
840
841    #[test]
842    fn multiple_series() {
843        let mt = Memtable::new();
844        mt.insert(&make_point("cpu", "a", 100, 1.0)).unwrap();
845        mt.insert(&make_point("cpu", "b", 100, 2.0)).unwrap();
846        mt.insert(&make_point("mem", "a", 100, 3.0)).unwrap();
847
848        assert_eq!(mt.len(), 3);
849
850        let all = mt.scan_all();
851        assert_eq!(all.len(), 3);
852    }
853
854    #[test]
855    fn concurrent_inserts() {
856        use std::sync::Arc;
857        use std::thread;
858
859        let mt = Arc::new(Memtable::new());
860        let mut handles = Vec::new();
861
862        for thread_id in 0..4 {
863            let mt = Arc::clone(&mt);
864            handles.push(thread::spawn(move || {
865                for i in 0..100 {
866                    let ts = i64::from(thread_id * 1000 + i);
867                    let host = format!("host-{thread_id}");
868                    mt.insert(&make_point("cpu", &host, ts, ts as f64)).unwrap();
869                }
870            }));
871        }
872
873        for h in handles {
874            h.join().unwrap();
875        }
876
877        assert_eq!(mt.len(), 400);
878        let all = mt.scan_all();
879        assert_eq!(all.len(), 400);
880
881        // Verify ordering: each series should be ordered by timestamp
882        let mut prev_key: Option<MemtableKey> = None;
883        for p in &all {
884            let key = MemtableKey::new(
885                p.series_key().hash_fnv(),
886                p.timestamp(),
887                Arc::from(p.series_key().canonical_form()),
888            );
889            if let Some(prev) = prev_key {
890                assert!(key >= prev, "entries should be ordered");
891            }
892            prev_key = Some(key);
893        }
894    }
895
896    #[allow(deprecated)]
897    #[test]
898    fn to_points_is_scan_all() {
899        let mt = Memtable::new();
900        mt.insert(&make_point("cpu", "a", 100, 1.0)).unwrap();
901        mt.insert(&make_point("cpu", "a", 200, 2.0)).unwrap();
902
903        let scan = mt.scan_all();
904        let points = mt.to_points();
905        assert_eq!(scan.len(), points.len());
906        for (s, d) in scan.iter().zip(points.iter()) {
907            assert_eq!(s.timestamp(), d.timestamp());
908            assert_eq!(s.fields(), d.fields());
909        }
910
911        // Verify deprecated alias still works
912        let drain = mt.drain_to_points();
913        assert_eq!(drain.len(), points.len());
914    }
915
916    #[test]
917    fn scan_range_across_series() {
918        let mt = Memtable::new();
919        mt.insert(&make_point("cpu", "a", 50, 1.0)).unwrap();
920        mt.insert(&make_point("cpu", "a", 100, 2.0)).unwrap();
921        mt.insert(&make_point("cpu", "b", 75, 3.0)).unwrap();
922        mt.insert(&make_point("cpu", "b", 200, 4.0)).unwrap();
923
924        let points = mt.scan_range(60, 150);
925        // Should get: cpu/a@100 and cpu/b@75
926        assert_eq!(points.len(), 2);
927        for p in &points {
928            assert!(
929                p.timestamp() >= 60 && p.timestamp() <= 150,
930                "timestamp {} should be in [60, 150]",
931                p.timestamp()
932            );
933        }
934    }
935
936    #[test]
937    fn scan_measurement_filters_by_name() {
938        let mt = Memtable::new();
939        mt.insert(&make_point("cpu", "a", 100, 1.0)).unwrap();
940        mt.insert(&make_point("cpu", "b", 200, 2.0)).unwrap();
941        mt.insert(&make_point("mem", "a", 150, 3.0)).unwrap();
942        mt.insert(&make_point("mem", "a", 300, 4.0)).unwrap();
943        mt.insert(&make_point("disk", "c", 250, 5.0)).unwrap();
944
945        // Scan only "cpu"
946        let cpu = mt.scan_measurement("cpu", 0, i64::MAX);
947        assert_eq!(cpu.len(), 2);
948        assert!(cpu.iter().all(|p| p.series_key().measurement() == "cpu"));
949
950        // Scan only "mem"
951        let mem = mt.scan_measurement("mem", 0, i64::MAX);
952        assert_eq!(mem.len(), 2);
953        assert!(mem.iter().all(|p| p.series_key().measurement() == "mem"));
954
955        // Scan "disk"
956        let disk = mt.scan_measurement("disk", 0, i64::MAX);
957        assert_eq!(disk.len(), 1);
958
959        // Scan non-existent measurement
960        let none = mt.scan_measurement("net", 0, i64::MAX);
961        assert!(none.is_empty());
962    }
963
964    #[test]
965    fn scan_measurement_respects_time_range() {
966        let mt = Memtable::new();
967        mt.insert(&make_point("cpu", "a", 100, 1.0)).unwrap();
968        mt.insert(&make_point("cpu", "a", 200, 2.0)).unwrap();
969        mt.insert(&make_point("cpu", "a", 300, 3.0)).unwrap();
970        mt.insert(&make_point("mem", "a", 200, 4.0)).unwrap();
971
972        let result = mt.scan_measurement("cpu", 150, 250);
973        assert_eq!(result.len(), 1);
974        assert_eq!(result[0].timestamp(), 200);
975    }
976
977    /// Regression test: two distinct series with the same FNV-1a hash
978    /// and timestamp must both be stored and retrievable (no silent
979    /// overwrite).
980    #[test]
981    fn hash_collision_preserves_both_series() {
982        let mt = Memtable::new();
983
984        // Create two points with the same hash and timestamp but
985        // different canonical forms by manually constructing keys.
986        // In production, FNV-1a collisions are rare but possible.
987        let p1 = make_point("cpu", "host-a", 1000, 42.0);
988        let p2 = make_point("cpu", "host-b", 1000, 99.0);
989
990        mt.insert(&p1).unwrap();
991        mt.insert(&p2).unwrap();
992
993        // Both must be present
994        assert_eq!(
995            mt.len(),
996            2,
997            "two distinct series at same timestamp must both survive"
998        );
999
1000        // Each series should return its own value
1001        let s1 = mt.scan(p1.series_key(), 0, i64::MAX);
1002        assert_eq!(s1.len(), 1);
1003        assert_eq!(
1004            *s1[0].field("value").unwrap(),
1005            chronix_core::FieldValue::F64(42.0)
1006        );
1007
1008        let s2 = mt.scan(p2.series_key(), 0, i64::MAX);
1009        assert_eq!(s2.len(), 1);
1010        assert_eq!(
1011            *s2[0].field("value").unwrap(),
1012            chronix_core::FieldValue::F64(99.0)
1013        );
1014    }
1015
1016    #[test]
1017    fn insert_batch_basic() {
1018        let mt = Memtable::new();
1019        let points = vec![
1020            make_point("cpu", "a", 100, 1.0),
1021            make_point("cpu", "a", 200, 2.0),
1022            make_point("cpu", "b", 300, 3.0),
1023            make_point("mem", "a", 400, 4.0),
1024        ];
1025        let count = mt.insert_batch(&points).unwrap();
1026        assert_eq!(count, 4);
1027
1028        let all = mt.scan_all();
1029        assert_eq!(all.len(), 4);
1030
1031        // Measurement index covers both measurements
1032        let cpu = mt.scan_measurement("cpu", 0, i64::MAX);
1033        assert_eq!(cpu.len(), 3);
1034        let mem = mt.scan_measurement("mem", 0, i64::MAX);
1035        assert_eq!(mem.len(), 1);
1036    }
1037
1038    #[test]
1039    fn insert_batch_overwrite() {
1040        let mt = Memtable::new();
1041        let initial = vec![
1042            make_point("cpu", "a", 100, 1.0),
1043            make_point("cpu", "a", 200, 2.0),
1044        ];
1045        mt.insert_batch(&initial).unwrap();
1046
1047        // Overwrite same timestamps with new values
1048        let overwrite = vec![
1049            make_point("cpu", "a", 100, 10.0),
1050            make_point("cpu", "a", 200, 20.0),
1051        ];
1052        mt.insert_batch(&overwrite).unwrap();
1053
1054        let all = mt.scan_all();
1055        assert_eq!(all.len(), 2);
1056        assert_eq!(
1057            *all[0].field("value").unwrap(),
1058            chronix_core::FieldValue::F64(10.0)
1059        );
1060        assert_eq!(
1061            *all[1].field("value").unwrap(),
1062            chronix_core::FieldValue::F64(20.0)
1063        );
1064    }
1065
1066    #[test]
1067    fn insert_batch_frozen_fails() {
1068        let mt = Memtable::new();
1069        mt.freeze();
1070        let points = vec![make_point("cpu", "a", 100, 1.0)];
1071        assert!(mt.insert_batch(&points).is_err());
1072    }
1073
1074    #[test]
1075    fn insert_batch_with_wal_seq_updates_range() {
1076        let mt = Memtable::new();
1077        let points = vec![
1078            make_point("cpu", "a", 100, 1.0),
1079            make_point("cpu", "a", 200, 2.0),
1080        ];
1081        mt.insert_batch_with_wal_seq(&points, 42).unwrap();
1082        assert_eq!(mt.min_wal_seq(), Some(42));
1083        assert_eq!(mt.max_wal_seq(), Some(42));
1084    }
1085}
1086
1087/// The size estimate against the allocator's number, not against itself.
1088///
1089/// This is its own module because it installs a counting global allocator
1090/// for the whole test binary; that is cheap (two atomics per allocation)
1091/// and it is the only way to ask what a memtable *actually* costs.
1092#[cfg(test)]
1093mod calibration {
1094    use std::alloc::{GlobalAlloc, Layout, System};
1095    use std::collections::BTreeMap;
1096    use std::sync::atomic::{AtomicUsize, Ordering};
1097
1098    use chronix_core::{FieldValue, Point, SeriesKey};
1099
1100    use super::Memtable;
1101
1102    struct Counting;
1103    static LIVE: AtomicUsize = AtomicUsize::new(0);
1104
1105    // Bytes allocated minus freed *on this thread*, so the other tests
1106    // running in parallel in this binary do not show up in the window.
1107    std::thread_local! {
1108        static THREAD_LIVE: std::cell::Cell<isize> = const { std::cell::Cell::new(0) };
1109    }
1110
1111    fn thread_live() -> isize {
1112        THREAD_LIVE.with(std::cell::Cell::get)
1113    }
1114
1115    // SAFETY: every operation is forwarded to the system allocator; the
1116    // counters are the only addition. The thread-local is `const`-initialised
1117    // so touching it never allocates, and `try_with` tolerates teardown.
1118    #[allow(unsafe_code)]
1119    unsafe impl GlobalAlloc for Counting {
1120        unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
1121            LIVE.fetch_add(layout.size(), Ordering::Relaxed);
1122            let _ = THREAD_LIVE.try_with(|c| c.set(c.get() + layout.size() as isize));
1123            unsafe { System.alloc(layout) }
1124        }
1125        unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
1126            LIVE.fetch_sub(layout.size(), Ordering::Relaxed);
1127            let _ = THREAD_LIVE.try_with(|c| c.set(c.get() - layout.size() as isize));
1128            unsafe { System.dealloc(ptr, layout) }
1129        }
1130    }
1131
1132    #[global_allocator]
1133    static ALLOC: Counting = Counting;
1134
1135    /// The design partner's shape: 50 series, two fields, a few hundred
1136    /// points each. The estimate must land within a quarter of what the
1137    /// allocator says — it used to be eight times below it.
1138    #[test]
1139    fn memtable_estimate_tracks_real_allocation() {
1140        let keys: Vec<SeriesKey> = (0..50)
1141            .map(|m| {
1142                SeriesKey::new(
1143                    "power",
1144                    BTreeMap::from([
1145                        ("meter".to_string(), format!("m{m:02}")),
1146                        ("site".to_string(), "home".to_string()),
1147                    ]),
1148                )
1149                .unwrap()
1150            })
1151            .collect();
1152        // Build the points first so their allocation is outside the window.
1153        let points: Vec<Point> = (0..400i64)
1154            .flat_map(|s| {
1155                keys.iter().map(move |k| {
1156                    Point::new(
1157                        k.clone(),
1158                        BTreeMap::from([
1159                            ("w".to_string(), FieldValue::F64(230.0 + s as f64)),
1160                            ("kwh".to_string(), FieldValue::F64(s as f64 / 1000.0)),
1161                        ]),
1162                        s * 1_000_000_000,
1163                    )
1164                    .unwrap()
1165                })
1166            })
1167            .collect();
1168
1169        let before = thread_live();
1170        let mt = Memtable::new();
1171        for p in &points {
1172            mt.insert(p).unwrap();
1173        }
1174        let measured = usize::try_from(thread_live() - before).unwrap_or(0);
1175        let estimated = mt.estimated_size();
1176        let ratio = estimated as f64 / measured as f64;
1177        assert!(
1178            (0.75..=1.25).contains(&ratio),
1179            "estimate {estimated} B vs allocator {measured} B for {} points: ratio {ratio:.2} \
1180             ({:.0} B/point measured, {:.0} B/point estimated)",
1181            points.len(),
1182            measured as f64 / points.len() as f64,
1183            estimated as f64 / points.len() as f64,
1184        );
1185        drop(mt);
1186    }
1187}
1188
1189/// One measurement's rows, ready for the segment writer.
1190#[derive(Debug)]
1191pub struct MeasurementBatch {
1192    /// The measurement.
1193    pub measurement: String,
1194    /// Its rows: `timestamp`, then the tag columns, then the field columns,
1195    /// each group sorted by name; rows in skip-list order (series-major,
1196    /// then time).
1197    pub batch: arrow::record_batch::RecordBatch,
1198    /// Which of the columns are tags.
1199    pub tag_columns: Vec<String>,
1200}
1201
1202/// Per-column Arrow builder for one field.
1203enum FieldBuilder {
1204    F64(arrow::array::Float64Builder),
1205    I64(arrow::array::Int64Builder),
1206    U64(arrow::array::UInt64Builder),
1207    Bool(arrow::array::BooleanBuilder),
1208    Str(arrow::array::StringBuilder),
1209}
1210
1211impl FieldBuilder {
1212    fn for_value(v: &chronix_core::FieldValue, rows_before: usize) -> Self {
1213        use chronix_core::FieldValue as V;
1214        let mut b = match v {
1215            V::F64(_) => Self::F64(arrow::array::Float64Builder::new()),
1216            V::I64(_) => Self::I64(arrow::array::Int64Builder::new()),
1217            V::U64(_) => Self::U64(arrow::array::UInt64Builder::new()),
1218            V::Bool(_) => Self::Bool(arrow::array::BooleanBuilder::new()),
1219            V::String(_) => Self::Str(arrow::array::StringBuilder::new()),
1220        };
1221        for _ in 0..rows_before {
1222            b.append_null();
1223        }
1224        b
1225    }
1226
1227    fn append_null(&mut self) {
1228        match self {
1229            Self::F64(b) => b.append_null(),
1230            Self::I64(b) => b.append_null(),
1231            Self::U64(b) => b.append_null(),
1232            Self::Bool(b) => b.append_null(),
1233            Self::Str(b) => b.append_null(),
1234        }
1235    }
1236
1237    /// Append a value; a value of another type than the column's is a null,
1238    /// because the schema registry has already refused it at write time
1239    /// and this is the last line of defence, not the first.
1240    fn append(&mut self, v: &chronix_core::FieldValue) {
1241        use chronix_core::FieldValue as V;
1242        match (self, v) {
1243            (Self::F64(b), V::F64(x)) => b.append_value(*x),
1244            (Self::I64(b), V::I64(x)) => b.append_value(*x),
1245            (Self::U64(b), V::U64(x)) => b.append_value(*x),
1246            (Self::Bool(b), V::Bool(x)) => b.append_value(*x),
1247            (Self::Str(b), V::String(x)) => b.append_value(x),
1248            (this, _) => this.append_null(),
1249        }
1250    }
1251
1252    fn finish(self) -> (arrow::datatypes::DataType, arrow::array::ArrayRef) {
1253        use arrow::datatypes::DataType;
1254        match self {
1255            Self::F64(mut b) => (DataType::Float64, Arc::new(b.finish())),
1256            Self::I64(mut b) => (DataType::Int64, Arc::new(b.finish())),
1257            Self::U64(mut b) => (DataType::UInt64, Arc::new(b.finish())),
1258            Self::Bool(mut b) => (DataType::Boolean, Arc::new(b.finish())),
1259            Self::Str(mut b) => (DataType::Utf8, Arc::new(b.finish())),
1260        }
1261    }
1262}
1263
1264/// Builders for one measurement, growing columns as the entries reveal them.
1265struct MeasurementBuilder {
1266    rows: usize,
1267    timestamps: arrow::array::Int64Builder,
1268    tags: std::collections::BTreeMap<Arc<str>, arrow::array::StringBuilder>,
1269    fields: std::collections::BTreeMap<Arc<str>, FieldBuilder>,
1270}
1271
1272impl MeasurementBuilder {
1273    fn new() -> Self {
1274        Self {
1275            rows: 0,
1276            timestamps: arrow::array::Int64Builder::new(),
1277            tags: std::collections::BTreeMap::new(),
1278            fields: std::collections::BTreeMap::new(),
1279        }
1280    }
1281
1282    fn push(&mut self, key: &MemtableKey, entry: &MemtableEntry) {
1283        self.timestamps.append_value(key.timestamp);
1284        // Tags: every known column gets a value or a null; a new column is
1285        // back-filled with nulls for the rows before it.
1286        for (k, v) in entry.tags.iter() {
1287            let rows = self.rows;
1288            self.tags.entry(Arc::clone(k)).or_insert_with(|| {
1289                let mut b = arrow::array::StringBuilder::new();
1290                for _ in 0..rows {
1291                    b.append_null();
1292                }
1293                b
1294            });
1295            let _ = v;
1296        }
1297        for (name, b) in &mut self.tags {
1298            match entry.tags.iter().find(|(k, _)| k == name) {
1299                Some((_, v)) => b.append_value(v),
1300                None => b.append_null(),
1301            }
1302        }
1303        for (k, v) in entry.fields.iter() {
1304            let rows = self.rows;
1305            self.fields
1306                .entry(Arc::clone(k))
1307                .or_insert_with(|| FieldBuilder::for_value(v, rows));
1308        }
1309        for (name, b) in &mut self.fields {
1310            match entry.fields.iter().find(|(k, _)| k == name) {
1311                Some((_, v)) => b.append(v),
1312                None => b.append_null(),
1313            }
1314        }
1315        self.rows += 1;
1316    }
1317
1318    fn finish(mut self, measurement: &str) -> Option<MeasurementBatch> {
1319        use arrow::datatypes::{DataType, Field, Schema};
1320        if self.rows == 0 {
1321            return None;
1322        }
1323        use crate::segment::metadata::roles;
1324        // A tag and a string field are the same Arrow type, so the schema has
1325        // to carry the role or every consumer guesses at it.
1326        let mut fields = vec![
1327            Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false)
1328                .with_metadata(roles::arrow_metadata(roles::TIMESTAMP)),
1329        ];
1330        let mut columns: Vec<arrow::array::ArrayRef> = vec![Arc::new(self.timestamps.finish())];
1331        let tag_columns: Vec<String> = self.tags.keys().map(ToString::to_string).collect();
1332        for (name, mut b) in self.tags {
1333            fields.push(
1334                Field::new(name.as_ref(), DataType::Utf8, true)
1335                    .with_metadata(roles::arrow_metadata(roles::TAG)),
1336            );
1337            columns.push(Arc::new(b.finish()));
1338        }
1339        for (name, b) in self.fields {
1340            let (dt, arr) = b.finish();
1341            fields.push(
1342                Field::new(name.as_ref(), dt, true)
1343                    .with_metadata(roles::arrow_metadata(roles::FIELD)),
1344            );
1345            columns.push(arr);
1346        }
1347        let batch =
1348            arrow::record_batch::RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
1349                .ok()?;
1350        Some(MeasurementBatch {
1351            measurement: measurement.to_string(),
1352            batch,
1353            tag_columns,
1354        })
1355    }
1356}
1357
1358impl Memtable {
1359    /// Every row, as one Arrow batch per measurement, built straight from
1360    /// the skip-list entries.
1361    ///
1362    /// This is what a flush hands the segment writer. It replaced
1363    /// `to_points()` on that path: materialising a full memtable as
1364    /// `Point`s — two `BTreeMap`s each — cost a kilobyte a row and made the
1365    /// flush of an 8 MB memtable peak at ten times that. A batch is the
1366    /// columns themselves, and the writer encodes it without another copy.
1367    #[must_use]
1368    pub fn to_record_batches(&self) -> Vec<MeasurementBatch> {
1369        let mut builders: std::collections::BTreeMap<Arc<str>, MeasurementBuilder> =
1370            std::collections::BTreeMap::new();
1371        for entry in self.data.iter() {
1372            let value = entry.value();
1373            builders
1374                .entry(Arc::clone(&value.measurement))
1375                .or_insert_with(MeasurementBuilder::new)
1376                .push(entry.key(), value);
1377        }
1378        builders
1379            .into_iter()
1380            .filter_map(|(m, b)| b.finish(&m))
1381            .collect()
1382    }
1383}