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    let mut hydrated = entity.clone();
865    let EntityData::TimeSeries(point) = &mut hydrated.data else {
866        return hydrated;
867    };
868    if !point.tags.is_empty() {
869        return hydrated;
870    }
871    let Some(series_id) = point.series_id else {
872        return hydrated;
873    };
874    if let Some(tags) = resolve_timeseries_series_tags(store, entity.kind.collection(), series_id) {
875        point.tags = tags;
876    }
877    hydrated
878}
879
880pub(crate) fn resolve_timeseries_series_tags(
881    store: &crate::storage::unified::UnifiedStore,
882    collection: &str,
883    series_id: u64,
884) -> Option<HashMap<String, String>> {
885    let manager = store.get_collection(TIMESERIES_SERIES_COLLECTION)?;
886    let rows = manager.query_all(|entity| {
887        entity.data.as_row().is_some_and(|row| {
888            row_text(row, "collection").is_some_and(|value| value == collection)
889                && row_u64(row, "series_id") == Some(series_id)
890        })
891    });
892    rows.iter()
893        .filter_map(|entity| entity.data.as_row())
894        .find_map(|row| row.get_field("tags").and_then(encoded_tags_from_value))
895}
896
897fn canonical_timeseries_tags(tags: &HashMap<String, String>) -> String {
898    match encoded_timeseries_tags_value(tags) {
899        Value::Json(bytes) => String::from_utf8(bytes).unwrap_or_default(),
900        _ => "{}".to_string(),
901    }
902}
903
904fn encoded_timeseries_tags_value(tags: &HashMap<String, String>) -> Value {
905    let object = tags
906        .iter()
907        .map(|(key, value)| (key.clone(), crate::json::Value::String(value.clone())))
908        .collect();
909    let json = crate::json::Value::Object(object);
910    Value::Json(crate::json::to_vec(&json).unwrap_or_default())
911}
912
913fn encoded_tags_from_value(value: &Value) -> Option<HashMap<String, String>> {
914    let Value::Json(bytes) = value else {
915        return None;
916    };
917    let json: crate::json::Value = crate::json::from_slice(bytes).ok()?;
918    let crate::json::Value::Object(object) = json else {
919        return None;
920    };
921    Some(
922        object
923            .into_iter()
924            .filter_map(|(key, value)| match value {
925                crate::json::Value::String(value) => Some((key, value)),
926                _ => None,
927            })
928            .collect(),
929    )
930}
931
932fn row_text<'a>(row: &'a crate::storage::RowData, field: &str) -> Option<&'a str> {
933    match row.get_field(field) {
934        Some(Value::Text(value)) => Some(value.as_ref()),
935        _ => None,
936    }
937}
938
939fn row_u64(row: &crate::storage::RowData, field: &str) -> Option<u64> {
940    match row.get_field(field) {
941        Some(Value::UnsignedInteger(value)) => Some(*value),
942        Some(Value::Integer(value)) if *value >= 0 => Some(*value as u64),
943        _ => None,
944    }
945}
946
947fn timeseries_max_series_per_collection(
948    store: &crate::storage::unified::UnifiedStore,
949    collection: &str,
950) -> usize {
951    let collection_key = format!("storage.time_series.collections.{collection}.max_series");
952    latest_usize_config(store, &collection_key)
953        .or_else(|| latest_usize_config(store, TIMESERIES_MAX_SERIES_GLOBAL_CONFIG))
954        .unwrap_or(DEFAULT_TIMESERIES_MAX_SERIES_PER_COLLECTION)
955}
956
957fn latest_usize_config(store: &crate::storage::unified::UnifiedStore, key: &str) -> Option<usize> {
958    let manager = store.get_collection("red_config")?;
959    let mut newest: Option<(u64, usize)> = None;
960    manager.for_each_entity(|entity| {
961        let Some(row) = entity.data.as_row() else {
962            return true;
963        };
964        if !row_text(row, "key").is_some_and(|value| value.eq_ignore_ascii_case(key)) {
965            return true;
966        }
967        let Some(value) = row.get_field("value").and_then(value_as_usize) else {
968            return true;
969        };
970        let id = entity.id.raw();
971        if newest.is_none_or(|(best_id, _)| id >= best_id) {
972            newest = Some((id, value));
973        }
974        true
975    });
976    newest.map(|(_, value)| value)
977}
978
979fn value_as_usize(value: &Value) -> Option<usize> {
980    let value = match value {
981        Value::UnsignedInteger(value) => *value,
982        Value::Integer(value) if *value >= 0 => *value as u64,
983        Value::Float(value) if *value >= 0.0 => *value as u64,
984        Value::Text(value) => value.trim().parse().ok()?,
985        _ => return None,
986    };
987    usize::try_from(value).ok()
988}
989
990/// Materialise `(timestamp_ns, value)` rows from the entity/row store for
991/// the half-open chunk window `[start, end)`, timestamp-ordered. This is
992/// the shared row reader: the columnar seal sources its chunk from it, and
993/// the read-bridge serves row-stored chunks through it (#861). `time_col`
994/// names the time axis; the value column follows the `value` convention.
995fn materialize_row_points(
996    manager: &crate::storage::unified::SegmentManager,
997    time_col: &str,
998    start: u64,
999    end: u64,
1000) -> Vec<(u64, f64)> {
1001    let mut points: Vec<(u64, f64)> = manager
1002        .query_all(|entity| {
1003            let ts = match &entity.data {
1004                EntityData::Row(row) => row.get_field(time_col).and_then(field_as_u64),
1005                EntityData::TimeSeries(point) => Some(point.timestamp_ns),
1006                _ => None,
1007            };
1008            ts.is_some_and(|ts| ts >= start && ts < end)
1009        })
1010        .iter()
1011        .filter_map(|entity| match &entity.data {
1012            EntityData::Row(row) => {
1013                let ts = row.get_field(time_col).and_then(field_as_u64)?;
1014                let value = row.get_field("value").and_then(field_as_f64).unwrap_or(0.0);
1015                Some((ts, value))
1016            }
1017            EntityData::TimeSeries(point) => Some((point.timestamp_ns, point.value)),
1018            _ => None,
1019        })
1020        .collect();
1021    points.sort_by_key(|(ts, _)| *ts);
1022    points
1023}
1024
1025/// Read a row field as a non-negative `u64` timestamp, accepting the
1026/// integer shapes the INSERT path stores for a time column (#911).
1027fn field_as_u64(value: &Value) -> Option<u64> {
1028    match value {
1029        Value::Integer(n) | Value::BigInt(n) | Value::Timestamp(n) if *n >= 0 => Some(*n as u64),
1030        Value::UnsignedInteger(n) => Some(*n),
1031        _ => None,
1032    }
1033}
1034
1035/// Read a row field as `f64` for the columnar value column (#911).
1036fn field_as_f64(value: &Value) -> Option<f64> {
1037    match value {
1038        Value::Float(f) => Some(*f),
1039        Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
1040        Value::UnsignedInteger(n) => Some(*n as f64),
1041        _ => None,
1042    }
1043}
1044
1045fn save_timeseries_metadata(
1046    store: &crate::storage::unified::UnifiedStore,
1047    query: &CreateTimeSeriesQuery,
1048) -> RedDBResult<()> {
1049    remove_timeseries_metadata(store, &query.name);
1050    let _ = store.get_or_create_collection(TIMESERIES_META_COLLECTION);
1051
1052    let mut fields = HashMap::new();
1053    fields.insert(
1054        "kind".to_string(),
1055        Value::text("timeseries_config".to_string()),
1056    );
1057    fields.insert("series".to_string(), Value::text(query.name.clone()));
1058    fields.insert(
1059        "retention_ms".to_string(),
1060        query
1061            .retention_ms
1062            .map(Value::UnsignedInteger)
1063            .unwrap_or(Value::Null),
1064    );
1065    fields.insert(
1066        "chunk_size".to_string(),
1067        query
1068            .chunk_size
1069            .map(|value| Value::UnsignedInteger(value as u64))
1070            .unwrap_or(Value::Null),
1071    );
1072    fields.insert(
1073        "downsample_policies".to_string(),
1074        Value::Array(
1075            query
1076                .downsample_policies
1077                .iter()
1078                .cloned()
1079                .map(Value::text)
1080                .collect(),
1081        ),
1082    );
1083
1084    store
1085        .insert_auto(
1086            TIMESERIES_META_COLLECTION,
1087            UnifiedEntity::new(
1088                EntityId::new(0),
1089                EntityKind::TableRow {
1090                    table: Arc::from(TIMESERIES_META_COLLECTION),
1091                    row_id: 0,
1092                },
1093                EntityData::Row(crate::storage::RowData {
1094                    columns: Vec::new(),
1095                    named: Some(fields),
1096                    schema: None,
1097                }),
1098            ),
1099        )
1100        .map_err(|err| RedDBError::Internal(err.to_string()))?;
1101
1102    Ok(())
1103}
1104
1105fn remove_timeseries_metadata(store: &crate::storage::unified::UnifiedStore, series: &str) {
1106    let Some(manager) = store.get_collection(TIMESERIES_META_COLLECTION) else {
1107        return;
1108    };
1109    let rows = manager.query_all(|entity| {
1110        entity.data.as_row().is_some_and(|row| {
1111            row.get_field("series").is_some_and(
1112                |value| matches!(value, Value::Text(candidate) if &**candidate == series),
1113            )
1114        })
1115    });
1116    for row in rows {
1117        let _ = store.delete(TIMESERIES_META_COLLECTION, row.id);
1118    }
1119}
1120
1121fn remove_timeseries_series_dictionary(
1122    store: &crate::storage::unified::UnifiedStore,
1123    collection: &str,
1124) {
1125    let Some(manager) = store.get_collection(TIMESERIES_SERIES_COLLECTION) else {
1126        return;
1127    };
1128    let rows = manager.query_all(|entity| {
1129        entity.data.as_row().is_some_and(|row| {
1130            row.get_field("collection").is_some_and(
1131                |value| matches!(value, Value::Text(candidate) if &**candidate == collection),
1132            )
1133        })
1134    });
1135    for row in rows {
1136        let _ = store.delete(TIMESERIES_SERIES_COLLECTION, row.id);
1137    }
1138}
1139
1140fn remove_timeseries_tag_index(store: &crate::storage::unified::UnifiedStore, collection: &str) {
1141    let Some(manager) = store.get_collection(TIMESERIES_TAG_INDEX_COLLECTION) else {
1142        return;
1143    };
1144    let rows = manager.query_all(|entity| {
1145        entity.data.as_row().is_some_and(|row| {
1146            row.get_field("collection").is_some_and(
1147                |value| matches!(value, Value::Text(candidate) if &**candidate == collection),
1148            )
1149        })
1150    });
1151    for row in rows {
1152        let _ = store.delete(TIMESERIES_TAG_INDEX_COLLECTION, row.id);
1153    }
1154}
1155
1156/// Build the contract's [`AnalyticalStorageConfig`] for the automatic
1157/// projection policy. `time_key` is the column carrying the time axis —
1158/// the hypertable's declared time column, or the timeseries `timestamp`
1159/// convention. `None` means the collection explicitly opted out and keeps
1160/// the row engine.
1161fn analytical_storage_for(
1162    columnar: bool,
1163    time_key: &str,
1164) -> Option<crate::catalog::AnalyticalStorageConfig> {
1165    columnar.then(|| crate::catalog::AnalyticalStorageConfig {
1166        columnar: true,
1167        time_key: time_key.to_string(),
1168        order_by_key: None,
1169    })
1170}
1171
1172fn hypertable_collection_contract(
1173    query: &CreateTimeSeriesQuery,
1174) -> crate::physical::CollectionContract {
1175    let now = current_unix_ms();
1176    let time_key = query
1177        .hypertable
1178        .as_ref()
1179        .map(|ht| ht.time_column.as_str())
1180        .unwrap_or("timestamp");
1181    crate::physical::CollectionContract {
1182        name: query.name.clone(),
1183        // Table model — rows go through the normal INSERT path,
1184        // which now calls HypertableRegistry::route after each row
1185        // lands. Hypertable-specific behaviour (chunk bounds, TTL
1186        // sweeps) lives on the registry, not the contract.
1187        declared_model: crate::catalog::CollectionModel::Table,
1188        schema_mode: crate::catalog::SchemaMode::SemiStructured,
1189        origin: crate::physical::ContractOrigin::Explicit,
1190        version: 1,
1191        created_at_unix_ms: now,
1192        updated_at_unix_ms: now,
1193        default_ttl_ms: query.retention_ms,
1194        vector_dimension: None,
1195        vector_metric: None,
1196        context_index_fields: Vec::new(),
1197        declared_columns: Vec::new(),
1198        table_def: None,
1199        timestamps_enabled: false,
1200        context_index_enabled: false,
1201        metrics_raw_retention_ms: None,
1202        metrics_rollup_policies: Vec::new(),
1203        metrics_tenant_identity: None,
1204        metrics_namespace: None,
1205        // Hypertable data is conceptually immutable once the chunk
1206        // seals. Reject UPDATE / DELETE at parse time and give the
1207        // operator a clear message instead of silent coalescing.
1208        append_only: true,
1209        subscriptions: Vec::new(),
1210        analytics_config: Vec::new(),
1211        session_key: None,
1212        session_gap_ms: None,
1213        retention_duration_ms: None,
1214        analytical_storage: analytical_storage_for(query.columnar, time_key),
1215
1216        ai_policy: None,
1217    }
1218}
1219
1220fn timeseries_collection_contract(
1221    query: &CreateTimeSeriesQuery,
1222) -> crate::physical::CollectionContract {
1223    let now = current_unix_ms();
1224    crate::physical::CollectionContract {
1225        name: query.name.clone(),
1226        declared_model: crate::catalog::CollectionModel::TimeSeries,
1227        schema_mode: crate::catalog::SchemaMode::SemiStructured,
1228        origin: crate::physical::ContractOrigin::Explicit,
1229        version: 1,
1230        created_at_unix_ms: now,
1231        updated_at_unix_ms: now,
1232        default_ttl_ms: query.retention_ms,
1233        vector_dimension: None,
1234        vector_metric: None,
1235        context_index_fields: Vec::new(),
1236        declared_columns: Vec::new(),
1237        table_def: None,
1238        timestamps_enabled: false,
1239        context_index_enabled: false,
1240        metrics_raw_retention_ms: None,
1241        metrics_rollup_policies: Vec::new(),
1242        metrics_tenant_identity: None,
1243        metrics_namespace: None,
1244        // Time-series collections are append-only by nature — the
1245        // storage model forbids in-place UPDATE already, so the flag
1246        // makes the catalog honest rather than changing semantics.
1247        append_only: true,
1248        subscriptions: Vec::new(),
1249        analytics_config: Vec::new(),
1250        // `WITH SESSION_KEY <col> SESSION_GAP <duration>` from the
1251        // CREATE TIMESERIES DDL becomes the default partition/gap
1252        // pairing for the SESSIONIZE operator (slice 2+). Stored on
1253        // the contract so a restart preserves the values without an
1254        // extra metadata side-table.
1255        session_key: query.session_key.clone(),
1256        session_gap_ms: query.session_gap_ms,
1257        retention_duration_ms: None,
1258        // Plain timeseries store points under the `timestamp` axis
1259        // convention (the `value` column carries the measurement).
1260        analytical_storage: analytical_storage_for(query.columnar, "timestamp"),
1261
1262        ai_policy: None,
1263    }
1264}
1265
1266fn current_unix_ms() -> u128 {
1267    std::time::SystemTime::now()
1268        .duration_since(std::time::UNIX_EPOCH)
1269        .unwrap_or_default()
1270        .as_millis()
1271}