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