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