Skip to main content

reddb_server/storage/timeseries/
hypertable.rs

1//! Hypertables — time-range-partitioned tables à la TimescaleDB.
2//!
3//! A hypertable is a logical collection that auto-partitions writes
4//! into child chunks, each covering a fixed time interval
5//! (`chunk_interval_ns`). Queries that filter by the time column
6//! see the partition pruner eliminate child chunks whose bounds
7//! fall outside the predicate; drops happen per-chunk so operators
8//! can retain the last N days without a full-table scan.
9//!
10//! This module defines the **metadata + router**. The physical
11//! chunk is the same [`super::chunk::TimeSeriesChunk`] the standalone
12//! time-series path already uses — the hypertable layer just tracks
13//! which chunk a row goes into.
14//!
15//! SQL surface (parsed elsewhere in the sprint):
16//!
17//! ```sql
18//! CREATE HYPERTABLE metrics (
19//!   ts    BIGINT,
20//!   host  TEXT,
21//!   value DOUBLE
22//! ) CHUNK INTERVAL '1 day';
23//!
24//! SELECT drop_chunks('metrics', INTERVAL '90 days');
25//! SELECT show_chunks('metrics');
26//! ```
27
28use std::collections::BTreeMap;
29use std::sync::{Arc, Mutex};
30
31use super::chunk::{
32    points_from_column_block, COLUMNAR_TS_COLUMN_ID, COLUMNAR_VALUE_COLUMN_ID, DEFAULT_GRANULE_SIZE,
33};
34use super::retention::parse_duration_ns;
35use crate::storage::engine::PageLocation;
36use crate::storage::schema::types::DataType;
37use crate::storage::unified::column_block::{write_column_block, ColumnBlockError, ColumnInput};
38use crate::storage::unified::segment_codec::ColumnSemantics;
39
40/// Spec declared by `CREATE HYPERTABLE`.
41#[derive(Debug, Clone)]
42pub struct HypertableSpec {
43    pub name: String,
44    /// Column name that carries the time axis (must be unix-ns BIGINT
45    /// or parseable to one).
46    pub time_column: String,
47    /// Fixed width of a single chunk, in nanoseconds.
48    pub chunk_interval_ns: u64,
49    /// Default TTL applied to every new chunk when the DDL didn't
50    /// request an explicit override. `None` means "no TTL — chunks
51    /// live until explicit `drop_chunks` / retention policy fires".
52    ///
53    /// The effective expiry of a chunk is `max_ts_ns + ttl_ns`, so
54    /// a chunk is safely droppable once `now_ns ≥ expiry`. That
55    /// matches the contract callers already learnt from the
56    /// retention daemon — partition TTL is the **declarative** way
57    /// to say the same thing at CREATE time without a separate
58    /// `add_retention_policy` call.
59    pub default_ttl_ns: Option<u64>,
60}
61
62impl HypertableSpec {
63    pub fn new(
64        name: impl Into<String>,
65        time_column: impl Into<String>,
66        chunk_interval_ns: u64,
67    ) -> Self {
68        Self {
69            name: name.into(),
70            time_column: time_column.into(),
71            chunk_interval_ns: chunk_interval_ns.max(1),
72            default_ttl_ns: None,
73        }
74    }
75
76    /// Convenience: construct from a Timescale-style duration string
77    /// (`"1d"`, `"1h"`, `"30m"`…).
78    pub fn from_interval_string(
79        name: impl Into<String>,
80        time_column: impl Into<String>,
81        interval: &str,
82    ) -> Option<Self> {
83        let ns = parse_duration_ns(interval)?;
84        if ns == 0 {
85            return None;
86        }
87        Some(Self::new(name, time_column, ns))
88    }
89
90    /// Builder-style: attach a default TTL. Uses the same duration
91    /// grammar as `chunk_interval` (`"90d"`, `"30s"`, …).
92    pub fn with_ttl(mut self, ttl: &str) -> Option<Self> {
93        let ns = parse_duration_ns(ttl)?;
94        if ns == 0 {
95            return None;
96        }
97        self.default_ttl_ns = Some(ns);
98        Some(self)
99    }
100
101    /// Direct setter when the TTL is already computed in ns.
102    pub fn with_ttl_ns(mut self, ttl_ns: u64) -> Self {
103        self.default_ttl_ns = if ttl_ns == 0 { None } else { Some(ttl_ns) };
104        self
105    }
106
107    /// Align `timestamp_ns` to the chunk's floor — the chunk that
108    /// row belongs to starts at this timestamp and covers
109    /// `[start, start + chunk_interval_ns)`.
110    pub fn chunk_start(&self, timestamp_ns: u64) -> u64 {
111        (timestamp_ns / self.chunk_interval_ns) * self.chunk_interval_ns
112    }
113
114    pub fn chunk_end_exclusive(&self, timestamp_ns: u64) -> u64 {
115        self.chunk_start(timestamp_ns)
116            .saturating_add(self.chunk_interval_ns)
117    }
118}
119
120/// Identifier of a single child chunk. Stable across restart so
121/// catalog + retention can reference it unambiguously.
122#[derive(Debug, Clone, PartialEq, Eq, Hash)]
123pub struct ChunkId {
124    pub hypertable: String,
125    /// Chunk start (inclusive), aligned to `chunk_interval_ns`.
126    pub start_ns: u64,
127}
128
129/// On-disk storage format of a chunk — the **read-bridge dispatch key**
130/// (PRD #850 Phase 1, #861). After `COLUMNAR` is enabled on a collection
131/// that already holds row data, pre-existing chunks stay `Row` and new
132/// chunks seal `ColumnarV1`; the two coexist in the same collection and a
133/// read dispatches on this discriminant — `Row` to the entity/row reader,
134/// `ColumnarV1` to the RDCC column-block reader — with no mass rewrite.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum ChunkFormat {
137    /// Legacy row-stored chunk (`columnar_page == None`): it predates the
138    /// columnar seal, or sealed while the collection was non-columnar. Its
139    /// rows are served from the entity/row path.
140    Row,
141    /// Columnar `RDCC` chunk, format version 1 (`columnar_page == Some`).
142    /// Its rows decode from the recorded `ColumnBlock`.
143    ColumnarV1,
144}
145
146/// Metadata tracked per child chunk. Physical storage lives in
147/// `TimeSeriesChunk` keyed by `(hypertable, start_ns)`.
148#[derive(Debug, Clone)]
149pub struct ChunkMeta {
150    pub id: ChunkId,
151    pub end_ns_exclusive: u64,
152    pub row_count: u64,
153    pub min_ts_ns: u64,
154    pub max_ts_ns: u64,
155    pub sealed: bool,
156    /// Optional per-chunk TTL override. `None` means "fall back to
157    /// the hypertable's default TTL". Setting this lets mixed-TTL
158    /// policies live inside the same hypertable — e.g. keep the
159    /// current month of data forever but expire everything older
160    /// than 90 days.
161    pub ttl_override_ns: Option<u64>,
162    /// Columnar-vs-row **migration discriminant** (PRD #850, Phase 1).
163    /// `Some(loc)` → this chunk was sealed columnar; its `RDCC`
164    /// [`ColumnBlock`](crate::storage::engine::PageType::ColumnBlock) lives
165    /// at `loc` and reads decode the columnar form. `None` → a legacy
166    /// row-stored chunk served by the entity path (read-bridge lands in
167    /// #861). This MUST persist so pre-existing row-stored data is never
168    /// mis-read as columnar after a restart.
169    pub columnar_page: Option<PageLocation>,
170}
171
172impl ChunkMeta {
173    pub fn new(id: ChunkId, end_ns_exclusive: u64) -> Self {
174        Self {
175            id,
176            end_ns_exclusive,
177            row_count: 0,
178            min_ts_ns: u64::MAX,
179            max_ts_ns: 0,
180            sealed: false,
181            ttl_override_ns: None,
182            columnar_page: None,
183        }
184    }
185
186    /// The chunk's storage format — the read-bridge dispatch key (#861).
187    /// Derived from the migration discriminant `columnar_page`: a recorded
188    /// RDCC `ColumnBlock` location means [`ChunkFormat::ColumnarV1`], its
189    /// absence means the legacy [`ChunkFormat::Row`] form. This is the
190    /// format-version gate that lets old row chunks and new columnar chunks
191    /// coexist in one collection without a rewrite.
192    pub fn format(&self) -> ChunkFormat {
193        match self.columnar_page {
194            Some(_) => ChunkFormat::ColumnarV1,
195            None => ChunkFormat::Row,
196        }
197    }
198
199    /// True when this chunk is stored in the columnar `RDCC` form.
200    pub fn is_columnar(&self) -> bool {
201        matches!(self.format(), ChunkFormat::ColumnarV1)
202    }
203
204    pub fn observe(&mut self, ts_ns: u64) {
205        self.row_count += 1;
206        if ts_ns < self.min_ts_ns {
207            self.min_ts_ns = ts_ns;
208        }
209        if ts_ns > self.max_ts_ns {
210            self.max_ts_ns = ts_ns;
211        }
212    }
213
214    /// Effective TTL = per-chunk override if present, otherwise the
215    /// hypertable default. `None` = the chunk has no automatic
216    /// expiry.
217    pub fn effective_ttl_ns(&self, default_ttl_ns: Option<u64>) -> Option<u64> {
218        self.ttl_override_ns.or(default_ttl_ns)
219    }
220
221    /// Absolute epoch-ns at which the chunk becomes droppable. Uses
222    /// `max_ts_ns` as the baseline — the newest row the chunk has
223    /// ever accepted — so an empty chunk (no rows yet) never
224    /// expires until at least one row lands.
225    pub fn expiry_ns(&self, default_ttl_ns: Option<u64>) -> Option<u64> {
226        let ttl = self.effective_ttl_ns(default_ttl_ns)?;
227        if self.row_count == 0 {
228            return None;
229        }
230        Some(self.max_ts_ns.saturating_add(ttl))
231    }
232
233    pub fn is_expired_at(&self, now_ns: u64, default_ttl_ns: Option<u64>) -> bool {
234        match self.expiry_ns(default_ttl_ns) {
235            Some(expiry) => now_ns >= expiry,
236            None => false,
237        }
238    }
239}
240
241/// In-memory catalog of hypertables and their chunks. Thread-safe
242/// because INSERTs can land from multiple writers simultaneously.
243#[derive(Clone, Default)]
244pub struct HypertableRegistry {
245    inner: Arc<Mutex<RegistryInner>>,
246}
247
248#[derive(Default)]
249struct RegistryInner {
250    specs: BTreeMap<String, HypertableSpec>,
251    /// `(hypertable, start_ns)` → chunk meta. `BTreeMap` so lookups
252    /// by name produce an ordered view (show_chunks must be
253    /// deterministic).
254    chunks: BTreeMap<(String, u64), ChunkMeta>,
255    /// `(hypertable, start_ns)` → the sealed chunk's RDCC `ColumnBlock`
256    /// bytes, populated by [`seal_chunk_columnar`](HypertableRegistry::seal_chunk_columnar)
257    /// during seal and by [`restore_columnar_block`](HypertableRegistry::restore_columnar_block)
258    /// during boot after reading the recorded engine page.
259    columnar_blocks: BTreeMap<(String, u64), Vec<u8>>,
260}
261
262impl std::fmt::Debug for HypertableRegistry {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        let guard = match self.inner.lock() {
265            Ok(g) => g,
266            Err(p) => p.into_inner(),
267        };
268        f.debug_struct("HypertableRegistry")
269            .field("hypertables", &guard.specs.len())
270            .field("chunks", &guard.chunks.len())
271            .finish()
272    }
273}
274
275impl HypertableRegistry {
276    pub fn new() -> Self {
277        Self::default()
278    }
279
280    /// Register a new hypertable. Replaces the previous spec if one
281    /// existed with the same name; chunks for that name are kept —
282    /// the operator is assumed to know what they're doing when they
283    /// redefine (e.g. widening `chunk_interval_ns`).
284    pub fn register(&self, spec: HypertableSpec) {
285        let mut guard = match self.inner.lock() {
286            Ok(g) => g,
287            Err(p) => p.into_inner(),
288        };
289        guard.specs.insert(spec.name.clone(), spec);
290    }
291
292    pub fn get(&self, name: &str) -> Option<HypertableSpec> {
293        let guard = match self.inner.lock() {
294            Ok(g) => g,
295            Err(p) => p.into_inner(),
296        };
297        guard.specs.get(name).cloned()
298    }
299
300    pub fn list(&self) -> Vec<HypertableSpec> {
301        let guard = match self.inner.lock() {
302            Ok(g) => g,
303            Err(p) => p.into_inner(),
304        };
305        guard.specs.values().cloned().collect()
306    }
307
308    /// Drop a hypertable from the registry. Returns the removed spec
309    /// when present, or `None` for unknown names. Callers drop the
310    /// backing collection separately — this is registry housekeeping
311    /// only.
312    pub fn unregister(&self, name: &str) -> Option<HypertableSpec> {
313        let mut guard = match self.inner.lock() {
314            Ok(g) => g,
315            Err(p) => p.into_inner(),
316        };
317        guard.specs.remove(name)
318    }
319
320    /// Route a write: returns the `ChunkId` the row belongs in,
321    /// allocating the chunk on first write. `None` when the
322    /// hypertable is unknown.
323    pub fn route(&self, hypertable: &str, timestamp_ns: u64) -> Option<ChunkId> {
324        let mut guard = match self.inner.lock() {
325            Ok(g) => g,
326            Err(p) => p.into_inner(),
327        };
328        let spec = guard.specs.get(hypertable)?.clone();
329        let start = spec.chunk_start(timestamp_ns);
330        let end = spec.chunk_end_exclusive(timestamp_ns);
331        let id = ChunkId {
332            hypertable: spec.name.clone(),
333            start_ns: start,
334        };
335        let key = (spec.name.clone(), start);
336        let meta = guard
337            .chunks
338            .entry(key)
339            .or_insert_with(|| ChunkMeta::new(id.clone(), end));
340        meta.observe(timestamp_ns);
341        Some(id)
342    }
343
344    /// Return every chunk for `hypertable`, oldest-first.
345    pub fn show_chunks(&self, hypertable: &str) -> Vec<ChunkMeta> {
346        let guard = match self.inner.lock() {
347            Ok(g) => g,
348            Err(p) => p.into_inner(),
349        };
350        guard
351            .chunks
352            .iter()
353            .filter(|((name, _), _)| name == hypertable)
354            .map(|(_, meta)| meta.clone())
355            .collect()
356    }
357
358    /// Drop every chunk of `hypertable` whose `max_ts_ns` is at or
359    /// below `cutoff_ns`. Returns the count dropped — the physical
360    /// storage release is the caller's responsibility (this module
361    /// only owns the metadata).
362    pub fn drop_chunks_before(&self, hypertable: &str, cutoff_ns: u64) -> Vec<ChunkMeta> {
363        let mut guard = match self.inner.lock() {
364            Ok(g) => g,
365            Err(p) => p.into_inner(),
366        };
367        let mut dropped = Vec::new();
368        let keys: Vec<(String, u64)> = guard
369            .chunks
370            .iter()
371            .filter(|((name, _), meta)| name == hypertable && meta.max_ts_ns <= cutoff_ns)
372            .map(|(k, _)| k.clone())
373            .collect();
374        for key in keys {
375            if let Some(meta) = guard.chunks.remove(&key) {
376                dropped.push(meta);
377            }
378        }
379        dropped
380    }
381
382    /// Sweep chunks whose effective TTL has fired. A chunk is
383    /// droppable when `now_ns ≥ max_ts_ns + effective_ttl_ns` — the
384    /// registry hands back every removed `ChunkMeta` so the
385    /// physical-storage callback can release bytes + indexes. Chunks
386    /// without an effective TTL (neither per-chunk override nor
387    /// hypertable default) are never touched.
388    ///
389    /// This is the "TTL applied at the partition level" primitive:
390    /// one O(1) metadata sweep reclaims every row of every expired
391    /// chunk, instead of scanning rows individually like an
392    /// entity-level TTL would. Empty hypertables stay empty.
393    pub fn sweep_expired(&self, hypertable: &str, now_ns: u64) -> Vec<ChunkMeta> {
394        let mut guard = match self.inner.lock() {
395            Ok(g) => g,
396            Err(p) => p.into_inner(),
397        };
398        let Some(spec) = guard.specs.get(hypertable).cloned() else {
399            return Vec::new();
400        };
401        let expired_keys: Vec<(String, u64)> = guard
402            .chunks
403            .iter()
404            .filter(|((name, _), meta)| {
405                name == hypertable && meta.is_expired_at(now_ns, spec.default_ttl_ns)
406            })
407            .map(|(k, _)| k.clone())
408            .collect();
409        let mut dropped = Vec::with_capacity(expired_keys.len());
410        for key in expired_keys {
411            if let Some(meta) = guard.chunks.remove(&key) {
412                dropped.push(meta);
413            }
414        }
415        dropped
416    }
417
418    /// Sweep every registered hypertable in one shot — the loop the
419    /// retention daemon runs every cycle. Returns a flat list of
420    /// `(hypertable_name, chunk_dropped)` pairs.
421    pub fn sweep_all_expired(&self, now_ns: u64) -> Vec<(String, ChunkMeta)> {
422        let names: Vec<String> = {
423            let guard = match self.inner.lock() {
424                Ok(g) => g,
425                Err(p) => p.into_inner(),
426            };
427            guard.specs.keys().cloned().collect()
428        };
429        let mut out = Vec::new();
430        for name in names {
431            for meta in self.sweep_expired(&name, now_ns) {
432                out.push((name.clone(), meta));
433            }
434        }
435        out
436    }
437
438    /// Install / replace the hypertable-wide default TTL. `None`
439    /// disables automatic expiry — chunks live until explicit
440    /// `drop_chunks` / per-chunk override fires.
441    pub fn set_default_ttl_ns(&self, hypertable: &str, ttl_ns: Option<u64>) -> bool {
442        let mut guard = match self.inner.lock() {
443            Ok(g) => g,
444            Err(p) => p.into_inner(),
445        };
446        match guard.specs.get_mut(hypertable) {
447            Some(spec) => {
448                spec.default_ttl_ns = match ttl_ns {
449                    Some(0) | None => None,
450                    Some(v) => Some(v),
451                };
452                true
453            }
454            None => false,
455        }
456    }
457
458    /// Override the TTL for a single chunk. Useful for "keep this
459    /// specific chunk longer because it contains an incident
460    /// replay" or "expire this one faster because it was filled
461    /// from a backfill we're about to redo". `None` removes the
462    /// override and falls back to the hypertable default.
463    pub fn set_chunk_ttl_ns(&self, id: &ChunkId, ttl_ns: Option<u64>) -> bool {
464        let mut guard = match self.inner.lock() {
465            Ok(g) => g,
466            Err(p) => p.into_inner(),
467        };
468        if let Some(meta) = guard.chunks.get_mut(&(id.hypertable.clone(), id.start_ns)) {
469            meta.ttl_override_ns = ttl_ns;
470            true
471        } else {
472            false
473        }
474    }
475
476    /// Inspect the list of chunks that are *about* to expire within
477    /// `horizon_ns`. Powers preview endpoints ("what will the next
478    /// sweep drop?") without actually dropping anything.
479    pub fn chunks_expiring_within(
480        &self,
481        hypertable: &str,
482        now_ns: u64,
483        horizon_ns: u64,
484    ) -> Vec<ChunkMeta> {
485        let guard = match self.inner.lock() {
486            Ok(g) => g,
487            Err(p) => p.into_inner(),
488        };
489        let Some(spec) = guard.specs.get(hypertable).cloned() else {
490            return Vec::new();
491        };
492        let cutoff = now_ns.saturating_add(horizon_ns);
493        guard
494            .chunks
495            .iter()
496            .filter(|((name, _), _)| name == hypertable)
497            .filter_map(|(_, meta)| {
498                let expiry = meta.expiry_ns(spec.default_ttl_ns)?;
499                if expiry <= cutoff {
500                    Some(meta.clone())
501                } else {
502                    None
503                }
504            })
505            .collect()
506    }
507
508    /// Seal a chunk — future writes to the same `start_ns` bucket
509    /// will still land (open-ended), but the `sealed` flag signals
510    /// the maintenance layer that the chunk can now be compressed /
511    /// uploaded / migrated. Returns `true` if the chunk existed.
512    pub fn seal_chunk(&self, id: &ChunkId) -> bool {
513        let mut guard = match self.inner.lock() {
514            Ok(g) => g,
515            Err(p) => p.into_inner(),
516        };
517        if let Some(meta) = guard.chunks.get_mut(&(id.hypertable.clone(), id.start_ns)) {
518            meta.sealed = true;
519            true
520        } else {
521            false
522        }
523    }
524
525    /// Seal a chunk **columnar** (PRD #850, #911): mark it sealed, record
526    /// where its RDCC `ColumnBlock` lives in `ChunkMeta.columnar_page`,
527    /// and stash the block `bytes` so the columnar read path can decode
528    /// them. The columnar counterpart of [`seal_chunk`](Self::seal_chunk)
529    /// — the production caller (`seal_chunk_with_config`'s columnar arm)
530    /// hands the sealed bytes here. Returns `true` if the chunk existed.
531    pub fn seal_chunk_columnar(&self, id: &ChunkId, page: PageLocation, bytes: Vec<u8>) -> bool {
532        let mut guard = match self.inner.lock() {
533            Ok(g) => g,
534            Err(p) => p.into_inner(),
535        };
536        let key = (id.hypertable.clone(), id.start_ns);
537        if let Some(meta) = guard.chunks.get_mut(&key) {
538            meta.sealed = true;
539            meta.columnar_page = Some(page);
540            guard.columnar_blocks.insert(key, bytes);
541            true
542        } else {
543            false
544        }
545    }
546
547    /// Rehydrate a previously persisted RDCC block after boot has restored
548    /// the chunk metadata and read the recorded `ColumnBlock` page.
549    pub fn restore_columnar_block(&self, id: &ChunkId, bytes: Vec<u8>) -> bool {
550        let mut guard = match self.inner.lock() {
551            Ok(g) => g,
552            Err(p) => p.into_inner(),
553        };
554        let key = (id.hypertable.clone(), id.start_ns);
555        if guard
556            .chunks
557            .get(&key)
558            .is_some_and(|meta| meta.columnar_page.is_some())
559        {
560            guard.columnar_blocks.insert(key, bytes);
561            true
562        } else {
563            false
564        }
565    }
566
567    /// Fetch the RDCC `ColumnBlock` bytes recorded for a columnar-sealed
568    /// chunk by [`seal_chunk_columnar`](Self::seal_chunk_columnar). `None`
569    /// for row-sealed chunks or chunks whose durable page could not be
570    /// rehydrated.
571    pub fn columnar_block(&self, id: &ChunkId) -> Option<Vec<u8>> {
572        let guard = match self.inner.lock() {
573            Ok(g) => g,
574            Err(p) => p.into_inner(),
575        };
576        guard
577            .columnar_blocks
578            .get(&(id.hypertable.clone(), id.start_ns))
579            .cloned()
580    }
581
582    /// Total row count across every chunk of `hypertable`. Used by
583    /// catalog views + benchmark harnesses.
584    pub fn total_rows(&self, hypertable: &str) -> u64 {
585        let guard = match self.inner.lock() {
586            Ok(g) => g,
587            Err(p) => p.into_inner(),
588        };
589        guard
590            .chunks
591            .iter()
592            .filter(|((name, _), _)| name == hypertable)
593            .map(|(_, meta)| meta.row_count)
594            .sum()
595    }
596
597    /// List the hypertables the retention daemon should sweep.
598    pub fn names(&self) -> Vec<String> {
599        let guard = match self.inner.lock() {
600            Ok(g) => g,
601            Err(p) => p.into_inner(),
602        };
603        guard.specs.keys().cloned().collect()
604    }
605
606    /// True when no hypertable is registered and no chunk is tracked.
607    /// Lets the durability layer skip the persist step entirely for
608    /// workloads that never declared a hypertable (zero overhead).
609    pub fn is_empty(&self) -> bool {
610        let guard = match self.inner.lock() {
611            Ok(g) => g,
612            Err(p) => p.into_inner(),
613        };
614        guard.specs.is_empty() && guard.chunks.is_empty()
615    }
616
617    /// Snapshot every chunk across all hypertables, ordered by
618    /// `(hypertable, start_ns)` so the persisted form is deterministic.
619    /// Pairs with [`restore_chunk`] on boot. Specs are snapshotted via
620    /// [`list`].
621    pub fn snapshot_chunks(&self) -> Vec<ChunkMeta> {
622        let guard = match self.inner.lock() {
623            Ok(g) => g,
624            Err(p) => p.into_inner(),
625        };
626        guard.chunks.values().cloned().collect()
627    }
628
629    /// Reinstate a chunk verbatim during recovery. Overwrites any
630    /// existing entry for the same `(hypertable, start_ns)`.
631    ///
632    /// Unlike [`route`], this does **not** observe a timestamp — it
633    /// restores the persisted counters (`row_count`, `min_ts_ns`,
634    /// `max_ts_ns`, `sealed`, `ttl_override_ns`) exactly so the
635    /// post-restart registry is identical to the pre-restart one. The
636    /// caller is expected to [`register`] the owning spec first; a
637    /// chunk whose hypertable has no spec is still tracked (routing
638    /// falls back to the spec once it is registered), matching the
639    /// pre-restart invariant that chunks outlive a missing spec only
640    /// transiently.
641    pub fn restore_chunk(&self, meta: ChunkMeta) {
642        let mut guard = match self.inner.lock() {
643            Ok(g) => g,
644            Err(p) => p.into_inner(),
645        };
646        let key = (meta.id.hypertable.clone(), meta.id.start_ns);
647        guard.chunks.insert(key, meta);
648    }
649
650    /// Drop the whole hypertable (spec + every chunk). Returns the
651    /// number of chunks removed.
652    pub fn drop_hypertable(&self, name: &str) -> usize {
653        let mut guard = match self.inner.lock() {
654            Ok(g) => g,
655            Err(p) => p.into_inner(),
656        };
657        guard.specs.remove(name);
658        let keys: Vec<(String, u64)> = guard
659            .chunks
660            .keys()
661            .filter(|(n, _)| n == name)
662            .cloned()
663            .collect();
664        for key in &keys {
665            guard.chunks.remove(key);
666        }
667        keys.len()
668    }
669
670    /// Select groups of small sealed columnar chunks that are candidates
671    /// for compaction. Returns a list of groups; each group is a list of
672    /// `ChunkId`s that together hold at most `max_rows_total` rows. Groups
673    /// with fewer than `min_chunks` chunks are not returned (no-op merges).
674    ///
675    /// Chunks are considered in oldest-first order within each hypertable.
676    /// The caller decides the merge policy (threshold sizes, timing).
677    pub fn select_compaction_candidates(
678        &self,
679        hypertable: &str,
680        max_rows_per_group: u64,
681        min_chunks: usize,
682    ) -> Vec<Vec<ChunkId>> {
683        let guard = match self.inner.lock() {
684            Ok(g) => g,
685            Err(p) => p.into_inner(),
686        };
687        // Collect sealed columnar chunks oldest-first.
688        let candidates: Vec<&ChunkMeta> = guard
689            .chunks
690            .iter()
691            .filter(|((name, _), meta)| name == hypertable && meta.sealed && meta.is_columnar())
692            .map(|(_, meta)| meta)
693            .collect();
694
695        // Greedy bin-packing: accumulate into a group until adding the next
696        // chunk would exceed the row budget, then close the group.
697        let mut groups: Vec<Vec<ChunkId>> = Vec::new();
698        let mut current: Vec<ChunkId> = Vec::new();
699        let mut current_rows: u64 = 0;
700
701        for meta in candidates {
702            if !current.is_empty() && current_rows + meta.row_count > max_rows_per_group {
703                if current.len() >= min_chunks {
704                    groups.push(std::mem::take(&mut current));
705                } else {
706                    current.clear();
707                }
708                current_rows = 0;
709            }
710            current.push(meta.id.clone());
711            current_rows += meta.row_count;
712        }
713        if current.len() >= min_chunks {
714            groups.push(current);
715        }
716
717        groups
718    }
719
720    /// Merge N sealed columnar chunks into one larger sealed columnar chunk.
721    ///
722    /// # Crash safety
723    ///
724    /// The merge is computed entirely in memory before any registry state is
725    /// touched. The final registry update (insert merged entry + remove source
726    /// entries) happens atomically under one `Mutex` lock acquisition. If the
727    /// process is killed before the lock is taken, all source chunks remain
728    /// intact — no committed data is lost. A torn merge (crash mid-computation,
729    /// before the atomic commit) is safe because the registry never saw the
730    /// partial output.
731    ///
732    /// # Arguments
733    ///
734    /// * `hypertable` — name of the owning hypertable.
735    /// * `source_ids` — the `ChunkId`s of the chunks to merge (must all be
736    ///   sealed, columnar, and have their blocks RAM-resident).
737    /// * `merged_chunk_id` — the `chunk_id` header field for the output RDCC
738    ///   block; the caller assigns this (e.g. `min(source start_ns)` cast).
739    /// * `schema_ref` — the catalog schema id written into the output header.
740    /// * `granule_size` — sparse-granule-index stride for the merged block.
741    ///   Pass `DEFAULT_GRANULE_SIZE` for the standard 8 192-row marks.
742    ///
743    /// Returns the `ChunkId` of the newly inserted merged chunk on success.
744    pub fn compact_columnar_chunks(
745        &self,
746        hypertable: &str,
747        source_ids: &[ChunkId],
748        merged_chunk_id: u64,
749        schema_ref: u64,
750        granule_size: u32,
751    ) -> Result<ChunkId, CompactionError> {
752        if source_ids.len() < 2 {
753            return Err(CompactionError::InsufficientSources);
754        }
755
756        let mut guard = match self.inner.lock() {
757            Ok(g) => g,
758            Err(p) => p.into_inner(),
759        };
760
761        // --- Validate and collect source data (still holding the lock) ---
762
763        for id in source_ids {
764            let key = (id.hypertable.clone(), id.start_ns);
765            let meta = guard
766                .chunks
767                .get(&key)
768                .ok_or_else(|| CompactionError::ChunkNotFound(id.clone()))?;
769            if !meta.sealed {
770                return Err(CompactionError::ChunkNotSealed(id.clone()));
771            }
772            if !meta.is_columnar() {
773                return Err(CompactionError::ChunkNotColumnar(id.clone()));
774            }
775            if !guard.columnar_blocks.contains_key(&key) {
776                return Err(CompactionError::BlockNotResident(id.clone()));
777            }
778        }
779
780        // --- Decode all source blocks into points ---
781
782        let mut all_points = Vec::new();
783        for id in source_ids {
784            let key = (id.hypertable.clone(), id.start_ns);
785            let bytes = guard.columnar_blocks.get(&key).unwrap();
786            let pts = points_from_column_block(bytes).map_err(CompactionError::Decode)?;
787            all_points.extend(pts);
788        }
789
790        // Sort by timestamp — chunks are time-partitioned so they should not
791        // overlap, but we sort anyway to guarantee the output is ordered.
792        all_points.sort_by_key(|p| p.timestamp_ns);
793
794        // --- Build the merged RDCC block ---
795
796        let row_count = all_points.len() as u64;
797        let min_ts_ns = all_points.first().map(|p| p.timestamp_ns).unwrap_or(0);
798        let max_ts_ns = all_points.last().map(|p| p.timestamp_ns).unwrap_or(0);
799
800        let ts_bytes: Vec<u8> = all_points
801            .iter()
802            .flat_map(|p| p.timestamp_ns.to_le_bytes())
803            .collect();
804        let val_bytes: Vec<u8> = all_points
805            .iter()
806            .flat_map(|p| p.value.to_le_bytes())
807            .collect();
808
809        let merged_bytes = write_column_block(
810            merged_chunk_id,
811            schema_ref,
812            row_count,
813            min_ts_ns,
814            max_ts_ns,
815            granule_size,
816            &[
817                ColumnInput {
818                    column_id: COLUMNAR_TS_COLUMN_ID,
819                    logical_type: DataType::UnsignedInteger.to_byte(),
820                    semantics: ColumnSemantics::Timestamp,
821                    data: &ts_bytes,
822                },
823                ColumnInput {
824                    column_id: COLUMNAR_VALUE_COLUMN_ID,
825                    logical_type: DataType::Float.to_byte(),
826                    semantics: ColumnSemantics::Gauge,
827                    data: &val_bytes,
828                },
829            ],
830        )
831        .map_err(CompactionError::Encode)?;
832
833        // --- Derive merged chunk metadata ---
834
835        // The merged chunk spans the time range of all sources.
836        let merged_start_ns = source_ids.iter().map(|id| id.start_ns).min().unwrap(); // safe: source_ids.len() >= 2
837        let merged_end_ns_exclusive = source_ids
838            .iter()
839            .map(|id| {
840                guard
841                    .chunks
842                    .get(&(id.hypertable.clone(), id.start_ns))
843                    .map(|m| m.end_ns_exclusive)
844                    .unwrap_or(merged_start_ns)
845            })
846            .max()
847            .unwrap();
848
849        let merged_id = ChunkId {
850            hypertable: hypertable.to_string(),
851            start_ns: merged_start_ns,
852        };
853
854        // Synthetic page location: page_id=0, offset=0, length=block_len.
855        // Callers that write through a real Pager should instead call
856        // `seal_chunk_columnar` with the returned PageLocation from the pager
857        // write; this sentinel is correct for the RAM-only path used here and
858        // in tests.
859        let merged_page = PageLocation::new(0, 0, merged_bytes.len() as u32);
860
861        let mut merged_meta = ChunkMeta::new(merged_id.clone(), merged_end_ns_exclusive);
862        merged_meta.row_count = row_count;
863        merged_meta.min_ts_ns = min_ts_ns;
864        merged_meta.max_ts_ns = max_ts_ns;
865        merged_meta.sealed = true;
866        merged_meta.columnar_page = Some(merged_page);
867
868        // --- Atomic commit: insert merged, remove sources ---
869
870        let merged_key = (hypertable.to_string(), merged_start_ns);
871        guard.chunks.insert(merged_key.clone(), merged_meta);
872        guard.columnar_blocks.insert(merged_key, merged_bytes);
873
874        for id in source_ids {
875            // Skip if source_id == merged_id (idempotent when the first source
876            // shares start_ns with the merged chunk).
877            if id.start_ns == merged_start_ns && id.hypertable == hypertable {
878                continue;
879            }
880            let key = (id.hypertable.clone(), id.start_ns);
881            guard.chunks.remove(&key);
882            guard.columnar_blocks.remove(&key);
883        }
884
885        Ok(merged_id)
886    }
887}
888
889/// Errors returned by [`HypertableRegistry::compact_columnar_chunks`].
890#[derive(Debug, Clone, PartialEq)]
891pub enum CompactionError {
892    /// Need at least 2 source chunks — merging 0 or 1 is a no-op.
893    InsufficientSources,
894    /// One of the source chunks does not exist in this hypertable.
895    ChunkNotFound(ChunkId),
896    /// A source chunk is not yet sealed — only sealed chunks can be compacted.
897    ChunkNotSealed(ChunkId),
898    /// A source chunk is not in columnar form.
899    ChunkNotColumnar(ChunkId),
900    /// A source chunk's RDCC block bytes are not RAM-resident.
901    BlockNotResident(ChunkId),
902    /// The RDCC block could not be decoded.
903    Decode(ColumnBlockError),
904    /// The merged RDCC block could not be encoded.
905    Encode(ColumnBlockError),
906}
907
908impl std::fmt::Display for CompactionError {
909    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
910        match self {
911            Self::InsufficientSources => {
912                write!(f, "compaction requires at least 2 source chunks")
913            }
914            Self::ChunkNotFound(id) => write!(
915                f,
916                "source chunk {}@{} not found",
917                id.hypertable, id.start_ns
918            ),
919            Self::ChunkNotSealed(id) => write!(
920                f,
921                "source chunk {}@{} is not sealed",
922                id.hypertable, id.start_ns
923            ),
924            Self::ChunkNotColumnar(id) => write!(
925                f,
926                "source chunk {}@{} is not columnar",
927                id.hypertable, id.start_ns
928            ),
929            Self::BlockNotResident(id) => write!(
930                f,
931                "source chunk {}@{} block bytes are not RAM-resident",
932                id.hypertable, id.start_ns
933            ),
934            Self::Decode(e) => write!(f, "RDCC decode error: {e}"),
935            Self::Encode(e) => write!(f, "RDCC encode error: {e}"),
936        }
937    }
938}
939
940impl std::error::Error for CompactionError {}
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945
946    const DAY_NS: u64 = 86_400_000_000_000;
947    const HOUR_NS: u64 = 3_600_000_000_000;
948
949    #[test]
950    fn chunk_start_aligns_to_interval_floor() {
951        let spec = HypertableSpec::new("m", "ts", DAY_NS);
952        assert_eq!(spec.chunk_start(0), 0);
953        assert_eq!(spec.chunk_start(DAY_NS - 1), 0);
954        assert_eq!(spec.chunk_start(DAY_NS), DAY_NS);
955        assert_eq!(spec.chunk_start(3 * DAY_NS + 123), 3 * DAY_NS);
956    }
957
958    #[test]
959    fn interval_string_accepts_duration_units() {
960        let s = HypertableSpec::from_interval_string("m", "ts", "1d").unwrap();
961        assert_eq!(s.chunk_interval_ns, DAY_NS);
962        let s = HypertableSpec::from_interval_string("m", "ts", "1h").unwrap();
963        assert_eq!(s.chunk_interval_ns, HOUR_NS);
964        assert!(HypertableSpec::from_interval_string("m", "ts", "raw").is_none());
965        assert!(HypertableSpec::from_interval_string("m", "ts", "garbage").is_none());
966    }
967
968    #[test]
969    fn route_allocates_chunk_on_first_write() {
970        let reg = HypertableRegistry::new();
971        reg.register(HypertableSpec::new("metrics", "ts", DAY_NS));
972        let id = reg.route("metrics", DAY_NS + 100).unwrap();
973        assert_eq!(id.hypertable, "metrics");
974        assert_eq!(id.start_ns, DAY_NS);
975        let chunks = reg.show_chunks("metrics");
976        assert_eq!(chunks.len(), 1);
977        assert_eq!(chunks[0].row_count, 1);
978        assert_eq!(chunks[0].min_ts_ns, DAY_NS + 100);
979        assert_eq!(chunks[0].max_ts_ns, DAY_NS + 100);
980        assert_eq!(chunks[0].end_ns_exclusive, 2 * DAY_NS);
981    }
982
983    #[test]
984    fn route_groups_writes_within_same_chunk() {
985        let reg = HypertableRegistry::new();
986        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
987        for offset in [10u64, 100, 1_000, DAY_NS - 1] {
988            let id = reg.route("m", offset).unwrap();
989            assert_eq!(id.start_ns, 0);
990        }
991        let chunks = reg.show_chunks("m");
992        assert_eq!(chunks.len(), 1);
993        assert_eq!(chunks[0].row_count, 4);
994    }
995
996    #[test]
997    fn route_splits_writes_across_adjacent_chunks() {
998        let reg = HypertableRegistry::new();
999        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1000        reg.route("m", DAY_NS - 1).unwrap();
1001        reg.route("m", DAY_NS).unwrap();
1002        reg.route("m", 2 * DAY_NS).unwrap();
1003        let chunks = reg.show_chunks("m");
1004        assert_eq!(chunks.len(), 3);
1005        assert!(chunks[0].id.start_ns <= chunks[1].id.start_ns);
1006        assert!(chunks[1].id.start_ns <= chunks[2].id.start_ns);
1007    }
1008
1009    #[test]
1010    fn route_returns_none_for_unknown_hypertable() {
1011        let reg = HypertableRegistry::new();
1012        assert!(reg.route("nope", 0).is_none());
1013    }
1014
1015    #[test]
1016    fn drop_chunks_before_removes_matching_chunks() {
1017        let reg = HypertableRegistry::new();
1018        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1019        reg.route("m", 0).unwrap(); // chunk start 0, max=0
1020        reg.route("m", DAY_NS).unwrap(); // chunk start DAY_NS, max=DAY_NS
1021        reg.route("m", 2 * DAY_NS + 5).unwrap(); // chunk start 2*DAY_NS
1022
1023        let dropped = reg.drop_chunks_before("m", DAY_NS);
1024        // max_ts_ns of first chunk is 0, of second chunk is DAY_NS.
1025        // cutoff = DAY_NS, so both are "<= cutoff" → both dropped.
1026        assert_eq!(dropped.len(), 2);
1027        let remaining = reg.show_chunks("m");
1028        assert_eq!(remaining.len(), 1);
1029        assert_eq!(remaining[0].id.start_ns, 2 * DAY_NS);
1030    }
1031
1032    #[test]
1033    fn show_chunks_is_ordered_by_start() {
1034        let reg = HypertableRegistry::new();
1035        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1036        for ts in [5 * DAY_NS, 2 * DAY_NS, 7 * DAY_NS, 1 * DAY_NS] {
1037            reg.route("m", ts).unwrap();
1038        }
1039        let starts: Vec<u64> = reg.show_chunks("m").iter().map(|c| c.id.start_ns).collect();
1040        assert_eq!(starts, vec![DAY_NS, 2 * DAY_NS, 5 * DAY_NS, 7 * DAY_NS]);
1041    }
1042
1043    #[test]
1044    fn seal_chunk_flips_flag() {
1045        let reg = HypertableRegistry::new();
1046        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1047        let id = reg.route("m", 0).unwrap();
1048        assert!(reg.seal_chunk(&id));
1049        assert!(reg.show_chunks("m")[0].sealed);
1050    }
1051
1052    #[test]
1053    fn drop_hypertable_removes_everything() {
1054        let reg = HypertableRegistry::new();
1055        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1056        reg.route("m", 0).unwrap();
1057        reg.route("m", DAY_NS).unwrap();
1058        assert_eq!(reg.drop_hypertable("m"), 2);
1059        assert!(reg.get("m").is_none());
1060        assert!(reg.show_chunks("m").is_empty());
1061    }
1062
1063    #[test]
1064    fn total_rows_sums_every_chunk() {
1065        let reg = HypertableRegistry::new();
1066        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1067        for ts in 0..1000 {
1068            reg.route("m", ts).unwrap();
1069        }
1070        for ts in DAY_NS..DAY_NS + 500 {
1071            reg.route("m", ts).unwrap();
1072        }
1073        assert_eq!(reg.total_rows("m"), 1500);
1074    }
1075
1076    #[test]
1077    fn names_lists_registered_hypertables() {
1078        let reg = HypertableRegistry::new();
1079        reg.register(HypertableSpec::new("a", "ts", DAY_NS));
1080        reg.register(HypertableSpec::new("b", "ts", HOUR_NS));
1081        let mut names = reg.names();
1082        names.sort();
1083        assert_eq!(names, vec!["a", "b"]);
1084    }
1085
1086    // -----------------------------------------------------------------
1087    // Partition-level TTL
1088    // -----------------------------------------------------------------
1089
1090    #[test]
1091    fn with_ttl_parses_duration_and_sets_default() {
1092        let s = HypertableSpec::new("m", "ts", DAY_NS)
1093            .with_ttl("7d")
1094            .unwrap();
1095        assert_eq!(s.default_ttl_ns, Some(7 * DAY_NS));
1096        assert!(HypertableSpec::new("m", "ts", DAY_NS)
1097            .with_ttl("raw")
1098            .is_none());
1099        assert!(HypertableSpec::new("m", "ts", DAY_NS)
1100            .with_ttl("garbage")
1101            .is_none());
1102    }
1103
1104    #[test]
1105    fn chunk_with_no_rows_never_expires() {
1106        let meta = ChunkMeta::new(
1107            ChunkId {
1108                hypertable: "m".into(),
1109                start_ns: 0,
1110            },
1111            DAY_NS,
1112        );
1113        assert!(!meta.is_expired_at(u64::MAX, Some(1)));
1114    }
1115
1116    #[test]
1117    fn chunk_expires_when_now_crosses_max_ts_plus_ttl() {
1118        let mut meta = ChunkMeta::new(
1119            ChunkId {
1120                hypertable: "m".into(),
1121                start_ns: 0,
1122            },
1123            DAY_NS,
1124        );
1125        meta.observe(500);
1126        // TTL = 1000, max_ts = 500 → expires at 1500.
1127        assert!(!meta.is_expired_at(1000, Some(1000)));
1128        assert!(!meta.is_expired_at(1499, Some(1000)));
1129        assert!(meta.is_expired_at(1500, Some(1000)));
1130    }
1131
1132    #[test]
1133    fn per_chunk_override_wins_over_hypertable_default() {
1134        let mut meta = ChunkMeta::new(
1135            ChunkId {
1136                hypertable: "m".into(),
1137                start_ns: 0,
1138            },
1139            DAY_NS,
1140        );
1141        meta.observe(500);
1142        // Default would say "expire at 500+1000 = 1500"; override
1143        // narrows to 500+100 = 600.
1144        meta.ttl_override_ns = Some(100);
1145        assert!(meta.is_expired_at(600, Some(1000)));
1146        assert!(!meta.is_expired_at(599, Some(1000)));
1147    }
1148
1149    #[test]
1150    fn sweep_expired_drops_chunks_past_ttl_and_returns_them() {
1151        let reg = HypertableRegistry::new();
1152        reg.register(HypertableSpec::new("m", "ts", DAY_NS).with_ttl_ns(2 * DAY_NS));
1153        // 3 chunks — at 0, DAY, 2*DAY.
1154        for t in [0, DAY_NS, 2 * DAY_NS] {
1155            reg.route("m", t).unwrap();
1156        }
1157        // now = 3 * DAY + 1 → chunks with max_ts in {0, DAY_NS} both
1158        // expired (max + 2d ≤ now), chunk at 2*DAY_NS still alive.
1159        let dropped = reg.sweep_expired("m", 3 * DAY_NS + 1);
1160        let mut starts: Vec<u64> = dropped.iter().map(|m| m.id.start_ns).collect();
1161        starts.sort();
1162        assert_eq!(starts, vec![0, DAY_NS]);
1163        let remaining = reg.show_chunks("m");
1164        assert_eq!(remaining.len(), 1);
1165        assert_eq!(remaining[0].id.start_ns, 2 * DAY_NS);
1166    }
1167
1168    #[test]
1169    fn sweep_without_ttl_keeps_every_chunk() {
1170        let reg = HypertableRegistry::new();
1171        reg.register(HypertableSpec::new("m", "ts", DAY_NS)); // no TTL
1172        for t in [0, DAY_NS, 2 * DAY_NS] {
1173            reg.route("m", t).unwrap();
1174        }
1175        let dropped = reg.sweep_expired("m", 10_000 * DAY_NS);
1176        assert!(dropped.is_empty());
1177        assert_eq!(reg.show_chunks("m").len(), 3);
1178    }
1179
1180    #[test]
1181    fn sweep_all_expired_iterates_every_hypertable() {
1182        let reg = HypertableRegistry::new();
1183        reg.register(HypertableSpec::new("fast", "ts", HOUR_NS).with_ttl_ns(HOUR_NS));
1184        reg.register(HypertableSpec::new("slow", "ts", DAY_NS).with_ttl_ns(7 * DAY_NS));
1185        // fast: chunk at 0 expires fast; slow: chunk at 0 still
1186        // within its 7-day TTL.
1187        reg.route("fast", 0).unwrap();
1188        reg.route("slow", 0).unwrap();
1189        let dropped = reg.sweep_all_expired(2 * HOUR_NS);
1190        assert_eq!(dropped.len(), 1);
1191        assert_eq!(dropped[0].0, "fast");
1192        assert_eq!(reg.show_chunks("slow").len(), 1);
1193    }
1194
1195    #[test]
1196    fn set_chunk_ttl_ns_lets_caller_pin_or_shorten() {
1197        let reg = HypertableRegistry::new();
1198        reg.register(HypertableSpec::new("m", "ts", DAY_NS).with_ttl_ns(DAY_NS));
1199        let id = reg.route("m", 0).unwrap();
1200        // Raise TTL to 100 days — chunk should survive the sweep.
1201        assert!(reg.set_chunk_ttl_ns(&id, Some(100 * DAY_NS)));
1202        let dropped = reg.sweep_expired("m", 10 * DAY_NS);
1203        assert!(dropped.is_empty());
1204        // Now shorten to 1 hour and sweep.
1205        reg.set_chunk_ttl_ns(&id, Some(HOUR_NS));
1206        let dropped = reg.sweep_expired("m", 10 * HOUR_NS);
1207        assert_eq!(dropped.len(), 1);
1208    }
1209
1210    #[test]
1211    fn snapshot_then_restore_reproduces_registry_identically() {
1212        // Pre-restart registry: two hypertables, several chunks, with a
1213        // sealed chunk and a per-chunk TTL override — the bits that are
1214        // NOT derivable from row data and so must round-trip verbatim.
1215        let reg = HypertableRegistry::new();
1216        reg.register(HypertableSpec::new("metrics", "ts", DAY_NS).with_ttl_ns(7 * DAY_NS));
1217        reg.register(HypertableSpec::new("events", "ts", HOUR_NS));
1218        for t in [0, DAY_NS + 5, DAY_NS + 9, 2 * DAY_NS] {
1219            reg.route("metrics", t).unwrap();
1220        }
1221        let id = reg.route("events", 0).unwrap();
1222        reg.seal_chunk(&id);
1223        reg.set_chunk_ttl_ns(&id, Some(3 * HOUR_NS));
1224
1225        let specs = reg.list();
1226        let chunks = reg.snapshot_chunks();
1227        assert!(!reg.is_empty());
1228
1229        // Simulated restart: rebuild a fresh registry from the snapshot.
1230        let restored = HypertableRegistry::new();
1231        assert!(restored.is_empty());
1232        for spec in specs {
1233            restored.register(spec);
1234        }
1235        for chunk in chunks {
1236            restored.restore_chunk(chunk);
1237        }
1238
1239        // Specs identical.
1240        let before = reg.get("metrics").unwrap();
1241        let after = restored.get("metrics").unwrap();
1242        assert_eq!(after.chunk_interval_ns, before.chunk_interval_ns);
1243        assert_eq!(after.time_column, before.time_column);
1244        assert_eq!(after.default_ttl_ns, before.default_ttl_ns);
1245
1246        // Chunk metadata identical (bounds, counts, sealed, TTL override).
1247        let m_before = reg.show_chunks("metrics");
1248        let m_after = restored.show_chunks("metrics");
1249        assert_eq!(m_after.len(), m_before.len());
1250        for (a, b) in m_after.iter().zip(m_before.iter()) {
1251            assert_eq!(a.id.start_ns, b.id.start_ns);
1252            assert_eq!(a.end_ns_exclusive, b.end_ns_exclusive);
1253            assert_eq!(a.row_count, b.row_count);
1254            assert_eq!(a.min_ts_ns, b.min_ts_ns);
1255            assert_eq!(a.max_ts_ns, b.max_ts_ns);
1256        }
1257        let e_after = restored.show_chunks("events");
1258        assert_eq!(e_after.len(), 1);
1259        assert!(e_after[0].sealed, "sealed flag must survive restore");
1260        assert_eq!(e_after[0].ttl_override_ns, Some(3 * HOUR_NS));
1261
1262        // A post-restart write routes to the EXISTING chunk — no
1263        // duplicate allocation.
1264        let routed = restored.route("metrics", DAY_NS + 1).unwrap();
1265        assert_eq!(routed.start_ns, DAY_NS);
1266        assert_eq!(
1267            restored.show_chunks("metrics").len(),
1268            m_before.len(),
1269            "write after restore must not allocate a new chunk"
1270        );
1271    }
1272
1273    // -----------------------------------------------------------------
1274    // Columnar chunk eviction via the EXISTING TTL/drop path (issue #859)
1275    //
1276    // A sealed *columnar* chunk is one whose `ChunkMeta.columnar_page` is
1277    // `Some(..)` — the RDCC `ColumnBlock` discriminant (PRD #850 Phase 1).
1278    // These guards prove the retention path is storage-form agnostic: the
1279    // SAME `sweep_expired` / `drop_chunks_before` metadata sweep that drops
1280    // row chunks drops columnar chunks too, in O(1) metadata work (no
1281    // per-row delete), and hands the `columnar_page` back on the dropped
1282    // meta so the physical-storage callback can release the RDCC block.
1283    // No separate columnar TTL/partition subsystem exists or is needed.
1284    // -----------------------------------------------------------------
1285
1286    /// Build a sealed columnar chunk meta directly — mirrors what the
1287    /// boot/seal path restores: a chunk carrying its RDCC `columnar_page`.
1288    fn columnar_chunk(hypertable: &str, start_ns: u64, max_ts_ns: u64) -> ChunkMeta {
1289        let mut meta = ChunkMeta::new(
1290            ChunkId {
1291                hypertable: hypertable.into(),
1292                start_ns,
1293            },
1294            start_ns + DAY_NS,
1295        );
1296        meta.row_count = 1;
1297        meta.min_ts_ns = max_ts_ns;
1298        meta.max_ts_ns = max_ts_ns;
1299        meta.sealed = true;
1300        meta.columnar_page = Some(PageLocation::new(7, 0, 1234));
1301        meta
1302    }
1303
1304    #[test]
1305    fn columnar_chunk_evicts_via_sweep_expired_carrying_its_page() {
1306        let reg = HypertableRegistry::new();
1307        reg.register(HypertableSpec::new("metrics", "ts", DAY_NS).with_ttl_ns(DAY_NS));
1308        // Inject a sealed columnar chunk (max_ts=0 → expiry at 1d).
1309        reg.restore_chunk(columnar_chunk("metrics", 0, 0));
1310        assert!(reg.show_chunks("metrics")[0].columnar_page.is_some());
1311
1312        // now = 3d → past the 1d TTL. The existing partition sweep drops it.
1313        let dropped = reg.sweep_expired("metrics", 3 * DAY_NS);
1314        assert_eq!(dropped.len(), 1, "columnar chunk must evict via TTL sweep");
1315        assert_eq!(
1316            dropped[0].columnar_page,
1317            Some(PageLocation::new(7, 0, 1234)),
1318            "dropped meta must carry columnar_page so physical release frees the RDCC block"
1319        );
1320        assert!(reg.show_chunks("metrics").is_empty());
1321    }
1322
1323    #[test]
1324    fn columnar_chunk_evicts_via_drop_chunks_before() {
1325        let reg = HypertableRegistry::new();
1326        reg.register(HypertableSpec::new("metrics", "ts", DAY_NS));
1327        reg.restore_chunk(columnar_chunk("metrics", 0, 0));
1328
1329        let dropped = reg.drop_chunks_before("metrics", DAY_NS);
1330        assert_eq!(dropped.len(), 1);
1331        assert!(
1332            dropped[0].columnar_page.is_some(),
1333            "drop_chunks_before is metadata-only and carries columnar_page through"
1334        );
1335        assert!(reg.show_chunks("metrics").is_empty());
1336    }
1337
1338    #[test]
1339    fn columnar_and_row_chunks_share_one_eviction_path() {
1340        // Regression guard (acceptance #3): a row chunk and a columnar
1341        // chunk with identical bounds + TTL must produce identical sweep
1342        // outcomes. If a separate columnar TTL subsystem were ever
1343        // introduced, the two would diverge here.
1344        let mk = |columnar: bool| {
1345            let reg = HypertableRegistry::new();
1346            reg.register(HypertableSpec::new("m", "ts", DAY_NS).with_ttl_ns(DAY_NS));
1347            if columnar {
1348                reg.restore_chunk(columnar_chunk("m", 0, 0));
1349            } else {
1350                reg.route("m", 0).unwrap(); // row chunk, max_ts=0
1351            }
1352            reg.sweep_expired("m", 3 * DAY_NS).len()
1353        };
1354        assert_eq!(mk(false), 1, "row chunk evicts");
1355        assert_eq!(mk(true), 1, "columnar chunk evicts the same way");
1356    }
1357
1358    #[test]
1359    fn columnar_chunk_prunes_by_time_bounds_like_row_chunk() {
1360        // Acceptance #2: the partition pruner selects chunks by their
1361        // [start_ns, end_ns_exclusive) bounds (surfaced via show_chunks) —
1362        // it never inspects columnar_page, so a columnar chunk outside the
1363        // query window is eliminated identically to a row chunk. We assert
1364        // the bounds the pruner consumes survive verbatim for a columnar
1365        // chunk.
1366        let reg = HypertableRegistry::new();
1367        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1368        reg.restore_chunk(columnar_chunk("m", 0, 0));
1369        reg.restore_chunk(columnar_chunk("m", 2 * DAY_NS, 2 * DAY_NS));
1370        let chunks = reg.show_chunks("m");
1371        // A window of [2d, 3d) overlaps only the second chunk — same
1372        // arithmetic the RangeChild pruner runs against these bounds.
1373        let lo = 2 * DAY_NS;
1374        let hi = 3 * DAY_NS;
1375        let overlapping: Vec<u64> = chunks
1376            .iter()
1377            .filter(|c| c.id.start_ns < hi && c.end_ns_exclusive > lo)
1378            .map(|c| c.id.start_ns)
1379            .collect();
1380        assert_eq!(
1381            overlapping,
1382            vec![2 * DAY_NS],
1383            "only in-window columnar chunk kept"
1384        );
1385        assert!(chunks.iter().all(|c| c.columnar_page.is_some()));
1386    }
1387
1388    /// Read-bridge dispatch key (#861): `ChunkMeta::format()` classifies a
1389    /// chunk purely from the `columnar_page` migration discriminant, so a
1390    /// pre-existing row chunk and a newly columnar-sealed chunk in the same
1391    /// registry are disambiguated by format version — the gate the read
1392    /// path dispatches on.
1393    #[test]
1394    fn chunk_format_dispatches_on_columnar_page_discriminant() {
1395        let reg = HypertableRegistry::new();
1396        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1397        // A row chunk (allocated by a write) and a columnar chunk coexist.
1398        reg.route("m", 0).unwrap();
1399        reg.restore_chunk(columnar_chunk("m", DAY_NS, DAY_NS));
1400
1401        let chunks = reg.show_chunks("m");
1402        let row = chunks.iter().find(|c| c.id.start_ns == 0).unwrap();
1403        let col = chunks.iter().find(|c| c.id.start_ns == DAY_NS).unwrap();
1404
1405        assert_eq!(row.format(), ChunkFormat::Row);
1406        assert!(!row.is_columnar());
1407        assert_eq!(col.format(), ChunkFormat::ColumnarV1);
1408        assert!(col.is_columnar());
1409    }
1410
1411    #[test]
1412    fn chunks_expiring_within_previews_without_dropping() {
1413        let reg = HypertableRegistry::new();
1414        reg.register(HypertableSpec::new("m", "ts", DAY_NS).with_ttl_ns(DAY_NS));
1415        // Chunks with max_ts at 0, 1d, 2d → expiries at 1d, 2d, 3d.
1416        for t in [0, DAY_NS, 2 * DAY_NS] {
1417            reg.route("m", t).unwrap();
1418        }
1419        // now=0, horizon=1.5d → only the first chunk (expiry 1d)
1420        // fits. Tight horizon proves the cutoff math.
1421        let preview = reg.chunks_expiring_within("m", 0, DAY_NS + DAY_NS / 2);
1422        assert_eq!(preview.len(), 1);
1423        assert_eq!(preview[0].id.start_ns, 0);
1424        // Wider horizon pulls in the second chunk too.
1425        let preview2 = reg.chunks_expiring_within("m", 0, 2 * DAY_NS);
1426        assert_eq!(preview2.len(), 2);
1427        // Registry still has every chunk — preview never drops.
1428        assert_eq!(reg.show_chunks("m").len(), 3);
1429    }
1430
1431    // -----------------------------------------------------------------
1432    // Chunk compaction (#857)
1433    // -----------------------------------------------------------------
1434
1435    /// Seal a columnar chunk into the registry with real RDCC block bytes.
1436    fn seal_columnar_chunk_into(
1437        reg: &HypertableRegistry,
1438        hypertable: &str,
1439        start_ns: u64,
1440        end_ns_exclusive: u64,
1441        points: &[(u64, f64)],
1442        schema_ref: u64,
1443    ) -> ChunkId {
1444        use super::super::chunk::TimeSeriesChunk;
1445        use std::collections::HashMap;
1446
1447        let id = ChunkId {
1448            hypertable: hypertable.to_string(),
1449            start_ns,
1450        };
1451        let mut chunk = TimeSeriesChunk::new("m", HashMap::new());
1452        for &(ts, v) in points {
1453            chunk.append(ts, v);
1454        }
1455        let block = chunk
1456            .seal_columnar(start_ns, schema_ref)
1457            .expect("seal_columnar");
1458        let page = PageLocation::new(0, 0, block.len() as u32);
1459
1460        let mut meta = ChunkMeta::new(id.clone(), end_ns_exclusive);
1461        meta.sealed = true;
1462        meta.columnar_page = Some(page);
1463        meta.row_count = points.len() as u64;
1464        if let Some(&(min_ts, _)) = points.iter().min_by_key(|(ts, _)| ts) {
1465            meta.min_ts_ns = min_ts;
1466        }
1467        if let Some(&(max_ts, _)) = points.iter().max_by_key(|(ts, _)| ts) {
1468            meta.max_ts_ns = max_ts;
1469        }
1470
1471        {
1472            let mut guard = reg.inner.lock().unwrap();
1473            guard
1474                .chunks
1475                .insert((hypertable.to_string(), start_ns), meta);
1476            guard
1477                .columnar_blocks
1478                .insert((hypertable.to_string(), start_ns), block);
1479        }
1480        id
1481    }
1482
1483    /// Acceptance criterion 1: N sealed chunks merge into one with logically
1484    /// identical contents (same rows and values, in timestamp order).
1485    #[test]
1486    fn compact_merges_chunks_to_identical_rows() {
1487        let reg = HypertableRegistry::new();
1488        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1489
1490        // Three small chunks with non-overlapping time ranges.
1491        let pts_a: Vec<(u64, f64)> = (0..10).map(|i| (i * 1_000, i as f64)).collect();
1492        let pts_b: Vec<(u64, f64)> = (10..20).map(|i| (i * 1_000, i as f64)).collect();
1493        let pts_c: Vec<(u64, f64)> = (20..30).map(|i| (i * 1_000, i as f64)).collect();
1494
1495        let id_a = seal_columnar_chunk_into(&reg, "m", 0, DAY_NS, &pts_a, 1);
1496        let id_b = seal_columnar_chunk_into(&reg, "m", DAY_NS, 2 * DAY_NS, &pts_b, 1);
1497        let id_c = seal_columnar_chunk_into(&reg, "m", 2 * DAY_NS, 3 * DAY_NS, &pts_c, 1);
1498
1499        let merged_id = reg
1500            .compact_columnar_chunks("m", &[id_a, id_b, id_c], 0, 1, DEFAULT_GRANULE_SIZE)
1501            .expect("compaction failed");
1502
1503        // Merged chunk exists and carries the right row count.
1504        let chunks = reg.show_chunks("m");
1505        assert_eq!(chunks.len(), 1, "three source chunks must collapse to one");
1506        let merged_meta = &chunks[0];
1507        assert_eq!(merged_meta.id.start_ns, merged_id.start_ns);
1508        assert_eq!(merged_meta.row_count, 30);
1509        assert!(merged_meta.sealed);
1510        assert!(merged_meta.is_columnar());
1511
1512        // Decode the merged block and verify every point is present.
1513        let block = reg
1514            .columnar_block(&merged_id)
1515            .expect("merged block must be RAM-resident");
1516        let got = points_from_column_block(&block).expect("decode merged block");
1517
1518        let mut expected: Vec<(u64, f64)> =
1519            pts_a.iter().chain(&pts_b).chain(&pts_c).copied().collect();
1520        expected.sort_by_key(|(ts, _)| *ts);
1521
1522        assert_eq!(got.len(), expected.len());
1523        for (point, (exp_ts, exp_val)) in got.iter().zip(&expected) {
1524            assert_eq!(point.timestamp_ns, *exp_ts);
1525            assert!(
1526                (point.value - exp_val).abs() < 1e-9,
1527                "value mismatch at ts {}: got {}, expected {}",
1528                exp_ts,
1529                point.value,
1530                exp_val
1531            );
1532        }
1533    }
1534
1535    /// Acceptance criterion 2: small-chunk count drops after compaction;
1536    /// merged block is recompressed (byte length is within reason).
1537    #[test]
1538    fn compact_reduces_chunk_count_and_recompresses() {
1539        let reg = HypertableRegistry::new();
1540        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1541
1542        // Five small chunks; each contributes 50 points.
1543        let mut ids = Vec::new();
1544        for i in 0..5u64 {
1545            let pts: Vec<(u64, f64)> = (0..50u64)
1546                .map(|j| (i * DAY_NS + j * 1_000, 1.0 + j as f64 * 0.01))
1547                .collect();
1548            let id = seal_columnar_chunk_into(&reg, "m", i * DAY_NS, (i + 1) * DAY_NS, &pts, 1);
1549            ids.push(id);
1550        }
1551
1552        assert_eq!(reg.show_chunks("m").len(), 5);
1553
1554        let merged_id = reg
1555            .compact_columnar_chunks("m", &ids, 0, 1, DEFAULT_GRANULE_SIZE)
1556            .expect("compaction failed");
1557
1558        // Count dropped from 5 to 1.
1559        let chunks = reg.show_chunks("m");
1560        assert_eq!(chunks.len(), 1, "five chunks must compact to one");
1561        assert_eq!(chunks[0].row_count, 250);
1562
1563        // Merged block is present and decodable.
1564        let block = reg.columnar_block(&merged_id).unwrap();
1565        let pts = points_from_column_block(&block).unwrap();
1566        assert_eq!(pts.len(), 250, "all 250 points must survive compaction");
1567
1568        // Compression sanity: the merged block must be smaller than 250 * 16
1569        // uncompressed bytes (timestamp 8 + value 8).
1570        let raw_uncompressed = 250 * 16;
1571        assert!(
1572            block.len() < raw_uncompressed,
1573            "merged block ({} bytes) should be compressed (raw = {})",
1574            block.len(),
1575            raw_uncompressed
1576        );
1577    }
1578
1579    /// Acceptance criterion 3: a "torn merge" (crash before commit) leaves
1580    /// the source chunks intact and loses no committed data.
1581    ///
1582    /// We simulate a torn merge by:
1583    /// 1. Reading the source blocks (the expensive "compute" phase).
1584    /// 2. Verifying sources still exist before the atomic commit fires.
1585    /// 3. Running the actual compaction and verifying sources are removed
1586    ///    only after the merged chunk is fully committed.
1587    #[test]
1588    fn torn_merge_leaves_inputs_intact() {
1589        let reg = HypertableRegistry::new();
1590        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1591
1592        let pts_a: Vec<(u64, f64)> = (0..20).map(|i| (i * 1_000, i as f64)).collect();
1593        let pts_b: Vec<(u64, f64)> = (100..120).map(|i| (i * 1_000, i as f64)).collect();
1594
1595        let id_a = seal_columnar_chunk_into(&reg, "m", 0, DAY_NS, &pts_a, 1);
1596        let id_b = seal_columnar_chunk_into(&reg, "m", DAY_NS, 2 * DAY_NS, &pts_b, 1);
1597
1598        // Simulate the "compute" phase: read source blocks without committing.
1599        // This mirrors what would happen if the process died between decode and
1600        // the atomic registry update — the registry never sees any write.
1601        {
1602            let guard = reg.inner.lock().unwrap();
1603            let block_a = guard
1604                .columnar_blocks
1605                .get(&("m".to_string(), 0))
1606                .expect("block_a must be present before any merge");
1607            let block_b = guard
1608                .columnar_blocks
1609                .get(&("m".to_string(), DAY_NS))
1610                .expect("block_b must be present before any merge");
1611            let pts_decoded_a = points_from_column_block(block_a).unwrap();
1612            let pts_decoded_b = points_from_column_block(block_b).unwrap();
1613            // "Compute" completed; simulate crash by simply not committing.
1614            assert_eq!(pts_decoded_a.len(), 20, "source A readable before merge");
1615            assert_eq!(pts_decoded_b.len(), 20, "source B readable before merge");
1616
1617            // Both source chunks are still in the registry — no partial state.
1618            assert!(
1619                guard.chunks.contains_key(&("m".to_string(), 0)),
1620                "source A must remain intact after torn merge"
1621            );
1622            assert!(
1623                guard.chunks.contains_key(&("m".to_string(), DAY_NS)),
1624                "source B must remain intact after torn merge"
1625            );
1626        }
1627
1628        // Now actually run compaction — verify it commits atomically.
1629        let merged_id = reg
1630            .compact_columnar_chunks("m", &[id_a, id_b], 0, 1, DEFAULT_GRANULE_SIZE)
1631            .expect("compaction after torn-merge simulation must succeed");
1632
1633        // After commit: merged chunk present, sources gone.
1634        let chunks = reg.show_chunks("m");
1635        assert_eq!(chunks.len(), 1, "only the merged chunk must remain");
1636        assert_eq!(chunks[0].id.start_ns, merged_id.start_ns);
1637
1638        // All data survives — no rows lost.
1639        let block = reg.columnar_block(&merged_id).unwrap();
1640        let pts = points_from_column_block(&block).unwrap();
1641        assert_eq!(pts.len(), 40, "all 40 points (20+20) must survive");
1642    }
1643
1644    /// Guard: `compact_columnar_chunks` returns `InsufficientSources` when
1645    /// called with fewer than 2 source chunks (a 1-to-1 "merge" is a no-op).
1646    #[test]
1647    fn compact_rejects_single_source() {
1648        let reg = HypertableRegistry::new();
1649        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1650        let id = seal_columnar_chunk_into(&reg, "m", 0, DAY_NS, &[(1_000, 1.0)], 1);
1651        let err = reg
1652            .compact_columnar_chunks("m", &[id], 0, 1, DEFAULT_GRANULE_SIZE)
1653            .unwrap_err();
1654        assert_eq!(err, CompactionError::InsufficientSources);
1655    }
1656
1657    /// Guard: `compact_columnar_chunks` rejects unsealed source chunks.
1658    #[test]
1659    fn compact_rejects_unsealed_chunk() {
1660        let reg = HypertableRegistry::new();
1661        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1662
1663        // Manually insert an unsealed (open) chunk meta.
1664        let open_id = ChunkId {
1665            hypertable: "m".to_string(),
1666            start_ns: 0,
1667        };
1668        reg.restore_chunk(ChunkMeta::new(open_id.clone(), DAY_NS));
1669
1670        let sealed_id =
1671            seal_columnar_chunk_into(&reg, "m", DAY_NS, 2 * DAY_NS, &[(DAY_NS + 1, 1.0)], 1);
1672
1673        let err = reg
1674            .compact_columnar_chunks("m", &[open_id, sealed_id], 0, 1, DEFAULT_GRANULE_SIZE)
1675            .unwrap_err();
1676        assert!(matches!(err, CompactionError::ChunkNotSealed(_)));
1677    }
1678
1679    /// Guard: `select_compaction_candidates` returns groups respecting the
1680    /// row budget and minimum-chunks threshold.
1681    #[test]
1682    fn select_candidates_respects_budget_and_threshold() {
1683        let reg = HypertableRegistry::new();
1684        reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1685
1686        // Five chunks of 100 rows each.
1687        for i in 0..5u64 {
1688            let pts: Vec<(u64, f64)> = (0..100u64).map(|j| (i * DAY_NS + j, j as f64)).collect();
1689            seal_columnar_chunk_into(&reg, "m", i * DAY_NS, (i + 1) * DAY_NS, &pts, 1);
1690        }
1691
1692        // Budget 250 rows per group, min 2 chunks → 2 groups of 2-3 chunks.
1693        let groups = reg.select_compaction_candidates("m", 250, 2);
1694        assert!(
1695            !groups.is_empty(),
1696            "must find at least one compaction group"
1697        );
1698        for group in &groups {
1699            assert!(group.len() >= 2, "each group must have at least 2 chunks");
1700            // Each group's total rows must be within budget.
1701            let total: u64 = group
1702                .iter()
1703                .map(|id| {
1704                    reg.show_chunks("m")
1705                        .iter()
1706                        .find(|c| c.id.start_ns == id.start_ns)
1707                        .map(|c| c.row_count)
1708                        .unwrap_or(0)
1709                })
1710                .sum();
1711            assert!(
1712                total <= 250,
1713                "group total rows {total} must not exceed budget 250"
1714            );
1715        }
1716
1717        // Threshold of 10 chunks → no group qualifies.
1718        let groups_high = reg.select_compaction_candidates("m", 250, 10);
1719        assert!(
1720            groups_high.is_empty(),
1721            "threshold of 10 must yield no groups when max is 5 chunks"
1722        );
1723    }
1724}