From 8cf355b9a22a9aa2c9eeac2f5517b1abd75b4961 Mon Sep 17 00:00:00 2001
From: discord9 <55937128+discord9@users.noreply.github.com>
Date: Thu, 27 Aug 2026 14:21:49 +0800
Subject: [PATCH] feat: stabilize streaming analyze metrics
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
---
config/config.md | 2 -
config/frontend.example.toml | 2 -
config/standalone.example.toml | 2 -
src/client/src/region.rs | 26 ++
src/common/recordbatch/src/adapter.rs | 85 +++++-
src/frontend/src/instance.rs | 82 ++++--
src/query/src/dist_plan/merge_scan.rs | 25 +-
src/servers/src/grpc/flight/stream.rs | 11 +
src/servers/src/http.rs | 76 +++---
src/servers/src/http/handler.rs | 288 ++++++++++++++------
src/servers/src/query_handler/sql.rs | 16 +-
src/servers/tests/http/http_handler_test.rs | 254 ++++++++++++++---
tests-integration/tests/http.rs | 57 +++-
13 files changed, 709 insertions(+), 217 deletions(-)
diff --git a/config/config.md b/config/config.md
index 1b18565cc08..111159664f3 100644
--- a/config/config.md
+++ b/config/config.md
@@ -32,7 +32,6 @@
| `http.body_limit` | String | `64MB` | HTTP request body limit.
The following units are supported: `B`, `KB`, `KiB`, `MB`, `MiB`, `GB`, `GiB`, `TB`, `TiB`, `PB`, `PiB`.
Set to 0 to disable limit. |
| `http.enable_cors` | Bool | `true` | HTTP CORS support, it's turned on by default
This allows browser to access http APIs without CORS restrictions |
| `http.cors_allowed_origins` | Array | Unset | Customize allowed origins for HTTP CORS. |
-| `http.experimental_enable_explain_analyze_stream` | Bool | `true` | Experimental: enable POST /v1/sql/analyze/stream for streaming EXPLAIN ANALYZE VERBOSE metrics. |
| `http.enable_api_server` | Bool | `false` | Whether to start the dedicated public HTTP **API** server. This server serves
only the `v1` interfaces plus the dashboard, and shares every other `[http]`
option with the main server. It is disabled by default; set to `true` to enable. |
| `http.api_server_addr` | String | `127.0.0.1:4006` | The address to bind the dedicated HTTP API server, in the same form as `addr`.
Defaults to `127.0.0.1:4006`. |
| `grpc` | -- | -- | The gRPC server options. |
@@ -262,7 +261,6 @@
| `http.body_limit` | String | `64MB` | HTTP request body limit.
The following units are supported: `B`, `KB`, `KiB`, `MB`, `MiB`, `GB`, `GiB`, `TB`, `TiB`, `PB`, `PiB`.
Set to 0 to disable limit. |
| `http.enable_cors` | Bool | `true` | HTTP CORS support, it's turned on by default
This allows browser to access http APIs without CORS restrictions |
| `http.cors_allowed_origins` | Array | Unset | Customize allowed origins for HTTP CORS. |
-| `http.experimental_enable_explain_analyze_stream` | Bool | `true` | Experimental: enable POST /v1/sql/analyze/stream for streaming EXPLAIN ANALYZE VERBOSE metrics. |
| `http.enable_api_server` | Bool | `false` | Whether to start the dedicated public HTTP **API** server. This server serves
only the `v1` interfaces plus the dashboard, and shares every other `[http]`
option with the main server. It is disabled by default; set to `true` to enable. |
| `http.api_server_addr` | String | `127.0.0.1:4006` | The address to bind the dedicated HTTP API server, in the same form as `addr`.
Defaults to `127.0.0.1:4006`. |
| `grpc` | -- | -- | The gRPC server options. |
diff --git a/config/frontend.example.toml b/config/frontend.example.toml
index ec1f9c89b42..64bd6c2825e 100644
--- a/config/frontend.example.toml
+++ b/config/frontend.example.toml
@@ -65,8 +65,6 @@ enable_cors = true
## Customize allowed origins for HTTP CORS.
## @toml2docs:none-default
cors_allowed_origins = ["https://example.com"]
-## Experimental: enable POST /v1/sql/analyze/stream for streaming EXPLAIN ANALYZE VERBOSE metrics.
-experimental_enable_explain_analyze_stream = true
## Whether to start the dedicated public HTTP **API** server. This server serves
## only the `v1` interfaces plus the dashboard, and shares every other `[http]`
diff --git a/config/standalone.example.toml b/config/standalone.example.toml
index 5ada605b92b..5eae52b7cd5 100644
--- a/config/standalone.example.toml
+++ b/config/standalone.example.toml
@@ -79,8 +79,6 @@ enable_cors = true
## @toml2docs:none-default
cors_allowed_origins = ["https://example.com"]
-## Experimental: enable POST /v1/sql/analyze/stream for streaming EXPLAIN ANALYZE VERBOSE metrics.
-experimental_enable_explain_analyze_stream = true
## Whether to start the dedicated public HTTP **API** server. This server serves
## only the `v1` interfaces plus the dashboard, and shares every other `[http]`
diff --git a/src/client/src/region.rs b/src/client/src/region.rs
index f1730a93c8e..aeb34c7bb43 100644
--- a/src/client/src/region.rs
+++ b/src/client/src/region.rs
@@ -525,6 +525,32 @@ mod test {
assert_eq!(metrics.elapsed_compute, 7);
}
+ #[tokio::test]
+ async fn test_record_batch_stream_captures_final_metrics_after_record_batch() {
+ let schema = test_schema();
+ let batch = RecordBatch::new(
+ schema.clone(),
+ vec![Arc::new(Int32Vector::from_slice([1])) as VectorRef],
+ )
+ .unwrap();
+ let final_metrics = serde_json::to_string(&RecordBatchMetrics {
+ elapsed_compute: 99,
+ ..Default::default()
+ })
+ .unwrap();
+ let mut recordbatches = recordbatches_from_flight_message_stream(stream::iter(vec![
+ Ok(FlightMessage::Schema(schema.arrow_schema().clone())),
+ Ok(FlightMessage::RecordBatch(batch.into_df_record_batch())),
+ Ok(FlightMessage::Metrics(final_metrics)),
+ ]))
+ .await
+ .unwrap();
+
+ assert_eq!(recordbatches.next().await.unwrap().unwrap().num_rows(), 1);
+ assert!(recordbatches.next().await.is_none());
+ assert_eq!(recordbatches.metrics().unwrap().elapsed_compute, 99);
+ }
+
#[tokio::test]
async fn test_record_batch_stream_exposes_error_after_pre_batch_metrics() {
let schema = test_schema();
diff --git a/src/common/recordbatch/src/adapter.rs b/src/common/recordbatch/src/adapter.rs
index eeac94904be..ca9619c013a 100644
--- a/src/common/recordbatch/src/adapter.rs
+++ b/src/common/recordbatch/src/adapter.rs
@@ -327,7 +327,9 @@ impl RecordBatchStreamAdapter {
query_load_region_id: Option,
) -> RecordBatchMetrics {
if explain_verbose {
- collect_full_metrics(df_plan, explain_verbose, query_load_region_id)
+ // Verbose in-progress snapshots have the same complete topology as
+ // the final snapshot, but use compact (Default) plan formatting.
+ collect_full_metrics(df_plan, false, query_load_region_id)
} else {
collect_lightweight_query_load_metrics(df_plan, query_load_region_id)
}
@@ -479,13 +481,17 @@ impl RecordBatchStream for RecordBatchStreamAdapter {
match &self.metrics_2 {
Metrics::Unresolved(df_plan) => {
if self.explain_verbose {
- Some(self.collect_plan_metrics(df_plan))
+ Some(Self::collect_partial_metrics(
+ df_plan.as_ref(),
+ true,
+ self.query_load_region_id,
+ ))
} else {
None
}
}
Metrics::PartialResolved(df_plan, metrics) => Some(if self.explain_verbose {
- self.collect_plan_metrics(df_plan)
+ Self::collect_partial_metrics(df_plan.as_ref(), true, self.query_load_region_id)
} else {
metrics.clone()
}),
@@ -954,6 +960,7 @@ mod test {
struct TestMetricsExec {
properties: Arc,
metrics: ExecutionPlanMetricsSet,
+ format_plan: bool,
}
impl TestMetricsExec {
@@ -961,7 +968,19 @@ mod test {
Self::with_output_bytes(schema, &[24])
}
+ fn new_without_plan_formatting(schema: DfSchemaRef) -> Self {
+ Self::with_output_bytes_and_formatting(schema, &[24], false)
+ }
+
fn with_output_bytes(schema: DfSchemaRef, output_bytes_by_partition: &[usize]) -> Self {
+ Self::with_output_bytes_and_formatting(schema, output_bytes_by_partition, true)
+ }
+
+ fn with_output_bytes_and_formatting(
+ schema: DfSchemaRef,
+ output_bytes_by_partition: &[usize],
+ format_plan: bool,
+ ) -> Self {
let metrics = ExecutionPlanMetricsSet::new();
let elapsed_compute = MetricBuilder::new(&metrics).elapsed_compute(0);
elapsed_compute.add_duration(Duration::from_nanos(42));
@@ -978,13 +997,22 @@ mod test {
Boundedness::Bounded,
)),
metrics,
+ format_plan,
}
}
}
impl DisplayAs for TestMetricsExec {
- fn fmt_as(&self, _t: DisplayFormatType, _f: &mut std::fmt::Formatter) -> std::fmt::Result {
- panic!("non-verbose lightweight partial metrics must not format the plan")
+ fn fmt_as(&self, t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ assert!(
+ self.format_plan,
+ "non-verbose lightweight partial metrics must not format the plan"
+ );
+ write!(f, "RegionScanExec")?;
+ if matches!(t, DisplayFormatType::Verbose) {
+ write!(f, ": files=[file-1.parquet]")?;
+ }
+ Ok(())
}
}
@@ -1070,7 +1098,9 @@ mod test {
futures::stream::iter(vec![Ok(batch1), Ok(batch2)]),
),
);
- let plan = Arc::new(TestMetricsExec::new(schema.arrow_schema().clone()));
+ let plan = Arc::new(TestMetricsExec::new_without_plan_formatting(
+ schema.arrow_schema().clone(),
+ ));
let mut adapter = RecordBatchStreamAdapter::try_new(df_stream).unwrap();
adapter.set_metrics2(plan);
@@ -1087,12 +1117,49 @@ mod test {
assert_eq!(metrics.plan_metrics.len(), 1);
assert_eq!(metrics.plan_metrics[0].plan, REGION_SCAN_EXEC_NAME);
assert_eq!(metrics.plan_metrics[0].plan_name, REGION_SCAN_EXEC_NAME);
- assert_eq!(
- metrics.plan_metrics[0].metrics,
- vec![("output_bytes".to_string(), 24)]
+ assert!(
+ metrics.plan_metrics[0]
+ .metrics
+ .iter()
+ .any(|(name, value)| name == "output_bytes" && *value == 24)
);
}
+ #[tokio::test]
+ async fn test_record_batch_stream_adapter_uses_compact_partial_and_verbose_final_metrics() {
+ let schema = Arc::new(Schema::new(vec![ColumnSchema::new(
+ "a",
+ ConcreteDataType::int32_datatype(),
+ false,
+ )]));
+ let batch = RecordBatch::new(
+ schema.clone(),
+ vec![Arc::new(Int32Vector::from_slice([1])) as _],
+ )
+ .unwrap()
+ .into_df_record_batch();
+ let df_stream = Box::pin(
+ datafusion::physical_plan::stream::RecordBatchStreamAdapter::new(
+ schema.arrow_schema().clone(),
+ futures::stream::iter(vec![Ok(batch)]),
+ ),
+ );
+ let plan = Arc::new(TestMetricsExec::new(schema.arrow_schema().clone()));
+ let mut adapter = RecordBatchStreamAdapter::try_new(df_stream).unwrap();
+ adapter.set_metrics2(plan);
+ adapter.set_explain_verbose(true);
+
+ adapter.next().await.unwrap().unwrap();
+ let partial = adapter.metrics().unwrap();
+ assert_eq!(partial.plan_metrics.len(), 1);
+ assert_eq!(partial.plan_metrics[0].plan.trim_end(), "RegionScanExec");
+ assert!(!partial.plan_metrics[0].plan.contains("files"));
+
+ assert!(adapter.next().await.is_none());
+ let final_metrics = adapter.metrics().unwrap();
+ assert!(final_metrics.plan_metrics[0].plan.contains("files"));
+ }
+
#[test]
fn test_record_batch_stream_adapter_reuses_partial_query_stats_on_drop() {
let schema = Arc::new(Schema::new(vec![ColumnSchema::new(
diff --git a/src/frontend/src/instance.rs b/src/frontend/src/instance.rs
index 78b701a3442..91b3b69397d 100644
--- a/src/frontend/src/instance.rs
+++ b/src/frontend/src/instance.rs
@@ -229,31 +229,52 @@ fn parse_stmt(sql: &str, dialect: &(dyn Dialect + Send + Sync)) -> Result bool {
matches!(stmt, Statement::Explain(explain) if explain.analyze && explain.verbose)
+ || matches!(stmt, Statement::Tql(Tql::Analyze(analyze)) if analyze.is_verbose)
}
fn validate_analyze_stream_statement(stmt: &mut Statement) -> Result<()> {
- let Statement::Explain(explain) = stmt else {
- return InvalidSqlSnafu {
- err_msg: "only EXPLAIN ANALYZE VERBOSE statement is supported",
+ match stmt {
+ Statement::Explain(explain) => {
+ ensure!(
+ explain.analyze && explain.verbose,
+ InvalidSqlSnafu {
+ err_msg: "statement must be EXPLAIN ANALYZE VERBOSE or TQL ANALYZE VERBOSE"
+ }
+ );
+ match explain.format {
+ None | Some(AnalyzeFormat::JSON) => {
+ // Keep explicit FORMAT JSON accepted, but pass JSON through
+ // QueryContext.explain_format instead of the statement to avoid
+ // the planner's current `EXPLAIN VERBOSE with FORMAT` limitation.
+ explain.format = None;
+ Ok(())
+ }
+ Some(_) => InvalidSqlSnafu {
+ err_msg: "only FORMAT JSON is supported for EXPLAIN ANALYZE VERBOSE or TQL ANALYZE VERBOSE",
+ }
+ .fail(),
+ }
}
- .fail();
- };
- ensure!(
- explain.analyze && explain.verbose,
- InvalidSqlSnafu {
- err_msg: "statement must be EXPLAIN ANALYZE VERBOSE"
+ Statement::Tql(Tql::Analyze(analyze)) => {
+ ensure!(
+ analyze.is_verbose,
+ InvalidSqlSnafu {
+ err_msg: "statement must be EXPLAIN ANALYZE VERBOSE or TQL ANALYZE VERBOSE"
+ }
+ );
+ match analyze.format {
+ None | Some(AnalyzeFormat::JSON) => {
+ analyze.format = None;
+ Ok(())
+ }
+ Some(_) => InvalidSqlSnafu {
+ err_msg: "only FORMAT JSON is supported for EXPLAIN ANALYZE VERBOSE or TQL ANALYZE VERBOSE",
+ }
+ .fail(),
+ }
}
- );
- match explain.format {
- None | Some(AnalyzeFormat::JSON) => {
- // Keep explicit FORMAT JSON accepted, but pass JSON through
- // QueryContext.explain_format instead of the statement to avoid the
- // planner's current `EXPLAIN VERBOSE with FORMAT` limitation.
- explain.format = None;
- Ok(())
- }
- Some(_) => InvalidSqlSnafu {
- err_msg: "only FORMAT JSON is supported for analyze stream",
+ _ => InvalidSqlSnafu {
+ err_msg: "only EXPLAIN ANALYZE VERBOSE or TQL ANALYZE VERBOSE statement is supported",
}
.fail(),
}
@@ -709,7 +730,7 @@ impl Instance {
ensure!(
stmts.len() == 1,
InvalidSqlSnafu {
- err_msg: "only single EXPLAIN ANALYZE VERBOSE statement is supported"
+ err_msg: "only a single EXPLAIN ANALYZE VERBOSE or TQL ANALYZE VERBOSE statement is supported"
}
);
let mut stmt = stmts.remove(0);
@@ -1775,6 +1796,9 @@ mod tests {
"explain analyze select 1",
"explain analyze verbose format text select 1",
"explain analyze verbose format graphviz select 1",
+ "TQL ANALYZE (0, 10, '5s') physical_metric",
+ "TQL EXPLAIN VERBOSE (0, 10, '5s') physical_metric",
+ "TQL ANALYZE VERBOSE FORMAT TEXT (0, 10, '5s') physical_metric",
] {
let mut stmts = parse_test_sql(sql);
assert!(
@@ -1786,16 +1810,19 @@ mod tests {
for sql in [
"explain analyze verbose select 1",
"explain analyze verbose format json select 1",
+ "TQL ANALYZE VERBOSE (0, 10, '5s') physical_metric",
+ "TQL ANALYZE VERBOSE FORMAT JSON (0, 10, '5s') physical_metric",
] {
let mut stmts = parse_test_sql(sql);
assert!(
validate_analyze_stream_statement(&mut stmts[0]).is_ok(),
"{sql}"
);
- let Statement::Explain(explain) = &stmts[0] else {
- unreachable!();
- };
- assert!(explain.format.is_none());
+ match &stmts[0] {
+ Statement::Explain(explain) => assert!(explain.format.is_none()),
+ Statement::Tql(Tql::Analyze(analyze)) => assert!(analyze.format.is_none()),
+ _ => unreachable!(),
+ }
}
assert_eq!(
@@ -1806,11 +1833,16 @@ mod tests {
assert!(is_explain_analyze_verbose(
&parse_test_sql("explain analyze verbose select 1")[0]
));
+ assert!(is_explain_analyze_verbose(
+ &parse_test_sql("TQL ANALYZE VERBOSE (0, 10, '5s') physical_metric")[0]
+ ));
for sql in [
"select 1",
"explain select 1",
"explain analyze select 1",
"explain verbose select 1",
+ "TQL ANALYZE (0, 10, '5s') physical_metric",
+ "TQL EXPLAIN VERBOSE (0, 10, '5s') physical_metric",
] {
assert!(
!is_explain_analyze_verbose(&parse_test_sql(sql)[0]),
diff --git a/src/query/src/dist_plan/merge_scan.rs b/src/query/src/dist_plan/merge_scan.rs
index 8d3580ea452..c91fa6b4022 100644
--- a/src/query/src/dist_plan/merge_scan.rs
+++ b/src/query/src/dist_plan/merge_scan.rs
@@ -754,6 +754,19 @@ impl MergeScanExec {
stream.next().instrument(region_span.clone()).await
};
let Some(batch) = batch else {
+ // The remote Flight stream publishes its terminal metrics
+ // immediately after EOF. Capture them before leaving the
+ // loop so the final verbose snapshot is not lost.
+ if let Some(metrics) = stream.metrics() {
+ let load = region_scan_load(&metrics);
+ let (c, s) = parse_catalog_and_schema_from_db_string(&dbname);
+ let value = read_meter!(c, s, load, current_channel as u8);
+ metric.record_greptime_exec_cost(value as usize);
+ sub_stage_metrics_moved
+ .lock()
+ .unwrap()
+ .insert(region_id, metrics);
+ }
break;
};
let poll_elapsed = poll_timer.elapsed();
@@ -823,18 +836,6 @@ impl MergeScanExec {
);
}
- // process metrics after all data is drained.
- if let Some(metrics) = stream.metrics() {
- let load = region_scan_load(&metrics);
- let (c, s) = parse_catalog_and_schema_from_db_string(&dbname);
- let value = read_meter!(c, s, load, current_channel as u8);
- metric.record_greptime_exec_cost(value as usize);
-
- // record metrics from sub sgates
- let mut sub_stage_metrics = sub_stage_metrics_moved.lock().unwrap();
- sub_stage_metrics.insert(region_id, metrics);
- }
-
MERGE_SCAN_POLL_ELAPSED.observe(poll_duration.as_secs_f64());
}
diff --git a/src/servers/src/grpc/flight/stream.rs b/src/servers/src/grpc/flight/stream.rs
index c222c72e1cd..b157e02bde4 100644
--- a/src/servers/src/grpc/flight/stream.rs
+++ b/src/servers/src/grpc/flight/stream.rs
@@ -576,6 +576,17 @@ mod test {
}
other => panic!("expected record batch after pending metrics, got {other:?}"),
}
+
+ drop(tx);
+ let final_metrics_data = tokio::time::timeout(Duration::from_secs(2), stream.next())
+ .await
+ .unwrap()
+ .unwrap()
+ .unwrap();
+ assert!(matches!(
+ decoder.try_decode(&final_metrics_data).unwrap().unwrap(),
+ FlightMessage::Metrics(_)
+ ));
}
#[tokio::test]
diff --git a/src/servers/src/http.rs b/src/servers/src/http.rs
index bf7478eacfd..db3d8a79c93 100644
--- a/src/servers/src/http.rs
+++ b/src/servers/src/http.rs
@@ -252,8 +252,6 @@ pub struct HttpOptions {
pub enable_cors: bool,
- pub experimental_enable_explain_analyze_stream: bool,
-
/// Whether to start the dedicated public HTTP **API** server, which serves
/// only the `v1` interfaces plus the dashboard. It shares every other
/// `[http]` option with the main server and only differs by its bound
@@ -273,7 +271,6 @@ impl Default for HttpOptions {
body_limit: DEFAULT_BODY_LIMIT,
cors_allowed_origins: Vec::new(),
enable_cors: true,
- experimental_enable_explain_analyze_stream: true,
enable_api_server: false,
api_server_addr: format!("127.0.0.1:{}", DEFAULT_HTTP_API_ADDR_PORT),
}
@@ -600,7 +597,6 @@ impl From for HttpResponse {
#[derive(Clone)]
pub struct ApiState {
pub sql_handler: ServerSqlQueryHandlerRef,
- pub experimental_enable_explain_analyze_stream: bool,
}
#[derive(Clone)]
@@ -637,12 +633,7 @@ impl HttpServerBuilder {
}
pub fn with_sql_handler(self, sql_handler: ServerSqlQueryHandlerRef) -> Self {
- let sql_router = HttpServer::route_sql(ApiState {
- sql_handler,
- experimental_enable_explain_analyze_stream: self
- .options
- .experimental_enable_explain_analyze_stream,
- });
+ let sql_router = HttpServer::route_sql(ApiState { sql_handler });
Self {
router: self
@@ -1312,12 +1303,10 @@ impl HttpServer {
routing::get(handler::promql).post(handler::promql),
);
- if api_state.experimental_enable_explain_analyze_stream {
- router = router.route(
- "/sql/analyze/stream",
- routing::post(handler::sql_analyze_stream),
- );
- }
+ router = router.route(
+ "/sql/analyze/stream",
+ routing::post(handler::sql_analyze_stream),
+ );
router.with_state(api_state)
}
@@ -1632,31 +1621,6 @@ mod test {
server.build(app).unwrap()
}
- #[tokio::test]
- pub async fn test_analyze_stream_route_config_gate() {
- let (tx, _rx) = mpsc::channel(100);
- let options = HttpOptions {
- experimental_enable_explain_analyze_stream: false,
- ..Default::default()
- };
- let app = make_test_app_custom(tx, options);
- let client = TestClient::new(app).await;
- let res = client
- .post("/v1/sql/analyze/stream?sql=EXPLAIN%20ANALYZE%20VERBOSE%20SELECT%201")
- .send()
- .await;
- assert_eq!(res.status(), StatusCode::NOT_FOUND);
-
- let (tx, _rx) = mpsc::channel(100);
- let app = make_test_app_custom(tx, HttpOptions::default());
- let client = TestClient::new(app).await;
- let res = client
- .post("/v1/sql/analyze/stream?sql=EXPLAIN%20ANALYZE%20VERBOSE%20SELECT%201")
- .send()
- .await;
- assert_ne!(res.status(), StatusCode::NOT_FOUND);
- }
-
fn make_split_builder() -> HttpServerBuilder {
let (tx, _rx) = mpsc::channel(100);
let instance = Arc::new(DummyInstance { _tx: tx });
@@ -1967,6 +1931,36 @@ mod test {
assert_eq!(Duration::from_secs(0), default.timeout)
}
+ #[tokio::test]
+ async fn test_http_options_legacy_analyze_stream_config_is_ignored() {
+ let options: HttpOptions = serde_json::from_value(serde_json::json!({
+ "addr": "127.0.0.1:4000",
+ "timeout": "0s",
+ "body_limit": "64MiB",
+ "cors_allowed_origins": [],
+ "enable_cors": true,
+ "experimental_enable_explain_analyze_stream": false,
+ "enable_api_server": false,
+ "api_server_addr": "127.0.0.1:4006"
+ }))
+ .unwrap();
+ let serialized = serde_json::to_string(&options).unwrap();
+ assert!(!serialized.contains("experimental_enable_explain_analyze_stream"));
+
+ let (tx, _rx) = mpsc::channel(100);
+ let app = make_test_app_custom(tx, options);
+ let client = TestClient::new(app).await;
+ let response = client
+ .post("/v1/sql/analyze/stream")
+ .form(&handler::SqlQuery {
+ sql: Some("EXPLAIN ANALYZE VERBOSE SELECT 1".to_string()),
+ ..Default::default()
+ })
+ .send()
+ .await;
+ assert_ne!(response.status(), StatusCode::NOT_FOUND);
+ }
+
#[tokio::test]
async fn test_http_server_request_timeout() {
common_telemetry::init_default_ut_logging();
diff --git a/src/servers/src/http/handler.rs b/src/servers/src/http/handler.rs
index f92f2413c7b..a9395969e22 100644
--- a/src/servers/src/http/handler.rs
+++ b/src/servers/src/http/handler.rs
@@ -13,6 +13,7 @@
// limitations under the License.
use std::collections::HashMap;
+use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -26,11 +27,10 @@ use common_error::ext::ErrorExt;
use common_error::status_code::StatusCode;
use common_plugins::GREPTIME_EXEC_WRITE_COST;
use common_query::{Output, OutputData};
-use common_recordbatch::{RecordBatch, SendableRecordBatchStream, util};
+use common_recordbatch::util;
use common_telemetry::tracing;
use datafusion::physical_plan::ExecutionPlan;
-use datatypes::schema::SchemaRef;
-use futures::StreamExt;
+use futures::{FutureExt, StreamExt};
use query::parser::{DEFAULT_LOOKBACK_STRING, PromQuery};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -39,6 +39,7 @@ use snafu::ResultExt;
use sql::dialect::GreptimeDbDialect;
use sql::parser::{ParseOptions, ParserContext};
use sql::statements::statement::Statement;
+use tokio::sync::{Notify, watch};
use crate::error::{FailedToParseQuerySnafu, InvalidQuerySnafu, Result};
use crate::http::header::collect_plan_metrics;
@@ -100,15 +101,40 @@ struct AnalyzeStreamPayload {
code: Option,
}
-struct AnalyzeStreamState {
- stream: SendableRecordBatchStream,
- schema: SchemaRef,
- plan: Option>,
- batches: Vec,
- seq: u64,
- start: Instant,
- requested_interval_ms: u64,
- current_interval_ms: u64,
+#[derive(Clone, Debug)]
+#[doc(hidden)]
+pub enum AnalyzeStreamMessage {
+ Empty,
+ Metrics {
+ payload: String,
+ },
+ Terminal {
+ event_name: &'static str,
+ payload: String,
+ },
+}
+
+struct AnalyzeStreamWorkerGuard {
+ cancel: watch::Sender,
+ handle: Option>,
+}
+
+impl Drop for AnalyzeStreamWorkerGuard {
+ fn drop(&mut self) {
+ let _ = self.cancel.send(true);
+ if let Some(handle) = self.handle.take() {
+ handle.abort();
+ }
+ }
+}
+
+struct AnalyzeStreamBodyState {
+ latest_metrics: watch::Receiver