Skip to main content

operator/statement/
semantic_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//! The entity-relationship graph: the physical declared-edge table DDL and the
16//! typed DataFusion plan builders for the read-time derivation behind the
17//! computed `semantic_entities` / `semantic_relationships` tables.
18//!
19//! In OSS the graph is derived at read time, so the *only* stored part is the
20//! declared-edge table: edges a user asserts by hand (`provenance = 'declared'`).
21//!
22//! The derivation is built as typed [`Expr`]s over [`DataFrame`]s (never as SQL
23//! text), so user-controlled identifiers are plain values — no quoting or SQL
24//! injection surface — and the plans compose with DataFusion's optimizer,
25//! including filter pushdown into the source table scans. See
26//! `docs/rfcs/2026-06-25-entity-relationships-and-graph-query.md`.
27
28mod conventions;
29mod relationships;
30
31use std::sync::{Arc, LazyLock};
32
33use api::v1::column_data_type_extension::TypeExt;
34use api::v1::{
35    ColumnDataType, ColumnDataTypeExtension, ColumnDef, CreateTableExpr, JsonTypeExtension,
36    SemanticType,
37};
38use common_catalog::consts::{
39    CONFIDENCE_COLUMN, DEFAULT_CATALOG_NAME, DEFAULT_PRIVATE_SCHEMA_NAME, DST_ID_COLUMN,
40    DST_TYPE_COLUMN, DURATION_COUNT_COLUMN, DURATION_SUM_COLUMN, EDGE_ATTRIBUTES_COLUMN,
41    ENTITY_DESCRIPTIVE_COLUMN, ENTITY_ID_ATTRS_COLUMN, ENTITY_ID_COLUMN, ENTITY_SCOPE_COLUMN,
42    ENTITY_TYPE_COLUMN, ERROR_COUNT_COLUMN, FRESH_UNTIL_COLUMN, GENERATION_ID_COLUMN,
43    OBSERVED_AT_COLUMN, PROVENANCE_COLUMN, REL_TYPE_COLUMN, REQUEST_COUNT_COLUMN,
44    SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME, SOURCE_TABLES_COLUMN, SRC_ID_COLUMN,
45    SRC_TYPE_COLUMN, VALID_FROM_COLUMN, VALID_UNTIL_COLUMN, WINDOW_END_COLUMN, WINDOW_START_COLUMN,
46};
47use common_function::function::FunctionContext;
48use common_function::function_registry::FUNCTION_REGISTRY;
49pub use conventions::{
50    Conventions, ENTITY_TYPE_GEN_AI_AGENT, ENTITY_TYPE_GEN_AI_MODEL, ENTITY_TYPE_GEN_AI_TOOL,
51    ENTITY_TYPE_HOST, ENTITY_TYPE_K8S_CONTAINER, ENTITY_TYPE_K8S_NODE, ENTITY_TYPE_K8S_POD,
52    ENTITY_TYPE_K8S_WORKLOAD, ENTITY_TYPE_PROCESS, ENTITY_TYPE_SERVICE,
53    ENTITY_TYPE_SERVICE_INSTANCE, ImplicitEntity, PROVENANCE_AGENT, PROVENANCE_ATTRIBUTE,
54    PROVENANCE_DECLARED, PROVENANCE_TRACE, REL_TYPE_CALLS, REL_TYPE_CONTAINS, REL_TYPE_DEPENDS_ON,
55    REL_TYPE_INVOKES, REL_TYPE_OWNS, REL_TYPE_PART_OF, REL_TYPE_RUNS_ON, REL_TYPE_USES,
56    conventions,
57};
58use datafusion::arrow::datatypes::{DataType, TimeUnit};
59use datafusion::dataframe::DataFrame;
60use datafusion::functions::{core as core_fns, datetime as datetime_fns, string as string_fns};
61use datafusion::functions_nested::expr_fn::make_array;
62use datafusion_common::{Column, Result as DfResult, ScalarValue};
63use datafusion_expr::{Expr, LogicalPlan, ScalarUDF, cast, ident, lit};
64pub use relationships::{
65    CallsSource, CoDeclaredSource, DeclaredSource, RelationshipSources, build_relationships_plan,
66};
67use store_api::mito_engine_options::{APPEND_MODE_KEY, MERGE_MODE_KEY, TTL_KEY};
68
69/// Whether an existing declared-edge table still matches the canonical
70/// definition ([`build_declared_relationships_expr`]): columns, time index,
71/// primary key, engine, and merge behaviour — the union branch's revision and
72/// dedup semantics lean on all of these. A mismatch (upgrade skew) must be
73/// surfaced, not silently derived wrong; DROP resets the table.
74pub fn declared_relationships_schema_matches(table_info: &table::metadata::TableInfo) -> bool {
75    let meta = &table_info.meta;
76    if meta.engine != common_catalog::consts::MITO_ENGINE {
77        return false;
78    }
79    let schema = &meta.schema;
80    if schema
81        .timestamp_column()
82        .is_none_or(|column| column.name != OBSERVED_AT_COLUMN)
83    {
84        return false;
85    }
86    let primary_keys = meta
87        .primary_key_indices
88        .iter()
89        .filter_map(|idx| schema.column_schemas().get(*idx))
90        .map(|column| column.name.as_str());
91    if !primary_keys.eq(DECLARED_PRIMARY_KEY_COLUMNS) {
92        return false;
93    }
94    // LastRow merge is what makes a re-asserted edge a *revision*; append mode
95    // or another merge mode would change the read-side dedup semantics.
96    let options = &meta.options.extra_options;
97    if options.get(APPEND_MODE_KEY).map(String::as_str) == Some("true")
98        || options
99            .get(MERGE_MODE_KEY)
100            .is_some_and(|mode| mode != "last_row")
101    {
102        return false;
103    }
104
105    let canonical = build_declared_relationships_expr(DEFAULT_CATALOG_NAME);
106    if schema.column_schemas().len() != canonical.column_defs.len() {
107        return false;
108    }
109    canonical
110        .column_defs
111        .iter()
112        .zip(schema.column_schemas())
113        .all(|(def, column)| {
114            let Ok(wrapper) = api::helper::ColumnDataTypeWrapper::try_new(
115                def.data_type,
116                def.datatype_extension.clone(),
117            ) else {
118                return false;
119            };
120            def.name == column.name
121                && datatypes::prelude::ConcreteDataType::from(wrapper) == column.data_type
122        })
123}
124
125/// Bin width for the temporal window of derived rows: 60s buckets, matching the
126/// service-graph convention.
127const BIN_NANOS: i64 = 60 * 1_000_000_000;
128
129/// Default retention for the declared-edge table; expiry slides the topology window.
130const DEFAULT_DECLARED_RELATIONSHIPS_TTL: &str = "90d";
131/// Environment variable overriding the declared-edge table's TTL at creation
132/// time (e.g. `180d`, `forever`).
133// TODO(entity-graph): promote this to a real configuration option.
134const DECLARED_RELATIONSHIPS_TTL_ENV: &str = "GREPTIMEDB_DECLARED_RELATIONSHIPS_TTL";
135
136fn declared_relationships_ttl() -> String {
137    std::env::var(DECLARED_RELATIONSHIPS_TTL_ENV)
138        .ok()
139        .map(|ttl| ttl.trim().to_string())
140        .filter(|ttl| !ttl.is_empty())
141        .unwrap_or_else(|| DEFAULT_DECLARED_RELATIONSHIPS_TTL.to_string())
142}
143
144/// The primary-key (tag) columns, in key order. Starting with the source endpoint
145/// makes out-edge lookup (`WHERE src_type=? AND src_id=?`) a key-prefix scan;
146/// `provenance` and `generation_id` are in the key so a declared edge and a
147/// (future) derived edge for the same pair coexist without clobbering.
148pub const DECLARED_PRIMARY_KEY_COLUMNS: [&str; 8] = [
149    SRC_TYPE_COLUMN,
150    SRC_ID_COLUMN,
151    REL_TYPE_COLUMN,
152    DST_TYPE_COLUMN,
153    DST_ID_COLUMN,
154    PROVENANCE_COLUMN,
155    ENTITY_SCOPE_COLUMN,
156    GENERATION_ID_COLUMN,
157];
158
159/// The externally visible edge identity: the primary key minus `scope` and
160/// `generation_id`, which the computed table does not expose. Revision ranking
161/// uses this identity, or assertions differing only in those two columns would
162/// surface as indistinguishable duplicate rows.
163const DECLARED_EDGE_IDENTITY_COLUMNS: [&str; 6] = [
164    SRC_TYPE_COLUMN,
165    SRC_ID_COLUMN,
166    REL_TYPE_COLUMN,
167    DST_TYPE_COLUMN,
168    DST_ID_COLUMN,
169    PROVENANCE_COLUMN,
170];
171
172fn column(
173    name: &str,
174    data_type: ColumnDataType,
175    semantic_type: SemanticType,
176    nullable: bool,
177) -> ColumnDef {
178    ColumnDef {
179        name: name.to_string(),
180        data_type: data_type as i32,
181        is_nullable: nullable,
182        default_constraint: vec![],
183        semantic_type: semantic_type as i32,
184        comment: String::new(),
185        datatype_extension: None,
186        options: None,
187    }
188}
189
190fn tag(name: &str) -> ColumnDef {
191    column(name, ColumnDataType::String, SemanticType::Tag, false)
192}
193
194fn field(name: &str, data_type: ColumnDataType) -> ColumnDef {
195    column(name, data_type, SemanticType::Field, true)
196}
197
198fn json_field(name: &str) -> ColumnDef {
199    let mut def = field(name, ColumnDataType::Binary);
200    def.datatype_extension = Some(ColumnDataTypeExtension {
201        type_ext: Some(TypeExt::JsonType(JsonTypeExtension::JsonBinary.into())),
202    });
203    def
204}
205
206/// Builds the `CREATE TABLE` request for the declared-edge table. Columns mirror
207/// the computed `semantic_relationships` shape (temporal window + endpoints +
208/// provenance/confidence + RED metrics) plus the declared-only business validity
209/// window (`valid_from` / `valid_until`), which — unlike TTL (physical retention)
210/// — expresses whether a hand-declared edge is still in effect.
211///
212/// The default `LastRow` merge dedups on primary key **plus** `observed_at`
213/// (the time index is part of mito's dedup key): re-asserting an edge at a new
214/// `observed_at` stores a new revision, and the read-side union keeps only the
215/// latest revision per edge key.
216pub fn build_declared_relationships_expr(catalog: &str) -> CreateTableExpr {
217    let column_defs = vec![
218        // Temporal: when this revision of the edge was declared (time index,
219        // also the TTL clock).
220        column(
221            OBSERVED_AT_COLUMN,
222            ColumnDataType::TimestampMillisecond,
223            SemanticType::Timestamp,
224            false,
225        ),
226        field(WINDOW_START_COLUMN, ColumnDataType::TimestampMillisecond),
227        field(WINDOW_END_COLUMN, ColumnDataType::TimestampMillisecond),
228        field(FRESH_UNTIL_COLUMN, ColumnDataType::TimestampMillisecond),
229        // Declared-only business validity. NULL valid_from = valid since the
230        // declaration; NULL valid_until = valid for as long as the row exists
231        // (TTL expiry retires the edge with the row).
232        field(VALID_FROM_COLUMN, ColumnDataType::TimestampMillisecond),
233        field(VALID_UNTIL_COLUMN, ColumnDataType::TimestampMillisecond),
234        // Endpoints + edge identity (all tags, in primary-key order).
235        tag(SRC_TYPE_COLUMN),
236        tag(SRC_ID_COLUMN),
237        tag(REL_TYPE_COLUMN),
238        tag(DST_TYPE_COLUMN),
239        tag(DST_ID_COLUMN),
240        tag(PROVENANCE_COLUMN),
241        tag(ENTITY_SCOPE_COLUMN),
242        tag(GENERATION_ID_COLUMN),
243        // Confidence + RED metrics (populated for derived edges; usually NULL here).
244        field(CONFIDENCE_COLUMN, ColumnDataType::Float64),
245        field(REQUEST_COUNT_COLUMN, ColumnDataType::Int64),
246        field(ERROR_COUNT_COLUMN, ColumnDataType::Int64),
247        field(DURATION_SUM_COLUMN, ColumnDataType::Float64),
248        field(DURATION_COUNT_COLUMN, ColumnDataType::Int64),
249        // JSONB, so the union matches the computed table's json column
250        // without a per-scan parse.
251        json_field(EDGE_ATTRIBUTES_COLUMN),
252    ];
253
254    let table_options = [(TTL_KEY.to_string(), declared_relationships_ttl())]
255        .into_iter()
256        .collect();
257
258    CreateTableExpr {
259        catalog_name: catalog.to_string(),
260        schema_name: DEFAULT_PRIVATE_SCHEMA_NAME.to_string(),
261        table_name: SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME.to_string(),
262        desc: "Hand-declared edges of the entity-relationship graph".to_string(),
263        column_defs,
264        time_index: OBSERVED_AT_COLUMN.to_string(),
265        primary_keys: DECLARED_PRIMARY_KEY_COLUMNS
266            .iter()
267            .map(|c| c.to_string())
268            .collect(),
269        create_if_not_exists: true,
270        table_options,
271        table_id: None,
272        engine: common_catalog::consts::MITO_ENGINE.to_string(),
273    }
274}
275
276/// A single table's entity-identity declaration, projected from
277/// `information_schema.table_semantics` (`greptime.semantic.entity.<type>.*`).
278#[derive(Debug, Clone)]
279pub struct EntityDeclaration {
280    /// The declaring table's schema, for the qualified `source_tables` lineage.
281    pub schema: String,
282    /// The declaring table's name, recorded in `source_tables`.
283    pub table: String,
284    /// The table's time index column, used for the temporal window filter.
285    pub time_index: String,
286    pub entity_type: String,
287    /// Identifying columns (>= 1). One column → id verbatim; several → composite.
288    pub id_columns: Vec<String>,
289    /// Descriptive columns snapshotted into the `descriptive` JSON (may be empty).
290    pub descriptive_columns: Vec<String>,
291    /// Scope columns (namespace/environment). One column → scope verbatim;
292    /// several → sorted `k=v,k=v`, mirroring the composite id rendering.
293    pub scope_columns: Vec<String>,
294}
295
296/// The read-time query window, resolved from the scan's `observed_at` predicate
297/// (or the product default). Two half-open `[start, end)` millisecond ranges:
298///
299/// - *observed*: the queried `observed_at` range. The scan's filters are
300///   re-applied above the computed table (`FilterPushDownType::Inexact`), so
301///   every emitted row's `observed_at` must fall inside it. The declared-edge
302///   branch also runs its validity overlap against this range.
303/// - *source scan*: `observed` widened to whole 60s buckets. Derived rows are
304///   `date_bin('60s')` aggregates keyed by the bucket start, so a bucket the
305///   observed range selects must be scanned over its full extent — filtering
306///   the source rows to the observed range instead would silently truncate the
307///   RED numbers of the boundary buckets.
308///
309/// The bounds are plain millisecond values turned into timestamp literals on
310/// demand — wall-clock snapshot semantics like `now()`, but as constants they
311/// prune the source table scans without depending on constant-folding.
312#[derive(Debug, Clone)]
313pub struct GraphQueryWindow {
314    observed_start_ms: i64,
315    observed_end_ms: i64,
316    source_start_ms: i64,
317    source_end_ms: i64,
318}
319
320impl GraphQueryWindow {
321    /// Builds the window for the queried `observed_at` range `[start_ms, end_ms)`.
322    pub fn from_observed(start_ms: i64, end_ms: i64) -> Self {
323        const BIN_MS: i64 = BIN_NANOS / 1_000_000;
324        // Ceiling division that stays correct for pre-epoch (negative) values.
325        let ceil_to_bin = |ms: i64| {
326            ms.div_euclid(BIN_MS) * BIN_MS
327                + if ms.rem_euclid(BIN_MS) == 0 {
328                    0
329                } else {
330                    BIN_MS
331                }
332        };
333        // A bucket `b` (a 60s multiple, `date_bin`'s floor of its rows) is
334        // selected when `start <= b < end`: the first is ceil(start) and the
335        // last one's rows extend to ceil(end).
336        Self {
337            observed_start_ms: start_ms,
338            observed_end_ms: end_ms,
339            source_start_ms: ceil_to_bin(start_ms),
340            source_end_ms: ceil_to_bin(end_ms),
341        }
342    }
343
344    /// Conservative default when a query carries no explicit time predicate: the
345    /// last hour, so a bare `SELECT * FROM semantic_entities` never scans every
346    /// declaring table's full history. This is a product default, not a cap.
347    pub fn default_last_hour() -> Self {
348        let end_ms = common_time::util::current_time_millis();
349        Self::from_observed(end_ms - 60 * 60 * 1000, end_ms)
350    }
351
352    /// Start of the queried `observed_at` range (inclusive).
353    pub fn observed_start(&self) -> Expr {
354        ts_ms_lit(self.observed_start_ms)
355    }
356
357    /// End of the queried `observed_at` range (exclusive).
358    pub fn observed_end(&self) -> Expr {
359        ts_ms_lit(self.observed_end_ms)
360    }
361
362    /// Start of the source-table scan range (inclusive).
363    pub fn source_start(&self) -> Expr {
364        ts_ms_lit(self.source_start_ms)
365    }
366
367    /// End of the source-table scan range (exclusive).
368    pub fn source_end(&self) -> Expr {
369        ts_ms_lit(self.source_end_ms)
370    }
371}
372
373fn ts_ms_lit(ms: i64) -> Expr {
374    lit(ScalarValue::TimestampMillisecond(Some(ms), None))
375}
376
377/// An `INTERVAL` literal of `nanos` nanoseconds.
378fn interval(nanos: i64) -> Expr {
379    lit(ScalarValue::new_interval_mdn(0, 0, nanos))
380}
381
382fn bin_interval() -> Expr {
383    interval(BIN_NANOS)
384}
385
386/// `date_bin(60s, ts)` cast to millisecond precision, so the output schema is
387/// deterministic regardless of the source column's precision (trace tables are
388/// nanosecond, metric tables millisecond).
389fn bin_ms(ts: Expr) -> Expr {
390    cast(
391        datetime_fns::date_bin().call(vec![bin_interval(), ts]),
392        DataType::Timestamp(TimeUnit::Millisecond, None),
393    )
394}
395
396/// Folds union branches without requiring a non-empty input.
397fn union_all(acc: Option<DataFrame>, branch: DataFrame) -> DfResult<Option<DataFrame>> {
398    Ok(Some(match acc {
399        Some(acc) => acc.union(branch)?,
400        None => branch,
401    }))
402}
403
404/// A column reference qualified by a join-side alias, built without string
405/// parsing (so column names containing `.` or `"` stay verbatim).
406fn qcol(relation: &str, name: &str) -> Expr {
407    Expr::Column(Column::new(Some(relation), name))
408}
409
410fn concat_expr(parts: Vec<Expr>) -> Expr {
411    string_fns::concat().call(parts)
412}
413
414/// `coalesce(CAST(column AS STRING), '')`: renders a nullable column for string
415/// concatenation without collapsing the result to NULL.
416fn cast_string_or_empty(column: &str) -> Expr {
417    core_fns::coalesce().call(vec![cast(ident(column), DataType::Utf8), lit("")])
418}
419
420/// The canonical entity-id expression for `id_columns`: the value verbatim for
421/// a single column, the sorted `k=v,k=v` rendering for a composite. `col`
422/// constructs the column reference (unqualified for registry branches,
423/// join-side-qualified for the calls derivation).
424/// A row identifies an entity only when every identity component is present
425/// and non-empty: kube-state-metrics descriptors emit empty-string labels (an
426/// unscheduled pod's `node`, an owner-less pod's `owner_*`), and an empty
427/// string is never a meaningful entity id.
428pub(crate) fn identifies(column: &str) -> Expr {
429    ident(column)
430        .is_not_null()
431        .and(cast(ident(column), DataType::Utf8).not_eq(lit("")))
432}
433
434fn entity_id_expr(id_columns: &[String], col: &dyn Fn(&str) -> Expr) -> Expr {
435    if let [id] = id_columns {
436        cast(col(id), DataType::Utf8)
437    } else {
438        let mut cols = id_columns.to_vec();
439        cols.sort();
440        sorted_kv_expr_with(&cols, false, col)
441    }
442}
443
444/// The `parse_json` UDF, shared by all derivation plans. Resolved from the
445/// global registry once: the UDF is stateless (its `FunctionContext` is unused).
446static PARSE_JSON_UDF: LazyLock<Arc<ScalarUDF>> = LazyLock::new(|| {
447    Arc::new(
448        FUNCTION_REGISTRY
449            .get_function("parse_json")
450            .expect("parse_json must be registered")
451            .provide(FunctionContext::default()),
452    )
453});
454
455/// Parses a JSON text expression into a JSONB value, cast from the UDF's
456/// `BinaryView` output to `Binary` — the storage type the computed tables'
457/// declared `json` columns map to in Arrow.
458fn parse_json_expr(json_text: Expr) -> Expr {
459    cast(PARSE_JSON_UDF.call(vec![json_text]), DataType::Binary)
460}
461
462/// A NULL literal typed as JSONB storage (`Binary`), so branches without a JSON
463/// value union-align with branches that produce one.
464fn null_json() -> Expr {
465    lit(ScalarValue::Binary(None))
466}
467
468/// Renders a compile-time-known string as JSON text (quoted, fully escaped).
469fn json_quote(value: &str) -> String {
470    serde_json::Value::from(value).to_string()
471}
472
473/// The `json_object` UDF, resolved like [`PARSE_JSON_UDF`]. It assembles the
474/// JSONB binary directly from the value columns, so runtime values need no
475/// JSON text escaping.
476static JSON_OBJECT_UDF: LazyLock<Arc<ScalarUDF>> = LazyLock::new(|| {
477    Arc::new(
478        FUNCTION_REGISTRY
479            .get_function("json_object")
480            .expect("json_object must be registered")
481            .provide(FunctionContext::default()),
482    )
483});
484
485/// Builds a JSONB object with one entry per column: key = the column name,
486/// value = the column rendered as a string, NULL coalesced to `""` so one NULL
487/// column does not null the entry (descriptive columns are nullable). Keys come
488/// out sorted — JSONB objects are key-ordered regardless of input order.
489fn json_object_expr(columns: &[String]) -> Expr {
490    let mut args = Vec::with_capacity(columns.len() * 2);
491    for column in columns {
492        args.push(lit(column.as_str()));
493        args.push(cast_string_or_empty(column));
494    }
495    cast(JSON_OBJECT_UDF.call(args), DataType::Binary)
496}
497
498/// Renders pre-sorted columns as a `k=v,k=v` concatenation. `nullable`
499/// coalesces each value to `''` (id columns are tags and non-null; scope
500/// columns carry no such guarantee).
501fn sorted_kv_expr_with(sorted_cols: &[String], nullable: bool, col: &dyn Fn(&str) -> Expr) -> Expr {
502    let mut parts = Vec::with_capacity(sorted_cols.len() * 3);
503    for (i, column) in sorted_cols.iter().enumerate() {
504        if i > 0 {
505            parts.push(lit(","));
506        }
507        parts.push(lit(format!("{column}=")));
508        parts.push(if nullable {
509            core_fns::coalesce().call(vec![cast(col(column), DataType::Utf8), lit("")])
510        } else {
511            cast(col(column), DataType::Utf8)
512        });
513    }
514    concat_expr(parts)
515}
516
517const REGISTRY_COLUMNS: [&str; 10] = [
518    OBSERVED_AT_COLUMN,
519    WINDOW_START_COLUMN,
520    WINDOW_END_COLUMN,
521    FRESH_UNTIL_COLUMN,
522    ENTITY_TYPE_COLUMN,
523    ENTITY_ID_COLUMN,
524    ENTITY_ID_ATTRS_COLUMN,
525    ENTITY_SCOPE_COLUMN,
526    ENTITY_DESCRIPTIVE_COLUMN,
527    SOURCE_TABLES_COLUMN,
528];
529
530const REGISTRY_VALID_COLUMN: &str = "__entity_valid";
531
532/// Expands one source row into one output row per entry of `rows` with a
533/// single source scan: each output field is first built as an array whose
534/// entries correspond to the rows, then unnested. Every row is `[valid,
535/// values...]` aligned with `columns`; rows whose `valid` expression is false
536/// are dropped, and the distinct output columns are `columns`.
537fn unnest_rows(
538    df: DataFrame,
539    window_predicate: Expr,
540    valid_column: &'static str,
541    columns: &[&'static str],
542    rows: Vec<Vec<Expr>>,
543) -> DfResult<DataFrame> {
544    let mut arrays = vec![Vec::with_capacity(rows.len()); columns.len() + 1];
545    for row in rows {
546        debug_assert_eq!(row.len(), arrays.len());
547        for (array, value) in arrays.iter_mut().zip(row) {
548            array.push(value);
549        }
550    }
551    let array_names = std::iter::once(valid_column)
552        .chain(columns.iter().copied())
553        .collect::<Vec<_>>();
554    let array_projection = array_names
555        .iter()
556        .zip(arrays)
557        .map(|(name, values)| make_array(values).alias(*name))
558        .collect::<Vec<_>>();
559
560    df.filter(window_predicate)?
561        .select(array_projection)?
562        .unnest_columns(&array_names)?
563        .filter(ident(valid_column))?
564        .select(columns.iter().map(|c| ident(*c)).collect::<Vec<_>>())?
565        .distinct()
566}
567
568/// Projects all entity declarations of one source table with one source scan
569/// via [`unnest_rows`].
570fn registry_source(
571    first: &EntityDeclaration,
572    rest: &[EntityDeclaration],
573    df: DataFrame,
574    window: &GraphQueryWindow,
575) -> DfResult<DataFrame> {
576    let ts = ident(&first.time_index);
577    let bin = bin_ms(ts.clone());
578    let window_predicate = ts
579        .clone()
580        .gt_eq(window.source_start())
581        .and(ts.lt(window.source_end()));
582
583    let mut rows = Vec::with_capacity(1 + rest.len());
584    for decl in std::iter::once(first).chain(rest) {
585        // CAST even a single-column id: id columns need not be strings, and
586        // the computed table declares entity_id STRING. Composite ids
587        // additionally carry a JSON object of the id columns in
588        // entity_id_attrs.
589        let entity_id = entity_id_expr(&decl.id_columns, &|c| ident(c));
590        let entity_id_attrs = if decl.id_columns.len() == 1 {
591            null_json()
592        } else {
593            json_object_expr(&decl.id_columns)
594        };
595
596        let scope = match decl.scope_columns.as_slice() {
597            [] => lit(""),
598            // Scope columns are not required to be tags, so guard against NULL.
599            [single] => cast_string_or_empty(single),
600            _ => {
601                let mut cols = decl.scope_columns.clone();
602                cols.sort();
603                sorted_kv_expr_with(&cols, true, &|c| ident(c))
604            }
605        };
606
607        let descriptive = if decl.descriptive_columns.is_empty() {
608            null_json()
609        } else {
610            json_object_expr(&decl.descriptive_columns)
611        };
612
613        let source_tables = parse_json_expr(lit(format!(
614            "[{}]",
615            json_quote(&format!("{}.{}", decl.schema, decl.table))
616        )));
617
618        // Keep this predicate per declaration so an absent identity for one
619        // entity does not remove other entities on the row.
620        let valid = decl
621            .id_columns
622            .iter()
623            .fold(lit(true), |predicate, id| predicate.and(identifies(id)));
624
625        rows.push(vec![
626            valid,
627            bin.clone(),
628            bin.clone(),
629            bin.clone() + bin_interval(),
630            bin.clone() + bin_interval(),
631            lit(decl.entity_type.as_str()),
632            entity_id,
633            entity_id_attrs,
634            scope,
635            descriptive,
636            source_tables,
637        ]);
638    }
639
640    unnest_rows(
641        df,
642        window_predicate,
643        REGISTRY_VALID_COLUMN,
644        &REGISTRY_COLUMNS,
645        rows,
646    )
647}
648
649/// A declaring table's scan paired with the entity declarations it carries —
650/// the unit `build_registry_plan` derives registry rows from.
651pub struct RegistrySource {
652    pub declarations: Vec<EntityDeclaration>,
653    pub scan: DataFrame,
654}
655
656/// Builds the `semantic_entities` registry plan: one branch and source scan per
657/// declaring table, filtered to `window`, then `UNION ALL` across source tables.
658/// Returns `None` when nothing declared an entity, so the computed table streams
659/// empty.
660pub fn build_registry_plan(
661    sources: Vec<RegistrySource>,
662    window: &GraphQueryWindow,
663) -> DfResult<Option<LogicalPlan>> {
664    let mut union_df: Option<DataFrame> = None;
665    for source in sources {
666        let Some((first, rest)) = source.declarations.split_first() else {
667            continue;
668        };
669        union_df = union_all(union_df, registry_source(first, rest, source.scan, window)?)?;
670    }
671    Ok(union_df.map(DataFrame::into_unoptimized_plan))
672}
673
674/// RecordBatch readers shared by this module's tests and the `relationships`
675/// tests.
676#[cfg(test)]
677pub(crate) mod test_util {
678    use datafusion::arrow::array::{Array, BinaryArray, StringArray, TimestampMillisecondArray};
679    use datafusion::arrow::record_batch::RecordBatch;
680    use datafusion::prelude::SessionContext;
681    use datafusion_expr::LogicalPlan;
682
683    pub(crate) async fn collect(ctx: &SessionContext, plan: LogicalPlan) -> Vec<RecordBatch> {
684        ctx.execute_logical_plan(plan)
685            .await
686            .unwrap()
687            .collect()
688            .await
689            .unwrap()
690    }
691
692    pub(crate) fn json_texts(batch: &RecordBatch, column: usize) -> Vec<Option<String>> {
693        let array = batch
694            .column(column)
695            .as_any()
696            .downcast_ref::<BinaryArray>()
697            .unwrap();
698        (0..array.len())
699            .map(|i| {
700                array
701                    .is_valid(i)
702                    .then(|| jsonb::from_slice(array.value(i)).unwrap().to_string())
703            })
704            .collect()
705    }
706
707    pub(crate) fn strings(batch: &RecordBatch, column: usize) -> Vec<String> {
708        let array = batch
709            .column(column)
710            .as_any()
711            .downcast_ref::<StringArray>()
712            .unwrap();
713        (0..array.len())
714            .map(|i| array.value(i).to_string())
715            .collect()
716    }
717
718    pub(crate) fn ts_values(batch: &RecordBatch, column: usize) -> Vec<i64> {
719        let array = batch
720            .column(column)
721            .as_any()
722            .downcast_ref::<TimestampMillisecondArray>()
723            .unwrap();
724        (0..array.len()).map(|i| array.value(i)).collect()
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use std::sync::Arc;
731
732    use datafusion::arrow::array::{ArrayRef, Int64Array, StringArray, TimestampMillisecondArray};
733    use datafusion::arrow::datatypes::{Field, Schema};
734    use datafusion::arrow::record_batch::RecordBatch;
735    use datafusion::datasource::MemTable;
736    use datafusion::prelude::SessionContext;
737
738    use super::test_util::{collect, json_texts, strings};
739    use super::*;
740
741    fn test_window() -> GraphQueryWindow {
742        GraphQueryWindow::from_observed(0, 10 * 60 * 1000)
743    }
744
745    #[test]
746    fn window_source_scan_covers_whole_buckets() {
747        // Buckets selected by [10:30.5', 11:30') is exactly {11:00'}; its source
748        // rows span [11:00', 12:00').
749        let window = GraphQueryWindow::from_observed(630_000, 690_000);
750        assert_eq!(
751            (window.source_start_ms, window.source_end_ms),
752            (660_000, 720_000)
753        );
754
755        // Aligned bounds stay put.
756        let window = GraphQueryWindow::from_observed(600_000, 720_000);
757        assert_eq!(
758            (window.source_start_ms, window.source_end_ms),
759            (600_000, 720_000)
760        );
761
762        // A sub-bucket range that selects no bucket start scans nothing.
763        let window = GraphQueryWindow::from_observed(610_000, 650_000);
764        assert!(window.source_start_ms >= window.source_end_ms);
765
766        // Pre-epoch bounds round toward the correct buckets.
767        let window = GraphQueryWindow::from_observed(-90_000, -30_000);
768        assert_eq!((window.source_start_ms, window.source_end_ms), (-60_000, 0));
769    }
770
771    /// A metric-like table: ms timestamps, service/pid identity, nullable
772    /// descriptive column with JSON-hostile characters.
773    fn metric_table_ctx() -> SessionContext {
774        let schema = Arc::new(Schema::new(vec![
775            Field::new(
776                "ts",
777                DataType::Timestamp(TimeUnit::Millisecond, None),
778                false,
779            ),
780            Field::new("service_name", DataType::Utf8, false),
781            Field::new("pid", DataType::Int64, false),
782            Field::new("host", DataType::Utf8, true),
783        ]));
784        let batch = RecordBatch::try_new(
785            schema.clone(),
786            vec![
787                Arc::new(TimestampMillisecondArray::from(vec![1_000, 2_000, 61_000])) as ArrayRef,
788                Arc::new(StringArray::from(vec!["cart", "cart", "cart"])),
789                Arc::new(Int64Array::from(vec![42, 42, 42])),
790                Arc::new(StringArray::from(vec![
791                    Some("we\"ird\\\nhost"),
792                    None,
793                    Some("h2"),
794                ])),
795            ],
796        )
797        .unwrap();
798        let ctx = SessionContext::new();
799        ctx.register_table(
800            "app_latency",
801            Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
802        )
803        .unwrap();
804        ctx
805    }
806
807    fn decl(entity_type: &str, id_columns: &[&str]) -> EntityDeclaration {
808        EntityDeclaration {
809            schema: "public".to_string(),
810            table: "app_latency".to_string(),
811            time_index: "ts".to_string(),
812            entity_type: entity_type.to_string(),
813            id_columns: id_columns.iter().map(|s| s.to_string()).collect(),
814            descriptive_columns: vec![],
815            scope_columns: vec![],
816        }
817    }
818
819    #[tokio::test]
820    async fn registry_single_column_identity() {
821        let ctx = metric_table_ctx();
822        let df = ctx.table("app_latency").await.unwrap();
823        let plan = build_registry_plan(
824            vec![RegistrySource {
825                declarations: vec![decl("service", &["service_name"])],
826                scan: df,
827            }],
828            &test_window(),
829        )
830        .unwrap()
831        .unwrap();
832
833        let names = plan
834            .schema()
835            .fields()
836            .iter()
837            .map(|f| f.name().as_str())
838            .collect::<Vec<_>>();
839        assert_eq!(
840            names,
841            [
842                "observed_at",
843                "window_start",
844                "window_end",
845                "fresh_until",
846                "entity_type",
847                "entity_id",
848                "entity_id_attrs",
849                "scope",
850                "descriptive",
851                "source_tables",
852            ]
853        );
854
855        let batches = collect(&ctx, plan).await;
856        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
857        // 3 rows in 2 distinct 60s bins, all the same entity -> 2 rows.
858        assert_eq!(total, 2);
859        let batch = &batches[0];
860        assert_eq!(strings(batch, 4), vec!["service"; batch.num_rows()]);
861        assert_eq!(strings(batch, 5), vec!["cart"; batch.num_rows()]);
862        // Single-column id -> entity_id_attrs and descriptive are typed-JSON NULLs.
863        assert!(json_texts(batch, 6).iter().all(Option::is_none));
864        assert!(json_texts(batch, 8).iter().all(Option::is_none));
865        assert_eq!(
866            json_texts(batch, 9),
867            vec![Some(r#"["public.app_latency"]"#.to_string()); batch.num_rows()]
868        );
869    }
870
871    #[tokio::test]
872    async fn registry_composite_identity_and_descriptive_escaping() {
873        let ctx = metric_table_ctx();
874        let df = ctx.table("app_latency").await.unwrap();
875        let mut declaration = decl("process", &["service_name", "pid"]);
876        declaration.descriptive_columns = vec!["host".to_string()];
877        let plan = build_registry_plan(
878            vec![RegistrySource {
879                declarations: vec![declaration],
880                scan: df,
881            }],
882            &test_window(),
883        )
884        .unwrap()
885        .unwrap();
886
887        let batches = collect(&ctx, plan).await;
888        let mut rows: Vec<(String, Option<String>, Option<String>)> = batches
889            .iter()
890            .flat_map(|batch| {
891                let ids = strings(batch, 5);
892                let id_attrs = json_texts(batch, 6);
893                let descriptives = json_texts(batch, 8);
894                ids.into_iter()
895                    .zip(id_attrs)
896                    .zip(descriptives)
897                    .map(|((id, attrs), descriptive)| (id, attrs, descriptive))
898                    .collect::<Vec<_>>()
899            })
900            .collect();
901        rows.sort();
902
903        // Composite id -> sorted `k=v,k=v` plus a JSON object of the id columns;
904        // descriptive JSON keeps `\`, `"` and control characters intact in
905        // runtime values, NULL -> "".
906        assert_eq!(
907            rows,
908            vec![
909                (
910                    "pid=42,service_name=cart".to_string(),
911                    Some(r#"{"pid":"42","service_name":"cart"}"#.to_string()),
912                    Some(r#"{"host":""}"#.to_string()),
913                ),
914                (
915                    "pid=42,service_name=cart".to_string(),
916                    Some(r#"{"pid":"42","service_name":"cart"}"#.to_string()),
917                    Some(r#"{"host":"h2"}"#.to_string()),
918                ),
919                (
920                    "pid=42,service_name=cart".to_string(),
921                    Some(r#"{"pid":"42","service_name":"cart"}"#.to_string()),
922                    Some(r#"{"host":"we\"ird\\\nhost"}"#.to_string()),
923                ),
924            ]
925        );
926    }
927
928    #[tokio::test]
929    async fn registry_scope_variants() {
930        let ctx = metric_table_ctx();
931
932        // Single scope column: its (NULL-safe) value verbatim.
933        let mut single = decl("service", &["service_name"]);
934        single.scope_columns = vec!["host".to_string()];
935        let df = ctx.table("app_latency").await.unwrap();
936        let plan = build_registry_plan(
937            vec![RegistrySource {
938                declarations: vec![single],
939                scan: df,
940            }],
941            &test_window(),
942        )
943        .unwrap()
944        .unwrap();
945        let batches = collect(&ctx, plan).await;
946        let mut scopes: Vec<String> = batches.iter().flat_map(|b| strings(b, 7)).collect();
947        scopes.sort();
948        assert_eq!(scopes, vec!["", "h2", "we\"ird\\\nhost"]);
949
950        // Multiple scope columns: sorted `k=v,k=v`.
951        let mut multi = decl("service", &["service_name"]);
952        multi.scope_columns = vec!["pid".to_string(), "host".to_string()];
953        let df = ctx.table("app_latency").await.unwrap();
954        let plan = build_registry_plan(
955            vec![RegistrySource {
956                declarations: vec![multi],
957                scan: df,
958            }],
959            &test_window(),
960        )
961        .unwrap()
962        .unwrap();
963        let batches = collect(&ctx, plan).await;
964        let mut scopes: Vec<String> = batches.iter().flat_map(|b| strings(b, 7)).collect();
965        scopes.sort();
966        assert_eq!(
967            scopes,
968            vec![
969                "host=,pid=42",
970                "host=h2,pid=42",
971                "host=we\"ird\\\nhost,pid=42"
972            ]
973        );
974    }
975
976    #[tokio::test]
977    async fn registry_skips_absent_identity_rows() {
978        // NULL identifies nothing, and so does a kube-state-metrics-style
979        // empty label (an unscheduled pod's `node` arrives as "").
980        let schema = Arc::new(Schema::new(vec![
981            Field::new(
982                "ts",
983                DataType::Timestamp(TimeUnit::Millisecond, None),
984                false,
985            ),
986            Field::new("host", DataType::Utf8, true),
987        ]));
988        let batch = RecordBatch::try_new(
989            schema.clone(),
990            vec![
991                Arc::new(TimestampMillisecondArray::from(vec![1_000, 2_000, 3_000])) as ArrayRef,
992                Arc::new(StringArray::from(vec![Some("h2"), None, Some("")])),
993            ],
994        )
995        .unwrap();
996        let ctx = SessionContext::new();
997        ctx.register_table(
998            "app_latency",
999            Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
1000        )
1001        .unwrap();
1002        let df = ctx.table("app_latency").await.unwrap();
1003        let plan = build_registry_plan(
1004            vec![RegistrySource {
1005                declarations: vec![decl("host", &["host"])],
1006                scan: df,
1007            }],
1008            &test_window(),
1009        )
1010        .unwrap()
1011        .unwrap();
1012        let batches = collect(&ctx, plan).await;
1013        let ids: Vec<String> = batches.iter().flat_map(|b| strings(b, 5)).collect();
1014        assert_eq!(ids, vec!["h2"]);
1015    }
1016
1017    #[tokio::test]
1018    async fn registry_expands_declarations_with_one_source_scan() {
1019        let ctx = metric_table_ctx();
1020        let df = ctx.table("app_latency").await.unwrap();
1021        let plan = build_registry_plan(
1022            vec![RegistrySource {
1023                declarations: vec![decl("service", &["service_name"]), decl("host", &["pid"])],
1024                scan: df,
1025            }],
1026            &test_window(),
1027        )
1028        .unwrap()
1029        .unwrap();
1030
1031        assert_eq!(
1032            plan.display_indent()
1033                .to_string()
1034                .matches("TableScan: app_latency")
1035                .count(),
1036            1
1037        );
1038        let batches = collect(&ctx, plan).await;
1039        let mut types: Vec<String> = batches.iter().flat_map(|b| strings(b, 4)).collect();
1040        types.sort();
1041        assert_eq!(types, vec!["host", "host", "service", "service"]);
1042
1043        // No declarations -> no plan.
1044        assert!(
1045            build_registry_plan(vec![], &test_window())
1046                .unwrap()
1047                .is_none()
1048        );
1049    }
1050
1051    /// A `TableInfo` derived from the canonical declared-edge definition, with
1052    /// injection points for each aspect the schema matcher must check.
1053    fn declared_table_info(
1054        mutate_columns: impl FnOnce(&mut Vec<datatypes::schema::ColumnSchema>),
1055        engine: &str,
1056        primary_key_indices: Vec<usize>,
1057        extra_options: &[(&str, &str)],
1058    ) -> table::metadata::TableInfo {
1059        let canonical = build_declared_relationships_expr("greptime");
1060        let mut columns: Vec<datatypes::schema::ColumnSchema> = canonical
1061            .column_defs
1062            .iter()
1063            .map(|def| {
1064                let wrapper = api::helper::ColumnDataTypeWrapper::try_new(
1065                    def.data_type,
1066                    def.datatype_extension.clone(),
1067                )
1068                .unwrap();
1069                datatypes::schema::ColumnSchema::new(
1070                    &def.name,
1071                    datatypes::prelude::ConcreteDataType::from(wrapper),
1072                    def.is_nullable,
1073                )
1074                .with_time_index(def.name == OBSERVED_AT_COLUMN)
1075            })
1076            .collect();
1077        mutate_columns(&mut columns);
1078        let schema = Arc::new(
1079            datatypes::schema::SchemaBuilder::try_from_columns(columns)
1080                .unwrap()
1081                .build()
1082                .unwrap(),
1083        );
1084        let options = table::requests::TableOptions {
1085            extra_options: extra_options
1086                .iter()
1087                .map(|(k, v)| (k.to_string(), v.to_string()))
1088                .collect(),
1089            ..Default::default()
1090        };
1091        let meta = table::metadata::TableMeta {
1092            schema,
1093            primary_key_indices,
1094            value_indices: vec![],
1095            engine: engine.to_string(),
1096            next_column_id: 1,
1097            options,
1098            created_on: Default::default(),
1099            updated_on: Default::default(),
1100            partition_key_indices: vec![],
1101            column_ids: vec![],
1102        };
1103        table::metadata::TableInfoBuilder::default()
1104            .table_id(1)
1105            .name(SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME)
1106            .catalog_name("greptime")
1107            .schema_name(DEFAULT_PRIVATE_SCHEMA_NAME)
1108            .table_version(0)
1109            .table_type(table::metadata::TableType::Base)
1110            .meta(meta)
1111            .build()
1112            .unwrap()
1113    }
1114
1115    #[test]
1116    fn declared_schema_match_checks_more_than_columns() {
1117        let mito = common_catalog::consts::MITO_ENGINE;
1118        let canonical_pk = || (6..14).collect::<Vec<_>>();
1119
1120        let ok = declared_table_info(|_| {}, mito, canonical_pk(), &[(TTL_KEY, "180d")]);
1121        assert!(declared_relationships_schema_matches(&ok));
1122        // The canonical expr must declare attributes as json, matching the
1123        // computed table's column.
1124        assert_eq!(
1125            ok.meta
1126                .schema
1127                .column_schema_by_name("attributes")
1128                .map(|c| c.data_type.clone()),
1129            Some(datatypes::prelude::ConcreteDataType::json_datatype())
1130        );
1131        let ok = declared_table_info(
1132            |_| {},
1133            mito,
1134            canonical_pk(),
1135            &[(MERGE_MODE_KEY, "last_row")],
1136        );
1137        assert!(declared_relationships_schema_matches(&ok));
1138
1139        let wrong_engine = declared_table_info(|_| {}, "metric", canonical_pk(), &[]);
1140        assert!(!declared_relationships_schema_matches(&wrong_engine));
1141
1142        let pk_missing_generation_id = declared_table_info(|_| {}, mito, (6..13).collect(), &[]);
1143        assert!(!declared_relationships_schema_matches(
1144            &pk_missing_generation_id
1145        ));
1146
1147        let wrong_time_index = declared_table_info(
1148            |columns| {
1149                columns[0] = columns[0].clone().with_time_index(false);
1150                columns[4] = datatypes::schema::ColumnSchema::new(
1151                    "valid_from",
1152                    datatypes::prelude::ConcreteDataType::timestamp_millisecond_datatype(),
1153                    false,
1154                )
1155                .with_time_index(true);
1156            },
1157            mito,
1158            canonical_pk(),
1159            &[],
1160        );
1161        assert!(!declared_relationships_schema_matches(&wrong_time_index));
1162
1163        let append_mode =
1164            declared_table_info(|_| {}, mito, canonical_pk(), &[(APPEND_MODE_KEY, "true")]);
1165        assert!(!declared_relationships_schema_matches(&append_mode));
1166
1167        let wrong_column_type = declared_table_info(
1168            |columns| {
1169                columns[14] = datatypes::schema::ColumnSchema::new(
1170                    "confidence",
1171                    datatypes::prelude::ConcreteDataType::int64_datatype(),
1172                    true,
1173                );
1174            },
1175            mito,
1176            canonical_pk(),
1177            &[],
1178        );
1179        assert!(!declared_relationships_schema_matches(&wrong_column_type));
1180    }
1181}