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 hydrate_timeseries_entity_ref(store, entity).into_owned()
865}
866
867pub(crate) fn hydrate_timeseries_entity_ref<'a>(
869 store: &crate::storage::unified::UnifiedStore,
870 entity: &'a UnifiedEntity,
871) -> std::borrow::Cow<'a, UnifiedEntity> {
872 let EntityData::TimeSeries(point) = &entity.data else {
873 return std::borrow::Cow::Borrowed(entity);
874 };
875 if !point.tags.is_empty() {
876 return std::borrow::Cow::Borrowed(entity);
877 }
878 let Some(series_id) = point.series_id else {
879 return std::borrow::Cow::Borrowed(entity);
880 };
881 let Some(tags) = resolve_timeseries_series_tags(store, entity.kind.collection(), series_id)
882 else {
883 return std::borrow::Cow::Borrowed(entity);
884 };
885 let mut hydrated = entity.clone();
886 if let EntityData::TimeSeries(point) = &mut hydrated.data {
887 point.tags = tags;
888 }
889 std::borrow::Cow::Owned(hydrated)
890}
891
892pub(crate) fn resolve_timeseries_series_tags(
893 store: &crate::storage::unified::UnifiedStore,
894 collection: &str,
895 series_id: u64,
896) -> Option<HashMap<String, String>> {
897 let manager = store.get_collection(TIMESERIES_SERIES_COLLECTION)?;
898 let rows = manager.query_all(|entity| {
899 entity.data.as_row().is_some_and(|row| {
900 row_text(row, "collection").is_some_and(|value| value == collection)
901 && row_u64(row, "series_id") == Some(series_id)
902 })
903 });
904 rows.iter()
905 .filter_map(|entity| entity.data.as_row())
906 .find_map(|row| row.get_field("tags").and_then(encoded_tags_from_value))
907}
908
909fn canonical_timeseries_tags(tags: &HashMap<String, String>) -> String {
910 match encoded_timeseries_tags_value(tags) {
911 Value::Json(bytes) => String::from_utf8(bytes).unwrap_or_default(),
912 _ => "{}".to_string(),
913 }
914}
915
916fn encoded_timeseries_tags_value(tags: &HashMap<String, String>) -> Value {
917 let object = tags
918 .iter()
919 .map(|(key, value)| (key.clone(), crate::json::Value::String(value.clone())))
920 .collect();
921 let json = crate::json::Value::Object(object);
922 Value::Json(crate::json::to_vec(&json).unwrap_or_default())
923}
924
925fn encoded_tags_from_value(value: &Value) -> Option<HashMap<String, String>> {
926 let Value::Json(bytes) = value else {
927 return None;
928 };
929 let json: crate::json::Value = crate::json::from_slice(bytes).ok()?;
930 let crate::json::Value::Object(object) = json else {
931 return None;
932 };
933 Some(
934 object
935 .into_iter()
936 .filter_map(|(key, value)| match value {
937 crate::json::Value::String(value) => Some((key, value)),
938 _ => None,
939 })
940 .collect(),
941 )
942}
943
944fn row_text<'a>(row: &'a crate::storage::RowData, field: &str) -> Option<&'a str> {
945 match row.get_field(field) {
946 Some(Value::Text(value)) => Some(value.as_ref()),
947 _ => None,
948 }
949}
950
951fn row_u64(row: &crate::storage::RowData, field: &str) -> Option<u64> {
952 match row.get_field(field) {
953 Some(Value::UnsignedInteger(value)) => Some(*value),
954 Some(Value::Integer(value)) if *value >= 0 => Some(*value as u64),
955 _ => None,
956 }
957}
958
959fn timeseries_max_series_per_collection(
960 store: &crate::storage::unified::UnifiedStore,
961 collection: &str,
962) -> usize {
963 let collection_key = format!("storage.time_series.collections.{collection}.max_series");
964 latest_usize_config(store, &collection_key)
965 .or_else(|| latest_usize_config(store, TIMESERIES_MAX_SERIES_GLOBAL_CONFIG))
966 .unwrap_or(DEFAULT_TIMESERIES_MAX_SERIES_PER_COLLECTION)
967}
968
969fn latest_usize_config(store: &crate::storage::unified::UnifiedStore, key: &str) -> Option<usize> {
970 let manager = store.get_collection("red_config")?;
971 let mut newest: Option<(u64, usize)> = None;
972 manager.for_each_entity(|entity| {
973 let Some(row) = entity.data.as_row() else {
974 return true;
975 };
976 if !row_text(row, "key").is_some_and(|value| value.eq_ignore_ascii_case(key)) {
977 return true;
978 }
979 let Some(value) = row.get_field("value").and_then(value_as_usize) else {
980 return true;
981 };
982 let id = entity.id.raw();
983 if newest.is_none_or(|(best_id, _)| id >= best_id) {
984 newest = Some((id, value));
985 }
986 true
987 });
988 newest.map(|(_, value)| value)
989}
990
991fn value_as_usize(value: &Value) -> Option<usize> {
992 let value = match value {
993 Value::UnsignedInteger(value) => *value,
994 Value::Integer(value) if *value >= 0 => *value as u64,
995 Value::Float(value) if *value >= 0.0 => *value as u64,
996 Value::Text(value) => value.trim().parse().ok()?,
997 _ => return None,
998 };
999 usize::try_from(value).ok()
1000}
1001
1002fn materialize_row_points(
1008 manager: &crate::storage::unified::SegmentManager,
1009 time_col: &str,
1010 start: u64,
1011 end: u64,
1012) -> Vec<(u64, f64)> {
1013 let mut points: Vec<(u64, f64)> = manager
1014 .query_all(|entity| {
1015 let ts = match &entity.data {
1016 EntityData::Row(row) => row.get_field(time_col).and_then(field_as_u64),
1017 EntityData::TimeSeries(point) => Some(point.timestamp_ns),
1018 _ => None,
1019 };
1020 ts.is_some_and(|ts| ts >= start && ts < end)
1021 })
1022 .iter()
1023 .filter_map(|entity| match &entity.data {
1024 EntityData::Row(row) => {
1025 let ts = row.get_field(time_col).and_then(field_as_u64)?;
1026 let value = row.get_field("value").and_then(field_as_f64).unwrap_or(0.0);
1027 Some((ts, value))
1028 }
1029 EntityData::TimeSeries(point) => Some((point.timestamp_ns, point.value)),
1030 _ => None,
1031 })
1032 .collect();
1033 points.sort_by_key(|(ts, _)| *ts);
1034 points
1035}
1036
1037fn field_as_u64(value: &Value) -> Option<u64> {
1040 match value {
1041 Value::Integer(n) | Value::BigInt(n) | Value::Timestamp(n) if *n >= 0 => Some(*n as u64),
1042 Value::UnsignedInteger(n) => Some(*n),
1043 _ => None,
1044 }
1045}
1046
1047fn field_as_f64(value: &Value) -> Option<f64> {
1049 match value {
1050 Value::Float(f) => Some(*f),
1051 Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
1052 Value::UnsignedInteger(n) => Some(*n as f64),
1053 _ => None,
1054 }
1055}
1056
1057fn save_timeseries_metadata(
1058 store: &crate::storage::unified::UnifiedStore,
1059 query: &CreateTimeSeriesQuery,
1060) -> RedDBResult<()> {
1061 remove_timeseries_metadata(store, &query.name);
1062 let _ = store.get_or_create_collection(TIMESERIES_META_COLLECTION);
1063
1064 let mut fields = HashMap::new();
1065 fields.insert(
1066 "kind".to_string(),
1067 Value::text("timeseries_config".to_string()),
1068 );
1069 fields.insert("series".to_string(), Value::text(query.name.clone()));
1070 fields.insert(
1071 "retention_ms".to_string(),
1072 query
1073 .retention_ms
1074 .map(Value::UnsignedInteger)
1075 .unwrap_or(Value::Null),
1076 );
1077 fields.insert(
1078 "chunk_size".to_string(),
1079 query
1080 .chunk_size
1081 .map(|value| Value::UnsignedInteger(value as u64))
1082 .unwrap_or(Value::Null),
1083 );
1084 fields.insert(
1085 "downsample_policies".to_string(),
1086 Value::Array(
1087 query
1088 .downsample_policies
1089 .iter()
1090 .cloned()
1091 .map(Value::text)
1092 .collect(),
1093 ),
1094 );
1095
1096 store
1097 .insert_auto(
1098 TIMESERIES_META_COLLECTION,
1099 UnifiedEntity::new(
1100 EntityId::new(0),
1101 EntityKind::TableRow {
1102 table: Arc::from(TIMESERIES_META_COLLECTION),
1103 row_id: 0,
1104 },
1105 EntityData::Row(crate::storage::RowData {
1106 columns: Vec::new(),
1107 named: Some(fields),
1108 schema: None,
1109 }),
1110 ),
1111 )
1112 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1113
1114 Ok(())
1115}
1116
1117fn remove_timeseries_metadata(store: &crate::storage::unified::UnifiedStore, series: &str) {
1118 let Some(manager) = store.get_collection(TIMESERIES_META_COLLECTION) else {
1119 return;
1120 };
1121 let rows = manager.query_all(|entity| {
1122 entity.data.as_row().is_some_and(|row| {
1123 row.get_field("series").is_some_and(
1124 |value| matches!(value, Value::Text(candidate) if &**candidate == series),
1125 )
1126 })
1127 });
1128 for row in rows {
1129 let _ = store.delete(TIMESERIES_META_COLLECTION, row.id);
1130 }
1131}
1132
1133fn remove_timeseries_series_dictionary(
1134 store: &crate::storage::unified::UnifiedStore,
1135 collection: &str,
1136) {
1137 let Some(manager) = store.get_collection(TIMESERIES_SERIES_COLLECTION) else {
1138 return;
1139 };
1140 let rows = manager.query_all(|entity| {
1141 entity.data.as_row().is_some_and(|row| {
1142 row.get_field("collection").is_some_and(
1143 |value| matches!(value, Value::Text(candidate) if &**candidate == collection),
1144 )
1145 })
1146 });
1147 for row in rows {
1148 let _ = store.delete(TIMESERIES_SERIES_COLLECTION, row.id);
1149 }
1150}
1151
1152fn remove_timeseries_tag_index(store: &crate::storage::unified::UnifiedStore, collection: &str) {
1153 let Some(manager) = store.get_collection(TIMESERIES_TAG_INDEX_COLLECTION) else {
1154 return;
1155 };
1156 let rows = manager.query_all(|entity| {
1157 entity.data.as_row().is_some_and(|row| {
1158 row.get_field("collection").is_some_and(
1159 |value| matches!(value, Value::Text(candidate) if &**candidate == collection),
1160 )
1161 })
1162 });
1163 for row in rows {
1164 let _ = store.delete(TIMESERIES_TAG_INDEX_COLLECTION, row.id);
1165 }
1166}
1167
1168fn analytical_storage_for(
1174 columnar: bool,
1175 time_key: &str,
1176) -> Option<crate::catalog::AnalyticalStorageConfig> {
1177 columnar.then(|| crate::catalog::AnalyticalStorageConfig {
1178 columnar: true,
1179 time_key: time_key.to_string(),
1180 order_by_key: None,
1181 })
1182}
1183
1184fn hypertable_collection_contract(
1185 query: &CreateTimeSeriesQuery,
1186) -> crate::physical::CollectionContract {
1187 let now = current_unix_ms();
1188 let time_key = query
1189 .hypertable
1190 .as_ref()
1191 .map(|ht| ht.time_column.as_str())
1192 .unwrap_or("timestamp");
1193 crate::physical::CollectionContract {
1194 name: query.name.clone(),
1195 declared_model: crate::catalog::CollectionModel::Table,
1200 schema_mode: crate::catalog::SchemaMode::SemiStructured,
1201 origin: crate::physical::ContractOrigin::Explicit,
1202 version: 1,
1203 created_at_unix_ms: now,
1204 updated_at_unix_ms: now,
1205 default_ttl_ms: query.retention_ms,
1206 vector_dimension: None,
1207 vector_metric: None,
1208 context_index_fields: Vec::new(),
1209 declared_columns: Vec::new(),
1210 table_def: None,
1211 timestamps_enabled: false,
1212 context_index_enabled: false,
1213 metrics_raw_retention_ms: None,
1214 metrics_rollup_policies: Vec::new(),
1215 metrics_tenant_identity: None,
1216 metrics_namespace: None,
1217 append_only: true,
1221 subscriptions: Vec::new(),
1222 analytics_config: Vec::new(),
1223 session_key: None,
1224 session_gap_ms: None,
1225 retention_duration_ms: None,
1226 analytical_storage: analytical_storage_for(query.columnar, time_key),
1227
1228 ai_policy: None,
1229 }
1230}
1231
1232fn timeseries_collection_contract(
1233 query: &CreateTimeSeriesQuery,
1234) -> crate::physical::CollectionContract {
1235 let now = current_unix_ms();
1236 crate::physical::CollectionContract {
1237 name: query.name.clone(),
1238 declared_model: crate::catalog::CollectionModel::TimeSeries,
1239 schema_mode: crate::catalog::SchemaMode::SemiStructured,
1240 origin: crate::physical::ContractOrigin::Explicit,
1241 version: 1,
1242 created_at_unix_ms: now,
1243 updated_at_unix_ms: now,
1244 default_ttl_ms: query.retention_ms,
1245 vector_dimension: None,
1246 vector_metric: None,
1247 context_index_fields: Vec::new(),
1248 declared_columns: Vec::new(),
1249 table_def: None,
1250 timestamps_enabled: false,
1251 context_index_enabled: false,
1252 metrics_raw_retention_ms: None,
1253 metrics_rollup_policies: Vec::new(),
1254 metrics_tenant_identity: None,
1255 metrics_namespace: None,
1256 append_only: true,
1260 subscriptions: Vec::new(),
1261 analytics_config: Vec::new(),
1262 session_key: query.session_key.clone(),
1268 session_gap_ms: query.session_gap_ms,
1269 retention_duration_ms: None,
1270 analytical_storage: analytical_storage_for(query.columnar, "timestamp"),
1273
1274 ai_policy: None,
1275 }
1276}
1277
1278fn current_unix_ms() -> u128 {
1279 std::time::SystemTime::now()
1280 .duration_since(std::time::UNIX_EPOCH)
1281 .unwrap_or_default()
1282 .as_millis()
1283}
1284
1285#[cfg(test)]
1286mod hydration_tests {
1287 use super::*;
1288
1289 #[test]
1290 fn ordinary_rows_borrow_and_interned_timeseries_tags_are_hydrated() {
1291 let store = crate::storage::UnifiedStore::new();
1292 let row = UnifiedEntity::table_row(
1293 crate::storage::EntityId::new(1),
1294 "rows",
1295 1,
1296 vec![Value::Array(vec![Value::text("large payload"); 256])],
1297 );
1298 let ordinary = hydrate_timeseries_entity_ref(&store, &row);
1299 assert!(matches!(ordinary, std::borrow::Cow::Borrowed(_)));
1300 assert!(std::ptr::eq(ordinary.as_ref(), &row));
1301 let tags = HashMap::from([("host".to_string(), "server-1".to_string())]);
1302 let mut series = UnifiedEntity::table_row(
1303 crate::storage::EntityId::new(0),
1304 TIMESERIES_SERIES_COLLECTION,
1305 0,
1306 vec![],
1307 );
1308 series.data = EntityData::Row(crate::storage::RowData::with_names(
1309 vec![
1310 Value::text("metrics"),
1311 Value::UnsignedInteger(42),
1312 encoded_timeseries_tags_value(&tags),
1313 ],
1314 vec!["collection".into(), "series_id".into(), "tags".into()],
1315 ));
1316 store
1317 .insert_auto(TIMESERIES_SERIES_COLLECTION, series)
1318 .expect("series metadata");
1319 let point = UnifiedEntity::new(
1320 crate::storage::EntityId::new(2),
1321 crate::storage::EntityKind::TimeSeriesPoint(Box::new(
1322 crate::storage::unified::entity::TimeSeriesPointKind {
1323 series: "metrics".into(),
1324 metric: "cpu".into(),
1325 },
1326 )),
1327 EntityData::TimeSeries(crate::storage::unified::entity::TimeSeriesData {
1328 metric: "cpu".into(),
1329 series_id: Some(42),
1330 timestamp_ns: 1,
1331 value: 7.0,
1332 tags: HashMap::new(),
1333 fields: HashMap::new(),
1334 }),
1335 );
1336 let hydrated = hydrate_timeseries_entity_ref(&store, &point);
1337 assert!(matches!(hydrated, std::borrow::Cow::Owned(_)));
1338 let EntityData::TimeSeries(data) = &hydrated.data else {
1339 panic!("point")
1340 };
1341 assert_eq!(data.tags, tags);
1342 assert!(matches!(
1343 hydrate_timeseries_entity_ref(&store, &hydrated),
1344 std::borrow::Cow::Borrowed(_)
1345 ));
1346 let EntityData::TimeSeries(original) = &point.data else {
1347 panic!("point")
1348 };
1349 assert!(original.tags.is_empty());
1350 }
1351}