feat: add query frontend core skeleton

Signed-off-by: jeremyhi <fengjiachun@gmail.com>
This commit is contained in:
jeremyhi
2026-06-15 22:39:31 +08:00
parent 5fc73ad237
commit f9dbe00d00
10 changed files with 569 additions and 0 deletions

10
Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -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

View File

@@ -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());
}
}

View File

@@ -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<Self::Response, Self::Error>;
}
#[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<Self::Response, Self::Error> {
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());
}
}

View File

@@ -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]"));
}
}

View File

@@ -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;

View File

@@ -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";

View File

@@ -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());
}
}

View File

@@ -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(),
}
}