Skip to main content

chronix_engine/memtable/
shard.rs

1//! Time-shard routing for memtable writes.
2//!
3//! The [`ShardRouter`] maps each incoming point to the correct shard based
4//! on its timestamp, and routes the write to that shard's
5//! [`FlushController`]. Shards outside the configured out-of-order
6//! tolerance window are rejected.
7//!
8//! # Shard lifecycle
9//!
10//! 1. A new shard is created lazily on first write.
11//! 2. When a shard's memtable reaches the flush threshold, it is frozen and
12//!    flushed to a segment file.
13//! 3. Shards below the tolerance window that hold no data are retired from
14//!    the router; a backfill recreates one lazily.
15
16use std::collections::BTreeMap;
17use std::sync::Arc;
18use std::time::Duration;
19
20use parking_lot::RwLock;
21
22use chronix_core::types::{Point, ShardId, Timestamp};
23
24use crate::memtable::error::{MemtableError, Result};
25use crate::memtable::flush::{FlushConfig, FlushController, FlushResult};
26
27/// Configuration for the shard router.
28#[derive(Debug, Clone)]
29pub struct ShardRouterConfig {
30    /// Duration of each shard (e.g. 1 hour).
31    pub shard_duration: Duration,
32    /// Number of shards before/after the active shard to still accept
33    /// writes for.
34    pub ooo_shard_tolerance: i64,
35    /// Flush configuration applied to each shard's controller.
36    pub flush_config: FlushConfig,
37}
38
39impl Default for ShardRouterConfig {
40    fn default() -> Self {
41        Self {
42            shard_duration: Duration::from_secs(3600), // 1 hour
43            ooo_shard_tolerance: 1,
44            flush_config: FlushConfig::default(),
45        }
46    }
47}
48
49/// A single shard entry managed by the router.
50struct ShardEntry {
51    /// The flush controller for this shard's memtable.
52    controller: Arc<FlushController>,
53}
54
55/// Routes writes to per-shard memtables based on point timestamps.
56///
57/// Each shard covers a duration of [`shard_duration`](ShardRouterConfig::shard_duration) (default: 1 hour).
58/// Writes within `±ooo_shard_tolerance` shards of the current active
59/// shard are accepted; writes outside this window are rejected.
60pub struct ShardRouter {
61    /// Per-shard flush controllers, keyed by `ShardId`.
62    shards: Arc<RwLock<BTreeMap<ShardId, ShardEntry>>>,
63    /// Configuration.
64    config: ShardRouterConfig,
65    /// The current active shard (the most recently written-to).
66    active_shard: Arc<RwLock<Option<ShardId>>>,
67}
68
69impl std::fmt::Debug for ShardRouter {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.debug_struct("ShardRouter")
72            .field("config", &self.config)
73            .field("active_shard", &*self.active_shard.read())
74            .field("shard_count", &self.shards.read().len())
75            .finish()
76    }
77}
78
79impl ShardRouter {
80    /// Create a new shard router with the given configuration.
81    #[must_use]
82    pub fn new(config: ShardRouterConfig) -> Self {
83        Self {
84            shards: Arc::new(RwLock::new(BTreeMap::new())),
85            config,
86            active_shard: Arc::new(RwLock::new(None)),
87        }
88    }
89
90    /// Insert a point, routing it to the appropriate shard.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`MemtableError::ShardOutOfRange`] if the point's timestamp
95    /// maps to a shard outside the tolerance window.
96    pub fn insert(&self, point: &Point) -> Result<()> {
97        let shard_id = ShardId::from_timestamp(point.timestamp(), self.config.shard_duration);
98
99        self.ensure_shard_allowed(shard_id, point.timestamp())?;
100        self.insert_into_shard(shard_id, point)
101    }
102
103    /// Insert a point with an associated WAL sequence number.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`MemtableError::ShardOutOfRange`] if the shard is outside
108    /// tolerance.
109    pub fn insert_with_wal_seq(&self, point: &Point, wal_seq: u64) -> Result<()> {
110        let shard_id = ShardId::from_timestamp(point.timestamp(), self.config.shard_duration);
111
112        self.ensure_shard_allowed(shard_id, point.timestamp())?;
113        self.insert_into_shard_with_wal_seq(shard_id, point, wal_seq)
114    }
115
116    /// Admit a timestamp: decide whether a write for it may be accepted.
117    ///
118    /// This is the out-of-order tolerance check, split from the insert so a
119    /// caller can run it *before* making the write durable. A point that
120    /// fails admission never reaches the WAL, so what the caller was told
121    /// was rejected cannot come back through replay.
122    ///
123    /// Admitting a timestamp in a newer shard advances the active shard,
124    /// exactly as an insert would.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`MemtableError::ShardOutOfRange`] if the timestamp maps to a
129    /// shard outside the tolerance window.
130    pub fn admit(&self, timestamp: Timestamp) -> Result<ShardId> {
131        let shard_id = ShardId::from_timestamp(timestamp, self.config.shard_duration);
132        self.ensure_shard_allowed(shard_id, timestamp)?;
133        Ok(shard_id)
134    }
135
136    /// Insert a point that has already passed [`admit`](Self::admit) and
137    /// been made durable.
138    ///
139    /// No tolerance check: the decision was taken before the WAL append,
140    /// and re-taking it here would let a concurrent writer that advanced
141    /// the active shard in between turn an acknowledged, durable write
142    /// into a rejected one. The WAL is the truth; what is in it is
143    /// inserted.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the shard's memtable insertion fails.
148    pub fn insert_admitted(&self, point: &Point, wal_seq: u64) -> Result<()> {
149        let shard_id = ShardId::from_timestamp(point.timestamp(), self.config.shard_duration);
150        self.insert_into_shard_with_wal_seq(shard_id, point, wal_seq)
151    }
152
153    /// Insert a point during WAL replay, bypassing shard tolerance checks.
154    ///
155    /// During crash recovery the WAL may contain records in a different
156    /// order than the original `ensure_shard_allowed` evaluations (because
157    /// WAL append order can differ from shard-check order under concurrent
158    /// writes). Replaying with tolerance checks would reject points that
159    /// were validly accepted at write time. This method skips the tolerance
160    /// gate and updates the active shard monotonically so that subsequent
161    /// live writes see the correct baseline.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if the shard's memtable insertion fails.
166    pub fn insert_replay(&self, point: &Point, wal_seq: u64) -> Result<()> {
167        let shard_id = ShardId::from_timestamp(point.timestamp(), self.config.shard_duration);
168
169        // Update active shard monotonically (no tolerance rejection)
170        {
171            let mut active_guard = self.active_shard.write();
172            match *active_guard {
173                Some(active) if shard_id.0 > active.0 => {
174                    *active_guard = Some(shard_id);
175                }
176                None => {
177                    *active_guard = Some(shard_id);
178                }
179                _ => {}
180            }
181        }
182
183        self.insert_into_shard_with_wal_seq(shard_id, point, wal_seq)
184    }
185
186    /// Flush a specific shard's memtable to segments.
187    ///
188    /// Equivalent to [`flush_shard_with`](Self::flush_shard_with) with a
189    /// registration step that does nothing.
190    ///
191    /// # Errors
192    ///
193    /// See [`flush_shard_with`](Self::flush_shard_with).
194    pub fn flush_shard(&self, shard_id: ShardId) -> Result<Vec<FlushResult>> {
195        self.flush_shard_with(shard_id, |_| Ok(()))
196    }
197
198    /// Freeze the shard's active memtable, write it to segments, hand the
199    /// results to `register`, and release the memtable only once
200    /// registration succeeded — see
201    /// [`FlushController::flush_frozen_with`].
202    ///
203    /// The router lock is released before the write starts: it used to be
204    /// held for the whole segment write, and parking_lot's writer-preferring
205    /// `RwLock` then queued every insert behind the first write that needed
206    /// a new shard.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the shard doesn't exist or the flush fails.
211    pub fn flush_shard_with<F>(
212        &self,
213        shard_id: ShardId,
214        mut register: F,
215    ) -> Result<Vec<FlushResult>>
216    where
217        F: FnMut(&[FlushResult]) -> Result<()>,
218    {
219        let controller = {
220            let shards = self.shards.read();
221            let entry = shards
222                .get(&shard_id)
223                .ok_or(MemtableError::NoSuchShard(shard_id.0))?;
224            Arc::clone(&entry.controller)
225        };
226
227        // A full frozen queue is not a reason to fail: it means earlier
228        // flushes failed, and draining it is exactly what this call is for.
229        match controller.freeze_and_swap() {
230            Ok(()) | Err(MemtableError::CapacityExceeded { .. }) => {}
231            Err(e) => return Err(e),
232        }
233        // Drain every frozen memtable, oldest first.
234        let mut all = Vec::new();
235        while controller.frozen_count() > 0 {
236            match controller.flush_frozen_with(&mut register) {
237                Ok(results) => all.extend(results),
238                Err(MemtableError::NoFrozenMemtable) => continue,
239                Err(e) => {
240                    if all.is_empty() {
241                        return Err(e);
242                    }
243                    // Something landed; report it and let the caller retry
244                    // the rest. The unflushed memtables still cap the floor.
245                    tracing::warn!(shard = %shard_id, error = %e, "flush stopped early");
246                    return Ok(all);
247                }
248            }
249        }
250        if all.is_empty() {
251            return Err(MemtableError::NoFrozenMemtable);
252        }
253        Ok(all)
254    }
255
256    /// The newest shard a write has been admitted to, if any.
257    ///
258    /// The out-of-order window is anchored here rather than at the wall
259    /// clock: a device that was offline for days and resumes with old
260    /// timestamps is still writing "now" as far as the engine is concerned.
261    #[must_use]
262    pub fn active_shard(&self) -> Option<ShardId> {
263        *self.active_shard.read()
264    }
265
266    /// Anchor the out-of-order window at `shard` if no write has anchored
267    /// it yet.
268    ///
269    /// `open()` seeds it from the newest timestamp in the catalog, so the
270    /// window a database enforces after a clean restart is the one it
271    /// enforced before — and so the rollup materialiser knows which buckets
272    /// are final without waiting for the first live write.
273    pub fn seed_active_shard(&self, shard: ShardId) {
274        let mut active = self.active_shard.write();
275        if active.is_none_or(|a| shard.0 > a.0) {
276            *active = Some(shard);
277        }
278    }
279
280    /// Drop the router entries of shards that hold no data and can no
281    /// longer receive live writes — everything below the out-of-order
282    /// window. They are recreated lazily if a backfill reaches them.
283    ///
284    /// Without this a long-running database accumulated one empty
285    /// controller per hour of uptime, forever.
286    pub fn retire_idle_shards(&self) {
287        let Some(active) = self.active_shard() else {
288            return;
289        };
290        let oldest_live = active.0.saturating_sub(self.config.ooo_shard_tolerance);
291        let mut shards = self.shards.write();
292        shards.retain(|id, entry| id.0 >= oldest_live || !entry.controller.is_idle());
293    }
294
295    /// Every shard the router currently holds a memtable for.
296    #[must_use]
297    pub fn shard_ids(&self) -> Vec<ShardId> {
298        self.shards.read().keys().copied().collect()
299    }
300
301    /// The canonical form of every series held in memory, across every
302    /// shard's active and frozen memtables.
303    ///
304    /// Read by the cardinality repair that runs after retention or GC drops
305    /// segments: what the database *holds* is the segments on disk plus
306    /// this, and anything else left in the budget is a series whose data is
307    /// gone.
308    #[must_use]
309    pub fn live_series(&self) -> std::collections::HashSet<String> {
310        let mut out = std::collections::HashSet::new();
311        for entry in self.shards.read().values() {
312            for key in entry.controller.all_series_canonical_forms() {
313                out.insert(key.to_string());
314            }
315        }
316        out
317    }
318
319    /// Every `(tag key, tag value)` pair held in memory, across every shard.
320    ///
321    /// The memtable half of label discovery — see
322    /// [`Memtable::visit_tags`](crate::memtable::memtable::Memtable::visit_tags).
323    #[must_use]
324    pub fn live_tag_pairs(&self) -> std::collections::BTreeSet<(String, String)> {
325        let mut out = std::collections::BTreeSet::new();
326        let mut visit = |key: &str, value: &str| {
327            out.insert((key.to_owned(), value.to_owned()));
328        };
329        for entry in self.shards.read().values() {
330            entry.controller.visit_tags(&mut visit);
331        }
332        out
333    }
334
335    /// Returns the total memory usage across all shards.
336    #[must_use]
337    pub fn total_memory(&self) -> usize {
338        self.shards
339            .read()
340            .values()
341            .map(|e| e.controller.total_memory())
342            .sum()
343    }
344
345    /// Heap bytes held by every memtable's string interner.
346    ///
347    /// Not included in [`total_memory`](Self::total_memory), which counts rows
348    /// and index entries. The interner grows with cardinality rather than with
349    /// row count, so it is worth its own number.
350    #[must_use]
351    pub fn total_interner_memory(&self) -> usize {
352        self.shards
353            .read()
354            .values()
355            .map(|e| e.controller.total_interner_memory())
356            .sum()
357    }
358
359    /// The smallest WAL sequence number held by any memtable — active or
360    /// frozen, in any shard — that is not yet in a registered segment.
361    /// `None` when every memtable is empty.
362    #[must_use]
363    pub fn min_unflushed_wal_seq(&self) -> Option<u64> {
364        self.shards
365            .read()
366            .values()
367            .filter_map(|e| e.controller.min_unflushed_wal_seq())
368            .min()
369    }
370
371    /// Scan a specific series across all shards within a time range.
372    #[must_use]
373    pub fn scan(
374        &self,
375        series_key: &chronix_core::SeriesKey,
376        min_ts: Timestamp,
377        max_ts: Timestamp,
378    ) -> Vec<Point> {
379        // Snapshot controller Arcs under the lock, then release before scanning.
380        let controllers: Vec<Arc<FlushController>> = self
381            .shards
382            .read()
383            .values()
384            .map(|e| Arc::clone(&e.controller))
385            .collect();
386
387        let mut all_points = Vec::new();
388        for ctrl in &controllers {
389            all_points.extend(ctrl.scan(series_key, min_ts, max_ts));
390        }
391
392        // Sort by timestamp
393        all_points.sort_by_key(Point::timestamp);
394        all_points
395    }
396
397    /// Scan all points belonging to a specific measurement across all shards.
398    ///
399    /// Returns points sorted by timestamp for the given measurement,
400    /// filtered to `[min_ts, max_ts]`.
401    #[must_use]
402    pub fn scan_measurement(
403        &self,
404        measurement: &str,
405        min_ts: Timestamp,
406        max_ts: Timestamp,
407    ) -> Vec<Point> {
408        // Snapshot controller Arcs under the lock, then release before scanning.
409        let controllers: Vec<Arc<FlushController>> = self
410            .shards
411            .read()
412            .values()
413            .map(|e| Arc::clone(&e.controller))
414            .collect();
415
416        let mut all_points = Vec::new();
417        for ctrl in &controllers {
418            all_points.extend(ctrl.scan_measurement(measurement, min_ts, max_ts));
419        }
420
421        // Sort by timestamp
422        all_points.sort_by_key(Point::timestamp);
423        all_points
424    }
425
426    /// Map a timestamp to its shard ID.
427    #[must_use]
428    pub fn shard_for_timestamp(&self, timestamp: Timestamp) -> ShardId {
429        ShardId::from_timestamp(timestamp, self.config.shard_duration)
430    }
431
432    /// Check if a shard is within the tolerance window.
433    /// `timestamp` is carried through only so the rejection can name the
434    /// value the caller actually wrote. Reporting the shard's *start* instead
435    /// hands back a number the caller never sent.
436    fn ensure_shard_allowed(&self, shard_id: ShardId, timestamp: Timestamp) -> Result<()> {
437        let mut active_guard = self.active_shard.write();
438
439        let active = if let Some(active) = *active_guard {
440            // Update active shard if this is a newer shard
441            if shard_id.0 > active.0 {
442                *active_guard = Some(shard_id);
443                shard_id
444            } else {
445                active
446            }
447        } else {
448            *active_guard = Some(shard_id);
449            shard_id
450        };
451
452        let min_allowed = active.0.saturating_sub(self.config.ooo_shard_tolerance);
453        let max_allowed = active.0.saturating_add(self.config.ooo_shard_tolerance);
454
455        if shard_id.0 < min_allowed || shard_id.0 > max_allowed {
456            return Err(MemtableError::ShardOutOfRange {
457                timestamp,
458                earliest: ShardId(min_allowed).start_timestamp(self.config.shard_duration),
459                latest: ShardId(max_allowed).end_timestamp(self.config.shard_duration),
460                tolerance: self.config.ooo_shard_tolerance,
461                shard_secs: self.config.shard_duration.as_secs(),
462            });
463        }
464
465        Ok(())
466    }
467
468    /// Obtain a reference to the shard entry, creating it if needed.
469    /// Calls `f` with the shard's `FlushController`.
470    fn with_shard<F, R>(&self, shard_id: ShardId, f: F) -> Result<R>
471    where
472        F: FnOnce(&FlushController) -> Result<R>,
473    {
474        // Fast path: read lock
475        {
476            let shards = self.shards.read();
477            if let Some(entry) = shards.get(&shard_id) {
478                return f(&entry.controller);
479            }
480        }
481
482        // Slow path: create shard under write lock
483        let mut shards = self.shards.write();
484        let entry = shards.entry(shard_id).or_insert_with(|| {
485            let mut config = self.config.flush_config.clone();
486            config.segment_dir = config.segment_dir.join(format!("shard_{}", shard_id.0));
487            ShardEntry {
488                controller: Arc::new(FlushController::new(config)),
489            }
490        });
491        f(&entry.controller)
492    }
493
494    /// Internal: insert a point into the specified shard.
495    fn insert_into_shard(&self, shard_id: ShardId, point: &Point) -> Result<()> {
496        self.with_shard(shard_id, |ctrl| ctrl.insert(point))
497    }
498
499    /// Internal: insert a point with WAL seq into the specified shard.
500    fn insert_into_shard_with_wal_seq(
501        &self,
502        shard_id: ShardId,
503        point: &Point,
504        wal_seq: u64,
505    ) -> Result<()> {
506        self.with_shard(shard_id, |ctrl| ctrl.insert_with_wal_seq(point, wal_seq))
507    }
508}
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use std::collections::BTreeMap as StdBTreeMap;
513
514    use crate::segment::writer::SegmentWriterConfig;
515    use chronix_core::types::{FieldValue, SeriesKey};
516
517    fn make_point(ts: Timestamp, value: f64) -> Point {
518        make_point_with_measurement("cpu", ts, value)
519    }
520
521    fn make_point_with_measurement(measurement: &str, ts: Timestamp, value: f64) -> Point {
522        let tags: StdBTreeMap<String, String> = [("host".to_string(), "a".to_string())]
523            .into_iter()
524            .collect();
525        let series_key = SeriesKey::new(measurement.to_string(), tags).unwrap();
526        let fields: StdBTreeMap<String, FieldValue> =
527            [("value".to_string(), FieldValue::F64(value))]
528                .into_iter()
529                .collect();
530        Point::new(series_key, fields, ts).unwrap()
531    }
532
533    fn test_router_config(dir: &std::path::Path) -> ShardRouterConfig {
534        ShardRouterConfig {
535            shard_duration: Duration::from_secs(3600),
536            ooo_shard_tolerance: 1,
537            flush_config: FlushConfig {
538                flush_threshold: 64 * 1024 * 1024,
539                max_memory: 256 * 1024 * 1024,
540                segment_dir: dir.to_path_buf(),
541                segment_writer_config: SegmentWriterConfig {
542                    compress: false,
543                    ..Default::default()
544                },
545                ..Default::default()
546            },
547        }
548    }
549
550    /// Nanoseconds per hour, matching `shard_duration`.
551    const NS_PER_HOUR: i64 = 3_600_000_000_000;
552
553    #[test]
554    fn routes_to_correct_shard() {
555        let dir = tempfile::tempdir().unwrap();
556        let router = ShardRouter::new(test_router_config(dir.path()));
557
558        // Shard 0: timestamps [0, NS_PER_HOUR)
559        let p1 = make_point(100, 1.0);
560        router.insert(&p1).unwrap();
561
562        assert_eq!(router.shard_ids().len(), 1);
563        assert_eq!(router.shard_ids()[0], ShardId(0));
564    }
565
566    #[test]
567    fn routes_to_multiple_shards() {
568        let dir = tempfile::tempdir().unwrap();
569        let router = ShardRouter::new(test_router_config(dir.path()));
570
571        // Shard 0
572        router.insert(&make_point(100, 1.0)).unwrap();
573        // Shard 1
574        router.insert(&make_point(NS_PER_HOUR + 100, 2.0)).unwrap();
575
576        let shard_ids = router.shard_ids();
577        assert_eq!(shard_ids.len(), 2);
578    }
579
580    #[test]
581    fn ooo_within_tolerance_accepted() {
582        let dir = tempfile::tempdir().unwrap();
583        let router = ShardRouter::new(test_router_config(dir.path()));
584
585        // Write to shard 1 first (sets active shard)
586        router.insert(&make_point(NS_PER_HOUR + 100, 1.0)).unwrap();
587
588        // Write to shard 0 (previous — within tolerance of 1)
589        router.insert(&make_point(100, 2.0)).unwrap();
590
591        assert_eq!(router.shard_ids().len(), 2);
592    }
593
594    #[test]
595    fn ooo_beyond_tolerance_rejected() {
596        let dir = tempfile::tempdir().unwrap();
597        let mut config = test_router_config(dir.path());
598        config.ooo_shard_tolerance = 0; // no tolerance
599        let router = ShardRouter::new(config);
600
601        // Write to shard 1 (sets active)
602        router.insert(&make_point(NS_PER_HOUR + 100, 1.0)).unwrap();
603
604        // Write to shard 0 (previous — beyond tolerance of 0)
605        let result = router.insert(&make_point(100, 2.0));
606        assert!(result.is_err());
607    }
608
609    #[test]
610    fn flush_shard() {
611        let dir = tempfile::tempdir().unwrap();
612        let router = ShardRouter::new(test_router_config(dir.path()));
613
614        for i in 0..10 {
615            router.insert(&make_point(i * 100, i as f64)).unwrap();
616        }
617
618        let shard_id = ShardId(0);
619        let results = router.flush_shard(shard_id).unwrap();
620        assert_eq!(results.len(), 1);
621        assert_eq!(results[0].measurement, "cpu");
622        assert_eq!(results[0].points_flushed, 10);
623    }
624
625    #[test]
626    fn scan_across_shards() {
627        let dir = tempfile::tempdir().unwrap();
628        let router = ShardRouter::new(test_router_config(dir.path()));
629
630        // Data in shard 0
631        router.insert(&make_point(100, 1.0)).unwrap();
632        // Data in shard 1
633        router.insert(&make_point(NS_PER_HOUR + 100, 2.0)).unwrap();
634
635        let key = SeriesKey::new(
636            "cpu".to_string(),
637            [("host".to_string(), "a".to_string())]
638                .into_iter()
639                .collect(),
640        )
641        .unwrap();
642
643        let points = router.scan(&key, 0, NS_PER_HOUR * 2);
644        assert_eq!(points.len(), 2);
645        assert!(points[0].timestamp() < points[1].timestamp());
646    }
647
648    #[test]
649    fn total_memory_across_shards() {
650        let dir = tempfile::tempdir().unwrap();
651        let router = ShardRouter::new(test_router_config(dir.path()));
652
653        router.insert(&make_point(100, 1.0)).unwrap();
654        router.insert(&make_point(NS_PER_HOUR + 100, 2.0)).unwrap();
655
656        assert!(router.total_memory() > 0);
657    }
658
659    #[test]
660    fn shard_for_timestamp_computation() {
661        let dir = tempfile::tempdir().unwrap();
662        let router = ShardRouter::new(test_router_config(dir.path()));
663
664        assert_eq!(router.shard_for_timestamp(0), ShardId(0));
665        assert_eq!(router.shard_for_timestamp(NS_PER_HOUR), ShardId(1));
666        assert_eq!(router.shard_for_timestamp(NS_PER_HOUR - 1), ShardId(0));
667    }
668
669    #[test]
670    fn scan_measurement_across_shards() {
671        let dir = tempfile::tempdir().unwrap();
672        let router = ShardRouter::new(test_router_config(dir.path()));
673
674        // Shard 0: cpu and mem points
675        router
676            .insert(&make_point_with_measurement("cpu", 100, 1.0))
677            .unwrap();
678        router
679            .insert(&make_point_with_measurement("mem", 200, 2.0))
680            .unwrap();
681        // Shard 1: cpu point
682        router
683            .insert(&make_point_with_measurement("cpu", NS_PER_HOUR + 100, 3.0))
684            .unwrap();
685
686        // Scan cpu across all shards
687        let cpu = router.scan_measurement("cpu", 0, i64::MAX);
688        assert_eq!(cpu.len(), 2);
689        assert!(cpu.iter().all(|p| p.series_key().measurement() == "cpu"));
690        assert!(cpu[0].timestamp() < cpu[1].timestamp()); // sorted
691
692        // Scan mem
693        let mem = router.scan_measurement("mem", 0, i64::MAX);
694        assert_eq!(mem.len(), 1);
695
696        // Scan non-existent
697        let none = router.scan_measurement("disk", 0, i64::MAX);
698        assert!(none.is_empty());
699    }
700
701    #[test]
702    fn scan_measurement_respects_time_range() {
703        let dir = tempfile::tempdir().unwrap();
704        let router = ShardRouter::new(test_router_config(dir.path()));
705
706        router
707            .insert(&make_point_with_measurement("cpu", 100, 1.0))
708            .unwrap();
709        router
710            .insert(&make_point_with_measurement("cpu", 200, 2.0))
711            .unwrap();
712        router
713            .insert(&make_point_with_measurement("cpu", 300, 3.0))
714            .unwrap();
715
716        let result = router.scan_measurement("cpu", 150, 250);
717        assert_eq!(result.len(), 1);
718        assert_eq!(result[0].timestamp(), 200);
719    }
720}