Skip to main content

reddb_server/runtime/
impl_timeseries.rs

1//! Time-series DDL execution
2
3use std::collections::HashMap;
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 DEFAULT_TIMESERIES_CHUNK_INTERVAL_NS: u64 = 86_400_000_000_000;
11const TIMESERIES_MAX_SERIES_GLOBAL_CONFIG: &str = "storage.time_series.max_series_per_collection";
12const DEFAULT_TIMESERIES_MAX_SERIES_PER_COLLECTION: usize = 1_000_000;
13
14#[derive(Default)]
15struct SealHypertableChunksOutcome {
16    chunks_sealed: usize,
17    columnar_chunks_sealed: usize,
18}
19
20impl RedDBRuntime {
21    pub fn execute_create_timeseries(
22        &self,
23        raw_query: &str,
24        query: &CreateTimeSeriesQuery,
25    ) -> RedDBResult<RuntimeQueryResult> {
26        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
27        for spec in &query.downsample_policies {
28            crate::storage::timeseries::retention::DownsamplePolicy::parse(spec).ok_or_else(
29                || RedDBError::Query(format!("invalid downsample policy '{}'", spec)),
30            )?;
31        }
32
33        let store = self.inner.db.store();
34        let exists = store.get_collection(&query.name).is_some();
35        if exists {
36            if query.if_not_exists {
37                return Ok(RuntimeQueryResult::ok_message(
38                    raw_query.to_string(),
39                    &format!("timeseries '{}' already exists", query.name),
40                    "create",
41                ));
42            }
43            return Err(RedDBError::Query(format!(
44                "timeseries '{}' already exists",
45                query.name
46            )));
47        }
48        store
49            .create_collection(&query.name)
50            .map_err(|e| RedDBError::Internal(e.to_string()))?;
51        if let Some(ttl_ms) = query.retention_ms {
52            self.inner
53                .db
54                .set_collection_default_ttl_ms(&query.name, ttl_ms);
55        }
56        // CREATE HYPERTABLE declares the collection as a Table so
57        // INSERT goes through the row path (which now includes
58        // automatic chunk routing). Plain CREATE TIMESERIES keeps
59        // the native TimeSeries contract with its metric/value/tags
60        // column convention.
61        let contract = if query.hypertable.is_some() {
62            hypertable_collection_contract(query)
63        } else {
64            timeseries_collection_contract(query)
65        };
66        self.inner
67            .db
68            .save_collection_contract(contract)
69            .map_err(|err| RedDBError::Internal(err.to_string()))?;
70        // Issue #747 — record per-collection tenant ownership when an
71        // active tenant context exists, so typed surfaces like
72        // `red.timeseries` can scope rows to the creating tenant just
73        // like `red.tables` does for `CREATE TABLE`.
74        if let Some(tenant_id) = crate::runtime::impl_core::current_tenant() {
75            store.set_config_tree(
76                &format!("red.collection_tenants.{}", query.name),
77                &crate::serde_json::Value::String(tenant_id),
78            );
79        }
80        save_timeseries_metadata(store.as_ref(), query)?;
81
82        let spec = match &query.hypertable {
83            Some(ht) => {
84                let mut spec = crate::storage::timeseries::HypertableSpec::new(
85                    query.name.clone(),
86                    ht.time_column.clone(),
87                    ht.chunk_interval_ns,
88                );
89                if let Some(ttl) = ht.default_ttl_ns {
90                    spec = spec.with_ttl_ns(ttl);
91                }
92                spec
93            }
94            None => {
95                let mut spec = crate::storage::timeseries::HypertableSpec::new(
96                    query.name.clone(),
97                    "timestamp",
98                    DEFAULT_TIMESERIES_CHUNK_INTERVAL_NS,
99                );
100                if let Some(ttl_ms) = query.retention_ms {
101                    spec = spec.with_ttl_ns(ttl_ms.saturating_mul(1_000_000));
102                }
103                spec
104            }
105        };
106        self.inner.db.hypertables().register(spec);
107
108        self.invalidate_result_cache();
109        self.inner
110            .db
111            .persist_metadata()
112            .map_err(|e| RedDBError::Internal(e.to_string()))?;
113        // Issue #120 — surface timeseries / hypertable in the
114        // schema-vocabulary. The hypertable variant carries the
115        // declared time column.
116        let columns: Vec<String> = query
117            .hypertable
118            .as_ref()
119            .map(|ht| vec![ht.time_column.clone()])
120            .unwrap_or_else(|| vec!["metric".to_string(), "value".to_string()]);
121        self.schema_vocabulary_apply(
122            crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
123                collection: query.name.clone(),
124                columns,
125                type_tags: Vec::new(),
126                description: None,
127            },
128        );
129
130        let noun = if query.hypertable.is_some() {
131            "hypertable"
132        } else {
133            "timeseries"
134        };
135        let mut msg = format!("{noun} '{}' created", query.name);
136        if let Some(ret) = query.retention_ms {
137            msg.push_str(&format!(" (retention={}ms)", ret));
138        }
139        if let Some(cs) = query.chunk_size {
140            msg.push_str(&format!(" (chunk_size={})", cs));
141        }
142        if !query.downsample_policies.is_empty() {
143            msg.push_str(&format!(
144                " (downsample_policies={})",
145                query.downsample_policies.len()
146            ));
147        }
148        Ok(RuntimeQueryResult::ok_message(
149            raw_query.to_string(),
150            &msg,
151            "create",
152        ))
153    }
154
155    pub fn execute_drop_timeseries(
156        &self,
157        raw_query: &str,
158        query: &DropTimeSeriesQuery,
159    ) -> RedDBResult<RuntimeQueryResult> {
160        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
161        let store = self.inner.db.store();
162        if super::impl_ddl::is_system_schema_name(&query.name) {
163            return Err(RedDBError::Query("system schema is read-only".to_string()));
164        }
165        if store.get_collection(&query.name).is_none() {
166            if query.if_exists {
167                return Ok(RuntimeQueryResult::ok_message(
168                    raw_query.to_string(),
169                    &format!("timeseries '{}' does not exist", query.name),
170                    "drop",
171                ));
172            }
173            return Err(RedDBError::NotFound(format!(
174                "timeseries '{}' not found",
175                query.name
176            )));
177        }
178        let actual = crate::runtime::ddl::polymorphic_resolver::resolve(
179            &query.name,
180            &self.inner.db.catalog_model_snapshot(),
181        )?;
182        if actual != crate::catalog::CollectionModel::TimeSeries
183            && actual != crate::catalog::CollectionModel::Table
184        {
185            crate::runtime::ddl::polymorphic_resolver::ensure_model_match(
186                crate::catalog::CollectionModel::TimeSeries,
187                actual,
188            )?;
189        }
190        // Remove from the hypertable registry before dropping the
191        // underlying collection — the registry lookup is cheap and
192        // staying consistent is the point of having a separate call.
193        let _ = self.inner.db.hypertables().unregister(&query.name);
194        store
195            .drop_collection(&query.name)
196            .map_err(|e| RedDBError::Internal(e.to_string()))?;
197        self.inner.db.clear_collection_default_ttl_ms(&query.name);
198        self.inner
199            .db
200            .remove_collection_contract(&query.name)
201            .map_err(|err| RedDBError::Internal(err.to_string()))?;
202        remove_timeseries_metadata(store.as_ref(), &query.name);
203        remove_timeseries_series_dictionary(store.as_ref(), &query.name);
204        self.invalidate_result_cache();
205        self.inner
206            .db
207            .persist_metadata()
208            .map_err(|e| RedDBError::Internal(e.to_string()))?;
209        // Issue #120 — invalidate the schema-vocabulary entry for the
210        // dropped timeseries / hypertable.
211        self.schema_vocabulary_apply(
212            crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
213                collection: query.name.clone(),
214            },
215        );
216        Ok(RuntimeQueryResult::ok_message(
217            raw_query.to_string(),
218            &format!("timeseries '{}' dropped", query.name),
219            "drop",
220        ))
221    }
222
223    /// Seal every still-open chunk of a hypertable, routing each seal
224    /// through [`seal_chunk_with_config`](crate::storage::timeseries::chunk::seal_chunk_with_config)
225    /// — the production caller PRD #850 lacked (#911). For a collection
226    /// whose contract carries `analytical_storage.columnar = true`, the
227    /// chunk's rows are materialised from the entity store into a
228    /// `TimeSeriesChunk`, sealed columnar, and the resulting RDCC
229    /// `ColumnBlock` recorded in `ChunkMeta.columnar_page` (bytes stashed
230    /// for read-back). Columnar-eligible chunks below the projection size
231    /// floor stay open and are picked up by a later seal once they grow.
232    /// Opted-out chunks fall to the row seal and `columnar_page` stays
233    /// `None`. Returns the number of chunks sealed columnar.
234    pub fn seal_hypertable_chunks(&self, collection: &str) -> RedDBResult<usize> {
235        self.seal_hypertable_chunks_internal(collection, usize::MAX, true)
236            .map(|outcome| outcome.columnar_chunks_sealed)
237    }
238
239    pub(crate) fn seal_hypertable_chunks_for_checkpoint(
240        &self,
241        max_chunks: usize,
242    ) -> RedDBResult<usize> {
243        if max_chunks == 0 {
244            return Ok(0);
245        }
246
247        let mut remaining = max_chunks;
248        let mut sealed = 0usize;
249        for collection in self.inner.db.hypertables().names() {
250            if remaining == 0 {
251                break;
252            }
253            let outcome = self.seal_hypertable_chunks_internal(&collection, remaining, false)?;
254            remaining = remaining.saturating_sub(outcome.chunks_sealed);
255            sealed += outcome.chunks_sealed;
256        }
257        Ok(sealed)
258    }
259
260    fn seal_hypertable_chunks_internal(
261        &self,
262        collection: &str,
263        max_chunks: usize,
264        include_tail_chunk: bool,
265    ) -> RedDBResult<SealHypertableChunksOutcome> {
266        if max_chunks == 0 {
267            return Ok(SealHypertableChunksOutcome::default());
268        }
269
270        let analytical = self
271            .inner
272            .db
273            .collection_contract(collection)
274            .and_then(|c| c.analytical_storage.clone());
275        let registry = self.inner.db.hypertables();
276        let Some(spec) = registry.get(collection) else {
277            return Ok(SealHypertableChunksOutcome::default());
278        };
279        let time_col = spec.time_column.clone();
280        let store = self.inner.db.store();
281        let Some(manager) = store.get_collection(collection) else {
282            return Ok(SealHypertableChunksOutcome::default());
283        };
284
285        let chunks = registry.show_chunks(collection);
286        let tail_chunk_start = if include_tail_chunk {
287            None
288        } else {
289            chunks.iter().map(|meta| meta.id.start_ns).max()
290        };
291
292        let mut outcome = SealHypertableChunksOutcome::default();
293        let mut changed = false;
294        for meta in chunks {
295            if outcome.chunks_sealed >= max_chunks {
296                break;
297            }
298            if meta.sealed {
299                continue;
300            }
301            if Some(meta.id.start_ns) == tail_chunk_start {
302                continue;
303            }
304            let start = meta.id.start_ns;
305            let end = meta.end_ns_exclusive;
306
307            // Materialise (ts, value) for rows whose time-column value
308            // lands in this chunk's `[start, end)` window — the same
309            // entity/row reader the read-bridge serves row chunks from.
310            let points = materialize_row_points(&manager, &time_col, start, end);
311            let columnar_enabled = analytical.as_ref().is_some_and(|cfg| cfg.columnar);
312            if columnar_enabled && points.len() < self.inner.columnar_projection_size_floor_rows {
313                continue;
314            }
315
316            let mut chunk = crate::storage::timeseries::TimeSeriesChunk::with_max_points(
317                collection.to_string(),
318                HashMap::new(),
319                points.len().max(1),
320            );
321            for (ts, value) in &points {
322                chunk.append(*ts, *value);
323            }
324
325            let routed = crate::storage::timeseries::chunk::seal_chunk_with_config(
326                &mut chunk,
327                analytical.as_ref(),
328                start,
329                0,
330            )
331            .map_err(|err| RedDBError::Internal(format!("columnar seal failed: {err:?}")))?;
332
333            match routed {
334                crate::storage::timeseries::chunk::SealedChunkStorage::Columnar(bytes) => {
335                    let page = self
336                        .inner
337                        .db
338                        .write_column_block_page(&bytes)
339                        .map_err(|err| {
340                            RedDBError::Internal(format!("columnar page write failed: {err}"))
341                        })?;
342                    registry.seal_chunk_columnar(&meta.id, page, bytes);
343                    outcome.columnar_chunks_sealed += 1;
344                    outcome.chunks_sealed += 1;
345                    changed = true;
346                }
347                crate::storage::timeseries::chunk::SealedChunkStorage::Row => {
348                    registry.seal_chunk(&meta.id);
349                    outcome.chunks_sealed += 1;
350                    changed = true;
351                }
352            }
353        }
354        if changed {
355            self.inner
356                .db
357                .persist_metadata()
358                .map_err(|e| RedDBError::Internal(e.to_string()))?;
359        }
360        Ok(outcome)
361    }
362
363    /// Count this hypertable's chunks that were sealed columnar — i.e.
364    /// whose `ChunkMeta.columnar_page` is set (#911). Lets a caller assert
365    /// the columnar arm fired without exposing the registry's chunk type.
366    pub fn columnar_chunk_count(&self, collection: &str) -> usize {
367        self.inner
368            .db
369            .hypertables()
370            .show_chunks(collection)
371            .iter()
372            .filter(|meta| meta.columnar_page.is_some())
373            .count()
374    }
375
376    /// Read back a columnar-sealed chunk's points over `[start_ns, end_ns]`
377    /// (inclusive) via the #856 column-block range scan, decoding the RDCC
378    /// `ColumnBlock` recorded by [`seal_hypertable_chunks`](Self::seal_hypertable_chunks).
379    /// `None` when the chunk was not sealed columnar (or its bytes are not
380    /// RAM-resident). Points come back as `(timestamp_ns, value)`.
381    pub fn columnar_chunk_points(
382        &self,
383        collection: &str,
384        chunk_start_ns: u64,
385        start_ns: u64,
386        end_ns: u64,
387    ) -> Option<Vec<(u64, f64)>> {
388        let id = crate::storage::timeseries::ChunkId {
389            hypertable: collection.to_string(),
390            start_ns: chunk_start_ns,
391        };
392        let bytes = self.inner.db.hypertables().columnar_block(&id)?;
393        let scan =
394            crate::storage::timeseries::chunk::query_column_block_range(&bytes, start_ns, end_ns)
395                .ok()?;
396        Some(
397            scan.points
398                .iter()
399                .map(|p| (p.timestamp_ns, p.value))
400                .collect(),
401        )
402    }
403
404    pub fn columnar_chunk_range_scan(
405        &self,
406        collection: &str,
407        chunk_start_ns: u64,
408        start_ns: u64,
409        end_ns: u64,
410    ) -> Option<crate::storage::timeseries::chunk::PrunedColumnScan> {
411        let id = crate::storage::timeseries::ChunkId {
412            hypertable: collection.to_string(),
413            start_ns: chunk_start_ns,
414        };
415        let bytes = self.inner.db.hypertables().columnar_block(&id)?;
416        crate::storage::timeseries::chunk::query_column_block_range(&bytes, start_ns, end_ns).ok()
417    }
418
419    pub fn columnar_chunk_value_eq_scan(
420        &self,
421        collection: &str,
422        chunk_start_ns: u64,
423        target: f64,
424    ) -> Option<crate::storage::timeseries::chunk::PrunedColumnScan> {
425        let id = crate::storage::timeseries::ChunkId {
426            hypertable: collection.to_string(),
427            start_ns: chunk_start_ns,
428        };
429        let bytes = self.inner.db.hypertables().columnar_block(&id)?;
430        crate::storage::timeseries::chunk::query_column_block_value_eq(&bytes, target).ok()
431    }
432
433    /// Read-bridge (#861): read every point of `collection` in the
434    /// inclusive range `[start_ns, end_ns]`, dispatching **per chunk** on
435    /// its storage format so row-stored and columnar (`RDCC`) chunks
436    /// coexist after `COLUMNAR` is enabled — with no mass rewrite of the
437    /// pre-existing row data.
438    ///
439    /// Each chunk's [`ChunkMeta::format`](crate::storage::timeseries::ChunkMeta::format)
440    /// is the format-version gate:
441    /// - [`ChunkFormat::ColumnarV1`] → decode the chunk's RDCC `ColumnBlock`
442    ///   through the granule-pruned column-block range scan, after
443    ///   confirming the block's embedded `format_version` is one this build
444    ///   understands ([`peek_column_block_version`]).
445    /// - [`ChunkFormat::Row`] → materialise the chunk's rows from the
446    ///   entity/row store, the same reader the seal sources from.
447    ///
448    /// Points come back merged and timestamp-ordered, so a caller sees one
449    /// logical series regardless of how each chunk is physically stored.
450    /// Chunk windows are disjoint, so a columnar chunk is read only through
451    /// its RDCC block and never double-counted via the row path.
452    pub fn read_bridge_points(
453        &self,
454        collection: &str,
455        start_ns: u64,
456        end_ns: u64,
457    ) -> RedDBResult<Vec<(u64, f64)>> {
458        use crate::storage::timeseries::ChunkFormat;
459        use crate::storage::unified::column_block::{
460            peek_column_block_version, COLUMN_BLOCK_VERSION_V1,
461        };
462
463        let registry = self.inner.db.hypertables();
464        let Some(spec) = registry.get(collection) else {
465            return Ok(Vec::new());
466        };
467        let time_col = spec.time_column.clone();
468        let store = self.inner.db.store();
469
470        let mut out: Vec<(u64, f64)> = Vec::new();
471        for meta in registry.show_chunks(collection) {
472            // Skip chunks whose observed window cannot intersect the query.
473            // An empty chunk has min_ts_ns == u64::MAX, so it is skipped.
474            if meta.max_ts_ns < start_ns || meta.min_ts_ns > end_ns {
475                continue;
476            }
477            match meta.format() {
478                ChunkFormat::ColumnarV1 => {
479                    // RDCC reader. Bytes may be absent post-restart (pending
480                    // the durable page-write bridge); nothing to read then.
481                    let Some(bytes) = registry.columnar_block(&meta.id) else {
482                        continue;
483                    };
484                    // Format-version gate: reject a block this build cannot
485                    // read rather than mis-decode it.
486                    match peek_column_block_version(&bytes) {
487                        Some(COLUMN_BLOCK_VERSION_V1) => {}
488                        Some(v) => {
489                            return Err(RedDBError::Internal(format!(
490                                "chunk {} @ {} carries unsupported columnar format version {v}",
491                                meta.id.hypertable, meta.id.start_ns
492                            )));
493                        }
494                        None => {
495                            return Err(RedDBError::Internal(format!(
496                                "chunk {} @ {} is flagged columnar but its block is not RDCC",
497                                meta.id.hypertable, meta.id.start_ns
498                            )));
499                        }
500                    }
501                    let scan = crate::storage::timeseries::chunk::query_column_block_range(
502                        &bytes, start_ns, end_ns,
503                    )
504                    .map_err(|err| {
505                        RedDBError::Internal(format!("columnar read-bridge decode failed: {err:?}"))
506                    })?;
507                    out.extend(scan.points.iter().map(|p| (p.timestamp_ns, p.value)));
508                }
509                ChunkFormat::Row => {
510                    // Row reader: materialise the chunk window, then filter
511                    // to the inclusive query range (mirrors the columnar
512                    // scan's `[start_ns, end_ns]` contract).
513                    let Some(manager) = store.get_collection(collection) else {
514                        continue;
515                    };
516                    let chunk_start = meta.id.start_ns;
517                    let chunk_end = meta.end_ns_exclusive;
518                    out.extend(
519                        materialize_row_points(&manager, &time_col, chunk_start, chunk_end)
520                            .into_iter()
521                            .filter(|(ts, _)| *ts >= start_ns && *ts <= end_ns),
522                    );
523                }
524            }
525        }
526        out.sort_by_key(|(ts, _)| *ts);
527        Ok(out)
528    }
529}
530
531pub(crate) fn intern_timeseries_series(
532    store: &crate::storage::unified::UnifiedStore,
533    collection: &str,
534    metric: &str,
535    tags: &HashMap<String, String>,
536) -> RedDBResult<u64> {
537    let canonical_tags = canonical_timeseries_tags(tags);
538    let _ = store.get_or_create_collection(TIMESERIES_SERIES_COLLECTION);
539    let manager = store
540        .get_collection(TIMESERIES_SERIES_COLLECTION)
541        .ok_or_else(|| RedDBError::Internal("timeseries series dictionary missing".to_string()))?;
542
543    let rows = manager.query_all(|entity| {
544        entity
545            .data
546            .as_row()
547            .is_some_and(|row| row_text(row, "collection").is_some_and(|value| value == collection))
548    });
549
550    let mut next_id = 0u64;
551    for entity in &rows {
552        let Some(row) = entity.data.as_row() else {
553            continue;
554        };
555        if let Some(existing_id) = row_u64(row, "series_id") {
556            next_id = next_id.max(existing_id.saturating_add(1));
557        }
558        if row_text(row, "metric") == Some(metric)
559            && row_text(row, "canonical_tags") == Some(canonical_tags.as_str())
560        {
561            if let Some(existing_id) = row_u64(row, "series_id") {
562                return Ok(existing_id);
563            }
564        }
565    }
566
567    let ceiling = timeseries_max_series_per_collection(store, collection);
568    if rows.len() >= ceiling {
569        return Err(RedDBError::Query(format!(
570            "timeseries collection '{collection}' has reached its distinct series ceiling of {ceiling}"
571        )));
572    }
573
574    let series_id = next_id;
575    let mut fields = HashMap::new();
576    fields.insert(
577        "collection".to_string(),
578        Value::text(collection.to_string()),
579    );
580    fields.insert("series_id".to_string(), Value::UnsignedInteger(series_id));
581    fields.insert("metric".to_string(), Value::text(metric.to_string()));
582    fields.insert(
583        "canonical_tags".to_string(),
584        Value::text(canonical_tags.clone()),
585    );
586    fields.insert("tags".to_string(), encoded_timeseries_tags_value(tags));
587
588    store
589        .insert_auto(
590            TIMESERIES_SERIES_COLLECTION,
591            UnifiedEntity::new(
592                EntityId::new(0),
593                EntityKind::TableRow {
594                    table: Arc::from(TIMESERIES_SERIES_COLLECTION),
595                    row_id: 0,
596                },
597                EntityData::Row(crate::storage::RowData {
598                    columns: Vec::new(),
599                    named: Some(fields),
600                    schema: None,
601                }),
602            ),
603        )
604        .map_err(|err| RedDBError::Internal(err.to_string()))?;
605
606    Ok(series_id)
607}
608
609pub(crate) fn hydrate_timeseries_entity(
610    store: &crate::storage::unified::UnifiedStore,
611    entity: &UnifiedEntity,
612) -> UnifiedEntity {
613    let mut hydrated = entity.clone();
614    let EntityData::TimeSeries(point) = &mut hydrated.data else {
615        return hydrated;
616    };
617    if !point.tags.is_empty() {
618        return hydrated;
619    }
620    let Some(series_id) = point.series_id else {
621        return hydrated;
622    };
623    if let Some(tags) = resolve_timeseries_series_tags(store, entity.kind.collection(), series_id) {
624        point.tags = tags;
625    }
626    hydrated
627}
628
629pub(crate) fn resolve_timeseries_series_tags(
630    store: &crate::storage::unified::UnifiedStore,
631    collection: &str,
632    series_id: u64,
633) -> Option<HashMap<String, String>> {
634    let manager = store.get_collection(TIMESERIES_SERIES_COLLECTION)?;
635    let rows = manager.query_all(|entity| {
636        entity.data.as_row().is_some_and(|row| {
637            row_text(row, "collection").is_some_and(|value| value == collection)
638                && row_u64(row, "series_id") == Some(series_id)
639        })
640    });
641    rows.iter()
642        .filter_map(|entity| entity.data.as_row())
643        .find_map(|row| row.get_field("tags").and_then(encoded_tags_from_value))
644}
645
646fn canonical_timeseries_tags(tags: &HashMap<String, String>) -> String {
647    match encoded_timeseries_tags_value(tags) {
648        Value::Json(bytes) => String::from_utf8(bytes).unwrap_or_default(),
649        _ => "{}".to_string(),
650    }
651}
652
653fn encoded_timeseries_tags_value(tags: &HashMap<String, String>) -> Value {
654    let object = tags
655        .iter()
656        .map(|(key, value)| (key.clone(), crate::json::Value::String(value.clone())))
657        .collect();
658    let json = crate::json::Value::Object(object);
659    Value::Json(crate::json::to_vec(&json).unwrap_or_default())
660}
661
662fn encoded_tags_from_value(value: &Value) -> Option<HashMap<String, String>> {
663    let Value::Json(bytes) = value else {
664        return None;
665    };
666    let json: crate::json::Value = crate::json::from_slice(bytes).ok()?;
667    let crate::json::Value::Object(object) = json else {
668        return None;
669    };
670    Some(
671        object
672            .into_iter()
673            .filter_map(|(key, value)| match value {
674                crate::json::Value::String(value) => Some((key, value)),
675                _ => None,
676            })
677            .collect(),
678    )
679}
680
681fn row_text<'a>(row: &'a crate::storage::RowData, field: &str) -> Option<&'a str> {
682    match row.get_field(field) {
683        Some(Value::Text(value)) => Some(value.as_ref()),
684        _ => None,
685    }
686}
687
688fn row_u64(row: &crate::storage::RowData, field: &str) -> Option<u64> {
689    match row.get_field(field) {
690        Some(Value::UnsignedInteger(value)) => Some(*value),
691        Some(Value::Integer(value)) if *value >= 0 => Some(*value as u64),
692        _ => None,
693    }
694}
695
696fn timeseries_max_series_per_collection(
697    store: &crate::storage::unified::UnifiedStore,
698    collection: &str,
699) -> usize {
700    let collection_key = format!("storage.time_series.collections.{collection}.max_series");
701    latest_usize_config(store, &collection_key)
702        .or_else(|| latest_usize_config(store, TIMESERIES_MAX_SERIES_GLOBAL_CONFIG))
703        .unwrap_or(DEFAULT_TIMESERIES_MAX_SERIES_PER_COLLECTION)
704}
705
706fn latest_usize_config(store: &crate::storage::unified::UnifiedStore, key: &str) -> Option<usize> {
707    let manager = store.get_collection("red_config")?;
708    let mut newest: Option<(u64, usize)> = None;
709    manager.for_each_entity(|entity| {
710        let Some(row) = entity.data.as_row() else {
711            return true;
712        };
713        if !row_text(row, "key").is_some_and(|value| value.eq_ignore_ascii_case(key)) {
714            return true;
715        }
716        let Some(value) = row.get_field("value").and_then(value_as_usize) else {
717            return true;
718        };
719        let id = entity.id.raw();
720        if newest.is_none_or(|(best_id, _)| id >= best_id) {
721            newest = Some((id, value));
722        }
723        true
724    });
725    newest.map(|(_, value)| value)
726}
727
728fn value_as_usize(value: &Value) -> Option<usize> {
729    let value = match value {
730        Value::UnsignedInteger(value) => *value,
731        Value::Integer(value) if *value >= 0 => *value as u64,
732        Value::Float(value) if *value >= 0.0 => *value as u64,
733        Value::Text(value) => value.trim().parse().ok()?,
734        _ => return None,
735    };
736    usize::try_from(value).ok()
737}
738
739/// Materialise `(timestamp_ns, value)` rows from the entity/row store for
740/// the half-open chunk window `[start, end)`, timestamp-ordered. This is
741/// the shared row reader: the columnar seal sources its chunk from it, and
742/// the read-bridge serves row-stored chunks through it (#861). `time_col`
743/// names the time axis; the value column follows the `value` convention.
744fn materialize_row_points(
745    manager: &crate::storage::unified::SegmentManager,
746    time_col: &str,
747    start: u64,
748    end: u64,
749) -> Vec<(u64, f64)> {
750    let mut points: Vec<(u64, f64)> = manager
751        .query_all(|entity| {
752            let ts = match &entity.data {
753                EntityData::Row(row) => row.get_field(time_col).and_then(field_as_u64),
754                EntityData::TimeSeries(point) => Some(point.timestamp_ns),
755                _ => None,
756            };
757            ts.is_some_and(|ts| ts >= start && ts < end)
758        })
759        .iter()
760        .filter_map(|entity| match &entity.data {
761            EntityData::Row(row) => {
762                let ts = row.get_field(time_col).and_then(field_as_u64)?;
763                let value = row.get_field("value").and_then(field_as_f64).unwrap_or(0.0);
764                Some((ts, value))
765            }
766            EntityData::TimeSeries(point) => Some((point.timestamp_ns, point.value)),
767            _ => None,
768        })
769        .collect();
770    points.sort_by_key(|(ts, _)| *ts);
771    points
772}
773
774/// Read a row field as a non-negative `u64` timestamp, accepting the
775/// integer shapes the INSERT path stores for a time column (#911).
776fn field_as_u64(value: &Value) -> Option<u64> {
777    match value {
778        Value::Integer(n) | Value::BigInt(n) | Value::Timestamp(n) if *n >= 0 => Some(*n as u64),
779        Value::UnsignedInteger(n) => Some(*n),
780        _ => None,
781    }
782}
783
784/// Read a row field as `f64` for the columnar value column (#911).
785fn field_as_f64(value: &Value) -> Option<f64> {
786    match value {
787        Value::Float(f) => Some(*f),
788        Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
789        Value::UnsignedInteger(n) => Some(*n as f64),
790        _ => None,
791    }
792}
793
794fn save_timeseries_metadata(
795    store: &crate::storage::unified::UnifiedStore,
796    query: &CreateTimeSeriesQuery,
797) -> RedDBResult<()> {
798    remove_timeseries_metadata(store, &query.name);
799    let _ = store.get_or_create_collection(TIMESERIES_META_COLLECTION);
800
801    let mut fields = HashMap::new();
802    fields.insert(
803        "kind".to_string(),
804        Value::text("timeseries_config".to_string()),
805    );
806    fields.insert("series".to_string(), Value::text(query.name.clone()));
807    fields.insert(
808        "retention_ms".to_string(),
809        query
810            .retention_ms
811            .map(Value::UnsignedInteger)
812            .unwrap_or(Value::Null),
813    );
814    fields.insert(
815        "chunk_size".to_string(),
816        query
817            .chunk_size
818            .map(|value| Value::UnsignedInteger(value as u64))
819            .unwrap_or(Value::Null),
820    );
821    fields.insert(
822        "downsample_policies".to_string(),
823        Value::Array(
824            query
825                .downsample_policies
826                .iter()
827                .cloned()
828                .map(Value::text)
829                .collect(),
830        ),
831    );
832
833    store
834        .insert_auto(
835            TIMESERIES_META_COLLECTION,
836            UnifiedEntity::new(
837                EntityId::new(0),
838                EntityKind::TableRow {
839                    table: Arc::from(TIMESERIES_META_COLLECTION),
840                    row_id: 0,
841                },
842                EntityData::Row(crate::storage::RowData {
843                    columns: Vec::new(),
844                    named: Some(fields),
845                    schema: None,
846                }),
847            ),
848        )
849        .map_err(|err| RedDBError::Internal(err.to_string()))?;
850
851    Ok(())
852}
853
854fn remove_timeseries_metadata(store: &crate::storage::unified::UnifiedStore, series: &str) {
855    let Some(manager) = store.get_collection(TIMESERIES_META_COLLECTION) else {
856        return;
857    };
858    let rows = manager.query_all(|entity| {
859        entity.data.as_row().is_some_and(|row| {
860            row.get_field("series").is_some_and(
861                |value| matches!(value, Value::Text(candidate) if &**candidate == series),
862            )
863        })
864    });
865    for row in rows {
866        let _ = store.delete(TIMESERIES_META_COLLECTION, row.id);
867    }
868}
869
870fn remove_timeseries_series_dictionary(
871    store: &crate::storage::unified::UnifiedStore,
872    collection: &str,
873) {
874    let Some(manager) = store.get_collection(TIMESERIES_SERIES_COLLECTION) else {
875        return;
876    };
877    let rows = manager.query_all(|entity| {
878        entity.data.as_row().is_some_and(|row| {
879            row.get_field("collection").is_some_and(
880                |value| matches!(value, Value::Text(candidate) if &**candidate == collection),
881            )
882        })
883    });
884    for row in rows {
885        let _ = store.delete(TIMESERIES_SERIES_COLLECTION, row.id);
886    }
887}
888
889/// Build the contract's [`AnalyticalStorageConfig`] for the automatic
890/// projection policy. `time_key` is the column carrying the time axis —
891/// the hypertable's declared time column, or the timeseries `timestamp`
892/// convention. `None` means the collection explicitly opted out and keeps
893/// the row engine.
894fn analytical_storage_for(
895    columnar: bool,
896    time_key: &str,
897) -> Option<crate::catalog::AnalyticalStorageConfig> {
898    columnar.then(|| crate::catalog::AnalyticalStorageConfig {
899        columnar: true,
900        time_key: time_key.to_string(),
901        order_by_key: None,
902    })
903}
904
905fn hypertable_collection_contract(
906    query: &CreateTimeSeriesQuery,
907) -> crate::physical::CollectionContract {
908    let now = current_unix_ms();
909    let time_key = query
910        .hypertable
911        .as_ref()
912        .map(|ht| ht.time_column.as_str())
913        .unwrap_or("timestamp");
914    crate::physical::CollectionContract {
915        name: query.name.clone(),
916        // Table model — rows go through the normal INSERT path,
917        // which now calls HypertableRegistry::route after each row
918        // lands. Hypertable-specific behaviour (chunk bounds, TTL
919        // sweeps) lives on the registry, not the contract.
920        declared_model: crate::catalog::CollectionModel::Table,
921        schema_mode: crate::catalog::SchemaMode::SemiStructured,
922        origin: crate::physical::ContractOrigin::Explicit,
923        version: 1,
924        created_at_unix_ms: now,
925        updated_at_unix_ms: now,
926        default_ttl_ms: query.retention_ms,
927        vector_dimension: None,
928        vector_metric: None,
929        context_index_fields: Vec::new(),
930        declared_columns: Vec::new(),
931        table_def: None,
932        timestamps_enabled: false,
933        context_index_enabled: false,
934        metrics_raw_retention_ms: None,
935        metrics_rollup_policies: Vec::new(),
936        metrics_tenant_identity: None,
937        metrics_namespace: None,
938        // Hypertable data is conceptually immutable once the chunk
939        // seals. Reject UPDATE / DELETE at parse time and give the
940        // operator a clear message instead of silent coalescing.
941        append_only: true,
942        subscriptions: Vec::new(),
943        analytics_config: Vec::new(),
944        session_key: None,
945        session_gap_ms: None,
946        retention_duration_ms: None,
947        analytical_storage: analytical_storage_for(query.columnar, time_key),
948
949        ai_policy: None,
950    }
951}
952
953fn timeseries_collection_contract(
954    query: &CreateTimeSeriesQuery,
955) -> crate::physical::CollectionContract {
956    let now = current_unix_ms();
957    crate::physical::CollectionContract {
958        name: query.name.clone(),
959        declared_model: crate::catalog::CollectionModel::TimeSeries,
960        schema_mode: crate::catalog::SchemaMode::SemiStructured,
961        origin: crate::physical::ContractOrigin::Explicit,
962        version: 1,
963        created_at_unix_ms: now,
964        updated_at_unix_ms: now,
965        default_ttl_ms: query.retention_ms,
966        vector_dimension: None,
967        vector_metric: None,
968        context_index_fields: Vec::new(),
969        declared_columns: Vec::new(),
970        table_def: None,
971        timestamps_enabled: false,
972        context_index_enabled: false,
973        metrics_raw_retention_ms: None,
974        metrics_rollup_policies: Vec::new(),
975        metrics_tenant_identity: None,
976        metrics_namespace: None,
977        // Time-series collections are append-only by nature — the
978        // storage model forbids in-place UPDATE already, so the flag
979        // makes the catalog honest rather than changing semantics.
980        append_only: true,
981        subscriptions: Vec::new(),
982        analytics_config: Vec::new(),
983        // `WITH SESSION_KEY <col> SESSION_GAP <duration>` from the
984        // CREATE TIMESERIES DDL becomes the default partition/gap
985        // pairing for the SESSIONIZE operator (slice 2+). Stored on
986        // the contract so a restart preserves the values without an
987        // extra metadata side-table.
988        session_key: query.session_key.clone(),
989        session_gap_ms: query.session_gap_ms,
990        retention_duration_ms: None,
991        // Plain timeseries store points under the `timestamp` axis
992        // convention (the `value` column carries the measurement).
993        analytical_storage: analytical_storage_for(query.columnar, "timestamp"),
994
995        ai_policy: None,
996    }
997}
998
999fn current_unix_ms() -> u128 {
1000    std::time::SystemTime::now()
1001        .duration_since(std::time::UNIX_EPOCH)
1002        .unwrap_or_default()
1003        .as_millis()
1004}