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::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_SIGNAL_TYPE, SEMANTIC_SOURCE, SIGNAL_TYPE_METRIC, SOURCE_PROMETHEUS,
64    is_trace_v1_table, parse_entity_columns, parse_entity_option_key,
65};
66
67use crate::error;
68
69/// The live [`EntityGraphProvider`], backed by the query engine.
70pub struct EntityGraphProviderImpl {
71    query_engine: QueryEngineRef,
72    catalog_manager: Weak<dyn CatalogManager>,
73    permission_checker: Option<PermissionCheckerRef>,
74}
75
76struct EntitySource {
77    declarations: Vec<EntityDeclaration>,
78    is_trace: bool,
79    table: TableRef,
80}
81
82struct TraceSource {
83    service: Option<EntityDeclaration>,
84    agent: Option<EntityDeclaration>,
85    table: TableRef,
86}
87
88impl EntityGraphProviderImpl {
89    pub fn new(
90        query_engine: QueryEngineRef,
91        catalog_manager: Weak<dyn CatalogManager>,
92        permission_checker: Option<PermissionCheckerRef>,
93    ) -> Self {
94        Self {
95            query_engine,
96            catalog_manager,
97            permission_checker,
98        }
99    }
100
101    /// Whether the caller may read the derivation sources named by `targets`.
102    /// `Ok(false)` = denied: the source is silently excluded, per the
103    /// derivation contract. Errors other than a denial abort the scan.
104    fn authorize_sources(
105        &self,
106        query_ctx: Option<&QueryContext>,
107        targets: PermissionTableTargets,
108    ) -> Result<bool, BoxedError> {
109        let Some(ctx) = query_ctx else {
110            return Ok(true);
111        };
112        match self
113            .permission_checker
114            .as_ref()
115            .check_permission_with_table_targets(
116                ctx.current_user(),
117                PermissionReq::Action(SEMANTIC_GRAPH_QUERY),
118                targets,
119            ) {
120            Ok(_) => Ok(true),
121            Err(err) if err.status_code() == StatusCode::PermissionDenied => Ok(false),
122            Err(err) => Err(BoxedError::new(err)),
123        }
124    }
125
126    /// Parses `greptime.semantic.entity.<type>.{id|descriptive|scope}` options of
127    /// one table into per-type declarations. A type with no `id` columns is skipped.
128    fn parse_declarations(table_info: &TableInfo) -> Vec<EntityDeclaration> {
129        let Some(time_index) = table_info
130            .meta
131            .schema
132            .timestamp_column()
133            .map(|c| c.name.clone())
134        else {
135            return vec![];
136        };
137
138        // entity_type -> (id_columns, descriptive_columns, scope_columns)
139        type RoleColumns = (Vec<String>, Vec<String>, Vec<String>);
140        let mut by_type: HashMap<String, RoleColumns> = HashMap::new();
141        for (key, value) in &table_info.meta.options.extra_options {
142            let Some((entity_type, role)) = parse_entity_option_key(key) else {
143                continue;
144            };
145            let cols = parse_entity_columns(value);
146            let entry = by_type.entry(entity_type.to_string()).or_default();
147            match role {
148                EntityRole::Id => entry.0 = cols,
149                EntityRole::Descriptive => entry.1 = cols,
150                EntityRole::Scope => entry.2 = cols,
151            }
152        }
153
154        by_type
155            .into_iter()
156            .filter(|(_, (id, _, _))| !id.is_empty())
157            .filter_map(
158                |(entity_type, (id_columns, descriptive_columns, scope_columns))| {
159                    // A stale declaration (e.g. its column was dropped later)
160                    // must not poison every graph scan; skip it.
161                    let schema = &table_info.meta.schema;
162                    if let Some(missing) = id_columns
163                        .iter()
164                        .chain(&descriptive_columns)
165                        .chain(&scope_columns)
166                        .find(|c| schema.column_schema_by_name(c).is_none())
167                    {
168                        warn!(
169                            "Skipping entity declaration `{}` of table `{}`: column `{}` not found",
170                            entity_type, table_info.name, missing
171                        );
172                        return None;
173                    }
174                    Some(EntityDeclaration {
175                        schema: table_info.schema_name.clone(),
176                        table: table_info.name.clone(),
177                        time_index: time_index.clone(),
178                        entity_type,
179                        id_columns,
180                        descriptive_columns,
181                        scope_columns,
182                    })
183                },
184            )
185            .collect()
186    }
187
188    /// All entity declarations of one table: the explicit options plus the
189    /// zero-configuration conventions (`otlp_trace_entities` for trace-v1
190    /// tables — including the `service` identity of tables created before the
191    /// ingest-side auto-stamp — and the Prometheus descriptor whitelist). An
192    /// explicit declaration of a type always suppresses the implicit one, even
193    /// when the explicit declaration is invalid and skipped: silently falling
194    /// back would change entity identity behind the user's back.
195    fn declarations_for(
196        table_info: &TableInfo,
197        conventions: &Conventions,
198    ) -> Vec<EntityDeclaration> {
199        let mut declarations = Self::parse_declarations(table_info);
200        if is_trace_v1_table(table_info) {
201            Self::extend_with_implicit_entities(
202                table_info,
203                &conventions.otlp_trace_entities,
204                &mut declarations,
205            );
206        }
207        Self::extend_with_prometheus_conventions(table_info, conventions, &mut declarations);
208        declarations
209    }
210
211    /// Whether the table carries an explicit `entity.<type>.id` option.
212    fn explicitly_declares(table_info: &TableInfo, entity_type: &str) -> bool {
213        table_info.meta.options.extra_options.keys().any(|key| {
214            parse_entity_option_key(key)
215                .is_some_and(|(ty, role)| ty == entity_type && role == EntityRole::Id)
216        })
217    }
218
219    /// Implicit declarations of the well-known Prometheus entity-descriptor
220    /// metrics (the `prometheus_info_metrics` whitelist of `conventions.yaml`),
221    /// gated on the ingest-stamped `signal_type=metric` + `source=prometheus`
222    /// options and keyed by table name. The metric engine's physical table
223    /// aggregates every logical table's columns and must not contribute a
224    /// duplicate source.
225    fn extend_with_prometheus_conventions(
226        table_info: &TableInfo,
227        conventions: &Conventions,
228        declarations: &mut Vec<EntityDeclaration>,
229    ) {
230        let Some(implicit_entities) = conventions.prometheus_info_metrics.get(&table_info.name)
231        else {
232            return;
233        };
234        let options = &table_info.meta.options.extra_options;
235        if options.get(SEMANTIC_SIGNAL_TYPE).map(String::as_str) != Some(SIGNAL_TYPE_METRIC)
236            || options.get(SEMANTIC_SOURCE).map(String::as_str) != Some(SOURCE_PROMETHEUS)
237            || table_info.is_physical_table()
238        {
239            debug!(
240                "Table `{}` matches the info-metric whitelist but is not a prometheus logical \
241                 metric table; skipping its implicit declarations",
242                table_info.name
243            );
244            return;
245        }
246        Self::extend_with_implicit_entities(table_info, implicit_entities, declarations);
247    }
248
249    /// Synthesizes the applicable subset of `entities` on `table_info`:
250    /// explicit declarations win, every id column must exist (no guessing),
251    /// descriptive columns are filtered to those present.
252    fn extend_with_implicit_entities(
253        table_info: &TableInfo,
254        entities: &[ImplicitEntity],
255        declarations: &mut Vec<EntityDeclaration>,
256    ) {
257        let schema = &table_info.meta.schema;
258        let Some(time_index) = schema.timestamp_column().map(|c| c.name.clone()) else {
259            debug!(
260                "Table `{}` has no time index; skipping its implicit declarations",
261                table_info.name
262            );
263            return;
264        };
265        for implicit in entities {
266            if Self::explicitly_declares(table_info, &implicit.entity) {
267                debug!(
268                    "Table `{}` explicitly declares `{}`; the implicit declaration is suppressed",
269                    table_info.name, implicit.entity
270                );
271                continue;
272            }
273            if let Some(missing) = implicit
274                .id
275                .iter()
276                .find(|c| schema.column_schema_by_name(c).is_none())
277            {
278                debug!(
279                    "Table `{}` lacks the id column `{}`; skipping the implicit `{}` declaration",
280                    table_info.name, missing, implicit.entity
281                );
282                continue;
283            }
284            let descriptive_columns = if implicit.descriptive_rest {
285                table_info
286                    .meta
287                    .row_key_column_names()
288                    .filter(|c| !implicit.id.contains(c))
289                    .cloned()
290                    .collect()
291            } else {
292                implicit
293                    .descriptive
294                    .iter()
295                    .filter(|c| schema.column_schema_by_name(c).is_some())
296                    .cloned()
297                    .collect()
298            };
299            declarations.push(EntityDeclaration {
300                schema: table_info.schema_name.clone(),
301                table: table_info.name.clone(),
302                time_index: time_index.clone(),
303                entity_type: implicit.entity.clone(),
304                id_columns: implicit.id.clone(),
305                descriptive_columns,
306                scope_columns: vec![],
307            });
308        }
309    }
310
311    /// Enumerates entity declarations and trace tables across a catalog. Trace
312    /// tables are keyed off the engine-native `table_data_model` option (same
313    /// check as the Jaeger query path) so pre-existing trace tables without the
314    /// newer `greptime.semantic.*` stamps are recognized too.
315    async fn enumerate(
316        &self,
317        catalog: &str,
318        query_ctx: Option<&QueryContext>,
319    ) -> Result<(Vec<EntitySource>, Vec<TraceSource>), BoxedError> {
320        let Some(catalog_manager) = self.catalog_manager.upgrade() else {
321            return Ok((vec![], vec![]));
322        };
323        let conventions = conventions()
324            .map_err(datafusion::error::DataFusionError::Internal)
325            .context(error::DataFusionSnafu)
326            .map_err(BoxedError::new)?;
327
328        // A target-blind checker (e.g. the default mode-based one) answers the
329        // same for every table: ask once up front instead of per table.
330        let per_table_auth = match self.permission_checker.as_ref() {
331            Some(checker) if query_ctx.is_some() => {
332                if checker.uses_table_targets() {
333                    true
334                } else if self
335                    .authorize_sources(query_ctx, PermissionTableTargets::resolved(vec![]))?
336                {
337                    false
338                } else {
339                    debug!(
340                        "Caller lacks the entity-graph read permission; deriving an empty graph \
341                         (catalog: {catalog})"
342                    );
343                    return Ok((vec![], vec![]));
344                }
345            }
346            _ => false,
347        };
348
349        let mut declarations = vec![];
350        let mut traces = vec![];
351        let schemas = catalog_manager
352            .schema_names(catalog, query_ctx)
353            .await
354            .map_err(BoxedError::new)?;
355        for schema in schemas {
356            // User telemetry never lives in the system schemas; skip them to avoid
357            // scanning information_schema (including the computed graph tables) etc.
358            if schema == INFORMATION_SCHEMA_NAME
359                || schema == PG_CATALOG_NAME
360                || schema == DEFAULT_PRIVATE_SCHEMA_NAME
361            {
362                continue;
363            }
364            let mut tables = catalog_manager.tables(catalog, &schema, query_ctx);
365            while let Some(table) = tables.try_next().await.map_err(BoxedError::new)? {
366                let table_info = table.table_info();
367                let table_declarations = Self::declarations_for(&table_info, conventions);
368                let is_trace = is_trace_v1_table(&table_info);
369                // Authorize only tables that would contribute rows.
370                if per_table_auth
371                    && (is_trace || !table_declarations.is_empty())
372                    && !self.authorize_sources(
373                        query_ctx,
374                        PermissionTableTargets::resolved(vec![PermissionTableTarget::new(
375                            catalog,
376                            &schema,
377                            &table_info.name,
378                        )]),
379                    )?
380                {
381                    debug!(
382                        "Excluding `{schema}.{}` from the entity-graph derivation: caller lacks \
383                         read permission",
384                        table_info.name
385                    );
386                    continue;
387                }
388                if is_trace {
389                    let find = |entity_type: &str| {
390                        table_declarations
391                            .iter()
392                            .find(|d| d.entity_type == entity_type)
393                            .cloned()
394                    };
395                    let service = find(ENTITY_TYPE_SERVICE);
396                    let agent = find(ENTITY_TYPE_GEN_AI_AGENT);
397                    if service.is_none() {
398                        // No usable service identity: the table cannot
399                        // contribute service-calls edges (see declarations_for).
400                        warn!(
401                            "Trace table `{}` has no usable service declaration; skipping calls derivation",
402                            table_info.name
403                        );
404                    }
405                    if service.is_some() || agent.is_some() {
406                        traces.push(TraceSource {
407                            service,
408                            agent,
409                            table: table.clone(),
410                        });
411                    }
412                }
413                if !table_declarations.is_empty() {
414                    declarations.push(EntitySource {
415                        declarations: table_declarations,
416                        is_trace,
417                        table,
418                    });
419                }
420            }
421        }
422        Ok((declarations, traces))
423    }
424
425    /// The time window to derive over, taken from the scan's `observed_at`
426    /// predicate.
427    ///
428    /// The contract (RFC "The contract"): no `observed_at` predicate → the
429    /// product default (last hour); a missing upper bound means "up to now"; a
430    /// predicate without a lower bound or in a shape that cannot be safely
431    /// extracted is an error asking for an explicit range — never a silent
432    /// fallback into incomplete results.
433    /// `Ok(None)` = the window cannot match anything (e.g. a lower bound in
434    /// the future with the implicit "up to now" upper bound): the scan streams
435    /// empty instead of deriving.
436    fn query_window(request: &ScanRequest) -> Result<Option<GraphQueryWindow>, BoxedError> {
437        let invalid =
438            |err_msg: String| Err(BoxedError::new(error::InvalidSqlSnafu { err_msg }.build()));
439        match extract_time_range_strict(OBSERVED_AT_COLUMN, TimeUnit::Millisecond, &request.filters)
440        {
441            TimeRangeExtraction::Absent => Ok(Some(GraphQueryWindow::default_last_hour())),
442            TimeRangeExtraction::Extracted(range) => {
443                let Some(start) = range.start() else {
444                    return invalid(format!(
445                        "the {OBSERVED_AT_COLUMN} filter has no lower bound; the graph cannot \
446                         derive over unbounded history — add e.g. {OBSERVED_AT_COLUMN} >= \
447                         '2026-01-01 00:00:00'"
448                    ));
449                };
450                let end_ms = range
451                    .end()
452                    .map(|ts| ts.value())
453                    .unwrap_or_else(common_time::util::current_time_millis);
454                if start.value() >= end_ms {
455                    return Ok(None);
456                }
457                Ok(Some(GraphQueryWindow::from_observed(start.value(), end_ms)))
458            }
459            TimeRangeExtraction::Unsupported => invalid(format!(
460                "cannot derive the graph window from the {OBSERVED_AT_COLUMN} filter; use plain \
461                 range predicates (>=, <, BETWEEN) with literal bounds"
462            )),
463        }
464    }
465
466    fn read_table(&self, table: TableRef) -> Result<DataFrame, BoxedError> {
467        self.query_engine.read_table(table).map_err(BoxedError::new)
468    }
469
470    /// The declared-edge branch source, when the physical table exists, the
471    /// caller may read it, and it still matches the canonical definition. A
472    /// mismatch (upgrade skew) is an explicit error, not a silent skip:
473    /// dropping declared edges would misrepresent the graph.
474    async fn declared_source(
475        &self,
476        catalog: &str,
477        query_ctx: Option<&QueryContext>,
478    ) -> Result<Option<DeclaredSource>, BoxedError> {
479        let Some(catalog_manager) = self.catalog_manager.upgrade() else {
480            return Ok(None);
481        };
482        let Some(table) = catalog_manager
483            .table(
484                catalog,
485                DEFAULT_PRIVATE_SCHEMA_NAME,
486                SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
487                query_ctx,
488            )
489            .await
490            .map_err(BoxedError::new)?
491        else {
492            return Ok(None);
493        };
494        if !self.authorize_sources(
495            query_ctx,
496            PermissionTableTargets::resolved(vec![PermissionTableTarget::new(
497                catalog,
498                DEFAULT_PRIVATE_SCHEMA_NAME,
499                SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
500            )]),
501        )? {
502            debug!(
503                "Excluding declared edges: caller lacks read permission on \
504                 `{DEFAULT_PRIVATE_SCHEMA_NAME}.{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME}`"
505            );
506            return Ok(None);
507        }
508        let table_info = table.table_info();
509        if !declared_relationships_schema_matches(&table_info) {
510            return Err(BoxedError::new(
511                error::InvalidSqlSnafu {
512                    err_msg: format!(
513                        "{DEFAULT_PRIVATE_SCHEMA_NAME}.{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME} \
514                         does not match its canonical schema; declared edges cannot be derived"
515                    ),
516                }
517                .build(),
518            ));
519        }
520        Ok(Some(DeclaredSource {
521            scan: self.read_table(table)?,
522        }))
523    }
524
525    /// Executes a derivation plan under the caller's context (inheriting its
526    /// permissions, cancellation and deadline); context-less internal scans
527    /// get a minimal default.
528    async fn execute_plan(
529        &self,
530        catalog: &str,
531        plan: LogicalPlan,
532        query_ctx: Option<QueryContextRef>,
533    ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
534        let query_ctx = query_ctx.unwrap_or_else(|| {
535            QueryContextBuilder::default()
536                .current_catalog(catalog.to_string())
537                .current_schema(DEFAULT_SCHEMA_NAME.to_string())
538                .build()
539                .into()
540        });
541        let output = self
542            .query_engine
543            .execute(plan, query_ctx)
544            .await
545            .map_err(BoxedError::new)?;
546        let stream = match output.data {
547            OutputData::Stream(stream) => stream,
548            OutputData::RecordBatches(batches) => batches.as_stream(),
549            OutputData::AffectedRows(_) => return Ok(None),
550        };
551        Ok(Some(stream))
552    }
553}
554
555#[async_trait]
556impl EntityGraphProvider for EntityGraphProviderImpl {
557    async fn scan_entities(
558        &self,
559        catalog: &str,
560        request: ScanRequest,
561        query_ctx: Option<QueryContextRef>,
562    ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
563        let (sources, _) = self.enumerate(catalog, query_ctx.as_deref()).await?;
564        let mut plans = Vec::with_capacity(sources.len());
565        for source in sources {
566            plans.push(RegistrySource {
567                declarations: source.declarations,
568                scan: self.read_table(source.table)?,
569            });
570        }
571        let Some(window) = Self::query_window(&request)? else {
572            return Ok(None);
573        };
574        let Some(plan) = build_registry_plan(plans, &window)
575            .context(error::DataFusionSnafu)
576            .map_err(BoxedError::new)?
577        else {
578            return Ok(None);
579        };
580        self.execute_plan(catalog, plan, query_ctx).await
581    }
582
583    async fn scan_relationships(
584        &self,
585        catalog: &str,
586        request: ScanRequest,
587        query_ctx: Option<QueryContextRef>,
588    ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
589        let (sources, traces) = self.enumerate(catalog, query_ctx.as_deref()).await?;
590        let mut calls = Vec::with_capacity(traces.len());
591        for trace in traces {
592            calls.push(CallsSource {
593                service: trace.service,
594                agent: trace.agent,
595                scan: self.read_table(trace.table)?,
596            });
597        }
598        let mut co_declared = Vec::with_capacity(sources.len());
599        for source in sources {
600            co_declared.push(CoDeclaredSource {
601                declarations: source.declarations,
602                is_trace: source.is_trace,
603                scan: self.read_table(source.table)?,
604            });
605        }
606        let declared = self.declared_source(catalog, query_ctx.as_deref()).await?;
607        let Some(window) = Self::query_window(&request)? else {
608            return Ok(None);
609        };
610        let Some(plan) = build_relationships_plan(
611            RelationshipSources {
612                traces: calls,
613                co_declared,
614                declared,
615            },
616            &window,
617        )
618        .context(error::DataFusionSnafu)
619        .map_err(BoxedError::new)?
620        else {
621            return Ok(None);
622        };
623        self.execute_plan(catalog, plan, query_ctx).await
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use std::collections::HashMap;
630    use std::sync::Arc;
631
632    use common_catalog::consts::{DEFAULT_CATALOG_NAME, MITO_ENGINE};
633    use datatypes::prelude::ConcreteDataType;
634    use datatypes::schema::{ColumnSchema, SchemaBuilder};
635    use store_api::metric_engine_consts::PHYSICAL_TABLE_METADATA_KEY;
636    use table::metadata::{TableInfoBuilder, TableMeta, TableType};
637    use table::requests::{TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1, TableOptions};
638
639    use super::*;
640
641    fn table_info(columns: &[&str], extra: &[(&str, &str)]) -> TableInfo {
642        let mut column_schemas = vec![
643            ColumnSchema::new(
644                "ts",
645                ConcreteDataType::timestamp_millisecond_datatype(),
646                false,
647            )
648            .with_time_index(true),
649        ];
650        column_schemas.extend(
651            columns
652                .iter()
653                .map(|c| ColumnSchema::new(*c, ConcreteDataType::string_datatype(), true)),
654        );
655        assemble_table_info(column_schemas, extra)
656    }
657
658    fn assemble_table_info(column_schemas: Vec<ColumnSchema>, extra: &[(&str, &str)]) -> TableInfo {
659        named_table_info("t1", column_schemas, vec![], extra)
660    }
661
662    fn named_table_info(
663        name: &str,
664        column_schemas: Vec<ColumnSchema>,
665        primary_key_indices: Vec<usize>,
666        extra: &[(&str, &str)],
667    ) -> TableInfo {
668        let schema = Arc::new(
669            SchemaBuilder::try_from_columns(column_schemas)
670                .unwrap()
671                .build()
672                .unwrap(),
673        );
674        let options = TableOptions {
675            extra_options: extra
676                .iter()
677                .map(|(k, v)| (k.to_string(), v.to_string()))
678                .collect::<HashMap<_, _>>(),
679            ..Default::default()
680        };
681        let meta = TableMeta {
682            schema,
683            primary_key_indices,
684            value_indices: vec![],
685            engine: MITO_ENGINE.to_string(),
686            next_column_id: 1,
687            options,
688            created_on: Default::default(),
689            updated_on: Default::default(),
690            partition_key_indices: vec![],
691            column_ids: vec![],
692        };
693        TableInfoBuilder::default()
694            .table_id(1)
695            .name(name)
696            .catalog_name(DEFAULT_CATALOG_NAME)
697            .schema_name(DEFAULT_SCHEMA_NAME)
698            .table_version(0)
699            .table_type(TableType::Base)
700            .meta(meta)
701            .build()
702            .unwrap()
703    }
704
705    /// A metric-engine-logical-table shape: every label column is a tag.
706    fn prom_table_info(name: &str, tags: &[&str], extra: &[(&str, &str)]) -> TableInfo {
707        let mut column_schemas = vec![
708            ColumnSchema::new(
709                "greptime_timestamp",
710                ConcreteDataType::timestamp_millisecond_datatype(),
711                false,
712            )
713            .with_time_index(true),
714        ];
715        column_schemas.extend(
716            tags.iter()
717                .map(|c| ColumnSchema::new(*c, ConcreteDataType::string_datatype(), true)),
718        );
719        let primary_key_indices = (1..=tags.len()).collect();
720        named_table_info(name, column_schemas, primary_key_indices, extra)
721    }
722
723    #[test]
724    fn future_lower_only_window_matches_nothing() {
725        let future_ms = common_time::util::current_time_millis() + 86_400_000;
726        let request = ScanRequest {
727            filters: vec![
728                datafusion_expr::col(OBSERVED_AT_COLUMN).gt_eq(datafusion_expr::lit(
729                    datafusion::common::ScalarValue::TimestampMillisecond(Some(future_ms), None),
730                )),
731            ],
732            ..Default::default()
733        };
734        assert!(
735            EntityGraphProviderImpl::query_window(&request)
736                .unwrap()
737                .is_none()
738        );
739
740        assert!(
741            EntityGraphProviderImpl::query_window(&ScanRequest::default())
742                .unwrap()
743                .is_some()
744        );
745    }
746
747    #[test]
748    fn declaration_referencing_missing_column_is_skipped() {
749        let info = table_info(
750            &["service_name"],
751            &[
752                ("greptime.semantic.entity.service.id", "service_name"),
753                ("greptime.semantic.entity.host.id", "gone"),
754            ],
755        );
756        let declarations = EntityGraphProviderImpl::declarations_for(&info, conventions().unwrap());
757        assert_eq!(declarations.len(), 1);
758        assert_eq!(declarations[0].entity_type, "service");
759
760        let info = table_info(
761            &["service_name"],
762            &[
763                ("greptime.semantic.entity.service.id", "service_name"),
764                ("greptime.semantic.entity.service.descriptive", "gone"),
765            ],
766        );
767        assert!(
768            EntityGraphProviderImpl::declarations_for(&info, conventions().unwrap()).is_empty()
769        );
770    }
771
772    const PROM_STAMPS: &[(&str, &str)] = &[
773        (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
774        (SEMANTIC_SOURCE, SOURCE_PROMETHEUS),
775    ];
776
777    fn sorted_declarations(info: &TableInfo) -> Vec<EntityDeclaration> {
778        let mut declarations =
779            EntityGraphProviderImpl::declarations_for(info, conventions().unwrap());
780        declarations.sort_by(|a, b| a.entity_type.cmp(&b.entity_type));
781        declarations
782    }
783
784    #[test]
785    fn prometheus_info_metric_gets_implicit_declarations() {
786        let info = prom_table_info(
787            "kube_pod_info",
788            &["namespace", "pod", "uid", "node", "job", "instance"],
789            PROM_STAMPS,
790        );
791        let declarations = sorted_declarations(&info);
792        assert_eq!(declarations.len(), 2);
793        assert_eq!(declarations[0].entity_type, "k8s.node");
794        assert_eq!(declarations[0].id_columns, vec!["node"]);
795        assert_eq!(declarations[1].entity_type, "k8s.pod");
796        assert_eq!(declarations[1].id_columns, vec!["uid"]);
797        // host_ip/pod_ip/created_by_* are absent from this table; descriptive
798        // shrinks to the present columns.
799        assert_eq!(
800            declarations[1].descriptive_columns,
801            vec!["namespace", "pod", "node"]
802        );
803        assert_eq!(declarations[1].time_index, "greptime_timestamp");
804    }
805
806    #[test]
807    fn trace_table_gets_implicit_resource_entities() {
808        let full = table_info(
809            &[
810                "service_name",
811                "resource_attributes.service.instance.id",
812                "resource_attributes.k8s.pod.uid",
813                "resource_attributes.k8s.pod.name",
814            ],
815            &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
816        );
817        let declarations = sorted_declarations(&full);
818        let types: Vec<&str> = declarations
819            .iter()
820            .map(|d| d.entity_type.as_str())
821            .collect();
822        assert_eq!(types, vec!["k8s.pod", "service", "service.instance"]);
823        assert_eq!(
824            declarations[0].id_columns,
825            vec!["resource_attributes.k8s.pod.uid"]
826        );
827        assert_eq!(
828            declarations[0].descriptive_columns,
829            vec!["resource_attributes.k8s.pod.name"]
830        );
831        assert_eq!(
832            declarations[2].id_columns,
833            vec!["service_name", "resource_attributes.service.instance.id"]
834        );
835
836        // Missing uid column: no pod entity synthesized, no name-based guess.
837        let no_uid = table_info(
838            &["service_name", "resource_attributes.k8s.pod.name"],
839            &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
840        );
841        let types: Vec<String> = sorted_declarations(&no_uid)
842            .into_iter()
843            .map(|d| d.entity_type)
844            .collect();
845        assert_eq!(types, vec!["service"]);
846
847        // An explicit declaration suppresses the implicit one even when it is
848        // invalid and skipped: identity must not change behind the user's back.
849        let invalid_explicit = table_info(
850            &["service_name"],
851            &[
852                (TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1),
853                ("greptime.semantic.entity.service.id", "gone"),
854            ],
855        );
856        assert!(sorted_declarations(&invalid_explicit).is_empty());
857    }
858
859    #[test]
860    fn prometheus_implicit_declarations_are_gated() {
861        let labels: &[&str] = &["namespace", "pod", "node"];
862        // A non-Prometheus source.
863        assert!(
864            sorted_declarations(&prom_table_info(
865                "kube_pod_info",
866                labels,
867                &[
868                    (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
869                    (SEMANTIC_SOURCE, "opentelemetry"),
870                ],
871            ))
872            .is_empty()
873        );
874        // Not a whitelisted metric name.
875        assert!(
876            sorted_declarations(&prom_table_info("http_requests_total", labels, PROM_STAMPS))
877                .is_empty()
878        );
879        let mut stamps = PROM_STAMPS.to_vec();
880        stamps.push((PHYSICAL_TABLE_METADATA_KEY, "true"));
881        assert!(sorted_declarations(&prom_table_info("kube_pod_info", labels, &stamps)).is_empty());
882
883        // A missing id column (uid) drops that entity, not the whole table.
884        let info = prom_table_info("kube_pod_info", &["namespace", "pod", "node"], PROM_STAMPS);
885        let declarations = sorted_declarations(&info);
886        assert_eq!(declarations.len(), 1);
887        assert_eq!(declarations[0].entity_type, "k8s.node");
888    }
889
890    #[test]
891    fn explicit_declaration_suppresses_the_implicit_one() {
892        let mut stamps = PROM_STAMPS.to_vec();
893        stamps.push(("greptime.semantic.entity.k8s.pod.id", "pod"));
894        let info = prom_table_info("kube_pod_info", &["namespace", "pod", "node"], &stamps);
895        let declarations = sorted_declarations(&info);
896        assert_eq!(declarations.len(), 2);
897        assert_eq!(declarations[0].entity_type, "k8s.node");
898        assert_eq!(declarations[1].entity_type, "k8s.pod");
899        // The explicit identity wins over the conventional [namespace, pod].
900        assert_eq!(declarations[1].id_columns, vec!["pod"]);
901    }
902
903    #[test]
904    fn target_info_descriptive_rest_covers_remaining_tags() {
905        let info = prom_table_info(
906            "target_info",
907            &["job", "instance", "k8s_cluster_name", "service_version"],
908            PROM_STAMPS,
909        );
910        let declarations = sorted_declarations(&info);
911        assert_eq!(declarations.len(), 2);
912        assert_eq!(declarations[0].entity_type, "service");
913        assert_eq!(declarations[0].id_columns, vec!["job"]);
914        assert!(declarations[0].descriptive_columns.is_empty());
915        // The remaining labels are the target's resource attributes: they
916        // describe the instance, not the logical service.
917        assert_eq!(declarations[1].entity_type, "service.instance");
918        assert_eq!(declarations[1].id_columns, vec!["job", "instance"]);
919        assert_eq!(
920            declarations[1].descriptive_columns,
921            vec!["k8s_cluster_name", "service_version"]
922        );
923    }
924}