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