1use 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 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 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 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 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 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 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 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 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 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 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 if meta.max_ts_ns < start_ns || meta.min_ts_ns > end_ns {
477 continue;
478 }
479 match meta.format() {
480 ChunkFormat::ColumnarV1 => {
481 let Some(bytes) = registry.columnar_block(&meta.id) else {
484 continue;
485 };
486 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 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
990fn 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
1025fn 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
1035fn 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
1156fn 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 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 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 append_only: true,
1248 subscriptions: Vec::new(),
1249 analytics_config: Vec::new(),
1250 session_key: query.session_key.clone(),
1256 session_gap_ms: query.session_gap_ms,
1257 retention_duration_ms: None,
1258 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}