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