Skip to main content

frontend/instance/
entity_graph.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Frontend implementation of the [`EntityGraphProvider`]: the live connector that
16//! makes the computed `greptime_private.semantic_entities` /
17//! `semantic_relationships` tables produce rows.
18//!
19//! It enumerates the entity-identity declarations by iterating the catalog's
20//! `TableInfo` options (`greptime.semantic.entity.*`), builds the read-time
21//! derivation plans as typed DataFusion `Expr`s over the declaring tables'
22//! DataFrames (`operator::statement::semantic_graph`), and executes them through
23//! the query engine. Injected into the catalog manager after the engine is built,
24//! breaking the `catalog -> query` cycle.
25
26use std::collections::{BTreeMap, HashMap};
27use std::sync::Weak;
28
29use async_trait::async_trait;
30use auth::{
31    PermissionChecker, PermissionCheckerRef, PermissionReq, PermissionTableTarget,
32    PermissionTableTargets, SEMANTIC_GRAPH_QUERY,
33};
34use catalog::CatalogManager;
35use catalog::system_schema::semantic_graph::EntityGraphProvider;
36use common_catalog::consts::{
37    DEFAULT_PRIVATE_SCHEMA_NAME, DEFAULT_SCHEMA_NAME, INFORMATION_SCHEMA_NAME, OBSERVED_AT_COLUMN,
38    PG_CATALOG_NAME, SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
39};
40use common_error::ext::{BoxedError, ErrorExt};
41use common_error::status_code::StatusCode;
42use common_query::OutputData;
43use common_recordbatch::SendableRecordBatchStream;
44use common_telemetry::{debug, warn};
45use common_time::timestamp::TimeUnit;
46use datafusion::dataframe::DataFrame;
47use datafusion_expr::LogicalPlan;
48use futures::TryStreamExt;
49use operator::statement::semantic_graph::{
50    CallsSource, CoDeclaredSource, Conventions, DeclaredSource, ENTITY_TYPE_GEN_AI_AGENT,
51    ENTITY_TYPE_SERVICE, EntityDeclaration, GraphQueryWindow, ImplicitEntity, RegistrySource,
52    RelationshipSources, build_registry_plan, build_relationships_plan, conventions,
53    declared_relationships_schema_matches,
54};
55use query::QueryEngineRef;
56use session::context::{QueryContext, QueryContextBuilder, QueryContextRef};
57use snafu::ResultExt;
58use store_api::storage::ScanRequest;
59use table::TableRef;
60use table::metadata::TableInfo;
61use table::predicate::{TimeRangeExtraction, extract_time_range_strict};
62use table::requests::{
63    EntityRole, SEMANTIC_METRIC_TYPE, SEMANTIC_SIGNAL_TYPE, SEMANTIC_SOURCE, SIGNAL_TYPE_METRIC,
64    SOURCE_OPENTELEMETRY, SOURCE_PROMETHEUS, is_trace_v1_table, parse_entity_columns,
65    parse_entity_option_key,
66};
67
68use crate::error;
69
70/// The live [`EntityGraphProvider`], backed by the query engine.
71pub struct EntityGraphProviderImpl {
72    query_engine: QueryEngineRef,
73    catalog_manager: Weak<dyn CatalogManager>,
74    permission_checker: Option<PermissionCheckerRef>,
75}
76
77struct EntitySource {
78    declarations: Vec<EntityDeclaration>,
79    is_trace: bool,
80    table: TableRef,
81}
82
83struct TraceSource {
84    service: Option<EntityDeclaration>,
85    agent: Option<EntityDeclaration>,
86    table: TableRef,
87}
88
89impl EntityGraphProviderImpl {
90    pub fn new(
91        query_engine: QueryEngineRef,
92        catalog_manager: Weak<dyn CatalogManager>,
93        permission_checker: Option<PermissionCheckerRef>,
94    ) -> Self {
95        Self {
96            query_engine,
97            catalog_manager,
98            permission_checker,
99        }
100    }
101
102    /// Whether the caller may read the derivation sources named by `targets`.
103    /// `Ok(false)` = denied: the source is silently excluded, per the
104    /// derivation contract. Errors other than a denial abort the scan.
105    fn authorize_sources(
106        &self,
107        query_ctx: Option<&QueryContext>,
108        targets: PermissionTableTargets,
109    ) -> Result<bool, BoxedError> {
110        let Some(ctx) = query_ctx else {
111            return Ok(true);
112        };
113        match self
114            .permission_checker
115            .as_ref()
116            .check_permission_with_table_targets(
117                ctx.current_user(),
118                PermissionReq::Action(SEMANTIC_GRAPH_QUERY),
119                targets,
120            ) {
121            Ok(_) => Ok(true),
122            Err(err) if err.status_code() == StatusCode::PermissionDenied => Ok(false),
123            Err(err) => Err(BoxedError::new(err)),
124        }
125    }
126
127    /// Parses `greptime.semantic.entity.<type>.{id|descriptive|scope}` options of
128    /// one table into per-type declarations. A type with no `id` columns is skipped.
129    fn parse_declarations(table_info: &TableInfo) -> Vec<EntityDeclaration> {
130        let Some(time_index) = table_info
131            .meta
132            .schema
133            .timestamp_column()
134            .map(|c| c.name.clone())
135        else {
136            return vec![];
137        };
138
139        // entity_type -> (id_columns, descriptive_columns, scope_columns)
140        type RoleColumns = (Vec<String>, Vec<String>, Vec<String>);
141        let mut by_type: HashMap<String, RoleColumns> = HashMap::new();
142        for (key, value) in &table_info.meta.options.extra_options {
143            let Some((entity_type, role)) = parse_entity_option_key(key) else {
144                continue;
145            };
146            let cols = parse_entity_columns(value);
147            let entry = by_type.entry(entity_type.to_string()).or_default();
148            match role {
149                EntityRole::Id => entry.0 = cols,
150                EntityRole::Descriptive => entry.1 = cols,
151                EntityRole::Scope => entry.2 = cols,
152            }
153        }
154
155        by_type
156            .into_iter()
157            .filter(|(_, (id, _, _))| !id.is_empty())
158            .filter_map(
159                |(entity_type, (id_columns, descriptive_columns, scope_columns))| {
160                    // A stale declaration (e.g. its column was dropped later)
161                    // must not poison every graph scan; skip it.
162                    let schema = &table_info.meta.schema;
163                    if let Some(missing) = id_columns
164                        .iter()
165                        .chain(&descriptive_columns)
166                        .chain(&scope_columns)
167                        .find(|c| schema.column_schema_by_name(c).is_none())
168                    {
169                        warn!(
170                            "Skipping entity declaration `{}` of table `{}`: column `{}` not found",
171                            entity_type, table_info.name, missing
172                        );
173                        return None;
174                    }
175                    Some(EntityDeclaration {
176                        schema: table_info.schema_name.clone(),
177                        table: table_info.name.clone(),
178                        time_index: time_index.clone(),
179                        entity_type,
180                        id_columns,
181                        id_qualifier: None,
182                        descriptive_columns,
183                        scope_columns,
184                    })
185                },
186            )
187            .collect()
188    }
189
190    /// All entity declarations of one table: the explicit options plus the
191    /// zero-configuration conventions (`otlp_trace_entities` for trace-v1
192    /// tables — including the `service` identity of tables created before the
193    /// ingest-side auto-stamp — and the Prometheus/OTel descriptor
194    /// whitelists). An explicit declaration of a type always suppresses the
195    /// implicit one, even when the explicit declaration is invalid and
196    /// skipped: silently falling back would change entity identity behind the
197    /// user's back.
198    fn declarations_for(
199        table_info: &TableInfo,
200        conventions: &Conventions,
201    ) -> Vec<EntityDeclaration> {
202        let mut declarations = Self::parse_declarations(table_info);
203        if is_trace_v1_table(table_info) {
204            Self::extend_with_implicit_entities(
205                table_info,
206                &conventions.otlp_trace_entities,
207                &mut declarations,
208            );
209        }
210        Self::extend_with_info_metric_conventions(
211            table_info,
212            &conventions.prometheus_info_metrics,
213            SOURCE_PROMETHEUS,
214            None,
215            &mut declarations,
216        );
217        Self::extend_with_info_metric_conventions(
218            table_info,
219            &conventions.otel_info_metrics,
220            SOURCE_OPENTELEMETRY,
221            Some(servers::semantic::METRIC_TYPE_INFO),
222            &mut declarations,
223        );
224        declarations
225    }
226
227    /// Whether the table carries an explicit `entity.<type>.id` option.
228    fn explicitly_declares(table_info: &TableInfo, entity_type: &str) -> bool {
229        table_info.meta.options.extra_options.keys().any(|key| {
230            parse_entity_option_key(key)
231                .is_some_and(|(ty, role)| ty == entity_type && role == EntityRole::Id)
232        })
233    }
234
235    /// Implicit declarations of the well-known entity-descriptor metrics
236    /// (the `prometheus_info_metrics` / `otel_info_metrics` whitelists of
237    /// `conventions.yaml`), gated on the ingest-stamped `signal_type=metric`
238    /// option plus the whitelist's expected `source`; OTel descriptors also
239    /// require `metric.type=info`. The metric engine's physical table
240    /// aggregates every logical table's columns and must not contribute a
241    /// duplicate source.
242    fn extend_with_info_metric_conventions(
243        table_info: &TableInfo,
244        whitelist: &BTreeMap<String, Vec<ImplicitEntity>>,
245        expected_source: &str,
246        expected_metric_type: Option<&str>,
247        declarations: &mut Vec<EntityDeclaration>,
248    ) {
249        let Some(implicit_entities) = whitelist.get(&table_info.name) else {
250            return;
251        };
252        let options = &table_info.meta.options.extra_options;
253        if options.get(SEMANTIC_SIGNAL_TYPE).map(String::as_str) != Some(SIGNAL_TYPE_METRIC)
254            || options.get(SEMANTIC_SOURCE).map(String::as_str) != Some(expected_source)
255            || expected_metric_type.is_some_and(|expected| {
256                options.get(SEMANTIC_METRIC_TYPE).map(String::as_str) != Some(expected)
257            })
258            || table_info.is_physical_table()
259        {
260            debug!(
261                "Table `{}` matches the info-metric whitelist but is not an eligible \
262                 `{expected_source}` info-metric source; skipping its implicit declarations",
263                table_info.name
264            );
265            return;
266        }
267        Self::extend_with_implicit_entities(table_info, implicit_entities, declarations);
268    }
269
270    /// Synthesizes the applicable subset of `entities` on `table_info`:
271    /// explicit declarations win, every id column must exist (no guessing),
272    /// descriptive columns are filtered to those present.
273    fn extend_with_implicit_entities(
274        table_info: &TableInfo,
275        entities: &[ImplicitEntity],
276        declarations: &mut Vec<EntityDeclaration>,
277    ) {
278        let schema = &table_info.meta.schema;
279        let Some(time_index) = schema.timestamp_column().map(|c| c.name.clone()) else {
280            debug!(
281                "Table `{}` has no time index; skipping its implicit declarations",
282                table_info.name
283            );
284            return;
285        };
286        for implicit in entities {
287            if Self::explicitly_declares(table_info, &implicit.entity) {
288                debug!(
289                    "Table `{}` explicitly declares `{}`; the implicit declaration is suppressed",
290                    table_info.name, implicit.entity
291                );
292                continue;
293            }
294            if let Some(missing) = implicit
295                .id
296                .iter()
297                .find(|c| schema.column_schema_by_name(c).is_none())
298            {
299                debug!(
300                    "Table `{}` lacks the id column `{}`; skipping the implicit `{}` declaration",
301                    table_info.name, missing, implicit.entity
302                );
303                continue;
304            }
305            let descriptive_columns = if implicit.descriptive_rest {
306                table_info
307                    .meta
308                    .row_key_column_names()
309                    .filter(|c| !implicit.id.contains(c))
310                    .cloned()
311                    .collect()
312            } else {
313                implicit
314                    .descriptive
315                    .iter()
316                    .filter(|c| schema.column_schema_by_name(c).is_some())
317                    .cloned()
318                    .collect()
319            };
320            // A table predating the qualifier column keeps the unqualified
321            // identity rather than losing the declaration.
322            let id_qualifier = implicit
323                .qualified_by
324                .clone()
325                .filter(|c| schema.column_schema_by_name(c).is_some());
326            declarations.push(EntityDeclaration {
327                schema: table_info.schema_name.clone(),
328                table: table_info.name.clone(),
329                time_index: time_index.clone(),
330                entity_type: implicit.entity.clone(),
331                id_columns: implicit.id.clone(),
332                id_qualifier,
333                descriptive_columns,
334                scope_columns: vec![],
335            });
336        }
337    }
338
339    /// Enumerates entity declarations and trace tables across a catalog. Trace
340    /// tables are keyed off the engine-native `table_data_model` option (same
341    /// check as the Jaeger query path) so pre-existing trace tables without the
342    /// newer `greptime.semantic.*` stamps are recognized too.
343    async fn enumerate(
344        &self,
345        catalog: &str,
346        query_ctx: Option<&QueryContext>,
347    ) -> Result<(Vec<EntitySource>, Vec<TraceSource>), BoxedError> {
348        let Some(catalog_manager) = self.catalog_manager.upgrade() else {
349            return Ok((vec![], vec![]));
350        };
351        let conventions = conventions()
352            .map_err(datafusion::error::DataFusionError::Internal)
353            .context(error::DataFusionSnafu)
354            .map_err(BoxedError::new)?;
355
356        // A target-blind checker (e.g. the default mode-based one) answers the
357        // same for every table: ask once up front instead of per table.
358        let per_table_auth = match self.permission_checker.as_ref() {
359            Some(checker) if query_ctx.is_some() => {
360                if checker.uses_table_targets() {
361                    true
362                } else if self
363                    .authorize_sources(query_ctx, PermissionTableTargets::resolved(vec![]))?
364                {
365                    false
366                } else {
367                    debug!(
368                        "Caller lacks the entity-graph read permission; deriving an empty graph \
369                         (catalog: {catalog})"
370                    );
371                    return Ok((vec![], vec![]));
372                }
373            }
374            _ => false,
375        };
376
377        let mut declarations = vec![];
378        let mut traces = vec![];
379        let schemas = catalog_manager
380            .schema_names(catalog, query_ctx)
381            .await
382            .map_err(BoxedError::new)?;
383        for schema in schemas {
384            // User telemetry never lives in the system schemas; skip them to avoid
385            // scanning information_schema (including the computed graph tables) etc.
386            if schema == INFORMATION_SCHEMA_NAME
387                || schema == PG_CATALOG_NAME
388                || schema == DEFAULT_PRIVATE_SCHEMA_NAME
389            {
390                continue;
391            }
392            let mut tables = catalog_manager.tables(catalog, &schema, query_ctx);
393            while let Some(table) = tables.try_next().await.map_err(BoxedError::new)? {
394                let table_info = table.table_info();
395                let table_declarations = Self::declarations_for(&table_info, conventions);
396                let is_trace = is_trace_v1_table(&table_info);
397                // Authorize only tables that would contribute rows.
398                if per_table_auth
399                    && (is_trace || !table_declarations.is_empty())
400                    && !self.authorize_sources(
401                        query_ctx,
402                        PermissionTableTargets::resolved(vec![PermissionTableTarget::new(
403                            catalog,
404                            &schema,
405                            &table_info.name,
406                        )]),
407                    )?
408                {
409                    debug!(
410                        "Excluding `{schema}.{}` from the entity-graph derivation: caller lacks \
411                         read permission",
412                        table_info.name
413                    );
414                    continue;
415                }
416                if is_trace {
417                    let find = |entity_type: &str| {
418                        table_declarations
419                            .iter()
420                            .find(|d| d.entity_type == entity_type)
421                            .cloned()
422                    };
423                    let service = find(ENTITY_TYPE_SERVICE);
424                    let agent = find(ENTITY_TYPE_GEN_AI_AGENT);
425                    if service.is_none() {
426                        // No usable service identity: the table cannot
427                        // contribute service-calls edges (see declarations_for).
428                        warn!(
429                            "Trace table `{}` has no usable service declaration; skipping calls derivation",
430                            table_info.name
431                        );
432                    }
433                    if service.is_some() || agent.is_some() {
434                        traces.push(TraceSource {
435                            service,
436                            agent,
437                            table: table.clone(),
438                        });
439                    }
440                }
441                if !table_declarations.is_empty() {
442                    declarations.push(EntitySource {
443                        declarations: table_declarations,
444                        is_trace,
445                        table,
446                    });
447                }
448            }
449        }
450        Ok((declarations, traces))
451    }
452
453    /// The time window to derive over, taken from the scan's `observed_at`
454    /// predicate.
455    ///
456    /// The contract (RFC "The contract"): no `observed_at` predicate → the
457    /// product default (last hour); a missing upper bound means "up to now"; a
458    /// predicate without a lower bound or in a shape that cannot be safely
459    /// extracted is an error asking for an explicit range — never a silent
460    /// fallback into incomplete results.
461    /// `Ok(None)` = the window cannot match anything (e.g. a lower bound in
462    /// the future with the implicit "up to now" upper bound): the scan streams
463    /// empty instead of deriving.
464    fn query_window(request: &ScanRequest) -> Result<Option<GraphQueryWindow>, BoxedError> {
465        let invalid =
466            |err_msg: String| Err(BoxedError::new(error::InvalidSqlSnafu { err_msg }.build()));
467        match extract_time_range_strict(OBSERVED_AT_COLUMN, TimeUnit::Millisecond, &request.filters)
468        {
469            TimeRangeExtraction::Absent => Ok(Some(GraphQueryWindow::default_last_hour())),
470            TimeRangeExtraction::Extracted(range) => {
471                let Some(start) = range.start() else {
472                    return invalid(format!(
473                        "the {OBSERVED_AT_COLUMN} filter has no lower bound; the graph cannot \
474                         derive over unbounded history — add e.g. {OBSERVED_AT_COLUMN} >= \
475                         '2026-01-01 00:00:00'"
476                    ));
477                };
478                let end_ms = range
479                    .end()
480                    .map(|ts| ts.value())
481                    .unwrap_or_else(common_time::util::current_time_millis);
482                if start.value() >= end_ms {
483                    return Ok(None);
484                }
485                Ok(Some(GraphQueryWindow::from_observed(start.value(), end_ms)))
486            }
487            TimeRangeExtraction::Unsupported => invalid(format!(
488                "cannot derive the graph window from the {OBSERVED_AT_COLUMN} filter; use plain \
489                 range predicates (>=, <, BETWEEN) with literal bounds"
490            )),
491        }
492    }
493
494    fn read_table(&self, table: TableRef) -> Result<DataFrame, BoxedError> {
495        self.query_engine.read_table(table).map_err(BoxedError::new)
496    }
497
498    /// The declared-edge branch source, when the physical table exists, the
499    /// caller may read it, and it still matches the canonical definition. A
500    /// mismatch (upgrade skew) is an explicit error, not a silent skip:
501    /// dropping declared edges would misrepresent the graph.
502    async fn declared_source(
503        &self,
504        catalog: &str,
505        query_ctx: Option<&QueryContext>,
506    ) -> Result<Option<DeclaredSource>, BoxedError> {
507        let Some(catalog_manager) = self.catalog_manager.upgrade() else {
508            return Ok(None);
509        };
510        let Some(table) = catalog_manager
511            .table(
512                catalog,
513                DEFAULT_PRIVATE_SCHEMA_NAME,
514                SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
515                query_ctx,
516            )
517            .await
518            .map_err(BoxedError::new)?
519        else {
520            return Ok(None);
521        };
522        if !self.authorize_sources(
523            query_ctx,
524            PermissionTableTargets::resolved(vec![PermissionTableTarget::new(
525                catalog,
526                DEFAULT_PRIVATE_SCHEMA_NAME,
527                SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
528            )]),
529        )? {
530            debug!(
531                "Excluding declared edges: caller lacks read permission on \
532                 `{DEFAULT_PRIVATE_SCHEMA_NAME}.{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME}`"
533            );
534            return Ok(None);
535        }
536        let table_info = table.table_info();
537        if !declared_relationships_schema_matches(&table_info) {
538            return Err(BoxedError::new(
539                error::InvalidSqlSnafu {
540                    err_msg: format!(
541                        "{DEFAULT_PRIVATE_SCHEMA_NAME}.{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME} \
542                         does not match its canonical schema; declared edges cannot be derived"
543                    ),
544                }
545                .build(),
546            ));
547        }
548        Ok(Some(DeclaredSource {
549            scan: self.read_table(table)?,
550        }))
551    }
552
553    /// Executes a derivation plan under the caller's context (inheriting its
554    /// permissions, cancellation and deadline); context-less internal scans
555    /// get a minimal default.
556    async fn execute_plan(
557        &self,
558        catalog: &str,
559        plan: LogicalPlan,
560        query_ctx: Option<QueryContextRef>,
561    ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
562        let query_ctx = query_ctx.unwrap_or_else(|| {
563            QueryContextBuilder::default()
564                .current_catalog(catalog.to_string())
565                .current_schema(DEFAULT_SCHEMA_NAME.to_string())
566                .build()
567                .into()
568        });
569        let output = self
570            .query_engine
571            .execute(plan, query_ctx)
572            .await
573            .map_err(BoxedError::new)?;
574        let stream = match output.data {
575            OutputData::Stream(stream) => stream,
576            OutputData::RecordBatches(batches) => batches.as_stream(),
577            OutputData::AffectedRows(_) => return Ok(None),
578        };
579        Ok(Some(stream))
580    }
581}
582
583#[async_trait]
584impl EntityGraphProvider for EntityGraphProviderImpl {
585    async fn scan_entities(
586        &self,
587        catalog: &str,
588        request: ScanRequest,
589        query_ctx: Option<QueryContextRef>,
590    ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
591        let (sources, _) = self.enumerate(catalog, query_ctx.as_deref()).await?;
592        let mut plans = Vec::with_capacity(sources.len());
593        for source in sources {
594            plans.push(RegistrySource {
595                declarations: source.declarations,
596                scan: self.read_table(source.table)?,
597            });
598        }
599        let Some(window) = Self::query_window(&request)? else {
600            return Ok(None);
601        };
602        let Some(plan) = build_registry_plan(plans, &window)
603            .context(error::DataFusionSnafu)
604            .map_err(BoxedError::new)?
605        else {
606            return Ok(None);
607        };
608        self.execute_plan(catalog, plan, query_ctx).await
609    }
610
611    async fn scan_relationships(
612        &self,
613        catalog: &str,
614        request: ScanRequest,
615        query_ctx: Option<QueryContextRef>,
616    ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
617        let (sources, traces) = self.enumerate(catalog, query_ctx.as_deref()).await?;
618        let mut calls = Vec::with_capacity(traces.len());
619        for trace in traces {
620            calls.push(CallsSource {
621                service: trace.service,
622                agent: trace.agent,
623                scan: self.read_table(trace.table)?,
624            });
625        }
626        let mut co_declared = Vec::with_capacity(sources.len());
627        for source in sources {
628            co_declared.push(CoDeclaredSource {
629                declarations: source.declarations,
630                is_trace: source.is_trace,
631                scan: self.read_table(source.table)?,
632            });
633        }
634        let declared = self.declared_source(catalog, query_ctx.as_deref()).await?;
635        let Some(window) = Self::query_window(&request)? else {
636            return Ok(None);
637        };
638        let Some(plan) = build_relationships_plan(
639            RelationshipSources {
640                traces: calls,
641                co_declared,
642                declared,
643            },
644            &window,
645        )
646        .context(error::DataFusionSnafu)
647        .map_err(BoxedError::new)?
648        else {
649            return Ok(None);
650        };
651        self.execute_plan(catalog, plan, query_ctx).await
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use std::collections::HashMap;
658    use std::sync::Arc;
659
660    use common_catalog::consts::{DEFAULT_CATALOG_NAME, MITO_ENGINE};
661    use datatypes::prelude::ConcreteDataType;
662    use datatypes::schema::{ColumnSchema, SchemaBuilder};
663    use store_api::metric_engine_consts::PHYSICAL_TABLE_METADATA_KEY;
664    use table::metadata::{TableInfoBuilder, TableMeta, TableType};
665    use table::requests::{TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1, TableOptions};
666
667    use super::*;
668
669    fn table_info(columns: &[&str], extra: &[(&str, &str)]) -> TableInfo {
670        let mut column_schemas = vec![
671            ColumnSchema::new(
672                "ts",
673                ConcreteDataType::timestamp_millisecond_datatype(),
674                false,
675            )
676            .with_time_index(true),
677        ];
678        column_schemas.extend(
679            columns
680                .iter()
681                .map(|c| ColumnSchema::new(*c, ConcreteDataType::string_datatype(), true)),
682        );
683        assemble_table_info(column_schemas, extra)
684    }
685
686    fn assemble_table_info(column_schemas: Vec<ColumnSchema>, extra: &[(&str, &str)]) -> TableInfo {
687        named_table_info("t1", column_schemas, vec![], extra)
688    }
689
690    fn named_table_info(
691        name: &str,
692        column_schemas: Vec<ColumnSchema>,
693        primary_key_indices: Vec<usize>,
694        extra: &[(&str, &str)],
695    ) -> TableInfo {
696        let schema = Arc::new(
697            SchemaBuilder::try_from_columns(column_schemas)
698                .unwrap()
699                .build()
700                .unwrap(),
701        );
702        let options = TableOptions {
703            extra_options: extra
704                .iter()
705                .map(|(k, v)| (k.to_string(), v.to_string()))
706                .collect::<HashMap<_, _>>(),
707            ..Default::default()
708        };
709        let meta = TableMeta {
710            schema,
711            primary_key_indices,
712            value_indices: vec![],
713            engine: MITO_ENGINE.to_string(),
714            next_column_id: 1,
715            options,
716            created_on: Default::default(),
717            updated_on: Default::default(),
718            partition_key_indices: vec![],
719            column_ids: vec![],
720        };
721        TableInfoBuilder::default()
722            .table_id(1)
723            .name(name)
724            .catalog_name(DEFAULT_CATALOG_NAME)
725            .schema_name(DEFAULT_SCHEMA_NAME)
726            .table_version(0)
727            .table_type(TableType::Base)
728            .meta(meta)
729            .build()
730            .unwrap()
731    }
732
733    /// A metric-engine-logical-table shape: every label column is a tag.
734    fn prom_table_info(name: &str, tags: &[&str], extra: &[(&str, &str)]) -> TableInfo {
735        let mut column_schemas = vec![
736            ColumnSchema::new(
737                "greptime_timestamp",
738                ConcreteDataType::timestamp_millisecond_datatype(),
739                false,
740            )
741            .with_time_index(true),
742        ];
743        column_schemas.extend(
744            tags.iter()
745                .map(|c| ColumnSchema::new(*c, ConcreteDataType::string_datatype(), true)),
746        );
747        let primary_key_indices = (1..=tags.len()).collect();
748        named_table_info(name, column_schemas, primary_key_indices, extra)
749    }
750
751    #[test]
752    fn future_lower_only_window_matches_nothing() {
753        let future_ms = common_time::util::current_time_millis() + 86_400_000;
754        let request = ScanRequest {
755            filters: vec![
756                datafusion_expr::col(OBSERVED_AT_COLUMN).gt_eq(datafusion_expr::lit(
757                    datafusion::common::ScalarValue::TimestampMillisecond(Some(future_ms), None),
758                )),
759            ],
760            ..Default::default()
761        };
762        assert!(
763            EntityGraphProviderImpl::query_window(&request)
764                .unwrap()
765                .is_none()
766        );
767
768        assert!(
769            EntityGraphProviderImpl::query_window(&ScanRequest::default())
770                .unwrap()
771                .is_some()
772        );
773    }
774
775    #[test]
776    fn declaration_referencing_missing_column_is_skipped() {
777        let info = table_info(
778            &["service_name"],
779            &[
780                ("greptime.semantic.entity.service.id", "service_name"),
781                ("greptime.semantic.entity.host.id", "gone"),
782            ],
783        );
784        let declarations = EntityGraphProviderImpl::declarations_for(&info, conventions().unwrap());
785        assert_eq!(declarations.len(), 1);
786        assert_eq!(declarations[0].entity_type, "service");
787
788        let info = table_info(
789            &["service_name"],
790            &[
791                ("greptime.semantic.entity.service.id", "service_name"),
792                ("greptime.semantic.entity.service.descriptive", "gone"),
793            ],
794        );
795        assert!(
796            EntityGraphProviderImpl::declarations_for(&info, conventions().unwrap()).is_empty()
797        );
798    }
799
800    const PROM_STAMPS: &[(&str, &str)] = &[
801        (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
802        (SEMANTIC_SOURCE, SOURCE_PROMETHEUS),
803    ];
804
805    fn sorted_declarations(info: &TableInfo) -> Vec<EntityDeclaration> {
806        let mut declarations =
807            EntityGraphProviderImpl::declarations_for(info, conventions().unwrap());
808        declarations.sort_by(|a, b| a.entity_type.cmp(&b.entity_type));
809        declarations
810    }
811
812    #[test]
813    fn prometheus_info_metric_gets_implicit_declarations() {
814        let info = prom_table_info(
815            "kube_pod_info",
816            &["namespace", "pod", "uid", "node", "job", "instance"],
817            PROM_STAMPS,
818        );
819        let declarations = sorted_declarations(&info);
820        assert_eq!(declarations.len(), 2);
821        assert_eq!(declarations[0].entity_type, "k8s.node");
822        assert_eq!(declarations[0].id_columns, vec!["node"]);
823        assert_eq!(declarations[1].entity_type, "k8s.pod");
824        assert_eq!(declarations[1].id_columns, vec!["uid"]);
825        // host_ip/pod_ip/created_by_* are absent from this table; descriptive
826        // shrinks to the present columns.
827        assert_eq!(
828            declarations[1].descriptive_columns,
829            vec!["namespace", "pod", "node"]
830        );
831        assert_eq!(declarations[1].time_index, "greptime_timestamp");
832    }
833
834    #[test]
835    fn trace_table_gets_implicit_resource_entities() {
836        let full = table_info(
837            &[
838                "service_name",
839                "resource_attributes.service.instance.id",
840                "resource_attributes.k8s.pod.uid",
841                "resource_attributes.k8s.pod.name",
842            ],
843            &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
844        );
845        let declarations = sorted_declarations(&full);
846        let types: Vec<&str> = declarations
847            .iter()
848            .map(|d| d.entity_type.as_str())
849            .collect();
850        assert_eq!(types, vec!["k8s.pod", "service", "service.instance"]);
851        assert_eq!(
852            declarations[0].id_columns,
853            vec!["resource_attributes.k8s.pod.uid"]
854        );
855        assert_eq!(
856            declarations[0].descriptive_columns,
857            vec!["resource_attributes.k8s.pod.name"]
858        );
859        assert_eq!(
860            declarations[2].id_columns,
861            vec!["service_name", "resource_attributes.service.instance.id"]
862        );
863        assert_eq!(declarations[1].id_qualifier, None);
864
865        let namespaced = table_info(
866            &[
867                "service_name",
868                "resource_attributes.service.namespace",
869                "resource_attributes.service.instance.id",
870            ],
871            &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
872        );
873        for declaration in sorted_declarations(&namespaced) {
874            assert_eq!(
875                declaration.id_qualifier.as_deref(),
876                Some("resource_attributes.service.namespace"),
877                "{} must qualify its identity like the metric side's job",
878                declaration.entity_type
879            );
880        }
881
882        // Missing uid column: no pod entity synthesized, no name-based guess.
883        let no_uid = table_info(
884            &["service_name", "resource_attributes.k8s.pod.name"],
885            &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
886        );
887        let types: Vec<String> = sorted_declarations(&no_uid)
888            .into_iter()
889            .map(|d| d.entity_type)
890            .collect();
891        assert_eq!(types, vec!["service"]);
892
893        // An explicit declaration suppresses the implicit one even when it is
894        // invalid and skipped: identity must not change behind the user's back.
895        let invalid_explicit = table_info(
896            &["service_name"],
897            &[
898                (TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1),
899                ("greptime.semantic.entity.service.id", "gone"),
900            ],
901        );
902        assert!(sorted_declarations(&invalid_explicit).is_empty());
903    }
904
905    #[test]
906    fn trace_table_host_and_container_require_stable_ids() {
907        let with_ids = table_info(
908            &[
909                "service_name",
910                "resource_attributes.host.id",
911                "resource_attributes.host.name",
912                "resource_attributes.container.id",
913            ],
914            &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
915        );
916        let declarations = sorted_declarations(&with_ids);
917        let types: Vec<&str> = declarations
918            .iter()
919            .map(|d| d.entity_type.as_str())
920            .collect();
921        assert_eq!(types, vec!["container", "host", "service"]);
922        assert_eq!(
923            declarations[1].id_columns,
924            vec!["resource_attributes.host.id"]
925        );
926        assert_eq!(
927            declarations[1].descriptive_columns,
928            vec!["resource_attributes.host.name"]
929        );
930
931        let names_only = table_info(
932            &[
933                "service_name",
934                "resource_attributes.host.name",
935                "resource_attributes.container.name",
936            ],
937            &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
938        );
939        let types: Vec<String> = sorted_declarations(&names_only)
940            .into_iter()
941            .map(|d| d.entity_type)
942            .collect();
943        assert_eq!(types, vec!["service"]);
944    }
945
946    #[test]
947    fn prometheus_implicit_declarations_are_gated() {
948        let labels: &[&str] = &["namespace", "pod", "node"];
949        // A non-Prometheus source.
950        assert!(
951            sorted_declarations(&prom_table_info(
952                "kube_pod_info",
953                labels,
954                &[
955                    (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
956                    (SEMANTIC_SOURCE, "opentelemetry"),
957                ],
958            ))
959            .is_empty()
960        );
961        // Not a whitelisted metric name.
962        assert!(
963            sorted_declarations(&prom_table_info("http_requests_total", labels, PROM_STAMPS))
964                .is_empty()
965        );
966        let mut stamps = PROM_STAMPS.to_vec();
967        stamps.push((PHYSICAL_TABLE_METADATA_KEY, "true"));
968        assert!(sorted_declarations(&prom_table_info("kube_pod_info", labels, &stamps)).is_empty());
969
970        // A missing id column (uid) drops that entity, not the whole table.
971        let info = prom_table_info("kube_pod_info", &["namespace", "pod", "node"], PROM_STAMPS);
972        let declarations = sorted_declarations(&info);
973        assert_eq!(declarations.len(), 1);
974        assert_eq!(declarations[0].entity_type, "k8s.node");
975    }
976
977    #[test]
978    fn explicit_declaration_suppresses_the_implicit_one() {
979        let mut stamps = PROM_STAMPS.to_vec();
980        stamps.push(("greptime.semantic.entity.k8s.pod.id", "pod"));
981        let info = prom_table_info("kube_pod_info", &["namespace", "pod", "node"], &stamps);
982        let declarations = sorted_declarations(&info);
983        assert_eq!(declarations.len(), 2);
984        assert_eq!(declarations[0].entity_type, "k8s.node");
985        assert_eq!(declarations[1].entity_type, "k8s.pod");
986        // The explicit identity wins over the conventional [namespace, pod].
987        assert_eq!(declarations[1].id_columns, vec!["pod"]);
988    }
989
990    const OTEL_STAMPS: &[(&str, &str)] = &[
991        (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
992        (SEMANTIC_SOURCE, SOURCE_OPENTELEMETRY),
993        (SEMANTIC_METRIC_TYPE, servers::semantic::METRIC_TYPE_INFO),
994    ];
995
996    #[test]
997    fn greptime_otel_resource_info_gets_implicit_declarations() {
998        let info = prom_table_info(
999            "greptime_otel_resource_info",
1000            &[
1001                "job",
1002                "instance",
1003                "service.name",
1004                "service.namespace",
1005                "host.id",
1006                "host.name",
1007                "container.id",
1008                "container.name",
1009                "k8s.pod.uid",
1010                "k8s.pod.name",
1011                "k8s.namespace.name",
1012            ],
1013            OTEL_STAMPS,
1014        );
1015        let declarations = sorted_declarations(&info);
1016        let types: Vec<&str> = declarations
1017            .iter()
1018            .map(|d| d.entity_type.as_str())
1019            .collect();
1020        assert_eq!(
1021            types,
1022            vec![
1023                "container",
1024                "host",
1025                "k8s.pod",
1026                "service",
1027                "service.instance"
1028            ]
1029        );
1030        assert_eq!(declarations[1].id_columns, vec!["host.id"]);
1031        assert_eq!(declarations[1].descriptive_columns, vec!["host.name"]);
1032        assert_eq!(declarations[3].id_columns, vec!["job"]);
1033        assert_eq!(
1034            declarations[3].descriptive_columns,
1035            vec!["service.name", "service.namespace"]
1036        );
1037        assert_eq!(declarations[4].id_columns, vec!["job", "instance"]);
1038        assert!(declarations[4].descriptive_columns.is_empty());
1039
1040        let partial = prom_table_info(
1041            "greptime_otel_resource_info",
1042            &["job", "service.name", "host.id"],
1043            OTEL_STAMPS,
1044        );
1045        let types: Vec<String> = sorted_declarations(&partial)
1046            .into_iter()
1047            .map(|d| d.entity_type)
1048            .collect();
1049        assert_eq!(types, vec!["host", "service"]);
1050    }
1051
1052    #[test]
1053    fn otel_implicit_declarations_are_gated() {
1054        let labels: &[&str] = &["job", "instance", "host.id"];
1055        assert!(
1056            sorted_declarations(&prom_table_info(
1057                "greptime_otel_resource_info",
1058                labels,
1059                PROM_STAMPS
1060            ))
1061            .is_empty()
1062        );
1063        let mut stamps = OTEL_STAMPS.to_vec();
1064        stamps.push((PHYSICAL_TABLE_METADATA_KEY, "true"));
1065        assert!(
1066            sorted_declarations(&prom_table_info(
1067                "greptime_otel_resource_info",
1068                labels,
1069                &stamps
1070            ))
1071            .is_empty()
1072        );
1073        assert!(
1074            conventions()
1075                .unwrap()
1076                .otel_info_metrics
1077                .contains_key(servers::otlp::metrics::OTEL_RESOURCE_INFO_TABLE_NAME)
1078        );
1079
1080        let wrong_type = [
1081            (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
1082            (SEMANTIC_SOURCE, SOURCE_OPENTELEMETRY),
1083            (SEMANTIC_METRIC_TYPE, servers::semantic::METRIC_TYPE_GAUGE),
1084        ];
1085        assert!(
1086            sorted_declarations(&prom_table_info(
1087                "greptime_otel_resource_info",
1088                labels,
1089                &wrong_type
1090            ))
1091            .is_empty()
1092        );
1093    }
1094
1095    #[test]
1096    fn target_info_descriptive_rest_covers_remaining_tags() {
1097        let info = prom_table_info(
1098            "target_info",
1099            &["job", "instance", "k8s_cluster_name", "service_version"],
1100            PROM_STAMPS,
1101        );
1102        let declarations = sorted_declarations(&info);
1103        assert_eq!(declarations.len(), 2);
1104        assert_eq!(declarations[0].entity_type, "service");
1105        assert_eq!(declarations[0].id_columns, vec!["job"]);
1106        assert!(declarations[0].descriptive_columns.is_empty());
1107        // The remaining labels are the target's resource attributes: they
1108        // describe the instance, not the logical service.
1109        assert_eq!(declarations[1].entity_type, "service.instance");
1110        assert_eq!(declarations[1].id_columns, vec!["job", "instance"]);
1111        assert_eq!(
1112            declarations[1].descriptive_columns,
1113            vec!["k8s_cluster_name", "service_version"]
1114        );
1115    }
1116}