Skip to main content

reddb_server/runtime/
impl_timeseries.rs

1//! Time-series DDL execution
2
3use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5
6use super::*;
7
8const TIMESERIES_META_COLLECTION: &str = "red.timeseries.meta";
9const TIMESERIES_SERIES_COLLECTION: &str = "red.timeseries.series";
10const TIMESERIES_TAG_INDEX_COLLECTION: &str = "red.timeseries.tag_index";
11const DEFAULT_TIMESERIES_CHUNK_INTERVAL_NS: u64 = 86_400_000_000_000;
12const TIMESERIES_MAX_SERIES_GLOBAL_CONFIG: &str = "storage.time_series.max_series_per_collection";
13const DEFAULT_TIMESERIES_MAX_SERIES_PER_COLLECTION: usize = 1_000_000;
14
15#[derive(Default)]
16struct SealHypertableChunksOutcome {
17    chunks_sealed: usize,
18    columnar_chunks_sealed: usize,
19}
20
21impl RedDBRuntime {
22    pub fn execute_create_timeseries(
23        &self,
24        raw_query: &str,
25        query: &CreateTimeSeriesQuery,
26    ) -> RedDBResult<RuntimeQueryResult> {
27        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
28        if super::impl_ddl::is_system_schema_name(&query.name) {
29            return Err(RedDBError::Query("system schema is read-only".to_string()));
30        }
31        for spec in &query.downsample_policies {
32            crate::storage::timeseries::retention::DownsamplePolicy::parse(spec).ok_or_else(
33                || RedDBError::Query(format!("invalid downsample policy '{}'", spec)),
34            )?;
35        }
36
37        let store = self.inner.db.store();
38        let exists = store.get_collection(&query.name).is_some();
39        if exists {
40            if query.if_not_exists {
41                return Ok(RuntimeQueryResult::ok_message(
42                    raw_query.to_string(),
43                    &format!("timeseries '{}' already exists", query.name),
44                    "create",
45                ));
46            }
47            return Err(RedDBError::Query(format!(
48                "timeseries '{}' already exists",
49                query.name
50            )));
51        }
52        store
53            .create_collection(&query.name)
54            .map_err(|e| RedDBError::Internal(e.to_string()))?;
55        if let Some(ttl_ms) = query.retention_ms {
56            self.inner
57                .db
58                .set_collection_default_ttl_ms(&query.name, ttl_ms);
59        }
60        // CREATE HYPERTABLE declares the collection as a Table so
61        // INSERT goes through the row path (which now includes
62        // automatic chunk routing). Plain CREATE TIMESERIES keeps
63        // the native TimeSeries contract with its metric/value/tags
64        // column convention.
65        let contract = if query.hypertable.is_some() {
66            hypertable_collection_contract(query)
67        } else {
68            timeseries_collection_contract(query)
69        };
70        self.inner
71            .db
72            .save_collection_contract(contract)
73            .map_err(|err| RedDBError::Internal(err.to_string()))?;
74        // Issue #747 — record per-collection tenant ownership when an
75        // active tenant context exists, so typed surfaces like
76        // `red.timeseries` can scope rows to the creating tenant just
77        // like `red.tables` does for `CREATE TABLE`.
78        if let Some(tenant_id) = crate::runtime::impl_core::current_tenant() {
79            store.set_config_tree(
80                &format!("collection_tenants.{}", query.name),
81                &crate::serde_json::Value::String(tenant_id),
82            );
83        }
84        save_timeseries_metadata(store.as_ref(), query)?;
85
86        let spec = match &query.hypertable {
87            Some(ht) => {
88                let mut spec = crate::storage::timeseries::HypertableSpec::new(
89                    query.name.clone(),
90                    ht.time_column.clone(),
91                    ht.chunk_interval_ns,
92                );
93                if let Some(ttl) = ht.default_ttl_ns {
94                    spec = spec.with_ttl_ns(ttl);
95                }
96                spec
97            }
98            None => {
99                let mut spec = crate::storage::timeseries::HypertableSpec::new(
100                    query.name.clone(),
101                    "timestamp",
102                    DEFAULT_TIMESERIES_CHUNK_INTERVAL_NS,
103                );
104                if let Some(ttl_ms) = query.retention_ms {
105                    spec = spec.with_ttl_ns(ttl_ms.saturating_mul(1_000_000));
106                }
107                spec
108            }
109        };
110        self.inner.db.hypertables().register(spec);
111
112        self.invalidate_result_cache();
113        self.inner
114            .db
115            .persist_metadata()
116            .map_err(|e| RedDBError::Internal(e.to_string()))?;
117        // Issue #120 — surface timeseries / hypertable in the
118        // schema-vocabulary. The hypertable variant carries the
119        // declared time column.
120        let columns: Vec<String> = query
121            .hypertable
122            .as_ref()
123            .map(|ht| vec![ht.time_column.clone()])
124            .unwrap_or_else(|| vec!["metric".to_string(), "value".to_string()]);
125        self.schema_vocabulary_apply(
126            crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
127                collection: query.name.clone(),
128                columns,
129                type_tags: Vec::new(),
130                description: None,
131            },
132        );
133
134        let noun = if query.hypertable.is_some() {
135            "hypertable"
136        } else {
137            "timeseries"
138        };
139        let mut msg = format!("{noun} '{}' created", query.name);
140        if let Some(ret) = query.retention_ms {
141            msg.push_str(&format!(" (retention={}ms)", ret));
142        }
143        if let Some(cs) = query.chunk_size {
144            msg.push_str(&format!(" (chunk_size={})", cs));
145        }
146        if !query.downsample_policies.is_empty() {
147            msg.push_str(&format!(
148                " (downsample_policies={})",
149                query.downsample_policies.len()
150            ));
151        }
152        Ok(RuntimeQueryResult::ok_message(
153            raw_query.to_string(),
154            &msg,
155            "create",
156        ))
157    }
158
159    pub fn execute_drop_timeseries(
160        &self,
161        raw_query: &str,
162        query: &DropTimeSeriesQuery,
163    ) -> RedDBResult<RuntimeQueryResult> {
164        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
165        let store = self.inner.db.store();
166        let _publication = store.vault_publication_lock.lock();
167        self.vcs_reject_destructive_ddl(&query.name)?;
168        if super::impl_ddl::is_system_schema_name(&query.name) {
169            return Err(RedDBError::Query("system schema is read-only".to_string()));
170        }
171        if store.get_collection(&query.name).is_none() {
172            if query.if_exists {
173                return Ok(RuntimeQueryResult::ok_message(
174                    raw_query.to_string(),
175                    &format!("timeseries '{}' does not exist", query.name),
176                    "drop",
177                ));
178            }
179            return Err(RedDBError::NotFound(format!(
180                "timeseries '{}' not found",
181                query.name
182            )));
183        }
184        let actual = crate::runtime::ddl::polymorphic_resolver::resolve(
185            &query.name,
186            &self.inner.db.catalog_model_snapshot(),
187        )?;
188        if actual != crate::catalog::CollectionModel::TimeSeries
189            && actual != crate::catalog::CollectionModel::Table
190        {
191            crate::runtime::ddl::polymorphic_resolver::ensure_model_match(
192                crate::catalog::CollectionModel::TimeSeries,
193                actual,
194            )?;
195        }
196        // Remove from the hypertable registry before dropping the
197        // underlying collection — the registry lookup is cheap and
198        // staying consistent is the point of having a separate call.
199        let _ = self.inner.db.hypertables().unregister(&query.name);
200        store
201            .drop_collection(&query.name)
202            .map_err(|e| RedDBError::Internal(e.to_string()))?;
203        self.inner.db.clear_collection_default_ttl_ms(&query.name);
204        self.inner
205            .db
206            .remove_collection_contract(&query.name)
207            .map_err(|err| RedDBError::Internal(err.to_string()))?;
208        remove_timeseries_metadata(store.as_ref(), &query.name);
209        remove_timeseries_series_dictionary(store.as_ref(), &query.name);
210        remove_timeseries_tag_index(store.as_ref(), &query.name);
211        self.invalidate_result_cache();
212        self.inner
213            .db
214            .persist_metadata()
215            .map_err(|e| RedDBError::Internal(e.to_string()))?;
216        // Issue #120 — invalidate the schema-vocabulary entry for the
217        // dropped timeseries / hypertable.
218        self.schema_vocabulary_apply(
219            crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
220                collection: query.name.clone(),
221            },
222        );
223        Ok(RuntimeQueryResult::ok_message(
224            raw_query.to_string(),
225            &format!("timeseries '{}' dropped", query.name),
226            "drop",
227        ))
228    }
229
230    /// Seal every still-open chunk of a hypertable, routing each seal
231    /// through [`seal_chunk_with_config`](crate::storage::timeseries::chunk::seal_chunk_with_config)
232    /// — the production caller PRD #850 lacked (#911). For a collection
233    /// whose contract carries `analytical_storage.columnar = true`, the
234    /// chunk's rows are materialised from the entity store into a
235    /// `TimeSeriesChunk`, sealed columnar, and the resulting RDCC
236    /// `ColumnBlock` recorded in `ChunkMeta.columnar_page` (bytes stashed
237    /// for read-back). Columnar-eligible chunks below the projection size
238    /// floor stay open and are picked up by a later seal once they grow.
239    /// Opted-out chunks fall to the row seal and `columnar_page` stays
240    /// `None`. Returns the number of chunks sealed columnar.
241    pub fn seal_hypertable_chunks(&self, collection: &str) -> RedDBResult<usize> {
242        self.seal_hypertable_chunks_internal(collection, usize::MAX, true)
243            .map(|outcome| outcome.columnar_chunks_sealed)
244    }
245
246    pub(crate) fn seal_hypertable_chunks_for_checkpoint(
247        &self,
248        max_chunks: usize,
249    ) -> RedDBResult<usize> {
250        if max_chunks == 0 {
251            return Ok(0);
252        }
253
254        let mut remaining = max_chunks;
255        let mut sealed = 0usize;
256        for collection in self.inner.db.hypertables().names() {
257            if remaining == 0 {
258                break;
259            }
260            let outcome = self.seal_hypertable_chunks_internal(&collection, remaining, false)?;
261            remaining = remaining.saturating_sub(outcome.chunks_sealed);
262            sealed += outcome.chunks_sealed;
263        }
264        Ok(sealed)
265    }
266
267    fn seal_hypertable_chunks_internal(
268        &self,
269        collection: &str,
270        max_chunks: usize,
271        include_tail_chunk: bool,
272    ) -> RedDBResult<SealHypertableChunksOutcome> {
273        if max_chunks == 0 || self.vcs_is_versioned(collection)? {
274            return Ok(SealHypertableChunksOutcome::default());
275        }
276
277        let analytical = self
278            .inner
279            .db
280            .collection_contract(collection)
281            .and_then(|c| c.analytical_storage.clone());
282        let registry = self.inner.db.hypertables();
283        let Some(spec) = registry.get(collection) else {
284            return Ok(SealHypertableChunksOutcome::default());
285        };
286        let time_col = spec.time_column.clone();
287        let store = self.inner.db.store();
288        let Some(manager) = store.get_collection(collection) else {
289            return Ok(SealHypertableChunksOutcome::default());
290        };
291
292        let chunks = registry.show_chunks(collection);
293        let tail_chunk_start = if include_tail_chunk {
294            None
295        } else {
296            chunks.iter().map(|meta| meta.id.start_ns).max()
297        };
298
299        let mut outcome = SealHypertableChunksOutcome::default();
300        let mut changed = false;
301        for meta in chunks {
302            if outcome.chunks_sealed >= max_chunks {
303                break;
304            }
305            if meta.sealed {
306                continue;
307            }
308            if Some(meta.id.start_ns) == tail_chunk_start {
309                continue;
310            }
311            let start = meta.id.start_ns;
312            let end = meta.end_ns_exclusive;
313
314            // Materialise (ts, value) for rows whose time-column value
315            // lands in this chunk's `[start, end)` window — the same
316            // entity/row reader the read-bridge serves row chunks from.
317            let points = materialize_row_points(&manager, &time_col, start, end);
318            let columnar_enabled = analytical.as_ref().is_some_and(|cfg| cfg.columnar);
319            if columnar_enabled && points.len() < self.inner.columnar_projection_size_floor_rows {
320                continue;
321            }
322
323            let mut chunk = crate::storage::timeseries::TimeSeriesChunk::with_max_points(
324                collection.to_string(),
325                HashMap::new(),
326                points.len().max(1),
327            );
328            for (ts, value) in &points {
329                chunk.append(*ts, *value);
330            }
331
332            let routed = crate::storage::timeseries::chunk::seal_chunk_with_config(
333                &mut chunk,
334                analytical.as_ref(),
335                start,
336                0,
337            )
338            .map_err(|err| RedDBError::Internal(format!("columnar seal failed: {err:?}")))?;
339
340            match routed {
341                crate::storage::timeseries::chunk::SealedChunkStorage::Columnar(bytes) => {
342                    let page = self
343                        .inner
344                        .db
345                        .write_column_block_page(&bytes)
346                        .map_err(|err| {
347                            RedDBError::Internal(format!("columnar page write failed: {err}"))
348                        })?;
349                    registry.seal_chunk_columnar(&meta.id, page, bytes);
350                    outcome.columnar_chunks_sealed += 1;
351                    outcome.chunks_sealed += 1;
352                    changed = true;
353                }
354                crate::storage::timeseries::chunk::SealedChunkStorage::Row => {
355                    registry.seal_chunk(&meta.id);
356                    outcome.chunks_sealed += 1;
357                    changed = true;
358                }
359            }
360        }
361        if changed {
362            self.inner
363                .db
364                .persist_metadata()
365                .map_err(|e| RedDBError::Internal(e.to_string()))?;
366        }
367        Ok(outcome)
368    }
369
370    /// Count this hypertable's chunks that were sealed columnar — i.e.
371    /// whose `ChunkMeta.columnar_page` is set (#911). Lets a caller assert
372    /// the columnar arm fired without exposing the registry's chunk type.
373    pub fn columnar_chunk_count(&self, collection: &str) -> usize {
374        self.inner
375            .db
376            .hypertables()
377            .show_chunks(collection)
378            .iter()
379            .filter(|meta| meta.columnar_page.is_some())
380            .count()
381    }
382
383    /// Read back a columnar-sealed chunk's points over `[start_ns, end_ns]`
384    /// (inclusive) via the #856 column-block range scan, decoding the RDCC
385    /// `ColumnBlock` recorded by [`seal_hypertable_chunks`](Self::seal_hypertable_chunks).
386    /// `None` when the chunk was not sealed columnar (or its bytes are not
387    /// RAM-resident). Points come back as `(timestamp_ns, value)`.
388    pub fn columnar_chunk_points(
389        &self,
390        collection: &str,
391        chunk_start_ns: u64,
392        start_ns: u64,
393        end_ns: u64,
394    ) -> Option<Vec<(u64, f64)>> {
395        if self.vcs_is_versioned(collection).ok()? {
396            return None;
397        }
398        let id = crate::storage::timeseries::ChunkId {
399            hypertable: collection.to_string(),
400            start_ns: chunk_start_ns,
401        };
402        let bytes = self.inner.db.hypertables().columnar_block(&id)?;
403        let scan =
404            crate::storage::timeseries::chunk::query_column_block_range(&bytes, start_ns, end_ns)
405                .ok()?;
406        Some(
407            scan.points
408                .iter()
409                .map(|p| (p.timestamp_ns, p.value))
410                .collect(),
411        )
412    }
413
414    pub fn columnar_chunk_range_scan(
415        &self,
416        collection: &str,
417        chunk_start_ns: u64,
418        start_ns: u64,
419        end_ns: u64,
420    ) -> Option<crate::storage::timeseries::chunk::PrunedColumnScan> {
421        if self.vcs_is_versioned(collection).ok()? {
422            return None;
423        }
424        let id = crate::storage::timeseries::ChunkId {
425            hypertable: collection.to_string(),
426            start_ns: chunk_start_ns,
427        };
428        let bytes = self.inner.db.hypertables().columnar_block(&id)?;
429        crate::storage::timeseries::chunk::query_column_block_range(&bytes, start_ns, end_ns).ok()
430    }
431
432    pub fn columnar_chunk_value_eq_scan(
433        &self,
434        collection: &str,
435        chunk_start_ns: u64,
436        target: f64,
437    ) -> Option<crate::storage::timeseries::chunk::PrunedColumnScan> {
438        if self.vcs_is_versioned(collection).ok()? {
439            return None;
440        }
441        let id = crate::storage::timeseries::ChunkId {
442            hypertable: collection.to_string(),
443            start_ns: chunk_start_ns,
444        };
445        let bytes = self.inner.db.hypertables().columnar_block(&id)?;
446        crate::storage::timeseries::chunk::query_column_block_value_eq(&bytes, target).ok()
447    }
448
449    /// Read-bridge (#861): read every point of `collection` in the
450    /// inclusive range `[start_ns, end_ns]`, dispatching **per chunk** on
451    /// its storage format so row-stored and columnar (`RDCC`) chunks
452    /// coexist after `COLUMNAR` is enabled — with no mass rewrite of the
453    /// pre-existing row data.
454    ///
455    /// Each chunk's [`ChunkMeta::format`](crate::storage::timeseries::ChunkMeta::format)
456    /// is the format-version gate:
457    /// - [`ChunkFormat::ColumnarV1`] → decode the chunk's RDCC `ColumnBlock`
458    ///   through the granule-pruned column-block range scan, after
459    ///   confirming the block's embedded `format_version` is one this build
460    ///   understands ([`peek_column_block_version`]).
461    /// - [`ChunkFormat::Row`] → materialise the chunk's rows from the
462    ///   entity/row store, the same reader the seal sources from.
463    ///
464    /// Points come back merged and timestamp-ordered, so a caller sees one
465    /// logical series regardless of how each chunk is physically stored.
466    /// Chunk windows are disjoint, so a columnar chunk is read only through
467    /// its RDCC block and never double-counted via the row path.
468    pub fn read_bridge_points(
469        &self,
470        collection: &str,
471        start_ns: u64,
472        end_ns: u64,
473    ) -> RedDBResult<Vec<(u64, f64)>> {
474        use crate::storage::timeseries::ChunkFormat;
475        use crate::storage::unified::column_block::{
476            peek_column_block_version, COLUMN_BLOCK_VERSION_V1,
477        };
478
479        let registry = self.inner.db.hypertables();
480        let spec = registry.get(collection);
481
482        // Shared column blocks have no branch lineage or MVCC stamps. Read
483        // canonical versions instead, including chunks sealed before opt-in.
484        if self.vcs_is_versioned(collection)? {
485            let mut points: Vec<_> = self
486                .scan_collection(collection, None, usize::MAX)?
487                .items
488                .into_iter()
489                .filter_map(|entity| match entity.data {
490                    EntityData::TimeSeries(point) => Some((point.timestamp_ns, point.value)),
491                    EntityData::Row(row) => Some((
492                        row.get_field(&spec.as_ref()?.time_column)
493                            .and_then(field_as_u64)?,
494                        row.get_field("value").and_then(field_as_f64).unwrap_or(0.0),
495                    )),
496                    _ => None,
497                })
498                .filter(|(timestamp, _)| *timestamp >= start_ns && *timestamp <= end_ns)
499                .collect();
500            points.sort_by_key(|(timestamp, _)| *timestamp);
501            return Ok(points);
502        }
503
504        let Some(spec) = spec else {
505            return Ok(Vec::new());
506        };
507        let time_col = spec.time_column.clone();
508        let store = self.inner.db.store();
509        let mut out: Vec<(u64, f64)> = Vec::new();
510        for meta in registry.show_chunks(collection) {
511            // Skip chunks whose observed window cannot intersect the query.
512            // An empty chunk has min_ts_ns == u64::MAX, so it is skipped.
513            if meta.max_ts_ns < start_ns || meta.min_ts_ns > end_ns {
514                continue;
515            }
516            match meta.format() {
517                ChunkFormat::ColumnarV1 => {
518                    // RDCC reader. Bytes may be absent post-restart (pending
519                    // the durable page-write bridge); nothing to read then.
520                    let Some(bytes) = registry.columnar_block(&meta.id) else {
521                        continue;
522                    };
523                    // Format-version gate: reject a block this build cannot
524                    // read rather than mis-decode it.
525                    match peek_column_block_version(&bytes) {
526                        Some(COLUMN_BLOCK_VERSION_V1) => {}
527                        Some(v) => {
528                            return Err(RedDBError::Internal(format!(
529                                "chunk {} @ {} carries unsupported columnar format version {v}",
530                                meta.id.hypertable, meta.id.start_ns
531                            )));
532                        }
533                        None => {
534                            return Err(RedDBError::Internal(format!(
535                                "chunk {} @ {} is flagged columnar but its block is not RDCC",
536                                meta.id.hypertable, meta.id.start_ns
537                            )));
538                        }
539                    }
540                    let scan = crate::storage::timeseries::chunk::query_column_block_range(
541                        &bytes, start_ns, end_ns,
542                    )
543                    .map_err(|err| {
544                        RedDBError::Internal(format!("columnar read-bridge decode failed: {err:?}"))
545                    })?;
546                    out.extend(scan.points.iter().map(|p| (p.timestamp_ns, p.value)));
547                }
548                ChunkFormat::Row => {
549                    // Row reader: materialise the chunk window, then filter
550                    // to the inclusive query range (mirrors the columnar
551                    // scan's `[start_ns, end_ns]` contract).
552                    let Some(manager) = store.get_collection(collection) else {
553                        continue;
554                    };
555                    let chunk_start = meta.id.start_ns;
556                    let chunk_end = meta.end_ns_exclusive;
557                    out.extend(
558                        materialize_row_points(&manager, &time_col, chunk_start, chunk_end)
559                            .into_iter()
560                            .filter(|(ts, _)| *ts >= start_ns && *ts <= end_ns),
561                    );
562                }
563            }
564        }
565        out.sort_by_key(|(ts, _)| *ts);
566        Ok(out)
567    }
568}
569
570pub(crate) fn intern_timeseries_series(
571    store: &crate::storage::unified::UnifiedStore,
572    collection: &str,
573    metric: &str,
574    tags: &HashMap<String, String>,
575) -> RedDBResult<u64> {
576    let canonical_tags = canonical_timeseries_tags(tags);
577    let _ = store.get_or_create_collection(TIMESERIES_SERIES_COLLECTION);
578    let manager = store
579        .get_collection(TIMESERIES_SERIES_COLLECTION)
580        .ok_or_else(|| RedDBError::Internal("timeseries series dictionary missing".to_string()))?;
581
582    let rows = manager.query_all(|entity| {
583        entity
584            .data
585            .as_row()
586            .is_some_and(|row| row_text(row, "collection").is_some_and(|value| value == collection))
587    });
588
589    let mut next_id = 0u64;
590    for entity in &rows {
591        let Some(row) = entity.data.as_row() else {
592            continue;
593        };
594        if let Some(existing_id) = row_u64(row, "series_id") {
595            next_id = next_id.max(existing_id.saturating_add(1));
596        }
597        if row_text(row, "metric") == Some(metric)
598            && row_text(row, "canonical_tags") == Some(canonical_tags.as_str())
599        {
600            if let Some(existing_id) = row_u64(row, "series_id") {
601                return Ok(existing_id);
602            }
603        }
604    }
605
606    let ceiling = timeseries_max_series_per_collection(store, collection);
607    if rows.len() >= ceiling {
608        return Err(RedDBError::Query(format!(
609            "timeseries collection '{collection}' has reached its distinct series ceiling of {ceiling}"
610        )));
611    }
612
613    let series_id = next_id;
614    let mut fields = HashMap::new();
615    fields.insert(
616        "collection".to_string(),
617        Value::text(collection.to_string()),
618    );
619    fields.insert("series_id".to_string(), Value::UnsignedInteger(series_id));
620    fields.insert("metric".to_string(), Value::text(metric.to_string()));
621    fields.insert(
622        "canonical_tags".to_string(),
623        Value::text(canonical_tags.clone()),
624    );
625    fields.insert("tags".to_string(), encoded_timeseries_tags_value(tags));
626
627    store
628        .insert_auto(
629            TIMESERIES_SERIES_COLLECTION,
630            UnifiedEntity::new(
631                EntityId::new(0),
632                EntityKind::TableRow {
633                    table: Arc::from(TIMESERIES_SERIES_COLLECTION),
634                    row_id: 0,
635                },
636                EntityData::Row(crate::storage::RowData {
637                    columns: Vec::new(),
638                    named: Some(fields),
639                    schema: None,
640                }),
641            ),
642        )
643        .map_err(|err| RedDBError::Internal(err.to_string()))?;
644
645    maintain_timeseries_tag_index(store, collection, series_id, tags)?;
646
647    Ok(series_id)
648}
649
650pub(crate) fn timeseries_tag_index_series_candidates(
651    store: &crate::storage::unified::UnifiedStore,
652    collection: &str,
653    filter: &Filter,
654) -> Option<HashSet<u64>> {
655    match filter {
656        Filter::Compare { field, op, value } if *op == CompareOp::Eq => {
657            let tag_key = timeseries_tag_field(field)?;
658            let tag_values = value_to_tag_index_texts(value)?;
659            lookup_timeseries_tag_index(store, collection, tag_key, &tag_values)
660        }
661        Filter::In { field, values } => {
662            let tag_key = timeseries_tag_field(field)?;
663            let tag_values = values
664                .iter()
665                .map(value_to_tag_index_texts)
666                .collect::<Option<Vec<_>>>()?
667                .into_iter()
668                .flatten()
669                .collect::<Vec<_>>();
670            lookup_timeseries_tag_index(store, collection, tag_key, &tag_values)
671        }
672        Filter::And(left, right) => {
673            match (
674                timeseries_tag_index_series_candidates(store, collection, left),
675                timeseries_tag_index_series_candidates(store, collection, right),
676            ) {
677                (Some(left), Some(right)) => {
678                    Some(left.intersection(&right).copied().collect::<HashSet<_>>())
679                }
680                (Some(candidates), None) | (None, Some(candidates)) => Some(candidates),
681                (None, None) => None,
682            }
683        }
684        Filter::Or(left, right) => {
685            let mut left = timeseries_tag_index_series_candidates(store, collection, left)?;
686            let right = timeseries_tag_index_series_candidates(store, collection, right)?;
687            left.extend(right);
688            Some(left)
689        }
690        _ => None,
691    }
692}
693
694pub(crate) fn strip_indexed_timeseries_tag_filter(filter: &Filter) -> Option<Filter> {
695    match filter {
696        Filter::Compare { field, op, value }
697            if *op == CompareOp::Eq
698                && timeseries_tag_field(field).is_some()
699                && value_to_tag_index_texts(value).is_some() =>
700        {
701            None
702        }
703        Filter::In { field, values }
704            if timeseries_tag_field(field).is_some()
705                && values
706                    .iter()
707                    .all(|value| value_to_tag_index_texts(value).is_some()) =>
708        {
709            None
710        }
711        Filter::And(left, right) => match (
712            strip_indexed_timeseries_tag_filter(left),
713            strip_indexed_timeseries_tag_filter(right),
714        ) {
715            (Some(left), Some(right)) => Some(Filter::And(Box::new(left), Box::new(right))),
716            (Some(filter), None) | (None, Some(filter)) => Some(filter),
717            (None, None) => None,
718        },
719        _ => Some(filter.clone()),
720    }
721}
722
723fn maintain_timeseries_tag_index(
724    store: &crate::storage::unified::UnifiedStore,
725    collection: &str,
726    series_id: u64,
727    tags: &HashMap<String, String>,
728) -> RedDBResult<()> {
729    let _ = store.get_or_create_collection(TIMESERIES_TAG_INDEX_COLLECTION);
730    let manager = store
731        .get_collection(TIMESERIES_TAG_INDEX_COLLECTION)
732        .ok_or_else(|| RedDBError::Internal("timeseries tag index missing".to_string()))?;
733
734    for (tag_key, tag_value) in tags {
735        let existing = manager
736            .query_all(|entity| tag_index_row_matches(entity, collection, tag_key, tag_value))
737            .into_iter()
738            .next();
739
740        if let Some(mut entity) = existing {
741            let EntityData::Row(row) = &mut entity.data else {
742                continue;
743            };
744            let mut ids = row
745                .get_field("series_ids")
746                .map(series_ids_from_value)
747                .unwrap_or_default();
748            if !ids.contains(&series_id) {
749                ids.push(series_id);
750                ids.sort_unstable();
751                set_row_field(row, "series_ids", series_ids_value(&ids));
752                manager
753                    .update(entity)
754                    .map_err(|err| RedDBError::Internal(err.to_string()))?;
755            }
756            continue;
757        }
758
759        let mut fields = HashMap::new();
760        fields.insert(
761            "collection".to_string(),
762            Value::text(collection.to_string()),
763        );
764        fields.insert("tag_key".to_string(), Value::text(tag_key.clone()));
765        fields.insert("tag_value".to_string(), Value::text(tag_value.clone()));
766        fields.insert("series_ids".to_string(), series_ids_value(&[series_id]));
767
768        store
769            .insert_auto(
770                TIMESERIES_TAG_INDEX_COLLECTION,
771                UnifiedEntity::new(
772                    EntityId::new(0),
773                    EntityKind::TableRow {
774                        table: Arc::from(TIMESERIES_TAG_INDEX_COLLECTION),
775                        row_id: 0,
776                    },
777                    EntityData::Row(crate::storage::RowData {
778                        columns: Vec::new(),
779                        named: Some(fields),
780                        schema: None,
781                    }),
782                ),
783            )
784            .map_err(|err| RedDBError::Internal(err.to_string()))?;
785    }
786
787    Ok(())
788}
789
790fn lookup_timeseries_tag_index(
791    store: &crate::storage::unified::UnifiedStore,
792    collection: &str,
793    tag_key: &str,
794    tag_values: &[String],
795) -> Option<HashSet<u64>> {
796    let manager = store.get_collection(TIMESERIES_TAG_INDEX_COLLECTION)?;
797    let wanted = tag_values
798        .iter()
799        .map(String::as_str)
800        .collect::<HashSet<_>>();
801    let rows = manager.query_all(|entity| {
802        entity.data.as_row().is_some_and(|row| {
803            row_text(row, "collection").is_some_and(|value| value == collection)
804                && row_text(row, "tag_key").is_some_and(|value| value == tag_key)
805                && row_text(row, "tag_value").is_some_and(|value| wanted.contains(value))
806        })
807    });
808
809    Some(
810        rows.iter()
811            .filter_map(|entity| entity.data.as_row())
812            .flat_map(|row| {
813                row.get_field("series_ids")
814                    .map(series_ids_from_value)
815                    .unwrap_or_default()
816            })
817            .collect(),
818    )
819}
820
821fn timeseries_tag_field(field: &FieldRef) -> Option<&str> {
822    match field {
823        FieldRef::TableColumn { table, column } if table == "tags" && !column.is_empty() => {
824            Some(column.as_str())
825        }
826        FieldRef::TableColumn { column, .. } => {
827            column.strip_prefix("tags.").filter(|key| !key.is_empty())
828        }
829        _ => None,
830    }
831}
832
833fn value_to_tag_index_texts(value: &Value) -> Option<Vec<String>> {
834    match value {
835        Value::Text(value) => {
836            let raw = value.to_string();
837            let encoded = encoded_json_tag_probe(&raw);
838            if encoded == raw {
839                Some(vec![raw])
840            } else {
841                Some(vec![raw, encoded])
842            }
843        }
844        _ => None,
845    }
846}
847
848fn encoded_json_tag_probe(value: &str) -> String {
849    let mut encoded = String::new();
850    encoded.push(crate::runtime::query_exec::TIMESERIES_TAG_JSON_PREFIX);
851    encoded.push_str(&crate::json::Value::String(value.to_string()).to_string_compact());
852    encoded
853}
854
855fn tag_index_row_matches(
856    entity: &UnifiedEntity,
857    collection: &str,
858    tag_key: &str,
859    tag_value: &str,
860) -> bool {
861    entity.data.as_row().is_some_and(|row| {
862        row_text(row, "collection").is_some_and(|value| value == collection)
863            && row_text(row, "tag_key").is_some_and(|value| value == tag_key)
864            && row_text(row, "tag_value").is_some_and(|value| value == tag_value)
865    })
866}
867
868fn series_ids_value(ids: &[u64]) -> Value {
869    Value::Array(
870        ids.iter()
871            .copied()
872            .map(Value::UnsignedInteger)
873            .collect::<Vec<_>>(),
874    )
875}
876
877fn series_ids_from_value(value: &Value) -> Vec<u64> {
878    let Value::Array(values) = value else {
879        return Vec::new();
880    };
881    values
882        .iter()
883        .filter_map(|value| match value {
884            Value::UnsignedInteger(value) => Some(*value),
885            Value::Integer(value) if *value >= 0 => Some(*value as u64),
886            _ => None,
887        })
888        .collect()
889}
890
891fn set_row_field(row: &mut crate::storage::RowData, name: &str, value: Value) {
892    if let Some(named) = &mut row.named {
893        named.insert(name.to_string(), value);
894    }
895}
896
897pub(crate) fn hydrate_timeseries_entity(
898    store: &crate::storage::unified::UnifiedStore,
899    entity: &UnifiedEntity,
900) -> UnifiedEntity {
901    hydrate_timeseries_entity_ref(store, entity).into_owned()
902}
903
904/// Ordinary rows and already hydrated points require no owned entity copy.
905pub(crate) fn hydrate_timeseries_entity_ref<'a>(
906    store: &crate::storage::unified::UnifiedStore,
907    entity: &'a UnifiedEntity,
908) -> std::borrow::Cow<'a, UnifiedEntity> {
909    let EntityData::TimeSeries(point) = &entity.data else {
910        return std::borrow::Cow::Borrowed(entity);
911    };
912    if !point.tags.is_empty() {
913        return std::borrow::Cow::Borrowed(entity);
914    }
915    let Some(series_id) = point.series_id else {
916        return std::borrow::Cow::Borrowed(entity);
917    };
918    let Some(tags) = resolve_timeseries_series_tags(store, entity.kind.collection(), series_id)
919    else {
920        return std::borrow::Cow::Borrowed(entity);
921    };
922    let mut hydrated = entity.clone();
923    if let EntityData::TimeSeries(point) = &mut hydrated.data {
924        point.tags = tags;
925    }
926    std::borrow::Cow::Owned(hydrated)
927}
928
929pub(crate) fn resolve_timeseries_series_tags(
930    store: &crate::storage::unified::UnifiedStore,
931    collection: &str,
932    series_id: u64,
933) -> Option<HashMap<String, String>> {
934    let manager = store.get_collection(TIMESERIES_SERIES_COLLECTION)?;
935    let rows = manager.query_all(|entity| {
936        entity.data.as_row().is_some_and(|row| {
937            row_text(row, "collection").is_some_and(|value| value == collection)
938                && row_u64(row, "series_id") == Some(series_id)
939        })
940    });
941    rows.iter()
942        .filter_map(|entity| entity.data.as_row())
943        .find_map(|row| row.get_field("tags").and_then(encoded_tags_from_value))
944}
945
946fn canonical_timeseries_tags(tags: &HashMap<String, String>) -> String {
947    match encoded_timeseries_tags_value(tags) {
948        Value::Json(bytes) => String::from_utf8(bytes).unwrap_or_default(),
949        _ => "{}".to_string(),
950    }
951}
952
953fn encoded_timeseries_tags_value(tags: &HashMap<String, String>) -> Value {
954    let object = tags
955        .iter()
956        .map(|(key, value)| (key.clone(), crate::json::Value::String(value.clone())))
957        .collect();
958    let json = crate::json::Value::Object(object);
959    Value::Json(crate::json::to_vec(&json).unwrap_or_default())
960}
961
962fn encoded_tags_from_value(value: &Value) -> Option<HashMap<String, String>> {
963    let Value::Json(bytes) = value else {
964        return None;
965    };
966    let json: crate::json::Value = crate::json::from_slice(bytes).ok()?;
967    let crate::json::Value::Object(object) = json else {
968        return None;
969    };
970    Some(
971        object
972            .into_iter()
973            .filter_map(|(key, value)| match value {
974                crate::json::Value::String(value) => Some((key, value)),
975                _ => None,
976            })
977            .collect(),
978    )
979}
980
981fn row_text<'a>(row: &'a crate::storage::RowData, field: &str) -> Option<&'a str> {
982    match row.get_field(field) {
983        Some(Value::Text(value)) => Some(value.as_ref()),
984        _ => None,
985    }
986}
987
988fn row_u64(row: &crate::storage::RowData, field: &str) -> Option<u64> {
989    match row.get_field(field) {
990        Some(Value::UnsignedInteger(value)) => Some(*value),
991        Some(Value::Integer(value)) if *value >= 0 => Some(*value as u64),
992        _ => None,
993    }
994}
995
996fn timeseries_max_series_per_collection(
997    store: &crate::storage::unified::UnifiedStore,
998    collection: &str,
999) -> usize {
1000    let collection_key = format!("storage.time_series.collections.{collection}.max_series");
1001    latest_usize_config(store, &collection_key)
1002        .or_else(|| latest_usize_config(store, TIMESERIES_MAX_SERIES_GLOBAL_CONFIG))
1003        .unwrap_or(DEFAULT_TIMESERIES_MAX_SERIES_PER_COLLECTION)
1004}
1005
1006fn latest_usize_config(store: &crate::storage::unified::UnifiedStore, key: &str) -> Option<usize> {
1007    let manager = store.get_collection("red.config")?;
1008    let mut newest: Option<(u64, usize)> = None;
1009    manager.for_each_entity(|entity| {
1010        let Some(row) = entity.data.as_row() else {
1011            return true;
1012        };
1013        if !row_text(row, "key").is_some_and(|value| value.eq_ignore_ascii_case(key)) {
1014            return true;
1015        }
1016        let Some(value) = row.get_field("value").and_then(value_as_usize) else {
1017            return true;
1018        };
1019        let id = entity.id.raw();
1020        if newest.is_none_or(|(best_id, _)| id >= best_id) {
1021            newest = Some((id, value));
1022        }
1023        true
1024    });
1025    newest.map(|(_, value)| value)
1026}
1027
1028fn value_as_usize(value: &Value) -> Option<usize> {
1029    let value = match value {
1030        Value::UnsignedInteger(value) => *value,
1031        Value::Integer(value) if *value >= 0 => *value as u64,
1032        Value::Float(value) if *value >= 0.0 => *value as u64,
1033        Value::Text(value) => value.trim().parse().ok()?,
1034        _ => return None,
1035    };
1036    usize::try_from(value).ok()
1037}
1038
1039/// Materialise `(timestamp_ns, value)` rows from the entity/row store for
1040/// the half-open chunk window `[start, end)`, timestamp-ordered. This is
1041/// the shared row reader: the columnar seal sources its chunk from it, and
1042/// the read-bridge serves row-stored chunks through it (#861). `time_col`
1043/// names the time axis; the value column follows the `value` convention.
1044fn materialize_row_points(
1045    manager: &crate::storage::unified::SegmentManager,
1046    time_col: &str,
1047    start: u64,
1048    end: u64,
1049) -> Vec<(u64, f64)> {
1050    let mut points: Vec<(u64, f64)> = manager
1051        .query_all(|entity| {
1052            let ts = match &entity.data {
1053                EntityData::Row(row) => row.get_field(time_col).and_then(field_as_u64),
1054                EntityData::TimeSeries(point) => Some(point.timestamp_ns),
1055                _ => None,
1056            };
1057            ts.is_some_and(|ts| ts >= start && ts < end)
1058        })
1059        .iter()
1060        .filter_map(|entity| match &entity.data {
1061            EntityData::Row(row) => {
1062                let ts = row.get_field(time_col).and_then(field_as_u64)?;
1063                let value = row.get_field("value").and_then(field_as_f64).unwrap_or(0.0);
1064                Some((ts, value))
1065            }
1066            EntityData::TimeSeries(point) => Some((point.timestamp_ns, point.value)),
1067            _ => None,
1068        })
1069        .collect();
1070    points.sort_by_key(|(ts, _)| *ts);
1071    points
1072}
1073
1074/// Read a row field as a non-negative `u64` timestamp, accepting the
1075/// integer shapes the INSERT path stores for a time column (#911).
1076fn field_as_u64(value: &Value) -> Option<u64> {
1077    match value {
1078        Value::Integer(n) | Value::BigInt(n) | Value::Timestamp(n) if *n >= 0 => Some(*n as u64),
1079        Value::UnsignedInteger(n) => Some(*n),
1080        _ => None,
1081    }
1082}
1083
1084/// Read a row field as `f64` for the columnar value column (#911).
1085fn field_as_f64(value: &Value) -> Option<f64> {
1086    match value {
1087        Value::Float(f) => Some(*f),
1088        Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
1089        Value::UnsignedInteger(n) => Some(*n as f64),
1090        _ => None,
1091    }
1092}
1093
1094fn save_timeseries_metadata(
1095    store: &crate::storage::unified::UnifiedStore,
1096    query: &CreateTimeSeriesQuery,
1097) -> RedDBResult<()> {
1098    remove_timeseries_metadata(store, &query.name);
1099    let _ = store.get_or_create_collection(TIMESERIES_META_COLLECTION);
1100
1101    let mut fields = HashMap::new();
1102    fields.insert(
1103        "kind".to_string(),
1104        Value::text("timeseries_config".to_string()),
1105    );
1106    fields.insert("series".to_string(), Value::text(query.name.clone()));
1107    fields.insert(
1108        "retention_ms".to_string(),
1109        query
1110            .retention_ms
1111            .map(Value::UnsignedInteger)
1112            .unwrap_or(Value::Null),
1113    );
1114    fields.insert(
1115        "chunk_size".to_string(),
1116        query
1117            .chunk_size
1118            .map(|value| Value::UnsignedInteger(value as u64))
1119            .unwrap_or(Value::Null),
1120    );
1121    fields.insert(
1122        "downsample_policies".to_string(),
1123        Value::Array(
1124            query
1125                .downsample_policies
1126                .iter()
1127                .cloned()
1128                .map(Value::text)
1129                .collect(),
1130        ),
1131    );
1132
1133    store
1134        .insert_auto(
1135            TIMESERIES_META_COLLECTION,
1136            UnifiedEntity::new(
1137                EntityId::new(0),
1138                EntityKind::TableRow {
1139                    table: Arc::from(TIMESERIES_META_COLLECTION),
1140                    row_id: 0,
1141                },
1142                EntityData::Row(crate::storage::RowData {
1143                    columns: Vec::new(),
1144                    named: Some(fields),
1145                    schema: None,
1146                }),
1147            ),
1148        )
1149        .map_err(|err| RedDBError::Internal(err.to_string()))?;
1150
1151    Ok(())
1152}
1153
1154fn remove_timeseries_metadata(store: &crate::storage::unified::UnifiedStore, series: &str) {
1155    let Some(manager) = store.get_collection(TIMESERIES_META_COLLECTION) else {
1156        return;
1157    };
1158    let rows = manager.query_all(|entity| {
1159        entity.data.as_row().is_some_and(|row| {
1160            row.get_field("series").is_some_and(
1161                |value| matches!(value, Value::Text(candidate) if &**candidate == series),
1162            )
1163        })
1164    });
1165    for row in rows {
1166        let _ = store.delete(TIMESERIES_META_COLLECTION, row.id);
1167    }
1168}
1169
1170fn remove_timeseries_series_dictionary(
1171    store: &crate::storage::unified::UnifiedStore,
1172    collection: &str,
1173) {
1174    let Some(manager) = store.get_collection(TIMESERIES_SERIES_COLLECTION) else {
1175        return;
1176    };
1177    let rows = manager.query_all(|entity| {
1178        entity.data.as_row().is_some_and(|row| {
1179            row.get_field("collection").is_some_and(
1180                |value| matches!(value, Value::Text(candidate) if &**candidate == collection),
1181            )
1182        })
1183    });
1184    for row in rows {
1185        let _ = store.delete(TIMESERIES_SERIES_COLLECTION, row.id);
1186    }
1187}
1188
1189fn remove_timeseries_tag_index(store: &crate::storage::unified::UnifiedStore, collection: &str) {
1190    let Some(manager) = store.get_collection(TIMESERIES_TAG_INDEX_COLLECTION) else {
1191        return;
1192    };
1193    let rows = manager.query_all(|entity| {
1194        entity.data.as_row().is_some_and(|row| {
1195            row.get_field("collection").is_some_and(
1196                |value| matches!(value, Value::Text(candidate) if &**candidate == collection),
1197            )
1198        })
1199    });
1200    for row in rows {
1201        let _ = store.delete(TIMESERIES_TAG_INDEX_COLLECTION, row.id);
1202    }
1203}
1204
1205/// Build the contract's [`AnalyticalStorageConfig`] for the automatic
1206/// projection policy. `time_key` is the column carrying the time axis —
1207/// the hypertable's declared time column, or the timeseries `timestamp`
1208/// convention. `None` means the collection explicitly opted out and keeps
1209/// the row engine.
1210fn analytical_storage_for(
1211    columnar: bool,
1212    time_key: &str,
1213) -> Option<crate::catalog::AnalyticalStorageConfig> {
1214    columnar.then(|| crate::catalog::AnalyticalStorageConfig {
1215        columnar: true,
1216        time_key: time_key.to_string(),
1217        order_by_key: None,
1218    })
1219}
1220
1221fn hypertable_collection_contract(
1222    query: &CreateTimeSeriesQuery,
1223) -> crate::physical::CollectionContract {
1224    let now = current_unix_ms();
1225    let time_key = query
1226        .hypertable
1227        .as_ref()
1228        .map(|ht| ht.time_column.as_str())
1229        .unwrap_or("timestamp");
1230    crate::physical::CollectionContract {
1231        graph_contract: None,
1232        name: query.name.clone(),
1233        description: None,
1234        description_generated: false,
1235        // Table model — rows go through the normal INSERT path,
1236        // which now calls HypertableRegistry::route after each row
1237        // lands. Hypertable-specific behaviour (chunk bounds, TTL
1238        // sweeps) lives on the registry, not the contract.
1239        declared_model: crate::catalog::CollectionModel::Table,
1240        schema_mode: crate::catalog::SchemaMode::SemiStructured,
1241        origin: crate::physical::ContractOrigin::Explicit,
1242        version: 1,
1243        created_at_unix_ms: now,
1244        updated_at_unix_ms: now,
1245        default_ttl_ms: query.retention_ms,
1246        vector_dimension: None,
1247        vector_metric: None,
1248        context_index_fields: Vec::new(),
1249        declared_columns: Vec::new(),
1250        table_def: None,
1251        timestamps_enabled: false,
1252        context_index_enabled: false,
1253        metrics_raw_retention_ms: None,
1254        metrics_rollup_policies: Vec::new(),
1255        metrics_tenant_identity: None,
1256        metrics_namespace: None,
1257        // Hypertable data is conceptually immutable once the chunk
1258        // seals. Reject UPDATE / DELETE at parse time and give the
1259        // operator a clear message instead of silent coalescing.
1260        append_only: true,
1261        subscriptions: Vec::new(),
1262        analytics_config: Vec::new(),
1263        session_key: None,
1264        session_gap_ms: None,
1265        retention_duration_ms: None,
1266        analytical_storage: analytical_storage_for(query.columnar, time_key),
1267
1268        ai_policy: None,
1269    }
1270}
1271
1272fn timeseries_collection_contract(
1273    query: &CreateTimeSeriesQuery,
1274) -> crate::physical::CollectionContract {
1275    let now = current_unix_ms();
1276    crate::physical::CollectionContract {
1277        graph_contract: None,
1278        name: query.name.clone(),
1279        description: None,
1280        description_generated: false,
1281        declared_model: crate::catalog::CollectionModel::TimeSeries,
1282        schema_mode: crate::catalog::SchemaMode::SemiStructured,
1283        origin: crate::physical::ContractOrigin::Explicit,
1284        version: 1,
1285        created_at_unix_ms: now,
1286        updated_at_unix_ms: now,
1287        default_ttl_ms: query.retention_ms,
1288        vector_dimension: None,
1289        vector_metric: None,
1290        context_index_fields: Vec::new(),
1291        declared_columns: Vec::new(),
1292        table_def: None,
1293        timestamps_enabled: false,
1294        context_index_enabled: false,
1295        metrics_raw_retention_ms: None,
1296        metrics_rollup_policies: Vec::new(),
1297        metrics_tenant_identity: None,
1298        metrics_namespace: None,
1299        // Time-series collections are append-only by nature — the
1300        // storage model forbids in-place UPDATE already, so the flag
1301        // makes the catalog honest rather than changing semantics.
1302        append_only: true,
1303        subscriptions: Vec::new(),
1304        analytics_config: Vec::new(),
1305        // `WITH SESSION_KEY <col> SESSION_GAP <duration>` from the
1306        // CREATE TIMESERIES DDL becomes the default partition/gap
1307        // pairing for the SESSIONIZE operator (slice 2+). Stored on
1308        // the contract so a restart preserves the values without an
1309        // extra metadata side-table.
1310        session_key: query.session_key.clone(),
1311        session_gap_ms: query.session_gap_ms,
1312        retention_duration_ms: None,
1313        // Plain timeseries store points under the `timestamp` axis
1314        // convention (the `value` column carries the measurement).
1315        analytical_storage: analytical_storage_for(query.columnar, "timestamp"),
1316
1317        ai_policy: None,
1318    }
1319}
1320
1321fn current_unix_ms() -> u128 {
1322    std::time::SystemTime::now()
1323        .duration_since(std::time::UNIX_EPOCH)
1324        .unwrap_or_default()
1325        .as_millis()
1326}
1327
1328#[cfg(test)]
1329mod hydration_tests {
1330    use super::*;
1331
1332    #[test]
1333    fn ordinary_rows_borrow_and_interned_timeseries_tags_are_hydrated() {
1334        let store = crate::storage::UnifiedStore::new();
1335        let row = UnifiedEntity::table_row(
1336            crate::storage::EntityId::new(1),
1337            "rows",
1338            1,
1339            vec![Value::Array(vec![Value::text("large payload"); 256])],
1340        );
1341        let ordinary = hydrate_timeseries_entity_ref(&store, &row);
1342        assert!(matches!(ordinary, std::borrow::Cow::Borrowed(_)));
1343        assert!(std::ptr::eq(ordinary.as_ref(), &row));
1344        let tags = HashMap::from([("host".to_string(), "server-1".to_string())]);
1345        let mut series = UnifiedEntity::table_row(
1346            crate::storage::EntityId::new(0),
1347            TIMESERIES_SERIES_COLLECTION,
1348            0,
1349            vec![],
1350        );
1351        series.data = EntityData::Row(crate::storage::RowData::with_names(
1352            vec![
1353                Value::text("metrics"),
1354                Value::UnsignedInteger(42),
1355                encoded_timeseries_tags_value(&tags),
1356            ],
1357            vec!["collection".into(), "series_id".into(), "tags".into()],
1358        ));
1359        store
1360            .insert_auto(TIMESERIES_SERIES_COLLECTION, series)
1361            .expect("series metadata");
1362        let point = UnifiedEntity::new(
1363            crate::storage::EntityId::new(2),
1364            crate::storage::EntityKind::TimeSeriesPoint(Box::new(
1365                crate::storage::unified::entity::TimeSeriesPointKind {
1366                    series: "metrics".into(),
1367                    metric: "cpu".into(),
1368                },
1369            )),
1370            EntityData::TimeSeries(crate::storage::unified::entity::TimeSeriesData {
1371                metric: "cpu".into(),
1372                series_id: Some(42),
1373                timestamp_ns: 1,
1374                value: 7.0,
1375                tags: HashMap::new(),
1376                fields: HashMap::new(),
1377            }),
1378        );
1379        let hydrated = hydrate_timeseries_entity_ref(&store, &point);
1380        assert!(matches!(hydrated, std::borrow::Cow::Owned(_)));
1381        let EntityData::TimeSeries(data) = &hydrated.data else {
1382            panic!("point")
1383        };
1384        assert_eq!(data.tags, tags);
1385        assert!(matches!(
1386            hydrate_timeseries_entity_ref(&store, &hydrated),
1387            std::borrow::Cow::Borrowed(_)
1388        ));
1389        let EntityData::TimeSeries(original) = &point.data else {
1390            panic!("point")
1391        };
1392        assert!(original.tags.is_empty());
1393    }
1394}