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![
446 PermissionTableTarget::new(catalog, &schema, &table_info.name)
447 .with_physical_metric_table(table_info.is_physical_table()),
448 ]),
449 )?
450 {
451 debug!(
452 "Excluding `{schema}.{}` from the entity-graph derivation: caller lacks \
453 read permission",
454 table_info.name
455 );
456 continue;
457 }
458 if is_trace {
459 let find = |entity_type: &str| {
460 table_declarations
461 .iter()
462 .find(|d| d.entity_type == entity_type)
463 .cloned()
464 };
465 let service = find(ENTITY_TYPE_SERVICE);
466 let agent = find(ENTITY_TYPE_GEN_AI_AGENT);
467 if service.is_none() {
468 warn!(
471 "Trace table `{}` has no usable service declaration; skipping calls derivation",
472 table_info.name
473 );
474 }
475 if service.is_some() || agent.is_some() {
476 traces.push(TraceSource {
477 service,
478 agent,
479 table: table.clone(),
480 });
481 }
482 }
483 if !table_declarations.is_empty() {
484 declarations.push(EntitySource {
485 declarations: table_declarations,
486 is_trace,
487 table,
488 });
489 }
490 }
491 }
492 Ok((declarations, traces))
493 }
494
495 fn query_window(request: &ScanRequest) -> Result<Option<GraphQueryWindow>, BoxedError> {
507 let invalid =
508 |err_msg: String| Err(BoxedError::new(error::InvalidSqlSnafu { err_msg }.build()));
509 match extract_time_range_strict(OBSERVED_AT_COLUMN, TimeUnit::Millisecond, &request.filters)
510 {
511 TimeRangeExtraction::Absent => Ok(Some(GraphQueryWindow::default_last_hour())),
512 TimeRangeExtraction::Extracted(range) => {
513 let Some(start) = range.start() else {
514 return invalid(format!(
515 "the {OBSERVED_AT_COLUMN} filter has no lower bound; the graph cannot \
516 derive over unbounded history — add e.g. {OBSERVED_AT_COLUMN} >= \
517 '2026-01-01 00:00:00'"
518 ));
519 };
520 let end_ms = range
521 .end()
522 .map(|ts| ts.value())
523 .unwrap_or_else(common_time::util::current_time_millis);
524 if start.value() >= end_ms {
525 return Ok(None);
526 }
527 Ok(Some(GraphQueryWindow::from_observed(start.value(), end_ms)))
528 }
529 TimeRangeExtraction::Unsupported => invalid(format!(
530 "cannot derive the graph window from the {OBSERVED_AT_COLUMN} filter; use plain \
531 range predicates (>=, <, BETWEEN) with literal bounds"
532 )),
533 }
534 }
535
536 fn can_resolve_entity_column(table_info: &TableInfo, column: &str) -> bool {
537 let table_schema = &table_info.meta.schema;
538 if table_schema.column_schema_by_name(column).is_some() {
539 return true;
540 }
541 let data_model = table_info.meta.options.data_model();
542 trace_v2_attribute(table_schema, data_model, column).is_some()
543 }
544
545 fn read_table(
548 &self,
549 table: TableRef,
550 declarations: &[EntityDeclaration],
551 ) -> Result<DataFrame, BoxedError> {
552 let info = table.table_info();
553 let mut scan = self
554 .query_engine
555 .read_table(table)
556 .map_err(BoxedError::new)?;
557 let data_model = info.meta.options.data_model();
558 if data_model != Some(TABLE_DATA_MODEL_TRACE_V2) {
559 return Ok(scan);
560 }
561
562 let mut columns = BTreeSet::new();
564 for declaration in declarations {
565 columns.extend(declaration.id_columns.iter());
566 columns.extend(declaration.descriptive_columns.iter());
567 columns.extend(declaration.scope_columns.iter());
568 columns.extend(declaration.id_qualifier.iter());
569 columns.extend(declaration.superseded_by_columns.iter());
570 }
571 let conventions = conventions()
572 .map_err(datafusion::error::DataFusionError::Internal)
573 .context(error::DataFusionSnafu)
574 .map_err(BoxedError::new)?;
575 let virtual_candidates = &conventions.virtual_dst_candidates;
576 columns.extend(virtual_candidates.iter().map(|candidate| &candidate.column));
577 let get = create_udf(Arc::new(JsonGetWithType::default()));
578 for column in columns {
579 if let Some((root, key)) = trace_v2_attribute(&info.meta.schema, data_model, column) {
580 let path = format!("$.{}", serde_json::Value::String(key.to_string()));
581 let value = get.call(vec![
582 Expr::Column(Column::from_name(root)),
583 lit(path),
584 lit(ScalarValue::Utf8View(None)),
585 ]);
586 scan = scan
587 .with_column(column, value)
588 .context(error::DataFusionSnafu)
589 .map_err(BoxedError::new)?;
590 }
591 }
592 Ok(scan)
593 }
594
595 async fn declared_source(
600 &self,
601 catalog: &str,
602 query_ctx: Option<&QueryContext>,
603 ) -> Result<Option<DeclaredSource>, BoxedError> {
604 let Some(catalog_manager) = self.catalog_manager.upgrade() else {
605 return Ok(None);
606 };
607 let Some(table) = catalog_manager
608 .table(
609 catalog,
610 DEFAULT_PRIVATE_SCHEMA_NAME,
611 SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
612 query_ctx,
613 )
614 .await
615 .map_err(BoxedError::new)?
616 else {
617 return Ok(None);
618 };
619 let table_info = table.table_info();
620 if !self.authorize_sources(
621 query_ctx,
622 PermissionTableTargets::resolved(vec![
623 PermissionTableTarget::new(
624 catalog,
625 DEFAULT_PRIVATE_SCHEMA_NAME,
626 SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
627 )
628 .with_physical_metric_table(table_info.is_physical_table()),
629 ]),
630 )? {
631 debug!(
632 "Excluding declared edges: caller lacks read permission on \
633 `{DEFAULT_PRIVATE_SCHEMA_NAME}.{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME}`"
634 );
635 return Ok(None);
636 }
637 if !declared_relationships_schema_matches(&table_info) {
638 return Err(BoxedError::new(
639 error::InvalidSqlSnafu {
640 err_msg: format!(
641 "{DEFAULT_PRIVATE_SCHEMA_NAME}.{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME} \
642 does not match its canonical schema; declared edges cannot be derived"
643 ),
644 }
645 .build(),
646 ));
647 }
648 Ok(Some(DeclaredSource {
649 scan: self.read_table(table, &[])?,
650 }))
651 }
652
653 async fn execute_plan(
657 &self,
658 catalog: &str,
659 plan: LogicalPlan,
660 query_ctx: Option<QueryContextRef>,
661 ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
662 let query_ctx = query_ctx.unwrap_or_else(|| {
663 QueryContextBuilder::default()
664 .current_catalog(catalog.to_string())
665 .current_schema(DEFAULT_SCHEMA_NAME.to_string())
666 .build()
667 .into()
668 });
669 let output = self
670 .query_engine
671 .execute(plan, query_ctx)
672 .await
673 .map_err(BoxedError::new)?;
674 let stream = match output.data {
675 OutputData::Stream(stream) => stream,
676 OutputData::RecordBatches(batches) => batches.as_stream(),
677 OutputData::AffectedRows(_) => return Ok(None),
678 };
679 Ok(Some(stream))
680 }
681}
682
683#[async_trait]
684impl EntityGraphProvider for EntityGraphProviderImpl {
685 async fn scan_entities(
686 &self,
687 catalog: &str,
688 request: ScanRequest,
689 query_ctx: Option<QueryContextRef>,
690 ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
691 let (sources, _) = self.enumerate(catalog, query_ctx.as_deref()).await?;
692 let mut plans = Vec::with_capacity(sources.len());
693 for source in sources {
694 plans.push(RegistrySource {
695 scan: self.read_table(source.table, &source.declarations)?,
696 declarations: source.declarations,
697 });
698 }
699 let Some(window) = Self::query_window(&request)? else {
700 return Ok(None);
701 };
702 let Some(plan) = build_registry_plan(plans, &window)
703 .context(error::DataFusionSnafu)
704 .map_err(BoxedError::new)?
705 else {
706 return Ok(None);
707 };
708 self.execute_plan(catalog, plan, query_ctx).await
709 }
710
711 async fn scan_relationships(
712 &self,
713 catalog: &str,
714 request: ScanRequest,
715 query_ctx: Option<QueryContextRef>,
716 ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
717 let (sources, traces) = self.enumerate(catalog, query_ctx.as_deref()).await?;
718 let mut calls = Vec::with_capacity(traces.len());
719 for trace in traces {
720 let declarations = trace
721 .service
722 .iter()
723 .chain(trace.agent.iter())
724 .cloned()
725 .collect::<Vec<_>>();
726 calls.push(CallsSource {
727 scan: self.read_table(trace.table, &declarations)?,
728 service: trace.service,
729 agent: trace.agent,
730 });
731 }
732 let mut co_declared = Vec::with_capacity(sources.len());
733 for source in sources {
734 co_declared.push(CoDeclaredSource {
735 scan: self.read_table(source.table, &source.declarations)?,
736 declarations: source.declarations,
737 is_trace: source.is_trace,
738 });
739 }
740 let declared = self.declared_source(catalog, query_ctx.as_deref()).await?;
741 let Some(window) = Self::query_window(&request)? else {
742 return Ok(None);
743 };
744 let Some(plan) = build_relationships_plan(
745 RelationshipSources {
746 traces: calls,
747 co_declared,
748 declared,
749 },
750 &window,
751 )
752 .context(error::DataFusionSnafu)
753 .map_err(BoxedError::new)?
754 else {
755 return Ok(None);
756 };
757 self.execute_plan(catalog, plan, query_ctx).await
758 }
759
760 fn table_declarations(&self, table_info: &TableInfo) -> Vec<TableEntityDeclaration> {
761 let derived = match conventions() {
764 Ok(conventions) => Self::declarations_for(table_info, conventions),
765 Err(_) => Self::parse_declarations(table_info),
766 };
767 let mut declarations = derived
768 .into_iter()
769 .map(|declaration| TableEntityDeclaration {
770 origin: if Self::explicitly_declares(table_info, &declaration.entity_type) {
771 DeclarationOrigin::Declared
772 } else {
773 DeclarationOrigin::Convention
774 },
775 entity_type: declaration.entity_type,
776 id_columns: declaration.id_columns,
777 id_qualifier: declaration.id_qualifier,
778 superseded_by_columns: declaration.superseded_by_columns,
779 descriptive_columns: declaration.descriptive_columns,
780 scope_columns: declaration.scope_columns,
781 })
782 .collect::<Vec<_>>();
783 declarations.sort_by(|a, b| a.entity_type.cmp(&b.entity_type));
784 declarations
785 }
786}
787
788#[cfg(test)]
789mod tests {
790 use std::collections::HashMap;
791 use std::sync::Arc;
792
793 use common_catalog::consts::{DEFAULT_CATALOG_NAME, MITO_ENGINE};
794 use datatypes::prelude::ConcreteDataType;
795 use datatypes::schema::{ColumnSchema, SchemaBuilder};
796 use store_api::metric_engine_consts::PHYSICAL_TABLE_METADATA_KEY;
797 use table::metadata::{TableInfoBuilder, TableMeta, TableType};
798 use table::requests::{TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1, TableOptions};
799
800 use super::*;
801
802 struct DenyPhysicalTables;
803
804 impl PermissionChecker for DenyPhysicalTables {
805 fn check_permission(
806 &self,
807 _: auth::UserInfoRef,
808 _: PermissionReq,
809 ) -> auth::error::Result<auth::PermissionResp> {
810 Ok(auth::PermissionResp::Allow)
811 }
812
813 fn check_permission_with_table_targets(
814 &self,
815 _: auth::UserInfoRef,
816 _: PermissionReq,
817 targets: PermissionTableTargets,
818 ) -> auth::error::Result<auth::PermissionResp> {
819 let PermissionTableTargets::Resolved(targets) = targets else {
820 panic!("entity graph must resolve its sources");
821 };
822 Ok(
823 if targets.iter().any(|target| target.is_physical_metric_table) {
824 auth::PermissionResp::Reject
825 } else {
826 auth::PermissionResp::Allow
827 },
828 )
829 }
830 }
831
832 #[tokio::test]
833 async fn test_enumerate_authorizes_physical_tables() {
834 for physical in [false, true] {
835 let mut options = vec![("greptime.semantic.entity.service.id", "service_name")];
836 if physical {
837 options.push((PHYSICAL_TABLE_METADATA_KEY, ""));
838 }
839 let info = table_info(&["service_name"], &options);
840 let batch = common_recordbatch::RecordBatch::new_empty(info.meta.schema.clone());
841 let source = table::test_util::MemTable::table("t1", batch);
842 let table = Arc::new(table::Table::new(
843 Arc::new(info),
844 table::metadata::FilterPushDownType::Unsupported,
845 source.data_source(),
846 ));
847 let catalog: Arc<dyn CatalogManager> =
848 catalog::memory::MemoryCatalogManager::new_with_table(table);
849 let engine = query::QueryEngineFactory::new(
850 catalog.clone(),
851 None,
852 None,
853 None,
854 None,
855 false,
856 query::options::QueryOptions::default(),
857 )
858 .query_engine();
859 let provider = EntityGraphProviderImpl::new(
860 engine,
861 Arc::downgrade(&catalog),
862 Some(Arc::new(DenyPhysicalTables)),
863 );
864 let ctx = QueryContext::arc();
865 let (sources, _) = provider
866 .enumerate(DEFAULT_CATALOG_NAME, Some(&ctx))
867 .await
868 .unwrap();
869 assert_eq!(sources.len(), usize::from(!physical));
870 }
871 }
872
873 fn table_info(columns: &[&str], extra: &[(&str, &str)]) -> TableInfo {
874 let mut column_schemas = vec![
875 ColumnSchema::new(
876 "ts",
877 ConcreteDataType::timestamp_millisecond_datatype(),
878 false,
879 )
880 .with_time_index(true),
881 ];
882 column_schemas.extend(
883 columns
884 .iter()
885 .map(|c| ColumnSchema::new(*c, ConcreteDataType::string_datatype(), true)),
886 );
887 assemble_table_info(column_schemas, extra)
888 }
889
890 fn assemble_table_info(column_schemas: Vec<ColumnSchema>, extra: &[(&str, &str)]) -> TableInfo {
891 named_table_info("t1", column_schemas, vec![], extra)
892 }
893
894 fn named_table_info(
895 name: &str,
896 column_schemas: Vec<ColumnSchema>,
897 primary_key_indices: Vec<usize>,
898 extra: &[(&str, &str)],
899 ) -> TableInfo {
900 let schema = Arc::new(
901 SchemaBuilder::try_from_columns(column_schemas)
902 .unwrap()
903 .build()
904 .unwrap(),
905 );
906 let options = TableOptions {
907 extra_options: extra
908 .iter()
909 .map(|(k, v)| (k.to_string(), v.to_string()))
910 .collect::<HashMap<_, _>>(),
911 ..Default::default()
912 };
913 let meta = TableMeta {
914 schema,
915 primary_key_indices,
916 value_indices: vec![],
917 engine: MITO_ENGINE.to_string(),
918 next_column_id: 1,
919 options,
920 created_on: Default::default(),
921 updated_on: Default::default(),
922 partition_key_indices: vec![],
923 column_ids: vec![],
924 };
925 TableInfoBuilder::default()
926 .table_id(1)
927 .name(name)
928 .catalog_name(DEFAULT_CATALOG_NAME)
929 .schema_name(DEFAULT_SCHEMA_NAME)
930 .table_version(0)
931 .table_type(TableType::Base)
932 .meta(meta)
933 .build()
934 .unwrap()
935 }
936
937 fn prom_table_info(name: &str, tags: &[&str], extra: &[(&str, &str)]) -> TableInfo {
939 let mut column_schemas = vec![
940 ColumnSchema::new(
941 "greptime_timestamp",
942 ConcreteDataType::timestamp_millisecond_datatype(),
943 false,
944 )
945 .with_time_index(true),
946 ];
947 column_schemas.extend(
948 tags.iter()
949 .map(|c| ColumnSchema::new(*c, ConcreteDataType::string_datatype(), true)),
950 );
951 let primary_key_indices = (1..=tags.len()).collect();
952 named_table_info(name, column_schemas, primary_key_indices, extra)
953 }
954
955 #[test]
956 fn future_lower_only_window_matches_nothing() {
957 let future_ms = common_time::util::current_time_millis() + 86_400_000;
958 let request = ScanRequest {
959 filters: vec![
960 datafusion_expr::col(OBSERVED_AT_COLUMN).gt_eq(datafusion_expr::lit(
961 datafusion::common::ScalarValue::TimestampMillisecond(Some(future_ms), None),
962 )),
963 ],
964 ..Default::default()
965 };
966 assert!(
967 EntityGraphProviderImpl::query_window(&request)
968 .unwrap()
969 .is_none()
970 );
971
972 assert!(
973 EntityGraphProviderImpl::query_window(&ScanRequest::default())
974 .unwrap()
975 .is_some()
976 );
977 }
978
979 #[test]
980 fn declaration_referencing_missing_column_is_skipped() {
981 let info = table_info(
982 &["service_name"],
983 &[
984 ("greptime.semantic.entity.service.id", "service_name"),
985 ("greptime.semantic.entity.host.id", "gone"),
986 ],
987 );
988 let declarations = EntityGraphProviderImpl::declarations_for(&info, conventions().unwrap());
989 assert_eq!(declarations.len(), 1);
990 assert_eq!(declarations[0].entity_type, "service");
991
992 let info = table_info(
993 &["service_name"],
994 &[
995 ("greptime.semantic.entity.service.id", "service_name"),
996 ("greptime.semantic.entity.service.descriptive", "gone"),
997 ],
998 );
999 assert!(
1000 EntityGraphProviderImpl::declarations_for(&info, conventions().unwrap()).is_empty()
1001 );
1002 }
1003
1004 const PROM_STAMPS: &[(&str, &str)] = &[
1005 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
1006 (SEMANTIC_SOURCE, SOURCE_PROMETHEUS),
1007 ];
1008
1009 fn sorted_declarations(info: &TableInfo) -> Vec<EntityDeclaration> {
1010 let mut declarations =
1011 EntityGraphProviderImpl::declarations_for(info, conventions().unwrap());
1012 declarations.sort_by(|a, b| a.entity_type.cmp(&b.entity_type));
1013 declarations
1014 }
1015
1016 #[test]
1017 fn prometheus_info_metric_gets_implicit_declarations() {
1018 let info = prom_table_info(
1019 "kube_pod_info",
1020 &["namespace", "pod", "uid", "node", "job", "instance"],
1021 PROM_STAMPS,
1022 );
1023 let declarations = sorted_declarations(&info);
1024 assert_eq!(declarations.len(), 2);
1025 assert_eq!(declarations[0].entity_type, "k8s.node");
1026 assert_eq!(declarations[0].id_columns, vec!["node"]);
1027 assert_eq!(declarations[1].entity_type, "k8s.pod");
1028 assert_eq!(declarations[1].id_columns, vec!["uid"]);
1029 assert_eq!(
1032 declarations[1].descriptive_columns,
1033 vec!["namespace", "pod", "node"]
1034 );
1035 assert_eq!(declarations[1].time_index, "greptime_timestamp");
1036 }
1037
1038 #[test]
1039 fn trace_table_gets_implicit_resource_entities() {
1040 let full = table_info(
1041 &[
1042 "service_name",
1043 "resource_attributes.service.instance.id",
1044 "resource_attributes.k8s.pod.uid",
1045 "resource_attributes.k8s.pod.name",
1046 "resource_attributes.k8s.node.name",
1047 ],
1048 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
1049 );
1050 let declarations = sorted_declarations(&full);
1051 let types: Vec<&str> = declarations
1052 .iter()
1053 .map(|d| d.entity_type.as_str())
1054 .collect();
1055 assert_eq!(
1056 types,
1057 vec!["k8s.node", "k8s.pod", "service", "service.instance"]
1058 );
1059 assert_eq!(
1060 declarations[0].id_columns,
1061 vec!["resource_attributes.k8s.node.name"]
1062 );
1063 assert_eq!(
1064 declarations[1].id_columns,
1065 vec!["resource_attributes.k8s.pod.uid"]
1066 );
1067 assert_eq!(
1068 declarations[1].descriptive_columns,
1069 vec!["resource_attributes.k8s.pod.name"]
1070 );
1071 assert_eq!(
1072 declarations[3].id_columns,
1073 vec!["service_name", "resource_attributes.service.instance.id"]
1074 );
1075 assert_eq!(declarations[2].id_qualifier, None);
1076
1077 let namespaced = table_info(
1078 &[
1079 "service_name",
1080 "resource_attributes.service.namespace",
1081 "resource_attributes.service.instance.id",
1082 ],
1083 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
1084 );
1085 for declaration in sorted_declarations(&namespaced) {
1086 assert_eq!(
1087 declaration.id_qualifier.as_deref(),
1088 Some("resource_attributes.service.namespace"),
1089 "{} must qualify its identity like the metric side's job",
1090 declaration.entity_type
1091 );
1092 }
1093
1094 let no_uid = table_info(
1096 &["service_name", "resource_attributes.k8s.pod.name"],
1097 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
1098 );
1099 let types: Vec<String> = sorted_declarations(&no_uid)
1100 .into_iter()
1101 .map(|d| d.entity_type)
1102 .collect();
1103 assert_eq!(types, vec!["service"]);
1104
1105 let invalid_explicit = table_info(
1108 &["service_name"],
1109 &[
1110 (TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1),
1111 ("greptime.semantic.entity.service.id", "gone"),
1112 ],
1113 );
1114 assert!(sorted_declarations(&invalid_explicit).is_empty());
1115 }
1116
1117 #[test]
1118 fn trace_table_host_and_container_require_stable_ids() {
1119 let with_ids = table_info(
1120 &[
1121 "service_name",
1122 "resource_attributes.host.id",
1123 "resource_attributes.host.name",
1124 "resource_attributes.container.id",
1125 ],
1126 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
1127 );
1128 let declarations = sorted_declarations(&with_ids);
1129 let types: Vec<&str> = declarations
1130 .iter()
1131 .map(|d| d.entity_type.as_str())
1132 .collect();
1133 assert_eq!(types, vec!["container", "host", "service"]);
1134 assert_eq!(
1135 declarations[1].id_columns,
1136 vec!["resource_attributes.host.id"]
1137 );
1138 assert_eq!(
1139 declarations[1].descriptive_columns,
1140 vec!["resource_attributes.host.name"]
1141 );
1142
1143 let pod_container = table_info(
1147 &[
1148 "service_name",
1149 "resource_attributes.container.id",
1150 "resource_attributes.container.name",
1151 "resource_attributes.k8s.pod.uid",
1152 "resource_attributes.k8s.container.name",
1153 ],
1154 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
1155 );
1156 let declarations = sorted_declarations(&pod_container);
1157 let types: Vec<&str> = declarations
1158 .iter()
1159 .map(|d| d.entity_type.as_str())
1160 .collect();
1161 assert_eq!(
1162 types,
1163 vec!["container", "k8s.container", "k8s.pod", "service"]
1164 );
1165 assert_eq!(
1166 declarations[0].superseded_by_columns,
1167 vec![
1168 "resource_attributes.k8s.pod.uid",
1169 "resource_attributes.k8s.container.name"
1170 ]
1171 );
1172 assert_eq!(
1173 declarations[1].descriptive_columns,
1174 vec![
1175 "resource_attributes.container.id",
1176 "resource_attributes.container.name"
1177 ]
1178 );
1179
1180 let names_only = table_info(
1181 &[
1182 "service_name",
1183 "resource_attributes.host.name",
1184 "resource_attributes.container.name",
1185 ],
1186 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
1187 );
1188 let types: Vec<String> = sorted_declarations(&names_only)
1189 .into_iter()
1190 .map(|d| d.entity_type)
1191 .collect();
1192 assert_eq!(types, vec!["service"]);
1193 }
1194
1195 #[test]
1196 fn prometheus_implicit_declarations_are_gated() {
1197 let labels: &[&str] = &["namespace", "pod", "node"];
1198 assert!(
1200 sorted_declarations(&prom_table_info(
1201 "kube_pod_info",
1202 labels,
1203 &[
1204 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
1205 (SEMANTIC_SOURCE, "opentelemetry"),
1206 ],
1207 ))
1208 .is_empty()
1209 );
1210 assert!(
1212 sorted_declarations(&prom_table_info("http_requests_total", labels, PROM_STAMPS))
1213 .is_empty()
1214 );
1215 let mut stamps = PROM_STAMPS.to_vec();
1216 stamps.push((PHYSICAL_TABLE_METADATA_KEY, "true"));
1217 assert!(sorted_declarations(&prom_table_info("kube_pod_info", labels, &stamps)).is_empty());
1218
1219 let info = prom_table_info("kube_pod_info", &["namespace", "pod", "node"], PROM_STAMPS);
1221 let declarations = sorted_declarations(&info);
1222 assert_eq!(declarations.len(), 1);
1223 assert_eq!(declarations[0].entity_type, "k8s.node");
1224 }
1225
1226 #[test]
1227 fn explicit_declaration_suppresses_the_implicit_one() {
1228 let mut stamps = PROM_STAMPS.to_vec();
1229 stamps.push(("greptime.semantic.entity.k8s.pod.id", "pod"));
1230 let info = prom_table_info("kube_pod_info", &["namespace", "pod", "node"], &stamps);
1231 let declarations = sorted_declarations(&info);
1232 assert_eq!(declarations.len(), 2);
1233 assert_eq!(declarations[0].entity_type, "k8s.node");
1234 assert_eq!(declarations[1].entity_type, "k8s.pod");
1235 assert_eq!(declarations[1].id_columns, vec!["pod"]);
1237 }
1238
1239 const OTEL_STAMPS: &[(&str, &str)] = &[
1240 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
1241 (SEMANTIC_SOURCE, SOURCE_OPENTELEMETRY),
1242 (SEMANTIC_METRIC_TYPE, servers::semantic::METRIC_TYPE_INFO),
1243 ];
1244
1245 #[test]
1246 fn greptime_otel_resource_info_gets_implicit_declarations() {
1247 let info = prom_table_info(
1248 "greptime_otel_resource_info",
1249 &[
1250 "job",
1251 "instance",
1252 "service.name",
1253 "service.namespace",
1254 "host.id",
1255 "host.name",
1256 "container.id",
1257 "container.name",
1258 "k8s.pod.uid",
1259 "k8s.pod.name",
1260 "k8s.container.name",
1261 "k8s.namespace.name",
1262 "k8s.node.name",
1263 ],
1264 OTEL_STAMPS,
1265 );
1266 let declarations = sorted_declarations(&info);
1267 let types: Vec<&str> = declarations
1268 .iter()
1269 .map(|d| d.entity_type.as_str())
1270 .collect();
1271 assert_eq!(
1272 types,
1273 vec![
1274 "container",
1275 "host",
1276 "k8s.container",
1277 "k8s.node",
1278 "k8s.pod",
1279 "service",
1280 "service.instance"
1281 ]
1282 );
1283 assert_eq!(
1286 declarations[2].id_columns,
1287 vec!["k8s.pod.uid", "k8s.container.name"]
1288 );
1289 assert_eq!(
1290 declarations[0].superseded_by_columns,
1291 vec!["k8s.pod.uid", "k8s.container.name"],
1292 "the generic container must yield to the k8s.container identity itself"
1293 );
1294 assert_eq!(declarations[1].id_columns, vec!["host.id"]);
1295 assert_eq!(declarations[1].descriptive_columns, vec!["host.name"]);
1296 assert_eq!(declarations[3].id_columns, vec!["k8s.node.name"]);
1297 assert_eq!(declarations[5].id_columns, vec!["job"]);
1298 assert_eq!(
1299 declarations[5].descriptive_columns,
1300 vec!["service.name", "service.namespace"]
1301 );
1302 assert_eq!(declarations[6].id_columns, vec!["job", "instance"]);
1303 assert!(declarations[6].descriptive_columns.is_empty());
1304
1305 let no_k8s = prom_table_info(
1308 "greptime_otel_resource_info",
1309 &["job", "container.id", "k8s.pod.uid"],
1310 OTEL_STAMPS,
1311 );
1312 let declarations = sorted_declarations(&no_k8s);
1313 assert_eq!(declarations[0].entity_type, "container");
1314 assert!(declarations[0].superseded_by_columns.is_empty());
1315
1316 let mut stamps = OTEL_STAMPS.to_vec();
1319 stamps.push(("greptime.semantic.entity.k8s.container.id", "gone"));
1320 let broken_explicit = prom_table_info(
1321 "greptime_otel_resource_info",
1322 &["job", "container.id", "k8s.pod.uid", "k8s.container.name"],
1323 &stamps,
1324 );
1325 let declarations = sorted_declarations(&broken_explicit);
1326 let types: Vec<&str> = declarations
1327 .iter()
1328 .map(|d| d.entity_type.as_str())
1329 .collect();
1330 assert_eq!(types, vec!["container", "k8s.pod", "service"]);
1331 assert!(declarations[0].superseded_by_columns.is_empty());
1332
1333 let partial = prom_table_info(
1334 "greptime_otel_resource_info",
1335 &["job", "service.name", "host.id"],
1336 OTEL_STAMPS,
1337 );
1338 let types: Vec<String> = sorted_declarations(&partial)
1339 .into_iter()
1340 .map(|d| d.entity_type)
1341 .collect();
1342 assert_eq!(types, vec!["host", "service"]);
1343 }
1344
1345 #[test]
1346 fn otel_implicit_declarations_are_gated() {
1347 let labels: &[&str] = &["job", "instance", "host.id"];
1348 assert!(
1349 sorted_declarations(&prom_table_info(
1350 "greptime_otel_resource_info",
1351 labels,
1352 PROM_STAMPS
1353 ))
1354 .is_empty()
1355 );
1356 let mut stamps = OTEL_STAMPS.to_vec();
1357 stamps.push((PHYSICAL_TABLE_METADATA_KEY, "true"));
1358 assert!(
1359 sorted_declarations(&prom_table_info(
1360 "greptime_otel_resource_info",
1361 labels,
1362 &stamps
1363 ))
1364 .is_empty()
1365 );
1366 assert!(
1367 conventions()
1368 .unwrap()
1369 .otel_info_metrics
1370 .contains_key(servers::otlp::metrics::OTEL_RESOURCE_INFO_TABLE_NAME)
1371 );
1372
1373 let wrong_type = [
1374 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
1375 (SEMANTIC_SOURCE, SOURCE_OPENTELEMETRY),
1376 (SEMANTIC_METRIC_TYPE, servers::semantic::METRIC_TYPE_GAUGE),
1377 ];
1378 assert!(
1379 sorted_declarations(&prom_table_info(
1380 "greptime_otel_resource_info",
1381 labels,
1382 &wrong_type
1383 ))
1384 .is_empty()
1385 );
1386 }
1387
1388 #[test]
1389 fn target_info_descriptive_rest_covers_remaining_tags() {
1390 let marker = OTLP_AGGREGATION_TEMPORALITY_LABEL;
1391 let info = prom_table_info(
1392 "target_info",
1393 &[
1394 "job",
1395 "instance",
1396 "k8s_cluster_name",
1397 "service_version",
1398 marker,
1399 ],
1400 PROM_STAMPS,
1401 );
1402 let declarations = sorted_declarations(&info);
1403 assert_eq!(declarations.len(), 2);
1404 assert_eq!(declarations[0].entity_type, "service");
1405 assert_eq!(declarations[0].id_columns, vec!["job"]);
1406 assert!(declarations[0].descriptive_columns.is_empty());
1407 assert_eq!(declarations[1].entity_type, "service.instance");
1410 assert_eq!(declarations[1].id_columns, vec!["job", "instance"]);
1411 assert_eq!(
1412 declarations[1].descriptive_columns,
1413 vec!["k8s_cluster_name", "service_version"]
1414 );
1415 }
1416}