Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/admin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ http = { workspace = true }
http-body = { workspace = true }
http-body-util = { workspace = true }
hyper-util = { workspace = true }
gardal = { workspace = true }
itertools = { workspace = true }
metrics = { workspace = true }
mime_guess = { version = "2.0.5", optional = true }
Expand Down
11 changes: 9 additions & 2 deletions crates/admin/src/cluster_controller/grpc_svc_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ use restate_core::protobuf::cluster_ctrl_svc::{
};
use restate_core::{Metadata, MetadataWriter};
use restate_metadata_store::WriteError;
use restate_storage_query_datafusion::context::QueryContext;
use restate_storage_query_datafusion::context::{QueryContext, QueryError};
use restate_storage_query_datafusion::node_fan_out::NodeWarnings;
use restate_types::config::{MetadataClientKind, MetadataClientOptions, NetworkingOptions};
use restate_types::identifiers::PartitionId;
Expand Down Expand Up @@ -429,7 +429,7 @@ impl ClusterCtrlSvc for ClusterCtrlSvcHandler {
.query_context
.execute(&request.query)
.await
.map_err(datafusion_error_to_status)?;
.map_err(datafusion_query_error_to_status)?;

let node_warnings = query_result.node_warnings;

Expand Down Expand Up @@ -651,6 +651,13 @@ fn drain_node_warnings(node_warnings: &[NodeWarnings]) -> Vec<QueryWarning> {
out
}

fn datafusion_query_error_to_status(err: QueryError) -> Status {
match err {
QueryError::DataFusion(e) => datafusion_error_to_status(e),
err @ QueryError::RateLimited(_) => Status::resource_exhausted(err.to_string()),
}
}

fn datafusion_error_to_status(err: DataFusionError) -> Status {
match err {
DataFusionError::SQL(..)
Expand Down
11 changes: 11 additions & 0 deletions crates/admin/src/metric_definitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@

use metrics::{Unit, describe_counter, describe_gauge, describe_histogram};

// Storage Accounting metrics
pub(crate) const USAGE_STATE_STORAGE_BYTES: &str = "restate.usage.state.storage_bytes";
pub(crate) const USAGE_STATE_STORAGE_BYTE_SECONDS: &str =
"restate.usage.state.storage_byte_seconds.total";
pub(crate) const USAGE_STATE_SIZE_ACCOUNTING_QUERY_DURATION_SECONDS: &str =
"restate.usage.state_size_accounting.query_duration_seconds";

// Query Engine metrics
pub(crate) const QUERY_ENGINE_QUERY_DURATION_SECONDS: &str =
"restate.query_engine.query_duration_seconds";

pub(crate) fn describe_metrics() {
describe_gauge!(
USAGE_STATE_STORAGE_BYTES,
Expand All @@ -33,5 +38,11 @@ pub(crate) fn describe_metrics() {
USAGE_STATE_SIZE_ACCOUNTING_QUERY_DURATION_SECONDS,
Unit::Seconds,
"Accounting query execution duration"
);

describe_histogram!(
QUERY_ENGINE_QUERY_DURATION_SECONDS,
Unit::Seconds,
"End-to-end duration of a query, including planning, execution and streaming the response"
)
}
84 changes: 80 additions & 4 deletions crates/admin/src/rest_api/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
use std::io::Write;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Instant;

use crate::query_utils::{RecordBatchWriter, WriteRecordBatchStream};
use crate::state::AdminServiceState;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
Expand All @@ -24,16 +24,24 @@ use datafusion::arrow::datatypes::Schema;
use datafusion::arrow::ipc::writer::StreamWriter;
use datafusion::arrow::json::writer::JsonArray;
use datafusion::common::DataFusionError;
use futures::{StreamExt, TryStreamExt};
use futures::{Stream, StreamExt, TryStreamExt};
use http::{HeaderMap, HeaderValue};
use http_body::Frame;
use http_body_util::StreamBody;
use metrics::histogram;
use parking_lot::Mutex;
use serde::Serialize;

use restate_admin_rest_model::query::QueryRequest;
use restate_core::network::TransportConnect;
use restate_types::invocation::client::InvocationClient;
use restate_types::schema::registry::{DiscoveryClient, MetadataService, TelemetryClient};
use serde::Serialize;

use crate::metric_definitions::QUERY_ENGINE_QUERY_DURATION_SECONDS;
use crate::query_utils::{RecordBatchWriter, WriteRecordBatchStream};
use crate::state::AdminServiceState;

const RETRY_AFTER_HEADER: &str = "Retry-After";

/// Error response for query endpoint.
#[derive(Debug, Serialize, utoipa::ToSchema)]
Expand All @@ -48,16 +56,47 @@ pub(crate) enum QueryError {
Datafusion(#[from] datafusion::error::DataFusionError),
#[error("Query service not available")]
Unavailable,
#[error("Rate limited")]
RateLimited(#[from] gardal::RateLimited),
}

impl From<restate_storage_query_datafusion::context::QueryError> for QueryError {
fn from(err: restate_storage_query_datafusion::context::QueryError) -> Self {
match err {
restate_storage_query_datafusion::context::QueryError::DataFusion(e) => {
Self::Datafusion(e)
}
restate_storage_query_datafusion::context::QueryError::RateLimited(e) => {
Self::RateLimited(e)
}
}
}
}

impl IntoResponse for QueryError {
fn into_response(self) -> Response {
let status_code = match &self {
QueryError::Datafusion(datafusion::error::DataFusionError::Plan(_))
| QueryError::Datafusion(datafusion::error::DataFusionError::SchemaError(_, _))
| QueryError::Datafusion(datafusion::error::DataFusionError::SQL(_, _)) => {
StatusCode::BAD_REQUEST
}
QueryError::Datafusion(_) => StatusCode::INTERNAL_SERVER_ERROR,
QueryError::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
QueryError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS,
};

let mut headers = http::HeaderMap::new();
if let QueryError::RateLimited(e) = &self {
headers.insert(
RETRY_AFTER_HEADER,
HeaderValue::from(e.earliest_retry_after().as_secs()),
);
}

(
status_code,
headers,
Json(QueryErrorBody {
message: self.to_string(),
}),
Expand Down Expand Up @@ -94,6 +133,10 @@ where
Invocations: InvocationClient + Send + Sync + Clone + 'static,
Transport: TransportConnect,
{
let duration_guard = QueryDurationGuard {
start: Instant::now(),
};
Comment on lines +136 to +138

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Maybe a QueryDurationGuard::new() is nicer ?


let Some(query_context) = state.query_context.as_ref() else {
return Err(QueryError::Unavailable);
};
Expand Down Expand Up @@ -126,13 +169,46 @@ where
return Err(err.into());
}

// Move the guard into the body so the duration is recorded once the stream is fully
// consumed or dropped (e.g. on client disconnect).
let result_stream = Timed {
inner: result_stream,
_guard: duration_guard,
};

Ok(Response::builder()
.header(http::header::CONTENT_TYPE, content_type)
.body(StreamBody::new(result_stream))
.expect("content-type header is correct")
.into_response())
}

/// Records the elapsed time since `start` into the query-duration histogram when dropped.
struct QueryDurationGuard {
start: Instant,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use our MillisSinceEpoch instead

}

impl Drop for QueryDurationGuard {
fn drop(&mut self) {
histogram!(QUERY_ENGINE_QUERY_DURATION_SECONDS).record(self.start.elapsed().as_secs_f64());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am still not entirely sure about this metric. You already mentioned in the commit message that this can/will be skewed by the complexity of the query. But will also be skewed by slow clients, network speed, or cancelled queries (users dropping the stream before draining it completely).

What I am trying to say it's heavily affected by user behaviour, and worried it can be misleading in some situations.

What might be useful imho is a metric that measure latency of query_context.execute then time of first record or TTFB.

@AhmedSoliman what do you hink?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I only considered the complexity of the query, but didn't consider the client's speed in draining the stream. That's a good point. We can definitely do TTFB, but then it wouldn't say much about how expensive the query is. I assume that maybe I can try to track the time we spent blocked on the record batch stream, but I don't know if this is going to be worth the complexity.

}
}

/// Wraps a stream, keeping a [`QueryDurationGuard`] alive for as long as the stream is held.
/// The guard records the query duration when this wrapper (the response body) is dropped.
struct Timed<S> {
inner: S,
_guard: QueryDurationGuard,
}

impl<S: Stream + Unpin> Stream for Timed<S> {
type Item = S::Item;

fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.inner.poll_next_unpin(cx)
}
}

#[derive(Clone)]
// unfortunately the json writer doesnt give a way to get a mutable reference to the underlying writer, so we need another pointer in to its buffer
// we use a lock here to help make the writer send/sync, despite it being totally uncontended :(
Expand Down
2 changes: 2 additions & 0 deletions crates/admin/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ use restate_types::net::listener::Listeners;
use restate_types::schema::registry::SchemaRegistry;
use restate_util_time::DurationExt;

use crate::metric_definitions::describe_metrics;
use crate::rest_api::{MAX_ADMIN_API_VERSION, MIN_ADMIN_API_VERSION};
use crate::schema_registry_integration::{MetadataService, TelemetryClient};
use crate::{rest_api, state};
Expand Down Expand Up @@ -71,6 +72,7 @@ where
service_discovery: ServiceDiscovery,
telemetry_http_client: Option<HttpClient>,
) -> Self {
describe_metrics();
let metadata_client = metadata_writer.raw_metadata_store_client().clone();
Self {
listeners,
Expand Down
4 changes: 1 addition & 3 deletions crates/admin/src/storage_accounting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use restate_util_time::DurationExt;

use crate::metric_definitions::{
USAGE_STATE_SIZE_ACCOUNTING_QUERY_DURATION_SECONDS, USAGE_STATE_STORAGE_BYTE_SECONDS,
USAGE_STATE_STORAGE_BYTES, describe_metrics,
USAGE_STATE_STORAGE_BYTES,
};

pub(crate) const STORAGE_QUERY: &str =
Expand All @@ -49,8 +49,6 @@ impl StorageAccountingTask {
}

pub async fn run(mut self) -> anyhow::Result<()> {
describe_metrics();

let effective_interval = self.update_interval.add_jitter(0.1);
let start_at = tokio::time::Instant::now() + effective_interval;
let mut update_interval = tokio::time::interval_at(start_at, effective_interval);
Expand Down
2 changes: 2 additions & 0 deletions crates/node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ impl Node {
mut address_book: AddressBook,
) -> Result<Self, BuildError> {
metric_definitions::describe_metrics();
restate_storage_query_datafusion::describe_metrics();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would avoid calling this from node crate tbh. describe_metric() should be private and internal to the crate.

I understand that datafusion has no singleton object to call this one on but it's idempotent and maybe we can just call it when creating a context (we only have 2 of these)


let mut server_builder = NetworkServerBuilder::new(&mut address_book);
let config = updateable_config.pinned();

Expand Down
2 changes: 2 additions & 0 deletions crates/storage-query-datafusion/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ datafusion-proto = { workspace = true }
derive_more = { workspace = true, features = ["debug"] }
enumset = { workspace = true }
futures = { workspace = true }
gardal = { workspace = true }
itertools = { workspace = true }
metrics = { workspace = true }
parking_lot = { workspace = true }
paste = { workspace = true }
prost = { workspace = true }
Expand Down
22 changes: 21 additions & 1 deletion crates/storage-query-datafusion/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ use crate::empty_invoker_status_handle::EmptyInvokerStatusHandle;
use crate::node_fan_out::NodeWarnings;
use crate::remote_query_scanner_manager::RemoteScannerManager;

type RateLimiter = gardal::SharedTokenBucket<gardal::TokioClock>;

#[derive(thiserror::Error, Debug)]
pub enum QueryError {
#[error("Datafusion error: {0}")]
DataFusion(#[from] datafusion::common::DataFusionError),
#[error("Rate limited")]
RateLimited(#[from] gardal::RateLimited),
}

const SYS_INVOCATION_VIEW: &str = "CREATE VIEW sys_invocation as SELECT
ss.id,
ss.vqueue_id,
Expand Down Expand Up @@ -496,6 +506,7 @@ where
pub struct QueryContext {
sql_options: SQLOptions,
datafusion_context: SessionContext,
rate_limiter: Option<RateLimiter>,
}

impl QueryContext {
Expand All @@ -508,6 +519,9 @@ impl QueryContext {
options.tmp_dir.clone(),
options.query_parallelism(),
&options.datafusion_options,
options.rate_limiting.as_ref().map(|limit| {
RateLimiter::new(gardal::Limit::from(limit.clone()), gardal::TokioClock)
}),
Comment on lines +522 to +524

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Share one limiter across query entry points

When rate limiting is configured, this allocates a fresh bucket for every QueryContext::create call. The admin REST context is attached to AdminService from crates/node/src/roles/admin.rs:143-156, while the cluster-controller builds a separate context from the same options.admin.query_engine in crates/admin/src/cluster_controller/service.rs:130-131; with both services enabled, a rate = "1/s" limit permits one REST query plus one gRPC query per second on the same node instead of a single per-node budget.

Useful? React with 👍 / 👎.

)?;

registerer.register(&ctx).await?;
Expand Down Expand Up @@ -572,6 +586,7 @@ impl QueryContext {
temp_folder: Option<String>,
default_parallelism: Option<usize>,
datafusion_options: &HashMap<String, String>,
rate_limiter: Option<RateLimiter>,
) -> Result<Self, DataFusionError> {
//
// build the runtime
Expand Down Expand Up @@ -625,10 +640,15 @@ impl QueryContext {
Ok(Self {
sql_options,
datafusion_context: ctx,
rate_limiter,
})
}

pub async fn execute(&self, sql: &str) -> datafusion::common::Result<QueryResult> {
pub async fn execute(&self, sql: &str) -> Result<QueryResult, QueryError> {
if let Some(limiter) = self.rate_limiter.as_ref() {
limiter.try_consume_one()?;
}
Comment on lines +648 to +650

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude background accounting from query throttling

Because the token is consumed inside QueryContext::execute, the background storage accounting task also spends the same bucket: AdminRole clones this context into the REST service and then moves it into StorageAccountingTask (crates/node/src/roles/admin.rs:156 and :179-182), and the task calls .execute(STORAGE_QUERY) in crates/admin/src/storage_accounting.rs:84-87. With accounting enabled and a low query limit, the periodic metrics query can either be rate-limited itself or consume the token that should be reserved for user /query requests.

Useful? React with 👍 / 👎.


let state = self.datafusion_context.state();
let statement = state.sql_to_statement(sql, &datafusion::config::Dialect::PostgreSQL)?;
let plan = state.statement_to_plan(statement).await?;
Expand Down
2 changes: 2 additions & 0 deletions crates/storage-query-datafusion/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ mod keyed_service_status;
mod locks;
mod log;
pub mod loglet_worker;
mod metric_definitions;
mod node;
pub mod node_fan_out;
mod partition;
Expand Down Expand Up @@ -57,6 +58,7 @@ use datafusion::arrow::record_batch::RecordBatch;
use datafusion::error::DataFusionError;
use datafusion::execution::TaskContext;
use datafusion::physical_plan::PhysicalExpr;
pub use metric_definitions::describe_metrics;
use prost::Message;

#[cfg(test)]
Expand Down
Loading
Loading