Skip to main content

frontend/instance/
entity_graph.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Frontend implementation of the [`EntityGraphProvider`]: the live connector that
16//! makes the computed `greptime_private.semantic_entities` /
17//! `semantic_relationships` tables produce rows.
18//!
19//! It enumerates the entity-identity declarations by iterating the catalog's
20//! `TableInfo` options (`greptime.semantic.entity.*`), builds the read-time
21//! derivation plans as typed DataFusion `Expr`s over the declaring tables'
22//! DataFrames (`operator::statement::semantic_graph`), and executes them through
23//! the query engine. Injected into the catalog manager after the engine is built,
24//! breaking the `catalog -> query` cycle.
25
26use 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
73/// The live [`EntityGraphProvider`], backed by the query engine.
74pub 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    /// Whether the caller may read the derivation sources named by `targets`.
106    /// `Ok(false)` = denied: the source is silently excluded, per the
107    /// derivation contract. Errors other than a denial abort the scan.
108    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    /// Parses `greptime.semantic.entity.<type>.{id|descriptive|scope}` options of
131    /// one table into per-type declarations. A type with no `id` columns is skipped.
132    fn parse_declarations(table_info: &TableInfo) -> Vec<EntityDeclaration> {
133        // entity_type -> (id_columns, descriptive_columns, scope_columns)
134        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                    // A stale declaration (e.g. its column was dropped later)
166                    // must not poison every graph scan; skip it.
167                    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    /// All entity declarations of one table: the explicit options plus the
197    /// zero-configuration conventions (`otlp_trace_entities` for trace-v1
198    /// tables — including the `service` identity of tables created before the
199    /// ingest-side auto-stamp — and the Prometheus/OTel descriptor
200    /// whitelists). An explicit declaration of a type always suppresses the
201    /// implicit one, even when the explicit declaration is invalid and
202    /// skipped: silently falling back would change entity identity behind the
203    /// user's back.
204    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    /// Binds each `superseded_by` to the identity the superseding type has on
239    /// this table, once every declaration is known. A type nothing declares
240    /// here leaves the guard empty, so the superseded entity stands instead of
241    /// yielding to a node that will never be derived.
242    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    /// Whether the table carries an explicit `entity.<type>.id` option.
257    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    /// Implicit declarations of the well-known entity-descriptor metrics
265    /// (the `prometheus_info_metrics` / `otel_info_metrics` whitelists of
266    /// `conventions.yaml`), gated on the ingest-stamped `signal_type=metric`
267    /// option plus the whitelist's expected `source`; OTel descriptors also
268    /// require `metric.type=info`. The metric engine's physical table
269    /// aggregates every logical table's columns and must not contribute a
270    /// duplicate source.
271    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    /// Synthesizes the applicable subset of `entities` on `table_info`:
306    /// explicit declarations win, every id column must exist (no guessing),
307    /// descriptive columns are filtered to those present.
308    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            // A table predating the qualifier column keeps the unqualified
358            // identity rather than losing the declaration.
359            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    /// Enumerates entity declarations and trace tables across a catalog. Trace
381    /// tables are keyed off the engine-native `table_data_model` option (same
382    /// check as the Jaeger query path) so pre-existing trace tables without the
383    /// newer `greptime.semantic.*` stamps are recognized too.
384    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        // A target-blind checker (e.g. the default mode-based one) answers the
398        // same for every table: ask once up front instead of per table.
399        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            // User telemetry never lives in the system schemas; skip them to avoid
426            // scanning information_schema (including the computed graph tables) etc.
427            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                // Authorize only tables that would contribute rows.
439                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                        // No usable service identity: the table cannot
468                        // contribute service-calls edges (see declarations_for).
469                        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    /// The time window to derive over, taken from the scan's `observed_at`
495    /// predicate.
496    ///
497    /// The contract (RFC "The contract"): no `observed_at` predicate → the
498    /// product default (last hour); a missing upper bound means "up to now"; a
499    /// predicate without a lower bound or in a shape that cannot be safely
500    /// extracted is an error asking for an explicit range — never a silent
501    /// fallback into incomplete results.
502    /// `Ok(None)` = the window cannot match anything (e.g. a lower bound in
503    /// the future with the implicit "up to now" upper bound): the scan streams
504    /// empty instead of deriving.
505    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    /// The declared-edge branch source, when the physical table exists, the
540    /// caller may read it, and it still matches the canonical definition. A
541    /// mismatch (upgrade skew) is an explicit error, not a silent skip:
542    /// dropping declared edges would misrepresent the graph.
543    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    /// Executes a derivation plan under the caller's context (inheriting its
595    /// permissions, cancellation and deadline); context-less internal scans
596    /// get a minimal default.
597    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        // A broken embedded file still leaves the explicit half reportable;
697        // the scan paths surface the error itself.
698        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    /// A metric-engine-logical-table shape: every label column is a tag.
802    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        // host_ip/pod_ip/created_by_* are absent from this table; descriptive
894        // shrinks to the present columns.
895        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        // Missing uid column: no pod entity synthesized, no name-based guess.
959        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        // An explicit declaration suppresses the implicit one even when it is
970        // invalid and skipped: identity must not change behind the user's back.
971        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        // A wrong `resource_attributes.` prefix would leave the generic
1008        // container standing beside the k8s one, which the descriptor-table
1009        // case cannot catch.
1010        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        // A non-Prometheus source.
1063        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        // Not a whitelisted metric name.
1075        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        // A missing id column (uid) drops that entity, not the whole table.
1084        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        // The explicit identity wins over the conventional [namespace, pod].
1100        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        // A pod's container is the k8s.container, under the identity
1148        // kube-state-metrics gives it; the generic type yields to it.
1149        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        // Nothing here can produce a k8s.container, so the generic one must
1170        // stand or the container disappears instead of changing type.
1171        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        // Same rule when a skipped explicit declaration blocks the implicit
1181        // one: nothing declares the type, so nothing may yield to it.
1182        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        // The remaining labels are the target's resource attributes: they
1272        // describe the instance, not the logical service.
1273        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}