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