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