From f9dbe00d0008773a96dfdafef65196aabfae7f0f Mon Sep 17 00:00:00 2001 From: jeremyhi Date: Mon, 15 Jun 2026 22:39:31 +0800 Subject: [PATCH] feat: add query frontend core skeleton Signed-off-by: jeremyhi --- Cargo.lock | 10 ++ Cargo.toml | 2 + src/query-frontend/Cargo.toml | 16 +++ src/query-frontend/src/config.rs | 67 +++++++++++ src/query-frontend/src/executor.rs | 67 +++++++++++ src/query-frontend/src/key.rs | 180 +++++++++++++++++++++++++++++ src/query-frontend/src/lib.rs | 40 +++++++ src/query-frontend/src/metrics.rs | 35 ++++++ src/query-frontend/src/policy.rs | 68 +++++++++++ src/query-frontend/src/request.rs | 84 ++++++++++++++ 10 files changed, 569 insertions(+) create mode 100644 src/query-frontend/Cargo.toml create mode 100644 src/query-frontend/src/config.rs create mode 100644 src/query-frontend/src/executor.rs create mode 100644 src/query-frontend/src/key.rs create mode 100644 src/query-frontend/src/lib.rs create mode 100644 src/query-frontend/src/metrics.rs create mode 100644 src/query-frontend/src/policy.rs create mode 100644 src/query-frontend/src/request.rs diff --git a/Cargo.lock b/Cargo.lock index 73687696f4..7fedddd93b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11504,6 +11504,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "query-frontend" +version = "1.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "quick-xml" version = "0.26.0" diff --git a/Cargo.toml b/Cargo.toml index 15652e474e..619f9cf35e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ members = [ "src/promql", "src/puffin", "src/query", + "src/query-frontend", "src/standalone", "src/servers", "src/session", @@ -328,6 +329,7 @@ plugins = { path = "src/plugins" } promql = { path = "src/promql" } puffin = { path = "src/puffin" } query = { path = "src/query" } +query-frontend = { path = "src/query-frontend" } servers = { path = "src/servers" } session = { path = "src/session" } sql = { path = "src/sql" } diff --git a/src/query-frontend/Cargo.toml b/src/query-frontend/Cargo.toml new file mode 100644 index 0000000000..3010b427e0 --- /dev/null +++ b/src/query-frontend/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "query-frontend" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true +tokio.workspace = true diff --git a/src/query-frontend/src/config.rs b/src/query-frontend/src/config.rs new file mode 100644 index 0000000000..8811bfa180 --- /dev/null +++ b/src/query-frontend/src/config.rs @@ -0,0 +1,67 @@ +// 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. + +//! Query frontend configuration. +//! +//! The default is fully disabled: constructing [`QueryFrontendConfig::default`] +//! yields a no-op frontend so that merely wiring this crate in does not change +//! runtime behavior. Enabling features is opt-in for later iterations. + +use serde::{Deserialize, Serialize}; + +/// Configuration for the query frontend. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct QueryFrontendConfig { + /// Master switch for the query frontend. Disabled by default; when `false` + /// the frontend must behave as a pass-through. + pub enable: bool, +} + +impl QueryFrontendConfig { + /// Returns whether the query frontend is enabled. + pub fn is_enabled(&self) -> bool { + self.enable + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_disabled() { + let config = QueryFrontendConfig::default(); + assert!(!config.enable); + assert!(!config.is_enabled()); + } + + #[test] + fn empty_config_deserializes_to_disabled_default() { + // An empty object exercises the `#[serde(default)]` path and must yield + // the disabled default. + let config: QueryFrontendConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(QueryFrontendConfig::default(), config); + assert!(!config.is_enabled()); + } + + #[test] + fn enable_round_trips() { + let config = QueryFrontendConfig { enable: true }; + let json = serde_json::to_string(&config).unwrap(); + let parsed: QueryFrontendConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(config, parsed); + assert!(parsed.is_enabled()); + } +} diff --git a/src/query-frontend/src/executor.rs b/src/query-frontend/src/executor.rs new file mode 100644 index 0000000000..d778c1c953 --- /dev/null +++ b/src/query-frontend/src/executor.rs @@ -0,0 +1,67 @@ +// 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. + +//! The boundary trait that runs the underlying query. +//! +//! The query frontend delegates the actual execution of a request to a +//! [`QueryExecutor`]. The response and error types are left associated so this +//! crate stays protocol-neutral and free of dependencies on `servers`, +//! `frontend`, `query`, or `session`. Concrete protocol layers implement this +//! trait over their own response/error types. + +use async_trait::async_trait; + +use crate::request::QueryFrontendRequest; + +/// Executes a [`QueryFrontendRequest`] and produces a protocol-specific +/// response. +#[async_trait] +pub trait QueryExecutor: Send + Sync { + /// The protocol-specific successful response type. + type Response: Send; + /// The protocol-specific error type. + type Error: Send; + + /// Executes the request, returning the response or an error. + async fn execute(&self, request: QueryFrontendRequest) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A trivial executor that echoes the request's query back, used to confirm + /// the trait can be implemented and awaited. + struct EchoExecutor; + + #[async_trait] + impl QueryExecutor for EchoExecutor { + type Response = String; + type Error = std::convert::Infallible; + + async fn execute( + &self, + request: QueryFrontendRequest, + ) -> Result { + Ok(request.query) + } + } + + #[tokio::test] + async fn executor_trait_is_implementable() { + let executor = EchoExecutor; + let request = crate::request::test_request(); + assert_eq!("up", executor.execute(request).await.unwrap()); + } +} diff --git a/src/query-frontend/src/key.rs b/src/query-frontend/src/key.rs new file mode 100644 index 0000000000..3614ecd6f8 --- /dev/null +++ b/src/query-frontend/src/key.rs @@ -0,0 +1,180 @@ +// 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. + +//! Exact identity key for query frontend requests. +//! +//! The key compares two requests for *exact* equality across every field. No +//! normalization (timestamps, query text, steps) is performed: two requests are +//! the same only if all of `db`, `read_preference`, `query`, `start`, `end`, +//! `step`, and `lookback` are byte-for-byte identical. +//! +//! The query text is retained only in memory for equality and hashing. It must +//! never be emitted in metrics or logs. + +use std::fmt; + +use crate::request::QueryFrontendRequest; + +/// Exact identity key derived from a [`QueryFrontendRequest`]. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct QueryKey { + db: String, + read_preference: String, + query: String, + start: String, + end: String, + step: String, + lookback: String, +} + +impl QueryKey { + /// Returns the target database, the only field safe to attach to telemetry. + pub fn db(&self) -> &str { + &self.db + } +} + +impl fmt::Debug for QueryKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("QueryKey") + .field("db", &self.db) + .field("read_preference", &self.read_preference) + .field("query", &"[REDACTED]") + .field("start", &self.start) + .field("end", &self.end) + .field("step", &self.step) + .field("lookback", &self.lookback) + .finish() + } +} + +impl From<&QueryFrontendRequest> for QueryKey { + fn from(request: &QueryFrontendRequest) -> Self { + Self { + db: request.db.clone(), + read_preference: request.read_preference.clone(), + query: request.query.clone(), + start: request.start.clone(), + end: request.end.clone(), + step: request.step.clone(), + lookback: request.lookback.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::request::test_request; + + fn base() -> QueryKey { + test_request().key() + } + + #[test] + fn equal_keys_match() { + assert_eq!(base(), test_request().key()); + } + + #[test] + fn each_field_distinguishes_the_key() { + let request = test_request(); + + assert_ne!( + base(), + QueryFrontendRequest { + db: "other".to_string(), + ..request.clone() + } + .key(), + "db must be part of the identity" + ); + assert_ne!( + base(), + QueryFrontendRequest { + read_preference: "FOLLOWER".to_string(), + ..request.clone() + } + .key(), + "read_preference must be part of the identity" + ); + assert_ne!( + base(), + QueryFrontendRequest { + query: "rate(up[5m])".to_string(), + ..request.clone() + } + .key(), + "query must be part of the identity" + ); + assert_ne!( + base(), + QueryFrontendRequest { + start: "0".to_string(), + ..request.clone() + } + .key(), + "start must be part of the identity" + ); + assert_ne!( + base(), + QueryFrontendRequest { + end: "3".to_string(), + ..request.clone() + } + .key(), + "end must be part of the identity" + ); + assert_ne!( + base(), + QueryFrontendRequest { + step: "10s".to_string(), + ..request.clone() + } + .key(), + "step must be part of the identity" + ); + assert_ne!( + base(), + QueryFrontendRequest { + lookback: "1m".to_string(), + ..request + } + .key(), + "lookback must be part of the identity" + ); + } + + #[test] + fn key_from_request_keeps_every_field() { + let request = test_request(); + assert_eq!(base(), request.key()); + assert_eq!("db", request.key().db()); + } + + #[test] + fn debug_redacts_query_text() { + let request = QueryFrontendRequest { + query: "secret_query".to_string(), + ..test_request() + }; + let request_debug = format!("{request:?}"); + let key_debug = format!("{:?}", request.key()); + + assert!(!request_debug.contains("secret_query")); + assert!(request_debug.contains("[REDACTED]")); + assert!(!key_debug.contains("secret_query")); + assert!(key_debug.contains("[REDACTED]")); + } +} diff --git a/src/query-frontend/src/lib.rs b/src/query-frontend/src/lib.rs new file mode 100644 index 0000000000..5df0bfe8ef --- /dev/null +++ b/src/query-frontend/src/lib.rs @@ -0,0 +1,40 @@ +// 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. + +//! Core skeleton for the GreptimeDB query frontend. +//! +//! The query frontend sits in front of the query path and is intended to host +//! optimizations such as in-flight coalescing or caching in later iterations. +//! This crate only establishes the protocol-neutral building blocks; it does +//! **not** yet change any runtime behavior. +//! +//! Deliberately out of scope for this skeleton: singleflight, caching, +//! stale-while-revalidate, TTLs, timestamp/query normalization, and response +//! replay. The defaults are no-ops so wiring this crate in cannot alter the +//! current request path. +//! +//! Module map: +//! - [`request`]: protocol-neutral request DTO handed to the frontend. +//! - [`key`]: exact identity key derived from a request. +//! - [`policy`]: decides whether the frontend engages for a request. +//! - [`config`]: configuration, disabled by default. +//! - [`metrics`]: stable metric/label names for observability. +//! - [`executor`]: the boundary trait that runs the underlying query. + +pub mod config; +pub mod executor; +pub mod key; +pub mod metrics; +pub mod policy; +pub mod request; diff --git a/src/query-frontend/src/metrics.rs b/src/query-frontend/src/metrics.rs new file mode 100644 index 0000000000..7b1312e4a1 --- /dev/null +++ b/src/query-frontend/src/metrics.rs @@ -0,0 +1,35 @@ +// 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. + +//! Stable metric and label names for query frontend observability. +//! +//! This module only declares the naming surface; it does not register or own +//! any metric instruments. Keeping the names here lets the eventual collector +//! and any observe-only call sites agree on identifiers. Per the key contract, +//! only the database label is safe to emit — never the query text. + +/// Metric name for query frontend observations. +pub const METRIC_QUERY_FRONTEND_OBSERVATIONS: &str = "greptime_query_frontend_observations_total"; + +/// Label carrying the target database name. +pub const LABEL_DB: &str = "db"; + +/// Label carrying the per-request outcome (see the `OUTCOME_*` constants). +pub const LABEL_OUTCOME: &str = "outcome"; + +/// Outcome label value: request bypassed the frontend. +pub const OUTCOME_BYPASS: &str = "bypass"; + +/// Outcome label value: request was handled by the frontend. +pub const OUTCOME_ENGAGE: &str = "engage"; diff --git a/src/query-frontend/src/policy.rs b/src/query-frontend/src/policy.rs new file mode 100644 index 0000000000..c81d486bf2 --- /dev/null +++ b/src/query-frontend/src/policy.rs @@ -0,0 +1,68 @@ +// 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. + +//! Decides whether the query frontend engages for a given request. +//! +//! A policy is the gate in front of any future optimization. The default +//! [`BypassPolicy`] always bypasses, so the frontend is a pass-through until a +//! real policy is introduced. + +use crate::request::QueryFrontendRequest; + +/// What the frontend should do with a request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PolicyDecision { + /// Hand the request straight to the underlying executor; the frontend adds + /// no behavior. + Bypass, + /// Let the frontend handle the request (coalescing, caching, etc. in a + /// later iteration). + Engage, +} + +impl PolicyDecision { + /// Returns `true` when the frontend should engage. + pub fn is_engage(&self) -> bool { + matches!(self, PolicyDecision::Engage) + } +} + +/// Decides, per request, whether the frontend engages. +pub trait QueryFrontendPolicy: Send + Sync { + /// Returns the [`PolicyDecision`] for `request`. + fn decide(&self, request: &QueryFrontendRequest) -> PolicyDecision; +} + +/// Default policy that always bypasses, keeping runtime behavior unchanged. +#[derive(Debug, Clone, Copy, Default)] +pub struct BypassPolicy; + +impl QueryFrontendPolicy for BypassPolicy { + fn decide(&self, _request: &QueryFrontendRequest) -> PolicyDecision { + PolicyDecision::Bypass + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_policy_always_bypasses() { + let policy = BypassPolicy; + let request = crate::request::test_request(); + assert_eq!(PolicyDecision::Bypass, policy.decide(&request)); + assert!(!policy.decide(&request).is_engage()); + } +} diff --git a/src/query-frontend/src/request.rs b/src/query-frontend/src/request.rs new file mode 100644 index 0000000000..8a960fef40 --- /dev/null +++ b/src/query-frontend/src/request.rs @@ -0,0 +1,84 @@ +// 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. + +//! Protocol-neutral request DTO consumed by the query frontend. +//! +//! Fields are intentionally kept as opaque strings so this crate does not +//! depend on `servers`, `frontend`, `query`, or `session` types. Callers are +//! responsible for translating their protocol-specific representations (for +//! example, a PromQL range query plus its session context) into these strings +//! verbatim. The frontend does not normalize them. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::key::QueryKey; + +/// A single query handed to the frontend. +/// +/// The fields mirror the inputs that uniquely identify a PromQL range query +/// today, but the types are protocol-neutral strings so the same DTO can carry +/// other query shapes later. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QueryFrontendRequest { + /// Target database/schema name. + pub db: String, + /// Read preference (e.g. `LEADER`, `FOLLOWER`), as an opaque string. + pub read_preference: String, + /// The raw query expression. + pub query: String, + /// Range start, verbatim from the caller. + pub start: String, + /// Range end, verbatim from the caller. + pub end: String, + /// Range step, verbatim from the caller. + pub step: String, + /// Lookback delta, verbatim from the caller. + pub lookback: String, +} + +impl QueryFrontendRequest { + /// Returns the exact identity [`QueryKey`] for this request. + pub fn key(&self) -> QueryKey { + QueryKey::from(self) + } +} + +impl fmt::Debug for QueryFrontendRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("QueryFrontendRequest") + .field("db", &self.db) + .field("read_preference", &self.read_preference) + .field("query", &"[REDACTED]") + .field("start", &self.start) + .field("end", &self.end) + .field("step", &self.step) + .field("lookback", &self.lookback) + .finish() + } +} + +#[cfg(test)] +pub(crate) fn test_request() -> QueryFrontendRequest { + QueryFrontendRequest { + db: "db".to_string(), + read_preference: "LEADER".to_string(), + query: "up".to_string(), + start: "1".to_string(), + end: "2".to_string(), + step: "5s".to_string(), + lookback: "5m".to_string(), + } +}