1use std::collections::{BTreeMap, HashMap};
27use std::sync::Weak;
28
29use async_trait::async_trait;
30use auth::{
31 PermissionChecker, PermissionCheckerRef, PermissionReq, PermissionTableTarget,
32 PermissionTableTargets, SEMANTIC_GRAPH_QUERY,
33};
34use catalog::CatalogManager;
35use catalog::system_schema::semantic_graph::{
36 DeclarationOrigin, EntityGraphProvider, TableEntityDeclaration,
37};
38use common_catalog::consts::{
39 DEFAULT_PRIVATE_SCHEMA_NAME, DEFAULT_SCHEMA_NAME, INFORMATION_SCHEMA_NAME, OBSERVED_AT_COLUMN,
40 PG_CATALOG_NAME, SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
41};
42use common_error::ext::{BoxedError, ErrorExt};
43use common_error::status_code::StatusCode;
44use common_query::OutputData;
45use common_query::prelude::OTLP_AGGREGATION_TEMPORALITY_LABEL;
46use common_recordbatch::SendableRecordBatchStream;
47use common_telemetry::{debug, warn};
48use common_time::timestamp::TimeUnit;
49use datafusion::dataframe::DataFrame;
50use datafusion_expr::LogicalPlan;
51use futures::TryStreamExt;
52use operator::statement::semantic_graph::{
53 CallsSource, CoDeclaredSource, Conventions, DeclaredSource, ENTITY_TYPE_GEN_AI_AGENT,
54 ENTITY_TYPE_SERVICE, EntityDeclaration, GraphQueryWindow, ImplicitEntity, RegistrySource,
55 RelationshipSources, build_registry_plan, build_relationships_plan, conventions,
56 declared_relationships_schema_matches,
57};
58use query::QueryEngineRef;
59use session::context::{QueryContext, QueryContextBuilder, QueryContextRef};
60use snafu::ResultExt;
61use store_api::storage::ScanRequest;
62use table::TableRef;
63use table::metadata::TableInfo;
64use table::predicate::{TimeRangeExtraction, extract_time_range_strict};
65use table::requests::{
66 EntityRole, SEMANTIC_METRIC_TYPE, SEMANTIC_SIGNAL_TYPE, SEMANTIC_SOURCE, SIGNAL_TYPE_METRIC,
67 SOURCE_OPENTELEMETRY, SOURCE_PROMETHEUS, is_trace_v1_table, parse_entity_columns,
68 parse_entity_option_key,
69};
70
71use crate::error;
72
73pub struct EntityGraphProviderImpl {
75 query_engine: QueryEngineRef,
76 catalog_manager: Weak<dyn CatalogManager>,
77 permission_checker: Option<PermissionCheckerRef>,
78}
79
80struct EntitySource {
81 declarations: Vec<EntityDeclaration>,
82 is_trace: bool,
83 table: TableRef,
84}
85
86struct TraceSource {
87 service: Option<EntityDeclaration>,
88 agent: Option<EntityDeclaration>,
89 table: TableRef,
90}
91
92impl EntityGraphProviderImpl {
93 pub fn new(
94 query_engine: QueryEngineRef,
95 catalog_manager: Weak<dyn CatalogManager>,
96 permission_checker: Option<PermissionCheckerRef>,
97 ) -> Self {
98 Self {
99 query_engine,
100 catalog_manager,
101 permission_checker,
102 }
103 }
104
105 fn authorize_sources(
109 &self,
110 query_ctx: Option<&QueryContext>,
111 targets: PermissionTableTargets,
112 ) -> Result<bool, BoxedError> {
113 let Some(ctx) = query_ctx else {
114 return Ok(true);
115 };
116 match self
117 .permission_checker
118 .as_ref()
119 .check_permission_with_table_targets(
120 ctx.current_user(),
121 PermissionReq::Action(SEMANTIC_GRAPH_QUERY),
122 targets,
123 ) {
124 Ok(_) => Ok(true),
125 Err(err) if err.status_code() == StatusCode::PermissionDenied => Ok(false),
126 Err(err) => Err(BoxedError::new(err)),
127 }
128 }
129
130 fn parse_declarations(table_info: &TableInfo) -> Vec<EntityDeclaration> {
133 type RoleColumns = (Vec<String>, Vec<String>, Vec<String>);
135 let mut by_type: HashMap<String, RoleColumns> = HashMap::new();
136 for (key, value) in &table_info.meta.options.extra_options {
137 let Some((entity_type, role)) = parse_entity_option_key(key) else {
138 continue;
139 };
140 let cols = parse_entity_columns(value);
141 let entry = by_type.entry(entity_type.to_string()).or_default();
142 match role {
143 EntityRole::Id => entry.0 = cols,
144 EntityRole::Descriptive => entry.1 = cols,
145 EntityRole::Scope => entry.2 = cols,
146 }
147 }
148 if by_type.is_empty() {
149 return vec![];
150 }
151 let Some(time_index) = table_info
152 .meta
153 .schema
154 .timestamp_column()
155 .map(|c| c.name.clone())
156 else {
157 return vec![];
158 };
159
160 by_type
161 .into_iter()
162 .filter(|(_, (id, _, _))| !id.is_empty())
163 .filter_map(
164 |(entity_type, (id_columns, descriptive_columns, scope_columns))| {
165 let schema = &table_info.meta.schema;
168 if let Some(missing) = id_columns
169 .iter()
170 .chain(&descriptive_columns)
171 .chain(&scope_columns)
172 .find(|c| schema.column_schema_by_name(c).is_none())
173 {
174 warn!(
175 "Skipping entity declaration `{}` of table `{}`: column `{}` not found",
176 entity_type, table_info.name, missing
177 );
178 return None;
179 }
180 Some(EntityDeclaration {
181 schema: table_info.schema_name.clone(),
182 table: table_info.name.clone(),
183 time_index: time_index.clone(),
184 entity_type,
185 id_columns,
186 id_qualifier: None,
187 superseded_by_columns: vec![],
188 descriptive_columns,
189 scope_columns,
190 })
191 },
192 )
193 .collect()
194 }
195
196 fn declarations_for(
205 table_info: &TableInfo,
206 conventions: &Conventions,
207 ) -> Vec<EntityDeclaration> {
208 let mut declarations = Self::parse_declarations(table_info);
209 let mut supersessions = Vec::new();
210 if is_trace_v1_table(table_info) {
211 Self::extend_with_implicit_entities(
212 table_info,
213 &conventions.otlp_trace_entities,
214 &mut declarations,
215 &mut supersessions,
216 );
217 }
218 Self::extend_with_info_metric_conventions(
219 table_info,
220 &conventions.prometheus_info_metrics,
221 SOURCE_PROMETHEUS,
222 None,
223 &mut declarations,
224 &mut supersessions,
225 );
226 Self::extend_with_info_metric_conventions(
227 table_info,
228 &conventions.otel_info_metrics,
229 SOURCE_OPENTELEMETRY,
230 Some(servers::semantic::METRIC_TYPE_INFO),
231 &mut declarations,
232 &mut supersessions,
233 );
234 Self::resolve_supersessions(&mut declarations, supersessions);
235 declarations
236 }
237
238 fn resolve_supersessions(
243 declarations: &mut [EntityDeclaration],
244 supersessions: Vec<(usize, String)>,
245 ) {
246 for (index, entity_type) in supersessions {
247 let identity = declarations
248 .iter()
249 .find(|declaration| declaration.entity_type == entity_type)
250 .map(|declaration| declaration.id_columns.clone())
251 .unwrap_or_default();
252 declarations[index].superseded_by_columns = identity;
253 }
254 }
255
256 fn explicitly_declares(table_info: &TableInfo, entity_type: &str) -> bool {
258 table_info.meta.options.extra_options.keys().any(|key| {
259 parse_entity_option_key(key)
260 .is_some_and(|(ty, role)| ty == entity_type && role == EntityRole::Id)
261 })
262 }
263
264 fn extend_with_info_metric_conventions(
272 table_info: &TableInfo,
273 whitelist: &BTreeMap<String, Vec<ImplicitEntity>>,
274 expected_source: &str,
275 expected_metric_type: Option<&str>,
276 declarations: &mut Vec<EntityDeclaration>,
277 supersessions: &mut Vec<(usize, String)>,
278 ) {
279 let Some(implicit_entities) = whitelist.get(&table_info.name) else {
280 return;
281 };
282 let options = &table_info.meta.options.extra_options;
283 if options.get(SEMANTIC_SIGNAL_TYPE).map(String::as_str) != Some(SIGNAL_TYPE_METRIC)
284 || options.get(SEMANTIC_SOURCE).map(String::as_str) != Some(expected_source)
285 || expected_metric_type.is_some_and(|expected| {
286 options.get(SEMANTIC_METRIC_TYPE).map(String::as_str) != Some(expected)
287 })
288 || table_info.is_physical_table()
289 {
290 debug!(
291 "Table `{}` matches the info-metric whitelist but is not an eligible \
292 `{expected_source}` info-metric source; skipping its implicit declarations",
293 table_info.name
294 );
295 return;
296 }
297 Self::extend_with_implicit_entities(
298 table_info,
299 implicit_entities,
300 declarations,
301 supersessions,
302 );
303 }
304
305 fn extend_with_implicit_entities(
309 table_info: &TableInfo,
310 entities: &[ImplicitEntity],
311 declarations: &mut Vec<EntityDeclaration>,
312 supersessions: &mut Vec<(usize, String)>,
313 ) {
314 let schema = &table_info.meta.schema;
315 let Some(time_index) = schema.timestamp_column().map(|c| c.name.clone()) else {
316 debug!(
317 "Table `{}` has no time index; skipping its implicit declarations",
318 table_info.name
319 );
320 return;
321 };
322 for implicit in entities {
323 if Self::explicitly_declares(table_info, &implicit.entity) {
324 debug!(
325 "Table `{}` explicitly declares `{}`; the implicit declaration is suppressed",
326 table_info.name, implicit.entity
327 );
328 continue;
329 }
330 if let Some(missing) = implicit
331 .id
332 .iter()
333 .find(|c| schema.column_schema_by_name(c).is_none())
334 {
335 debug!(
336 "Table `{}` lacks the id column `{}`; skipping the implicit `{}` declaration",
337 table_info.name, missing, implicit.entity
338 );
339 continue;
340 }
341 let descriptive_columns = if implicit.descriptive_rest {
342 table_info
343 .meta
344 .row_key_column_names()
345 .filter(|c| !implicit.id.contains(c))
346 .filter(|c| c.as_str() != OTLP_AGGREGATION_TEMPORALITY_LABEL)
347 .cloned()
348 .collect()
349 } else {
350 implicit
351 .descriptive
352 .iter()
353 .filter(|c| schema.column_schema_by_name(c).is_some())
354 .cloned()
355 .collect()
356 };
357 let id_qualifier = implicit
360 .qualified_by
361 .clone()
362 .filter(|c| schema.column_schema_by_name(c).is_some());
363 if let Some(entity_type) = &implicit.superseded_by {
364 supersessions.push((declarations.len(), entity_type.clone()));
365 }
366 declarations.push(EntityDeclaration {
367 schema: table_info.schema_name.clone(),
368 table: table_info.name.clone(),
369 time_index: time_index.clone(),
370 entity_type: implicit.entity.clone(),
371 id_columns: implicit.id.clone(),
372 id_qualifier,
373 superseded_by_columns: vec![],
374 descriptive_columns,
375 scope_columns: vec![],
376 });
377 }
378 }
379
380 async fn enumerate(
385 &self,
386 catalog: &str,
387 query_ctx: Option<&QueryContext>,
388 ) -> Result<(Vec<EntitySource>, Vec<TraceSource>), BoxedError> {
389 let Some(catalog_manager) = self.catalog_manager.upgrade() else {
390 return Ok((vec![], vec![]));
391 };
392 let conventions = conventions()
393 .map_err(datafusion::error::DataFusionError::Internal)
394 .context(error::DataFusionSnafu)
395 .map_err(BoxedError::new)?;
396
397 let per_table_auth = match self.permission_checker.as_ref() {
400 Some(checker) if query_ctx.is_some() => {
401 if checker.uses_table_targets() {
402 true
403 } else if self
404 .authorize_sources(query_ctx, PermissionTableTargets::resolved(vec![]))?
405 {
406 false
407 } else {
408 debug!(
409 "Caller lacks the entity-graph read permission; deriving an empty graph \
410 (catalog: {catalog})"
411 );
412 return Ok((vec![], vec![]));
413 }
414 }
415 _ => false,
416 };
417
418 let mut declarations = vec![];
419 let mut traces = vec![];
420 let schemas = catalog_manager
421 .schema_names(catalog, query_ctx)
422 .await
423 .map_err(BoxedError::new)?;
424 for schema in schemas {
425 if schema == INFORMATION_SCHEMA_NAME
428 || schema == PG_CATALOG_NAME
429 || schema == DEFAULT_PRIVATE_SCHEMA_NAME
430 {
431 continue;
432 }
433 let mut tables = catalog_manager.tables(catalog, &schema, query_ctx);
434 while let Some(table) = tables.try_next().await.map_err(BoxedError::new)? {
435 let table_info = table.table_info();
436 let table_declarations = Self::declarations_for(&table_info, conventions);
437 let is_trace = is_trace_v1_table(&table_info);
438 if per_table_auth
440 && (is_trace || !table_declarations.is_empty())
441 && !self.authorize_sources(
442 query_ctx,
443 PermissionTableTargets::resolved(vec![PermissionTableTarget::new(
444 catalog,
445 &schema,
446 &table_info.name,
447 )]),
448 )?
449 {
450 debug!(
451 "Excluding `{schema}.{}` from the entity-graph derivation: caller lacks \
452 read permission",
453 table_info.name
454 );
455 continue;
456 }
457 if is_trace {
458 let find = |entity_type: &str| {
459 table_declarations
460 .iter()
461 .find(|d| d.entity_type == entity_type)
462 .cloned()
463 };
464 let service = find(ENTITY_TYPE_SERVICE);
465 let agent = find(ENTITY_TYPE_GEN_AI_AGENT);
466 if service.is_none() {
467 warn!(
470 "Trace table `{}` has no usable service declaration; skipping calls derivation",
471 table_info.name
472 );
473 }
474 if service.is_some() || agent.is_some() {
475 traces.push(TraceSource {
476 service,
477 agent,
478 table: table.clone(),
479 });
480 }
481 }
482 if !table_declarations.is_empty() {
483 declarations.push(EntitySource {
484 declarations: table_declarations,
485 is_trace,
486 table,
487 });
488 }
489 }
490 }
491 Ok((declarations, traces))
492 }
493
494 fn query_window(request: &ScanRequest) -> Result<Option<GraphQueryWindow>, BoxedError> {
506 let invalid =
507 |err_msg: String| Err(BoxedError::new(error::InvalidSqlSnafu { err_msg }.build()));
508 match extract_time_range_strict(OBSERVED_AT_COLUMN, TimeUnit::Millisecond, &request.filters)
509 {
510 TimeRangeExtraction::Absent => Ok(Some(GraphQueryWindow::default_last_hour())),
511 TimeRangeExtraction::Extracted(range) => {
512 let Some(start) = range.start() else {
513 return invalid(format!(
514 "the {OBSERVED_AT_COLUMN} filter has no lower bound; the graph cannot \
515 derive over unbounded history — add e.g. {OBSERVED_AT_COLUMN} >= \
516 '2026-01-01 00:00:00'"
517 ));
518 };
519 let end_ms = range
520 .end()
521 .map(|ts| ts.value())
522 .unwrap_or_else(common_time::util::current_time_millis);
523 if start.value() >= end_ms {
524 return Ok(None);
525 }
526 Ok(Some(GraphQueryWindow::from_observed(start.value(), end_ms)))
527 }
528 TimeRangeExtraction::Unsupported => invalid(format!(
529 "cannot derive the graph window from the {OBSERVED_AT_COLUMN} filter; use plain \
530 range predicates (>=, <, BETWEEN) with literal bounds"
531 )),
532 }
533 }
534
535 fn read_table(&self, table: TableRef) -> Result<DataFrame, BoxedError> {
536 self.query_engine.read_table(table).map_err(BoxedError::new)
537 }
538
539 async fn declared_source(
544 &self,
545 catalog: &str,
546 query_ctx: Option<&QueryContext>,
547 ) -> Result<Option<DeclaredSource>, BoxedError> {
548 let Some(catalog_manager) = self.catalog_manager.upgrade() else {
549 return Ok(None);
550 };
551 let Some(table) = catalog_manager
552 .table(
553 catalog,
554 DEFAULT_PRIVATE_SCHEMA_NAME,
555 SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
556 query_ctx,
557 )
558 .await
559 .map_err(BoxedError::new)?
560 else {
561 return Ok(None);
562 };
563 if !self.authorize_sources(
564 query_ctx,
565 PermissionTableTargets::resolved(vec![PermissionTableTarget::new(
566 catalog,
567 DEFAULT_PRIVATE_SCHEMA_NAME,
568 SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
569 )]),
570 )? {
571 debug!(
572 "Excluding declared edges: caller lacks read permission on \
573 `{DEFAULT_PRIVATE_SCHEMA_NAME}.{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME}`"
574 );
575 return Ok(None);
576 }
577 let table_info = table.table_info();
578 if !declared_relationships_schema_matches(&table_info) {
579 return Err(BoxedError::new(
580 error::InvalidSqlSnafu {
581 err_msg: format!(
582 "{DEFAULT_PRIVATE_SCHEMA_NAME}.{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME} \
583 does not match its canonical schema; declared edges cannot be derived"
584 ),
585 }
586 .build(),
587 ));
588 }
589 Ok(Some(DeclaredSource {
590 scan: self.read_table(table)?,
591 }))
592 }
593
594 async fn execute_plan(
598 &self,
599 catalog: &str,
600 plan: LogicalPlan,
601 query_ctx: Option<QueryContextRef>,
602 ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
603 let query_ctx = query_ctx.unwrap_or_else(|| {
604 QueryContextBuilder::default()
605 .current_catalog(catalog.to_string())
606 .current_schema(DEFAULT_SCHEMA_NAME.to_string())
607 .build()
608 .into()
609 });
610 let output = self
611 .query_engine
612 .execute(plan, query_ctx)
613 .await
614 .map_err(BoxedError::new)?;
615 let stream = match output.data {
616 OutputData::Stream(stream) => stream,
617 OutputData::RecordBatches(batches) => batches.as_stream(),
618 OutputData::AffectedRows(_) => return Ok(None),
619 };
620 Ok(Some(stream))
621 }
622}
623
624#[async_trait]
625impl EntityGraphProvider for EntityGraphProviderImpl {
626 async fn scan_entities(
627 &self,
628 catalog: &str,
629 request: ScanRequest,
630 query_ctx: Option<QueryContextRef>,
631 ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
632 let (sources, _) = self.enumerate(catalog, query_ctx.as_deref()).await?;
633 let mut plans = Vec::with_capacity(sources.len());
634 for source in sources {
635 plans.push(RegistrySource {
636 declarations: source.declarations,
637 scan: self.read_table(source.table)?,
638 });
639 }
640 let Some(window) = Self::query_window(&request)? else {
641 return Ok(None);
642 };
643 let Some(plan) = build_registry_plan(plans, &window)
644 .context(error::DataFusionSnafu)
645 .map_err(BoxedError::new)?
646 else {
647 return Ok(None);
648 };
649 self.execute_plan(catalog, plan, query_ctx).await
650 }
651
652 async fn scan_relationships(
653 &self,
654 catalog: &str,
655 request: ScanRequest,
656 query_ctx: Option<QueryContextRef>,
657 ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
658 let (sources, traces) = self.enumerate(catalog, query_ctx.as_deref()).await?;
659 let mut calls = Vec::with_capacity(traces.len());
660 for trace in traces {
661 calls.push(CallsSource {
662 service: trace.service,
663 agent: trace.agent,
664 scan: self.read_table(trace.table)?,
665 });
666 }
667 let mut co_declared = Vec::with_capacity(sources.len());
668 for source in sources {
669 co_declared.push(CoDeclaredSource {
670 declarations: source.declarations,
671 is_trace: source.is_trace,
672 scan: self.read_table(source.table)?,
673 });
674 }
675 let declared = self.declared_source(catalog, query_ctx.as_deref()).await?;
676 let Some(window) = Self::query_window(&request)? else {
677 return Ok(None);
678 };
679 let Some(plan) = build_relationships_plan(
680 RelationshipSources {
681 traces: calls,
682 co_declared,
683 declared,
684 },
685 &window,
686 )
687 .context(error::DataFusionSnafu)
688 .map_err(BoxedError::new)?
689 else {
690 return Ok(None);
691 };
692 self.execute_plan(catalog, plan, query_ctx).await
693 }
694
695 fn table_declarations(&self, table_info: &TableInfo) -> Vec<TableEntityDeclaration> {
696 let derived = match conventions() {
699 Ok(conventions) => Self::declarations_for(table_info, conventions),
700 Err(_) => Self::parse_declarations(table_info),
701 };
702 let mut declarations = derived
703 .into_iter()
704 .map(|declaration| TableEntityDeclaration {
705 origin: if Self::explicitly_declares(table_info, &declaration.entity_type) {
706 DeclarationOrigin::Declared
707 } else {
708 DeclarationOrigin::Convention
709 },
710 entity_type: declaration.entity_type,
711 id_columns: declaration.id_columns,
712 id_qualifier: declaration.id_qualifier,
713 superseded_by_columns: declaration.superseded_by_columns,
714 descriptive_columns: declaration.descriptive_columns,
715 scope_columns: declaration.scope_columns,
716 })
717 .collect::<Vec<_>>();
718 declarations.sort_by(|a, b| a.entity_type.cmp(&b.entity_type));
719 declarations
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use std::collections::HashMap;
726 use std::sync::Arc;
727
728 use common_catalog::consts::{DEFAULT_CATALOG_NAME, MITO_ENGINE};
729 use datatypes::prelude::ConcreteDataType;
730 use datatypes::schema::{ColumnSchema, SchemaBuilder};
731 use store_api::metric_engine_consts::PHYSICAL_TABLE_METADATA_KEY;
732 use table::metadata::{TableInfoBuilder, TableMeta, TableType};
733 use table::requests::{TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1, TableOptions};
734
735 use super::*;
736
737 fn table_info(columns: &[&str], extra: &[(&str, &str)]) -> TableInfo {
738 let mut column_schemas = vec![
739 ColumnSchema::new(
740 "ts",
741 ConcreteDataType::timestamp_millisecond_datatype(),
742 false,
743 )
744 .with_time_index(true),
745 ];
746 column_schemas.extend(
747 columns
748 .iter()
749 .map(|c| ColumnSchema::new(*c, ConcreteDataType::string_datatype(), true)),
750 );
751 assemble_table_info(column_schemas, extra)
752 }
753
754 fn assemble_table_info(column_schemas: Vec<ColumnSchema>, extra: &[(&str, &str)]) -> TableInfo {
755 named_table_info("t1", column_schemas, vec![], extra)
756 }
757
758 fn named_table_info(
759 name: &str,
760 column_schemas: Vec<ColumnSchema>,
761 primary_key_indices: Vec<usize>,
762 extra: &[(&str, &str)],
763 ) -> TableInfo {
764 let schema = Arc::new(
765 SchemaBuilder::try_from_columns(column_schemas)
766 .unwrap()
767 .build()
768 .unwrap(),
769 );
770 let options = TableOptions {
771 extra_options: extra
772 .iter()
773 .map(|(k, v)| (k.to_string(), v.to_string()))
774 .collect::<HashMap<_, _>>(),
775 ..Default::default()
776 };
777 let meta = TableMeta {
778 schema,
779 primary_key_indices,
780 value_indices: vec![],
781 engine: MITO_ENGINE.to_string(),
782 next_column_id: 1,
783 options,
784 created_on: Default::default(),
785 updated_on: Default::default(),
786 partition_key_indices: vec![],
787 column_ids: vec![],
788 };
789 TableInfoBuilder::default()
790 .table_id(1)
791 .name(name)
792 .catalog_name(DEFAULT_CATALOG_NAME)
793 .schema_name(DEFAULT_SCHEMA_NAME)
794 .table_version(0)
795 .table_type(TableType::Base)
796 .meta(meta)
797 .build()
798 .unwrap()
799 }
800
801 fn prom_table_info(name: &str, tags: &[&str], extra: &[(&str, &str)]) -> TableInfo {
803 let mut column_schemas = vec![
804 ColumnSchema::new(
805 "greptime_timestamp",
806 ConcreteDataType::timestamp_millisecond_datatype(),
807 false,
808 )
809 .with_time_index(true),
810 ];
811 column_schemas.extend(
812 tags.iter()
813 .map(|c| ColumnSchema::new(*c, ConcreteDataType::string_datatype(), true)),
814 );
815 let primary_key_indices = (1..=tags.len()).collect();
816 named_table_info(name, column_schemas, primary_key_indices, extra)
817 }
818
819 #[test]
820 fn future_lower_only_window_matches_nothing() {
821 let future_ms = common_time::util::current_time_millis() + 86_400_000;
822 let request = ScanRequest {
823 filters: vec![
824 datafusion_expr::col(OBSERVED_AT_COLUMN).gt_eq(datafusion_expr::lit(
825 datafusion::common::ScalarValue::TimestampMillisecond(Some(future_ms), None),
826 )),
827 ],
828 ..Default::default()
829 };
830 assert!(
831 EntityGraphProviderImpl::query_window(&request)
832 .unwrap()
833 .is_none()
834 );
835
836 assert!(
837 EntityGraphProviderImpl::query_window(&ScanRequest::default())
838 .unwrap()
839 .is_some()
840 );
841 }
842
843 #[test]
844 fn declaration_referencing_missing_column_is_skipped() {
845 let info = table_info(
846 &["service_name"],
847 &[
848 ("greptime.semantic.entity.service.id", "service_name"),
849 ("greptime.semantic.entity.host.id", "gone"),
850 ],
851 );
852 let declarations = EntityGraphProviderImpl::declarations_for(&info, conventions().unwrap());
853 assert_eq!(declarations.len(), 1);
854 assert_eq!(declarations[0].entity_type, "service");
855
856 let info = table_info(
857 &["service_name"],
858 &[
859 ("greptime.semantic.entity.service.id", "service_name"),
860 ("greptime.semantic.entity.service.descriptive", "gone"),
861 ],
862 );
863 assert!(
864 EntityGraphProviderImpl::declarations_for(&info, conventions().unwrap()).is_empty()
865 );
866 }
867
868 const PROM_STAMPS: &[(&str, &str)] = &[
869 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
870 (SEMANTIC_SOURCE, SOURCE_PROMETHEUS),
871 ];
872
873 fn sorted_declarations(info: &TableInfo) -> Vec<EntityDeclaration> {
874 let mut declarations =
875 EntityGraphProviderImpl::declarations_for(info, conventions().unwrap());
876 declarations.sort_by(|a, b| a.entity_type.cmp(&b.entity_type));
877 declarations
878 }
879
880 #[test]
881 fn prometheus_info_metric_gets_implicit_declarations() {
882 let info = prom_table_info(
883 "kube_pod_info",
884 &["namespace", "pod", "uid", "node", "job", "instance"],
885 PROM_STAMPS,
886 );
887 let declarations = sorted_declarations(&info);
888 assert_eq!(declarations.len(), 2);
889 assert_eq!(declarations[0].entity_type, "k8s.node");
890 assert_eq!(declarations[0].id_columns, vec!["node"]);
891 assert_eq!(declarations[1].entity_type, "k8s.pod");
892 assert_eq!(declarations[1].id_columns, vec!["uid"]);
893 assert_eq!(
896 declarations[1].descriptive_columns,
897 vec!["namespace", "pod", "node"]
898 );
899 assert_eq!(declarations[1].time_index, "greptime_timestamp");
900 }
901
902 #[test]
903 fn trace_table_gets_implicit_resource_entities() {
904 let full = table_info(
905 &[
906 "service_name",
907 "resource_attributes.service.instance.id",
908 "resource_attributes.k8s.pod.uid",
909 "resource_attributes.k8s.pod.name",
910 "resource_attributes.k8s.node.name",
911 ],
912 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
913 );
914 let declarations = sorted_declarations(&full);
915 let types: Vec<&str> = declarations
916 .iter()
917 .map(|d| d.entity_type.as_str())
918 .collect();
919 assert_eq!(
920 types,
921 vec!["k8s.node", "k8s.pod", "service", "service.instance"]
922 );
923 assert_eq!(
924 declarations[0].id_columns,
925 vec!["resource_attributes.k8s.node.name"]
926 );
927 assert_eq!(
928 declarations[1].id_columns,
929 vec!["resource_attributes.k8s.pod.uid"]
930 );
931 assert_eq!(
932 declarations[1].descriptive_columns,
933 vec!["resource_attributes.k8s.pod.name"]
934 );
935 assert_eq!(
936 declarations[3].id_columns,
937 vec!["service_name", "resource_attributes.service.instance.id"]
938 );
939 assert_eq!(declarations[2].id_qualifier, None);
940
941 let namespaced = table_info(
942 &[
943 "service_name",
944 "resource_attributes.service.namespace",
945 "resource_attributes.service.instance.id",
946 ],
947 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
948 );
949 for declaration in sorted_declarations(&namespaced) {
950 assert_eq!(
951 declaration.id_qualifier.as_deref(),
952 Some("resource_attributes.service.namespace"),
953 "{} must qualify its identity like the metric side's job",
954 declaration.entity_type
955 );
956 }
957
958 let no_uid = table_info(
960 &["service_name", "resource_attributes.k8s.pod.name"],
961 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
962 );
963 let types: Vec<String> = sorted_declarations(&no_uid)
964 .into_iter()
965 .map(|d| d.entity_type)
966 .collect();
967 assert_eq!(types, vec!["service"]);
968
969 let invalid_explicit = table_info(
972 &["service_name"],
973 &[
974 (TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1),
975 ("greptime.semantic.entity.service.id", "gone"),
976 ],
977 );
978 assert!(sorted_declarations(&invalid_explicit).is_empty());
979 }
980
981 #[test]
982 fn trace_table_host_and_container_require_stable_ids() {
983 let with_ids = table_info(
984 &[
985 "service_name",
986 "resource_attributes.host.id",
987 "resource_attributes.host.name",
988 "resource_attributes.container.id",
989 ],
990 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
991 );
992 let declarations = sorted_declarations(&with_ids);
993 let types: Vec<&str> = declarations
994 .iter()
995 .map(|d| d.entity_type.as_str())
996 .collect();
997 assert_eq!(types, vec!["container", "host", "service"]);
998 assert_eq!(
999 declarations[1].id_columns,
1000 vec!["resource_attributes.host.id"]
1001 );
1002 assert_eq!(
1003 declarations[1].descriptive_columns,
1004 vec!["resource_attributes.host.name"]
1005 );
1006
1007 let pod_container = table_info(
1011 &[
1012 "service_name",
1013 "resource_attributes.container.id",
1014 "resource_attributes.container.name",
1015 "resource_attributes.k8s.pod.uid",
1016 "resource_attributes.k8s.container.name",
1017 ],
1018 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
1019 );
1020 let declarations = sorted_declarations(&pod_container);
1021 let types: Vec<&str> = declarations
1022 .iter()
1023 .map(|d| d.entity_type.as_str())
1024 .collect();
1025 assert_eq!(
1026 types,
1027 vec!["container", "k8s.container", "k8s.pod", "service"]
1028 );
1029 assert_eq!(
1030 declarations[0].superseded_by_columns,
1031 vec![
1032 "resource_attributes.k8s.pod.uid",
1033 "resource_attributes.k8s.container.name"
1034 ]
1035 );
1036 assert_eq!(
1037 declarations[1].descriptive_columns,
1038 vec![
1039 "resource_attributes.container.id",
1040 "resource_attributes.container.name"
1041 ]
1042 );
1043
1044 let names_only = table_info(
1045 &[
1046 "service_name",
1047 "resource_attributes.host.name",
1048 "resource_attributes.container.name",
1049 ],
1050 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
1051 );
1052 let types: Vec<String> = sorted_declarations(&names_only)
1053 .into_iter()
1054 .map(|d| d.entity_type)
1055 .collect();
1056 assert_eq!(types, vec!["service"]);
1057 }
1058
1059 #[test]
1060 fn prometheus_implicit_declarations_are_gated() {
1061 let labels: &[&str] = &["namespace", "pod", "node"];
1062 assert!(
1064 sorted_declarations(&prom_table_info(
1065 "kube_pod_info",
1066 labels,
1067 &[
1068 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
1069 (SEMANTIC_SOURCE, "opentelemetry"),
1070 ],
1071 ))
1072 .is_empty()
1073 );
1074 assert!(
1076 sorted_declarations(&prom_table_info("http_requests_total", labels, PROM_STAMPS))
1077 .is_empty()
1078 );
1079 let mut stamps = PROM_STAMPS.to_vec();
1080 stamps.push((PHYSICAL_TABLE_METADATA_KEY, "true"));
1081 assert!(sorted_declarations(&prom_table_info("kube_pod_info", labels, &stamps)).is_empty());
1082
1083 let info = prom_table_info("kube_pod_info", &["namespace", "pod", "node"], PROM_STAMPS);
1085 let declarations = sorted_declarations(&info);
1086 assert_eq!(declarations.len(), 1);
1087 assert_eq!(declarations[0].entity_type, "k8s.node");
1088 }
1089
1090 #[test]
1091 fn explicit_declaration_suppresses_the_implicit_one() {
1092 let mut stamps = PROM_STAMPS.to_vec();
1093 stamps.push(("greptime.semantic.entity.k8s.pod.id", "pod"));
1094 let info = prom_table_info("kube_pod_info", &["namespace", "pod", "node"], &stamps);
1095 let declarations = sorted_declarations(&info);
1096 assert_eq!(declarations.len(), 2);
1097 assert_eq!(declarations[0].entity_type, "k8s.node");
1098 assert_eq!(declarations[1].entity_type, "k8s.pod");
1099 assert_eq!(declarations[1].id_columns, vec!["pod"]);
1101 }
1102
1103 const OTEL_STAMPS: &[(&str, &str)] = &[
1104 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
1105 (SEMANTIC_SOURCE, SOURCE_OPENTELEMETRY),
1106 (SEMANTIC_METRIC_TYPE, servers::semantic::METRIC_TYPE_INFO),
1107 ];
1108
1109 #[test]
1110 fn greptime_otel_resource_info_gets_implicit_declarations() {
1111 let info = prom_table_info(
1112 "greptime_otel_resource_info",
1113 &[
1114 "job",
1115 "instance",
1116 "service.name",
1117 "service.namespace",
1118 "host.id",
1119 "host.name",
1120 "container.id",
1121 "container.name",
1122 "k8s.pod.uid",
1123 "k8s.pod.name",
1124 "k8s.container.name",
1125 "k8s.namespace.name",
1126 "k8s.node.name",
1127 ],
1128 OTEL_STAMPS,
1129 );
1130 let declarations = sorted_declarations(&info);
1131 let types: Vec<&str> = declarations
1132 .iter()
1133 .map(|d| d.entity_type.as_str())
1134 .collect();
1135 assert_eq!(
1136 types,
1137 vec![
1138 "container",
1139 "host",
1140 "k8s.container",
1141 "k8s.node",
1142 "k8s.pod",
1143 "service",
1144 "service.instance"
1145 ]
1146 );
1147 assert_eq!(
1150 declarations[2].id_columns,
1151 vec!["k8s.pod.uid", "k8s.container.name"]
1152 );
1153 assert_eq!(
1154 declarations[0].superseded_by_columns,
1155 vec!["k8s.pod.uid", "k8s.container.name"],
1156 "the generic container must yield to the k8s.container identity itself"
1157 );
1158 assert_eq!(declarations[1].id_columns, vec!["host.id"]);
1159 assert_eq!(declarations[1].descriptive_columns, vec!["host.name"]);
1160 assert_eq!(declarations[3].id_columns, vec!["k8s.node.name"]);
1161 assert_eq!(declarations[5].id_columns, vec!["job"]);
1162 assert_eq!(
1163 declarations[5].descriptive_columns,
1164 vec!["service.name", "service.namespace"]
1165 );
1166 assert_eq!(declarations[6].id_columns, vec!["job", "instance"]);
1167 assert!(declarations[6].descriptive_columns.is_empty());
1168
1169 let no_k8s = prom_table_info(
1172 "greptime_otel_resource_info",
1173 &["job", "container.id", "k8s.pod.uid"],
1174 OTEL_STAMPS,
1175 );
1176 let declarations = sorted_declarations(&no_k8s);
1177 assert_eq!(declarations[0].entity_type, "container");
1178 assert!(declarations[0].superseded_by_columns.is_empty());
1179
1180 let mut stamps = OTEL_STAMPS.to_vec();
1183 stamps.push(("greptime.semantic.entity.k8s.container.id", "gone"));
1184 let broken_explicit = prom_table_info(
1185 "greptime_otel_resource_info",
1186 &["job", "container.id", "k8s.pod.uid", "k8s.container.name"],
1187 &stamps,
1188 );
1189 let declarations = sorted_declarations(&broken_explicit);
1190 let types: Vec<&str> = declarations
1191 .iter()
1192 .map(|d| d.entity_type.as_str())
1193 .collect();
1194 assert_eq!(types, vec!["container", "k8s.pod", "service"]);
1195 assert!(declarations[0].superseded_by_columns.is_empty());
1196
1197 let partial = prom_table_info(
1198 "greptime_otel_resource_info",
1199 &["job", "service.name", "host.id"],
1200 OTEL_STAMPS,
1201 );
1202 let types: Vec<String> = sorted_declarations(&partial)
1203 .into_iter()
1204 .map(|d| d.entity_type)
1205 .collect();
1206 assert_eq!(types, vec!["host", "service"]);
1207 }
1208
1209 #[test]
1210 fn otel_implicit_declarations_are_gated() {
1211 let labels: &[&str] = &["job", "instance", "host.id"];
1212 assert!(
1213 sorted_declarations(&prom_table_info(
1214 "greptime_otel_resource_info",
1215 labels,
1216 PROM_STAMPS
1217 ))
1218 .is_empty()
1219 );
1220 let mut stamps = OTEL_STAMPS.to_vec();
1221 stamps.push((PHYSICAL_TABLE_METADATA_KEY, "true"));
1222 assert!(
1223 sorted_declarations(&prom_table_info(
1224 "greptime_otel_resource_info",
1225 labels,
1226 &stamps
1227 ))
1228 .is_empty()
1229 );
1230 assert!(
1231 conventions()
1232 .unwrap()
1233 .otel_info_metrics
1234 .contains_key(servers::otlp::metrics::OTEL_RESOURCE_INFO_TABLE_NAME)
1235 );
1236
1237 let wrong_type = [
1238 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
1239 (SEMANTIC_SOURCE, SOURCE_OPENTELEMETRY),
1240 (SEMANTIC_METRIC_TYPE, servers::semantic::METRIC_TYPE_GAUGE),
1241 ];
1242 assert!(
1243 sorted_declarations(&prom_table_info(
1244 "greptime_otel_resource_info",
1245 labels,
1246 &wrong_type
1247 ))
1248 .is_empty()
1249 );
1250 }
1251
1252 #[test]
1253 fn target_info_descriptive_rest_covers_remaining_tags() {
1254 let marker = OTLP_AGGREGATION_TEMPORALITY_LABEL;
1255 let info = prom_table_info(
1256 "target_info",
1257 &[
1258 "job",
1259 "instance",
1260 "k8s_cluster_name",
1261 "service_version",
1262 marker,
1263 ],
1264 PROM_STAMPS,
1265 );
1266 let declarations = sorted_declarations(&info);
1267 assert_eq!(declarations.len(), 2);
1268 assert_eq!(declarations[0].entity_type, "service");
1269 assert_eq!(declarations[0].id_columns, vec!["job"]);
1270 assert!(declarations[0].descriptive_columns.is_empty());
1271 assert_eq!(declarations[1].entity_type, "service.instance");
1274 assert_eq!(declarations[1].id_columns, vec!["job", "instance"]);
1275 assert_eq!(
1276 declarations[1].descriptive_columns,
1277 vec!["k8s_cluster_name", "service_version"]
1278 );
1279 }
1280}