Files
greptimedb/tests-integration/src/instance.rs
dennis zhuang e778a72829 feat: complete the derived-edge vocabulary of the entity graph (#8836)
* feat(operator): pair calls edges across trace tables and derive virtual-node edges

Union the normalized client and server spans of all trace tables before the
join, so a client span pairs with a server span stored in a different table.
A client span with no matching server span becomes an edge to a virtual node
named by span attributes (peer.service / db.name / server.address), with
confidence < 1.0 and attributes.connection_type; a window's real pairs win
over virtual candidates for the same edge key.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* feat(operator): derive same-row co-declared edges from the built-in vocabulary

A table declaring both entity types of a vocabulary pair witnesses the edge
on every row carrying both identities: runs_on / contains / part_of for any
declaring table (provenance 'attribute'), agent uses model / agent invoked
tool only for trace sources (span-structure observations, provenance
'trace').

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* feat(operator): derive parent_agent-calls-agent edges from span structure

Trace tables declaring an agent entity pair each span with its child span
across tables (no span-kind filter), keep pairs whose agent identities
differ, and aggregate RED metrics per window, anchored on the parent span
like the service derivation is anchored on the client.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* feat(frontend): feed co-declared and agent sources into the relationships scan

scan_relationships now passes every declaring table (with its trace-ness)
to the co-declared branch and the trace tables' agent declarations to the
agent-calls derivation. enumerate validates the fixed trace-v1 columns and
derives around a malformed trace table instead of failing the whole scan.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* test: cover cross-table pairing, virtual nodes, co-declared and agent edges

sqlness exercises the new derivations end to end (including a malformed
trace-model table being skipped); the integration authorization test now
also pins that a pair split across tables derives no edge when the caller
cannot read one side.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* chore: update the relationships module doc for the new branches

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* chore: import shared derivation helpers via crate paths

The fmt CI gate rejects module-level 'use super::' imports.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* fix: fold co-declared duplicates, decouple agent calls, verify the trace time index

Review findings: the co-declared branch lacked a cross-source DISTINCT, so
two tables witnessing the same edge in one window emitted duplicate rows;
the agent-calls derivation was gated on a usable service declaration; the
trace schema guard accepted a table whose time index is not the column the
derivations bucket by. The empty-trace-table test asserted a union
invariant with no information and is dropped.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* fix: rename the agent-tool edge to invokes and track current OTel peer attributes

The vocabulary's other relation names are present tense; semconv 1.39/1.26
replaced peer.service and db.name with service.peer.name and db.namespace,
so the virtual-node candidates now check the current names first and keep
the deprecated ones for existing telemetry.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* fix: trust the trace-v1 table option instead of matching the fixed schema

The option is only ever stamped by the ingest path, which guarantees the
fixed span columns; matching column types here couples the graph to every
trace schema evolution (e.g. #8816) for a case that cannot occur.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-08-12 04:06:31 +00:00

695 lines
24 KiB
Rust

// Copyright 2023 Greptime Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicU32;
use api::v1::region::QueryRequest;
use client::OutputData;
use common_base::Plugins;
use common_catalog::consts::{DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME};
use common_error::ext::ErrorExt;
use common_error::status_code::StatusCode;
use common_meta::key::table_name::TableNameKey;
use common_meta::rpc::router::region_distribution;
use common_query::Output;
use common_recordbatch::RecordBatches;
use common_telemetry::debug;
use datafusion_expr::LogicalPlan;
use frontend::error::{Error, Result};
use frontend::instance::Instance;
use query::parser::{QueryLanguageParser, QueryStatement};
use query::query_engine::DefaultSerializer;
use servers::interceptor::{SqlQueryInterceptor, SqlQueryInterceptorRef};
use servers::query_handler::sql::SqlQueryHandler;
use session::context::{QueryContext, QueryContextRef};
use sql::statements::statement::Statement;
use store_api::storage::RegionId;
use substrait::{DFLogicalSubstraitConvertor, SubstraitPlan};
use crate::standalone::GreptimeDbStandaloneBuilder;
use crate::tests;
use crate::tests::MockDistributedInstance;
#[tokio::test(flavor = "multi_thread")]
async fn test_standalone_exec_sql() {
let standalone = GreptimeDbStandaloneBuilder::new("test_standalone_exec_sql")
.build()
.await;
let instance = standalone.fe_instance();
let sql = r#"
CREATE TABLE demo(
host STRING,
ts TIMESTAMP,
cpu DOUBLE NULL,
memory DOUBLE NULL,
disk_util DOUBLE DEFAULT 9.9,
TIME INDEX (ts),
PRIMARY KEY(host)
) engine=mito"#;
create_table(instance, sql).await;
insert_and_query(instance).await;
drop_table(instance).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn test_distributed_exec_sql() {
common_telemetry::init_default_ut_logging();
let distributed = tests::create_distributed_instance("test_distributed_exec_sql").await;
let frontend = distributed.frontend();
let instance = frontend.as_ref();
let sql = r#"
CREATE TABLE demo(
host STRING,
ts TIMESTAMP,
cpu DOUBLE NULL,
memory DOUBLE NULL,
disk_util DOUBLE DEFAULT 9.9,
TIME INDEX (ts),
PRIMARY KEY(host)
)
PARTITION ON COLUMNS (host) (
host < '550-A',
host >= '550-A' AND host < '550-W',
host >= '550-W' AND host < 'MOSS',
host >= 'MOSS'
)
engine=mito"#;
create_table(instance, sql).await;
insert_and_query(instance).await;
verify_data_distribution(
&distributed,
HashMap::from([
(
0u32,
"\
+---------------------+------+
| ts | host |
+---------------------+------+
| 2013-12-31T16:00:00 | 490 |
+---------------------+------+",
),
(
1u32,
"\
+---------------------+-------+
| ts | host |
+---------------------+-------+
| 2022-12-31T16:00:00 | 550-A |
+---------------------+-------+",
),
(
2u32,
"\
+---------------------+-------+
| ts | host |
+---------------------+-------+
| 2023-12-31T16:00:00 | 550-W |
+---------------------+-------+",
),
(
3u32,
"\
+---------------------+------+
| ts | host |
+---------------------+------+
| 2043-12-31T16:00:00 | MOSS |
+---------------------+------+",
),
]),
)
.await;
drop_table(instance).await;
verify_table_is_dropped(&distributed).await;
}
async fn query(instance: &Instance, sql: &str) -> Output {
SqlQueryHandler::do_query(instance, sql, QueryContext::arc())
.await
.remove(0)
.unwrap()
}
async fn create_table(instance: &Instance, sql: &str) {
let output = query(instance, sql).await;
let OutputData::AffectedRows(x) = output.data else {
unreachable!()
};
assert_eq!(x, 0);
}
async fn insert_and_query(instance: &Instance) {
let sql = r#"INSERT INTO demo(host, cpu, memory, ts) VALUES
('490', 0.1, 1, 1388505600000),
('550-A', 1, 100, 1672502400000),
('550-W', 10000, 1000000, 1704038400000),
('MOSS', 100000000, 10000000000, 2335190400000)
"#;
let output = query(instance, sql).await;
let OutputData::AffectedRows(x) = output.data else {
unreachable!()
};
assert_eq!(x, 4);
let sql = "SELECT * FROM demo WHERE ts > cast(1000000000 as timestamp) ORDER BY host"; // use nanoseconds as where condition
let output = query(instance, sql).await;
let OutputData::Stream(s) = output.data else {
unreachable!()
};
let batches = common_recordbatch::util::collect_batches(s).await.unwrap();
let pretty_print = batches.pretty_print().unwrap();
let expected = "\
+-------+---------------------+-------------+---------------+-----------+
| host | ts | cpu | memory | disk_util |
+-------+---------------------+-------------+---------------+-----------+
| 490 | 2013-12-31T16:00:00 | 0.1 | 1.0 | 9.9 |
| 550-A | 2022-12-31T16:00:00 | 1.0 | 100.0 | 9.9 |
| 550-W | 2023-12-31T16:00:00 | 10000.0 | 1000000.0 | 9.9 |
| MOSS | 2043-12-31T16:00:00 | 100000000.0 | 10000000000.0 | 9.9 |
+-------+---------------------+-------------+---------------+-----------+";
assert_eq!(pretty_print, expected);
}
async fn verify_data_distribution(
instance: &MockDistributedInstance,
expected_distribution: HashMap<u32, &str>,
) {
let manager = instance.table_metadata_manager();
let table_id = manager
.table_name_manager()
.get(TableNameKey::new(
DEFAULT_CATALOG_NAME,
DEFAULT_SCHEMA_NAME,
"demo",
))
.await
.unwrap()
.unwrap()
.table_id();
debug!("Reading table {table_id}");
let table_route_value = manager
.table_route_manager()
.table_route_storage()
.get(table_id)
.await
.unwrap()
.unwrap();
let region_to_dn_map = region_distribution(
table_route_value
.region_routes()
.expect("region routes should be physical"),
)
.iter()
.map(|(k, v)| (v.leader_regions[0], *k))
.collect::<HashMap<u32, u64>>();
assert!(region_to_dn_map.len() <= instance.datanodes().len());
let stmt = QueryLanguageParser::parse_sql(
"SELECT ts, host FROM demo ORDER BY ts",
&QueryContext::arc(),
)
.unwrap();
let plan = instance
.frontend()
.statement_executor()
.plan(&stmt, QueryContext::arc())
.await
.unwrap();
let plan = DFLogicalSubstraitConvertor
.encode(&plan, DefaultSerializer)
.unwrap();
for (region, dn) in region_to_dn_map.iter() {
let region_server = instance.datanodes().get(dn).unwrap().region_server();
let region_id = RegionId::new(table_id, *region);
let stream = region_server
.handle_remote_read(
QueryRequest {
region_id: region_id.as_u64(),
plan: plan.to_vec(),
..Default::default()
},
QueryContext::arc(),
)
.await
.unwrap();
let recordbatches = RecordBatches::try_collect(stream).await.unwrap();
let actual = recordbatches.pretty_print().unwrap();
let expected = expected_distribution.get(region).unwrap();
assert_eq!(&actual, expected);
}
}
async fn drop_table(instance: &Instance) {
let sql = "DROP TABLE demo";
let output = query(instance, sql).await;
let OutputData::AffectedRows(x) = output.data else {
unreachable!()
};
assert_eq!(x, 0);
}
async fn verify_table_is_dropped(instance: &MockDistributedInstance) {
assert!(
instance
.frontend()
.catalog_manager()
.table("greptime", "public", "demo", None)
.await
.unwrap()
.is_none()
)
}
#[tokio::test(flavor = "multi_thread")]
async fn test_sql_interceptor_plugin() {
#[derive(Default)]
struct AssertionHook {
pub(crate) c: AtomicU32,
}
impl SqlQueryInterceptor for AssertionHook {
type Error = Error;
fn pre_parsing<'a>(
&self,
query: &'a str,
_query_ctx: QueryContextRef,
) -> Result<Cow<'a, str>> {
let _ = self.c.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert!(query.starts_with("CREATE TABLE demo"));
Ok(Cow::Borrowed(query))
}
fn post_parsing(
&self,
statements: Vec<Statement>,
_query_ctx: QueryContextRef,
) -> Result<Vec<Statement>> {
let _ = self.c.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert!(matches!(statements[0], Statement::CreateTable(_)));
Ok(statements)
}
fn pre_execute(
&self,
_statement: Option<&Statement>,
_plan: Option<&LogicalPlan>,
_query_ctx: QueryContextRef,
) -> Result<()> {
let _ = self.c.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
fn post_execute(
&self,
mut output: Output,
_query_ctx: QueryContextRef,
) -> Result<Output> {
let _ = self.c.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
match &mut output.data {
OutputData::AffectedRows(rows) => {
assert_eq!(*rows, 0);
// update output result
*rows = 10;
}
_ => unreachable!(),
}
Ok(output)
}
}
let plugins = Plugins::new();
let counter_hook = Arc::new(AssertionHook::default());
plugins.insert::<SqlQueryInterceptorRef<Error>>(counter_hook.clone());
let standalone = GreptimeDbStandaloneBuilder::new("test_sql_interceptor_plugin")
.with_plugin(plugins)
.build()
.await;
let instance = standalone.fe_instance().clone();
let sql = r#"CREATE TABLE demo(
host STRING,
ts TIMESTAMP,
cpu DOUBLE NULL,
memory DOUBLE NULL,
disk_util DOUBLE DEFAULT 9.9,
TIME INDEX (ts),
PRIMARY KEY(host)
) engine=mito;"#;
let output = SqlQueryHandler::do_query(&*instance, sql, QueryContext::arc())
.await
.remove(0)
.unwrap();
// assert that the hook is called 3 times
assert_eq!(4, counter_hook.c.load(std::sync::atomic::Ordering::Relaxed));
match output.data {
OutputData::AffectedRows(rows) => assert_eq!(rows, 10),
_ => unreachable!(),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_exec_plan_interceptor_plugin() {
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Default)]
struct ExecPlanHook {
pub(crate) pre_execute_called: AtomicBool,
pub(crate) post_execute_called: AtomicBool,
}
impl SqlQueryInterceptor for ExecPlanHook {
type Error = Error;
fn pre_execute(
&self,
_statement: Option<&Statement>,
_plan: Option<&LogicalPlan>,
_query_ctx: QueryContextRef,
) -> Result<()> {
self.pre_execute_called.store(true, Ordering::Relaxed);
Ok(())
}
fn post_execute(&self, output: Output, _query_ctx: QueryContextRef) -> Result<Output> {
self.post_execute_called.store(true, Ordering::Relaxed);
Ok(output)
}
}
let plugins = Plugins::new();
let hook = Arc::new(ExecPlanHook::default());
plugins.insert::<SqlQueryInterceptorRef<Error>>(hook.clone());
let standalone = GreptimeDbStandaloneBuilder::new("test_exec_plan_interceptor_plugin")
.with_plugin(plugins)
.build()
.await;
let instance = standalone.fe_instance().clone();
let sql = r#"CREATE TABLE demo(
host STRING,
ts TIMESTAMP,
cpu DOUBLE NULL,
TIME INDEX (ts),
PRIMARY KEY(host)
) engine=mito;"#;
SqlQueryHandler::do_query(&*instance, sql, QueryContext::arc())
.await
.remove(0)
.unwrap();
let query_ctx = QueryContext::arc();
let stmt = QueryLanguageParser::parse_sql("SELECT * FROM demo", &query_ctx).unwrap();
let plan = instance
.statement_executor()
.plan(&stmt, query_ctx.clone())
.await
.unwrap();
let QueryStatement::Sql(sql_stmt) = stmt else {
unreachable!()
};
SqlQueryHandler::do_exec_plan(&*instance, plan, Some(sql_stmt), query_ctx.clone())
.await
.unwrap();
assert!(
hook.pre_execute_called.load(Ordering::Relaxed),
"pre_execute should be called for do_exec_plan"
);
assert!(
hook.post_execute_called.load(Ordering::Relaxed),
"post_execute should be called for do_exec_plan"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_disable_db_operation_plugin() {
#[derive(Default)]
struct DisableDBOpHook;
impl SqlQueryInterceptor for DisableDBOpHook {
type Error = Error;
fn post_parsing(
&self,
statements: Vec<Statement>,
_query_ctx: QueryContextRef,
) -> Result<Vec<Statement>> {
for s in &statements {
match s {
Statement::CreateDatabase(_) | Statement::ShowDatabases(_) => {
return Err(Error::NotSupported {
feat: "Database operations".to_owned(),
});
}
_ => {}
}
}
Ok(statements)
}
}
let query_ctx = QueryContext::arc();
let plugins = Plugins::new();
let hook = Arc::new(DisableDBOpHook);
plugins.insert::<SqlQueryInterceptorRef<Error>>(hook.clone());
let standalone = GreptimeDbStandaloneBuilder::new("test_disable_db_operation_plugin")
.with_plugin(plugins)
.build()
.await;
let instance = standalone.fe_instance().clone();
let sql = r#"CREATE TABLE demo(
host STRING,
ts TIMESTAMP,
cpu DOUBLE NULL,
memory DOUBLE NULL,
disk_util DOUBLE DEFAULT 9.9,
TIME INDEX (ts),
PRIMARY KEY(host)
) engine=mito;"#;
let output = SqlQueryHandler::do_query(&*instance, sql, query_ctx.clone())
.await
.remove(0)
.unwrap();
match output.data {
OutputData::AffectedRows(rows) => assert_eq!(rows, 0),
_ => unreachable!(),
}
let sql = r#"CREATE DATABASE tomcat"#;
if let Err(e) = SqlQueryHandler::do_query(&*instance, sql, query_ctx.clone())
.await
.remove(0)
{
assert_eq!(e.status_code(), StatusCode::Unsupported);
} else {
unreachable!();
}
let sql = r#"SELECT 1; SHOW DATABASES"#;
if let Err(e) = SqlQueryHandler::do_query(&*instance, sql, query_ctx.clone())
.await
.remove(0)
{
assert_eq!(e.status_code(), StatusCode::Unsupported);
} else {
unreachable!();
}
}
/// The entity-graph derivation must run as the caller: a source table the
/// caller cannot read contributes neither entities nor edges.
#[tokio::test(flavor = "multi_thread")]
async fn test_entity_graph_derivation_runs_as_caller() {
common_telemetry::init_default_ut_logging();
/// Denies the entity-graph derivation on `secret_traces` only.
struct DenySecretTraces;
impl auth::PermissionChecker for DenySecretTraces {
fn check_permission(
&self,
_user_info: auth::UserInfoRef,
_req: auth::PermissionReq,
) -> auth::error::Result<auth::PermissionResp> {
Ok(auth::PermissionResp::Allow)
}
fn check_permission_with_table_targets(
&self,
_user_info: auth::UserInfoRef,
req: auth::PermissionReq,
targets: auth::PermissionTableTargets,
) -> auth::error::Result<auth::PermissionResp> {
if matches!(req, auth::PermissionReq::Action(auth::SEMANTIC_GRAPH_QUERY))
&& let auth::PermissionTableTargets::Resolved(targets) = targets
&& targets
.iter()
.any(|target| target.table.starts_with("secret_"))
{
return Ok(auth::PermissionResp::Reject);
}
Ok(auth::PermissionResp::Allow)
}
}
let plugins = Plugins::new();
plugins.insert::<auth::PermissionCheckerRef>(Arc::new(DenySecretTraces));
let standalone = GreptimeDbStandaloneBuilder::new("test_entity_graph_runs_as_caller")
.with_plugin(plugins)
.build()
.await;
let instance = standalone.fe_instance().clone();
for (table, trace_id) in [("open_traces", "t0"), ("secret_traces", "t1")] {
let create = format!(
r#"create table {table} (
"timestamp" timestamp(9) time index,
trace_id string,
span_id string,
parent_span_id string,
span_kind string,
span_status_code string,
service_name string,
duration_nano bigint unsigned,
primary key (service_name)
) with ('table_data_model' = 'greptime_trace_v1', 'append_mode' = 'true')"#
);
create_table(&instance, &create).await;
let insert = format!(
"insert into {table} values \
(now(), '{trace_id}', 'c1', NULL, 'SPAN_KIND_CLIENT', 'STATUS_CODE_UNSET', 'client-{table}', 0), \
(now(), '{trace_id}', 's1', 'c1', 'SPAN_KIND_SERVER', 'STATUS_CODE_UNSET', 'server-{table}', 100)"
);
let output = query(&instance, &insert).await;
let OutputData::AffectedRows(x) = output.data else {
unreachable!()
};
assert_eq!(x, 2);
}
// A pair split across tables (client here, server in a denied table):
// a join-derived edge needs read access to all of its input tables.
create_table(
&instance,
r#"create table cross_clients (
"timestamp" timestamp(9) time index,
trace_id string,
span_id string,
parent_span_id string,
span_kind string,
span_status_code string,
service_name string,
duration_nano bigint unsigned,
primary key (service_name)
) with ('table_data_model' = 'greptime_trace_v1', 'append_mode' = 'true')"#,
)
.await;
create_table(
&instance,
r#"create table secret_cross_servers (
"timestamp" timestamp(9) time index,
trace_id string,
span_id string,
parent_span_id string,
span_kind string,
span_status_code string,
service_name string,
duration_nano bigint unsigned,
primary key (service_name)
) with ('table_data_model' = 'greptime_trace_v1', 'append_mode' = 'true')"#,
)
.await;
for insert in [
"insert into cross_clients values \
(now(), 't9', 'c9', NULL, 'SPAN_KIND_CLIENT', 'STATUS_CODE_UNSET', 'client-cross', 0)",
"insert into secret_cross_servers values \
(now(), 't9', 's9', 'c9', 'SPAN_KIND_SERVER', 'STATUS_CODE_UNSET', 'server-cross', 100)",
] {
let output = query(&instance, insert).await;
let OutputData::AffectedRows(x) = output.data else {
unreachable!()
};
assert_eq!(x, 1);
}
let sql = "select src_id, dst_id from greptime_private.semantic_relationships \
order by src_id";
let output = query(&instance, sql).await;
let OutputData::Stream(s) = output.data else {
unreachable!()
};
let batches = common_recordbatch::util::collect_batches(s).await.unwrap();
let pretty_print = batches.pretty_print().unwrap();
assert!(
pretty_print.contains("client-open_traces"),
"allowed edge missing:\n{pretty_print}"
);
assert!(
!pretty_print.contains("secret_traces"),
"denied source leaked into edges:\n{pretty_print}"
);
assert!(
!pretty_print.contains("client-cross"),
"edge with a denied join side leaked:\n{pretty_print}"
);
let sql = "select entity_id from greptime_private.semantic_entities order by entity_id";
let output = query(&instance, sql).await;
let OutputData::Stream(s) = output.data else {
unreachable!()
};
let batches = common_recordbatch::util::collect_batches(s).await.unwrap();
let pretty_print = batches.pretty_print().unwrap();
assert!(
pretty_print.contains("client-open_traces"),
"allowed entity missing:\n{pretty_print}"
);
assert!(
!pretty_print.contains("secret_traces") && !pretty_print.contains("server-cross"),
"denied source leaked into entities:\n{pretty_print}"
);
}
}