1use std::collections::HashMap;
4use std::sync::Arc;
5
6use super::*;
7
8const TIMESERIES_META_COLLECTION: &str = "red_timeseries_meta";
9const TIMESERIES_SERIES_COLLECTION: &str = "red_timeseries_series";
10const DEFAULT_TIMESERIES_CHUNK_INTERVAL_NS: u64 = 86_400_000_000_000;
11const TIMESERIES_MAX_SERIES_GLOBAL_CONFIG: &str = "storage.time_series.max_series_per_collection";
12const DEFAULT_TIMESERIES_MAX_SERIES_PER_COLLECTION: usize = 1_000_000;
13
14#[derive(Default)]
15struct SealHypertableChunksOutcome {
16 chunks_sealed: usize,
17 columnar_chunks_sealed: usize,
18}
19
20impl RedDBRuntime {
21 pub fn execute_create_timeseries(
22 &self,
23 raw_query: &str,
24 query: &CreateTimeSeriesQuery,
25 ) -> RedDBResult<RuntimeQueryResult> {
26 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
27 for spec in &query.downsample_policies {
28 crate::storage::timeseries::retention::DownsamplePolicy::parse(spec).ok_or_else(
29 || RedDBError::Query(format!("invalid downsample policy '{}'", spec)),
30 )?;
31 }
32
33 let store = self.inner.db.store();
34 let exists = store.get_collection(&query.name).is_some();
35 if exists {
36 if query.if_not_exists {
37 return Ok(RuntimeQueryResult::ok_message(
38 raw_query.to_string(),
39 &format!("timeseries '{}' already exists", query.name),
40 "create",
41 ));
42 }
43 return Err(RedDBError::Query(format!(
44 "timeseries '{}' already exists",
45 query.name
46 )));
47 }
48 store
49 .create_collection(&query.name)
50 .map_err(|e| RedDBError::Internal(e.to_string()))?;
51 if let Some(ttl_ms) = query.retention_ms {
52 self.inner
53 .db
54 .set_collection_default_ttl_ms(&query.name, ttl_ms);
55 }
56 let contract = if query.hypertable.is_some() {
62 hypertable_collection_contract(query)
63 } else {
64 timeseries_collection_contract(query)
65 };
66 self.inner
67 .db
68 .save_collection_contract(contract)
69 .map_err(|err| RedDBError::Internal(err.to_string()))?;
70 if let Some(tenant_id) = crate::runtime::impl_core::current_tenant() {
75 store.set_config_tree(
76 &format!("red.collection_tenants.{}", query.name),
77 &crate::serde_json::Value::String(tenant_id),
78 );
79 }
80 save_timeseries_metadata(store.as_ref(), query)?;
81
82 let spec = match &query.hypertable {
83 Some(ht) => {
84 let mut spec = crate::storage::timeseries::HypertableSpec::new(
85 query.name.clone(),
86 ht.time_column.clone(),
87 ht.chunk_interval_ns,
88 );
89 if let Some(ttl) = ht.default_ttl_ns {
90 spec = spec.with_ttl_ns(ttl);
91 }
92 spec
93 }
94 None => {
95 let mut spec = crate::storage::timeseries::HypertableSpec::new(
96 query.name.clone(),
97 "timestamp",
98 DEFAULT_TIMESERIES_CHUNK_INTERVAL_NS,
99 );
100 if let Some(ttl_ms) = query.retention_ms {
101 spec = spec.with_ttl_ns(ttl_ms.saturating_mul(1_000_000));
102 }
103 spec
104 }
105 };
106 self.inner.db.hypertables().register(spec);
107
108 self.invalidate_result_cache();
109 self.inner
110 .db
111 .persist_metadata()
112 .map_err(|e| RedDBError::Internal(e.to_string()))?;
113 let columns: Vec<String> = query
117 .hypertable
118 .as_ref()
119 .map(|ht| vec![ht.time_column.clone()])
120 .unwrap_or_else(|| vec!["metric".to_string(), "value".to_string()]);
121 self.schema_vocabulary_apply(
122 crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
123 collection: query.name.clone(),
124 columns,
125 type_tags: Vec::new(),
126 description: None,
127 },
128 );
129
130 let noun = if query.hypertable.is_some() {
131 "hypertable"
132 } else {
133 "timeseries"
134 };
135 let mut msg = format!("{noun} '{}' created", query.name);
136 if let Some(ret) = query.retention_ms {
137 msg.push_str(&format!(" (retention={}ms)", ret));
138 }
139 if let Some(cs) = query.chunk_size {
140 msg.push_str(&format!(" (chunk_size={})", cs));
141 }
142 if !query.downsample_policies.is_empty() {
143 msg.push_str(&format!(
144 " (downsample_policies={})",
145 query.downsample_policies.len()
146 ));
147 }
148 Ok(RuntimeQueryResult::ok_message(
149 raw_query.to_string(),
150 &msg,
151 "create",
152 ))
153 }
154
155 pub fn execute_drop_timeseries(
156 &self,
157 raw_query: &str,
158 query: &DropTimeSeriesQuery,
159 ) -> RedDBResult<RuntimeQueryResult> {
160 self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
161 let store = self.inner.db.store();
162 if super::impl_ddl::is_system_schema_name(&query.name) {
163 return Err(RedDBError::Query("system schema is read-only".to_string()));
164 }
165 if store.get_collection(&query.name).is_none() {
166 if query.if_exists {
167 return Ok(RuntimeQueryResult::ok_message(
168 raw_query.to_string(),
169 &format!("timeseries '{}' does not exist", query.name),
170 "drop",
171 ));
172 }
173 return Err(RedDBError::NotFound(format!(
174 "timeseries '{}' not found",
175 query.name
176 )));
177 }
178 let actual = crate::runtime::ddl::polymorphic_resolver::resolve(
179 &query.name,
180 &self.inner.db.catalog_model_snapshot(),
181 )?;
182 if actual != crate::catalog::CollectionModel::TimeSeries
183 && actual != crate::catalog::CollectionModel::Table
184 {
185 crate::runtime::ddl::polymorphic_resolver::ensure_model_match(
186 crate::catalog::CollectionModel::TimeSeries,
187 actual,
188 )?;
189 }
190 let _ = self.inner.db.hypertables().unregister(&query.name);
194 store
195 .drop_collection(&query.name)
196 .map_err(|e| RedDBError::Internal(e.to_string()))?;
197 self.inner.db.clear_collection_default_ttl_ms(&query.name);
198 self.inner
199 .db
200 .remove_collection_contract(&query.name)
201 .map_err(|err| RedDBError::Internal(err.to_string()))?;
202 remove_timeseries_metadata(store.as_ref(), &query.name);
203 remove_timeseries_series_dictionary(store.as_ref(), &query.name);
204 self.invalidate_result_cache();
205 self.inner
206 .db
207 .persist_metadata()
208 .map_err(|e| RedDBError::Internal(e.to_string()))?;
209 self.schema_vocabulary_apply(
212 crate::runtime::schema_vocabulary::DdlEvent::DropCollection {
213 collection: query.name.clone(),
214 },
215 );
216 Ok(RuntimeQueryResult::ok_message(
217 raw_query.to_string(),
218 &format!("timeseries '{}' dropped", query.name),
219 "drop",
220 ))
221 }
222
223 pub fn seal_hypertable_chunks(&self, collection: &str) -> RedDBResult<usize> {
235 self.seal_hypertable_chunks_internal(collection, usize::MAX, true)
236 .map(|outcome| outcome.columnar_chunks_sealed)
237 }
238
239 pub(crate) fn seal_hypertable_chunks_for_checkpoint(
240 &self,
241 max_chunks: usize,
242 ) -> RedDBResult<usize> {
243 if max_chunks == 0 {
244 return Ok(0);
245 }
246
247 let mut remaining = max_chunks;
248 let mut sealed = 0usize;
249 for collection in self.inner.db.hypertables().names() {
250 if remaining == 0 {
251 break;
252 }
253 let outcome = self.seal_hypertable_chunks_internal(&collection, remaining, false)?;
254 remaining = remaining.saturating_sub(outcome.chunks_sealed);
255 sealed += outcome.chunks_sealed;
256 }
257 Ok(sealed)
258 }
259
260 fn seal_hypertable_chunks_internal(
261 &self,
262 collection: &str,
263 max_chunks: usize,
264 include_tail_chunk: bool,
265 ) -> RedDBResult<SealHypertableChunksOutcome> {
266 if max_chunks == 0 {
267 return Ok(SealHypertableChunksOutcome::default());
268 }
269
270 let analytical = self
271 .inner
272 .db
273 .collection_contract(collection)
274 .and_then(|c| c.analytical_storage.clone());
275 let registry = self.inner.db.hypertables();
276 let Some(spec) = registry.get(collection) else {
277 return Ok(SealHypertableChunksOutcome::default());
278 };
279 let time_col = spec.time_column.clone();
280 let store = self.inner.db.store();
281 let Some(manager) = store.get_collection(collection) else {
282 return Ok(SealHypertableChunksOutcome::default());
283 };
284
285 let chunks = registry.show_chunks(collection);
286 let tail_chunk_start = if include_tail_chunk {
287 None
288 } else {
289 chunks.iter().map(|meta| meta.id.start_ns).max()
290 };
291
292 let mut outcome = SealHypertableChunksOutcome::default();
293 let mut changed = false;
294 for meta in chunks {
295 if outcome.chunks_sealed >= max_chunks {
296 break;
297 }
298 if meta.sealed {
299 continue;
300 }
301 if Some(meta.id.start_ns) == tail_chunk_start {
302 continue;
303 }
304 let start = meta.id.start_ns;
305 let end = meta.end_ns_exclusive;
306
307 let points = materialize_row_points(&manager, &time_col, start, end);
311 let columnar_enabled = analytical.as_ref().is_some_and(|cfg| cfg.columnar);
312 if columnar_enabled && points.len() < self.inner.columnar_projection_size_floor_rows {
313 continue;
314 }
315
316 let mut chunk = crate::storage::timeseries::TimeSeriesChunk::with_max_points(
317 collection.to_string(),
318 HashMap::new(),
319 points.len().max(1),
320 );
321 for (ts, value) in &points {
322 chunk.append(*ts, *value);
323 }
324
325 let routed = crate::storage::timeseries::chunk::seal_chunk_with_config(
326 &mut chunk,
327 analytical.as_ref(),
328 start,
329 0,
330 )
331 .map_err(|err| RedDBError::Internal(format!("columnar seal failed: {err:?}")))?;
332
333 match routed {
334 crate::storage::timeseries::chunk::SealedChunkStorage::Columnar(bytes) => {
335 let page = self
336 .inner
337 .db
338 .write_column_block_page(&bytes)
339 .map_err(|err| {
340 RedDBError::Internal(format!("columnar page write failed: {err}"))
341 })?;
342 registry.seal_chunk_columnar(&meta.id, page, bytes);
343 outcome.columnar_chunks_sealed += 1;
344 outcome.chunks_sealed += 1;
345 changed = true;
346 }
347 crate::storage::timeseries::chunk::SealedChunkStorage::Row => {
348 registry.seal_chunk(&meta.id);
349 outcome.chunks_sealed += 1;
350 changed = true;
351 }
352 }
353 }
354 if changed {
355 self.inner
356 .db
357 .persist_metadata()
358 .map_err(|e| RedDBError::Internal(e.to_string()))?;
359 }
360 Ok(outcome)
361 }
362
363 pub fn columnar_chunk_count(&self, collection: &str) -> usize {
367 self.inner
368 .db
369 .hypertables()
370 .show_chunks(collection)
371 .iter()
372 .filter(|meta| meta.columnar_page.is_some())
373 .count()
374 }
375
376 pub fn columnar_chunk_points(
382 &self,
383 collection: &str,
384 chunk_start_ns: u64,
385 start_ns: u64,
386 end_ns: u64,
387 ) -> Option<Vec<(u64, f64)>> {
388 let id = crate::storage::timeseries::ChunkId {
389 hypertable: collection.to_string(),
390 start_ns: chunk_start_ns,
391 };
392 let bytes = self.inner.db.hypertables().columnar_block(&id)?;
393 let scan =
394 crate::storage::timeseries::chunk::query_column_block_range(&bytes, start_ns, end_ns)
395 .ok()?;
396 Some(
397 scan.points
398 .iter()
399 .map(|p| (p.timestamp_ns, p.value))
400 .collect(),
401 )
402 }
403
404 pub fn columnar_chunk_range_scan(
405 &self,
406 collection: &str,
407 chunk_start_ns: u64,
408 start_ns: u64,
409 end_ns: u64,
410 ) -> Option<crate::storage::timeseries::chunk::PrunedColumnScan> {
411 let id = crate::storage::timeseries::ChunkId {
412 hypertable: collection.to_string(),
413 start_ns: chunk_start_ns,
414 };
415 let bytes = self.inner.db.hypertables().columnar_block(&id)?;
416 crate::storage::timeseries::chunk::query_column_block_range(&bytes, start_ns, end_ns).ok()
417 }
418
419 pub fn columnar_chunk_value_eq_scan(
420 &self,
421 collection: &str,
422 chunk_start_ns: u64,
423 target: f64,
424 ) -> Option<crate::storage::timeseries::chunk::PrunedColumnScan> {
425 let id = crate::storage::timeseries::ChunkId {
426 hypertable: collection.to_string(),
427 start_ns: chunk_start_ns,
428 };
429 let bytes = self.inner.db.hypertables().columnar_block(&id)?;
430 crate::storage::timeseries::chunk::query_column_block_value_eq(&bytes, target).ok()
431 }
432
433 pub fn read_bridge_points(
453 &self,
454 collection: &str,
455 start_ns: u64,
456 end_ns: u64,
457 ) -> RedDBResult<Vec<(u64, f64)>> {
458 use crate::storage::timeseries::ChunkFormat;
459 use crate::storage::unified::column_block::{
460 peek_column_block_version, COLUMN_BLOCK_VERSION_V1,
461 };
462
463 let registry = self.inner.db.hypertables();
464 let Some(spec) = registry.get(collection) else {
465 return Ok(Vec::new());
466 };
467 let time_col = spec.time_column.clone();
468 let store = self.inner.db.store();
469
470 let mut out: Vec<(u64, f64)> = Vec::new();
471 for meta in registry.show_chunks(collection) {
472 if meta.max_ts_ns < start_ns || meta.min_ts_ns > end_ns {
475 continue;
476 }
477 match meta.format() {
478 ChunkFormat::ColumnarV1 => {
479 let Some(bytes) = registry.columnar_block(&meta.id) else {
482 continue;
483 };
484 match peek_column_block_version(&bytes) {
487 Some(COLUMN_BLOCK_VERSION_V1) => {}
488 Some(v) => {
489 return Err(RedDBError::Internal(format!(
490 "chunk {} @ {} carries unsupported columnar format version {v}",
491 meta.id.hypertable, meta.id.start_ns
492 )));
493 }
494 None => {
495 return Err(RedDBError::Internal(format!(
496 "chunk {} @ {} is flagged columnar but its block is not RDCC",
497 meta.id.hypertable, meta.id.start_ns
498 )));
499 }
500 }
501 let scan = crate::storage::timeseries::chunk::query_column_block_range(
502 &bytes, start_ns, end_ns,
503 )
504 .map_err(|err| {
505 RedDBError::Internal(format!("columnar read-bridge decode failed: {err:?}"))
506 })?;
507 out.extend(scan.points.iter().map(|p| (p.timestamp_ns, p.value)));
508 }
509 ChunkFormat::Row => {
510 let Some(manager) = store.get_collection(collection) else {
514 continue;
515 };
516 let chunk_start = meta.id.start_ns;
517 let chunk_end = meta.end_ns_exclusive;
518 out.extend(
519 materialize_row_points(&manager, &time_col, chunk_start, chunk_end)
520 .into_iter()
521 .filter(|(ts, _)| *ts >= start_ns && *ts <= end_ns),
522 );
523 }
524 }
525 }
526 out.sort_by_key(|(ts, _)| *ts);
527 Ok(out)
528 }
529}
530
531pub(crate) fn intern_timeseries_series(
532 store: &crate::storage::unified::UnifiedStore,
533 collection: &str,
534 metric: &str,
535 tags: &HashMap<String, String>,
536) -> RedDBResult<u64> {
537 let canonical_tags = canonical_timeseries_tags(tags);
538 let _ = store.get_or_create_collection(TIMESERIES_SERIES_COLLECTION);
539 let manager = store
540 .get_collection(TIMESERIES_SERIES_COLLECTION)
541 .ok_or_else(|| RedDBError::Internal("timeseries series dictionary missing".to_string()))?;
542
543 let rows = manager.query_all(|entity| {
544 entity
545 .data
546 .as_row()
547 .is_some_and(|row| row_text(row, "collection").is_some_and(|value| value == collection))
548 });
549
550 let mut next_id = 0u64;
551 for entity in &rows {
552 let Some(row) = entity.data.as_row() else {
553 continue;
554 };
555 if let Some(existing_id) = row_u64(row, "series_id") {
556 next_id = next_id.max(existing_id.saturating_add(1));
557 }
558 if row_text(row, "metric") == Some(metric)
559 && row_text(row, "canonical_tags") == Some(canonical_tags.as_str())
560 {
561 if let Some(existing_id) = row_u64(row, "series_id") {
562 return Ok(existing_id);
563 }
564 }
565 }
566
567 let ceiling = timeseries_max_series_per_collection(store, collection);
568 if rows.len() >= ceiling {
569 return Err(RedDBError::Query(format!(
570 "timeseries collection '{collection}' has reached its distinct series ceiling of {ceiling}"
571 )));
572 }
573
574 let series_id = next_id;
575 let mut fields = HashMap::new();
576 fields.insert(
577 "collection".to_string(),
578 Value::text(collection.to_string()),
579 );
580 fields.insert("series_id".to_string(), Value::UnsignedInteger(series_id));
581 fields.insert("metric".to_string(), Value::text(metric.to_string()));
582 fields.insert(
583 "canonical_tags".to_string(),
584 Value::text(canonical_tags.clone()),
585 );
586 fields.insert("tags".to_string(), encoded_timeseries_tags_value(tags));
587
588 store
589 .insert_auto(
590 TIMESERIES_SERIES_COLLECTION,
591 UnifiedEntity::new(
592 EntityId::new(0),
593 EntityKind::TableRow {
594 table: Arc::from(TIMESERIES_SERIES_COLLECTION),
595 row_id: 0,
596 },
597 EntityData::Row(crate::storage::RowData {
598 columns: Vec::new(),
599 named: Some(fields),
600 schema: None,
601 }),
602 ),
603 )
604 .map_err(|err| RedDBError::Internal(err.to_string()))?;
605
606 Ok(series_id)
607}
608
609pub(crate) fn hydrate_timeseries_entity(
610 store: &crate::storage::unified::UnifiedStore,
611 entity: &UnifiedEntity,
612) -> UnifiedEntity {
613 let mut hydrated = entity.clone();
614 let EntityData::TimeSeries(point) = &mut hydrated.data else {
615 return hydrated;
616 };
617 if !point.tags.is_empty() {
618 return hydrated;
619 }
620 let Some(series_id) = point.series_id else {
621 return hydrated;
622 };
623 if let Some(tags) = resolve_timeseries_series_tags(store, entity.kind.collection(), series_id) {
624 point.tags = tags;
625 }
626 hydrated
627}
628
629pub(crate) fn resolve_timeseries_series_tags(
630 store: &crate::storage::unified::UnifiedStore,
631 collection: &str,
632 series_id: u64,
633) -> Option<HashMap<String, String>> {
634 let manager = store.get_collection(TIMESERIES_SERIES_COLLECTION)?;
635 let rows = manager.query_all(|entity| {
636 entity.data.as_row().is_some_and(|row| {
637 row_text(row, "collection").is_some_and(|value| value == collection)
638 && row_u64(row, "series_id") == Some(series_id)
639 })
640 });
641 rows.iter()
642 .filter_map(|entity| entity.data.as_row())
643 .find_map(|row| row.get_field("tags").and_then(encoded_tags_from_value))
644}
645
646fn canonical_timeseries_tags(tags: &HashMap<String, String>) -> String {
647 match encoded_timeseries_tags_value(tags) {
648 Value::Json(bytes) => String::from_utf8(bytes).unwrap_or_default(),
649 _ => "{}".to_string(),
650 }
651}
652
653fn encoded_timeseries_tags_value(tags: &HashMap<String, String>) -> Value {
654 let object = tags
655 .iter()
656 .map(|(key, value)| (key.clone(), crate::json::Value::String(value.clone())))
657 .collect();
658 let json = crate::json::Value::Object(object);
659 Value::Json(crate::json::to_vec(&json).unwrap_or_default())
660}
661
662fn encoded_tags_from_value(value: &Value) -> Option<HashMap<String, String>> {
663 let Value::Json(bytes) = value else {
664 return None;
665 };
666 let json: crate::json::Value = crate::json::from_slice(bytes).ok()?;
667 let crate::json::Value::Object(object) = json else {
668 return None;
669 };
670 Some(
671 object
672 .into_iter()
673 .filter_map(|(key, value)| match value {
674 crate::json::Value::String(value) => Some((key, value)),
675 _ => None,
676 })
677 .collect(),
678 )
679}
680
681fn row_text<'a>(row: &'a crate::storage::RowData, field: &str) -> Option<&'a str> {
682 match row.get_field(field) {
683 Some(Value::Text(value)) => Some(value.as_ref()),
684 _ => None,
685 }
686}
687
688fn row_u64(row: &crate::storage::RowData, field: &str) -> Option<u64> {
689 match row.get_field(field) {
690 Some(Value::UnsignedInteger(value)) => Some(*value),
691 Some(Value::Integer(value)) if *value >= 0 => Some(*value as u64),
692 _ => None,
693 }
694}
695
696fn timeseries_max_series_per_collection(
697 store: &crate::storage::unified::UnifiedStore,
698 collection: &str,
699) -> usize {
700 let collection_key = format!("storage.time_series.collections.{collection}.max_series");
701 latest_usize_config(store, &collection_key)
702 .or_else(|| latest_usize_config(store, TIMESERIES_MAX_SERIES_GLOBAL_CONFIG))
703 .unwrap_or(DEFAULT_TIMESERIES_MAX_SERIES_PER_COLLECTION)
704}
705
706fn latest_usize_config(store: &crate::storage::unified::UnifiedStore, key: &str) -> Option<usize> {
707 let manager = store.get_collection("red_config")?;
708 let mut newest: Option<(u64, usize)> = None;
709 manager.for_each_entity(|entity| {
710 let Some(row) = entity.data.as_row() else {
711 return true;
712 };
713 if !row_text(row, "key").is_some_and(|value| value.eq_ignore_ascii_case(key)) {
714 return true;
715 }
716 let Some(value) = row.get_field("value").and_then(value_as_usize) else {
717 return true;
718 };
719 let id = entity.id.raw();
720 if newest.is_none_or(|(best_id, _)| id >= best_id) {
721 newest = Some((id, value));
722 }
723 true
724 });
725 newest.map(|(_, value)| value)
726}
727
728fn value_as_usize(value: &Value) -> Option<usize> {
729 let value = match value {
730 Value::UnsignedInteger(value) => *value,
731 Value::Integer(value) if *value >= 0 => *value as u64,
732 Value::Float(value) if *value >= 0.0 => *value as u64,
733 Value::Text(value) => value.trim().parse().ok()?,
734 _ => return None,
735 };
736 usize::try_from(value).ok()
737}
738
739fn materialize_row_points(
745 manager: &crate::storage::unified::SegmentManager,
746 time_col: &str,
747 start: u64,
748 end: u64,
749) -> Vec<(u64, f64)> {
750 let mut points: Vec<(u64, f64)> = manager
751 .query_all(|entity| {
752 let ts = match &entity.data {
753 EntityData::Row(row) => row.get_field(time_col).and_then(field_as_u64),
754 EntityData::TimeSeries(point) => Some(point.timestamp_ns),
755 _ => None,
756 };
757 ts.is_some_and(|ts| ts >= start && ts < end)
758 })
759 .iter()
760 .filter_map(|entity| match &entity.data {
761 EntityData::Row(row) => {
762 let ts = row.get_field(time_col).and_then(field_as_u64)?;
763 let value = row.get_field("value").and_then(field_as_f64).unwrap_or(0.0);
764 Some((ts, value))
765 }
766 EntityData::TimeSeries(point) => Some((point.timestamp_ns, point.value)),
767 _ => None,
768 })
769 .collect();
770 points.sort_by_key(|(ts, _)| *ts);
771 points
772}
773
774fn field_as_u64(value: &Value) -> Option<u64> {
777 match value {
778 Value::Integer(n) | Value::BigInt(n) | Value::Timestamp(n) if *n >= 0 => Some(*n as u64),
779 Value::UnsignedInteger(n) => Some(*n),
780 _ => None,
781 }
782}
783
784fn field_as_f64(value: &Value) -> Option<f64> {
786 match value {
787 Value::Float(f) => Some(*f),
788 Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
789 Value::UnsignedInteger(n) => Some(*n as f64),
790 _ => None,
791 }
792}
793
794fn save_timeseries_metadata(
795 store: &crate::storage::unified::UnifiedStore,
796 query: &CreateTimeSeriesQuery,
797) -> RedDBResult<()> {
798 remove_timeseries_metadata(store, &query.name);
799 let _ = store.get_or_create_collection(TIMESERIES_META_COLLECTION);
800
801 let mut fields = HashMap::new();
802 fields.insert(
803 "kind".to_string(),
804 Value::text("timeseries_config".to_string()),
805 );
806 fields.insert("series".to_string(), Value::text(query.name.clone()));
807 fields.insert(
808 "retention_ms".to_string(),
809 query
810 .retention_ms
811 .map(Value::UnsignedInteger)
812 .unwrap_or(Value::Null),
813 );
814 fields.insert(
815 "chunk_size".to_string(),
816 query
817 .chunk_size
818 .map(|value| Value::UnsignedInteger(value as u64))
819 .unwrap_or(Value::Null),
820 );
821 fields.insert(
822 "downsample_policies".to_string(),
823 Value::Array(
824 query
825 .downsample_policies
826 .iter()
827 .cloned()
828 .map(Value::text)
829 .collect(),
830 ),
831 );
832
833 store
834 .insert_auto(
835 TIMESERIES_META_COLLECTION,
836 UnifiedEntity::new(
837 EntityId::new(0),
838 EntityKind::TableRow {
839 table: Arc::from(TIMESERIES_META_COLLECTION),
840 row_id: 0,
841 },
842 EntityData::Row(crate::storage::RowData {
843 columns: Vec::new(),
844 named: Some(fields),
845 schema: None,
846 }),
847 ),
848 )
849 .map_err(|err| RedDBError::Internal(err.to_string()))?;
850
851 Ok(())
852}
853
854fn remove_timeseries_metadata(store: &crate::storage::unified::UnifiedStore, series: &str) {
855 let Some(manager) = store.get_collection(TIMESERIES_META_COLLECTION) else {
856 return;
857 };
858 let rows = manager.query_all(|entity| {
859 entity.data.as_row().is_some_and(|row| {
860 row.get_field("series").is_some_and(
861 |value| matches!(value, Value::Text(candidate) if &**candidate == series),
862 )
863 })
864 });
865 for row in rows {
866 let _ = store.delete(TIMESERIES_META_COLLECTION, row.id);
867 }
868}
869
870fn remove_timeseries_series_dictionary(
871 store: &crate::storage::unified::UnifiedStore,
872 collection: &str,
873) {
874 let Some(manager) = store.get_collection(TIMESERIES_SERIES_COLLECTION) else {
875 return;
876 };
877 let rows = manager.query_all(|entity| {
878 entity.data.as_row().is_some_and(|row| {
879 row.get_field("collection").is_some_and(
880 |value| matches!(value, Value::Text(candidate) if &**candidate == collection),
881 )
882 })
883 });
884 for row in rows {
885 let _ = store.delete(TIMESERIES_SERIES_COLLECTION, row.id);
886 }
887}
888
889fn analytical_storage_for(
895 columnar: bool,
896 time_key: &str,
897) -> Option<crate::catalog::AnalyticalStorageConfig> {
898 columnar.then(|| crate::catalog::AnalyticalStorageConfig {
899 columnar: true,
900 time_key: time_key.to_string(),
901 order_by_key: None,
902 })
903}
904
905fn hypertable_collection_contract(
906 query: &CreateTimeSeriesQuery,
907) -> crate::physical::CollectionContract {
908 let now = current_unix_ms();
909 let time_key = query
910 .hypertable
911 .as_ref()
912 .map(|ht| ht.time_column.as_str())
913 .unwrap_or("timestamp");
914 crate::physical::CollectionContract {
915 name: query.name.clone(),
916 declared_model: crate::catalog::CollectionModel::Table,
921 schema_mode: crate::catalog::SchemaMode::SemiStructured,
922 origin: crate::physical::ContractOrigin::Explicit,
923 version: 1,
924 created_at_unix_ms: now,
925 updated_at_unix_ms: now,
926 default_ttl_ms: query.retention_ms,
927 vector_dimension: None,
928 vector_metric: None,
929 context_index_fields: Vec::new(),
930 declared_columns: Vec::new(),
931 table_def: None,
932 timestamps_enabled: false,
933 context_index_enabled: false,
934 metrics_raw_retention_ms: None,
935 metrics_rollup_policies: Vec::new(),
936 metrics_tenant_identity: None,
937 metrics_namespace: None,
938 append_only: true,
942 subscriptions: Vec::new(),
943 analytics_config: Vec::new(),
944 session_key: None,
945 session_gap_ms: None,
946 retention_duration_ms: None,
947 analytical_storage: analytical_storage_for(query.columnar, time_key),
948
949 ai_policy: None,
950 }
951}
952
953fn timeseries_collection_contract(
954 query: &CreateTimeSeriesQuery,
955) -> crate::physical::CollectionContract {
956 let now = current_unix_ms();
957 crate::physical::CollectionContract {
958 name: query.name.clone(),
959 declared_model: crate::catalog::CollectionModel::TimeSeries,
960 schema_mode: crate::catalog::SchemaMode::SemiStructured,
961 origin: crate::physical::ContractOrigin::Explicit,
962 version: 1,
963 created_at_unix_ms: now,
964 updated_at_unix_ms: now,
965 default_ttl_ms: query.retention_ms,
966 vector_dimension: None,
967 vector_metric: None,
968 context_index_fields: Vec::new(),
969 declared_columns: Vec::new(),
970 table_def: None,
971 timestamps_enabled: false,
972 context_index_enabled: false,
973 metrics_raw_retention_ms: None,
974 metrics_rollup_policies: Vec::new(),
975 metrics_tenant_identity: None,
976 metrics_namespace: None,
977 append_only: true,
981 subscriptions: Vec::new(),
982 analytics_config: Vec::new(),
983 session_key: query.session_key.clone(),
989 session_gap_ms: query.session_gap_ms,
990 retention_duration_ms: None,
991 analytical_storage: analytical_storage_for(query.columnar, "timestamp"),
994
995 ai_policy: None,
996 }
997}
998
999fn current_unix_ms() -> u128 {
1000 std::time::SystemTime::now()
1001 .duration_since(std::time::UNIX_EPOCH)
1002 .unwrap_or_default()
1003 .as_millis()
1004}