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, 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
76/// The live [`EntityGraphProvider`], backed by the query engine.
77pub 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    /// Whether the caller may read the derivation sources named by `targets`.
109    /// `Ok(false)` = denied: the source is silently excluded, per the
110    /// derivation contract. Errors other than a denial abort the scan.
111    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    /// Parses `greptime.semantic.entity.<type>.{id|descriptive|scope}` options of
134    /// one table into per-type declarations. A type with no `id` columns is skipped.
135    fn parse_declarations(table_info: &TableInfo) -> Vec<EntityDeclaration> {
136        // entity_type -> (id_columns, descriptive_columns, scope_columns)
137        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                    // A stale declaration (e.g. its column was dropped later)
169                    // must not poison every graph scan; skip it.
170                    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    /// All entity declarations of one table: the explicit options plus the
199    /// zero-configuration conventions (`otlp_trace_entities` for trace-v1/v2
200    /// tables — including the `service` identity of tables created before the
201    /// ingest-side auto-stamp — and the Prometheus/OTel descriptor
202    /// whitelists). An explicit declaration of a type always suppresses the
203    /// implicit one, even when the explicit declaration is invalid and
204    /// skipped: silently falling back would change entity identity behind the
205    /// user's back.
206    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    /// Binds each `superseded_by` to the identity the superseding type has on
241    /// this table, once every declaration is known. A type nothing declares
242    /// here leaves the guard empty, so the superseded entity stands instead of
243    /// yielding to a node that will never be derived.
244    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    /// Whether the table carries an explicit `entity.<type>.id` option.
259    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    /// Implicit declarations of the well-known entity-descriptor metrics
267    /// (the `prometheus_info_metrics` / `otel_info_metrics` whitelists of
268    /// `conventions.yaml`), gated on the ingest-stamped `signal_type=metric`
269    /// option plus the whitelist's expected `source`; OTel descriptors also
270    /// require `metric.type=info`. The metric engine's physical table
271    /// aggregates every logical table's columns and must not contribute a
272    /// duplicate source.
273    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    /// Synthesizes the applicable subset of `entities` on `table_info`:
308    /// explicit declarations win, every id reference must be resolvable,
309    /// and descriptive references are filtered by the same rule.
310    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            // A table predating the qualifier column keeps the unqualified
360            // identity rather than losing the declaration.
361            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    /// Enumerates entity declarations and trace tables across a catalog. Trace
383    /// tables are keyed off the engine-native `table_data_model` option (same
384    /// check as the Jaeger query path) so pre-existing trace tables without the
385    /// newer `greptime.semantic.*` stamps are recognized too.
386    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        // A target-blind checker (e.g. the default mode-based one) answers the
400        // same for every table: ask once up front instead of per table.
401        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            // User telemetry never lives in the system schemas; skip them to avoid
428            // scanning information_schema (including the computed graph tables) etc.
429            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                // Authorize only tables that would contribute rows.
441                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                        // No usable service identity: the table cannot
469                        // contribute service-calls edges (see declarations_for).
470                        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    /// The time window to derive over, taken from the scan's `observed_at`
496    /// predicate.
497    ///
498    /// The contract (RFC "The contract"): no `observed_at` predicate → the
499    /// product default (last hour); a missing upper bound means "up to now"; a
500    /// predicate without a lower bound or in a shape that cannot be safely
501    /// extracted is an error asking for an explicit range — never a silent
502    /// fallback into incomplete results.
503    /// `Ok(None)` = the window cannot match anything (e.g. a lower bound in
504    /// the future with the implicit "up to now" upper bound): the scan streams
505    /// empty instead of deriving.
506    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    /// Projects referenced V2 attributes under their declaration names for
546    /// the shared entity and relationship plans. Other models scan unchanged.
547    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        // Read Trace V2 table:
563        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    /// The declared-edge branch source, when the physical table exists, the
596    /// caller may read it, and it still matches the canonical definition. A
597    /// mismatch (upgrade skew) is an explicit error, not a silent skip:
598    /// dropping declared edges would misrepresent the graph.
599    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    /// Executes a derivation plan under the caller's context (inheriting its
654    /// permissions, cancellation and deadline); context-less internal scans
655    /// get a minimal default.
656    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        // A broken embedded file still leaves the explicit half reportable;
762        // the scan paths surface the error itself.
763        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    /// A metric-engine-logical-table shape: every label column is a tag.
938    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        // host_ip/pod_ip/created_by_* are absent from this table; descriptive
1030        // shrinks to the present columns.
1031        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        // Missing uid column: no pod entity synthesized, no name-based guess.
1095        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        // An explicit declaration suppresses the implicit one even when it is
1106        // invalid and skipped: identity must not change behind the user's back.
1107        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        // A wrong `resource_attributes.` prefix would leave the generic
1144        // container standing beside the k8s one, which the descriptor-table
1145        // case cannot catch.
1146        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        // A non-Prometheus source.
1199        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        // Not a whitelisted metric name.
1211        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        // A missing id column (uid) drops that entity, not the whole table.
1220        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        // The explicit identity wins over the conventional [namespace, pod].
1236        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        // A pod's container is the k8s.container, under the identity
1284        // kube-state-metrics gives it; the generic type yields to it.
1285        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        // Nothing here can produce a k8s.container, so the generic one must
1306        // stand or the container disappears instead of changing type.
1307        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        // Same rule when a skipped explicit declaration blocks the implicit
1317        // one: nothing declares the type, so nothing may yield to it.
1318        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        // The remaining labels are the target's resource attributes: they
1408        // describe the instance, not the logical service.
1409        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}