mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-10-26 05:36:16 +00:00
* feat: add column encoding option and set it for trace span ids COMPRESSION WITH accepts encoding = 'plain' | 'delta_binary_packed'. Either disables the dictionary for the column; delta_binary_packed is limited to integer and timestamp columns. OTLP trace v1/v2 tables now create span_id and parent_span_id with plain encoding and 64KiB pages, and timestamp_end with delta_binary_packed. Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test: add compatibility cases for column encoding options Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * test: move column encoding downgrade checks into downgrade_compatibility Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2296 lines
90 KiB
Rust
2296 lines
90 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 test {
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use TimeUnit as ArrowTimeUnit;
|
|
use api::region::RegionResponse;
|
|
use api::v1::region::{RegionRequest, region_request};
|
|
use client::{DEFAULT_CATALOG_NAME, OutputData};
|
|
use common_meta::cache::LayeredCacheRegistryBuilder;
|
|
use common_meta::node_manager::{
|
|
Datanode, DatanodeManager, DatanodeRef, FlownodeManager, FlownodeRef, NodeManagerRef,
|
|
};
|
|
use common_meta::peer::Peer;
|
|
use common_query::request::QueryRequest;
|
|
use common_recordbatch::{RecordBatches, SendableRecordBatchStream};
|
|
use datatypes::arrow::array::AsArray;
|
|
use datatypes::arrow::datatypes::{
|
|
DataType, TimeUnit, TimestampMicrosecondType, TimestampMillisecondType,
|
|
TimestampNanosecondType, TimestampSecondType,
|
|
};
|
|
use frontend::instance::Instance;
|
|
use frontend::instance::builder::FrontendBuilder;
|
|
use frontend::service_config::BatcherOptions;
|
|
use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
|
|
use otel_arrow_rust::proto::opentelemetry::collector::metrics::v1::ExportMetricsServiceRequest;
|
|
use otel_arrow_rust::proto::opentelemetry::common::v1::any_value::Value as Val;
|
|
use otel_arrow_rust::proto::opentelemetry::common::v1::{
|
|
AnyValue, InstrumentationScope, KeyValue,
|
|
};
|
|
use otel_arrow_rust::proto::opentelemetry::metrics::v1::number_data_point::Value;
|
|
use otel_arrow_rust::proto::opentelemetry::metrics::v1::{
|
|
AggregationTemporality, DataPointFlags, Gauge, Histogram, HistogramDataPoint, Metric,
|
|
NumberDataPoint, ResourceMetrics, ScopeMetrics, Sum, metric,
|
|
};
|
|
use otel_arrow_rust::proto::opentelemetry::resource::v1::Resource;
|
|
use pipeline::{GreptimePipelineParams, PipelineWay};
|
|
use serde_json::json;
|
|
use servers::batcher::BatchingProtocol;
|
|
use servers::otlp::trace::{TraceAuxData, span};
|
|
use servers::query_handler::OpenTelemetryProtocolHandler;
|
|
use servers::query_handler::sql::SqlQueryHandler;
|
|
use session::context::QueryContext;
|
|
use session::protocol_ctx::{OtlpMetricCtx, ProtocolCtx};
|
|
use store_api::region_engine::RegionEngine;
|
|
use store_api::storage::RegionId;
|
|
use tokio::sync::oneshot;
|
|
|
|
use crate::standalone::{GreptimeDbStandalone, GreptimeDbStandaloneBuilder};
|
|
use crate::tests;
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn test_trace_aux_cache() {
|
|
check_trace_aux_cache(false).await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn test_trace_aux_cache_with_batching_v0_v1() {
|
|
check_trace_aux_cache(true).await;
|
|
}
|
|
|
|
#[test]
|
|
fn test_trace_aux_async_completion() {
|
|
temp_env::with_var("PENDING_ROWS_BATCH_SYNC", Some("false"), || {
|
|
tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap()
|
|
.block_on(check_trace_aux_async_completion());
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_trace_aux_admission() {
|
|
temp_env::with_var("PENDING_ROWS_BATCH_SYNC", Some("false"), || {
|
|
tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap()
|
|
.block_on(async {
|
|
tokio::time::timeout(Duration::from_secs(60), check_trace_aux_admission())
|
|
.await
|
|
.expect("auxiliary admission must make progress");
|
|
});
|
|
});
|
|
}
|
|
|
|
struct TraceAuxWriteGate {
|
|
region_id: RegionId,
|
|
started: oneshot::Sender<()>,
|
|
release: oneshot::Receiver<bool>,
|
|
}
|
|
|
|
struct GatedTraceNodeManager {
|
|
inner: NodeManagerRef,
|
|
gate: Arc<Mutex<Option<TraceAuxWriteGate>>>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl DatanodeManager for GatedTraceNodeManager {
|
|
async fn datanode(&self, peer: &Peer) -> DatanodeRef {
|
|
Arc::new(GatedTraceDatanode {
|
|
inner: self.inner.datanode(peer).await,
|
|
gate: self.gate.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl FlownodeManager for GatedTraceNodeManager {
|
|
async fn flownode(&self, peer: &Peer) -> FlownodeRef {
|
|
self.inner.flownode(peer).await
|
|
}
|
|
}
|
|
|
|
struct GatedTraceDatanode {
|
|
inner: DatanodeRef,
|
|
gate: Arc<Mutex<Option<TraceAuxWriteGate>>>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Datanode for GatedTraceDatanode {
|
|
async fn handle_request(
|
|
&self,
|
|
request: Arc<RegionRequest>,
|
|
) -> common_meta::error::Result<RegionResponse> {
|
|
let gate = {
|
|
let mut gate = self.gate.lock().unwrap();
|
|
if gate.as_ref().is_some_and(|gate| {
|
|
matches!(&request.body, Some(region_request::Body::Inserts(inserts))
|
|
if inserts.requests.iter().any(|insert| insert.region_id == gate.region_id.as_u64()))
|
|
}) {
|
|
gate.take()
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
if let Some(gate) = gate {
|
|
gate.started.send(()).unwrap();
|
|
if !gate.release.await.unwrap() {
|
|
return common_meta::error::UnexpectedSnafu {
|
|
err_msg: "injected auxiliary write failure",
|
|
}
|
|
.fail();
|
|
}
|
|
}
|
|
self.inner.handle(request).await
|
|
}
|
|
|
|
async fn handle_query(
|
|
&self,
|
|
request: QueryRequest,
|
|
) -> common_meta::error::Result<SendableRecordBatchStream> {
|
|
self.inner.handle_query(request).await
|
|
}
|
|
}
|
|
|
|
async fn check_trace_aux_admission() {
|
|
let standalone = GreptimeDbStandaloneBuilder::new("trace_aux_admission")
|
|
.with_table_batcher(BatcherOptions {
|
|
protocols: vec![BatchingProtocol::Otlp],
|
|
pending_rows_flush_interval: Duration::from_secs(3600),
|
|
max_batch_rows: 1,
|
|
max_inflight_requests: 1,
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.await;
|
|
let source = standalone.fe_instance();
|
|
let gate = Arc::new(Mutex::new(None));
|
|
let cache_registry = Arc::new(
|
|
cache::with_default_composite_cache_registry(
|
|
LayeredCacheRegistryBuilder::default().add_cache_registry(
|
|
cache::build_fundamental_cache_registry(standalone.kv_backend.clone()),
|
|
),
|
|
)
|
|
.unwrap()
|
|
.build(),
|
|
);
|
|
let mut options = standalone.opts.frontend_options();
|
|
options.otlp.trace_ingest_chunk_size = 1;
|
|
let instance = Arc::new(
|
|
FrontendBuilder::new(
|
|
options,
|
|
standalone.kv_backend.clone(),
|
|
cache_registry,
|
|
source.catalog_manager().clone(),
|
|
Arc::new(GatedTraceNodeManager {
|
|
inner: source.node_manager().clone(),
|
|
gate: gate.clone(),
|
|
}),
|
|
source.procedure_executor().clone(),
|
|
Arc::new(catalog::process_manager::ProcessManager::new(
|
|
"trace_aux_admission".into(),
|
|
None,
|
|
)),
|
|
)
|
|
.try_build()
|
|
.await
|
|
.unwrap(),
|
|
);
|
|
|
|
for version in 0..=1 {
|
|
let table_name = format!("trace_aux_admission_v{version}");
|
|
let mut context = QueryContext::with(DEFAULT_CATALOG_NAME, "public");
|
|
context.set_extension(
|
|
common_catalog::consts::TRACE_TABLE_NAME_SESSION_KEY,
|
|
&table_name,
|
|
);
|
|
context.set_extension(table::requests::TRACE_TABLE_PARTITIONS_HINT_KEY, "1");
|
|
context.set_batching_enabled(true);
|
|
let ctx = Arc::new(context);
|
|
let write = async |request| {
|
|
instance
|
|
.traces(
|
|
instance.clone(),
|
|
request,
|
|
if version == 0 {
|
|
PipelineWay::OtlpTraceDirectV0
|
|
} else {
|
|
PipelineWay::OtlpTraceDirectV1
|
|
},
|
|
GreptimePipelineParams::default(),
|
|
table_name.clone(),
|
|
ctx.clone(),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
};
|
|
let cached = |request: ExportTraceServiceRequest| {
|
|
let mut data = TraceAuxData::default();
|
|
for span in span::parse(request).iter().flat_map(|group| &group.spans) {
|
|
data.observe_span(span);
|
|
}
|
|
instance.trace_aux_data_cached_for_test(&table_name, data, &ctx)
|
|
};
|
|
let warm = trace_aux_request();
|
|
assert_eq!(1, write(warm.clone()).await.accepted_spans);
|
|
while !cached(warm.clone()) {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
let table = instance
|
|
.catalog_manager()
|
|
.table(
|
|
DEFAULT_CATALOG_NAME,
|
|
"public",
|
|
&format!("{table_name}_operations"),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let route = instance
|
|
.partition_manager()
|
|
.find_physical_table_route(table.table_info().table_id())
|
|
.await
|
|
.unwrap();
|
|
let region_id = route.region_routes[0].region.id;
|
|
|
|
for success in [true, false] {
|
|
let mut cold = warm.clone();
|
|
let spans = &mut cold.resource_spans[0].scope_spans[0].spans;
|
|
spans[0].name = format!("cold_{success}");
|
|
spans.push(spans[0].clone());
|
|
let (started_tx, started_rx) = oneshot::channel();
|
|
let (release_tx, release_rx) = oneshot::channel();
|
|
*gate.lock().unwrap() = Some(TraceAuxWriteGate {
|
|
region_id,
|
|
started: started_tx,
|
|
release: release_rx,
|
|
});
|
|
|
|
// Two chunks must progress with just one main and one auxiliary slot.
|
|
assert_eq!(2, write(cold.clone()).await.accepted_spans);
|
|
started_rx.await.unwrap();
|
|
assert!(
|
|
!cached(cold.clone()),
|
|
"pending writes must not warm the cache"
|
|
);
|
|
let before = trace_aux_sequences(&standalone, &table_name).await;
|
|
let mut waiting = Box::pin(write(cold.clone()));
|
|
assert!(futures::poll!(waiting.as_mut()).is_pending());
|
|
|
|
// Main admission is free while auxiliary storage is blocked. Warm
|
|
// requests must bypass auxiliary admission and keep making progress.
|
|
assert_eq!(1, write(warm.clone()).await.accepted_spans);
|
|
assert!(futures::poll!(waiting.as_mut()).is_pending());
|
|
release_tx.send(success).unwrap();
|
|
assert_eq!(2, waiting.await.accepted_spans);
|
|
if !success {
|
|
// The failed write cannot satisfy the waiting request's miss.
|
|
while !cached(cold.clone()) {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
}
|
|
let after = trace_aux_sequences(&standalone, &table_name).await;
|
|
assert_eq!(before[1], after[1], "cached service must not be rewritten");
|
|
assert_eq!(
|
|
before[2] + 1,
|
|
after[2],
|
|
"exactly one successful operation write"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn trace_aux_request() -> ExportTraceServiceRequest {
|
|
serde_json::from_value(json!({
|
|
"resourceSpans": [{
|
|
"resource": {"attributes": [
|
|
{"key": "service.name", "value": {"stringValue": "svc"}}
|
|
]},
|
|
"scopeSpans": [{"spans": [{
|
|
"traceId": "c05d7a4ec8e1f231f02ed6e8da8655b4",
|
|
"spanId": "9630f2916e2f7909",
|
|
"name": "operation",
|
|
"kind": 2,
|
|
"startTimeUnixNano": "1736480942444376000",
|
|
"endTimeUnixNano": "1736480942444499000"
|
|
}]}]
|
|
}]
|
|
}))
|
|
.unwrap()
|
|
}
|
|
|
|
async fn trace_aux_sequences(standalone: &GreptimeDbStandalone, table_name: &str) -> Vec<u64> {
|
|
let instance = standalone.fe_instance();
|
|
let mut sequences = Vec::new();
|
|
for suffix in ["", "_services", "_operations"] {
|
|
let table = instance
|
|
.catalog_manager()
|
|
.table(
|
|
DEFAULT_CATALOG_NAME,
|
|
"public",
|
|
&format!("{table_name}{suffix}"),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let route = instance
|
|
.partition_manager()
|
|
.find_physical_table_route(table.table_info().table_id())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(route.region_routes.len(), 1);
|
|
sequences.push(
|
|
standalone
|
|
.mito_engine
|
|
.get_committed_sequence(route.region_routes[0].region.id)
|
|
.await
|
|
.unwrap(),
|
|
);
|
|
}
|
|
sequences
|
|
}
|
|
|
|
async fn check_trace_aux_async_completion() {
|
|
let standalone = GreptimeDbStandaloneBuilder::new("trace_aux_async_completion")
|
|
.with_table_batcher(BatcherOptions {
|
|
protocols: vec![BatchingProtocol::Otlp],
|
|
pending_rows_flush_interval: Duration::from_secs(3600),
|
|
max_batch_rows: 2,
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.await;
|
|
let instance = standalone.fe_instance();
|
|
// V2 bulk ingestion has a separate Variant/Struct conversion failure.
|
|
for version in 0..=1 {
|
|
let table_name = format!("trace_aux_async_v{version}");
|
|
let mut context = QueryContext::with(DEFAULT_CATALOG_NAME, "public");
|
|
context.set_extension(
|
|
common_catalog::consts::TRACE_TABLE_NAME_SESSION_KEY,
|
|
&table_name,
|
|
);
|
|
context.set_extension(table::requests::TRACE_TABLE_PARTITIONS_HINT_KEY, "1");
|
|
context.set_batching_enabled(true);
|
|
let ctx = Arc::new(context);
|
|
let request = trace_aux_request();
|
|
let write = async |request, ctx| {
|
|
let outcome = tokio::time::timeout(
|
|
Duration::from_secs(30),
|
|
instance.traces(
|
|
instance.clone(),
|
|
request,
|
|
if version == 0 {
|
|
PipelineWay::OtlpTraceDirectV0
|
|
} else {
|
|
PipelineWay::OtlpTraceDirectV1
|
|
},
|
|
GreptimePipelineParams::default(),
|
|
table_name.clone(),
|
|
ctx,
|
|
),
|
|
)
|
|
.await
|
|
.expect("async trace ingestion must return before the batch flush")
|
|
.unwrap();
|
|
assert_eq!((outcome.accepted_spans, outcome.rejected_spans), (1, 0));
|
|
assert!(outcome.error_message.is_none());
|
|
};
|
|
write(request.clone(), ctx.clone()).await;
|
|
assert_trace_v2_query(
|
|
instance,
|
|
&format!("SELECT COUNT(*) = 0 FROM {table_name}"),
|
|
ctx.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
for suffix in ["_services", "_operations"] {
|
|
assert!(
|
|
instance
|
|
.catalog_manager()
|
|
.table(
|
|
DEFAULT_CATALOG_NAME,
|
|
"public",
|
|
&format!("{table_name}{suffix}"),
|
|
None
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
// The second submission deterministically triggers the main flush.
|
|
// Omitting the service avoids a second concurrent auxiliary write.
|
|
let mut flush_request = request.clone();
|
|
flush_request.resource_spans[0]
|
|
.resource
|
|
.as_mut()
|
|
.unwrap()
|
|
.attributes
|
|
.clear();
|
|
write(flush_request, ctx.clone()).await;
|
|
tokio::time::timeout(Duration::from_secs(30), async {
|
|
loop {
|
|
let mut aux_data = TraceAuxData::default();
|
|
for span in span::parse(request.clone())
|
|
.into_iter()
|
|
.flat_map(|group| group.spans)
|
|
{
|
|
aux_data.observe_span(&span);
|
|
}
|
|
if instance.trace_aux_data_cached_for_test(&table_name, aux_data, &ctx) {
|
|
break;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("deferred auxiliary writes must populate the cache");
|
|
for suffix in ["_services", "_operations"] {
|
|
assert_trace_v2_query(
|
|
instance,
|
|
&format!("SELECT COUNT(*) = 1 FROM {table_name}{suffix}"),
|
|
ctx.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
assert_trace_v2_query(
|
|
instance,
|
|
&format!("SELECT COUNT(*) > 0 FROM {table_name}"),
|
|
ctx.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let before = trace_aux_sequences(&standalone, &table_name).await;
|
|
// A direct warm request finishes before we compare storage sequences.
|
|
let mut warm_ctx = ctx.fork();
|
|
warm_ctx.set_batching_enabled(false);
|
|
write(request, Arc::new(warm_ctx)).await;
|
|
let after = trace_aux_sequences(&standalone, &table_name).await;
|
|
assert!(after[0] > before[0]);
|
|
assert_eq!(after[1..], before[1..], "v{version}");
|
|
}
|
|
}
|
|
|
|
async fn check_trace_aux_cache(batching: bool) {
|
|
let standalone = GreptimeDbStandaloneBuilder::new("trace_aux_cache")
|
|
.with_table_batcher(if batching {
|
|
BatcherOptions {
|
|
protocols: vec![BatchingProtocol::Otlp],
|
|
// Two main spans trigger a flush, while each auxiliary table
|
|
// has only one row and would wait an hour if batched.
|
|
pending_rows_flush_interval: Duration::from_secs(3600),
|
|
max_batch_rows: 2,
|
|
..Default::default()
|
|
}
|
|
} else {
|
|
BatcherOptions::default()
|
|
})
|
|
.build()
|
|
.await;
|
|
let instance = standalone.fe_instance();
|
|
let cloned_instance = Arc::new(instance.as_ref().clone());
|
|
|
|
// V2 bulk writes currently fail on Variant/Struct schema conversion
|
|
// before reaching auxiliary ingestion; cover V2 with direct main writes.
|
|
let max_version = if batching { 1 } else { 2 };
|
|
for version in 0..=max_version {
|
|
let table_name = format!("trace_aux_v{version}");
|
|
let mut context = QueryContext::with(DEFAULT_CATALOG_NAME, "public");
|
|
context.set_extension(
|
|
common_catalog::consts::TRACE_TABLE_NAME_SESSION_KEY,
|
|
&table_name,
|
|
);
|
|
context.set_extension(table::requests::TRACE_TABLE_PARTITIONS_HINT_KEY, "1");
|
|
context.set_batching_enabled(batching);
|
|
let ctx = Arc::new(context);
|
|
let mut request = trace_aux_request();
|
|
if batching {
|
|
let spans = &mut request.resource_spans[0].scope_spans[0].spans;
|
|
spans.push(spans[0].clone());
|
|
}
|
|
let accepted_spans = if batching { 2 } else { 1 };
|
|
let write = async |frontend: &Arc<Instance>, request| {
|
|
tokio::time::timeout(
|
|
Duration::from_secs(30),
|
|
frontend.traces(
|
|
frontend.clone(),
|
|
request,
|
|
match version {
|
|
0 => PipelineWay::OtlpTraceDirectV0,
|
|
1 => PipelineWay::OtlpTraceDirectV1,
|
|
_ => PipelineWay::OtlpTraceDirectV2,
|
|
},
|
|
GreptimePipelineParams::default(),
|
|
table_name.clone(),
|
|
ctx.clone(),
|
|
),
|
|
)
|
|
.await
|
|
.expect("auxiliary writes must not wait for the batch flush interval")
|
|
.unwrap()
|
|
};
|
|
|
|
// An incompatible auxiliary column fails after the main span is
|
|
// accepted. No candidate key may be cached from the failed batch.
|
|
instance
|
|
.do_query(
|
|
&format!(
|
|
"CREATE TABLE {table_name}_operations (\
|
|
\"timestamp\" TIMESTAMP(9) TIME INDEX, service_name STRING, \
|
|
span_name STRING, span_kind BIGINT)"
|
|
),
|
|
ctx.clone(),
|
|
)
|
|
.await
|
|
.remove(0)
|
|
.unwrap();
|
|
let failed_aux = write(instance, request.clone()).await;
|
|
assert_eq!(
|
|
(failed_aux.accepted_spans, failed_aux.rejected_spans),
|
|
(accepted_spans, 0)
|
|
);
|
|
assert!(
|
|
failed_aux
|
|
.error_message
|
|
.unwrap()
|
|
.contains("Auxiliary trace tables")
|
|
);
|
|
|
|
instance
|
|
.do_query(&format!("DROP TABLE {table_name}_operations"), ctx.clone())
|
|
.await
|
|
.remove(0)
|
|
.unwrap();
|
|
let retry = write(instance, request.clone()).await;
|
|
assert_eq!(
|
|
(retry.accepted_spans, retry.rejected_spans),
|
|
(accepted_spans, 0)
|
|
);
|
|
assert!(retry.error_message.is_none(), "{retry:?}");
|
|
let before = trace_aux_sequences(&standalone, &table_name).await;
|
|
assert!(before.iter().all(|sequence| *sequence > 0));
|
|
|
|
// Storage sequences, unlike row counts in an upsert table, prove
|
|
// the writes were skipped. Cloned Instances must share the cache.
|
|
let warm = write(&cloned_instance, request.clone()).await;
|
|
assert_eq!(
|
|
(warm.accepted_spans, warm.rejected_spans),
|
|
(accepted_spans, 0)
|
|
);
|
|
assert!(warm.error_message.is_none(), "{warm:?}");
|
|
let after = trace_aux_sequences(&standalone, &table_name).await;
|
|
assert!(after[0] > before[0]);
|
|
assert_eq!(after[1..], before[1..], "v{version}");
|
|
|
|
request.resource_spans[0].scope_spans[0].spans[0].name = "new_operation".into();
|
|
let new_operation = write(instance, request).await;
|
|
assert_eq!(
|
|
(new_operation.accepted_spans, new_operation.rejected_spans),
|
|
(accepted_spans, 0)
|
|
);
|
|
assert!(new_operation.error_message.is_none(), "{new_operation:?}");
|
|
let new_sequences = trace_aux_sequences(&standalone, &table_name).await;
|
|
assert!(new_sequences[0] > after[0]);
|
|
assert_eq!(new_sequences[1], after[1]);
|
|
assert!(new_sequences[2] > after[2]);
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn test_trace_v2_on_standalone() -> Result<(), Box<dyn std::error::Error>> {
|
|
let standalone = GreptimeDbStandaloneBuilder::new("trace_v2_standalone")
|
|
.build()
|
|
.await;
|
|
test_trace_v2(standalone.fe_instance()).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn test_trace_v2_on_distributed() -> Result<(), Box<dyn std::error::Error>> {
|
|
let distributed = tests::create_distributed_instance("trace_v2_distributed").await;
|
|
test_trace_v2(&distributed.frontend()).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn test_trace_v2(instance: &Arc<Instance>) -> Result<(), Box<dyn std::error::Error>> {
|
|
let mut context = QueryContext::with(DEFAULT_CATALOG_NAME, "public");
|
|
context.set_extension(
|
|
common_catalog::consts::TRACE_TABLE_NAME_SESSION_KEY,
|
|
"trace_v2",
|
|
);
|
|
let ctx = Arc::new(context);
|
|
let request: ExportTraceServiceRequest = serde_json::from_value(json!({
|
|
"resourceSpans": [{
|
|
"resource": {"attributes": [
|
|
{"key": "deployment.environment", "value": {"stringValue": "prod"}},
|
|
{"key": "cloud.region", "value": {"stringValue": "us-west"}},
|
|
{"key": "capacity", "value": {"intValue": "8"}}
|
|
]},
|
|
"scopeSpans": [{
|
|
"scope": {"attributes": [
|
|
{"key": "sample.rate", "value": {"doubleValue": 0.5}},
|
|
{"key": "enabled", "value": {"boolValue": true}},
|
|
{"key": "config", "value": {"kvlistValue": {"values": [
|
|
{"key": "batch_size", "value": {"intValue": "4"}}
|
|
]}}}
|
|
]},
|
|
"spans": [{
|
|
"traceId": "c05d7a4ec8e1f231f02ed6e8da8655b4",
|
|
"spanId": "9630f2916e2f7909", "name": "op", "kind": 2,
|
|
"startTimeUnixNano": "1736480942444376000",
|
|
"endTimeUnixNano": "1736480942444499000",
|
|
"events": [{
|
|
"timeUnixNano": "1736480942444400000",
|
|
"name": "cache.hit",
|
|
"attributes": [{"key": "event.code", "value": {"intValue": "7"}}]
|
|
}],
|
|
"links": [{
|
|
"traceId": "cc9e0991a2e63d274984bd44ee669203",
|
|
"spanId": "8f847259b0f6e1ab",
|
|
"traceState": "vendor=value",
|
|
"attributes": [{"key": "link.type", "value": {"stringValue": "follows_from"}}]
|
|
}],
|
|
"attributes": [
|
|
{"key": "http.status_code", "value": {"intValue": "200"}},
|
|
{"key": "latency", "value": {"doubleValue": 12.5}},
|
|
{"key": "empty"},
|
|
{"key": "bytes", "value": {"bytesValue": "AQID"}},
|
|
{"key": "nested", "value": {"kvlistValue": {"values": [
|
|
{"key": "a.b", "value": {"arrayValue": {"values": [{"boolValue": true}, {"stringValue": "ok"}]}}}
|
|
]}}}
|
|
]
|
|
}]
|
|
}]
|
|
}]
|
|
}))?;
|
|
let result = instance
|
|
.traces(
|
|
instance.clone(),
|
|
request.clone(),
|
|
PipelineWay::OtlpTraceDirectV2,
|
|
GreptimePipelineParams::default(),
|
|
"trace_v2".to_string(),
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
assert_eq!((result.accepted_spans, result.rejected_spans), (1, 0));
|
|
|
|
// Check the complete schema created by the first export, before later writes.
|
|
let output = instance
|
|
.do_query("SHOW CREATE TABLE trace_v2", ctx.clone())
|
|
.await;
|
|
let batches = match output
|
|
.into_iter()
|
|
.next()
|
|
.ok_or("Expected non-empty SQL output")??
|
|
.data
|
|
{
|
|
OutputData::Stream(stream) => RecordBatches::try_collect(stream).await?,
|
|
OutputData::RecordBatches(batches) => batches,
|
|
OutputData::AffectedRows(_) => return Err("Expected SHOW CREATE TABLE rows".into()),
|
|
};
|
|
let expected = r#"CREATE TABLE IF NOT EXISTS "trace_v2" (
|
|
"timestamp" TIMESTAMP(9) NOT NULL,
|
|
"timestamp_end" TIMESTAMP(9) NULL COMPRESSION WITH(encoding = 'delta_binary_packed', type = 'zstd'),
|
|
"duration_nano" BIGINT NULL,
|
|
"parent_span_id" STRING NULL SKIPPING INDEX WITH(false_positive_rate = '0.01', granularity = '10240', type = 'BLOOM') COMPRESSION WITH(encoding = 'plain', page_size = '64KiB', type = 'zstd'),
|
|
"trace_id" STRING NULL SKIPPING INDEX WITH(false_positive_rate = '0.01', granularity = '10240', type = 'BLOOM'),
|
|
"span_id" STRING NULL COMPRESSION WITH(encoding = 'plain', page_size = '64KiB', type = 'zstd'),
|
|
"span_kind" STRING NULL,
|
|
"span_name" STRING NULL,
|
|
"span_status_code" STRING NULL,
|
|
"span_status_message" STRING NULL,
|
|
"trace_state" STRING NULL,
|
|
"scope_name" STRING NULL,
|
|
"scope_version" STRING NULL,
|
|
"service_name" STRING NULL SKIPPING INDEX WITH(false_positive_rate = '0.01', granularity = '10240', type = 'BLOOM'),
|
|
"span_attributes" JSON2(
|
|
max_auto_expanded_paths = 100
|
|
) NULL,
|
|
"scope_attributes" JSON2(
|
|
max_auto_expanded_paths = 100
|
|
) NULL,
|
|
"resource_attributes" JSON2(
|
|
max_auto_expanded_paths = 100
|
|
) NULL,
|
|
"span_events" JSON NULL,
|
|
"span_links" JSON NULL,
|
|
TIME INDEX ("timestamp"),
|
|
PRIMARY KEY ("service_name")
|
|
)
|
|
PARTITION ON COLUMNS ("trace_id") (
|
|
trace_id < '1',
|
|
trace_id >= '1' AND trace_id < '2',
|
|
trace_id >= '2' AND trace_id < '3',
|
|
trace_id >= '3' AND trace_id < '4',
|
|
trace_id >= '4' AND trace_id < '5',
|
|
trace_id >= '5' AND trace_id < '6',
|
|
trace_id >= '6' AND trace_id < '7',
|
|
trace_id >= '7' AND trace_id < '8',
|
|
trace_id >= '8' AND trace_id < '9',
|
|
trace_id >= '9' AND trace_id < 'a',
|
|
trace_id >= 'a' AND trace_id < 'b',
|
|
trace_id >= 'b' AND trace_id < 'c',
|
|
trace_id >= 'c' AND trace_id < 'd',
|
|
trace_id >= 'd' AND trace_id < 'e',
|
|
trace_id >= 'e' AND trace_id < 'f',
|
|
trace_id >= 'f'
|
|
)
|
|
ENGINE=mito
|
|
WITH(
|
|
'comment' = 'Created on insertion',
|
|
append_mode = 'true',
|
|
'greptime.semantic.entity.service.id' = 'service_name',
|
|
'greptime.semantic.pipeline' = 'greptime_trace_v2',
|
|
'greptime.semantic.signal_type' = 'trace',
|
|
'greptime.semantic.source' = 'opentelemetry',
|
|
'greptime.semantic.trace.conventions' = 'unknown',
|
|
table_data_model = 'greptime_trace_v2'
|
|
)"#;
|
|
let batches = batches.take();
|
|
assert_eq!(batches.len(), 1);
|
|
assert_eq!(batches[0].num_rows(), 1);
|
|
assert_eq!(batches[0].num_columns(), 2);
|
|
assert_eq!(
|
|
batches[0].iter_column_as_string(1).collect::<Vec<_>>(),
|
|
vec![Some(expected.to_string())],
|
|
);
|
|
|
|
// First write has no service.name: it must still create the tag, but no auxiliary rows.
|
|
assert_trace_v2_query(
|
|
instance,
|
|
"SELECT COUNT(*) = 1
|
|
FROM trace_v2
|
|
WHERE service_name IS NULL",
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
assert_trace_v2_query(
|
|
instance,
|
|
"SELECT COUNT(*) = 19
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'trace_v2'",
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
assert_trace_v2_query(
|
|
instance,
|
|
"SELECT COUNT(*) = 0
|
|
FROM information_schema.tables
|
|
WHERE table_name IN ('trace_v2_services', 'trace_v2_operations')",
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
assert_trace_v2_query(
|
|
instance,
|
|
r#"SELECT COUNT(*) = 1
|
|
FROM trace_v2
|
|
WHERE span_attributes."http.status_code"::BIGINT = 200
|
|
AND span_attributes.empty::STRING IS NULL
|
|
AND span_attributes.bytes[1]::BIGINT = 2
|
|
AND span_attributes.nested."a.b"[0]::BOOLEAN = true"#,
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
|
|
// Exercise each JSON2 column and expressions combining columns, before and after flush.
|
|
let event_time =
|
|
common_time::Timestamp::new_nanosecond(1736480942444400000).to_iso8601_string();
|
|
let event_query = format!(
|
|
r#"SELECT COUNT(*) > 0 AND COUNT(*) = COUNT(CASE
|
|
WHEN json_get_string(span_events, '$[0].name') = 'cache.hit'
|
|
AND json_get_string(span_events, '$[0].time') = '{event_time}'
|
|
AND json_get_int(span_events, '$[0].attributes."event.code"') = 7
|
|
AND json_get_string(span_links, '$[0].trace_id') = 'cc9e0991a2e63d274984bd44ee669203'
|
|
AND json_get_string(span_links, '$[0].span_id') = '8f847259b0f6e1ab'
|
|
AND json_get_string(span_links, '$[0].trace_state') = 'vendor=value'
|
|
AND json_get_string(span_links, '$[0].attributes."link.type"') = 'follows_from'
|
|
THEN 1 END)
|
|
FROM trace_v2"#
|
|
);
|
|
let json_queries = [
|
|
event_query.as_str(),
|
|
r#"SELECT COUNT(*) > 0 AND COUNT(*) = COUNT(CASE
|
|
WHEN span_attributes.latency::DOUBLE * 2 = 25
|
|
AND upper(span_attributes.nested."a.b"[1]::STRING) = 'OK'
|
|
AND span_attributes.missing::STRING IS NULL
|
|
THEN 1 END)
|
|
FROM trace_v2"#,
|
|
r#"SELECT COUNT(*) > 0 AND COUNT(*) = COUNT(CASE
|
|
WHEN scope_attributes."sample.rate"::DOUBLE + 0.25 = 0.75
|
|
AND scope_attributes.enabled::BOOLEAN
|
|
AND scope_attributes.config.batch_size::BIGINT * 2 = 8
|
|
THEN 1 END)
|
|
FROM trace_v2"#,
|
|
r#"SELECT COUNT(*) > 0 AND COUNT(*) = COUNT(CASE
|
|
WHEN upper(resource_attributes."cloud.region"::STRING) = 'US-WEST'
|
|
AND resource_attributes.capacity::BIGINT - 2 = 6
|
|
THEN 1 END)
|
|
FROM trace_v2"#,
|
|
r#"SELECT AVG(span_attributes.latency::DOUBLE
|
|
* scope_attributes."sample.rate"::DOUBLE) = 6.25
|
|
AND SUM(resource_attributes.capacity::BIGINT)
|
|
= SUM(scope_attributes.config.batch_size::BIGINT) * 2
|
|
FROM trace_v2"#,
|
|
];
|
|
for sql in json_queries {
|
|
assert_trace_v2_query(instance, sql, ctx.clone()).await?;
|
|
}
|
|
|
|
let mut mixed = request.clone();
|
|
mixed.resource_spans[0]
|
|
.resource
|
|
.as_mut()
|
|
.ok_or("Expected non-empty resource in trace fixture")?
|
|
.attributes[0]
|
|
.key = "service.name".to_string();
|
|
let spans = &mut mixed.resource_spans[0].scope_spans[0].spans;
|
|
// Reject one span in the first chunk, merge its healthy rows, then write the next chunk.
|
|
spans.resize(514, spans[0].clone());
|
|
for (index, span) in spans.iter_mut().enumerate() {
|
|
span.span_id = (index as u64).to_be_bytes().to_vec();
|
|
}
|
|
spans[0].attributes[0]
|
|
.value
|
|
.as_mut()
|
|
.ok_or("Expected non-empty attribute value in trace fixture")?
|
|
.value = Some(
|
|
opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue("ok".to_string()),
|
|
);
|
|
spans[511].end_time_unix_nano = u64::MAX;
|
|
let result = instance
|
|
.traces(
|
|
instance.clone(),
|
|
mixed,
|
|
PipelineWay::OtlpTraceDirectV2,
|
|
GreptimePipelineParams::default(),
|
|
"trace_v2".to_string(),
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
assert_eq!((result.accepted_spans, result.rejected_spans), (513, 1));
|
|
assert!(
|
|
result
|
|
.error_message
|
|
.as_deref()
|
|
.is_some_and(|s| s.contains("Timestamp overflow"))
|
|
);
|
|
assert_trace_v2_query(
|
|
instance,
|
|
"SELECT COUNT(*) = 514
|
|
FROM trace_v2",
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
assert_trace_v2_query(
|
|
instance,
|
|
r#"SELECT COUNT(*) = 1
|
|
FROM trace_v2
|
|
WHERE span_attributes."http.status_code"::STRING = 'ok'"#,
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
assert_trace_v2_query(
|
|
instance,
|
|
"SELECT COUNT(*) = 1
|
|
FROM trace_v2_services
|
|
WHERE service_name = 'prod'",
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
assert_trace_v2_query(
|
|
instance,
|
|
"SELECT COUNT(*) = 1
|
|
FROM trace_v2_operations
|
|
WHERE service_name = 'prod'",
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let mut invalid = request.clone();
|
|
invalid.resource_spans[0].scope_spans[0].spans[0].start_time_unix_nano = u64::MAX;
|
|
let result = instance
|
|
.traces(
|
|
instance.clone(),
|
|
invalid,
|
|
PipelineWay::OtlpTraceDirectV2,
|
|
GreptimePipelineParams::default(),
|
|
"trace_v2".to_string(),
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
assert_eq!((result.accepted_spans, result.rejected_spans), (0, 1));
|
|
|
|
// A v1 request must fail before auto-ALTER can flatten attributes into this table.
|
|
let error = instance
|
|
.traces(
|
|
instance.clone(),
|
|
request.clone(),
|
|
PipelineWay::OtlpTraceDirectV1,
|
|
GreptimePipelineParams::default(),
|
|
"trace_v2".to_string(),
|
|
ctx.clone(),
|
|
)
|
|
.await
|
|
.err()
|
|
.ok_or("Expected mixed trace models to be rejected")?;
|
|
let error = format!("{error:?}");
|
|
assert!(
|
|
error.contains("Trace table `trace_v2` uses greptime_trace_v2, but the request uses greptime_trace_v1"),
|
|
"{error}",
|
|
);
|
|
assert_trace_v2_query(
|
|
instance,
|
|
"SELECT COUNT(*) = 19
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'trace_v2'",
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
|
|
let mut context = (*ctx).clone();
|
|
context.set_extension(
|
|
common_catalog::consts::TRACE_TABLE_NAME_SESSION_KEY,
|
|
"trace_v1",
|
|
);
|
|
let v1_ctx = Arc::new(context);
|
|
// Create the v1 table with an explicit service identity for the isolation check.
|
|
let mut v1_request = request.clone();
|
|
v1_request.resource_spans[0]
|
|
.resource
|
|
.as_mut()
|
|
.ok_or("Expected non-empty resource in trace fixture")?
|
|
.attributes[0]
|
|
.key = "service.name".to_string();
|
|
let result = instance
|
|
.traces(
|
|
instance.clone(),
|
|
v1_request,
|
|
PipelineWay::OtlpTraceDirectV1,
|
|
GreptimePipelineParams::default(),
|
|
"trace_v1".to_string(),
|
|
v1_ctx.clone(),
|
|
)
|
|
.await?;
|
|
assert_eq!(result.accepted_spans, 1);
|
|
let error = instance
|
|
.traces(
|
|
instance.clone(),
|
|
request.clone(),
|
|
PipelineWay::OtlpTraceDirectV2,
|
|
GreptimePipelineParams::default(),
|
|
"trace_v1".to_string(),
|
|
v1_ctx,
|
|
)
|
|
.await
|
|
.err()
|
|
.ok_or("Expected mixed trace models to be rejected")?;
|
|
let error = format!("{error:?}");
|
|
assert!(
|
|
error.contains("Trace table `trace_v1` uses greptime_trace_v1, but the request uses greptime_trace_v2"),
|
|
"{error}",
|
|
);
|
|
|
|
let output = instance
|
|
.do_query("ADMIN FLUSH_TABLE('trace_v2')", ctx.clone())
|
|
.await;
|
|
assert!(output.into_iter().all(|r| r.is_ok()));
|
|
assert_trace_v2_query(
|
|
instance,
|
|
r#"SELECT COUNT(*) = 513
|
|
FROM trace_v2
|
|
WHERE span_attributes."http.status_code"::BIGINT = 200"#,
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
for sql in json_queries {
|
|
assert_trace_v2_query(instance, sql, ctx.clone()).await?;
|
|
}
|
|
// Empty events and links are stored as JSON arrays in the same 19-column table.
|
|
let mut empty_request = request;
|
|
let empty_span = &mut empty_request.resource_spans[0].scope_spans[0].spans[0];
|
|
empty_span.events.clear();
|
|
empty_span.links.clear();
|
|
let result = instance
|
|
.traces(
|
|
instance.clone(),
|
|
empty_request,
|
|
PipelineWay::OtlpTraceDirectV2,
|
|
GreptimePipelineParams::default(),
|
|
"trace_v2".to_string(),
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
assert_eq!((result.accepted_spans, result.rejected_spans), (1, 0));
|
|
assert_trace_v2_query(
|
|
instance,
|
|
"SELECT COUNT(*) = 1 FROM trace_v2
|
|
WHERE json_to_string(span_events) = '[]' AND json_to_string(span_links) = '[]'",
|
|
ctx.clone(),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_trace_v2_query(
|
|
instance: &Arc<Instance>,
|
|
sql: &str,
|
|
ctx: Arc<QueryContext>,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let output = instance.do_query(sql, ctx).await;
|
|
let OutputData::Stream(stream) = output
|
|
.into_iter()
|
|
.next()
|
|
.ok_or("Expected non-empty SQL output")??
|
|
.data
|
|
else {
|
|
return Err(format!("Expected a query stream: {sql}").into());
|
|
};
|
|
let batches = RecordBatches::try_collect(stream).await?;
|
|
let batches = batches.take();
|
|
assert_eq!(batches.len(), 1, "{sql}");
|
|
assert_eq!(batches[0].num_rows(), 1, "{sql}");
|
|
assert_eq!(
|
|
batches[0].column(0).as_boolean().iter().collect::<Vec<_>>(),
|
|
vec![Some(true)],
|
|
"{sql}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn test_otlp_logical_batcher_non_millisecond_physical_table() {
|
|
// Microsecond and nanosecond physical tables must both use the
|
|
// logical batcher: requests are converted to the physical table's
|
|
// unit during batch alignment
|
|
// (<https://github.com/GreptimeTeam/greptimedb/issues/9342>).
|
|
run_otlp_logical_batcher_non_millisecond_physical_table(
|
|
"us",
|
|
"TIMESTAMP(6)",
|
|
ArrowTimeUnit::Microsecond,
|
|
[60_000_000, 120_000_000],
|
|
)
|
|
.await;
|
|
run_otlp_logical_batcher_non_millisecond_physical_table(
|
|
"ns",
|
|
"TIMESTAMP(9)",
|
|
ArrowTimeUnit::Nanosecond,
|
|
[60_000_000_000, 120_000_000_000],
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn run_otlp_logical_batcher_non_millisecond_physical_table(
|
|
suite: &str,
|
|
sql_ts_type: &str,
|
|
unit: ArrowTimeUnit,
|
|
expected: [i64; 2],
|
|
) {
|
|
use std::time::Duration;
|
|
|
|
use common_base::Plugins;
|
|
use datatypes::arrow::array::AsArray;
|
|
use datatypes::arrow::datatypes::{TimestampMicrosecondType, TimestampNanosecondType};
|
|
use frontend::server::Services;
|
|
use frontend::service_config::pending_rows_batcher::BatcherOptions;
|
|
use prost::Message;
|
|
use servers::batcher::BatchingProtocol;
|
|
use servers::http::test_helpers::TestClient;
|
|
use session::protocol_ctx::{OtlpMetricCtx, ProtocolCtx};
|
|
|
|
let standalone =
|
|
GreptimeDbStandaloneBuilder::new(&format!("otlp_logical_{suite}_physical"))
|
|
.with_logical_batcher(BatcherOptions {
|
|
protocols: vec![BatchingProtocol::Otlp],
|
|
pending_rows_flush_interval: Duration::from_millis(5),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.await;
|
|
let instance = standalone.fe_instance();
|
|
let options = standalone.opts.clone();
|
|
let services = Services::new(options.clone(), instance.clone(), Plugins::default());
|
|
let server = services
|
|
.http_server_builder(
|
|
&options.frontend_options(),
|
|
services.server_memory_limiter.clone(),
|
|
)
|
|
.build();
|
|
let client = TestClient::new(server.build(server.make_app()).unwrap()).await;
|
|
|
|
let mut ctx = QueryContext::with(DEFAULT_CATALOG_NAME, "public");
|
|
ctx.set_logical_batching_enabled(true);
|
|
ctx.set_protocol_ctx(ProtocolCtx::OtlpMetric(OtlpMetricCtx {
|
|
with_metric_engine: true,
|
|
..Default::default()
|
|
}));
|
|
let ctx = Arc::new(ctx);
|
|
|
|
// Pre-create the physical metric table with a non-millisecond time
|
|
// index BEFORE any ingestion, so the batcher's bulk path must
|
|
// handle it.
|
|
let mut output = instance
|
|
.do_query(
|
|
&format!(
|
|
"CREATE TABLE greptime_physical_table (\
|
|
greptime_timestamp {sql_ts_type} NOT NULL, \
|
|
greptime_value DOUBLE NULL, \
|
|
TIME INDEX (greptime_timestamp)) \
|
|
ENGINE = metric WITH ('physical_metric_table' = 'true')"
|
|
),
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
assert!(output.remove(0).is_ok());
|
|
|
|
// submit_build_and_align counts every batcher submission in both
|
|
// acknowledgement modes, so the assertion detects a regression that
|
|
// disables non-millisecond batching.
|
|
let submissions = servers::metrics::PENDING_ROWS_BATCH_INGEST_STAGE_ELAPSED
|
|
.with_label_values(&["submit_build_and_align"]);
|
|
let before = submissions.get_sample_count();
|
|
for (ts, value) in [(60, 10), (120, 20)] {
|
|
let request = build_sum_request(
|
|
"non.ms.alignment",
|
|
AggregationTemporality::Cumulative,
|
|
&[(ts, value)],
|
|
);
|
|
let response = client
|
|
.post("/v1/otlp/v1/metrics")
|
|
.header("content-type", "application/x-protobuf")
|
|
.body(request.encode_to_vec())
|
|
.send()
|
|
.await;
|
|
assert_eq!(response.status().as_u16(), 200);
|
|
}
|
|
assert_eq!(
|
|
submissions.get_sample_count() - before,
|
|
2,
|
|
"non-millisecond physical tables must use the logical batcher"
|
|
);
|
|
|
|
// The rows land on the non-millisecond physical table, converted
|
|
// from the nanosecond encoding.
|
|
let sql = "SELECT greptime_timestamp, greptime_value FROM non_ms_alignment_total ORDER BY greptime_timestamp";
|
|
let batches = tokio::time::timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
let output = instance.do_query(sql, ctx.clone()).await.remove(0).unwrap();
|
|
let OutputData::Stream(stream) = output.data else {
|
|
panic!("expected stream")
|
|
};
|
|
let batches = RecordBatches::try_collect(stream).await.unwrap();
|
|
if batches.iter().map(|batch| batch.num_rows()).sum::<usize>() == 2 {
|
|
break batches;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let batch = &batches.take()[0];
|
|
let stored = |row: usize| match unit {
|
|
ArrowTimeUnit::Microsecond => batch
|
|
.column(0)
|
|
.as_primitive::<TimestampMicrosecondType>()
|
|
.value(row),
|
|
ArrowTimeUnit::Nanosecond => batch
|
|
.column(0)
|
|
.as_primitive::<TimestampNanosecondType>()
|
|
.value(row),
|
|
_ => unreachable!("covered units only"),
|
|
};
|
|
assert_eq!(stored(0), expected[0]);
|
|
assert_eq!(stored(1), expected[1]);
|
|
let values = batch
|
|
.column(1)
|
|
.as_any()
|
|
.downcast_ref::<datatypes::arrow::array::Float64Array>()
|
|
.unwrap_or_else(|| panic!("expected f64 values"));
|
|
assert_eq!(values.value(0), 10.0);
|
|
assert_eq!(values.value(1), 20.0);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn test_otlp_logical_batcher_fallback_for_cross_physical_destination() {
|
|
use std::time::Duration;
|
|
|
|
use common_base::Plugins;
|
|
use datatypes::arrow::array::AsArray;
|
|
use datatypes::arrow::datatypes::TimestampMicrosecondType;
|
|
use frontend::server::Services;
|
|
use frontend::service_config::pending_rows_batcher::BatcherOptions;
|
|
use prost::Message;
|
|
use servers::batcher::BatchingProtocol;
|
|
use servers::http::test_helpers::TestClient;
|
|
use session::protocol_ctx::{OtlpMetricCtx, ProtocolCtx};
|
|
|
|
// With the logical batcher enabled, an OTLP request targeting an
|
|
// existing logical table bound to ANOTHER physical table must fall
|
|
// back to the ordinary insert path (the bulk eligibility check
|
|
// rejects the destination binding), which converts the request to
|
|
// the destination's unit. This covers the case previously guarded
|
|
// by the removed OTLP pre-gate alignment.
|
|
let standalone = GreptimeDbStandaloneBuilder::new("otlp_logical_cross_physical")
|
|
.with_logical_batcher(BatcherOptions {
|
|
protocols: vec![BatchingProtocol::Otlp],
|
|
pending_rows_flush_interval: Duration::from_millis(5),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.await;
|
|
let instance = standalone.fe_instance();
|
|
let options = standalone.opts.clone();
|
|
let services = Services::new(options.clone(), instance.clone(), Plugins::default());
|
|
let server = services
|
|
.http_server_builder(
|
|
&options.frontend_options(),
|
|
services.server_memory_limiter.clone(),
|
|
)
|
|
.build();
|
|
let client = TestClient::new(server.build(server.make_app()).unwrap()).await;
|
|
|
|
let mut ctx = QueryContext::with(DEFAULT_CATALOG_NAME, "public");
|
|
ctx.set_logical_batching_enabled(true);
|
|
ctx.set_protocol_ctx(ProtocolCtx::OtlpMetric(OtlpMetricCtx {
|
|
with_metric_engine: true,
|
|
..Default::default()
|
|
}));
|
|
let ctx = Arc::new(ctx);
|
|
|
|
// A physical table with a microsecond time index and a logical table
|
|
// bound to it; OTLP requests always select the default
|
|
// (millisecond) physical table, so the destination binding differs.
|
|
for sql in [
|
|
"CREATE TABLE phy_us (\
|
|
greptime_timestamp TIMESTAMP(6) NOT NULL, greptime_value DOUBLE NULL, \
|
|
TIME INDEX (greptime_timestamp)) \
|
|
ENGINE = metric WITH ('physical_metric_table' = 'true')",
|
|
"CREATE TABLE cross_alignment_total (\
|
|
greptime_timestamp TIMESTAMP(6) NOT NULL, greptime_value DOUBLE NULL, \
|
|
\"stream\" STRING NULL, TIME INDEX (greptime_timestamp), PRIMARY KEY (\"stream\")) \
|
|
ENGINE = metric WITH ('on_physical_table' = 'phy_us')",
|
|
] {
|
|
let mut output = instance.do_query(sql, ctx.clone()).await;
|
|
let result = output.remove(0);
|
|
assert!(result.is_ok(), "setup ddl failed: {result:?} — {sql}");
|
|
}
|
|
|
|
let submissions = servers::metrics::PENDING_ROWS_BATCH_INGEST_STAGE_ELAPSED
|
|
.with_label_values(&["submit_build_and_align"]);
|
|
let before = submissions.get_sample_count();
|
|
let request = build_sum_request(
|
|
"cross.alignment",
|
|
AggregationTemporality::Cumulative,
|
|
&[(60, 10)],
|
|
);
|
|
let response = client
|
|
.post("/v1/otlp/v1/metrics")
|
|
.header("content-type", "application/x-protobuf")
|
|
.body(request.encode_to_vec())
|
|
.send()
|
|
.await;
|
|
assert_eq!(response.status().as_u16(), 200);
|
|
// The request must NOT enter the logical batcher.
|
|
assert_eq!(
|
|
submissions.get_sample_count() - before,
|
|
0,
|
|
"cross-physical destinations must fall back to the ordinary insert path"
|
|
);
|
|
|
|
// The row lands on the microsecond logical table, converted from the
|
|
// nanosecond encoding: 60s -> 60_000_000us.
|
|
let sql = "SELECT greptime_timestamp, greptime_value FROM cross_alignment_total";
|
|
let batches = tokio::time::timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
let output = instance.do_query(sql, ctx.clone()).await.remove(0).unwrap();
|
|
let OutputData::Stream(stream) = output.data else {
|
|
panic!("expected stream")
|
|
};
|
|
let batches = RecordBatches::try_collect(stream).await.unwrap();
|
|
if batches.iter().map(|batch| batch.num_rows()).sum::<usize>() == 1 {
|
|
break batches;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let batch = &batches.take()[0];
|
|
let timestamps = batch.column(0).as_primitive::<TimestampMicrosecondType>();
|
|
assert_eq!(timestamps.value(0), 60_000_000);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn test_otlp_logical_batcher_alignment() {
|
|
use std::time::Duration;
|
|
|
|
use common_base::Plugins;
|
|
use frontend::server::Services;
|
|
use frontend::service_config::pending_rows_batcher::BatcherOptions;
|
|
use otel_arrow_rust::proto::opentelemetry::metrics::v1::{
|
|
ExponentialHistogram, ExponentialHistogramDataPoint, exponential_histogram_data_point,
|
|
};
|
|
use prost::Message;
|
|
use servers::batcher::{BatchingProtocol, pending_rows_batch_sync_enabled};
|
|
use servers::http::test_helpers::TestClient;
|
|
use session::protocol_ctx::{OtlpMetricCtx, ProtocolCtx};
|
|
|
|
// Fixed workload, only the batcher opt-in changes. Compare stored rows,
|
|
// schema evolution and visibility under the configured acknowledgement policy.
|
|
let mut results = Vec::new();
|
|
for enabled in [false, true] {
|
|
let standalone = GreptimeDbStandaloneBuilder::new(&format!("otlp_logical_{enabled}"))
|
|
.with_logical_batcher(BatcherOptions {
|
|
protocols: if enabled {
|
|
vec![BatchingProtocol::Otlp]
|
|
} else {
|
|
vec![]
|
|
},
|
|
pending_rows_flush_interval: Duration::from_millis(5),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.await;
|
|
let instance = standalone.fe_instance();
|
|
let options = standalone.opts.clone();
|
|
let services = Services::new(options.clone(), instance.clone(), Plugins::default());
|
|
let server = services
|
|
.http_server_builder(
|
|
&options.frontend_options(),
|
|
services.server_memory_limiter.clone(),
|
|
)
|
|
.build();
|
|
let client = TestClient::new(server.build(server.make_app()).unwrap()).await;
|
|
let mut ctx = QueryContext::with(DEFAULT_CATALOG_NAME, "public");
|
|
ctx.set_logical_batching_enabled(true);
|
|
ctx.set_protocol_ctx(ProtocolCtx::OtlpMetric(OtlpMetricCtx {
|
|
with_metric_engine: true,
|
|
..Default::default()
|
|
}));
|
|
let ctx = Arc::new(ctx);
|
|
let submissions = servers::metrics::PENDING_ROWS_BATCH_INGEST_STAGE_ELAPSED
|
|
.with_label_values(&["submit_wait_flush_result"]);
|
|
let before = submissions.get_sample_count();
|
|
for (ts, value) in [(60, 10), (120, 20)] {
|
|
let mut request = build_sum_request(
|
|
"batch.alignment",
|
|
AggregationTemporality::Cumulative,
|
|
&[(ts, value)],
|
|
);
|
|
if ts == 120
|
|
&& let Some(metric::Data::Sum(sum)) =
|
|
&mut request.resource_metrics[0].scope_metrics[0].metrics[0].data
|
|
{
|
|
sum.data_points[0].attributes.push(keyvalue("extra", "new"));
|
|
}
|
|
let response = client
|
|
.post("/v1/otlp/v1/metrics")
|
|
.header("content-type", "application/x-protobuf")
|
|
.body(request.encode_to_vec())
|
|
.send()
|
|
.await;
|
|
assert_eq!(response.status().as_u16(), 200);
|
|
}
|
|
if enabled {
|
|
assert_eq!(
|
|
submissions.get_sample_count() - before,
|
|
if pending_rows_batch_sync_enabled() {
|
|
2
|
|
} else {
|
|
0
|
|
},
|
|
"logical submissions must follow the global acknowledgement policy"
|
|
);
|
|
}
|
|
let sql = "SELECT greptime_timestamp, greptime_value, stream, extra FROM batch_alignment_total ORDER BY greptime_timestamp";
|
|
let batches = tokio::time::timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
let output = instance.do_query(sql, ctx.clone()).await.remove(0).unwrap();
|
|
let OutputData::Stream(stream) = output.data else {
|
|
panic!("expected stream")
|
|
};
|
|
let batches = RecordBatches::try_collect(stream).await.unwrap();
|
|
if !enabled
|
|
|| pending_rows_batch_sync_enabled()
|
|
|| batches.iter().map(|batch| batch.num_rows()).sum::<usize>() == 2
|
|
{
|
|
break batches;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
|
|
2
|
|
);
|
|
results.push(batches.pretty_print().unwrap());
|
|
|
|
let before_histograms = submissions.get_sample_count();
|
|
// First create a histogram table, then reuse it in a mixed export.
|
|
// Neither request may enter the scalar-only logical batcher.
|
|
for mixed in [false, true] {
|
|
let mut request = build_sum_request(
|
|
"batch.mixed",
|
|
AggregationTemporality::Cumulative,
|
|
&[(180, 30)],
|
|
);
|
|
let metrics = &mut request.resource_metrics[0].scope_metrics[0].metrics;
|
|
let mut histogram = metrics[0].clone();
|
|
histogram.name = "batch.histogram".to_string();
|
|
histogram.data = Some(metric::Data::ExponentialHistogram(ExponentialHistogram {
|
|
aggregation_temporality: AggregationTemporality::Cumulative as i32,
|
|
data_points: vec![ExponentialHistogramDataPoint {
|
|
start_time_unix_nano: 1_000_000_000,
|
|
time_unix_nano: if mixed { 4_000_000_000 } else { 3_000_000_000 },
|
|
count: 4,
|
|
sum: Some(8.0),
|
|
zero_count: 1,
|
|
positive: Some(exponential_histogram_data_point::Buckets {
|
|
offset: -1,
|
|
bucket_counts: vec![1, 2],
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
}));
|
|
if !mixed {
|
|
metrics.clear();
|
|
}
|
|
metrics.push(histogram);
|
|
let response = client
|
|
.post("/v1/otlp/v1/metrics")
|
|
.header("content-type", "application/x-protobuf")
|
|
.body(request.encode_to_vec())
|
|
.send()
|
|
.await;
|
|
assert_eq!(
|
|
response.status().as_u16(),
|
|
200,
|
|
"enabled={enabled}, mixed={mixed}"
|
|
);
|
|
}
|
|
assert_eq!(submissions.get_sample_count(), before_histograms);
|
|
for (sql, expected_rows) in [
|
|
(
|
|
"SELECT greptime_timestamp, greptime_native_histogram FROM batch_histogram ORDER BY greptime_timestamp",
|
|
2,
|
|
),
|
|
(
|
|
"SELECT greptime_timestamp, greptime_value FROM batch_mixed_total ORDER BY greptime_timestamp",
|
|
1,
|
|
),
|
|
] {
|
|
let output = instance.do_query(sql, ctx.clone()).await.remove(0).unwrap();
|
|
let OutputData::Stream(stream) = output.data else {
|
|
panic!("expected stream")
|
|
};
|
|
let batches = RecordBatches::try_collect(stream).await.unwrap();
|
|
assert_eq!(
|
|
batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
|
|
expected_rows
|
|
);
|
|
results.push(batches.pretty_print().unwrap());
|
|
}
|
|
|
|
// An existing metric attached to another physical table must use
|
|
// the original routing, not the request's default physical table.
|
|
for sql in [
|
|
"CREATE TABLE custom_physical (greptime_timestamp TIMESTAMP TIME INDEX, greptime_value DOUBLE) ENGINE=metric WITH ('physical_metric_table'='')",
|
|
"CREATE TABLE custom_total (greptime_timestamp TIMESTAMP(3) TIME INDEX, greptime_value DOUBLE, \"stream\" STRING PRIMARY KEY) ENGINE=metric WITH ('on_physical_table'='custom_physical')",
|
|
] {
|
|
instance.do_query(sql, ctx.clone()).await.remove(0).unwrap();
|
|
}
|
|
instance
|
|
.metrics(
|
|
build_sum_request("custom", AggregationTemporality::Cumulative, &[(60, 7)]),
|
|
ctx.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let output = instance
|
|
.do_query("SELECT greptime_value FROM custom_total", ctx.clone())
|
|
.await
|
|
.remove(0)
|
|
.unwrap();
|
|
let OutputData::Stream(stream) = output.data else {
|
|
panic!("expected stream")
|
|
};
|
|
assert!(
|
|
RecordBatches::try_collect(stream)
|
|
.await
|
|
.unwrap()
|
|
.pretty_print()
|
|
.unwrap()
|
|
.contains("7.0")
|
|
);
|
|
|
|
// Request-level schema policy cannot be bypassed by batching.
|
|
let mut fixed = ctx.fork();
|
|
fixed.set_extension("auto_create_table", "false");
|
|
assert!(
|
|
instance
|
|
.metrics(
|
|
build_sum_request(
|
|
"missing",
|
|
AggregationTemporality::Cumulative,
|
|
&[(60, 1)]
|
|
),
|
|
Arc::new(fixed)
|
|
)
|
|
.await
|
|
.is_err()
|
|
);
|
|
}
|
|
assert_eq!(results[..3], results[3..]);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
pub async fn test_otlp_on_standalone() {
|
|
let standalone = GreptimeDbStandaloneBuilder::new("test_standalone_otlp")
|
|
.build()
|
|
.await;
|
|
let instance = standalone.fe_instance();
|
|
|
|
test_otlp(instance).await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
pub async fn test_otlp_on_distributed() {
|
|
let instance = tests::create_distributed_instance("test_standalone_otlp").await;
|
|
|
|
test_otlp(&instance.frontend()).await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
pub async fn test_otlp_fixed_schema_rejects_missing_temporality_tag() {
|
|
let standalone = GreptimeDbStandaloneBuilder::new("test_otlp_fixed_schema")
|
|
.with_auto_create_table(false)
|
|
.build()
|
|
.await;
|
|
let instance = standalone.fe_instance();
|
|
let ctx = Arc::new(QueryContext::with(DEFAULT_CATALOG_NAME, "public"));
|
|
let mut output = instance
|
|
.do_query(
|
|
"CREATE TABLE fixed_delta_total (\
|
|
\"stream\" STRING, greptime_timestamp TIMESTAMP(3) NOT NULL, \
|
|
greptime_value DOUBLE, TIME INDEX (greptime_timestamp), \
|
|
PRIMARY KEY (\"stream\")) ENGINE=mito",
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
let result = output.remove(0);
|
|
assert!(result.is_ok(), "{result:?}");
|
|
|
|
let error = instance
|
|
.metrics(
|
|
build_sum_request("fixed.delta", AggregationTemporality::Delta, &[(60, 10)]),
|
|
ctx.clone(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(format!("{error:?}").contains("otlp_aggregation_temporality"));
|
|
|
|
let mut output = instance
|
|
.do_query("SELECT COUNT(*) FROM fixed_delta_total", ctx)
|
|
.await;
|
|
let OutputData::Stream(stream) = output.remove(0).unwrap().data else {
|
|
unreachable!()
|
|
};
|
|
assert!(
|
|
RecordBatches::try_collect(stream)
|
|
.await
|
|
.unwrap()
|
|
.pretty_print()
|
|
.unwrap()
|
|
.contains("| 0 |")
|
|
);
|
|
}
|
|
|
|
async fn test_otlp(instance: &Arc<Instance>) {
|
|
let req = build_request();
|
|
let db = "otlp";
|
|
let ctx = Arc::new(QueryContext::with(DEFAULT_CATALOG_NAME, db));
|
|
|
|
assert!(
|
|
SqlQueryHandler::do_query(
|
|
instance.as_ref(),
|
|
&format!("CREATE DATABASE IF NOT EXISTS {db}"),
|
|
ctx.clone(),
|
|
)
|
|
.await
|
|
.first()
|
|
.unwrap()
|
|
.is_ok()
|
|
);
|
|
|
|
let resp = instance.metrics(req, ctx.clone()).await;
|
|
assert!(resp.is_ok());
|
|
|
|
let mut output = instance
|
|
.do_query(
|
|
"CREATE TABLE raw_delta_mito_total (\
|
|
\"stream\" STRING, greptime_timestamp TIMESTAMP(3) NOT NULL, \
|
|
greptime_value DOUBLE, TIME INDEX (greptime_timestamp), \
|
|
PRIMARY KEY (\"stream\")) ENGINE=mito",
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
let result = output.remove(0);
|
|
assert!(result.is_ok(), "{result:?}");
|
|
|
|
for (metric, table) in [
|
|
("raw.delta", "raw_delta_total"),
|
|
("raw.delta.mito", "raw_delta_mito_total"),
|
|
] {
|
|
for request in [
|
|
build_sum_request(metric, AggregationTemporality::Cumulative, &[(60, 10)]),
|
|
build_sum_request(
|
|
metric,
|
|
AggregationTemporality::Delta,
|
|
&[(60, 10), (120, 20), (180, 15)],
|
|
),
|
|
build_sum_request(metric, AggregationTemporality::Cumulative, &[(180, 30)]),
|
|
] {
|
|
let result = instance.metrics(request, ctx.clone()).await;
|
|
assert!(result.is_ok(), "{metric}: {result:?}");
|
|
}
|
|
|
|
let mut output = instance
|
|
.do_query(
|
|
&format!(
|
|
"SELECT COALESCE(otlp_aggregation_temporality, '') AS temporality, \
|
|
COUNT(*) AS samples, SUM(greptime_value) AS total \
|
|
FROM {table} GROUP BY otlp_aggregation_temporality ORDER BY temporality"
|
|
),
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
let OutputData::Stream(stream) = output.remove(0).unwrap().data else {
|
|
unreachable!()
|
|
};
|
|
assert_eq!(
|
|
RecordBatches::try_collect(stream)
|
|
.await
|
|
.unwrap()
|
|
.pretty_print()
|
|
.unwrap(),
|
|
"\
|
|
+-------------+---------+-------+
|
|
| temporality | samples | total |
|
|
+-------------+---------+-------+
|
|
| | 2 | 40.0 |
|
|
| delta | 3 | 45.0 |
|
|
+-------------+---------+-------+"
|
|
);
|
|
|
|
for (function, expected) in [("increase", "45.0"), ("rate", "0.25")] {
|
|
let mut output = instance
|
|
.do_query(
|
|
&format!("TQL EVAL (180, 180, '1m') {function}({table}[3m])"),
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
let OutputData::Stream(stream) = output.remove(0).unwrap().data else {
|
|
unreachable!()
|
|
};
|
|
let rendered = RecordBatches::try_collect(stream)
|
|
.await
|
|
.unwrap()
|
|
.pretty_print()
|
|
.unwrap();
|
|
assert!(rendered.contains("delta"), "{rendered}");
|
|
assert!(rendered.contains(expected), "{rendered}");
|
|
}
|
|
|
|
let mut stale = build_sum_request(metric, AggregationTemporality::Delta, &[(240, 99)]);
|
|
let Some(metric::Data::Sum(sum)) = stale.resource_metrics[0].scope_metrics[0].metrics
|
|
[0]
|
|
.data
|
|
.as_mut() else {
|
|
unreachable!()
|
|
};
|
|
sum.data_points[0].flags = DataPointFlags::NoRecordedValueMask as u32;
|
|
assert!(instance.metrics(stale, ctx.clone()).await.is_ok());
|
|
|
|
for (matcher, expected_rows) in [
|
|
("otlp_aggregation_temporality=\"delta\"", 0),
|
|
("otlp_aggregation_temporality!=\"delta\"", 1),
|
|
] {
|
|
let mut output = instance
|
|
.do_query(
|
|
&format!("TQL EVAL (240, 240, '1m') {table}{{{matcher}}}"),
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
let OutputData::Stream(stream) = output.remove(0).unwrap().data else {
|
|
unreachable!()
|
|
};
|
|
let batches = RecordBatches::try_collect(stream).await.unwrap();
|
|
assert_eq!(
|
|
expected_rows,
|
|
batches.iter().map(|batch| batch.num_rows()).sum::<usize>()
|
|
);
|
|
if expected_rows == 1 {
|
|
assert!(batches.pretty_print().unwrap().contains("30.0"));
|
|
}
|
|
}
|
|
}
|
|
|
|
let malformed = ExportMetricsServiceRequest {
|
|
resource_metrics: vec![ResourceMetrics {
|
|
scope_metrics: vec![ScopeMetrics {
|
|
metrics: vec![Metric {
|
|
name: "rejected.delta.histogram".to_string(),
|
|
data: Some(metric::Data::Histogram(Histogram {
|
|
data_points: vec![HistogramDataPoint {
|
|
count: 1,
|
|
bucket_counts: vec![1],
|
|
explicit_bounds: vec![1.0, 2.0],
|
|
..Default::default()
|
|
}],
|
|
aggregation_temporality: AggregationTemporality::Delta as i32,
|
|
})),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
};
|
|
let outcome = instance.metrics(malformed, ctx.clone()).await.unwrap();
|
|
assert_eq!(0, outcome.accepted_data_points);
|
|
assert_eq!(1, outcome.rejected_data_points);
|
|
assert!(
|
|
outcome
|
|
.error_message
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("bucket_counts length")
|
|
);
|
|
let mut output = instance
|
|
.do_query(
|
|
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name IN \
|
|
('rejected_delta_histogram_bucket', 'rejected_delta_histogram_sum', \
|
|
'rejected_delta_histogram_count')",
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
let OutputData::Stream(stream) = output.remove(0).unwrap().data else {
|
|
unreachable!()
|
|
};
|
|
assert!(
|
|
RecordBatches::try_collect(stream)
|
|
.await
|
|
.unwrap()
|
|
.pretty_print()
|
|
.unwrap()
|
|
.contains("| 0 |")
|
|
);
|
|
|
|
let point = |stream: &str, seconds: u64, bounds: Vec<f64>, sum| HistogramDataPoint {
|
|
attributes: vec![keyvalue("stream", stream)],
|
|
time_unix_nano: seconds * 1_000_000_000,
|
|
count: u64::try_from(bounds.len() + 1).unwrap(),
|
|
sum,
|
|
bucket_counts: vec![1; bounds.len() + 1],
|
|
explicit_bounds: bounds,
|
|
..Default::default()
|
|
};
|
|
let tombstone = |stream: &str, seconds: u64, bounds: Vec<f64>, sum| HistogramDataPoint {
|
|
flags: DataPointFlags::NoRecordedValueMask as u32,
|
|
..point(stream, seconds, bounds, sum)
|
|
};
|
|
for points in [
|
|
vec![
|
|
point("same", 300, vec![1.0, 2.0], Some(4.0)),
|
|
tombstone("same", 360, vec![1.0, 2.0], None),
|
|
],
|
|
vec![
|
|
point("changed", 420, vec![3.0, 5.0], Some(8.0)),
|
|
tombstone("changed", 480, vec![1.0, 2.0], Some(99.0)),
|
|
],
|
|
vec![
|
|
point("boundless", 420, vec![3.0, 5.0], Some(8.0)),
|
|
tombstone("boundless", 480, vec![], None),
|
|
],
|
|
vec![tombstone("new", 480, vec![], None)],
|
|
] {
|
|
let outcome = instance
|
|
.metrics(
|
|
build_histogram_request("raw.delta.histogram", points),
|
|
ctx.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(0, outcome.rejected_data_points);
|
|
}
|
|
|
|
let mut output = instance
|
|
.do_query(
|
|
"TQL EVAL (300, 300, '1m') \
|
|
histogram_quantile(0.5, rate(raw_delta_histogram_bucket[2m]))",
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
let OutputData::Stream(stream) = output.remove(0).unwrap().data else {
|
|
unreachable!()
|
|
};
|
|
let rendered = RecordBatches::try_collect(stream)
|
|
.await
|
|
.unwrap()
|
|
.pretty_print()
|
|
.unwrap();
|
|
assert!(rendered.contains("1.5"), "{rendered}");
|
|
|
|
for (query, expected_rows) in [
|
|
("raw_delta_histogram_bucket{stream=\"same\",le=~\"1|2\"}", 0),
|
|
("raw_delta_histogram_sum{stream=\"same\"}", 1),
|
|
(
|
|
"raw_delta_histogram_bucket{stream=\"changed\",le=~\"3|5\"}",
|
|
2,
|
|
),
|
|
("raw_delta_histogram_sum{stream=\"changed\"}", 0),
|
|
(
|
|
"raw_delta_histogram_bucket{stream=\"boundless\",le=~\"3|5\"}",
|
|
2,
|
|
),
|
|
] {
|
|
let mut output = instance
|
|
.do_query(&format!("TQL EVAL (480, 480, '1m') {query}"), ctx.clone())
|
|
.await;
|
|
let OutputData::Stream(stream) = output.remove(0).unwrap().data else {
|
|
unreachable!()
|
|
};
|
|
let batches = RecordBatches::try_collect(stream).await.unwrap();
|
|
assert_eq!(
|
|
expected_rows,
|
|
batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
|
|
"{query}: {}",
|
|
batches.pretty_print().unwrap()
|
|
);
|
|
}
|
|
|
|
let mut output = instance
|
|
.do_query(
|
|
"SELECT \
|
|
(SELECT COUNT(*) FROM raw_delta_histogram_bucket WHERE \"stream\" = 'new') AS buckets, \
|
|
(SELECT COUNT(*) FROM raw_delta_histogram_count WHERE \"stream\" = 'new') AS counts, \
|
|
(SELECT COUNT(*) FROM raw_delta_histogram_sum WHERE \"stream\" = 'new') AS sums",
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
let OutputData::Stream(stream) = output.remove(0).unwrap().data else {
|
|
unreachable!()
|
|
};
|
|
let rendered = RecordBatches::try_collect(stream)
|
|
.await
|
|
.unwrap()
|
|
.pretty_print()
|
|
.unwrap();
|
|
assert!(
|
|
rendered.contains("| 1 | 1 | 0 |"),
|
|
"{rendered}"
|
|
);
|
|
|
|
let mut output = instance
|
|
.do_query(
|
|
"SELECT * FROM my_test_metric_my_ignored_unit ORDER BY greptime_timestamp",
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
let output = output.remove(0).unwrap();
|
|
let OutputData::Stream(stream) = output.data else {
|
|
unreachable!()
|
|
};
|
|
let recordbatches = RecordBatches::try_collect(stream).await.unwrap();
|
|
assert_eq!(
|
|
recordbatches.pretty_print().unwrap(),
|
|
"\
|
|
+----------------+---------------------+----------------+
|
|
| container_name | greptime_timestamp | greptime_value |
|
|
+----------------+---------------------+----------------+
|
|
| testserver | 1970-01-01T00:00:00 | 105.0 |
|
|
| testsevrer | 1970-01-01T00:00:00 | 100.0 |
|
|
+----------------+---------------------+----------------+",
|
|
);
|
|
|
|
let mut output = instance
|
|
.do_query(
|
|
"SELECT le, greptime_value FROM my_test_histo_my_ignored_unit_bucket order by le",
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
let output = output.remove(0).unwrap();
|
|
let OutputData::Stream(stream) = output.data else {
|
|
unreachable!()
|
|
};
|
|
let recordbatches = RecordBatches::try_collect(stream).await.unwrap();
|
|
assert_eq!(
|
|
recordbatches.pretty_print().unwrap(),
|
|
"\
|
|
+-----+----------------+
|
|
| le | greptime_value |
|
|
+-----+----------------+
|
|
| 1 | 1.0 |
|
|
| 5 | 3.0 |
|
|
| inf | 4.0 |
|
|
+-----+----------------+",
|
|
);
|
|
|
|
let mut output = instance
|
|
.do_query(
|
|
"SELECT * FROM my_test_histo_my_ignored_unit_sum",
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
let output = output.remove(0).unwrap();
|
|
let OutputData::Stream(stream) = output.data else {
|
|
unreachable!()
|
|
};
|
|
let recordbatches = RecordBatches::try_collect(stream).await.unwrap();
|
|
assert_eq!(
|
|
recordbatches.pretty_print().unwrap(),
|
|
"\
|
|
+------------+---------------------+----------------+
|
|
| host | greptime_timestamp | greptime_value |
|
|
+------------+---------------------+----------------+
|
|
| testserver | 1970-01-01T00:00:00 | 51.0 |
|
|
+------------+---------------------+----------------+",
|
|
);
|
|
|
|
let mut output = instance
|
|
.do_query(
|
|
"SELECT * FROM my_test_histo_my_ignored_unit_count",
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
let output = output.remove(0).unwrap();
|
|
let OutputData::Stream(stream) = output.data else {
|
|
unreachable!()
|
|
};
|
|
let recordbatches = RecordBatches::try_collect(stream).await.unwrap();
|
|
assert_eq!(
|
|
recordbatches.pretty_print().unwrap(),
|
|
"\
|
|
+------------+---------------------+----------------+
|
|
| host | greptime_timestamp | greptime_value |
|
|
+------------+---------------------+----------------+
|
|
| testserver | 1970-01-01T00:00:00 | 4.0 |
|
|
+------------+---------------------+----------------+",
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
pub async fn test_otlp_metrics_into_microsecond_physical_table_on_standalone() {
|
|
let standalone = GreptimeDbStandaloneBuilder::new("test_otlp_us_physical")
|
|
.build()
|
|
.await;
|
|
|
|
test_otlp_metrics_into_non_millisecond_physical_table(
|
|
standalone.fe_instance(),
|
|
"TIMESTAMP(6)",
|
|
TimeUnit::Microsecond,
|
|
1_704_067_200_123_456,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
pub async fn test_otlp_metrics_into_microsecond_physical_table_on_distributed() {
|
|
let instance = tests::create_distributed_instance("test_otlp_us_physical_dist").await;
|
|
|
|
test_otlp_metrics_into_non_millisecond_physical_table(
|
|
&instance.frontend(),
|
|
"TIMESTAMP(6)",
|
|
TimeUnit::Microsecond,
|
|
1_704_067_200_123_456,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
pub async fn test_otlp_metrics_into_seconds_physical_table_on_standalone() {
|
|
let standalone = GreptimeDbStandaloneBuilder::new("test_otlp_s_physical")
|
|
.build()
|
|
.await;
|
|
|
|
// Narrowing truncates: 1704067200123456789ns -> 1704067200s.
|
|
test_otlp_metrics_into_non_millisecond_physical_table(
|
|
standalone.fe_instance(),
|
|
"TIMESTAMP(0)",
|
|
TimeUnit::Second,
|
|
1_704_067_200,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
pub async fn test_otlp_metrics_into_nanoseconds_physical_table_on_standalone() {
|
|
let standalone = GreptimeDbStandaloneBuilder::new("test_otlp_ns_physical")
|
|
.build()
|
|
.await;
|
|
|
|
// Same unit: the full nanosecond precision is kept verbatim.
|
|
test_otlp_metrics_into_non_millisecond_physical_table(
|
|
standalone.fe_instance(),
|
|
"TIMESTAMP(9)",
|
|
TimeUnit::Nanosecond,
|
|
1_704_067_200_123_456_789,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Regression test for <https://github.com/GreptimeTeam/greptimedb/issues/9231>:
|
|
/// a physical metric table pre-created with a non-millisecond time index
|
|
/// accepts OTLP ingestion; the nanosecond samples are converted to the
|
|
/// physical table's unit (kept verbatim for a nanosecond table,
|
|
/// truncating the sub-unit part for micro/second tables).
|
|
async fn test_otlp_metrics_into_non_millisecond_physical_table(
|
|
instance: &Arc<Instance>,
|
|
sql_ts_type: &str,
|
|
expected_unit: TimeUnit,
|
|
expected_value: i64,
|
|
) {
|
|
let db = "otlp_non_ms_physical";
|
|
let mut ctx = QueryContext::with(DEFAULT_CATALOG_NAME, db);
|
|
// Route the request to the metric engine, like the OTLP HTTP handler
|
|
// does with the default `prom_store.with_metric_engine = true`.
|
|
ctx.set_protocol_ctx(ProtocolCtx::OtlpMetric(OtlpMetricCtx {
|
|
with_metric_engine: true,
|
|
..Default::default()
|
|
}));
|
|
let ctx = Arc::new(ctx);
|
|
assert!(
|
|
SqlQueryHandler::do_query(
|
|
instance.as_ref(),
|
|
&format!("CREATE DATABASE IF NOT EXISTS {db}"),
|
|
ctx.clone(),
|
|
)
|
|
.await
|
|
.first()
|
|
.unwrap()
|
|
.is_ok()
|
|
);
|
|
|
|
let mut output = instance
|
|
.do_query(
|
|
&format!(
|
|
"CREATE TABLE greptime_physical_table (\
|
|
greptime_timestamp {sql_ts_type} NOT NULL, \
|
|
greptime_value DOUBLE NULL, \
|
|
TIME INDEX (greptime_timestamp)) \
|
|
ENGINE = metric WITH ('physical_metric_table' = 'true')"
|
|
),
|
|
ctx.clone(),
|
|
)
|
|
.await;
|
|
assert!(output.remove(0).is_ok());
|
|
|
|
let request = ExportMetricsServiceRequest {
|
|
resource_metrics: vec![ResourceMetrics {
|
|
scope_metrics: vec![ScopeMetrics {
|
|
metrics: vec![Metric {
|
|
name: "my_gauge".to_string(),
|
|
data: Some(metric::Data::Gauge(Gauge {
|
|
data_points: vec![NumberDataPoint {
|
|
attributes: vec![keyvalue("host", "h1")],
|
|
time_unix_nano: 1_704_067_200_123_456_789,
|
|
value: Some(Value::AsDouble(1.0)),
|
|
..Default::default()
|
|
}],
|
|
})),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
};
|
|
instance.metrics(request, ctx.clone()).await.unwrap();
|
|
|
|
let mut output = instance
|
|
.do_query("SELECT greptime_timestamp FROM my_gauge", ctx.clone())
|
|
.await;
|
|
let OutputData::Stream(stream) = output.remove(0).unwrap().data else {
|
|
unreachable!()
|
|
};
|
|
let batches = RecordBatches::try_collect(stream).await.unwrap().take();
|
|
assert_eq!(batches[0].num_rows(), 1);
|
|
// The logical table's time index keeps the physical table's unit.
|
|
let ts_column = batches[0].column(0);
|
|
assert_eq!(
|
|
ts_column.data_type(),
|
|
&DataType::Timestamp(expected_unit, None),
|
|
"unexpected time index type"
|
|
);
|
|
let stored = match expected_unit {
|
|
TimeUnit::Second => ts_column.as_primitive::<TimestampSecondType>().value(0),
|
|
TimeUnit::Millisecond => ts_column
|
|
.as_primitive::<TimestampMillisecondType>()
|
|
.value(0),
|
|
TimeUnit::Microsecond => ts_column
|
|
.as_primitive::<TimestampMicrosecondType>()
|
|
.value(0),
|
|
TimeUnit::Nanosecond => ts_column.as_primitive::<TimestampNanosecondType>().value(0),
|
|
};
|
|
assert_eq!(stored, expected_value);
|
|
}
|
|
|
|
fn build_request() -> ExportMetricsServiceRequest {
|
|
let data_points = vec![
|
|
NumberDataPoint {
|
|
attributes: vec![keyvalue("container.name", "testsevrer")],
|
|
time_unix_nano: 100,
|
|
value: Some(Value::AsInt(100)),
|
|
..Default::default()
|
|
},
|
|
NumberDataPoint {
|
|
attributes: vec![keyvalue("container.name", "testserver")],
|
|
time_unix_nano: 105,
|
|
value: Some(Value::AsInt(105)),
|
|
..Default::default()
|
|
},
|
|
];
|
|
let gauge = Gauge { data_points };
|
|
|
|
let histo_data_points = vec![HistogramDataPoint {
|
|
attributes: vec![keyvalue("host", "testserver")],
|
|
time_unix_nano: 100,
|
|
count: 4,
|
|
bucket_counts: vec![1, 2, 1],
|
|
explicit_bounds: vec![1.0f64, 5.0f64],
|
|
sum: Some(51f64),
|
|
..Default::default()
|
|
}];
|
|
|
|
let histo = Histogram {
|
|
data_points: histo_data_points,
|
|
aggregation_temporality: 0,
|
|
};
|
|
|
|
ExportMetricsServiceRequest {
|
|
resource_metrics: vec![ResourceMetrics {
|
|
scope_metrics: vec![ScopeMetrics {
|
|
metrics: vec![
|
|
Metric {
|
|
name: "my.test.metric".into(),
|
|
description: "my ignored desc".into(),
|
|
unit: "my ignored unit".into(),
|
|
metadata: vec![],
|
|
data: Some(metric::Data::Gauge(gauge)),
|
|
},
|
|
Metric {
|
|
name: "my.test.histo".into(),
|
|
description: "my ignored desc".into(),
|
|
unit: "my ignored unit".into(),
|
|
metadata: vec![],
|
|
data: Some(metric::Data::Histogram(histo)),
|
|
},
|
|
],
|
|
scope: Some(InstrumentationScope {
|
|
attributes: vec![
|
|
keyvalue("scope", "otel"),
|
|
keyvalue("telemetry.sdk.name", "java"),
|
|
],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
resource: Some(Resource {
|
|
attributes: vec![keyvalue("resource", "greptimedb")],
|
|
dropped_attributes_count: 0,
|
|
entity_refs: vec![],
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn build_sum_request(
|
|
name: &str,
|
|
temporality: AggregationTemporality,
|
|
points: &[(u64, i64)],
|
|
) -> ExportMetricsServiceRequest {
|
|
let data_points = points
|
|
.iter()
|
|
.map(|(seconds, value)| NumberDataPoint {
|
|
attributes: vec![keyvalue("stream", "same")],
|
|
time_unix_nano: *seconds * 1_000_000_000,
|
|
value: Some(Value::AsInt(*value)),
|
|
..Default::default()
|
|
})
|
|
.collect();
|
|
ExportMetricsServiceRequest {
|
|
resource_metrics: vec![ResourceMetrics {
|
|
scope_metrics: vec![ScopeMetrics {
|
|
metrics: vec![Metric {
|
|
name: name.to_string(),
|
|
data: Some(metric::Data::Sum(Sum {
|
|
data_points,
|
|
aggregation_temporality: temporality as i32,
|
|
is_monotonic: true,
|
|
})),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn build_histogram_request(
|
|
name: &str,
|
|
points: Vec<HistogramDataPoint>,
|
|
) -> ExportMetricsServiceRequest {
|
|
ExportMetricsServiceRequest {
|
|
resource_metrics: vec![ResourceMetrics {
|
|
scope_metrics: vec![ScopeMetrics {
|
|
metrics: vec![Metric {
|
|
name: name.to_string(),
|
|
data: Some(metric::Data::Histogram(Histogram {
|
|
data_points: points,
|
|
aggregation_temporality: AggregationTemporality::Delta as i32,
|
|
})),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn keyvalue(key: &str, value: &str) -> KeyValue {
|
|
KeyValue {
|
|
key: key.into(),
|
|
value: Some(AnyValue {
|
|
value: Some(Val::StringValue(value.into())),
|
|
}),
|
|
}
|
|
}
|
|
}
|