From 9c2c58eadaddd76a1567638d16abd361fb2db91a Mon Sep 17 00:00:00 2001 From: Zeying Zhu Date: Thu, 7 May 2026 16:30:20 -0400 Subject: [PATCH] refactor: tier-co-locate engines/{simple,gorilla}/ + delete JSONL legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step-1 of the JSONL deprecation: lift the warm/archive engines into tier-co-located directories, lift the dispatcher into a dedicated routing/ module, and delete the §5.2 local-FS JSONL fallback path outright. Step-2 (Prometheus-block format + Thanos store-gateway as the archive query engine) lands in a separate dispatch. Layout changes (asap-query-engine/src/): * engines/simple_engine.rs → engines/simple/{mod,engine,tests}.rs * engines/gorilla_engine/{mod,query_planner,exact_executor}.rs → engines/gorilla/{mod,engine}.rs (planner + executor MERGED into engine.rs) * drivers/query/fallback/cold_store/gorilla_s3.rs → engines/gorilla/store.rs * drivers/query/fallback/cold_store/s3_cost_tracker.rs → engines/gorilla/s3_cost.rs * postings cache + intersection helper extracted to engines/gorilla/postings.rs * engines/router.rs → routing/engine_router.rs * data_model/backend_storage_routing.rs → routing/backend_storage_routing.rs * routing/mod.rs added; data_model::* re-exports the routing types so existing callers keep compiling Deletions (no jsonl_legacy/ parking lot — direct delete): * drivers/query/fallback/cold_store/local_fs.rs (LocalFsColdStore) * drivers/query/fallback/cold_store/format.rs (parse_jsonl + the RawSample wire format + torn-trailing-line tolerance + pin tests) * drivers/query/fallback/s3_adapter.rs (the §5.2 ColdFallback adapter) * drivers/query/fallback/cold_store/ directory itself * tests/cold_fallback_tests.rs (whole file) * StorageBackend::ColdJsonlFallback enum variant * compatible_storage_backends ColdJsonlFallback failover slot * AdapterConfig::prometheus_promql_with_cold + ::from_prom_with_optional_cold constructors * main.rs / precompute_engine.rs --cold-store-root flag + ASAP_COLD_STORE_ROOT env var Renames in the gorilla module: * GorillaS3ColdStore → GorillaS3Store * ColdStore (trait) → Store * ColdStoreError → StoreError * MockColdStore (tests) → MockStore cargo build --release: green. cargo test --release --lib: 856 passed, 33 failed (every failing test also fails on origin/main — pre-existing bugs in schema_timeline_dispatch + datafusion plan_execution; the 26-test delta vs main is the cold_fallback_tests + local_fs/format/s3_adapter inline tests intentionally deleted). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs/asap_types/src/capability_matching.rs | 97 +-- .../src/bin/precompute_engine.rs | 47 +- .../src/bin/show_logical_plans.rs | 2 +- asap-query-engine/src/data_model/mod.rs | 11 +- .../src/drivers/query/adapters/config.rs | 69 --- .../query/fallback/cold_store/format.rs | 208 ------- .../query/fallback/cold_store/local_fs.rs | 188 ------ .../drivers/query/fallback/cold_store/mod.rs | 221 ------- .../src/drivers/query/fallback/metrics.rs | 9 +- .../src/drivers/query/fallback/mod.rs | 4 - .../src/drivers/query/fallback/s3_adapter.rs | 572 ------------------ .../src/drivers/query/servers/http.rs | 86 +-- .../exact_executor.rs => gorilla/engine.rs} | 478 ++++++++++++++- .../{gorilla_engine => gorilla}/mod.rs | 126 ++-- .../src/engines/gorilla/postings.rs | 164 +++++ .../gorilla/s3_cost.rs} | 18 +- .../gorilla/store.rs} | 487 ++++++++------- .../{gorilla_engine => gorilla}/tests.rs | 58 +- .../engines/gorilla_engine/query_planner.rs | 410 ------------- .../src/engines/logical/plan_builder.rs | 4 +- asap-query-engine/src/engines/mod.rs | 55 +- .../{simple_engine.rs => simple/engine.rs} | 16 +- asap-query-engine/src/engines/simple/mod.rs | 29 + asap-query-engine/src/engines/simple/tests.rs | 13 + asap-query-engine/src/lib.rs | 1 + asap-query-engine/src/main.rs | 97 +-- .../backend_storage_routing.rs | 17 +- .../router.rs => routing/engine_router.rs} | 47 +- asap-query-engine/src/routing/mod.rs | 33 + .../src/tests/capability_matching_tests.rs | 2 +- .../src/tests/cold_fallback_tests.rs | 402 ------------ .../datafusion/plan_builder_binary_tests.rs | 2 +- .../plan_builder_regression_tests.rs | 2 +- .../tests/datafusion/plan_execution_tests.rs | 2 +- asap-query-engine/src/tests/mod.rs | 1 - .../src/tests/query_equivalence_tests.rs | 2 +- .../src/tests/sql_pattern_matching_tests.rs | 2 +- .../src/tests/test_utilities/comparison.rs | 2 +- .../tests/test_utilities/engine_factories.rs | 2 +- 39 files changed, 1257 insertions(+), 2729 deletions(-) delete mode 100644 asap-query-engine/src/drivers/query/fallback/cold_store/format.rs delete mode 100644 asap-query-engine/src/drivers/query/fallback/cold_store/local_fs.rs delete mode 100644 asap-query-engine/src/drivers/query/fallback/cold_store/mod.rs delete mode 100644 asap-query-engine/src/drivers/query/fallback/s3_adapter.rs rename asap-query-engine/src/engines/{gorilla_engine/exact_executor.rs => gorilla/engine.rs} (50%) rename asap-query-engine/src/engines/{gorilla_engine => gorilla}/mod.rs (75%) create mode 100644 asap-query-engine/src/engines/gorilla/postings.rs rename asap-query-engine/src/{drivers/query/fallback/cold_store/s3_cost_tracker.rs => engines/gorilla/s3_cost.rs} (94%) rename asap-query-engine/src/{drivers/query/fallback/cold_store/gorilla_s3.rs => engines/gorilla/store.rs} (75%) rename asap-query-engine/src/engines/{gorilla_engine => gorilla}/tests.rs (94%) delete mode 100644 asap-query-engine/src/engines/gorilla_engine/query_planner.rs rename asap-query-engine/src/engines/{simple_engine.rs => simple/engine.rs} (99%) create mode 100644 asap-query-engine/src/engines/simple/mod.rs create mode 100644 asap-query-engine/src/engines/simple/tests.rs rename asap-query-engine/src/{data_model => routing}/backend_storage_routing.rs (98%) rename asap-query-engine/src/{engines/router.rs => routing/engine_router.rs} (93%) create mode 100644 asap-query-engine/src/routing/mod.rs delete mode 100644 asap-query-engine/src/tests/cold_fallback_tests.rs diff --git a/asap-common/dependencies/rs/asap_types/src/capability_matching.rs b/asap-common/dependencies/rs/asap_types/src/capability_matching.rs index 17d8ae7de..fcd78587c 100644 --- a/asap-common/dependencies/rs/asap_types/src/capability_matching.rs +++ b/asap-common/dependencies/rs/asap_types/src/capability_matching.rs @@ -28,6 +28,12 @@ use promql_utilities::query_logics::enums::AggregationType; /// `SketchWarmTier` is the default — every existing `AggregationConfig` and /// `StreamingConfig` decodes into this variant via `#[serde(default)]`, so /// pre-Phase-5 deploys keep dispatching to `SimpleEngine` unchanged. +/// +/// **Step-1 of the JSONL deprecation refactor** removed the +/// `ColdJsonlFallback` variant. The legacy local-FS JSONL leg +/// (`LocalFsColdStore`, `parse_jsonl`, the §5.2 raw-store +/// fallback) was deleted at the same commit; the surviving +/// failover surface is warm-tier sketch ↔ Gorilla-S3 archive. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, )] @@ -38,16 +44,11 @@ pub enum StorageBackend { #[default] SketchWarmTier, - /// NEW (Phase 5) — Gorilla-S3 archive. Served by `GorillaQueryEngine`, - /// reading per-hour Gorilla chunks from S3 / MinIO via the Phase-3 - /// `GorillaS3ColdStore`. + /// Gorilla-S3 archive. Served by `GorillaQueryEngine`, reading + /// per-hour Gorilla chunks from S3 / MinIO via the + /// `GorillaS3Store`. GorillaS3Archive, - /// Local-FS JSONL fallback (PR #54 §5.2). Served by the existing - /// cold-fallback path; used when no warm-tier or archive aggregation - /// can answer the query. - ColdJsonlFallback, - /// Double-write: the metric is written to both warm-tier sketches AND the /// Gorilla-S3 archive. Capability matching surfaces both options and the /// cost-aware dispatcher picks per query (typically warm-tier for low- @@ -63,7 +64,6 @@ impl StorageBackend { match self { StorageBackend::SketchWarmTier => "sketch_warm", StorageBackend::GorillaS3Archive => "gorilla_archive", - StorageBackend::ColdJsonlFallback => "cold_jsonl", StorageBackend::DoubleWrite => "double_write", } } @@ -148,8 +148,10 @@ pub fn compatible_agg_types(stat: Statistic) -> &'static [AggregationType] { /// /// The returned list is **ordered by preference**: the router walks it in /// order and dispatches to the first backend whose engine is registered. -/// `ColdJsonlFallback` is appended whenever the warm tier is in play so a -/// capability miss falls through to the §5.2 raw-store path before erroring. +/// +/// **Step-1 of the JSONL deprecation refactor**: the legacy +/// `ColdJsonlFallback` failover slot was removed. Surviving +/// failover surface is warm-tier sketch ↔ Gorilla-S3 archive. /// /// Routing rules (mirrors `docs/design-gorilla-s3-cold-engine.md` §8): /// @@ -159,15 +161,13 @@ pub fn compatible_agg_types(stat: Statistic) -> &'static [AggregationType] { /// routes to the archive when the metric is Gorilla-only — there is no /// warm-tier sketch to fall back to in that deploy shape. /// * Metric configured for `SketchWarmTier` (or unconfigured / default): -/// `[SketchWarmTier, ColdJsonlFallback]` — warm tier first, raw-store -/// fallback if no compatible aggregation exists. +/// `[SketchWarmTier]`. A capability miss in the warm tier surfaces +/// as a 404 — the previous JSONL fallback path has been deleted. /// * Metric configured for `DoubleWrite`: head depends on accuracy hint, /// tail is the failover sequence (the cost-aware `EngineRouter` picks /// the head, walks the tail on failure): -/// - `Exact` → `[GorillaS3Archive, SketchWarmTier, ColdJsonlFallback]` -/// - `Approximate` → `[SketchWarmTier, GorillaS3Archive, ColdJsonlFallback]` -/// * Metric explicitly configured for `ColdJsonlFallback`: just -/// `[ColdJsonlFallback]`. +/// - `Exact` → `[GorillaS3Archive, SketchWarmTier]` +/// - `Approximate` → `[SketchWarmTier, GorillaS3Archive]` pub fn compatible_storage_backends( _stat: Statistic, accuracy: AccuracyTarget, @@ -175,32 +175,21 @@ pub fn compatible_storage_backends( ) -> Vec { match metric_storage_config { StorageBackend::GorillaS3Archive => { - // Exact-on-archive subsumes approximate-on-warm: a Gorilla-only - // metric has no sketch to back-fall to, and the archive can - // always answer exact (and therefore also approximate) queries. vec![StorageBackend::GorillaS3Archive] } StorageBackend::SketchWarmTier => { - vec![ - StorageBackend::SketchWarmTier, - StorageBackend::ColdJsonlFallback, - ] + vec![StorageBackend::SketchWarmTier] } StorageBackend::DoubleWrite => match accuracy { AccuracyTarget::Exact => vec![ StorageBackend::GorillaS3Archive, StorageBackend::SketchWarmTier, - StorageBackend::ColdJsonlFallback, ], AccuracyTarget::Approximate => vec![ StorageBackend::SketchWarmTier, StorageBackend::GorillaS3Archive, - StorageBackend::ColdJsonlFallback, ], }, - StorageBackend::ColdJsonlFallback => { - vec![StorageBackend::ColdJsonlFallback] - } } } @@ -1131,18 +1120,15 @@ mod tests { AccuracyTarget::Approximate, StorageBackend::SketchWarmTier, ); - assert_eq!( - backends, - vec![ - StorageBackend::SketchWarmTier, - StorageBackend::ColdJsonlFallback, - ] - ); + // Step-1 of the JSONL deprecation: warm-tier only routes + // to itself; the previous `ColdJsonlFallback` failover slot + // has been deleted. + assert_eq!(backends, vec![StorageBackend::SketchWarmTier]); } #[test] fn double_write_metric_returns_both_options() { - // Exact: archive head, warm-tier failover, then JSONL. + // Exact: archive head, warm-tier failover. let exact = compatible_storage_backends( Statistic::Sum, AccuracyTarget::Exact, @@ -1153,11 +1139,10 @@ mod tests { vec![ StorageBackend::GorillaS3Archive, StorageBackend::SketchWarmTier, - StorageBackend::ColdJsonlFallback, ] ); - // Approximate: warm-tier head (cheaper for ε/δ-bounded answers), - // archive failover, then JSONL. + // Approximate: warm-tier head (cheaper for ε/δ-bounded + // answers), archive failover. let approx = compatible_storage_backends( Statistic::Quantile, AccuracyTarget::Approximate, @@ -1168,7 +1153,6 @@ mod tests { vec![ StorageBackend::SketchWarmTier, StorageBackend::GorillaS3Archive, - StorageBackend::ColdJsonlFallback, ] ); } @@ -1187,16 +1171,6 @@ mod tests { assert_eq!(backends, vec![StorageBackend::GorillaS3Archive]); } - #[test] - fn cold_jsonl_only_metric_routes_to_jsonl() { - let backends = compatible_storage_backends( - Statistic::Sum, - AccuracyTarget::Exact, - StorageBackend::ColdJsonlFallback, - ); - assert_eq!(backends, vec![StorageBackend::ColdJsonlFallback]); - } - #[test] fn storage_backend_default_is_warm_tier() { // `#[serde(default)]` on `StreamingConfig.storage_backend` (and on @@ -1215,21 +1189,18 @@ mod tests { "gorilla_archive", ); assert_eq!( - StorageBackend::ColdJsonlFallback.data_source_id(), - "cold_jsonl", + StorageBackend::DoubleWrite.data_source_id(), + "double_write", ); } /// Source-of-truth agreement check, mirrors /// `capability_canonical_map_agreement` for the storage axis. /// - /// For every `(Statistic, AccuracyTarget, StorageBackend)` triple: - /// 1. The returned backend list is non-empty. - /// 2. The first element matches the expected head per the routing matrix - /// in `compatible_storage_backends`'s docstring (kept sync'd by hand). - /// 3. Every list ends in something the router can dispatch — either the - /// archive (Gorilla-only deploys) or `ColdJsonlFallback` (every - /// other deploy shape). + /// For every `(Statistic, AccuracyTarget, StorageBackend)` triple + /// the returned backend list must be non-empty and its head must + /// match the routing matrix in `compatible_storage_backends`'s + /// docstring. #[test] fn capability_storage_backend_agreement() { let stats = [ @@ -1247,7 +1218,6 @@ mod tests { let configs = [ StorageBackend::SketchWarmTier, StorageBackend::GorillaS3Archive, - StorageBackend::ColdJsonlFallback, StorageBackend::DoubleWrite, ]; @@ -1262,16 +1232,15 @@ mod tests { ); let last = *backends.last().unwrap(); assert!( - last == StorageBackend::ColdJsonlFallback + last == StorageBackend::SketchWarmTier || last == StorageBackend::GorillaS3Archive, "backend list for ({stat:?}, {acc:?}, {cfg:?}) must terminate in a \ - dispatchable failover (ColdJsonlFallback or GorillaS3Archive); got {last:?}", + dispatchable failover (SketchWarmTier or GorillaS3Archive); got {last:?}", ); // The expected head is determined by `(metric_storage_config, accuracy)`: let expected_head = match (cfg, acc) { (StorageBackend::GorillaS3Archive, _) => StorageBackend::GorillaS3Archive, (StorageBackend::SketchWarmTier, _) => StorageBackend::SketchWarmTier, - (StorageBackend::ColdJsonlFallback, _) => StorageBackend::ColdJsonlFallback, (StorageBackend::DoubleWrite, AccuracyTarget::Exact) => { StorageBackend::GorillaS3Archive } diff --git a/asap-query-engine/src/bin/precompute_engine.rs b/asap-query-engine/src/bin/precompute_engine.rs index c012b0eeb..aa415b8ab 100644 --- a/asap-query-engine/src/bin/precompute_engine.rs +++ b/asap-query-engine/src/bin/precompute_engine.rs @@ -96,16 +96,11 @@ struct Args { #[arg(long)] persistence_part_cache_mb: Option, - /// Root of the §5.2 cold-tier raw-sample store. When set, - /// capability-miss queries first try the hour-bucketed JSONL - /// layout under this root. Combine with - /// `--forward-unsupported-queries` to keep Prometheus as the - /// tail of the fallback chain. Reads from `ASAP_COLD_STORE_ROOT` - /// so containerised deploys can wire it via env (matches the - /// backend Docker image's environment in - /// `deploy/docker-compose/base.yml`). - #[arg(long, env = "ASAP_COLD_STORE_ROOT")] - cold_store_root: Option, + // Step-1 of the JSONL deprecation refactor removed the + // `--cold-store-root` / `ASAP_COLD_STORE_ROOT` flag. The §5.2 + // local-FS JSONL fallback was deleted at the same commit; the + // surviving fallback chain is just Prometheus (gated by + // `--forward-unsupported-queries`). /// Upstream Prometheus URL for the tail of the fallback chain. /// Only consulted when `--forward-unsupported-queries` is set. @@ -273,17 +268,12 @@ async fn main() -> Result<(), Box> { args.prometheus_scrape_interval, // default 30s (matches e2e window size) QueryLanguage::promql, )); - if let Some(root) = args.cold_store_root.as_deref() { - info!( - cold_store_root = %root.display(), - prom_tail = args.forward_unsupported_queries, - "Cold-tier fallback enabled (§5.2 cold store)", - ); - } - let adapter_config = AdapterConfig::from_prom_with_optional_cold( + // Step-1 of the JSONL deprecation: the only surviving + // fallback path is Prometheus (gated by + // `--forward-unsupported-queries`). + let adapter_config = AdapterConfig::prometheus_promql( args.prometheus_server.clone(), args.forward_unsupported_queries, - args.cold_store_root.as_deref(), ); let http_config = HttpServerConfig { port: args.query_port, @@ -343,32 +333,31 @@ async fn main() -> Result<(), Box> { // Mirrors the registration block in `src/main.rs` so the // `precompute_engine` binary (used by the deploy/docker image) // matches the full backend's behaviour. - match query_engine_rust::drivers::query::fallback::cold_store::GorillaS3Config::from_env() { + match query_engine_rust::engines::gorilla::GorillaS3Config::from_env() { Ok(s3_cfg) => { - match query_engine_rust::drivers::query::fallback::cold_store::GorillaS3ColdStore::with_default_backend(s3_cfg) { - Ok(cold_store) => { - use query_engine_rust::engines::{ - GorillaEngineConfig, GorillaQueryEngine, QueryEngine, - }; + match query_engine_rust::engines::gorilla::GorillaS3Store::with_default_backend(s3_cfg) { + Ok(store) => { + use query_engine_rust::engines::{GorillaEngineConfig, GorillaQueryEngine}; + use query_engine_rust::routing::QueryEngine; let gorilla = Arc::new(GorillaQueryEngine::with_gorilla_s3( - Arc::new(cold_store), + Arc::new(store), GorillaEngineConfig::default(), )); info!( - "Phase-6: registering GorillaQueryEngine on the capability router (data_source_id=gorilla_archive)", + "Registering GorillaQueryEngine on the capability router (data_source_id=gorilla_archive)", ); http_server = http_server.with_query_engine(gorilla as Arc); } Err(e) => { warn!( - "ASAP_GORILLA_S3_* env vars present but GorillaS3ColdStore failed to build ({e}); router will not have a cold-archive engine", + "ASAP_GORILLA_S3_* env vars present but GorillaS3Store failed to build ({e}); router will not have an archive engine", ); } } } Err(_) => { info!( - "ASAP_GORILLA_S3_* env vars not configured — router serves warm-tier metrics only (set ASAP_GORILLA_S3_BUCKET + ASAP_GORILLA_S3_REGION to enable cold-archive routing)", + "ASAP_GORILLA_S3_* env vars not configured — router serves warm-tier metrics only (set ASAP_GORILLA_S3_BUCKET + ASAP_GORILLA_S3_REGION to enable archive routing)", ); } } diff --git a/asap-query-engine/src/bin/show_logical_plans.rs b/asap-query-engine/src/bin/show_logical_plans.rs index 39bd02b22..0410242ba 100644 --- a/asap-query-engine/src/bin/show_logical_plans.rs +++ b/asap-query-engine/src/bin/show_logical_plans.rs @@ -16,7 +16,7 @@ use datafusion_summary_library::{PrecomputedSummaryRead, SummaryInfer, SummaryMe use promql_utilities::data_model::KeyByLabelNames; use promql_utilities::query_logics::enums::{AggregationType, Statistic}; use query_engine_rust::data_model::AggregationIdInfo; -use query_engine_rust::engines::simple_engine::{ +use query_engine_rust::engines::simple::engine::{ QueryExecutionContext, QueryMetadata, StoreQueryParams, StoreQueryPlan, }; use std::collections::HashMap; diff --git a/asap-query-engine/src/data_model/mod.rs b/asap-query-engine/src/data_model/mod.rs index 9444aadcc..8527e081c 100644 --- a/asap-query-engine/src/data_model/mod.rs +++ b/asap-query-engine/src/data_model/mod.rs @@ -1,6 +1,5 @@ pub mod aggregation_config; pub mod aggregation_reference; -pub mod backend_storage_routing; pub mod enums; pub mod hot_reload_config; pub mod inference_config; @@ -14,7 +13,6 @@ pub mod traits; pub use aggregation_config::*; pub use aggregation_reference::*; -pub use backend_storage_routing::*; pub use enums::*; pub use hot_reload_config::*; pub use inference_config::*; @@ -25,3 +23,12 @@ pub use promql_schema::*; pub use query_config::*; pub use streaming_config::*; pub use traits::*; + +// Step-1 of the JSONL deprecation refactor moved +// `backend_storage_routing` into the new `crate::routing` module +// alongside the engine router. Re-export here to keep +// `crate::data_model::BackendStorageRouting` compiling for any +// transitive caller that hasn't been migrated yet. +pub use crate::routing::{ + classify_query_shape, BackendStorageRouting, QueryShape, RoutingTarget, +}; diff --git a/asap-query-engine/src/drivers/query/adapters/config.rs b/asap-query-engine/src/drivers/query/adapters/config.rs index 8a3e782a8..948237ae6 100644 --- a/asap-query-engine/src/drivers/query/adapters/config.rs +++ b/asap-query-engine/src/drivers/query/adapters/config.rs @@ -60,75 +60,6 @@ impl AdapterConfig { ) } - /// Prometheus + cold-tier fallback chain (§5.2 of the sketch-DB design). - /// - /// Composes a [`ColdFallback`](crate::drivers::query::fallback::ColdFallback) - /// in front of a [`PrometheusHttpFallback`](crate::drivers::query::fallback::PrometheusHttpFallback) - /// so capability-misses first try the raw cold tier (exact - /// answers for supported query shapes) and only hit the live - /// Prometheus if the cold adapter can't handle the shape. - /// - /// * `cold_root` — local-FS root that mirrors the S3 key - /// layout documented in - /// [`cold_store::format`](crate::drivers::query::fallback::cold_store::format). - /// Swap in an S3-backed [`ColdStore`](crate::drivers::query::fallback::ColdStore) - /// impl later without touching this config. - /// * `prom_fallback_url` — upstream Prometheus used for the - /// tail of the fallback chain; set to `None` to short-circuit - /// unsupported shapes with an empty vector instead of - /// forwarding. - pub fn prometheus_promql_with_cold( - cold_root: std::path::PathBuf, - prom_fallback_url: Option, - ) -> Self { - use crate::drivers::query::fallback::{ - ColdFallback, LocalFsColdStore, PrometheusHttpFallback, - }; - - let cold_store = Arc::new(LocalFsColdStore::new(cold_root)); - let cold = ColdFallback::new(cold_store); - let cold: Arc = match prom_fallback_url { - Some(url) => { - let prom: Arc = Arc::new(PrometheusHttpFallback::new(url)); - Arc::new(cold.with_inner(prom)) - } - None => Arc::new(cold), - }; - - Self::new( - QueryProtocol::PrometheusHttp, - QueryLanguage::promql, - Some(cold), - ) - } - - /// Pick between [`Self::prometheus_promql`] and - /// [`Self::prometheus_promql_with_cold`] based on whether the - /// caller has a cold-store root configured (`--cold-store-root` - /// CLI flag or `ASAP_COLD_STORE_ROOT` env var). - /// - /// Wired into both binaries that face deployment: - /// `query_engine_rust` (`src/main.rs`) and `precompute_engine` - /// (`src/bin/precompute_engine.rs`). Centralised here so the - /// behaviour matrix only lives in one place. - pub fn from_prom_with_optional_cold( - prometheus_server: String, - forward_unsupported: bool, - cold_store_root: Option<&std::path::Path>, - ) -> Self { - match cold_store_root { - Some(root) => { - let prom = if forward_unsupported { - Some(prometheus_server) - } else { - None - }; - Self::prometheus_promql_with_cold(root.to_path_buf(), prom) - } - None => Self::prometheus_promql(prometheus_server, forward_unsupported), - } - } - /// Create a configuration for ClickHouse HTTP with SQL /// Convenience constructor for ClickHouse adapter pub fn clickhouse_sql(base_url: String, database: String, forward_unsupported: bool) -> Self { diff --git a/asap-query-engine/src/drivers/query/fallback/cold_store/format.rs b/asap-query-engine/src/drivers/query/fallback/cold_store/format.rs deleted file mode 100644 index 8258db431..000000000 --- a/asap-query-engine/src/drivers/query/fallback/cold_store/format.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! JSONL raw-sample format + key-prefix helpers. -//! -//! The format is intentionally boring: one JSON object per line, -//! one sample per object. That makes it cheap for an OTel exporter -//! or a test fixture to produce and for any reader (Python, jq, -//! ClickHouse external table, etc.) to consume. -//! -//! Key layout is hour-bucketed so a range scan that spans `N` -//! hours touches at most `N` key-prefixes regardless of ingest -//! rate. The same layout works on S3 (list-objects-v2 with -//! `Prefix`) without modification. - -use chrono::{DateTime, Datelike, Timelike, Utc}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::path::Path; -use tracing::warn; - -use super::ColdStoreError; - -/// A single raw observability sample as written by the cold -/// exporter. `labels` is a `BTreeMap` so the on-disk JSON is -/// deterministic per sample (useful for golden tests). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct RawSample { - pub ts_ms: i64, - pub labels: BTreeMap, - pub value: f64, -} - -/// Key-prefix for the hour-bucket containing `ts_ms`, relative to -/// the cold-store root. Identical shape for local-FS and S3. -/// -/// Example: `raw/http_requests_total/2026/04/21/08/` -pub fn part_path_prefix(metric: &str, ts_ms: i64) -> String { - let dt: DateTime = DateTime::::from_timestamp_millis(ts_ms) - .unwrap_or_else(|| DateTime::::from_timestamp(0, 0).unwrap()); - format!( - "raw/{}/{:04}/{:02}/{:02}/{:02}/", - metric, - dt.year(), - dt.month(), - dt.day(), - dt.hour(), - ) -} - -/// Enumerate the hour-bucket prefixes covering the half-open -/// range `[start_ms, end_ms)`. Always returns at least one bucket -/// (the one containing `start_ms`). Used by `ColdStore` impls to -/// drive object-listing / directory-walk. -pub fn hour_prefixes(metric: &str, start_ms: i64, end_ms: i64) -> Vec { - if end_ms <= start_ms { - return vec![part_path_prefix(metric, start_ms)]; - } - const HOUR_MS: i64 = 3_600_000; - let first_hour = (start_ms / HOUR_MS) * HOUR_MS; - // Align `end` up to the next hour boundary; we scan strictly - // *less than* `end_ms` so the last included bucket is the one - // containing `end_ms - 1`. - let last_hour = ((end_ms - 1) / HOUR_MS) * HOUR_MS; - let mut out = Vec::new(); - let mut cur = first_hour; - while cur <= last_hour { - out.push(part_path_prefix(metric, cur)); - cur += HOUR_MS; - } - out -} - -/// Parse a `.jsonl` blob into `RawSample`s, filtering to the -/// half-open range `[start_ms, end_ms)`. Mid-file malformed lines -/// are hard errors. A malformed *trailing* line is tolerated -/// (warn + drop) only when the blob has no terminating newline — -/// that's the producer-mid-flush shape, see `docs/design-sketch-db.md` -/// §5.2. `path` is threaded through purely for the warn log. -pub fn parse_jsonl( - bytes: &[u8], - start_ms: i64, - end_ms: i64, -) -> Result, ColdStoreError> { - parse_jsonl_at(bytes, start_ms, end_ms, None) -} - -pub fn parse_jsonl_at( - bytes: &[u8], - start_ms: i64, - end_ms: i64, - path: Option<&Path>, -) -> Result, ColdStoreError> { - let text = std::str::from_utf8(bytes) - .map_err(|e| ColdStoreError::Malformed(format!("non-utf8: {e}")))?; - let trailing_torn = !text.is_empty() && !text.ends_with('\n'); - let lines: Vec<&str> = text.lines().collect(); - let last = lines.len().saturating_sub(1); - let mut out = Vec::new(); - for (i, raw) in lines.iter().enumerate() { - let line = raw.trim(); - if line.is_empty() { - continue; - } - match serde_json::from_str::(line) { - Ok(s) if s.ts_ms >= start_ms && s.ts_ms < end_ms => out.push(s), - Ok(_) => {} - Err(e) if i == last && trailing_torn => warn!( - path = %path.map(|p| p.display().to_string()).unwrap_or_default(), - line = %line.chars().take(200).collect::(), - error = %e, - "cold-store: dropping torn trailing line in JSONL part \ - (no terminating newline; likely producer mid-flush)" - ), - Err(e) => return Err(ColdStoreError::Malformed(format!("line {}: {}", i + 1, e))), - } - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - use chrono::TimeZone; - - fn ts_ms(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> i64 { - Utc.with_ymd_and_hms(year, month, day, hour, minute, 0) - .unwrap() - .timestamp_millis() - } - - #[test] - fn prefix_shape() { - let ts = ts_ms(2026, 4, 21, 8, 15); - assert_eq!( - part_path_prefix("http_requests_total", ts), - "raw/http_requests_total/2026/04/21/08/" - ); - } - - #[test] - fn hour_prefixes_single_bucket() { - let start = ts_ms(2026, 4, 21, 8, 15); - let end = start + 60_000; - let p = hour_prefixes("m", start, end); - assert_eq!(p.len(), 1); - } - - #[test] - fn hour_prefixes_span_two_hours() { - let start = ts_ms(2026, 4, 21, 8, 15); - let end = start + 3_600_000 + 1; - let p = hour_prefixes("m", start, end); - assert_eq!(p.len(), 2); - assert_ne!(p[0], p[1]); - } - - #[test] - fn parse_jsonl_filters_range() { - let blob = r#"{"ts_ms":100,"labels":{"a":"1"},"value":1.0} -{"ts_ms":200,"labels":{"a":"2"},"value":2.0} -{"ts_ms":300,"labels":{"a":"3"},"value":3.0} -"#; - let out = parse_jsonl(blob.as_bytes(), 150, 300).unwrap(); - // end is exclusive -> only ts=200 matches - assert_eq!(out.len(), 1); - assert_eq!(out[0].ts_ms, 200); - } - - #[test] - fn parse_jsonl_malformed_errs() { - let blob = "not-json\n"; - assert!(parse_jsonl(blob.as_bytes(), 0, i64::MAX).is_err()); - } - - /// Pins follow-up #5: producer is mid-flush, reader sees a - /// part file whose last line is partial (no terminating - /// newline). We must surface the N preceding records, not - /// fail the whole scan. - #[test] - fn parse_jsonl_ignores_torn_trailing_line() { - let blob = "{\"ts_ms\":100,\"labels\":{\"a\":\"1\"},\"value\":1.0}\n\ - {\"ts_ms\":200,\"labels\":{\"a\":\"2\"},\"value\":2.0}\n\ - {\"ts_ms\":300,\"labels\":{\"a\":\"3\"},\"value\":3.0}\n\ - {\"ts_ms\":400,\"labels\":{\"a\":\"4\"},\"valu"; - let out = parse_jsonl(blob.as_bytes(), 0, i64::MAX).expect("torn tail must not error"); - assert_eq!(out.len(), 3); - assert_eq!(out[0].ts_ms, 100); - assert_eq!(out[2].ts_ms, 300); - } - - /// A malformed line in the *middle* of the file is real - /// corruption, not a concurrent-write torn tail — must still - /// hard-error so callers can route around the bad part. - #[test] - fn parse_jsonl_errors_on_mid_file_corruption() { - let blob = "{\"ts_ms\":100,\"labels\":{\"a\":\"1\"},\"value\":1.0}\n\ - not-json\n\ - {\"ts_ms\":300,\"labels\":{\"a\":\"3\"},\"value\":3.0}\n"; - let err = parse_jsonl(blob.as_bytes(), 0, i64::MAX) - .expect_err("mid-file corruption must surface as an error"); - match err { - ColdStoreError::Malformed(msg) => assert!( - msg.contains("line 2"), - "expected lineno in error, got: {msg}" - ), - other => panic!("expected Malformed, got {other:?}"), - } - } -} diff --git a/asap-query-engine/src/drivers/query/fallback/cold_store/local_fs.rs b/asap-query-engine/src/drivers/query/fallback/cold_store/local_fs.rs deleted file mode 100644 index 781dc75c3..000000000 --- a/asap-query-engine/src/drivers/query/fallback/cold_store/local_fs.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Local-filesystem [`ColdStore`] impl. -//! -//! Walks the same directory layout an S3 bucket would hold, so a -//! future `S3ColdStore` can drop in without the adapter caring. -//! Used today by tests and by the single-node evaluation -//! deployment. -//! -//! Concurrency: scans read each file via `tokio::fs::read`, so -//! multiple overlapping scans can progress in parallel without -//! serialization. - -use async_trait::async_trait; -use std::path::{Path, PathBuf}; - -use super::format::{hour_prefixes, parse_jsonl}; -use super::{ColdStore, ColdStoreError, RawSample}; - -/// Cold store backed by a local directory tree. -pub struct LocalFsColdStore { - root: PathBuf, -} - -impl LocalFsColdStore { - /// Create a store rooted at `root`. The directory must exist; - /// producing raw dumps is the exporter's job, not the reader's. - pub fn new(root: impl Into) -> Self { - Self { root: root.into() } - } - - pub fn root(&self) -> &Path { - &self.root - } - - /// List the JSONL parts inside `prefix_dir`, sorted by file name - /// so scans over the same input are deterministic. - async fn list_parts(&self, prefix_dir: &Path) -> Result, ColdStoreError> { - let mut out = Vec::new(); - let mut rd = match tokio::fs::read_dir(prefix_dir).await { - Ok(rd) => rd, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), - Err(e) => return Err(e.into()), - }; - while let Some(entry) = rd.next_entry().await? { - let path = entry.path(); - if path - .extension() - .and_then(|s| s.to_str()) - .is_some_and(|ext| ext == "jsonl") - { - out.push(path); - } - } - out.sort(); - Ok(out) - } -} - -#[async_trait] -impl ColdStore for LocalFsColdStore { - async fn scan( - &self, - metric: &str, - start_ms: i64, - end_ms: i64, - ) -> Result, ColdStoreError> { - let mut out = Vec::new(); - for prefix in hour_prefixes(metric, start_ms, end_ms) { - let dir = self.root.join(&prefix); - for part in self.list_parts(&dir).await? { - let bytes = tokio::fs::read(&part).await?; - let mut samples = parse_jsonl(&bytes, start_ms, end_ms)?; - out.append(&mut samples); - } - } - Ok(out) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::{TimeZone, Utc}; - use std::collections::BTreeMap; - use tempfile::TempDir; - - fn hour_ms(year: i32, month: u32, day: u32, hour: u32) -> i64 { - Utc.with_ymd_and_hms(year, month, day, hour, 0, 0) - .unwrap() - .timestamp_millis() - } - - async fn write_part(root: &Path, rel: &str, lines: &[RawSample]) { - let dir = root.join(rel); - tokio::fs::create_dir_all(&dir).await.unwrap(); - let mut buf = String::new(); - for s in lines { - buf.push_str(&serde_json::to_string(s).unwrap()); - buf.push('\n'); - } - tokio::fs::write(dir.join("part-000001.jsonl"), buf) - .await - .unwrap(); - } - - fn sample(ts_ms: i64, labels: &[(&str, &str)], value: f64) -> RawSample { - RawSample { - ts_ms, - labels: labels - .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) - .collect::>(), - value, - } - } - - #[tokio::test] - async fn scan_returns_samples_in_range() { - let tmp = TempDir::new().unwrap(); - let base = hour_ms(2026, 4, 21, 8); - write_part( - tmp.path(), - "raw/http_requests_total/2026/04/21/08/", - &[ - sample(base + 1_000, &[("zone", "a")], 1.0), - sample(base + 2_000, &[("zone", "b")], 2.0), - ], - ) - .await; - - let s = LocalFsColdStore::new(tmp.path()); - let got = s - .scan("http_requests_total", base, base + 60_000) - .await - .unwrap(); - assert_eq!(got.len(), 2); - } - - #[tokio::test] - async fn scan_prunes_by_time() { - let tmp = TempDir::new().unwrap(); - let base = hour_ms(2026, 4, 21, 8); - write_part( - tmp.path(), - "raw/m/2026/04/21/08/", - &[ - sample(base + 1_000, &[], 1.0), - sample(base + 60_000, &[], 2.0), - ], - ) - .await; - - let s = LocalFsColdStore::new(tmp.path()); - let got = s.scan("m", base, base + 30_000).await.unwrap(); - assert_eq!(got.len(), 1); - assert_eq!(got[0].value, 1.0); - } - - #[tokio::test] - async fn scan_missing_prefix_is_empty_not_error() { - let tmp = TempDir::new().unwrap(); - let s = LocalFsColdStore::new(tmp.path()); - let got = s.scan("never_written", 0, 1).await.unwrap(); - assert!(got.is_empty()); - } - - #[tokio::test] - async fn scan_spans_hour_boundary() { - let tmp = TempDir::new().unwrap(); - let h8 = hour_ms(2026, 4, 21, 8); - let h9 = hour_ms(2026, 4, 21, 9); - write_part( - tmp.path(), - "raw/m/2026/04/21/08/", - &[sample(h8 + 3_599_000, &[], 1.0)], - ) - .await; - write_part( - tmp.path(), - "raw/m/2026/04/21/09/", - &[sample(h9 + 1_000, &[], 2.0)], - ) - .await; - - let s = LocalFsColdStore::new(tmp.path()); - let got = s.scan("m", h8 + 3_598_000, h9 + 2_000).await.unwrap(); - assert_eq!(got.len(), 2); - } -} diff --git a/asap-query-engine/src/drivers/query/fallback/cold_store/mod.rs b/asap-query-engine/src/drivers/query/fallback/cold_store/mod.rs deleted file mode 100644 index 6e5cd89a6..000000000 --- a/asap-query-engine/src/drivers/query/fallback/cold_store/mod.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! Cold raw-sample store used by the §5.2 cold-query fallback. -//! -//! In the paper architecture, the edge OTel collector dumps raw -//! observability data to a cheap cold tier (S3) in parallel with -//! the sketch path. When a query hits a capability-miss — most -//! notably a `TimelineCoverage::Purged` segment whose sketch was -//! aged out — the engine falls through to this store to recover -//! an exact answer from raw records. -//! -//! This module exposes a **storage-agnostic** `ColdStore` trait so -//! the same `s3_adapter` fallback can point at either a local -//! filesystem root (used today + for tests) or a real S3 bucket -//! (future swap, identical object key layout — see -//! [`format::part_path_prefix`]). -//! -//! # Format -//! -//! Raw samples live under a deterministic key tree: -//! -//! ```text -//! /raw//YYYY/MM/DD/HH/part-NNNNNN.jsonl -//! ``` -//! -//! Each line is one sample encoded as JSON: -//! -//! ```json -//! {"ts_ms": 1713657600000, "labels": {"zone": "a"}, "value": 42.5} -//! ``` -//! -//! See [`format`] for serialization and path helpers. - -use async_trait::async_trait; -use std::collections::BTreeMap; -use thiserror::Error; - -pub mod format; -pub mod gorilla_s3; -pub mod local_fs; -pub mod s3_cost_tracker; - -pub use format::{part_path_prefix, RawSample}; -pub use gorilla_s3::{GorillaS3ColdStore, GorillaS3Config, GorillaS3ConfigError}; -pub use local_fs::LocalFsColdStore; -pub use s3_cost_tracker::{ - global_s3_cost_counters, S3CostCounters, S3CostSnapshot, S3CostTrackingObjectStore, -}; - -/// Error surface for cold-store scans. -#[derive(Debug, Error)] -pub enum ColdStoreError { - #[error("I/O error: {0}")] - Io(#[from] std::io::Error), - #[error("malformed raw record: {0}")] - Malformed(String), - /// Backend-storage error (e.g. an S3 GET failed) that is not - /// itself a `std::io::Error`. Phase 3 introduced this variant for - /// the Gorilla-S3 cold store; the local-FS path keeps using - /// [`ColdStoreError::Io`]. - #[error("backend error: {0}")] - Backend(String), - /// A trait method that this `ColdStore` impl does not support. - /// Returned by the default `list_chunks` / `read_chunk` impls on - /// JSONL-only stores; Gorilla-S3 / future chunk-native stores - /// override. - #[error("unsupported cold-store operation: {0}")] - Unsupported(&'static str), -} - -/// Descriptor for a single immutable cold-store chunk. -/// -/// Returned by [`ColdStore::list_chunks`] for chunk-native backends -/// (Phase 3+ Gorilla-S3). Carries enough metadata for callers to -/// prune by time / label without reading the chunk body. -#[derive(Debug, Clone, PartialEq)] -pub struct ChunkRef { - /// Opaque object key (e.g. an S3 key). The Telegraf-side - /// `gorilla_s3` output uses - /// `/block---.gorilla`; the - /// design.md-style layout is `//YYYY/MM/DD/HH/ - /// part-NNNNNN.gor`. Either is fine — the index file is the - /// source of truth for what keys exist. - pub key: String, - /// Metric name the chunk was fetched against. Recovered from - /// the caller's `list_chunks` request rather than the on-wire - /// chunk metadata, since not all backends require chunks to be - /// metric-pure. - pub metric: String, - /// `(start_unix_ms, end_unix_ms)` covered by the chunk — - /// converted from the on-wire nanosecond range so it can be - /// directly compared with [`ColdStore::scan`]'s - /// `[start_ms, end_ms)` window. - pub time_range_ms: (i64, i64), - /// 64-bit canonical-label-set hash — for prune-by-label-equality - /// without fetching the chunk. - pub label_hash: u64, - /// Number of samples in the chunk. - pub sample_count: u32, - /// On-wire size of the chunk object in bytes. - pub size_bytes: u32, -} - -/// **mvp/v5**: postings result returned by [`ColdStore::list_postings_for`]. -/// -/// `series_ids` is the union of `series_id` lists across all hour -/// buckets in the requested time range, deduped and sorted ascending. -/// `postings_present_buckets` counts how many hour buckets actually -/// had a postings sidecar — used by the engine to decide whether to -/// emit a `data_source_quirk: postings_missing` annotation. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct PostingsHits { - /// Series ids matching all label predicates, sorted ascending, - /// deduplicated. - pub series_ids: Vec, - /// Hour buckets in the request window. - pub buckets_in_range: usize, - /// Buckets that actually had a `postings-v1.json` sidecar. - pub buckets_with_postings: usize, -} - -impl PostingsHits { - /// `true` iff at least one hour bucket carried postings — - /// indicates the postings-aware filter ran on real data and the - /// caller should trust [`Self::series_ids`] as a complete answer. - pub fn fully_covered(&self) -> bool { - self.buckets_in_range > 0 && self.buckets_with_postings == self.buckets_in_range - } - - /// `true` iff postings were present for every bucket AND at - /// least one matched series. - pub fn nonempty_and_complete(&self) -> bool { - self.fully_covered() && !self.series_ids.is_empty() - } -} - -/// Read-only view over a cold raw-sample store. -/// -/// Scans are `(metric, [start_ms, end_ms))` — inclusive start, -/// exclusive end — matching the half-open range convention used by -/// the rest of the engine. Implementations are expected to: -/// -/// * prune by metric via the `/` key prefix, -/// * prune by hour via the `YYYY/MM/DD/HH/` key prefix, -/// * scan inside candidate parts and emit only samples whose -/// `ts_ms` falls in the requested range. -/// -/// Label matching is **not** pushed down here — callers filter -/// samples client-side. This keeps the trait small and makes the -/// local-FS / S3 impls trivially swappable. -/// -/// # Phase-3 trait extension -/// -/// The `list_chunks` / `read_chunk` pair is additive (default impls -/// return [`ColdStoreError::Unsupported`]) so the existing JSONL -/// `LocalFsColdStore` keeps compiling unchanged. Chunk-native -/// backends (Gorilla-S3) override both so the upcoming -/// `GorillaQueryEngine` can iterate chunks one at a time without -/// materialising every sample up front. See -/// [`docs/design-gorilla-s3-cold-engine.md` §7.2](#) for the -/// rationale. -#[async_trait] -pub trait ColdStore: Send + Sync { - /// Return all samples for `metric` whose timestamp lies in - /// `[start_ms, end_ms)`. Ordering is not guaranteed. - async fn scan( - &self, - metric: &str, - start_ms: i64, - end_ms: i64, - ) -> Result, ColdStoreError>; - - /// List chunk descriptors covering `[start_ms, end_ms)` without - /// decoding any bodies. Default impl returns - /// [`ColdStoreError::Unsupported`] — only chunk-native backends - /// (e.g. [`GorillaS3ColdStore`]) override. - async fn list_chunks( - &self, - _metric: &str, - _start_ms: i64, - _end_ms: i64, - ) -> Result, ColdStoreError> { - Err(ColdStoreError::Unsupported("list_chunks")) - } - - /// Decode a single chunk into an owned `Vec`. - /// - /// Returning `Vec` rather than a streaming iterator keeps the - /// trait object-safe and matches the existing `scan` contract; - /// chunks are bounded-size in practice (Phase 1 emits one series - /// per ~1 hour). The decoded samples can also be cached cheaply - /// by the impl. Default returns [`ColdStoreError::Unsupported`]. - async fn read_chunk( - &self, - _chunk: &ChunkRef, - ) -> Result, ColdStoreError> { - Err(ColdStoreError::Unsupported("read_chunk")) - } - - /// **mvp/v5**: load + intersect per-bucket postings under - /// `(metric, time_range)` for the supplied `(label_name, - /// label_value)` matchers. The result's `series_ids` is the - /// intersection across all matchers — i.e. only series_ids - /// that match every predicate. With zero matchers this returns - /// the union of every series_id in range (rare; the engine - /// short-circuits the postings-aware path before calling). - /// - /// Default impl returns [`ColdStoreError::Unsupported`] so - /// JSONL-only stores keep compiling. The Gorilla-S3 cold - /// store overrides. - async fn list_postings_for( - &self, - _metric: &str, - _start_ms: i64, - _end_ms: i64, - _matchers: &[(String, String)], - ) -> Result { - Err(ColdStoreError::Unsupported("list_postings_for")) - } -} - -/// Convenience alias: a label set as stored in a [`RawSample`]. -pub type LabelSet = BTreeMap; diff --git a/asap-query-engine/src/drivers/query/fallback/metrics.rs b/asap-query-engine/src/drivers/query/fallback/metrics.rs index c7fc12570..7237601c9 100644 --- a/asap-query-engine/src/drivers/query/fallback/metrics.rs +++ b/asap-query-engine/src/drivers/query/fallback/metrics.rs @@ -8,9 +8,8 @@ //! //! * **Hot** = the `SimpleEngine` handled the query from live //! sketch-backed state. -//! * **Cold** = the query fell through to a -//! [`ColdFallback`](super::s3_adapter::ColdFallback) and was -//! answered from the raw cold tier. +//! * **Cold** = the query was answered from the Gorilla archive +//! tier ([`crate::engines::gorilla::GorillaQueryEngine`]). //! //! The "shape" label is the parsed query's root op (`sum`, //! `count`, `avg`, `selector`, ...) — low-cardinality by design, @@ -33,9 +32,9 @@ lazy_static! { ) .unwrap(); - /// Queries served by the cold (raw S3 / local-FS) path, keyed + /// Queries served by the cold (Gorilla archive) path, keyed /// by `(metric, shape)`. Incremented once per successful - /// `ColdFallback::execute_query`. + /// archive answer. pub static ref QUERIES_COLD_TOTAL: CounterVec = register_counter_vec!( "queryengine_cold_queries_total", "Queries answered from the cold raw-sample path, keyed by metric + query shape", diff --git a/asap-query-engine/src/drivers/query/fallback/mod.rs b/asap-query-engine/src/drivers/query/fallback/mod.rs index 4950b31a4..d0b47c8b9 100644 --- a/asap-query-engine/src/drivers/query/fallback/mod.rs +++ b/asap-query-engine/src/drivers/query/fallback/mod.rs @@ -83,12 +83,8 @@ mod clickhouse; mod elastic; mod prometheus; -pub mod cold_store; pub mod metrics; -pub mod s3_adapter; pub use clickhouse::ClickHouseHttpFallback; -pub use cold_store::{ColdStore, ColdStoreError, LocalFsColdStore, RawSample}; pub use elastic::ElasticHttpFallback; pub use prometheus::PrometheusHttpFallback; -pub use s3_adapter::ColdFallback; diff --git a/asap-query-engine/src/drivers/query/fallback/s3_adapter.rs b/asap-query-engine/src/drivers/query/fallback/s3_adapter.rs deleted file mode 100644 index 64aa9c9c5..000000000 --- a/asap-query-engine/src/drivers/query/fallback/s3_adapter.rs +++ /dev/null @@ -1,572 +0,0 @@ -//! Cold-tier fallback adapter (§5.2 of the sketch-DB design). -//! -//! When a query hits a capability-miss — most notably a -//! `TimelineCoverage::Purged` segment whose sketch was aged out — -//! the server falls through to a [`FallbackClient`] impl. This -//! module provides [`ColdFallback`], which answers the query from -//! raw observability samples in a [`ColdStore`] (local FS today, -//! S3 tomorrow — identical key layout, see -//! [`super::cold_store::format`]). -//! -//! Supported query shapes (v1 paper scope): -//! -//! * bare instant vector selector: `metric_name{label="val",...}` -//! at time `t` — per-series latest value within `[t - 5m, t]` -//! (the Prometheus default lookback delta) -//! * scalar aggregation without grouping: -//! `sum|count|avg|min|max ( metric_name{...} )` over the same -//! instant vector -//! -//! Anything outside that surface delegates to the optional -//! `inner` fallback (typically a Prometheus proxy) — the cold -//! adapter chains with the existing §5.2 forwarding adapter -//! rather than replacing it. -//! -//! Accuracy: results computed here are exact on the set of -//! samples in the cold tier. The adapter does not attempt to -//! reconcile against missing/late samples — the raw tier is the -//! canonical source of truth per the paper architecture. - -use async_trait::async_trait; -use axum::http::StatusCode; -use promql_parser::label::MatchOp; -use promql_parser::parser::{Expr, VectorSelector}; -use serde_json::{json, Value}; -use std::collections::{BTreeMap, HashMap}; -use std::sync::Arc; -use tracing::{debug, warn}; - -use crate::drivers::query::adapters::{ParsedQueryRequest, PrometheusResponse}; - -use super::cold_store::{ColdStore, RawSample}; -use super::metrics::{BYTES_SERVED_COLD_TOTAL, QUERIES_COLD_TOTAL}; -use super::{FallbackClient, FallbackResponse}; - -/// Prometheus's default instant-query lookback delta (5 minutes). -/// Controls how far back the adapter scans the cold store to find -/// the most-recent sample per series. -const INSTANT_LOOKBACK_MS: i64 = 5 * 60 * 1_000; - -/// Cold-tier fallback client. Parametrised by the `ColdStore` -/// impl so the same adapter runs over `LocalFsColdStore` in tests -/// and over a (future) S3-backed store in production. -pub struct ColdFallback { - store: Arc, - /// Chain-of-responsibility: unsupported query shapes fall - /// through to this inner client. Typically a - /// [`PrometheusHttpFallback`](super::PrometheusHttpFallback) - /// pointing at the live Prometheus so development / demo - /// environments stay operational. - inner: Option>, -} - -impl ColdFallback { - pub fn new(store: Arc) -> Self { - Self { store, inner: None } - } - - pub fn with_inner(mut self, inner: Arc) -> Self { - self.inner = Some(inner); - self - } -} - -#[async_trait] -impl FallbackClient for ColdFallback { - async fn execute_query( - &self, - request: &ParsedQueryRequest, - ) -> Result { - let ast = match promql_parser::parser::parse(&request.query) { - Ok(a) => a, - Err(e) => { - warn!( - "cold fallback: PromQL parse failed ({}); delegating to inner", - e - ); - return self.delegate(request).await; - } - }; - - let plan = match plan_query(&ast) { - Some(p) => p, - None => { - debug!( - "cold fallback: unsupported query shape for '{}'; delegating to inner", - request.query - ); - return self.delegate(request).await; - } - }; - - let query_time_ms = (request.time * 1_000.0) as i64; - let start_ms = query_time_ms - INSTANT_LOOKBACK_MS; - let end_ms = query_time_ms + 1; - - let samples = match self.store.scan(&plan.metric, start_ms, end_ms).await { - Ok(s) => s, - Err(e) => { - warn!( - "cold fallback: scan failed for metric '{}': {}", - plan.metric, e - ); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - - // Telemetry — size is a stable proxy for "cold work" even - // when the post-filter result has zero samples. - let bytes = approx_wire_bytes(&samples); - let shape = plan.op.as_label(); - BYTES_SERVED_COLD_TOTAL - .with_label_values(&[plan.metric.as_str(), shape]) - .inc_by(bytes as f64); - QUERIES_COLD_TOTAL - .with_label_values(&[plan.metric.as_str(), shape]) - .inc(); - - let filtered = samples - .into_iter() - .filter(|s| plan.matches_labels(&s.labels)) - .collect::>(); - - let latest = latest_per_series(&filtered); - let data = compute_result(&plan, &latest, &request.query, query_time_ms); - let resp = PrometheusResponse::success(data); - let value = serde_json::to_value(resp).unwrap_or_else(|_| json!({"status":"error"})); - Ok(FallbackResponse::Json(value)) - } - - async fn execute_query_with_headers( - &self, - request: &ParsedQueryRequest, - _headers: HashMap, - ) -> Result { - self.execute_query(request).await - } - - async fn get_runtime_info(&self) -> Result { - // Runtime info is meaningless for a cold store; defer to - // inner if configured, else return empty. - match &self.inner { - Some(inner) => inner.get_runtime_info().await, - None => Ok(json!({})), - } - } -} - -impl ColdFallback { - async fn delegate(&self, request: &ParsedQueryRequest) -> Result { - match &self.inner { - Some(inner) => inner.execute_query(request).await, - None => { - // No chain — return an empty Prometheus success - // response rather than a 5xx; the adapter is - // advisory for query shapes it can't handle. - let resp = PrometheusResponse::success(json!({ - "resultType": "vector", - "result": [] - })); - Ok(FallbackResponse::Json( - serde_json::to_value(resp).unwrap_or(json!({"status":"error"})), - )) - } - } - } -} - -/// The query op we recognise for cold evaluation. Kept narrow so -/// the adapter's behaviour is obvious from the outside — anything -/// else delegates. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum QueryOp { - /// Bare instant vector (`metric{labels}`). - Selector, - Sum, - Count, - Avg, - Min, - Max, -} - -impl QueryOp { - fn as_label(&self) -> &'static str { - match self { - QueryOp::Selector => "selector", - QueryOp::Sum => "sum", - QueryOp::Count => "count", - QueryOp::Avg => "avg", - QueryOp::Min => "min", - QueryOp::Max => "max", - } - } - - fn from_agg_str(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "sum" => Some(QueryOp::Sum), - "count" => Some(QueryOp::Count), - "avg" => Some(QueryOp::Avg), - "min" => Some(QueryOp::Min), - "max" => Some(QueryOp::Max), - _ => None, - } - } -} - -/// Matcher set we understand. Regex matchers (`=~`, `!~`) are -/// deliberately out of scope for v1 — passing a regex matcher -/// causes the adapter to delegate upstream. -#[derive(Debug, Clone)] -struct LabelPredicate { - name: String, - value: String, - equals: bool, -} - -struct QueryPlan { - metric: String, - predicates: Vec, - op: QueryOp, -} - -impl QueryPlan { - fn matches_labels(&self, labels: &BTreeMap) -> bool { - for p in &self.predicates { - let hit = labels.get(&p.name).map(|v| v == &p.value).unwrap_or(false); - if p.equals && !hit { - return false; - } - if !p.equals && hit { - return false; - } - } - true - } -} - -/// Inspect the PromQL AST and, if it's a shape we support, return -/// the extracted `(metric, predicates, op)`. Returns `None` -/// otherwise — caller delegates to inner fallback. -fn plan_query(ast: &Expr) -> Option { - match ast { - Expr::VectorSelector(vs) => { - let (metric, predicates) = selector_to_plan(vs)?; - Some(QueryPlan { - metric, - predicates, - op: QueryOp::Selector, - }) - } - Expr::Paren(p) => plan_query(&p.expr), - Expr::Aggregate(agg) => { - // Only recognise no-grouping-modifier, no-param aggs — - // `sum by (...)` / `topk(k, expr)` exceed v1 scope. - if agg.modifier.is_some() { - return None; - } - if agg.param.is_some() { - return None; - } - let op = QueryOp::from_agg_str(&agg.op.to_string())?; - let vs = match agg.expr.as_ref() { - Expr::VectorSelector(vs) => vs, - Expr::Paren(p) => match p.expr.as_ref() { - Expr::VectorSelector(vs) => vs, - _ => return None, - }, - _ => return None, - }; - let (metric, predicates) = selector_to_plan(vs)?; - Some(QueryPlan { - metric, - predicates, - op, - }) - } - _ => None, - } -} - -fn selector_to_plan(vs: &VectorSelector) -> Option<(String, Vec)> { - let metric = vs.name.clone()?; - let mut predicates = Vec::new(); - for m in &vs.matchers.matchers { - // __name__ is already captured via vs.name — skip any - // explicit __name__ matcher that duplicates it. - if m.name == "__name__" { - continue; - } - let equals = match m.op { - MatchOp::Equal => true, - MatchOp::NotEqual => false, - _ => return None, // regex matchers out of scope - }; - predicates.push(LabelPredicate { - name: m.name.clone(), - value: m.value.clone(), - equals, - }); - } - Some((metric, predicates)) -} - -/// Reduce a pool of samples to one-per-unique-label-set, taking -/// the sample with the largest `ts_ms`. Mirrors Prometheus's -/// instant-vector semantics. -fn latest_per_series(samples: &[RawSample]) -> Vec { - let mut seen: HashMap, RawSample> = HashMap::new(); - for s in samples { - let key: Vec<_> = s - .labels - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - seen.entry(key) - .and_modify(|existing| { - if s.ts_ms > existing.ts_ms { - *existing = s.clone(); - } - }) - .or_insert_with(|| s.clone()); - } - seen.into_values().collect() -} - -/// Build the Prometheus HTTP `data` payload for the query result. -/// * `Selector` → vector with one entry per series. -/// * `sum|count|avg|min|max` → single-element vector with no -/// `__name__` label (matches `sum(foo)` Prometheus output). -fn compute_result( - plan: &QueryPlan, - latest: &[RawSample], - _query: &str, - query_time_ms: i64, -) -> Value { - let ts_s = (query_time_ms as f64) / 1_000.0; - match plan.op { - QueryOp::Selector => { - let items = latest - .iter() - .map(|s| { - let mut m = serde_json::Map::new(); - m.insert("__name__".to_string(), Value::String(plan.metric.clone())); - for (k, v) in &s.labels { - m.insert(k.clone(), Value::String(v.clone())); - } - json!({ - "metric": Value::Object(m), - "value": [ts_s, format_prom_float(s.value)], - }) - }) - .collect::>(); - json!({ - "resultType": "vector", - "result": items, - }) - } - op => { - let agg = aggregate(op, latest); - let value = match agg { - Some(v) => json!([ts_s, format_prom_float(v)]), - // Empty-input semantics: Prometheus returns empty - // result for sum/min/max/avg over no samples. - None => { - return json!({ - "resultType": "vector", - "result": [], - }) - } - }; - json!({ - "resultType": "vector", - "result": [ - { - "metric": {}, - "value": value, - } - ], - }) - } - } -} - -fn aggregate(op: QueryOp, latest: &[RawSample]) -> Option { - if latest.is_empty() { - // count over empty = 0 is what Prometheus does; other ops - // return empty vector. - return match op { - QueryOp::Count => Some(0.0), - _ => None, - }; - } - let xs = latest.iter().map(|s| s.value); - Some(match op { - QueryOp::Sum => xs.sum(), - QueryOp::Count => latest.len() as f64, - QueryOp::Avg => latest.iter().map(|s| s.value).sum::() / (latest.len() as f64), - QueryOp::Min => xs.fold(f64::INFINITY, f64::min), - QueryOp::Max => xs.fold(f64::NEG_INFINITY, f64::max), - QueryOp::Selector => unreachable!("Selector handled in compute_result"), - }) -} - -/// Stable, Prometheus-style float formatting for the HTTP JSON -/// `value` field — integers render without a trailing `.0`. -fn format_prom_float(v: f64) -> String { - if v.is_nan() { - return "NaN".to_string(); - } - if v.is_infinite() { - return if v > 0.0 { - "+Inf".into() - } else { - "-Inf".into() - }; - } - if v == v.trunc() && v.abs() < 1e15 { - format!("{}", v as i64) - } else { - format!("{v}") - } -} - -fn approx_wire_bytes(samples: &[RawSample]) -> usize { - // Rough proxy: one JSON line per sample. Avoids re-serialising - // every scan into memory just to count. Good enough for - // observability dashboards. - samples - .iter() - .map(|s| { - let label_bytes: usize = s - .labels - .iter() - .map(|(k, v)| k.len() + v.len() + 6) // "k":"v", - .sum(); - // {"ts_ms":<13>,"labels":{...},"value":<~20>} + newline - 13 + 12 + label_bytes + 12 + 20 + 2 - }) - .sum() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn rs(ts_ms: i64, labels: &[(&str, &str)], value: f64) -> RawSample { - RawSample { - ts_ms, - labels: labels - .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) - .collect(), - value, - } - } - - #[test] - fn plan_bare_selector() { - let ast = promql_parser::parser::parse("up{zone=\"a\"}").unwrap(); - let plan = plan_query(&ast).unwrap(); - assert_eq!(plan.metric, "up"); - assert_eq!(plan.predicates.len(), 1); - assert_eq!(plan.op, QueryOp::Selector); - } - - #[test] - fn plan_sum_over_selector() { - let ast = promql_parser::parser::parse("sum(up)").unwrap(); - let plan = plan_query(&ast).unwrap(); - assert_eq!(plan.op, QueryOp::Sum); - } - - #[test] - fn plan_sum_by_is_unsupported() { - let ast = promql_parser::parser::parse("sum by (zone) (up)").unwrap(); - assert!(plan_query(&ast).is_none()); - } - - #[test] - fn plan_rate_is_unsupported() { - let ast = promql_parser::parser::parse("rate(up[1m])").unwrap(); - assert!(plan_query(&ast).is_none()); - } - - #[test] - fn plan_regex_matcher_is_unsupported() { - let ast = promql_parser::parser::parse("up{zone=~\"a.*\"}").unwrap(); - assert!(plan_query(&ast).is_none()); - } - - #[test] - fn latest_per_series_picks_newest() { - let ss = vec![ - rs(100, &[("zone", "a")], 1.0), - rs(200, &[("zone", "a")], 2.0), - rs(150, &[("zone", "b")], 9.0), - ]; - let got = latest_per_series(&ss); - assert_eq!(got.len(), 2); - let a = got.iter().find(|s| s.labels["zone"] == "a").unwrap(); - let b = got.iter().find(|s| s.labels["zone"] == "b").unwrap(); - assert_eq!(a.value, 2.0); - assert_eq!(b.value, 9.0); - } - - #[test] - fn aggregate_ops() { - let samples = vec![ - rs(1, &[("z", "a")], 1.0), - rs(1, &[("z", "b")], 2.0), - rs(1, &[("z", "c")], 3.0), - ]; - assert_eq!(aggregate(QueryOp::Sum, &samples), Some(6.0)); - assert_eq!(aggregate(QueryOp::Count, &samples), Some(3.0)); - assert_eq!(aggregate(QueryOp::Avg, &samples), Some(2.0)); - assert_eq!(aggregate(QueryOp::Min, &samples), Some(1.0)); - assert_eq!(aggregate(QueryOp::Max, &samples), Some(3.0)); - } - - #[test] - fn aggregate_empty_count_is_zero() { - assert_eq!(aggregate(QueryOp::Count, &[]), Some(0.0)); - assert_eq!(aggregate(QueryOp::Sum, &[]), None); - } - - #[test] - fn format_prom_float_matches_prometheus_conventions() { - assert_eq!(format_prom_float(1.0), "1"); - assert_eq!(format_prom_float(1.5), "1.5"); - assert_eq!(format_prom_float(f64::INFINITY), "+Inf"); - assert_eq!(format_prom_float(f64::NAN), "NaN"); - } - - #[test] - fn label_predicate_equal_and_not_equal() { - let plan = QueryPlan { - metric: "m".into(), - predicates: vec![ - LabelPredicate { - name: "zone".into(), - value: "a".into(), - equals: true, - }, - LabelPredicate { - name: "env".into(), - value: "prod".into(), - equals: false, - }, - ], - op: QueryOp::Selector, - }; - let match_a: BTreeMap = [("zone", "a"), ("env", "dev")] - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - let no_match: BTreeMap = [("zone", "a"), ("env", "prod")] - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - assert!(plan.matches_labels(&match_a)); - assert!(!plan.matches_labels(&no_match)); - } -} diff --git a/asap-query-engine/src/drivers/query/servers/http.rs b/asap-query-engine/src/drivers/query/servers/http.rs index b5749ba8c..6b4700183 100644 --- a/asap-query-engine/src/drivers/query/servers/http.rs +++ b/asap-query-engine/src/drivers/query/servers/http.rs @@ -16,7 +16,8 @@ use tracing::{debug, info, warn}; use crate::drivers::query::adapters::{create_http_adapter, AdapterConfig, HttpProtocolAdapter}; use crate::drivers::query::servers::metrics as srv_metrics; -use crate::engines::{EngineRouter, EngineRouterError, QueryEngine, SimpleEngine}; +use crate::engines::SimpleEngine; +use crate::routing::{EngineRouter, EngineRouterError, QueryEngine}; use crate::query_tracker::QueryTracker; use crate::stores::Store; use asap_types::{AccuracyTarget, StorageBackend}; @@ -35,7 +36,8 @@ use promql_utilities::query_logics::enums::Statistic; /// (Fix 1) for the design rationale. /// /// Recognised values match `StorageBackend::data_source_id()` — -/// `sketch_warm`, `gorilla_archive`, `cold_jsonl`, `double_write`. An +/// `sketch_warm`, `gorilla_archive`, `double_write`. (Step-1 of +/// the JSONL deprecation removed the `cold_jsonl` value.) An /// unknown value returns 400. pub const ENGINE_OVERRIDE_HEADER: &str = "X-ASAP-Engine"; pub const ENGINE_OVERRIDE_QUERY_PARAM: &str = "engine"; @@ -795,7 +797,7 @@ async fn process_via_named_engine( /// `DoubleWrite` head-selection heuristic (other deploy shapes /// degenerate to a fixed list keyed only by `metric_storage`), so /// defaulting to `(Sum, Approximate)` is safe for `GorillaS3Archive`- -/// only and `ColdJsonlFallback`-only deploys. A follow-up will thread +/// only deploys. A follow-up will thread /// the real values through once the Phase-6 query-tracker exposes /// them per request. /// @@ -823,8 +825,8 @@ async fn process_via_router( // Default `(Sum, Approximate)` — see fn doc above. The router's // capability table only consults these axes for `DoubleWrite` - // metrics; for `GorillaS3Archive`-only and `ColdJsonlFallback`-only - // deploys the dispatch is a function of `metric_storage` alone. + // metrics; for `GorillaS3Archive`-only deploys the dispatch + // is a function of `metric_storage` alone. let stat = Statistic::Sum; let accuracy = AccuracyTarget::Approximate; @@ -1231,7 +1233,7 @@ async fn handle_metrics() -> impl IntoResponse { // exposition. Mirrors `/internal/s3_cost.csv` — the CSV is for // the demo, this is for live dashboards. let counters = - crate::drivers::query::fallback::cold_store::global_s3_cost_counters(); + crate::engines::gorilla::global_s3_cost_counters(); buffer.extend_from_slice(counters.render_prometheus().as_bytes()); ( [( @@ -1249,7 +1251,7 @@ async fn handle_metrics() -> impl IntoResponse { /// the CSV is still well-formed). async fn handle_s3_cost_csv() -> impl IntoResponse { let counters = - crate::drivers::query::fallback::cold_store::global_s3_cost_counters(); + crate::engines::gorilla::global_s3_cost_counters(); ( [( axum::http::header::CONTENT_TYPE, @@ -2526,7 +2528,8 @@ aggregations: // carries a `data_source: ` info-line so dashboards / e2e // tests can byte-compare which engine answered. - use crate::engines::{EngineCapabilities, EngineError, QueryEngine, QueryResult}; + use crate::engines::{EngineError, QueryResult}; + use crate::routing::{EngineCapabilities, QueryEngine}; use async_trait::async_trait; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -2705,8 +2708,8 @@ aggregations: // metric at a backend whose data_source_id doesn't match // any registered engine — since `HttpServer::new` only // registers SimpleEngine (sketch_warm), routing a - // `ColdJsonlFallback`-only metric trips the empty path - // (compatible_storage_backends = [ColdJsonlFallback], no + // `GorillaS3Archive`-only metric trips the empty path + // (compatible_storage_backends = [GorillaS3Archive], no // engine registered for that id). setup_test_server_with_router(metric_storage_backend, Vec::new()).await } @@ -2819,14 +2822,16 @@ aggregations: #[tokio::test] async fn http_returns_503_when_no_engines_registered() { - // Pin `storage_backend = ColdJsonlFallback` but register no - // engine for that id (only `SimpleEngine` is registered, and - // it lives under `sketch_warm`). The router walks - // `compatible_storage_backends = [ColdJsonlFallback]` and + // Pin `storage_backend = GorillaS3Archive` but register no + // archive engine (only `SimpleEngine` is registered under + // `sketch_warm`). The router walks + // `compatible_storage_backends = [GorillaS3Archive]` and // bails out with `NoEngineRegistered`, which the HTTP layer - // surfaces as 503. + // surfaces as 503. Step-1 of the JSONL deprecation removed + // the `ColdJsonlFallback` failover slot, so this is the + // canonical "engine missing" path now. let server_port = - setup_test_server_with_empty_router(StorageBackend::ColdJsonlFallback).await; + setup_test_server_with_empty_router(StorageBackend::GorillaS3Archive).await; let client = Client::new(); let resp = client .get(format!("http://127.0.0.1:{server_port}/api/v1/query")) @@ -2907,29 +2912,25 @@ aggregations: } #[tokio::test] - async fn http_router_falls_through_to_jsonl_when_archive_fails() { - // Optional (graceful fallback) — verifies that a - // `DoubleWrite` deploy whose archive engine errors does NOT - // surface a 5xx; the router walks the compatibility list and - // ColdJsonlFallback answers. Pins the §8 behaviour of - // `design-gorilla-s3-cold-engine.md`. - let (gorilla_failing, gorilla_calls) = + async fn http_router_serves_double_write_via_warm_head() { + // Step-1 of the JSONL deprecation deleted the + // `ColdJsonlFallback` last-resort slot; the surviving + // failover surface is warm-tier sketch ↔ Gorilla-S3 archive. + // The HTTP handler dispatches with default + // `(Statistic::Sum, AccuracyTarget::Approximate)`, so for a + // `DoubleWrite` metric the compatibility list is + // `[SketchWarmTier, GorillaS3Archive]` and the warm-tier + // mock answers first. The archive must NOT be hit (no + // failover needed when the head succeeds). + let (warm_ok, warm_calls) = + MockQueryEngine::new(StorageBackend::SketchWarmTier, MockOutcome::OkEmpty); + let (archive, archive_calls) = MockQueryEngine::new(StorageBackend::GorillaS3Archive, MockOutcome::Backend); - let (jsonl_ok, jsonl_calls) = - MockQueryEngine::new(StorageBackend::ColdJsonlFallback, MockOutcome::OkEmpty); - // SimpleEngine is registered under `sketch_warm` by - // `HttpServer::new`; for `DoubleWrite` + `Approximate` the - // compatibility list is - // `[SketchWarmTier, GorillaS3Archive, ColdJsonlFallback]`. - // SimpleEngine is configured with no agg ids, so its - // `handle_query` returns `None` → `EngineError::CapabilityMiss`, - // which the router tolerates and falls through. Then Gorilla - // fails with `Backend`, so JSONL must answer. let server_port = setup_test_server_with_router( StorageBackend::DoubleWrite, vec![ - gorilla_failing as Arc, - jsonl_ok as Arc, + warm_ok as Arc, + archive as Arc, ], ) .await; @@ -2942,16 +2943,15 @@ aggregations: .unwrap(); assert!( resp.status().is_success(), - "double-write fallback must answer 2xx; got {}", + "double-write must answer 2xx; got {}", resp.status() ); - // We dispatched as `metric_storage = DoubleWrite`, so the - // `data_source` info-line reflects the *requested* axis (the - // router's `execute` doesn't expose which member of the - // failover list answered). Verifying the fallback was - // exercised happens via call counts. - assert_eq!(gorilla_calls.load(Ordering::SeqCst), 1); - assert_eq!(jsonl_calls.load(Ordering::SeqCst), 1); + assert_eq!(warm_calls.load(Ordering::SeqCst), 1); + assert_eq!( + archive_calls.load(Ordering::SeqCst), + 0, + "archive must not run when the warm-tier head answers cleanly", + ); } // ── Issue #46 production-path coverage: BackendStorageRouting ───────────── diff --git a/asap-query-engine/src/engines/gorilla_engine/exact_executor.rs b/asap-query-engine/src/engines/gorilla/engine.rs similarity index 50% rename from asap-query-engine/src/engines/gorilla_engine/exact_executor.rs rename to asap-query-engine/src/engines/gorilla/engine.rs index 212debcf4..1c3b33a98 100644 --- a/asap-query-engine/src/engines/gorilla_engine/exact_executor.rs +++ b/asap-query-engine/src/engines/gorilla/engine.rs @@ -1,28 +1,384 @@ -//! Per-statistic executors for the Phase-4 Gorilla engine. +//! `GorillaQueryEngine` planner + per-statistic exact executor. //! -//! Two strategies, picked by [`super::query_planner::QueryStatistic::is_streaming_additive`]: +//! Step-1 of the JSONL-deprecation refactor merged the previous +//! `query_planner.rs` (PromQL → [`QueryPlan`]) and `exact_executor.rs` +//! (per-statistic `[`ExactExecutor`]` dispatch) into a single +//! `engine.rs` so the engine's data flow is readable top-to-bottom +//! in one file: parse PromQL → plan → execute. //! -//! * **Streaming-additive** — `Sum / Count / Avg / Min / Max / -//! Rate / Increase`. Walk chunks one at a time, fold each -//! sample into a tiny accumulator, drop the decoded chunk -//! before fetching the next one. Memory is O(1) per query. -//! * **Buffered** — `Quantile / TopK / Cardinality`. Materialise -//! every in-range sample, then sort or otherwise post-process. -//! Bounded by [`super::GorillaEngineConfig::max_buffered_samples`]; -//! over-budget queries fail fast with -//! [`super::EngineError::TooManySamples`] rather than OOM. +//! The two halves keep their existing structure inside the merged +//! file: +//! +//! * **Planner** (top) — translates a PromQL string into a +//! [`QueryPlan`] (`metric, time_range_ms, statistic, +//! label_matchers`). Supports `*_over_time`, `rate`, `increase`, +//! `quantile_over_time`, `topk`, and the v7 `last_over_time` +//! freshness-probe spelling. +//! * **Executor** (bottom) — takes a [`QueryPlan`] + an +//! `Arc` and returns an +//! [`super::ExecutionOutcome`]. Two strategies, picked by +//! [`QueryStatistic::is_streaming_additive`]: +//! +//! - **Streaming-additive** — `Sum / Count / Avg / Min / Max / +//! Rate / Increase / Last`. One chunk at a time, fold into a +//! tiny accumulator, drop the decoded chunk before fetching +//! the next one. Memory is O(1) per query. +//! - **Buffered** — `Quantile / TopK / Cardinality`. Materialise +//! every in-range sample, then sort or otherwise post-process. +//! Bounded by [`super::GorillaEngineConfig::max_buffered_samples`]; +//! over-budget queries fail fast with +//! [`super::EngineError::TooManySamples`] rather than OOM. use std::sync::Arc; +use std::time::SystemTime; -use tracing::debug; - -use crate::drivers::query::fallback::cold_store::{ - ChunkRef, ColdStore, ColdStoreError, RawSample, +use chrono::Utc; +use promql_parser::parser::{ + AggregateExpr, Call, Expr, FunctionArgs, MatrixSelector, NumberLiteral, ParenExpr, + VectorSelector, }; +use tracing::debug; -use super::query_planner::{LabelMatcher, QueryPlan, QueryStatistic}; +use super::store::{ChunkRef, RawSample, Store, StoreError}; use super::{EngineError, ExecutionOutcome, GorillaEngineConfig}; +// ===================================================================== +// Planner +// ===================================================================== + +/// Statistic to compute, alongside any extra parameters +/// (quantile φ, top-k k). +#[derive(Debug, Clone, PartialEq)] +pub enum QueryStatistic { + /// `sum_over_time(m[range])` + SumOverTime, + /// `count_over_time(m[range])` + CountOverTime, + /// `avg_over_time(m[range])` (= sum / count) + AvgOverTime, + /// `min_over_time(m[range])` + MinOverTime, + /// `max_over_time(m[range])` + MaxOverTime, + /// `rate(m[range])` — `(last - first) / range_seconds` + Rate, + /// `increase(m[range])` — `last - first` + Increase, + /// `quantile_over_time(φ, m[range])` + QuantileOverTime { phi: f64 }, + /// `topk(k, sum_over_time(m[range]))`-style aggregation. The + /// MVP Phase 4 implementation returns the sum of the top-`k` + /// sample values in the range — once Phase 5 adds spatial + /// grouping the executor will return a per-group vector. + TopK { k: usize }, + /// **v7**: `last_over_time(m[range])` — value of the + /// largest-timestamp sample in the range. Used by issue #46 + /// criterion ⑥ freshness probes; counter-shaped probes encode + /// `unix_ts_ms_of_emission` in their cumulative value, and + /// `last_over_time(...)` returns that value so the replay + /// client can compute per-path freshness deltas. + LastOverTime, +} + +impl QueryStatistic { + /// True iff the executor can answer this statistic via the + /// streaming-additive path; false → buffered path (everything + /// has to be in memory before producing the answer). + pub fn is_streaming_additive(&self) -> bool { + matches!( + self, + Self::SumOverTime + | Self::CountOverTime + | Self::AvgOverTime + | Self::MinOverTime + | Self::MaxOverTime + | Self::Rate + | Self::Increase + | Self::LastOverTime + ) + } +} + +/// One label-equality matcher extracted from the PromQL AST. mvp/v5 +/// uses these to drive the postings-aware chunk-pruning path. +/// +/// The MVP only supports exact equality (`label = "value"`). Regex +/// (`=~`) and inequality (`!=`, `!~`) matchers fall through to a +/// post-decode filter — the postings file holds *exact* values per +/// label, not patterns. The fall-through is correct (just slower) +/// and is signalled to callers via +/// [`QueryPlan::has_unsupported_matchers`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LabelMatcher { + /// Label name, e.g. `"service"`. + pub name: String, + /// Label value, e.g. `"api"`. + pub value: String, +} + +/// Output of [`plan_query`]. +#[derive(Debug, Clone, PartialEq)] +pub struct QueryPlan { + pub metric: String, + /// Half-open `[start_ms, end_ms)` request window. Computed as + /// `(now_ms - range_ms, now_ms)` from the matrix selector's + /// `[range]` duration. + pub time_range_ms: (i64, i64), + pub statistic: QueryStatistic, + /// **mvp/v5**: exact-equality label matchers extracted from the + /// vector selector. Empty for `metric[range]` (no predicate). + /// Non-empty for `metric{label="value"}[range]`. Used by the + /// postings-aware chunk filter; `=~` / `!=` / `!~` matchers are + /// dropped from this list and signalled via + /// [`Self::has_unsupported_matchers`]. + pub label_matchers: Vec, + /// **mvp/v5**: `true` iff the original PromQL had at least one + /// matcher we couldn't translate into a postings lookup (regex, + /// inequality). The caller must still apply those matchers + /// post-decode; we surface the flag so `data_source_quirk` + /// annotations make it back to the client. + pub has_unsupported_matchers: bool, +} + +/// Parse `query` and produce a [`QueryPlan`]. `now` defaults to +/// the system clock; the [`plan_query_at`] variant lets tests pin +/// a deterministic timestamp. +pub fn plan_query(query: &str) -> Result { + let now_ms = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or_else(|_| Utc::now().timestamp_millis()); + plan_query_at(query, now_ms) +} + +/// As [`plan_query`], with a caller-supplied `now_ms`. +pub fn plan_query_at(query: &str, now_ms: i64) -> Result { + let ast = promql_parser::parser::parse(query).map_err(|e| format!("parse: {e}"))?; + plan_from_ast(&ast, now_ms) +} + +fn plan_from_ast(ast: &Expr, now_ms: i64) -> Result { + match ast { + Expr::Paren(ParenExpr { expr }) => plan_from_ast(expr, now_ms), + Expr::Call(call) => plan_from_call(call, now_ms), + Expr::Aggregate(agg) => plan_from_aggregate(agg, now_ms), + other => Err(format!( + "unsupported top-level expression: {:?}; the Gorilla engine \ + expects a single function call (rate/increase/*_over_time) \ + or topk(k, ...) aggregation", + std::mem::discriminant(other) + )), + } +} + +fn plan_from_call(call: &Call, now_ms: i64) -> Result { + let name = call.func.name.to_lowercase(); + match name.as_str() { + "sum_over_time" + | "count_over_time" + | "avg_over_time" + | "min_over_time" + | "max_over_time" + | "last_over_time" + | "rate" + | "increase" => { + let ms = expect_single_matrix_arg(&call.args, &name)?; + let (metric, range_ms) = matrix_metric_and_range_ms(ms); + let stat = match name.as_str() { + "sum_over_time" => QueryStatistic::SumOverTime, + "count_over_time" => QueryStatistic::CountOverTime, + "avg_over_time" => QueryStatistic::AvgOverTime, + "min_over_time" => QueryStatistic::MinOverTime, + "max_over_time" => QueryStatistic::MaxOverTime, + "last_over_time" => QueryStatistic::LastOverTime, + "rate" => QueryStatistic::Rate, + "increase" => QueryStatistic::Increase, + _ => unreachable!(), + }; + let (label_matchers, has_unsupported) = extract_label_matchers(&ms.vs); + Ok(QueryPlan { + metric, + time_range_ms: (now_ms - range_ms, now_ms), + statistic: stat, + label_matchers, + has_unsupported_matchers: has_unsupported, + }) + } + "quantile_over_time" => { + // quantile_over_time(φ, m[range]) + if call.args.args.len() != 2 { + return Err(format!( + "quantile_over_time expects 2 args, got {}", + call.args.args.len() + )); + } + let phi = expect_number(&call.args.args[0], "quantile_over_time φ")?; + let ms = expect_matrix_selector(&call.args.args[1], "quantile_over_time")?; + let (metric, range_ms) = matrix_metric_and_range_ms(ms); + let (label_matchers, has_unsupported) = extract_label_matchers(&ms.vs); + Ok(QueryPlan { + metric, + time_range_ms: (now_ms - range_ms, now_ms), + statistic: QueryStatistic::QuantileOverTime { phi }, + label_matchers, + has_unsupported_matchers: has_unsupported, + }) + } + other => Err(format!( + "unsupported PromQL function: {other}; the Gorilla engine \ + supports rate/increase/*_over_time/quantile_over_time" + )), + } +} + +fn plan_from_aggregate(agg: &AggregateExpr, now_ms: i64) -> Result { + // PromQL grammar requires aggregation operators to take a + // vector — so the legal Phase-4 spellings are e.g. + // `topk(2, sum_over_time(m[10s]))`. We strip the outer + // aggregation, recurse into the inner call to recover the + // `(metric, range)` pair, then overlay the TopK statistic. + let op_str = format!("{}", agg.op); + if !op_str.eq_ignore_ascii_case("topk") { + return Err(format!( + "unsupported top-level aggregation: {op_str}; only `topk(k, ...)` \ + is supported in Phase 4" + )); + } + let k_expr = agg + .param + .as_deref() + .ok_or_else(|| "topk requires a numeric parameter (k)".to_string())?; + let k = expect_number(k_expr, "topk k")?; + if !k.is_finite() || k <= 0.0 { + return Err(format!("topk k must be positive, got {k}")); + } + // Recurse into the inner expression — it can be a matrix + // selector (handled by [`matrix_metric_and_range_ms`] + // directly) OR a vector-returning function call (the legal + // PromQL spelling). Either way we end up with a + // `(metric, range_ms)` pair we can overlay TopK on. + let inner_plan = match &*agg.expr { + Expr::MatrixSelector(ms) => { + let (metric, range_ms) = matrix_metric_and_range_ms(ms); + let (label_matchers, has_unsupported) = extract_label_matchers(&ms.vs); + QueryPlan { + metric, + time_range_ms: (now_ms - range_ms, now_ms), + statistic: QueryStatistic::SumOverTime, // overlay below + label_matchers, + has_unsupported_matchers: has_unsupported, + } + } + _ => plan_from_ast(&agg.expr, now_ms)?, + }; + Ok(QueryPlan { + metric: inner_plan.metric, + time_range_ms: inner_plan.time_range_ms, + statistic: QueryStatistic::TopK { k: k as usize }, + label_matchers: inner_plan.label_matchers, + has_unsupported_matchers: inner_plan.has_unsupported_matchers, + }) +} + +fn expect_single_matrix_arg<'a>( + args: &'a FunctionArgs, + fname: &str, +) -> Result<&'a MatrixSelector, String> { + if args.args.len() != 1 { + return Err(format!( + "{fname} expects 1 matrix-selector arg, got {}", + args.args.len() + )); + } + expect_matrix_selector(&args.args[0], fname) +} + +fn expect_matrix_selector<'a>(expr: &'a Expr, ctx: &str) -> Result<&'a MatrixSelector, String> { + match expr { + Expr::MatrixSelector(ms) => Ok(ms), + Expr::Paren(ParenExpr { expr }) => expect_matrix_selector(expr, ctx), + other => Err(format!( + "{ctx}: expected matrix selector `metric[range]`, got {:?}", + std::mem::discriminant(other) + )), + } +} + +fn expect_number(expr: &Expr, ctx: &str) -> Result { + match expr { + Expr::NumberLiteral(NumberLiteral { val }) => Ok(*val), + Expr::Paren(ParenExpr { expr }) => expect_number(expr, ctx), + other => Err(format!( + "{ctx}: expected numeric literal, got {:?}", + std::mem::discriminant(other) + )), + } +} + +fn matrix_metric_and_range_ms(ms: &MatrixSelector) -> (String, i64) { + let metric = vector_selector_metric(&ms.vs); + let range_ms = ms.range.as_millis() as i64; + (metric, range_ms) +} + +fn vector_selector_metric(vs: &VectorSelector) -> String { + if let Some(name) = &vs.name { + return name.clone(); + } + // Fallback: inspect matchers for an `__name__` exact match. + for m in vs.matchers.matchers.iter() { + if m.name == "__name__" { + return m.value.clone(); + } + } + String::new() +} + +/// **mvp/v5**: extract exact-equality label matchers from a vector +/// selector for postings-aware chunk pruning. +/// +/// Returns `(supported_matchers, has_unsupported_matchers)`. Supported +/// matchers are the `label = "value"` tuples the postings file can +/// answer directly. Anything else (regex, inequality, the implicit +/// `__name__` matcher) is excluded from `supported_matchers` and +/// flips the second return value to `true` — the executor still +/// applies them post-decode for correctness. +pub(crate) fn extract_label_matchers(vs: &VectorSelector) -> (Vec, bool) { + use promql_parser::label::MatchOp; + + let mut supported = Vec::new(); + let mut has_unsupported = false; + for m in vs.matchers.matchers.iter() { + // The implicit `__name__` matcher is the metric name itself + // — we already pulled that out of the selector elsewhere. + if m.name == "__name__" { + continue; + } + match &m.op { + MatchOp::Equal => { + supported.push(LabelMatcher { + name: m.name.clone(), + value: m.value.clone(), + }); + } + // Regex / inequality matchers are correctness-relevant + // but cannot be answered by an exact postings lookup. + // Surface the flag so the caller emits a quirk + // annotation; the actual filter is applied post-decode. + MatchOp::NotEqual | MatchOp::Re(_) | MatchOp::NotRe(_) => { + has_unsupported = true; + } + } + } + (supported, has_unsupported) +} + + + +// ===================================================================== +// Executor +// ===================================================================== + /// Streaming-additive operation tag — what the per-sample fold /// does. Pulled out so [`ExactExecutor::execute_streaming_additive`] /// is a single function regardless of which stat is being computed. @@ -46,17 +402,17 @@ pub enum AdditiveOp { Last, } -/// Per-statistic executor. Holds an `Arc` so the -/// engine + executor share the same cold-tier handle without +/// Per-statistic executor. Holds an `Arc` so the +/// engine + executor share the same archive-tier handle without /// re-implementing trait dispatch. pub struct ExactExecutor { - cold_store: Arc, + store: Arc, config: GorillaEngineConfig, } impl ExactExecutor { - pub fn new(cold_store: Arc, config: GorillaEngineConfig) -> Self { - Self { cold_store, config } + pub fn new(store: Arc, config: GorillaEngineConfig) -> Self { + Self { store, config } } /// Top-level dispatch — picks streaming vs buffered based on @@ -109,7 +465,7 @@ impl ExactExecutor { ) -> Result { let (start_ms, end_ms) = plan.time_range_ms; let chunks = self - .cold_store + .store .list_chunks(&plan.metric, start_ms, end_ms) .await?; let total_chunks = chunks.len(); @@ -128,7 +484,7 @@ impl ExactExecutor { let mut samples_scanned: usize = 0; let chunks_fetched = filtered_chunks.len(); for chunk in filtered_chunks { - let samples = self.cold_store.read_chunk(&chunk).await?; + let samples = self.store.read_chunk(&chunk).await?; for s in samples { if s.ts_ms >= start_ms && s.ts_ms < end_ms { if !self.sample_matches(plan, &s) { @@ -179,15 +535,15 @@ impl ExactExecutor { .collect(); let (start_ms, end_ms) = plan.time_range_ms; let hits = match self - .cold_store + .store .list_postings_for(&plan.metric, start_ms, end_ms, &matchers) .await { Ok(h) => h, - Err(ColdStoreError::Unsupported(_)) => { - // Backend doesn't support postings at all (legacy - // cold store). Surface as missing and fall through. - debug!("gorilla-engine: cold store does not support postings; falling back to scan-all"); + Err(StoreError::Unsupported(_)) => { + // Backend doesn't support postings at all. Surface + // as missing and fall through. + debug!("gorilla-engine: store does not support postings; falling back to scan-all"); return ( chunks.to_vec(), PostingsOutcome { @@ -351,7 +707,7 @@ impl ExactExecutor { ) -> Result { let (start_ms, end_ms) = plan.time_range_ms; let chunks = self - .cold_store + .store .list_chunks(&plan.metric, start_ms, end_ms) .await?; let total_chunks = chunks.len(); @@ -361,7 +717,7 @@ impl ExactExecutor { let mut buffer: Vec = Vec::new(); let limit = self.config.max_buffered_samples; for chunk in filtered_chunks { - let samples = self.cold_store.read_chunk(&chunk).await?; + let samples = self.store.read_chunk(&chunk).await?; for s in samples { if s.ts_ms >= start_ms && s.ts_ms < end_ms { if !self.sample_matches(plan, &s) { @@ -508,3 +864,65 @@ impl AdditiveAccumulator { } } +#[cfg(test)] +mod tests { + use super::*; + + const NOW: i64 = 1_715_000_000_000; + + #[test] + fn plans_sum_over_time() { + let plan = plan_query_at("sum_over_time(http_requests_total[5m])", NOW).unwrap(); + assert_eq!(plan.metric, "http_requests_total"); + assert_eq!(plan.statistic, QueryStatistic::SumOverTime); + assert_eq!(plan.time_range_ms, (NOW - 5 * 60_000, NOW)); + } + + #[test] + fn plans_quantile_over_time() { + let plan = plan_query_at("quantile_over_time(0.99, latency_ms[1m])", NOW).unwrap(); + assert_eq!(plan.metric, "latency_ms"); + assert!(matches!( + plan.statistic, + QueryStatistic::QuantileOverTime { phi } if (phi - 0.99).abs() < 1e-12 + )); + } + + #[test] + fn plans_topk() { + // Legal PromQL spelling: aggregation wraps a vector-returning + // function call. The Phase-4 planner peels off the outer + // `topk` and recovers the `(metric, range)` pair from the + // inner `sum_over_time(...)`. + let plan = plan_query_at("topk(3, sum_over_time(m[10s]))", NOW).unwrap(); + assert!(matches!(plan.statistic, QueryStatistic::TopK { k } if k == 3)); + assert_eq!(plan.metric, "m"); + assert_eq!(plan.time_range_ms, (NOW - 10_000, NOW)); + } + + #[test] + fn rejects_binary_expression() { + assert!(plan_query_at("foo + bar", NOW).is_err()); + } + + #[test] + fn streaming_classification() { + assert!(QueryStatistic::SumOverTime.is_streaming_additive()); + assert!(QueryStatistic::Rate.is_streaming_additive()); + assert!(QueryStatistic::LastOverTime.is_streaming_additive()); + assert!(!QueryStatistic::QuantileOverTime { phi: 0.5 }.is_streaming_additive()); + assert!(!QueryStatistic::TopK { k: 1 }.is_streaming_additive()); + } + + #[test] + fn plans_last_over_time_v7() { + // v7: `last_over_time(...)` translates to the streaming + // additive path, picking the value of the largest-timestamp + // sample in [now-range, now). Issue #46 ⑥ freshness probes + // ride this path. + let plan = plan_query_at("last_over_time(http_freshness_probe_warm[10s])", NOW).unwrap(); + assert_eq!(plan.metric, "http_freshness_probe_warm"); + assert_eq!(plan.statistic, QueryStatistic::LastOverTime); + assert_eq!(plan.time_range_ms, (NOW - 10_000, NOW)); + } +} diff --git a/asap-query-engine/src/engines/gorilla_engine/mod.rs b/asap-query-engine/src/engines/gorilla/mod.rs similarity index 75% rename from asap-query-engine/src/engines/gorilla_engine/mod.rs rename to asap-query-engine/src/engines/gorilla/mod.rs index 33eda5b24..5a887bcdf 100644 --- a/asap-query-engine/src/engines/gorilla_engine/mod.rs +++ b/asap-query-engine/src/engines/gorilla/mod.rs @@ -1,12 +1,24 @@ -//! Phase 4: `GorillaQueryEngine` — exact PromQL execution over the -//! Gorilla-S3 cold tier. +//! `GorillaQueryEngine` — exact PromQL execution over the Gorilla +//! archive tier. //! -//! This engine is a SIBLING of [`crate::engines::simple_engine::SimpleEngine`]. -//! Both consume the same PromQL surface, but where `SimpleEngine` -//! answers from warm-tier sketches (approximate, ε/δ-bounded), the -//! `GorillaQueryEngine` answers exactly from per-hour Gorilla -//! chunks landed on S3 / MinIO via the Phase-3 -//! [`crate::drivers::query::fallback::cold_store::gorilla_s3::GorillaS3ColdStore`]. +//! Sibling of [`crate::engines::simple`] (warm-tier sketches). Both +//! consume the same PromQL surface; `GorillaQueryEngine` answers +//! exactly from per-hour Gorilla chunks landed on S3 / MinIO via +//! [`store::GorillaS3Store`]. +//! +//! ## Module layout (post Step-1 refactor) +//! +//! * [`engine`] — query planner + per-statistic exact executor (the +//! merged form of the previous `query_planner.rs` + +//! `exact_executor.rs`). +//! * [`store`] — `GorillaS3Store` (the only `Store` impl after +//! the JSONL deletion) + `Store`/`ObjectStore` traits + +//! `RawSample` / `ChunkRef` types. +//! * [`postings`] — postings-sidecar cache + per-bucket +//! intersection helper. +//! * [`s3_cost`] — instrumented S3 client wrapper that ticks the +//! process-wide cost counters surfaced on +//! `/internal/s3_cost.csv` + `/metrics`. //! //! Result wrapping pins three things: //! @@ -19,7 +31,7 @@ //! //! ## Two execution strategies //! -//! Per-statistic dispatch in [`exact_executor`]: +//! Per-statistic dispatch in [`engine::ExactExecutor`]: //! //! * **Streaming-additive** — `Sum`, `Count`, `Min`, `Max`, `Rate`, //! `Increase` (and `Avg` derived as Sum/Count). One chunk at a @@ -30,8 +42,10 @@ //! [`GorillaEngineConfig::max_buffered_samples`]; over-budget //! queries fail fast with [`EngineError::TooManySamples`]. -pub mod exact_executor; -pub mod query_planner; +pub mod engine; +pub mod postings; +pub mod s3_cost; +pub mod store; #[cfg(test)] mod tests; @@ -44,12 +58,20 @@ use tokio::time::error::Elapsed; use tracing::debug; use crate::data_model::KeyByLabelValues; -use crate::drivers::query::fallback::cold_store::{ColdStore, ColdStoreError}; use crate::engines::query_result::{InstantVectorElement, QueryResult}; use crate::stores::sketch_db::accuracy::{AccuracyEnvelope, AccuracyProfile}; -pub use exact_executor::{AdditiveOp, ExactExecutor}; -pub use query_planner::{plan_query, plan_query_at, QueryPlan, QueryStatistic}; +pub use engine::{ + plan_query, plan_query_at, AdditiveOp, ExactExecutor, LabelMatcher, QueryPlan, QueryStatistic, +}; +pub use postings::PostingsHits; +pub use s3_cost::{ + global_s3_cost_counters, S3CostCounters, S3CostSnapshot, S3CostTrackingObjectStore, +}; +pub use store::{ + ChunkRef, GorillaS3Config, GorillaS3ConfigError, GorillaS3Store, ObjectStore, RawSample, + S3ObjectStore, Store, StoreError, +}; /// Marker line that every `GorillaQueryEngine` answer carries on /// its `infos` array. Pinned so dashboards / Phase-5 capability @@ -85,12 +107,12 @@ impl Default for GorillaEngineConfig { #[derive(Debug, Error)] pub enum EngineError { /// PromQL string failed to parse, or used a construct outside - /// the engine's supported surface (see [`query_planner`]). + /// the engine's supported surface (see [`engine`]). #[error("query planning failed: {0}")] Plan(String), - /// Cold-store fetch / decode failed. - #[error("cold-store error: {0}")] - ColdStore(#[from] ColdStoreError), + /// Archive-store fetch / decode failed. + #[error("store error: {0}")] + Store(#[from] store::StoreError), /// Buffered-aggregate budget exceeded — query asked for more /// samples than [`GorillaEngineConfig::max_buffered_samples`] /// will allow. The user should narrow the time range or @@ -116,57 +138,55 @@ impl From for EngineError { } } -/// Phase-4 cold-tier exact engine. +/// Archive-tier exact engine. /// -/// Holds an `Arc` rather than a concrete -/// `Arc` so tests can inject in-memory mocks -/// and so future cold backends (local-FS chunks, multi-region -/// fan-out) drop in without changing the engine surface. The -/// production constructor [`GorillaQueryEngine::with_gorilla_s3`] -/// keeps the design.md type signature working at the call site. +/// Holds an `Arc` rather than a concrete +/// `Arc` so tests can inject in-memory mocks +/// and so future archive backends (Prometheus-block format via +/// the planned Step-2 Thanos store-gateway, multi-region fan-out) +/// drop in without changing the engine surface. The production +/// constructor [`GorillaQueryEngine::with_gorilla_s3`] keeps the +/// design.md type signature working at the call site. pub struct GorillaQueryEngine { - cold_store: Arc, + store: Arc, config: GorillaEngineConfig, } impl GorillaQueryEngine { - /// Build with an arbitrary cold-store implementation. Used by - /// tests + the Phase-5 capability router (which may swap the + /// Build with an arbitrary `Store` implementation. Used by + /// tests + the capability router (which may swap the /// concrete impl based on routing decisions). - pub fn new(cold_store: Arc, config: GorillaEngineConfig) -> Self { - Self { - cold_store, - config, - } + pub fn new(store: Arc, config: GorillaEngineConfig) -> Self { + Self { store, config } } /// Convenience constructor for the production - /// [`crate::drivers::query::fallback::cold_store::gorilla_s3::GorillaS3ColdStore`] - /// path. Mirrors the design.md type signature. + /// [`store::GorillaS3Store`] path. Mirrors the design.md type + /// signature. pub fn with_gorilla_s3( - cold_store: Arc, + store: Arc, config: GorillaEngineConfig, ) -> Self { - Self::new(cold_store as Arc, config) + Self::new(store as Arc, config) } /// Read-only access to the configured limits — useful for - /// diagnostics + the Phase-5 router's cost estimator. + /// diagnostics + the cost-aware router's cost estimator. pub fn config(&self) -> &GorillaEngineConfig { &self.config } - /// Test-only accessor for the underlying cold store. mvp/v5 + /// Test-only accessor for the underlying store. mvp/v5 /// tests use this to construct a `ExactExecutor` that shares /// the same mock without re-wrapping in a fresh `Arc`. #[cfg(test)] - pub(super) fn cold_store_for_tests(&self) -> Arc { - self.cold_store.clone() + pub(super) fn store_for_tests(&self) -> Arc { + self.store.clone() } - /// Execute a parsed PromQL query against the cold tier. + /// Execute a parsed PromQL query against the archive tier. /// - /// The query string is parsed via [`query_planner::plan_query`], + /// The query string is parsed via [`engine::plan_query`], /// the resulting plan dispatches to either the streaming /// additive or the buffered execution path, and the answer is /// wrapped with the exact-accuracy envelope + the @@ -181,8 +201,8 @@ impl GorillaQueryEngine { /// Like [`Self::execute`], with a caller-supplied `now_ms` /// pinning the right edge of the request window. Used by - /// tests + by the (future) Phase-5 router that wants to back- - /// date a query against historical chunks. + /// tests + by callers that want to back-date a query against + /// historical chunks. pub async fn execute_at( &self, query: &str, @@ -199,7 +219,7 @@ impl GorillaQueryEngine { query: &str, now_ms: i64, ) -> Result { - let plan = query_planner::plan_query_at(query, now_ms).map_err(EngineError::Plan)?; + let plan = engine::plan_query_at(query, now_ms).map_err(EngineError::Plan)?; debug!( metric = plan.metric.as_str(), stat = ?plan.statistic, @@ -208,7 +228,7 @@ impl GorillaQueryEngine { "gorilla-engine: executing plan" ); - let executor = ExactExecutor::new(self.cold_store.clone(), self.config.clone()); + let executor = ExactExecutor::new(self.store.clone(), self.config.clone()); let outcome = executor.execute_plan(&plan).await?; Ok(wrap_result(&plan, outcome)) @@ -306,19 +326,19 @@ impl ExecutionOutcome { } // --------------------------------------------------------------------------- -// Phase-5: `QueryEngine` trait impl. +// `QueryEngine` trait impl. // // Wraps `GorillaQueryEngine::execute` with the EngineError envelope the // router speaks. Plan-time / parse-time failures fold into // `EngineError::CapabilityMiss` (the engine cannot serve this query -// shape; router should fall through). Cold-store / timeout / buffer-budget +// shape; router should fall through). Store / timeout / buffer-budget // failures fold into `EngineError::Backend` (the engine could have served // the query but its backend transiently failed; router should also fall -// through, typically to `ColdJsonlFallback`). +// through, typically to the warm-tier sketch path on `DoubleWrite`). // --------------------------------------------------------------------------- #[async_trait::async_trait] -impl crate::engines::router::QueryEngine for GorillaQueryEngine { +impl crate::routing::engine_router::QueryEngine for GorillaQueryEngine { async fn execute( &self, query: &str, @@ -336,8 +356,8 @@ impl crate::engines::router::QueryEngine for GorillaQueryEngine { } } - fn capabilities(&self) -> crate::engines::router::EngineCapabilities { - crate::engines::router::EngineCapabilities { + fn capabilities(&self) -> crate::routing::engine_router::EngineCapabilities { + crate::routing::engine_router::EngineCapabilities { data_source_id: asap_types::StorageBackend::GorillaS3Archive.data_source_id(), storage_backend: asap_types::StorageBackend::GorillaS3Archive, // The buffered-aggregate budget gives a natural ceiling: each diff --git a/asap-query-engine/src/engines/gorilla/postings.rs b/asap-query-engine/src/engines/gorilla/postings.rs new file mode 100644 index 000000000..aaa9110a2 --- /dev/null +++ b/asap-query-engine/src/engines/gorilla/postings.rs @@ -0,0 +1,164 @@ +//! Postings cache + sidecar fetch helper for the Gorilla archive +//! engine. +//! +//! The on-S3 postings sidecar is a JSON file emitted by the agent +//! `gorillas3processor` alongside each per-hour `index.json`: +//! `//YYYY/MM/DD/HH/postings-v1.json`. It maps +//! `(label_name, label_value) → [series_id, ...]` so the +//! [`super::ExactExecutor`] can prune chunks by `label_hash` +//! without paying the chunk-body GET cost. +//! +//! Step-1 of the JSONL deprecation refactor pulled this code out +//! of `gorilla_s3.rs` so the cache + intersection logic has a +//! single home; the previous co-located version conflated three +//! responsibilities (S3 wiring, postings cache, intersection +//! algebra). With Step-2 (Prometheus-block format + Thanos +//! store-gateway) coming next, splitting now means the Thanos +//! impl can either reuse [`intersect_per_bucket_postings`] as-is +//! or replace it without touching the gorilla store. + +use std::collections::BTreeSet; +use std::num::NonZeroUsize; +use std::sync::Arc; + +use lru::LruCache; +use tokio::sync::Mutex; +use tracing::debug; + +use asap_gorilla::Postings; + +use super::store::{ObjectStore, StoreError}; + +/// Default LRU capacity for the postings cache. ~1 MiB per +/// postings file, so 64 entries ≈ 64 MiB worst-case. +pub const POSTINGS_CACHE_DEFAULT_CAPACITY: usize = 64; + +/// LRU cache for parsed postings sidecars. Keyed by the +/// `postings-v1.json` S3 key (one per `(metric, hour)`). +pub type PostingsCache = Mutex>>; + +/// Build a fresh empty postings cache with capacity `cap` (clamped +/// to ≥ 1). +pub fn new_postings_cache(cap: usize) -> PostingsCache { + let cap = NonZeroUsize::new(cap.max(1)).unwrap_or(NonZeroUsize::new(1).unwrap()); + Mutex::new(LruCache::new(cap)) +} + +/// Output of [`intersect_per_bucket_postings`]: the union of +/// series_ids matching every label predicate, plus per-bucket +/// coverage counters used by the engine to decide whether to emit +/// the `postings_missing` quirk on the response. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PostingsHits { + /// Series ids matching all label predicates, sorted ascending, + /// deduplicated. + pub series_ids: Vec, + /// Hour buckets in the request window. + pub buckets_in_range: usize, + /// Buckets that actually had a `postings-v1.json` sidecar. + pub buckets_with_postings: usize, +} + +impl PostingsHits { + /// `true` iff at least one hour bucket carried postings — + /// indicates the postings-aware filter ran on real data and the + /// caller should trust [`Self::series_ids`] as a complete answer. + pub fn fully_covered(&self) -> bool { + self.buckets_in_range > 0 && self.buckets_with_postings == self.buckets_in_range + } + + /// `true` iff postings were present for every bucket AND at + /// least one matched series. + pub fn nonempty_and_complete(&self) -> bool { + self.fully_covered() && !self.series_ids.is_empty() + } +} + +/// Walk every `postings-v1.json` key in `keys`, fetch + parse via +/// `object_store` (LRU-cached in `cache`), intersect the per-matcher +/// series-id lists within each bucket, and union the results +/// across buckets. +/// +/// Empty `matchers` ⇒ returns the union of every series_id across +/// every label in every bucket (the no-predicate short-circuit). +/// +/// Cross-bucket join is a UNION (a series might exist in one hour +/// but not the next); intra-bucket intersection across matchers is +/// an AND. +pub async fn intersect_per_bucket_postings( + object_store: &dyn ObjectStore, + cache: &PostingsCache, + keys: &[String], + matchers: &[(String, String)], +) -> Result { + let mut hits = PostingsHits { + series_ids: Vec::new(), + buckets_in_range: keys.len(), + buckets_with_postings: 0, + }; + let mut union_set: BTreeSet = BTreeSet::new(); + + for key in keys { + // LRU short-circuit. + let postings = { + let mut guard = cache.lock().await; + guard.get(key).cloned() + }; + let postings = match postings { + Some(p) => Some(p), + None => match object_store.get_object(key).await { + Ok(bytes) => match Postings::read(bytes.as_slice()) { + Ok(p) => { + let arc = Arc::new(p); + let mut guard = cache.lock().await; + guard.put(key.clone(), arc.clone()); + Some(arc) + } + Err(e) => { + // Treat a corrupt postings file as + // "missing" — the engine then falls + // through to the scan-all path with + // the postings_missing quirk. + debug!( + key = %key, + error = %e, + "gorilla-engine: postings parse failed; treating as missing" + ); + None + } + }, + Err(e) if object_store.object_missing(&e) => { + debug!(key = %key, "gorilla-engine: postings missing for hour bucket"); + None + } + Err(e) => return Err(e), + }, + }; + let Some(postings) = postings else { continue }; + hits.buckets_with_postings += 1; + + // Intersect across matchers within this bucket. + let bucket_set: BTreeSet = if matchers.is_empty() { + // Union of every series_id across every label. + let mut set = BTreeSet::new(); + for by_value in postings.by_label.values() { + for ids in by_value.values() { + set.extend(ids.iter().copied()); + } + } + set + } else { + let first = postings.lookup(&matchers[0].0, &matchers[0].1); + let mut acc: BTreeSet = first.iter().copied().collect(); + for (label_name, label_value) in &matchers[1..] { + let next = postings.lookup(label_name, label_value); + let next_set: BTreeSet = next.iter().copied().collect(); + acc = acc.intersection(&next_set).copied().collect(); + } + acc + }; + union_set.extend(bucket_set); + } + hits.series_ids = union_set.into_iter().collect(); + Ok(hits) +} diff --git a/asap-query-engine/src/drivers/query/fallback/cold_store/s3_cost_tracker.rs b/asap-query-engine/src/engines/gorilla/s3_cost.rs similarity index 94% rename from asap-query-engine/src/drivers/query/fallback/cold_store/s3_cost_tracker.rs rename to asap-query-engine/src/engines/gorilla/s3_cost.rs index a40e2d569..3162df56a 100644 --- a/asap-query-engine/src/drivers/query/fallback/cold_store/s3_cost_tracker.rs +++ b/asap-query-engine/src/engines/gorilla/s3_cost.rs @@ -14,7 +14,8 @@ //! ## Boundary //! //! The wrapper sits at the lowest level — between the -//! `GorillaS3ColdStore::ObjectStore` impl and the actual `Bucket`. +//! [`GorillaS3Store`](super::store::GorillaS3Store)'s `ObjectStore` +//! impl and the actual `Bucket`. //! Tests that don't need S3 (the in-memory mock path) never touch //! it; production deployments wire `S3CostTrackingObjectStore` //! around `S3ObjectStore`. @@ -26,7 +27,7 @@ use async_trait::async_trait; /// Process-wide S3 cost counters. The HTTP server's /// `/internal/s3_cost.csv` endpoint reads this; the -/// `GorillaS3ColdStore` constructor opts in via +/// [`GorillaS3Store`](super::store::GorillaS3Store) constructor opts in via /// [`S3CostTrackingObjectStore`]. Lazy-initialised on first access. static GLOBAL_S3_COST: OnceLock> = OnceLock::new(); @@ -37,8 +38,7 @@ pub fn global_s3_cost_counters() -> Arc { .clone() } -use super::gorilla_s3::ObjectStore; -use super::ColdStoreError; +use super::store::{ObjectStore, StoreError}; /// Per-operation counter set + cumulative bytes. #[derive(Debug, Default)] @@ -158,7 +158,7 @@ impl S3CostTrackingObjectStore { #[async_trait] impl ObjectStore for S3CostTrackingObjectStore { - async fn get_object(&self, key: &str) -> Result, ColdStoreError> { + async fn get_object(&self, key: &str) -> Result, StoreError> { self.counters.get_count.fetch_add(1, Ordering::Relaxed); let body = self.inner.get_object(key).await?; self.counters @@ -167,7 +167,7 @@ impl ObjectStore for S3CostTrackingObjectStore { Ok(body) } - fn object_missing(&self, err: &ColdStoreError) -> bool { + fn object_missing(&self, err: &StoreError) -> bool { self.inner.object_missing(err) } } @@ -175,7 +175,7 @@ impl ObjectStore for S3CostTrackingObjectStore { #[cfg(test)] mod tests { use super::*; - use crate::drivers::query::fallback::cold_store::gorilla_s3::ObjectStore as _; + use crate::engines::gorilla::store::ObjectStore as _; use std::collections::HashMap; use tokio::sync::Mutex; @@ -189,11 +189,11 @@ mod tests { #[async_trait] impl ObjectStore for StubStore { - async fn get_object(&self, key: &str) -> Result, ColdStoreError> { + async fn get_object(&self, key: &str) -> Result, StoreError> { let g = self.inner.lock().await; match g.get(key) { Some(b) => Ok(b.clone()), - None => Err(ColdStoreError::Backend(format!("get {key}: not found"))), + None => Err(StoreError::Backend(format!("get {key}: not found"))), } } } diff --git a/asap-query-engine/src/drivers/query/fallback/cold_store/gorilla_s3.rs b/asap-query-engine/src/engines/gorilla/store.rs similarity index 75% rename from asap-query-engine/src/drivers/query/fallback/cold_store/gorilla_s3.rs rename to asap-query-engine/src/engines/gorilla/store.rs index e2fe8a8a8..d90d59604 100644 --- a/asap-query-engine/src/drivers/query/fallback/cold_store/gorilla_s3.rs +++ b/asap-query-engine/src/engines/gorilla/store.rs @@ -1,22 +1,21 @@ -//! Gorilla-on-S3 [`ColdStore`] adapter — Phase 3 of the -//! Gorilla-S3-cold-engine. +//! Gorilla-on-S3 archive store — the Phase-4 [`GorillaQueryEngine`](super::GorillaQueryEngine)'s +//! sole storage backend. //! //! Lists per-hour `index.json` catalogs out of an S3-compatible //! bucket, prunes them by time range, then fetches + decodes the -//! selected `GORILLA1` chunks via the freshly-merged -//! [`asap_gorilla`] crate (`ASAPCollector` PR #281). +//! selected `GORILLA1` chunks via the [`asap_gorilla`] crate +//! (`ASAPCollector` PR #281). //! -//! Sits alongside [`super::LocalFsColdStore`] — both impls satisfy -//! the same [`super::ColdStore`] trait, so the existing -//! `s3_adapter::ColdFallback` query path can swap between them -//! without code change. The Phase 3 trait extension -//! ([`super::ColdStore::list_chunks`] / [`super::ColdStore::read_chunk`]) -//! lets the upcoming Phase 4 `GorillaQueryEngine` pull chunks one -//! at a time without materialising every sample. +//! Step-1 refactor (`refactor: tier-co-locate engines/{simple,gorilla}/`) +//! folded the previous `ColdStore` trait + `RawSample`/`ChunkRef` +//! types into this module. The legacy JSONL leg +//! (`LocalFsColdStore`, `parse_jsonl`, `ColdJsonlFallback`) was +//! deleted at the same commit; this is now the only `Store` impl +//! in the archive tier. //! //! # Object key layout //! -//! `GorillaS3ColdStore` is **agnostic** about the on-S3 chunk-key +//! `GorillaS3Store` is **agnostic** about the on-S3 chunk-key //! shape. Two layouts are known to coexist (see PR #281): //! //! * design.md canonical: @@ -37,6 +36,7 @@ //! Hidden behind the [`ObjectStore`] trait below so tests use an //! in-memory mock and do not need a live MinIO. +use std::collections::BTreeMap; use std::num::NonZeroUsize; use std::sync::Arc; @@ -46,19 +46,140 @@ use std::collections::HashMap; use async_trait::async_trait; use chrono::{DateTime, Datelike, Timelike, Utc}; use lru::LruCache; +use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::sync::Mutex; use tracing::debug; -use asap_gorilla::{GorillaDecoder, IndexFile, Postings}; +use asap_gorilla::{GorillaDecoder, IndexFile}; -use super::{ChunkRef, ColdStore, ColdStoreError, PostingsHits, RawSample}; +use super::postings::{intersect_per_bucket_postings, PostingsCache, PostingsHits}; +use super::s3_cost::{global_s3_cost_counters, S3CostTrackingObjectStore}; + +// ───────────────────────────────────────────────────────────────────── +// Public types — merged in from the deleted `cold_store/mod.rs` +// ───────────────────────────────────────────────────────────────────── + +/// A single raw observability sample as decoded out of a +/// `GORILLA1` chunk. `labels` is a `BTreeMap` so identical samples +/// hash deterministically (handy for golden tests + the postings +/// cross-check). +/// +/// Pre-Step-1 this lived in the JSONL `cold_store::format` module +/// and was the wire format the legacy `LocalFsColdStore` parsed. +/// JSONL is gone; the type stays as the in-memory shape every +/// gorilla-engine consumer (`exact_executor`, the postings filter, +/// the test mocks) speaks. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RawSample { + pub ts_ms: i64, + pub labels: BTreeMap, + pub value: f64, +} + +/// Convenience alias: a label set as stored on a [`RawSample`]. +pub type LabelSet = BTreeMap; + +/// Error surface for archive-store operations. +#[derive(Debug, Error)] +pub enum StoreError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("malformed record: {0}")] + Malformed(String), + /// Backend-storage error (e.g. an S3 GET failed) that is not + /// itself a `std::io::Error`. + #[error("backend error: {0}")] + Backend(String), + /// A trait method that this `Store` impl does not support. + /// Reserved for forwards-compatible trait extensions. + #[error("unsupported store operation: {0}")] + Unsupported(&'static str), +} + +/// Descriptor for a single immutable chunk stored in the archive +/// tier. Returned by [`Store::list_chunks`]; carries enough +/// metadata for callers to prune by time / label without reading +/// the chunk body. +#[derive(Debug, Clone, PartialEq)] +pub struct ChunkRef { + /// Opaque object key (e.g. an S3 key). The Telegraf-side + /// `gorilla_s3` output uses + /// `/block---.gorilla`; the + /// design.md-style layout is `//YYYY/MM/DD/HH/ + /// part-NNNNNN.gor`. Either is fine — the index file is the + /// source of truth for what keys exist. + pub key: String, + /// Metric name the chunk was fetched against. Recovered from + /// the caller's `list_chunks` request rather than the on-wire + /// chunk metadata. + pub metric: String, + /// `(start_unix_ms, end_unix_ms)` covered by the chunk. + pub time_range_ms: (i64, i64), + /// 64-bit canonical-label-set hash — for prune-by-label-equality + /// without fetching the chunk. + pub label_hash: u64, + /// Number of samples in the chunk. + pub sample_count: u32, + /// On-wire size of the chunk object in bytes. + pub size_bytes: u32, +} + +/// Read-only view over the Gorilla archive tier. +/// +/// Trait-shaped (rather than collapsed onto `GorillaS3Store` +/// concretely) so tests can drop in an in-memory mock without +/// touching production S3 wiring. Step-2 of the JSONL deprecation +/// (Prometheus-block format + Thanos store-gateway) will plug a +/// second impl in under the same trait. +/// +/// Scans are `(metric, [start_ms, end_ms))` — inclusive start, +/// exclusive end — matching the half-open range convention used by +/// the rest of the engine. +#[async_trait] +pub trait Store: Send + Sync { + /// Return all samples for `metric` whose timestamp lies in + /// `[start_ms, end_ms)`. Ordering is not guaranteed. + async fn scan( + &self, + metric: &str, + start_ms: i64, + end_ms: i64, + ) -> Result, StoreError>; + + /// List chunk descriptors covering `[start_ms, end_ms)` without + /// decoding any bodies. + async fn list_chunks( + &self, + metric: &str, + start_ms: i64, + end_ms: i64, + ) -> Result, StoreError>; + + /// Decode a single chunk into an owned `Vec`. + async fn read_chunk(&self, chunk: &ChunkRef) -> Result, StoreError>; + + /// Load + intersect per-bucket postings under `(metric, + /// time_range)` for the supplied `(label_name, label_value)` + /// matchers. Default impl returns + /// [`StoreError::Unsupported`] so chunk-only stores keep + /// compiling without postings sidecars. + async fn list_postings_for( + &self, + _metric: &str, + _start_ms: i64, + _end_ms: i64, + _matchers: &[(String, String)], + ) -> Result { + Err(StoreError::Unsupported("list_postings_for")) + } +} // ───────────────────────────────────────────────────────────────────── // Public config // ───────────────────────────────────────────────────────────────────── -/// Tunable configuration for [`GorillaS3ColdStore`]. +/// Tunable configuration for [`GorillaS3Store`]. /// /// Use [`GorillaS3Config::from_env`] to pull values from environment /// variables in deployment, or build manually for tests. @@ -188,26 +309,21 @@ impl GorillaS3Config { /// Minimal async object-fetch interface. /// -/// Sized + `Send + Sync` so [`GorillaS3ColdStore`] can hold one +/// Sized + `Send + Sync` so [`GorillaS3Store`] can hold one /// behind an `Arc` regardless of how it's backed. /// Production callers use [`S3ObjectStore`] (rust-s3); tests use the /// in-memory mock at the bottom of this file. #[async_trait] pub trait ObjectStore: Send + Sync { /// Fetch the full object body for `key`. - /// - /// Returns [`ColdStoreError::Backend`] for transport errors and - /// [`ColdStoreError::Backend`] (with a `not found` substring) - /// for missing keys; callers distinguish via - /// [`ObjectStore::object_missing`] if they need to. - async fn get_object(&self, key: &str) -> Result, ColdStoreError>; + async fn get_object(&self, key: &str) -> Result, StoreError>; /// True iff `err` was raised because the requested key did not /// exist (vs. a transport / permission failure). Used by the /// list path to treat a missing `index.json` as "no chunks for /// this hour" rather than a hard error. - fn object_missing(&self, err: &ColdStoreError) -> bool { - matches!(err, ColdStoreError::Backend(msg) if msg.contains("not found")) + fn object_missing(&self, err: &StoreError) -> bool { + matches!(err, StoreError::Backend(msg) if msg.contains("not found")) } } @@ -230,7 +346,7 @@ mod rust_s3_backend { /// Build from a [`GorillaS3Config`]. Sets /// `path_style = true` whenever a custom endpoint is /// configured (MinIO mandates path-style addressing). - pub fn new(cfg: &GorillaS3Config) -> Result { + pub fn new(cfg: &GorillaS3Config) -> Result { let region = match &cfg.endpoint { Some(ep) => { let endpoint = if ep.starts_with("http://") || ep.starts_with("https://") { @@ -248,20 +364,20 @@ mod rust_s3_backend { None => cfg .region .parse::() - .map_err(|e| ColdStoreError::Backend(format!("region parse: {e}")))?, + .map_err(|e| StoreError::Backend(format!("region parse: {e}")))?, }; let creds = match (&cfg.access_key_id, &cfg.secret_access_key) { (Some(ak), Some(sk)) => { Credentials::new(Some(ak), Some(sk), None, None, None).map_err(|e| { - ColdStoreError::Backend(format!("credentials: {e}")) + StoreError::Backend(format!("credentials: {e}")) })? } _ => Credentials::default().map_err(|e| { - ColdStoreError::Backend(format!("default credentials: {e}")) + StoreError::Backend(format!("default credentials: {e}")) })?, }; let bucket = Bucket::new(&cfg.bucket, region, creds) - .map_err(|e| ColdStoreError::Backend(format!("bucket: {e}")))?; + .map_err(|e| StoreError::Backend(format!("bucket: {e}")))?; // MinIO + most S3-compatibles require path-style addressing // when a custom endpoint is in play. AWS S3 supports both, // so leaving it on for the AWS path is safe but slightly @@ -277,19 +393,19 @@ mod rust_s3_backend { #[async_trait] impl ObjectStore for S3ObjectStore { - async fn get_object(&self, key: &str) -> Result, ColdStoreError> { + async fn get_object(&self, key: &str) -> Result, StoreError> { let resp = self .bucket .get_object(key) .await - .map_err(|e| ColdStoreError::Backend(format!("s3 get {key}: {e}")))?; + .map_err(|e| StoreError::Backend(format!("s3 get {key}: {e}")))?; if resp.status_code() == 404 { - return Err(ColdStoreError::Backend(format!( + return Err(StoreError::Backend(format!( "s3 get {key}: not found" ))); } if !(200..300).contains(&resp.status_code()) { - return Err(ColdStoreError::Backend(format!( + return Err(StoreError::Backend(format!( "s3 get {key}: status {}", resp.status_code() ))); @@ -302,7 +418,7 @@ mod rust_s3_backend { pub use rust_s3_backend::S3ObjectStore; // ───────────────────────────────────────────────────────────────────── -// GorillaS3ColdStore +// GorillaS3Store // ───────────────────────────────────────────────────────────────────── /// LRU cache keyed by chunk object key. Stored values are @@ -310,19 +426,18 @@ pub use rust_s3_backend::S3ObjectStore; /// chunk skip the Gorilla decode pass entirely. type ChunkCache = Mutex>>>; -/// **mvp/v5**: LRU cache for parsed postings sidecars. Keyed by -/// the postings-v1.json S3 key (one per `(metric, hour)`). 256 -/// entries by default → ≈ 256 MiB at 1 MiB per postings file. -type PostingsCache = Mutex>>; - /// **mvp/v5**: LRU cache for parsed `index.json` files (per /// `(metric, hour)`). Same capacity tier as the postings cache. type IndexCache = Mutex>>; -/// `ColdStore` adapter that reads `GORILLA1`-format chunks out of -/// an S3-compatible bucket. See module docs for layout + S3 client +/// `Store` adapter that reads `GORILLA1`-format chunks out of an +/// S3-compatible bucket. See module docs for layout + S3 client /// notes. -pub struct GorillaS3ColdStore { +/// +/// Step-1 rename (`GorillaS3ColdStore` → `GorillaS3Store`) reflects +/// the JSONL deprecation: there is no longer a "warm/cold" split +/// inside the archive tier; this is *the* archive store. +pub struct GorillaS3Store { object_store: Arc, config: GorillaS3Config, cache: ChunkCache, @@ -338,7 +453,7 @@ pub struct GorillaS3ColdStore { index_cache: IndexCache, } -impl GorillaS3ColdStore { +impl GorillaS3Store { /// Build with an explicit object-store backend. The production /// constructor [`Self::with_default_backend`] wires up /// `S3ObjectStore` from `cfg`; tests inject the in-memory mock. @@ -363,13 +478,13 @@ impl GorillaS3ColdStore { /// `rust-s3`-backed [`ObjectStore`]. /// /// **mvp/v5**: the underlying `S3ObjectStore` is wrapped in an - /// [`super::S3CostTrackingObjectStore`] tied to the global - /// counter set, so the HTTP server's `/internal/s3_cost.csv` - /// + `/metrics` endpoints report measured PUT/GET/etc counts. - pub fn with_default_backend(config: GorillaS3Config) -> Result { + /// [`S3CostTrackingObjectStore`] tied to the global counter + /// set, so the HTTP server's `/internal/s3_cost.csv` + + /// `/metrics` endpoints report measured PUT/GET/etc counts. + pub fn with_default_backend(config: GorillaS3Config) -> Result { let backend: Arc = Arc::new(S3ObjectStore::new(&config)?); - let counters = super::s3_cost_tracker::global_s3_cost_counters(); - let tracked = super::S3CostTrackingObjectStore::new(backend, counters); + let counters = global_s3_cost_counters(); + let tracked = S3CostTrackingObjectStore::new(backend, counters); Ok(Self::new(Arc::new(tracked), config)) } @@ -402,21 +517,7 @@ impl GorillaS3ColdStore { /// * `{year}`/`{month}`/`{day}`/`{hour}` — the backend's /// long-standing names. /// * `{YYYY}`/`{MM}`/`{DD}`/`{HH}` — the agent - /// `gorillas3processor`'s naming, documented in - /// `opentelemetry-collector-contrib-patch/processor/ - /// gorillas3processor/config.go`. - /// - /// Pre-v7 the two sides used different placeholders, so when a - /// deploy set `ASAP_GORILLA_S3_PREFIX_TEMPLATE` to the - /// agent-side spelling (the v6 demo does — see - /// `deploy/docker-compose/mvp-v6-multi-stage.yml`), the backend - /// substituted `{tenant}` and `{metric}` but left the - /// timestamp placeholders un-replaced, so every `index.json` - /// fetch issued a literal `{YYYY}/{MM}/{DD}/{HH}` path that - /// missed the actual chunk objects on disk. Issue #46 - /// criterion ⑥ (freshness probes) surfaced as 0 samples on - /// every path because of this. Accepting both spellings keeps - /// pre-v7 deploys working AND the v6/v7 demo deploy aligned. + /// `gorillas3processor`'s naming. fn bucket_prefix(&self, metric: &str, ts_ms: i64) -> String { let dt: DateTime = DateTime::::from_timestamp_millis(ts_ms) .unwrap_or_else(|| DateTime::::from_timestamp(0, 0).unwrap()); @@ -429,16 +530,10 @@ impl GorillaS3ColdStore { .prefix_template .replace("{tenant}", &self.config.tenant) .replace("{metric}", metric) - // Long-form placeholders (the backend's historical - // spelling — preserved for backwards compatibility). .replace("{year}", &year) .replace("{month}", &month) .replace("{day}", &day) .replace("{hour}", &hour) - // Agent-side `{YYYY}`/`{MM}`/`{DD}`/`{HH}` aliases — - // matches the spelling in the agent's - // `gorillas3processor/config.go` and - // `s3_sink.go::renderPrefix`. .replace("{YYYY}", &year) .replace("{MM}", &month) .replace("{DD}", &day) @@ -471,12 +566,12 @@ impl GorillaS3ColdStore { /// Fetch + parse one hour's `index.json`. Missing index = empty /// catalog (the producer may not have flushed yet); transport - /// failure surfaces as `ColdStoreError::Backend`. - async fn fetch_index(&self, metric: &str, hour_ms: i64) -> Result { + /// failure surfaces as `StoreError::Backend`. + async fn fetch_index(&self, metric: &str, hour_ms: i64) -> Result { let key = self.index_key(metric, hour_ms); match self.object_store.get_object(&key).await { Ok(bytes) => IndexFile::read(bytes.as_slice()).map_err(|e| { - ColdStoreError::Malformed(format!("index.json at {key}: {e}")) + StoreError::Malformed(format!("index.json at {key}: {e}")) }), Err(e) if self.object_store.object_missing(&e) => { debug!(key = %key, "gorilla-s3: index.json missing for hour bucket; skipping"); @@ -488,13 +583,13 @@ impl GorillaS3ColdStore { } #[async_trait] -impl ColdStore for GorillaS3ColdStore { +impl Store for GorillaS3Store { async fn scan( &self, metric: &str, start_ms: i64, end_ms: i64, - ) -> Result, ColdStoreError> { + ) -> Result, StoreError> { let chunks = self.list_chunks(metric, start_ms, end_ms).await?; let mut out = Vec::new(); for chunk in chunks { @@ -513,15 +608,11 @@ impl ColdStore for GorillaS3ColdStore { metric: &str, start_ms: i64, end_ms: i64, - ) -> Result, ColdStoreError> { + ) -> Result, StoreError> { // Convert the request window to the nanosecond unit the // index file uses (`IndexEntry.time_range` is `(ns, ns)`, // mirroring the Go encoder's `time.Time.UnixNano()` source). let start_ns = (start_ms as i128).saturating_mul(1_000_000) as u64; - // `end_ms` is exclusive on the ms side; the index iter - // overlap test is inclusive so subtract 1 ns to keep the - // semantics aligned. If `end_ms == start_ms` we still want - // to scan the bucket containing `start_ms`. let end_ns = if end_ms <= start_ms { start_ns } else { @@ -540,10 +631,7 @@ impl ColdStore for GorillaS3ColdStore { // v7: agent-produced index entries carry just the // chunk's basename (`part-NNNN-MMMM.gor`), not the // full S3 key. Detect a bare basename (no `/`) and - // prepend the bucket prefix so the subsequent - // `read_chunk` GET hits the right object. - // Backend-produced entries carry the full key; we - // leave those unchanged. + // prepend the bucket prefix. let key = if entry.key.contains('/') { entry.key.clone() } else { @@ -562,7 +650,7 @@ impl ColdStore for GorillaS3ColdStore { Ok(out) } - async fn read_chunk(&self, chunk: &ChunkRef) -> Result, ColdStoreError> { + async fn read_chunk(&self, chunk: &ChunkRef) -> Result, StoreError> { // Cache hit fast path. { let mut guard = self.cache.lock().await; @@ -573,7 +661,7 @@ impl ColdStore for GorillaS3ColdStore { let bytes = self.object_store.get_object(&chunk.key).await?; let samples = decode_block(&bytes) - .map_err(|e| ColdStoreError::Malformed(format!("decode {}: {e}", chunk.key)))?; + .map_err(|e| StoreError::Malformed(format!("decode {}: {e}", chunk.key)))?; let arc = Arc::new(samples.clone()); { @@ -583,100 +671,29 @@ impl ColdStore for GorillaS3ColdStore { Ok(samples) } - /// **mvp/v5**: postings-aware chunk pruning. - /// - /// Walks the per-hour buckets covering `[start_ms, end_ms)`, - /// fetches each `postings-v1.json` (LRU-cached), and intersects - /// the per-matcher series-id lists across every bucket. - /// Missing-postings buckets are noted (caller-visible quirk). - /// - /// Empty `matchers` ⇒ returns the union of all postings' - /// series_ids in range — this is the "no predicate" - /// short-circuit and the engine usually skips calling us in - /// that case. + /// **mvp/v5**: postings-aware chunk pruning — delegated to + /// [`super::postings::intersect_per_bucket_postings`] so the + /// per-bucket fetch + intersect logic sits in one place + /// regardless of which `Store` impl owns the postings cache. async fn list_postings_for( &self, metric: &str, start_ms: i64, end_ms: i64, matchers: &[(String, String)], - ) -> Result { + ) -> Result { let buckets = Self::hour_starts(start_ms, end_ms); - let mut hits = PostingsHits { - series_ids: Vec::new(), - buckets_in_range: buckets.len(), - buckets_with_postings: 0, - }; - // Per-bucket: load postings, intersect across matchers, - // union into the running result. Cross-bucket join is a - // UNION (a series might exist in one hour but not the - // next); intra-bucket intersection across matchers is an - // AND. - let mut union_set: std::collections::BTreeSet = - std::collections::BTreeSet::new(); + let mut keys: Vec = Vec::with_capacity(buckets.len()); for hour_ms in buckets { - let key = self.postings_key(metric, hour_ms); - // LRU short-circuit. - let postings = { - let mut guard = self.postings_cache.lock().await; - guard.get(&key).cloned() - }; - let postings = match postings { - Some(p) => Some(p), - None => match self.object_store.get_object(&key).await { - Ok(bytes) => match Postings::read(bytes.as_slice()) { - Ok(p) => { - let arc = Arc::new(p); - let mut guard = self.postings_cache.lock().await; - guard.put(key.clone(), arc.clone()); - Some(arc) - } - Err(e) => { - // Treat a corrupt postings file as - // "missing" — the engine then falls - // through to the scan-all path with - // the postings_missing quirk. - debug!(key = %key, error = %e, "gorilla-s3: postings parse failed; treating as missing"); - None - } - }, - Err(e) if self.object_store.object_missing(&e) => { - debug!(key = %key, "gorilla-s3: postings missing for hour bucket"); - None - } - Err(e) => return Err(e), - }, - }; - let Some(postings) = postings else { continue }; - hits.buckets_with_postings += 1; - - // Intersect across matchers within this bucket. - let bucket_set: std::collections::BTreeSet = if matchers.is_empty() { - // Union of every series_id across every label. - let mut set = std::collections::BTreeSet::new(); - for by_value in postings.by_label.values() { - for ids in by_value.values() { - set.extend(ids.iter().copied()); - } - } - set - } else { - let first = - postings.lookup(&matchers[0].0, &matchers[0].1); - let mut acc: std::collections::BTreeSet = - first.iter().copied().collect(); - for (label_name, label_value) in &matchers[1..] { - let next = postings.lookup(label_name, label_value); - let next_set: std::collections::BTreeSet = - next.iter().copied().collect(); - acc = acc.intersection(&next_set).copied().collect(); - } - acc - }; - union_set.extend(bucket_set); + keys.push(self.postings_key(metric, hour_ms)); } - hits.series_ids = union_set.into_iter().collect(); - Ok(hits) + intersect_per_bucket_postings( + self.object_store.as_ref(), + &self.postings_cache, + &keys, + matchers, + ) + .await } } @@ -685,17 +702,11 @@ impl ColdStore for GorillaS3ColdStore { // ───────────────────────────────────────────────────────────────────── /// Decode a single in-memory `GORILLA1` block into [`RawSample`]s. -/// -/// Walks every series in the block; multi-series blocks are -/// flattened into one `Vec`. Timestamps are converted from the -/// on-wire nanoseconds (Go `time.Time.UnixNano()` source) to the -/// [`RawSample::ts_ms`] millisecond unit. fn decode_block(bytes: &[u8]) -> Result, asap_gorilla::DecodeError> { let mut decoder = GorillaDecoder::from_reader(bytes)?; let mut out: Vec = Vec::new(); while let Some(header) = decoder.header().cloned() { - let labels: std::collections::BTreeMap = - header.labels.iter().cloned().collect(); + let labels: BTreeMap = header.labels.iter().cloned().collect(); for sample in decoder.samples() { let (ts_ns, value) = sample?; out.push(RawSample { @@ -716,11 +727,6 @@ fn decode_block(bytes: &[u8]) -> Result, asap_gorilla::DecodeErro // can exercise the same fixture without a live MinIO. // ───────────────────────────────────────────────────────────────────── -/// In-memory [`ObjectStore`] used by `gorilla_s3` tests. -/// -/// Holds a `HashMap>` plus a per-key fetch counter so -/// cache-hit assertions are first-class. Optionally fails every -/// `get_object` call for the network-error test. #[cfg(test)] #[derive(Default)] pub(crate) struct InMemoryObjectStore { @@ -762,24 +768,24 @@ impl InMemoryObjectStore { #[cfg(test)] #[async_trait] impl ObjectStore for InMemoryObjectStore { - async fn get_object(&self, key: &str) -> Result, ColdStoreError> { + async fn get_object(&self, key: &str) -> Result, StoreError> { let mut g = self.inner.lock().await; if let Some(msg) = g.fail_all.clone() { - return Err(ColdStoreError::Backend(msg)); + return Err(StoreError::Backend(msg)); } *g.fetch_counts.entry(key.to_string()).or_insert(0) += 1; match g.objects.get(key) { Some(b) => Ok(b.clone()), - None => Err(ColdStoreError::Backend(format!("get {key}: not found"))), + None => Err(StoreError::Backend(format!("get {key}: not found"))), } } } -// Static `Send` assertion — `GorillaS3ColdStore` must be storable -// behind an `Arc` in the existing s3_adapter chain. +// Static `Send` assertion — `GorillaS3Store` must be storable +// behind an `Arc` in the engine wiring. const _: fn() = || { fn _assert_send() {} - _assert_send::(); + _assert_send::(); }; // ───────────────────────────────────────────────────────────────────── @@ -812,7 +818,6 @@ mod tests { .collect(), ); for (ts_ms, v) in samples { - // ts_ms → ts_ns enc.append((*ts_ms as u64) * 1_000_000, *v); } enc.finalize().unwrap() @@ -838,8 +843,6 @@ mod tests { } } - /// Layout: hour bucket H, three chunks A/B/C in time order, the - /// requested window only overlaps B → list returns B alone. #[tokio::test] async fn list_chunks_via_indexfile_prunes_by_time() { let store = InMemoryObjectStore::new(); @@ -858,9 +861,9 @@ mod tests { sample_count: 10, label_hash: 0xAAAA, size_bytes: 100, - object_key: None, - byte_offset: None, - byte_length: None, + object_key: None, + byte_offset: None, + byte_length: None, }, IndexEntry { key: key_b.clone(), @@ -868,9 +871,9 @@ mod tests { sample_count: 11, label_hash: 0xBBBB, size_bytes: 110, - object_key: None, - byte_offset: None, - byte_length: None, + object_key: None, + byte_offset: None, + byte_length: None, }, IndexEntry { key: key_c.clone(), @@ -878,9 +881,9 @@ mod tests { sample_count: 12, label_hash: 0xCCCC, size_bytes: 120, - object_key: None, - byte_offset: None, - byte_length: None, + object_key: None, + byte_offset: None, + byte_length: None, }, ]; @@ -891,7 +894,7 @@ mod tests { ) .await; - let cs = GorillaS3ColdStore::new(Arc::new(store), cfg()); + let cs = GorillaS3Store::new(Arc::new(store), cfg()); let chunks = cs .list_chunks(metric, h0 + 5_500, h0 + 5_800) .await @@ -932,14 +935,14 @@ mod tests { sample_count: 3, label_hash: 0x1234, size_bytes: block.len() as u32, - object_key: None, - byte_offset: None, - byte_length: None, + object_key: None, + byte_offset: None, + byte_length: None, }]), ) .await; - let cs = GorillaS3ColdStore::new(Arc::new(store), cfg()); + let cs = GorillaS3Store::new(Arc::new(store), cfg()); let chunks = cs.list_chunks(metric, h0, h0 + 60_000).await.unwrap(); assert_eq!(chunks.len(), 1); @@ -976,14 +979,14 @@ mod tests { sample_count: 2, label_hash: 0, size_bytes: block.len() as u32, - object_key: None, - byte_offset: None, - byte_length: None, + object_key: None, + byte_offset: None, + byte_length: None, }]), ) .await; - let cs = GorillaS3ColdStore::new(store.clone(), cfg()); + let cs = GorillaS3Store::new(store.clone(), cfg()); let chunks = cs.list_chunks(metric, h0, h0 + 60_000).await.unwrap(); assert_eq!(chunks.len(), 1); @@ -1001,8 +1004,6 @@ mod tests { #[tokio::test] async fn lru_eviction_under_pressure() { - // cache_capacity=2, fill with three chunks then re-read the - // first → that triggers an S3 GET because the LRU evicted it. let store = Arc::new(InMemoryObjectStore::new()); let h0 = ms(2026, 5, 6, 12, 0, 0); @@ -1025,9 +1026,9 @@ mod tests { sample_count: 2, label_hash: i as u64, size_bytes: block.len() as u32, - object_key: None, - byte_offset: None, - byte_length: None, + object_key: None, + byte_offset: None, + byte_length: None, }); chunk_refs.push(ChunkRef { key, @@ -1044,7 +1045,7 @@ mod tests { let mut config = cfg(); config.cache_capacity = 2; - let cs = GorillaS3ColdStore::new(store.clone(), config); + let cs = GorillaS3Store::new(store.clone(), config); cs.read_chunk(&chunk_refs[0]).await.unwrap(); cs.read_chunk(&chunk_refs[1]).await.unwrap(); @@ -1071,10 +1072,10 @@ mod tests { ) .await; - let cs = GorillaS3ColdStore::new(Arc::new(store), cfg()); + let cs = GorillaS3Store::new(Arc::new(store), cfg()); let res = cs.list_chunks("m", h0, h0 + 60_000).await; match res { - Err(ColdStoreError::Malformed(msg)) => { + Err(StoreError::Malformed(msg)) => { assert!(msg.contains("index.json"), "msg should name the key: {msg}") } other => panic!("expected Malformed, got {other:?}"), @@ -1085,11 +1086,11 @@ mod tests { async fn s3_unavailable_returns_error() { let store = Arc::new(InMemoryObjectStore::new()); store.fail_all("simulated network outage").await; - let cs = GorillaS3ColdStore::new(store, cfg()); + let cs = GorillaS3Store::new(store, cfg()); let h0 = ms(2026, 5, 6, 12, 0, 0); let res = cs.list_chunks("m", h0, h0 + 60_000).await; match res { - Err(ColdStoreError::Backend(msg)) => assert!(msg.contains("simulated network outage")), + Err(StoreError::Backend(msg)) => assert!(msg.contains("simulated network outage")), other => panic!("expected Backend, got {other:?}"), } } @@ -1097,7 +1098,7 @@ mod tests { #[tokio::test] async fn missing_index_is_empty_not_error() { let store = InMemoryObjectStore::new(); - let cs = GorillaS3ColdStore::new(Arc::new(store), cfg()); + let cs = GorillaS3Store::new(Arc::new(store), cfg()); let h0 = ms(2026, 5, 6, 12, 0, 0); let chunks = cs.list_chunks("never_written", h0, h0 + 60_000).await.unwrap(); assert!(chunks.is_empty()); @@ -1107,11 +1108,6 @@ mod tests { #[tokio::test] async fn scan_filters_to_requested_range() { - // Chunk has samples at h0+1_000 and h0+10_000; request only - // [h0+5_000, h0+9_000) — chunk overlaps the request, but the - // matching sample is *outside* the inner filter, so scan - // returns 0 samples (read_chunk would still load + cache the - // chunk). let store = Arc::new(InMemoryObjectStore::new()); let h0 = ms(2026, 5, 6, 12, 0, 0); let block = make_block( @@ -1133,14 +1129,14 @@ mod tests { sample_count: 2, label_hash: 0, size_bytes: block.len() as u32, - object_key: None, - byte_offset: None, - byte_length: None, + object_key: None, + byte_offset: None, + byte_length: None, }]), ) .await; - let cs = GorillaS3ColdStore::new(store, cfg()); + let cs = GorillaS3Store::new(store, cfg()); let samples = cs.scan("m", h0 + 5_000, h0 + 9_000).await.unwrap(); assert!(samples.is_empty(), "no sample inside [5_000, 9_000) ms"); @@ -1167,9 +1163,9 @@ mod tests { sample_count: 1, label_hash: 0, size_bytes: 50, - object_key: None, - byte_offset: None, - byte_length: None, + object_key: None, + byte_offset: None, + byte_length: None, }]), ) .await; @@ -1185,13 +1181,13 @@ mod tests { sample_count: 1, label_hash: 0, size_bytes: 50, - object_key: None, - byte_offset: None, - byte_length: None, + object_key: None, + byte_offset: None, + byte_length: None, }]), ) .await; - let cs = GorillaS3ColdStore::new(Arc::new(store), cfg()); + let cs = GorillaS3Store::new(Arc::new(store), cfg()); let chunks = cs .list_chunks("m", h12 + 3_500_000, h13 + 30_000) .await @@ -1203,50 +1199,39 @@ mod tests { #[test] fn bucket_prefix_supports_long_form_placeholders() { - // Backend's historical spelling — preserved. let mut config = cfg(); config.prefix_template = "{tenant}/{metric}/{year}/{month}/{day}/{hour}/".to_string(); let store = InMemoryObjectStore::new(); - let cs = GorillaS3ColdStore::new(Arc::new(store), config); + let cs = GorillaS3Store::new(Arc::new(store), config); let key = cs.bucket_prefix("foo", ms(2026, 5, 6, 12, 0, 0)); assert_eq!(key, "tenant1/foo/2026/05/06/12/"); } #[test] fn bucket_prefix_supports_agent_side_yyyy_mm_dd_hh_placeholders() { - // v7 fix: the agent's gorillas3processor uses - // `{YYYY}`/`{MM}`/`{DD}`/`{HH}`. Pre-v7 the backend left - // these literal; v7 substitutes them so a deploy that - // configures the routing yaml with the agent-side - // spelling gets matching index.json keys on both sides. let mut config = cfg(); config.prefix_template = "{tenant}/{metric}/{YYYY}/{MM}/{DD}/{HH}/".to_string(); let store = InMemoryObjectStore::new(); - let cs = GorillaS3ColdStore::new(Arc::new(store), config); + let cs = GorillaS3Store::new(Arc::new(store), config); let key = cs.bucket_prefix("http_freshness_probe_archive", ms(2026, 5, 7, 4, 0, 0)); assert_eq!( key, "tenant1/http_freshness_probe_archive/2026/05/07/04/", - "v7 must substitute {{YYYY}}/{{MM}}/{{DD}}/{{HH}} the same as the long-form names", ); } #[test] fn bucket_prefix_handles_mixed_long_and_short_placeholders() { - // Defensive — accept a mix in case some operator templates - // it that way. let mut config = cfg(); config.prefix_template = "{tenant}/{metric}/{year}/{MM}/{DD}/{hour}/".to_string(); let store = InMemoryObjectStore::new(); - let cs = GorillaS3ColdStore::new(Arc::new(store), config); + let cs = GorillaS3Store::new(Arc::new(store), config); let key = cs.bucket_prefix("m", ms(2026, 5, 7, 4, 0, 0)); assert_eq!(key, "tenant1/m/2026/05/07/04/"); } #[test] fn from_env_requires_bucket() { - // Don't pollute global env in a unit test; just exercise the - // missing-var path. let prev_bucket = std::env::var("ASAP_GORILLA_S3_BUCKET").ok(); std::env::remove_var("ASAP_GORILLA_S3_BUCKET"); let res = GorillaS3Config::from_env(); diff --git a/asap-query-engine/src/engines/gorilla_engine/tests.rs b/asap-query-engine/src/engines/gorilla/tests.rs similarity index 94% rename from asap-query-engine/src/engines/gorilla_engine/tests.rs rename to asap-query-engine/src/engines/gorilla/tests.rs index e2a9e4dda..63207cb03 100644 --- a/asap-query-engine/src/engines/gorilla_engine/tests.rs +++ b/asap-query-engine/src/engines/gorilla/tests.rs @@ -1,7 +1,7 @@ -//! Phase-4 unit tests for the Gorilla query engine. +//! Unit tests for the Gorilla query engine. //! -//! Tests exercise the engine end-to-end via a `MockColdStore` -//! injected in place of the production `GorillaS3ColdStore`. The +//! Tests exercise the engine end-to-end via a `MockStore` +//! injected in place of the production `GorillaS3Store`. The //! mock is intentionally minimal: it owns a `Vec<(ChunkRef, //! Vec)>` and answers `list_chunks` / `read_chunk` //! straight off it, with optional latency injection for the @@ -14,39 +14,37 @@ use std::time::Duration; use async_trait::async_trait; use tokio::time::sleep; -use crate::drivers::query::fallback::cold_store::{ - ChunkRef, ColdStore, ColdStoreError, PostingsHits, RawSample, -}; use crate::engines::query_result::QueryResult; use crate::stores::sketch_db::accuracy::{AccuracyKind, AccuracyProfile}; -use super::query_planner::{plan_query_at, QueryStatistic}; +use super::engine::{plan_query_at, QueryStatistic}; +use super::store::{ChunkRef, RawSample, Store, StoreError}; use super::{ wrap_result, EngineError, ExactExecutor, ExecutionOutcome, GorillaEngineConfig, - GorillaQueryEngine, DATA_SOURCE_GORILLA_ARCHIVE, + GorillaQueryEngine, PostingsHits, DATA_SOURCE_GORILLA_ARCHIVE, }; // ───────────────────────────────────────────────────────────────────── // Mock cold store // ───────────────────────────────────────────────────────────────────── -/// In-process mock that satisfies the [`ColdStore`] trait without +/// In-process mock that satisfies the [`Store`] trait without /// any S3 / disk roundtrip. Built once in each test from a list of /// `(ChunkRef, samples)` pairs. #[derive(Default)] -struct MockColdStore { +struct MockStore { chunks: Vec<(ChunkRef, Vec)>, /// If set, every `read_chunk` call sleeps for this duration — /// used by the timeout test. read_delay: Option, /// **mvp/v5**: optional postings table keyed by `(label_name, - /// label_value)`. When `Some`, [`ColdStore::list_postings_for`] + /// label_value)`. When `Some`, [`Store::list_postings_for`] /// answers from this table; when `None`, returns a "missing /// postings" outcome (driving the fall-back path test). postings: Option>>, } -impl MockColdStore { +impl MockStore { fn new(chunks: Vec<(ChunkRef, Vec)>) -> Self { Self { chunks, @@ -72,13 +70,13 @@ impl MockColdStore { } #[async_trait] -impl ColdStore for MockColdStore { +impl Store for MockStore { async fn scan( &self, metric: &str, start_ms: i64, end_ms: i64, - ) -> Result, ColdStoreError> { + ) -> Result, StoreError> { let mut out = Vec::new(); let chunks = self.list_chunks(metric, start_ms, end_ms).await?; for c in chunks { @@ -96,7 +94,7 @@ impl ColdStore for MockColdStore { metric: &str, start_ms: i64, end_ms: i64, - ) -> Result, ColdStoreError> { + ) -> Result, StoreError> { Ok(self .chunks .iter() @@ -109,7 +107,7 @@ impl ColdStore for MockColdStore { .collect()) } - async fn read_chunk(&self, chunk: &ChunkRef) -> Result, ColdStoreError> { + async fn read_chunk(&self, chunk: &ChunkRef) -> Result, StoreError> { if let Some(d) = self.read_delay { sleep(d).await; } @@ -118,7 +116,7 @@ impl ColdStore for MockColdStore { return Ok(samples.clone()); } } - Err(ColdStoreError::Backend(format!( + Err(StoreError::Backend(format!( "mock: no such chunk {}", chunk.key ))) @@ -130,13 +128,13 @@ impl ColdStore for MockColdStore { _start_ms: i64, _end_ms: i64, matchers: &[(String, String)], - ) -> Result { + ) -> Result { let Some(table) = &self.postings else { // Mirror "real" missing-postings behaviour: the trait // says return Unsupported when the backend doesn't // know how to compute this. The executor treats that // as fall-through. - return Err(ColdStoreError::Unsupported("list_postings_for")); + return Err(StoreError::Unsupported("list_postings_for")); }; let mut hits = PostingsHits { series_ids: Vec::new(), @@ -216,14 +214,14 @@ fn cfg() -> GorillaEngineConfig { } fn engine_with(chunks: Vec<(ChunkRef, Vec)>) -> GorillaQueryEngine { - GorillaQueryEngine::new(Arc::new(MockColdStore::new(chunks)), cfg()) + GorillaQueryEngine::new(Arc::new(MockStore::new(chunks)), cfg()) } fn engine_with_config( chunks: Vec<(ChunkRef, Vec)>, config: GorillaEngineConfig, ) -> GorillaQueryEngine { - GorillaQueryEngine::new(Arc::new(MockColdStore::new(chunks)), config) + GorillaQueryEngine::new(Arc::new(MockStore::new(chunks)), config) } // ───────────────────────────────────────────────────────────────────── @@ -246,7 +244,7 @@ async fn execute_sum_over_time_streaming() { assert_eq!(plan.statistic, QueryStatistic::SumOverTime); let exec = ExactExecutor::new( - Arc::new(MockColdStore::new(vec![linear_chunk( + Arc::new(MockStore::new(vec![linear_chunk( "c1", NOW_MS - 60_000, 1_000, @@ -275,7 +273,7 @@ async fn execute_count_over_time() { let engine = engine_with(chunks); let plan = plan_query_at(&format!("count_over_time({METRIC}[1m])"), NOW_MS).unwrap(); let exec = ExactExecutor::new( - Arc::new(MockColdStore::new(vec![linear_chunk( + Arc::new(MockStore::new(vec![linear_chunk( "c1", NOW_MS - 30_000, 1_000, @@ -734,7 +732,7 @@ async fn engine_respects_config_timeout() { }; chunks.push((chunk, vec![raw(NOW_MS - 1_000, 1.0)])); } - let mock = MockColdStore::new(chunks).with_read_delay(Duration::from_millis(250)); + let mock = MockStore::new(chunks).with_read_delay(Duration::from_millis(250)); let cfg = GorillaEngineConfig { max_buffered_samples: 1_000_000, query_timeout_secs: 1, @@ -797,13 +795,13 @@ async fn postings_aware_path_prunes_chunks() { let mut postings: BTreeMap<(String, String), Vec> = BTreeMap::new(); postings.insert(("zone".to_string(), "a".to_string()), vec![11]); postings.insert(("zone".to_string(), "b".to_string()), vec![22]); - let mock = MockColdStore::new(vec![(chunk_a, samples_a), (chunk_b, samples_b)]) + let mock = MockStore::new(vec![(chunk_a, samples_a), (chunk_b, samples_b)]) .with_postings(postings); let engine = GorillaQueryEngine::new(Arc::new(mock), cfg()); let plan = plan_query_at(&format!(r#"sum_over_time({METRIC}{{zone="a"}}[5m])"#), NOW_MS).unwrap(); assert_eq!(plan.label_matchers.len(), 1); - let exec = ExactExecutor::new(engine.cold_store_for_tests(), cfg()); + let exec = ExactExecutor::new(engine.store_for_tests(), cfg()); let outcome = exec.execute_plan(&plan).await.unwrap(); // Only zone=a chunk contributed: 5.0 + 5.0 = 10.0 (NOT 5+5+99+99=208). assert_eq!(outcome.value, 10.0); @@ -822,11 +820,11 @@ async fn postings_missing_falls_back_to_scan_all() { labeled_chunk("k-a", 11, "a", NOW_MS - 30_000, &[(NOW_MS - 1_000, 5.0)]); let (chunk_b, samples_b) = labeled_chunk("k-b", 22, "b", NOW_MS - 30_000, &[(NOW_MS - 1_000, 99.0)]); - let mock = MockColdStore::new(vec![(chunk_a, samples_a), (chunk_b, samples_b)]); + let mock = MockStore::new(vec![(chunk_a, samples_a), (chunk_b, samples_b)]); let engine = GorillaQueryEngine::new(Arc::new(mock), cfg()); let plan = plan_query_at(&format!(r#"sum_over_time({METRIC}{{zone="a"}}[5m])"#), NOW_MS).unwrap(); - let exec = ExactExecutor::new(engine.cold_store_for_tests(), cfg()); + let exec = ExactExecutor::new(engine.store_for_tests(), cfg()); let outcome = exec.execute_plan(&plan).await.unwrap(); // Correctness: only zone=a sample (5.0) folded in. The // post-decode filter does the work. @@ -847,11 +845,11 @@ async fn postings_path_no_label_predicate_skips_postings_lookup() { labeled_chunk("k-a", 11, "a", NOW_MS - 30_000, &[(NOW_MS - 1_000, 5.0)]); let (chunk_b, samples_b) = labeled_chunk("k-b", 22, "b", NOW_MS - 30_000, &[(NOW_MS - 1_000, 99.0)]); - let mock = MockColdStore::new(vec![(chunk_a, samples_a), (chunk_b, samples_b)]); + let mock = MockStore::new(vec![(chunk_a, samples_a), (chunk_b, samples_b)]); let engine = GorillaQueryEngine::new(Arc::new(mock), cfg()); let plan = plan_query_at(&format!("sum_over_time({METRIC}[5m])"), NOW_MS).unwrap(); assert!(plan.label_matchers.is_empty()); - let exec = ExactExecutor::new(engine.cold_store_for_tests(), cfg()); + let exec = ExactExecutor::new(engine.store_for_tests(), cfg()); let outcome = exec.execute_plan(&plan).await.unwrap(); assert_eq!(outcome.value, 104.0); assert_eq!(outcome.chunks_fetched, 2); diff --git a/asap-query-engine/src/engines/gorilla_engine/query_planner.rs b/asap-query-engine/src/engines/gorilla_engine/query_planner.rs deleted file mode 100644 index 694ea5f91..000000000 --- a/asap-query-engine/src/engines/gorilla_engine/query_planner.rs +++ /dev/null @@ -1,410 +0,0 @@ -//! PromQL → [`QueryPlan`] translator for the Phase-4 Gorilla engine. -//! -//! The Phase-4 surface is intentionally narrow: instant-vector -//! queries that wrap a single matrix selector with one of the -//! supported `*_over_time` / `rate` / `increase` functions, OR -//! a top-level `quantile_over_time(φ, m[range])` / -//! `topk(k, m[range])`-style aggregation. -//! -//! Time range is `(now - lookback_ms, now)` where `now` is the -//! caller-supplied "query time" — fixed to `chrono::Utc::now()` -//! when not specified, so callers that don't care about backdating -//! a query don't need to thread a clock through. - -use std::time::SystemTime; - -use chrono::Utc; -use promql_parser::parser::{ - AggregateExpr, Call, Expr, FunctionArgs, MatrixSelector, NumberLiteral, ParenExpr, - VectorSelector, -}; - -/// Statistic to compute, alongside any extra parameters -/// (quantile φ, top-k k). -#[derive(Debug, Clone, PartialEq)] -pub enum QueryStatistic { - /// `sum_over_time(m[range])` - SumOverTime, - /// `count_over_time(m[range])` - CountOverTime, - /// `avg_over_time(m[range])` (= sum / count) - AvgOverTime, - /// `min_over_time(m[range])` - MinOverTime, - /// `max_over_time(m[range])` - MaxOverTime, - /// `rate(m[range])` — `(last - first) / range_seconds` - Rate, - /// `increase(m[range])` — `last - first` - Increase, - /// `quantile_over_time(φ, m[range])` - QuantileOverTime { phi: f64 }, - /// `topk(k, sum_over_time(m[range]))`-style aggregation. The - /// MVP Phase 4 implementation returns the sum of the top-`k` - /// sample values in the range — once Phase 5 adds spatial - /// grouping the executor will return a per-group vector. - TopK { k: usize }, - /// **v7**: `last_over_time(m[range])` — value of the - /// largest-timestamp sample in the range. Used by issue #46 - /// criterion ⑥ freshness probes; counter-shaped probes encode - /// `unix_ts_ms_of_emission` in their cumulative value, and - /// `last_over_time(...)` returns that value so the replay - /// client can compute per-path freshness deltas. - LastOverTime, -} - -impl QueryStatistic { - /// True iff the executor can answer this statistic via the - /// streaming-additive path; false → buffered path (everything - /// has to be in memory before producing the answer). - pub fn is_streaming_additive(&self) -> bool { - matches!( - self, - Self::SumOverTime - | Self::CountOverTime - | Self::AvgOverTime - | Self::MinOverTime - | Self::MaxOverTime - | Self::Rate - | Self::Increase - | Self::LastOverTime - ) - } -} - -/// One label-equality matcher extracted from the PromQL AST. mvp/v5 -/// uses these to drive the postings-aware chunk-pruning path. -/// -/// The MVP only supports exact equality (`label = "value"`). Regex -/// (`=~`) and inequality (`!=`, `!~`) matchers fall through to a -/// post-decode filter — the postings file holds *exact* values per -/// label, not patterns. The fall-through is correct (just slower) -/// and is signalled to callers via -/// [`QueryPlan::has_unsupported_matchers`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LabelMatcher { - /// Label name, e.g. `"service"`. - pub name: String, - /// Label value, e.g. `"api"`. - pub value: String, -} - -/// Output of [`plan_query`]. -#[derive(Debug, Clone, PartialEq)] -pub struct QueryPlan { - pub metric: String, - /// Half-open `[start_ms, end_ms)` request window. Computed as - /// `(now_ms - range_ms, now_ms)` from the matrix selector's - /// `[range]` duration. - pub time_range_ms: (i64, i64), - pub statistic: QueryStatistic, - /// **mvp/v5**: exact-equality label matchers extracted from the - /// vector selector. Empty for `metric[range]` (no predicate). - /// Non-empty for `metric{label="value"}[range]`. Used by the - /// postings-aware chunk filter; `=~` / `!=` / `!~` matchers are - /// dropped from this list and signalled via - /// [`Self::has_unsupported_matchers`]. - pub label_matchers: Vec, - /// **mvp/v5**: `true` iff the original PromQL had at least one - /// matcher we couldn't translate into a postings lookup (regex, - /// inequality). The caller must still apply those matchers - /// post-decode; we surface the flag so `data_source_quirk` - /// annotations make it back to the client. - pub has_unsupported_matchers: bool, -} - -/// Parse `query` and produce a [`QueryPlan`]. `now` defaults to -/// the system clock; the [`plan_query_at`] variant lets tests pin -/// a deterministic timestamp. -pub fn plan_query(query: &str) -> Result { - let now_ms = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or_else(|_| Utc::now().timestamp_millis()); - plan_query_at(query, now_ms) -} - -/// As [`plan_query`], with a caller-supplied `now_ms`. -pub fn plan_query_at(query: &str, now_ms: i64) -> Result { - let ast = promql_parser::parser::parse(query).map_err(|e| format!("parse: {e}"))?; - plan_from_ast(&ast, now_ms) -} - -fn plan_from_ast(ast: &Expr, now_ms: i64) -> Result { - match ast { - Expr::Paren(ParenExpr { expr }) => plan_from_ast(expr, now_ms), - Expr::Call(call) => plan_from_call(call, now_ms), - Expr::Aggregate(agg) => plan_from_aggregate(agg, now_ms), - other => Err(format!( - "unsupported top-level expression: {:?}; the Gorilla engine \ - expects a single function call (rate/increase/*_over_time) \ - or topk(k, ...) aggregation", - std::mem::discriminant(other) - )), - } -} - -fn plan_from_call(call: &Call, now_ms: i64) -> Result { - let name = call.func.name.to_lowercase(); - match name.as_str() { - "sum_over_time" - | "count_over_time" - | "avg_over_time" - | "min_over_time" - | "max_over_time" - | "last_over_time" - | "rate" - | "increase" => { - let ms = expect_single_matrix_arg(&call.args, &name)?; - let (metric, range_ms) = matrix_metric_and_range_ms(ms); - let stat = match name.as_str() { - "sum_over_time" => QueryStatistic::SumOverTime, - "count_over_time" => QueryStatistic::CountOverTime, - "avg_over_time" => QueryStatistic::AvgOverTime, - "min_over_time" => QueryStatistic::MinOverTime, - "max_over_time" => QueryStatistic::MaxOverTime, - "last_over_time" => QueryStatistic::LastOverTime, - "rate" => QueryStatistic::Rate, - "increase" => QueryStatistic::Increase, - _ => unreachable!(), - }; - let (label_matchers, has_unsupported) = extract_label_matchers(&ms.vs); - Ok(QueryPlan { - metric, - time_range_ms: (now_ms - range_ms, now_ms), - statistic: stat, - label_matchers, - has_unsupported_matchers: has_unsupported, - }) - } - "quantile_over_time" => { - // quantile_over_time(φ, m[range]) - if call.args.args.len() != 2 { - return Err(format!( - "quantile_over_time expects 2 args, got {}", - call.args.args.len() - )); - } - let phi = expect_number(&call.args.args[0], "quantile_over_time φ")?; - let ms = expect_matrix_selector(&call.args.args[1], "quantile_over_time")?; - let (metric, range_ms) = matrix_metric_and_range_ms(ms); - let (label_matchers, has_unsupported) = extract_label_matchers(&ms.vs); - Ok(QueryPlan { - metric, - time_range_ms: (now_ms - range_ms, now_ms), - statistic: QueryStatistic::QuantileOverTime { phi }, - label_matchers, - has_unsupported_matchers: has_unsupported, - }) - } - other => Err(format!( - "unsupported PromQL function: {other}; the Gorilla engine \ - supports rate/increase/*_over_time/quantile_over_time" - )), - } -} - -fn plan_from_aggregate(agg: &AggregateExpr, now_ms: i64) -> Result { - // PromQL grammar requires aggregation operators to take a - // vector — so the legal Phase-4 spellings are e.g. - // `topk(2, sum_over_time(m[10s]))`. We strip the outer - // aggregation, recurse into the inner call to recover the - // `(metric, range)` pair, then overlay the TopK statistic. - let op_str = format!("{}", agg.op); - if !op_str.eq_ignore_ascii_case("topk") { - return Err(format!( - "unsupported top-level aggregation: {op_str}; only `topk(k, ...)` \ - is supported in Phase 4" - )); - } - let k_expr = agg - .param - .as_deref() - .ok_or_else(|| "topk requires a numeric parameter (k)".to_string())?; - let k = expect_number(k_expr, "topk k")?; - if !k.is_finite() || k <= 0.0 { - return Err(format!("topk k must be positive, got {k}")); - } - // Recurse into the inner expression — it can be a matrix - // selector (handled by [`matrix_metric_and_range_ms`] - // directly) OR a vector-returning function call (the legal - // PromQL spelling). Either way we end up with a - // `(metric, range_ms)` pair we can overlay TopK on. - let inner_plan = match &*agg.expr { - Expr::MatrixSelector(ms) => { - let (metric, range_ms) = matrix_metric_and_range_ms(ms); - let (label_matchers, has_unsupported) = extract_label_matchers(&ms.vs); - QueryPlan { - metric, - time_range_ms: (now_ms - range_ms, now_ms), - statistic: QueryStatistic::SumOverTime, // overlay below - label_matchers, - has_unsupported_matchers: has_unsupported, - } - } - _ => plan_from_ast(&agg.expr, now_ms)?, - }; - Ok(QueryPlan { - metric: inner_plan.metric, - time_range_ms: inner_plan.time_range_ms, - statistic: QueryStatistic::TopK { k: k as usize }, - label_matchers: inner_plan.label_matchers, - has_unsupported_matchers: inner_plan.has_unsupported_matchers, - }) -} - -fn expect_single_matrix_arg<'a>( - args: &'a FunctionArgs, - fname: &str, -) -> Result<&'a MatrixSelector, String> { - if args.args.len() != 1 { - return Err(format!( - "{fname} expects 1 matrix-selector arg, got {}", - args.args.len() - )); - } - expect_matrix_selector(&args.args[0], fname) -} - -fn expect_matrix_selector<'a>(expr: &'a Expr, ctx: &str) -> Result<&'a MatrixSelector, String> { - match expr { - Expr::MatrixSelector(ms) => Ok(ms), - Expr::Paren(ParenExpr { expr }) => expect_matrix_selector(expr, ctx), - other => Err(format!( - "{ctx}: expected matrix selector `metric[range]`, got {:?}", - std::mem::discriminant(other) - )), - } -} - -fn expect_number(expr: &Expr, ctx: &str) -> Result { - match expr { - Expr::NumberLiteral(NumberLiteral { val }) => Ok(*val), - Expr::Paren(ParenExpr { expr }) => expect_number(expr, ctx), - other => Err(format!( - "{ctx}: expected numeric literal, got {:?}", - std::mem::discriminant(other) - )), - } -} - -fn matrix_metric_and_range_ms(ms: &MatrixSelector) -> (String, i64) { - let metric = vector_selector_metric(&ms.vs); - let range_ms = ms.range.as_millis() as i64; - (metric, range_ms) -} - -fn vector_selector_metric(vs: &VectorSelector) -> String { - if let Some(name) = &vs.name { - return name.clone(); - } - // Fallback: inspect matchers for an `__name__` exact match. - for m in vs.matchers.matchers.iter() { - if m.name == "__name__" { - return m.value.clone(); - } - } - String::new() -} - -/// **mvp/v5**: extract exact-equality label matchers from a vector -/// selector for postings-aware chunk pruning. -/// -/// Returns `(supported_matchers, has_unsupported_matchers)`. Supported -/// matchers are the `label = "value"` tuples the postings file can -/// answer directly. Anything else (regex, inequality, the implicit -/// `__name__` matcher) is excluded from `supported_matchers` and -/// flips the second return value to `true` — the executor still -/// applies them post-decode for correctness. -pub(crate) fn extract_label_matchers(vs: &VectorSelector) -> (Vec, bool) { - use promql_parser::label::MatchOp; - - let mut supported = Vec::new(); - let mut has_unsupported = false; - for m in vs.matchers.matchers.iter() { - // The implicit `__name__` matcher is the metric name itself - // — we already pulled that out of the selector elsewhere. - if m.name == "__name__" { - continue; - } - match &m.op { - MatchOp::Equal => { - supported.push(LabelMatcher { - name: m.name.clone(), - value: m.value.clone(), - }); - } - // Regex / inequality matchers are correctness-relevant - // but cannot be answered by an exact postings lookup. - // Surface the flag so the caller emits a quirk - // annotation; the actual filter is applied post-decode. - MatchOp::NotEqual | MatchOp::Re(_) | MatchOp::NotRe(_) => { - has_unsupported = true; - } - } - } - (supported, has_unsupported) -} - -#[cfg(test)] -mod tests { - use super::*; - - const NOW: i64 = 1_715_000_000_000; - - #[test] - fn plans_sum_over_time() { - let plan = plan_query_at("sum_over_time(http_requests_total[5m])", NOW).unwrap(); - assert_eq!(plan.metric, "http_requests_total"); - assert_eq!(plan.statistic, QueryStatistic::SumOverTime); - assert_eq!(plan.time_range_ms, (NOW - 5 * 60_000, NOW)); - } - - #[test] - fn plans_quantile_over_time() { - let plan = plan_query_at("quantile_over_time(0.99, latency_ms[1m])", NOW).unwrap(); - assert_eq!(plan.metric, "latency_ms"); - assert!(matches!( - plan.statistic, - QueryStatistic::QuantileOverTime { phi } if (phi - 0.99).abs() < 1e-12 - )); - } - - #[test] - fn plans_topk() { - // Legal PromQL spelling: aggregation wraps a vector-returning - // function call. The Phase-4 planner peels off the outer - // `topk` and recovers the `(metric, range)` pair from the - // inner `sum_over_time(...)`. - let plan = plan_query_at("topk(3, sum_over_time(m[10s]))", NOW).unwrap(); - assert!(matches!(plan.statistic, QueryStatistic::TopK { k } if k == 3)); - assert_eq!(plan.metric, "m"); - assert_eq!(plan.time_range_ms, (NOW - 10_000, NOW)); - } - - #[test] - fn rejects_binary_expression() { - assert!(plan_query_at("foo + bar", NOW).is_err()); - } - - #[test] - fn streaming_classification() { - assert!(QueryStatistic::SumOverTime.is_streaming_additive()); - assert!(QueryStatistic::Rate.is_streaming_additive()); - assert!(QueryStatistic::LastOverTime.is_streaming_additive()); - assert!(!QueryStatistic::QuantileOverTime { phi: 0.5 }.is_streaming_additive()); - assert!(!QueryStatistic::TopK { k: 1 }.is_streaming_additive()); - } - - #[test] - fn plans_last_over_time_v7() { - // v7: `last_over_time(...)` translates to the streaming - // additive path, picking the value of the largest-timestamp - // sample in [now-range, now). Issue #46 ⑥ freshness probes - // ride this path. - let plan = plan_query_at("last_over_time(http_freshness_probe_warm[10s])", NOW).unwrap(); - assert_eq!(plan.metric, "http_freshness_probe_warm"); - assert_eq!(plan.statistic, QueryStatistic::LastOverTime); - assert_eq!(plan.time_range_ms, (NOW - 10_000, NOW)); - } -} diff --git a/asap-query-engine/src/engines/logical/plan_builder.rs b/asap-query-engine/src/engines/logical/plan_builder.rs index 813f02415..925a8a3db 100644 --- a/asap-query-engine/src/engines/logical/plan_builder.rs +++ b/asap-query-engine/src/engines/logical/plan_builder.rs @@ -18,7 +18,7 @@ use promql_parser::parser::token::{self, T_ADD, T_DIV, T_MOD, T_MUL, T_POW, T_SU use promql_utilities::query_logics::enums::{AggregationType, Statistic}; use std::sync::Arc; -use crate::engines::simple_engine::{QueryExecutionContext, StoreQueryParams}; +use crate::engines::simple::engine::{QueryExecutionContext, StoreQueryParams}; /// Extension trait for building DataFusion logical plans from QueryExecutionContext impl QueryExecutionContext { @@ -355,7 +355,7 @@ pub fn build_scalar_plan( mod tests { use super::*; use crate::data_model::AggregationIdInfo; - use crate::engines::simple_engine::{QueryMetadata, StoreQueryParams, StoreQueryPlan}; + use crate::engines::simple::engine::{QueryMetadata, StoreQueryParams, StoreQueryPlan}; use promql_utilities::data_model::KeyByLabelNames; use std::collections::HashMap; diff --git a/asap-query-engine/src/engines/mod.rs b/asap-query-engine/src/engines/mod.rs index aad49a0ff..f0604adcb 100644 --- a/asap-query-engine/src/engines/mod.rs +++ b/asap-query-engine/src/engines/mod.rs @@ -1,37 +1,54 @@ -pub mod gorilla_engine; +//! Tier-co-located query engines. +//! +//! Step-1 of the JSONL deprecation refactor split this module +//! into two tier sub-directories ([`simple`] for the warm sketch +//! tier, [`gorilla`] for the archive tier) plus the shared +//! infrastructure (`logical`/`physical` plan helpers, +//! `query_result`, `timeline_dispatch`, `window_merger`) used by +//! both. The capability `EngineRouter` and per-metric +//! `BackendStorageRouting` config loader moved out into +//! [`crate::routing`]. +//! +//! ## Public surface +//! +//! Two engines + the shared error envelope: +//! +//! * [`simple::SimpleEngine`] — warm-tier sketch query engine. +//! * [`gorilla::GorillaQueryEngine`] — archive-tier exact query +//! engine over the [`gorilla::store::GorillaS3Store`]. +//! * [`EngineError`] — the trait-level error envelope every +//! `crate::routing::QueryEngine` impl returns. + +pub mod gorilla; pub mod logical; pub mod physical; pub mod query_result; -pub mod router; -pub mod simple_engine; +pub mod simple; pub mod timeline_dispatch; pub mod window_merger; -pub use gorilla_engine::{ +pub use gorilla::{ EngineError as GorillaEngineError, GorillaEngineConfig, GorillaQueryEngine, }; pub use query_result::{InstantVector, QueryResult, RangeVector, RangeVectorElement, Sample}; -pub use router::{EngineCapabilities, EngineRouter, EngineRouterError, QueryEngine}; -pub use simple_engine::SimpleEngine; +pub use simple::SimpleEngine; pub use timeline_dispatch::{combine_statistic, CombinedResult}; pub use window_merger::{create_window_merger, NaiveMerger, WindowMerger}; // --------------------------------------------------------------------------- -// Phase-5: shared `EngineError` surface returned by the `QueryEngine` trait. +// Shared `EngineError` surface returned by the `QueryEngine` trait. // // The trait is engine-agnostic, so its `execute` must return an error type // that can wrap *any* concrete engine's failure mode. Today's two engines // — `SimpleEngine` (capability-miss → `None`) and `GorillaQueryEngine` -// (rich `EngineError`) — fold into this common envelope. New engines -// can plug in by adding a `Backend(String)` arm or a typed conversion. +// (rich `EngineError`) — fold into this common envelope. // --------------------------------------------------------------------------- use thiserror::Error; -/// Top-level error returned by any [`QueryEngine`] impl. +/// Top-level error returned by any [`crate::routing::QueryEngine`] impl. /// -/// Concrete engines convert their internal error types into this envelope -/// via the conversions in this file (or via `?` for `GorillaEngineError`). +/// Concrete engines convert their internal error types into this envelope. /// The router uses the variant to decide whether a failover is sensible /// (e.g. `Backend(_)` falls through to the next compatible backend; /// `CapabilityMiss` does not — the caller should escalate). @@ -39,17 +56,21 @@ use thiserror::Error; pub enum EngineError { /// The engine has no aggregation that can answer this query. Mirrors /// `SimpleEngine::handle_query` returning `None`. The router treats - /// this as a "hard miss" and falls through to the next backend in the - /// `compatible_storage_backends` list (typically `ColdJsonlFallback`). + /// this as a "hard miss" and falls through to the next backend in + /// the `compatible_storage_backends` list. After Step-1 of the + /// JSONL deprecation, the surviving failovers are warm-tier + /// sketch ↔ Gorilla-S3 archive only (the cold JSONL leg was + /// deleted). #[error("no compatible aggregation in {engine_id}: {detail}")] CapabilityMiss { engine_id: &'static str, detail: String, }, - /// The engine's backend (cold-store, S3, planner, …) failed during - /// execution. Wraps the underlying engine's error as a string so the - /// router doesn't take a hard dep on every engine's error type. + /// The engine's backend (archive store, S3, planner, …) failed + /// during execution. Wraps the underlying engine's error as a + /// string so the router doesn't take a hard dep on every engine's + /// error type. #[error("backend failure in {engine_id}: {message}")] Backend { engine_id: &'static str, diff --git a/asap-query-engine/src/engines/simple_engine.rs b/asap-query-engine/src/engines/simple/engine.rs similarity index 99% rename from asap-query-engine/src/engines/simple_engine.rs rename to asap-query-engine/src/engines/simple/engine.rs index 8dbbdb069..58393959e 100644 --- a/asap-query-engine/src/engines/simple_engine.rs +++ b/asap-query-engine/src/engines/simple/engine.rs @@ -1110,7 +1110,7 @@ impl SimpleEngine { use datafusion::execution::context::SessionContext; use datafusion::physical_plan::collect; - use super::physical::conversion::record_batch_to_result_map; + use crate::engines::physical::conversion::record_batch_to_result_map; let total_start = Instant::now(); @@ -1133,7 +1133,7 @@ impl SimpleEngine { let session_ctx = SessionContext::new(); #[allow(deprecated)] let state = session_ctx.state().with_query_planner(std::sync::Arc::new( - super::physical::CustomQueryPlanner::new(self.store.clone()), + crate::engines::physical::CustomQueryPlanner::new(self.store.clone()), )); // 3. Create physical plan @@ -1218,13 +1218,13 @@ impl SimpleEngine { use datafusion::execution::context::SessionContext; use datafusion::physical_plan::collect; - use super::physical::conversion::record_batch_to_result_map; + use crate::engines::physical::conversion::record_batch_to_result_map; // Create session context with our custom extension planner let session_ctx = SessionContext::new(); #[allow(deprecated)] let state = session_ctx.state().with_query_planner(std::sync::Arc::new( - super::physical::CustomQueryPlanner::new(self.store.clone()), + crate::engines::physical::CustomQueryPlanner::new(self.store.clone()), )); let physical_plan = state @@ -4099,7 +4099,7 @@ impl SimpleEngine { // --------------------------------------------------------------------------- #[async_trait::async_trait] -impl crate::engines::router::QueryEngine for SimpleEngine { +impl crate::routing::engine_router::QueryEngine for SimpleEngine { async fn execute( &self, query: &str, @@ -4120,8 +4120,8 @@ impl crate::engines::router::QueryEngine for SimpleEngine { } } - fn capabilities(&self) -> crate::engines::router::EngineCapabilities { - crate::engines::router::EngineCapabilities { + fn capabilities(&self) -> crate::routing::engine_router::EngineCapabilities { + crate::routing::engine_router::EngineCapabilities { data_source_id: asap_types::StorageBackend::SketchWarmTier.data_source_id(), storage_backend: asap_types::StorageBackend::SketchWarmTier, // Warm-tier sketches are O(sketch-size); call it 16 MiB ceiling @@ -4909,7 +4909,7 @@ mod range_query_tests { #[cfg(test)] mod sketch_query_tests { // use crate::data_model::{CleanupPolicy, InferenceConfig, QueryLanguage, StreamingConfig}; - // use crate::engines::simple_engine::SimpleEngine; + // use crate::engines::simple::engine::SimpleEngine; // use crate::stores::promsketch_store::PromSketchStore; // use crate::stores::{Store, TimestampedBucketsMap}; // use std::collections::HashMap; diff --git a/asap-query-engine/src/engines/simple/mod.rs b/asap-query-engine/src/engines/simple/mod.rs new file mode 100644 index 000000000..c8bffef14 --- /dev/null +++ b/asap-query-engine/src/engines/simple/mod.rs @@ -0,0 +1,29 @@ +//! Warm-tier sketch query engine. +//! +//! `SimpleEngine` is the long-standing PromQL/SQL/Elasticsearch-DSL +//! query path that answers from the in-memory sketch DB +//! ([`crate::stores::sketch_db::SimpleMapStore`]) and its +//! per-`agg_id` precomputed accumulators. It returns ε/δ-bounded +//! approximate answers for sketch-resident queries and `None` on +//! a capability miss (router falls through, which after Step-1 of +//! the JSONL deprecation means the archive tier or a hard 404 — +//! the JSONL leg has been deleted). +//! +//! Step-1 of the JSONL deprecation refactor moved this module +//! from `engines/simple_engine.rs` (single file) into +//! `engines/simple/{mod.rs, engine.rs, tests.rs}` so the +//! warm-tier query engine sits under its own tier-co-located +//! directory, mirroring [`crate::engines::gorilla`] for the +//! archive tier. The engine's data model + execution code is +//! kept verbatim in [`engine`]; this `mod.rs` is the public +//! surface re-exporting the long-standing types. + +pub mod engine; + +#[cfg(test)] +pub mod tests; + +pub use engine::{ + QueryExecutionContext, QueryMetadata, QueryTimestamps, SimpleEngine, StoreQueryParams, + StoreQueryPlan, +}; diff --git a/asap-query-engine/src/engines/simple/tests.rs b/asap-query-engine/src/engines/simple/tests.rs new file mode 100644 index 000000000..e3765b5c4 --- /dev/null +++ b/asap-query-engine/src/engines/simple/tests.rs @@ -0,0 +1,13 @@ +//! Placeholder for simple-engine tests. +//! +//! Step-1 of the JSONL deprecation refactor moved +//! `engines/simple_engine.rs` to `engines/simple/engine.rs`. The +//! engine's tests live inline in [`super::engine`] (~6 distinct +//! `#[cfg(test)] mod tests { ... }` blocks, each pinning a +//! specific dispatch axis). They are exercised under +//! `crate::engines::simple::engine::tests` rather than this file +//! to preserve `git blame` continuity across the move. +//! +//! Step-2 (Prometheus-block format + Thanos store-gateway) can +//! pull the inline test blocks out into this file once the +//! engine's data model stabilises. diff --git a/asap-query-engine/src/lib.rs b/asap-query-engine/src/lib.rs index 659f99e55..a353cbf19 100644 --- a/asap-query-engine/src/lib.rs +++ b/asap-query-engine/src/lib.rs @@ -5,6 +5,7 @@ pub mod planner_client; pub mod precompute_engine; pub mod precompute_operators; pub mod query_tracker; +pub mod routing; pub mod stores; #[cfg(test)] diff --git a/asap-query-engine/src/main.rs b/asap-query-engine/src/main.rs index e7b0204b4..99873d3ab 100644 --- a/asap-query-engine/src/main.rs +++ b/asap-query-engine/src/main.rs @@ -81,21 +81,6 @@ struct Args { #[arg(long)] forward_unsupported_queries: bool, - /// Root directory of the §5.2 cold-tier raw-sample store. - /// When set, capability-miss queries first try the - /// hour-bucketed JSONL layout under this root - /// (`raw//YYYY/MM/DD/HH/part-NNNNNN.jsonl` — - /// byte-identical to the S3 key layout, see - /// `drivers::query::fallback::cold_store::format`) and only - /// fall through to Prometheus when the cold adapter can't - /// answer the query shape. Combine with - /// `--forward-unsupported-queries` to keep Prom as the tail - /// of the chain; without it, unsupported shapes return empty - /// instead of forwarding. Also reads from `ASAP_COLD_STORE_ROOT` - /// so containerised deploys can wire it via env. - #[arg(long, env = "ASAP_COLD_STORE_ROOT")] - cold_store_root: Option, - /// Kafka broker address #[arg(long, default_value = "localhost:9092")] kafka_broker: String, @@ -624,17 +609,13 @@ async fn main() -> Result<()> { //); // Original Prometheus config (commented out temporarily): - if let Some(root) = args.cold_store_root.as_deref() { - info!( - cold_store_root = %root.display(), - prom_tail = args.forward_unsupported_queries, - "Cold-tier fallback enabled (§5.2 cold store)", - ); - } - let adapter_config = AdapterConfig::from_prom_with_optional_cold( + // Step-1 of the JSONL deprecation deleted the local-FS cold + // store + the §5.2 `ColdFallback` adapter; the surviving + // fallback chain is just Prometheus (when + // `--forward-unsupported-queries` is set). + let adapter_config = AdapterConfig::prometheus_promql( args.prometheus_server.clone(), args.forward_unsupported_queries, - args.cold_store_root.as_deref(), ); let http_config = HttpServerConfig { @@ -727,32 +708,31 @@ async fn main() -> Result<()> { // handler, which is the correct fail-loud behaviour: a deploy // that pins `GorillaS3Archive` without provisioning the cold // store is a configuration bug. - match query_engine_rust::drivers::query::fallback::cold_store::GorillaS3Config::from_env() { + match query_engine_rust::engines::gorilla::GorillaS3Config::from_env() { Ok(s3_cfg) => { - match query_engine_rust::drivers::query::fallback::cold_store::GorillaS3ColdStore::with_default_backend(s3_cfg) { - Ok(cold_store) => { - use query_engine_rust::engines::{ - GorillaEngineConfig, GorillaQueryEngine, QueryEngine, - }; + match query_engine_rust::engines::gorilla::GorillaS3Store::with_default_backend(s3_cfg) { + Ok(store) => { + use query_engine_rust::engines::{GorillaEngineConfig, GorillaQueryEngine}; + use query_engine_rust::routing::QueryEngine; let gorilla = Arc::new(GorillaQueryEngine::with_gorilla_s3( - Arc::new(cold_store), + Arc::new(store), GorillaEngineConfig::default(), )); info!( - "Phase-6: registering GorillaQueryEngine on the capability router (data_source_id=gorilla_archive)", + "Registering GorillaQueryEngine on the capability router (data_source_id=gorilla_archive)", ); server = server.with_query_engine(gorilla as Arc); } Err(e) => { warn!( - "ASAP_GORILLA_S3_* env vars present but GorillaS3ColdStore failed to build ({e}); router will not have a cold-archive engine", + "ASAP_GORILLA_S3_* env vars present but GorillaS3Store failed to build ({e}); router will not have an archive engine", ); } } } Err(_) => { info!( - "ASAP_GORILLA_S3_* env vars not configured — router serves warm-tier metrics only (set ASAP_GORILLA_S3_BUCKET + ASAP_GORILLA_S3_REGION to enable cold-archive routing)", + "ASAP_GORILLA_S3_* env vars not configured — router serves warm-tier metrics only (set ASAP_GORILLA_S3_BUCKET + ASAP_GORILLA_S3_REGION to enable archive routing)", ); } } @@ -1005,52 +985,27 @@ fn setup_logging( mod tests { use query_engine_rust::drivers::AdapterConfig; - #[test] - fn no_cold_no_forward_yields_no_fallback() { - let cfg = - AdapterConfig::from_prom_with_optional_cold("http://prom:9090".into(), false, None); - assert!( - cfg.fallback.is_none(), - "without cold-store and without forward, no fallback should be installed", - ); - } + // Step-1 of the JSONL deprecation refactor deleted the + // §5.2 `ColdFallback` adapter and the + // `from_prom_with_optional_cold` constructor. The surviving + // fallback chain is just Prometheus (gated by + // `forward_unsupported_queries`). #[test] - fn no_cold_with_forward_yields_prom_fallback() { - let cfg = - AdapterConfig::from_prom_with_optional_cold("http://prom:9090".into(), true, None); + fn no_forward_yields_no_fallback() { + let cfg = AdapterConfig::prometheus_promql("http://prom:9090".into(), false); assert!( - cfg.fallback.is_some(), - "forward_unsupported=true must install Prom fallback", + cfg.fallback.is_none(), + "forward_unsupported=false must leave the fallback slot empty", ); } #[test] - fn cold_store_set_installs_fallback_even_without_forward() { - // Key wiring claim: setting --cold-store-root alone is - // sufficient to engage the §5.2 cold path. The only thing - // forward_unsupported_queries adds in that case is the Prom - // tail of the chain. - let tmp = tempfile::TempDir::new().unwrap(); - let cfg = AdapterConfig::from_prom_with_optional_cold( - "http://prom:9090".into(), - false, - Some(tmp.path()), - ); + fn forward_yields_prom_fallback() { + let cfg = AdapterConfig::prometheus_promql("http://prom:9090".into(), true); assert!( cfg.fallback.is_some(), - "cold-store-root must install ColdFallback regardless of forward_unsupported", - ); - } - - #[test] - fn cold_store_with_forward_installs_full_chain() { - let tmp = tempfile::TempDir::new().unwrap(); - let cfg = AdapterConfig::from_prom_with_optional_cold( - "http://prom:9090".into(), - true, - Some(tmp.path()), + "forward_unsupported=true must install the Prom fallback", ); - assert!(cfg.fallback.is_some()); } } diff --git a/asap-query-engine/src/data_model/backend_storage_routing.rs b/asap-query-engine/src/routing/backend_storage_routing.rs similarity index 98% rename from asap-query-engine/src/data_model/backend_storage_routing.rs rename to asap-query-engine/src/routing/backend_storage_routing.rs index 7b60a1c64..ee3557635 100644 --- a/asap-query-engine/src/data_model/backend_storage_routing.rs +++ b/asap-query-engine/src/routing/backend_storage_routing.rs @@ -76,7 +76,8 @@ //! //! Valid `StorageBackend` values mirror the snake-cased serde tags on //! `asap_types::StorageBackend`: `sketch_warm_tier`, -//! `gorilla_s3_archive`, `cold_jsonl_fallback`, `double_write`. +//! `gorilla_s3_archive`, `double_write`. (Step-1 of the JSONL +//! deprecation refactor removed the `cold_jsonl_fallback` tag.) //! //! Loaded once at backend startup (CLI flag `--backend-storage-routing` //! on `precompute_engine`) and stored in `AppState`. Lookup is @@ -592,7 +593,7 @@ mod tests { default: sketch_warm_tier metrics: http_requests_total: gorilla_s3_archive - audit_events: cold_jsonl_fallback + audit_events: gorilla_s3_archive "#; let r = BackendStorageRouting::from_yaml_str(yaml).expect("parse"); assert_eq!( @@ -601,7 +602,7 @@ metrics: ); assert_eq!( r.lookup("audit_events"), - StorageBackend::ColdJsonlFallback + StorageBackend::GorillaS3Archive ); assert_eq!(r.lookup("unlisted"), StorageBackend::SketchWarmTier); assert_eq!(r.len(), 2); @@ -731,7 +732,7 @@ metrics: let yaml = r#" default: sketch_warm_tier metrics: - http_requests_total: cold_jsonl_fallback + http_requests_total: gorilla_s3_archive routes: - metric: http_requests_total targets: @@ -740,7 +741,7 @@ routes: applies_to_query_shape: [count] "#; let r = BackendStorageRouting::from_yaml_str(yaml).expect("parse"); - // Default slot wins for non-count shapes. + // Default slot (warm) wins for non-count shapes. assert_eq!( r.lookup_with_shape("http_requests_total", QueryShape::Quantile), StorageBackend::SketchWarmTier, @@ -750,8 +751,8 @@ routes: r.lookup_with_shape("http_requests_total", QueryShape::Count), StorageBackend::GorillaS3Archive, ); - // The `metrics:` entry was overridden — no trace of - // ColdJsonlFallback. + // The `metrics:` entry was overridden by the multi-target + // `routes:` entry (the single-target archive vanished). assert_eq!(r.target_count("http_requests_total"), 2); } @@ -777,7 +778,7 @@ routes: vec![ RoutingTarget::for_shapes(StorageBackend::GorillaS3Archive, vec![QueryShape::Count]), RoutingTarget::for_shapes( - StorageBackend::ColdJsonlFallback, + StorageBackend::SketchWarmTier, vec![QueryShape::Topk], ), ], diff --git a/asap-query-engine/src/engines/router.rs b/asap-query-engine/src/routing/engine_router.rs similarity index 93% rename from asap-query-engine/src/engines/router.rs rename to asap-query-engine/src/routing/engine_router.rs index c29319f12..e915374b3 100644 --- a/asap-query-engine/src/engines/router.rs +++ b/asap-query-engine/src/routing/engine_router.rs @@ -23,7 +23,7 @@ use tracing::{debug, warn}; use asap_types::{compatible_storage_backends, AccuracyTarget, StorageBackend}; use promql_utilities::query_logics::enums::Statistic; -use super::{EngineError, QueryResult}; +use crate::engines::{EngineError, QueryResult}; // --------------------------------------------------------------------------- // `QueryEngine` trait — the abstraction the router holds. @@ -304,10 +304,10 @@ mod tests { async fn router_dispatches_to_warm_tier_for_sketch_metrics() { let mut router = EngineRouter::new(); let (warm, warm_calls) = StubEngine::new(StorageBackend::SketchWarmTier, Outcome::Ok); - let (jsonl, jsonl_calls) = - StubEngine::new(StorageBackend::ColdJsonlFallback, Outcome::Ok); + let (archive, archive_calls) = + StubEngine::new(StorageBackend::GorillaS3Archive, Outcome::Ok); router.register(warm); - router.register(jsonl); + router.register(archive); let result = router .execute( @@ -319,7 +319,11 @@ mod tests { .await; assert!(result.is_ok()); assert_eq!(warm_calls.load(Ordering::SeqCst), 1); - assert_eq!(jsonl_calls.load(Ordering::SeqCst), 0, "JSONL must not run when warm-tier succeeds"); + assert_eq!( + archive_calls.load(Ordering::SeqCst), + 0, + "archive must not run for a warm-tier-only metric (Step-1 deleted the JSONL fallback slot)", + ); } #[tokio::test] @@ -349,18 +353,17 @@ mod tests { } #[tokio::test] - async fn router_falls_back_to_jsonl_when_archive_fails() { - // Double-write deploy: archive fails, warm-tier fails too, JSONL answers. + async fn router_falls_back_to_warm_when_archive_fails_on_double_write() { + // Double-write deploy with `Exact` head: archive head fails, + // router falls through to the warm-tier sketch (the only + // remaining failover after Step-1 deleted JSONL). let mut router = EngineRouter::new(); let (gorilla, gorilla_calls) = StubEngine::new(StorageBackend::GorillaS3Archive, Outcome::Backend); let (warm, warm_calls) = - StubEngine::new(StorageBackend::SketchWarmTier, Outcome::CapabilityMiss); - let (jsonl, jsonl_calls) = - StubEngine::new(StorageBackend::ColdJsonlFallback, Outcome::Ok); + StubEngine::new(StorageBackend::SketchWarmTier, Outcome::Ok); router.register(gorilla); router.register(warm); - router.register(jsonl); let result = router .execute( @@ -370,10 +373,12 @@ mod tests { StorageBackend::DoubleWrite, ) .await; - assert!(result.is_ok(), "router must reach JSONL on archive+warm failure"); + assert!( + result.is_ok(), + "router must reach warm-tier when the archive head fails", + ); assert_eq!(gorilla_calls.load(Ordering::SeqCst), 1); assert_eq!(warm_calls.load(Ordering::SeqCst), 1); - assert_eq!(jsonl_calls.load(Ordering::SeqCst), 1); } #[tokio::test] @@ -389,13 +394,9 @@ mod tests { .await; match result { Err(EngineRouterError::NoEngineRegistered { tried, registered }) => { - assert_eq!( - tried, - vec![ - StorageBackend::SketchWarmTier, - StorageBackend::ColdJsonlFallback, - ] - ); + // Step-1 deleted the JSONL failover slot, so the + // SketchWarmTier failover sequence is just itself. + assert_eq!(tried, vec![StorageBackend::SketchWarmTier]); assert!(registered.is_empty()); } other => panic!("expected NoEngineRegistered, got {other:?}"), @@ -404,11 +405,12 @@ mod tests { #[tokio::test] async fn router_returns_all_failed_when_every_engine_errors() { + // After Step-1 deleted JSONL, the SketchWarmTier failover + // sequence is just `[SketchWarmTier]`. A failing warm-tier + // engine is the only error path on this metric. let mut router = EngineRouter::new(); let (warm, _) = StubEngine::new(StorageBackend::SketchWarmTier, Outcome::Backend); - let (jsonl, _) = StubEngine::new(StorageBackend::ColdJsonlFallback, Outcome::Backend); router.register(warm); - router.register(jsonl); let result = router .execute( @@ -420,7 +422,6 @@ mod tests { .await; match result { Err(EngineRouterError::AllFailed { last }) => { - // The deepest failure (JSONL) is what the caller sees. assert!(matches!(last, EngineError::Backend { .. })); } other => panic!("expected AllFailed, got {other:?}"), diff --git a/asap-query-engine/src/routing/mod.rs b/asap-query-engine/src/routing/mod.rs new file mode 100644 index 000000000..9a099367f --- /dev/null +++ b/asap-query-engine/src/routing/mod.rs @@ -0,0 +1,33 @@ +//! Per-metric storage-backend routing. +//! +//! This module is the dispatch boundary between the HTTP query +//! handler and the tier-co-located engines (warm sketch tier in +//! [`crate::engines::simple`], archive tier in +//! [`crate::engines::gorilla`]). Two cooperating pieces: +//! +//! * [`backend_storage_routing`] — config loader + multi-target +//! per-metric lookup (`metric → [(backend, query-shape filter), ...]`). +//! Loaded once at backend startup from +//! `deploy/configs/backend-storage-routing.yaml`; queried on +//! every HTTP request. +//! * [`engine_router`] — the engine dispatcher. Holds a small map +//! of `data_source_id → Arc` and walks the +//! compatibility list returned by +//! [`asap_types::compatible_storage_backends`] to pick which +//! engine answers a given `(query, metric_storage)` pair. +//! +//! Step-1 of the JSONL deprecation refactor lifted these out of +//! `data_model/backend_storage_routing.rs` and `engines/router.rs` +//! into this dedicated `routing/` directory so the HTTP handler's +//! dispatch surface is a single import (`use crate::routing::*`) +//! instead of straddling two unrelated module trees. + +pub mod backend_storage_routing; +pub mod engine_router; + +pub use backend_storage_routing::{ + classify_query_shape, BackendStorageRouting, QueryShape, RoutingTarget, +}; +pub use engine_router::{ + EngineCapabilities, EngineRouter, EngineRouterError, QueryEngine, +}; diff --git a/asap-query-engine/src/tests/capability_matching_tests.rs b/asap-query-engine/src/tests/capability_matching_tests.rs index c1f20c517..525328469 100644 --- a/asap-query-engine/src/tests/capability_matching_tests.rs +++ b/asap-query-engine/src/tests/capability_matching_tests.rs @@ -9,7 +9,7 @@ use crate::data_model::{ PrecomputedOutput, PromQLSchema, QueryConfig, QueryLanguage, SchemaConfig, StreamingConfig, WindowType, }; -use crate::engines::simple_engine::SimpleEngine; +use crate::engines::simple::engine::SimpleEngine; use crate::precompute_operators::count_min_sketch_accumulator::CountMinSketchAccumulator; use crate::precompute_operators::datasketches_kll_accumulator::DatasketchesKLLAccumulator; use crate::precompute_operators::delta_set_aggregator_accumulator::DeltaSetAggregatorAccumulator; diff --git a/asap-query-engine/src/tests/cold_fallback_tests.rs b/asap-query-engine/src/tests/cold_fallback_tests.rs deleted file mode 100644 index 2c23f10ae..000000000 --- a/asap-query-engine/src/tests/cold_fallback_tests.rs +++ /dev/null @@ -1,402 +0,0 @@ -//! End-to-end tests for the §5.2 cold-query fallback. -//! -//! Exercises the full HTTP → engine-miss → [`ColdFallback`] path -//! with raw sample fixtures on a local-FS [`ColdStore`]. The -//! format used here (hour-bucketed JSONL) is byte-identical to -//! what a future S3 cold adapter will read, so these tests -//! double as format-lock tests for the on-disk / on-object layout. -//! -//! Coverage: -//! * bare instant vector → cold path, correct per-series latest -//! * `sum(...)` → cold path, correct scalar -//! * unsupported query shape (e.g. `rate(...)`) falls through to -//! the inner Prometheus chain -//! * telemetry counters increment on cold hits -//! * purged-time-range semantics: sketch-absent data served from cold - -#[cfg(test)] -use crate::data_model::{CleanupPolicy, InferenceConfig, QueryLanguage, StreamingConfig}; -use crate::drivers::query::adapters::AdapterConfig; -use crate::drivers::query::fallback::cold_store::format::RawSample; -use crate::drivers::query::fallback::metrics::{BYTES_SERVED_COLD_TOTAL, QUERIES_COLD_TOTAL}; -use crate::drivers::query::fallback::{ - ColdFallback, FallbackClient, LocalFsColdStore, PrometheusHttpFallback, -}; -use crate::drivers::query::servers::http::{HttpServer, HttpServerConfig}; -use crate::engines::SimpleEngine; -use crate::stores::sketch_db::simple_map_store::SimpleMapStore; -use chrono::{TimeZone, Utc}; -use reqwest::Client; -use serde_json::Value; -use std::collections::BTreeMap; -use std::path::Path; -use std::sync::Arc; -use tempfile::TempDir; -use tokio::net::TcpListener; -use tokio::time::{sleep, Duration}; - -/// Build a [`RawSample`] with ergonomic literal labels. -fn sample(ts_ms: i64, labels: &[(&str, &str)], value: f64) -> RawSample { - RawSample { - ts_ms, - labels: labels - .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) - .collect::>(), - value, - } -} - -/// Write a JSONL part under the hour-bucket prefix (same key -/// layout as S3). -async fn write_part(root: &Path, rel: &str, lines: &[RawSample]) { - let dir = root.join(rel); - tokio::fs::create_dir_all(&dir).await.unwrap(); - let mut buf = String::new(); - for s in lines { - buf.push_str(&serde_json::to_string(s).unwrap()); - buf.push('\n'); - } - tokio::fs::write(dir.join("part-000001.jsonl"), buf) - .await - .unwrap(); -} - -fn make_engine_and_store() -> (Arc, Arc) { - let inference_config = InferenceConfig::new(QueryLanguage::promql, CleanupPolicy::NoCleanup); - let streaming_config = Arc::new(StreamingConfig::default()); - let store = Arc::new(SimpleMapStore::new( - streaming_config.clone(), - CleanupPolicy::NoCleanup, - )); - let engine = Arc::new(SimpleEngine::new( - store.clone(), - inference_config, - streaming_config.clone(), - 15000, - QueryLanguage::promql, - )); - (engine, store) -} - -/// Start an HTTP server whose fallback chain is -/// `ColdFallback(LocalFsColdStore) → None`. Returns the server -/// port. No sketch data is ingested, so every query -/// capability-misses and is served from cold. -async fn start_cold_only_server(cold_root: &Path) -> u16 { - let cold_store = Arc::new(LocalFsColdStore::new(cold_root)); - let cold = Arc::new(ColdFallback::new(cold_store)) as Arc; - - let adapter_config = AdapterConfig::new( - crate::data_model::enums::QueryProtocol::PrometheusHttp, - QueryLanguage::promql, - Some(cold), - ); - let config = HttpServerConfig { - port: 0, - handle_http_requests: true, - adapter_config, - }; - let (engine, store) = make_engine_and_store(); - let server = HttpServer::new(config, engine, store, None); - server - .start_test_server() - .await - .expect("Failed to start test server") -} - -/// Mock upstream Prometheus that returns a fixed marker body, used -/// to check that unsupported shapes fall through the cold adapter -/// to the inner chain. -async fn start_mock_prometheus(port: u16, marker: &'static str) { - use axum::{routing::get, Json, Router}; - use serde_json::json; - async fn h(marker: &'static str) -> Json { - Json(json!({ - "status": "success", - "data": {"resultType":"scalar", "result":[0, marker]} - })) - } - let app = Router::new().route("/api/v1/query", get(move || h(marker))); - let listener = TcpListener::bind(format!("127.0.0.1:{port}")) - .await - .unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - sleep(Duration::from_millis(50)).await; -} - -async fn start_chained_server(cold_root: &Path, prom_url: String) -> u16 { - let cold_store = Arc::new(LocalFsColdStore::new(cold_root)); - let prom: Arc = Arc::new(PrometheusHttpFallback::new(prom_url)); - let cold = Arc::new(ColdFallback::new(cold_store).with_inner(prom)) as Arc; - let adapter_config = AdapterConfig::new( - crate::data_model::enums::QueryProtocol::PrometheusHttp, - QueryLanguage::promql, - Some(cold), - ); - let config = HttpServerConfig { - port: 0, - handle_http_requests: true, - adapter_config, - }; - let (engine, store) = make_engine_and_store(); - let server = HttpServer::new(config, engine, store, None); - server - .start_test_server() - .await - .expect("Failed to start test server") -} - -#[tokio::test] -async fn cold_fallback_serves_bare_selector_from_raw_samples() { - let tmp = TempDir::new().unwrap(); - // 2026-04-21 08:00:00 UTC - let base = Utc - .with_ymd_and_hms(2026, 4, 21, 8, 0, 0) - .unwrap() - .timestamp_millis(); - write_part( - tmp.path(), - "raw/http_requests_total/2026/04/21/08/", - &[ - sample(base + 10_000, &[("zone", "a")], 1.0), - sample(base + 20_000, &[("zone", "a")], 2.0), // latest for zone=a - sample(base + 15_000, &[("zone", "b")], 9.0), - ], - ) - .await; - - let server_port = start_cold_only_server(tmp.path()).await; - let client = Client::new(); - - // Query time = base + 30s. Lookback window covers all three samples. - let query_time = (base + 30_000) as f64 / 1_000.0; - let resp = client - .get(format!("http://127.0.0.1:{server_port}/api/v1/query")) - .query(&[ - ("query", "http_requests_total".to_string()), - ("time", query_time.to_string()), - ]) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::OK); - let body: Value = resp.json().await.unwrap(); - - assert_eq!(body["status"], "success"); - assert_eq!(body["data"]["resultType"], "vector"); - let items = body["data"]["result"].as_array().unwrap(); - assert_eq!(items.len(), 2); - // Find zone=a entry and verify it got the latest value. - let za = items - .iter() - .find(|v| v["metric"]["zone"] == "a") - .expect("zone=a series"); - assert_eq!(za["metric"]["__name__"], "http_requests_total"); - assert_eq!(za["value"][1], "2"); -} - -#[tokio::test] -async fn cold_fallback_serves_sum_aggregation() { - let tmp = TempDir::new().unwrap(); - let base = Utc - .with_ymd_and_hms(2026, 4, 21, 8, 0, 0) - .unwrap() - .timestamp_millis(); - write_part( - tmp.path(), - "raw/cpu_seconds_total/2026/04/21/08/", - &[ - sample(base + 1_000, &[("pod", "a")], 10.0), - sample(base + 2_000, &[("pod", "b")], 20.0), - sample(base + 3_000, &[("pod", "c")], 30.0), - ], - ) - .await; - - let server_port = start_cold_only_server(tmp.path()).await; - let client = Client::new(); - - let query_time = (base + 10_000) as f64 / 1_000.0; - let resp = client - .get(format!("http://127.0.0.1:{server_port}/api/v1/query")) - .query(&[ - ("query", "sum(cpu_seconds_total)".to_string()), - ("time", query_time.to_string()), - ]) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::OK); - let body: Value = resp.json().await.unwrap(); - - assert_eq!(body["status"], "success"); - assert_eq!(body["data"]["resultType"], "vector"); - let items = body["data"]["result"].as_array().unwrap(); - assert_eq!(items.len(), 1); - assert_eq!(items[0]["value"][1], "60"); - // Aggregation without grouping → empty metric labels. - let metric = items[0]["metric"].as_object().unwrap(); - assert!(metric.is_empty()); -} - -#[tokio::test] -async fn cold_fallback_label_matcher_filters() { - let tmp = TempDir::new().unwrap(); - let base = Utc - .with_ymd_and_hms(2026, 4, 21, 8, 0, 0) - .unwrap() - .timestamp_millis(); - write_part( - tmp.path(), - "raw/requests_total/2026/04/21/08/", - &[ - sample(base + 1_000, &[("zone", "a")], 1.0), - sample(base + 2_000, &[("zone", "b")], 2.0), - sample(base + 3_000, &[("zone", "c")], 3.0), - ], - ) - .await; - - let server_port = start_cold_only_server(tmp.path()).await; - let client = Client::new(); - - let query_time = (base + 10_000) as f64 / 1_000.0; - let resp = client - .get(format!("http://127.0.0.1:{server_port}/api/v1/query")) - .query(&[ - ("query", "sum(requests_total{zone=\"b\"})".to_string()), - ("time", query_time.to_string()), - ]) - .send() - .await - .unwrap(); - let body: Value = resp.json().await.unwrap(); - let items = body["data"]["result"].as_array().unwrap(); - assert_eq!(items.len(), 1); - assert_eq!(items[0]["value"][1], "2"); -} - -#[tokio::test] -async fn cold_fallback_unsupported_query_delegates_to_inner() { - let tmp = TempDir::new().unwrap(); - // Pick a deterministic port for the mock Prom — low risk of - // collision with the rest of the suite since each test picks - // a different one. - let prom_port = 19_201; - start_mock_prometheus(prom_port, "MARKER_INNER").await; - - let server_port = - start_chained_server(tmp.path(), format!("http://127.0.0.1:{prom_port}")).await; - let client = Client::new(); - - // rate(...) is not a shape we handle in cold — expect the - // inner Prometheus mock to answer. - let resp = client - .get(format!("http://127.0.0.1:{server_port}/api/v1/query")) - .query(&[("query", "rate(foo[1m])"), ("time", "1000")]) - .send() - .await - .unwrap(); - let body: Value = resp.json().await.unwrap(); - assert_eq!(body["status"], "success"); - // Mock Prom returns a scalar with "MARKER_INNER" at index [1]. - assert_eq!(body["data"]["result"][1], "MARKER_INNER"); -} - -#[tokio::test] -async fn cold_fallback_increments_telemetry_counters() { - let tmp = TempDir::new().unwrap(); - let base = Utc - .with_ymd_and_hms(2026, 4, 21, 8, 0, 0) - .unwrap() - .timestamp_millis(); - write_part( - tmp.path(), - "raw/telemetry_test_metric/2026/04/21/08/", - &[sample(base + 1_000, &[("zone", "a")], 1.0)], - ) - .await; - - let before_q = QUERIES_COLD_TOTAL - .with_label_values(&["telemetry_test_metric", "sum"]) - .get(); - let before_b = BYTES_SERVED_COLD_TOTAL - .with_label_values(&["telemetry_test_metric", "sum"]) - .get(); - - let server_port = start_cold_only_server(tmp.path()).await; - let client = Client::new(); - let query_time = (base + 10_000) as f64 / 1_000.0; - let _ = client - .get(format!("http://127.0.0.1:{server_port}/api/v1/query")) - .query(&[ - ("query", "sum(telemetry_test_metric)".to_string()), - ("time", query_time.to_string()), - ]) - .send() - .await - .unwrap(); - - let after_q = QUERIES_COLD_TOTAL - .with_label_values(&["telemetry_test_metric", "sum"]) - .get(); - let after_b = BYTES_SERVED_COLD_TOTAL - .with_label_values(&["telemetry_test_metric", "sum"]) - .get(); - assert!( - after_q >= before_q + 1.0, - "expected cold queries counter to advance: before={before_q} after={after_q}" - ); - assert!( - after_b > before_b, - "expected cold bytes-served counter to advance" - ); -} - -#[tokio::test] -async fn cold_fallback_purged_time_range_served_from_raw() { - // Simulates the §5.2 "Purged segment" path: no sketch exists - // for the queried range (nothing ingested into the engine), - // but the raw tier has samples, and the cold adapter recovers - // the exact answer. This is the canonical paper claim. - let tmp = TempDir::new().unwrap(); - let base = Utc - .with_ymd_and_hms(2026, 4, 21, 8, 0, 0) - .unwrap() - .timestamp_millis(); - // Three distinct series so `avg` runs over three latest-per-series - // values — matches Prometheus instant-vector semantics. - write_part( - tmp.path(), - "raw/purged_metric/2026/04/21/08/", - &[ - sample(base + 1_000, &[("pod", "a")], 10.0), - sample(base + 2_000, &[("pod", "b")], 20.0), - sample(base + 3_000, &[("pod", "c")], 30.0), - ], - ) - .await; - - let server_port = start_cold_only_server(tmp.path()).await; - let client = Client::new(); - let query_time = (base + 10_000) as f64 / 1_000.0; - - // avg over the cold raw samples. - let resp = client - .get(format!("http://127.0.0.1:{server_port}/api/v1/query")) - .query(&[ - ("query", "avg(purged_metric)".to_string()), - ("time", query_time.to_string()), - ]) - .send() - .await - .unwrap(); - let body: Value = resp.json().await.unwrap(); - let items = body["data"]["result"].as_array().unwrap(); - assert_eq!(items.len(), 1); - // avg(10, 20, 30) = 20. - assert_eq!(items[0]["value"][1], "20"); -} diff --git a/asap-query-engine/src/tests/datafusion/plan_builder_binary_tests.rs b/asap-query-engine/src/tests/datafusion/plan_builder_binary_tests.rs index c66390698..c4da722bf 100644 --- a/asap-query-engine/src/tests/datafusion/plan_builder_binary_tests.rs +++ b/asap-query-engine/src/tests/datafusion/plan_builder_binary_tests.rs @@ -7,7 +7,7 @@ mod tests { use crate::data_model::AggregationIdInfo; use crate::engines::logical::plan_builder::{build_binary_vector_plan, build_scalar_plan}; - use crate::engines::simple_engine::{ + use crate::engines::simple::engine::{ QueryExecutionContext, QueryMetadata, StoreQueryParams, StoreQueryPlan, }; use datafusion::logical_expr::LogicalPlan; diff --git a/asap-query-engine/src/tests/datafusion/plan_builder_regression_tests.rs b/asap-query-engine/src/tests/datafusion/plan_builder_regression_tests.rs index 8764a806b..20fb465fd 100644 --- a/asap-query-engine/src/tests/datafusion/plan_builder_regression_tests.rs +++ b/asap-query-engine/src/tests/datafusion/plan_builder_regression_tests.rs @@ -6,7 +6,7 @@ #[cfg(test)] mod tests { use crate::data_model::AggregationIdInfo; - use crate::engines::simple_engine::{ + use crate::engines::simple::engine::{ QueryExecutionContext, QueryMetadata, StoreQueryParams, StoreQueryPlan, }; use promql_utilities::data_model::KeyByLabelNames; diff --git a/asap-query-engine/src/tests/datafusion/plan_execution_tests.rs b/asap-query-engine/src/tests/datafusion/plan_execution_tests.rs index 169d4f32d..2edd73cfd 100644 --- a/asap-query-engine/src/tests/datafusion/plan_execution_tests.rs +++ b/asap-query-engine/src/tests/datafusion/plan_execution_tests.rs @@ -6,7 +6,7 @@ //! These tests use an actual store with test data. use crate::data_model::{AggregationType, KeyByLabelValues, Measurement}; -use crate::engines::simple_engine::SimpleEngine; +use crate::engines::simple::engine::SimpleEngine; use crate::precompute_operators::sum_accumulator::SumAccumulator; use std::collections::HashMap; diff --git a/asap-query-engine/src/tests/mod.rs b/asap-query-engine/src/tests/mod.rs index 1cd87235f..2ee2e7e1d 100644 --- a/asap-query-engine/src/tests/mod.rs +++ b/asap-query-engine/src/tests/mod.rs @@ -3,7 +3,6 @@ pub mod accuracy_in_promql_response_tests; pub mod capability_matching_tests; pub mod capability_miss_http_e2e_tests; pub mod clickhouse_forwarding_tests; -pub mod cold_fallback_tests; pub mod datafusion; pub mod elastic_dsl_query_tests; pub mod elastic_forwarding_tests; diff --git a/asap-query-engine/src/tests/query_equivalence_tests.rs b/asap-query-engine/src/tests/query_equivalence_tests.rs index d202787a3..54fabc453 100644 --- a/asap-query-engine/src/tests/query_equivalence_tests.rs +++ b/asap-query-engine/src/tests/query_equivalence_tests.rs @@ -8,7 +8,7 @@ //! queries against a store. use crate::data_model::{QueryLanguage, WindowType}; -use crate::engines::simple_engine::SimpleEngine; +use crate::engines::simple::engine::SimpleEngine; use crate::stores::{Store, TimestampedBucketsMap}; use crate::tests::test_utilities::{assert_execution_context_equivalent, TestConfigBuilder}; use std::collections::HashMap; diff --git a/asap-query-engine/src/tests/sql_pattern_matching_tests.rs b/asap-query-engine/src/tests/sql_pattern_matching_tests.rs index abe70feec..470585fa2 100644 --- a/asap-query-engine/src/tests/sql_pattern_matching_tests.rs +++ b/asap-query-engine/src/tests/sql_pattern_matching_tests.rs @@ -9,7 +9,7 @@ mod tests { AggregationConfig, AggregationReference, AggregationType, CleanupPolicy, InferenceConfig, QueryConfig, QueryLanguage, SchemaConfig, StreamingConfig, WindowType, }; - use crate::engines::simple_engine::SimpleEngine; + use crate::engines::simple::engine::SimpleEngine; use crate::stores::sketch_db::simple_map_store::SimpleMapStore; use promql_utilities::data_model::KeyByLabelNames; use sql_utilities::sqlhelper::{SQLSchema, Table}; diff --git a/asap-query-engine/src/tests/test_utilities/comparison.rs b/asap-query-engine/src/tests/test_utilities/comparison.rs index 954d51076..5382fc6b6 100644 --- a/asap-query-engine/src/tests/test_utilities/comparison.rs +++ b/asap-query-engine/src/tests/test_utilities/comparison.rs @@ -3,7 +3,7 @@ //! Provides assertion helpers for deep equality checking of query execution contexts. use crate::data_model::{AggregationIdInfo, AggregationType}; -use crate::engines::simple_engine::{ +use crate::engines::simple::engine::{ QueryExecutionContext, QueryMetadata, StoreQueryParams, StoreQueryPlan, }; use promql_utilities::data_model::KeyByLabelNames; diff --git a/asap-query-engine/src/tests/test_utilities/engine_factories.rs b/asap-query-engine/src/tests/test_utilities/engine_factories.rs index f971dd873..1b85f6f3e 100644 --- a/asap-query-engine/src/tests/test_utilities/engine_factories.rs +++ b/asap-query-engine/src/tests/test_utilities/engine_factories.rs @@ -11,7 +11,7 @@ use crate::data_model::{ StreamingConfig, WindowType, }; use crate::engines::query_result::InstantVectorElement; -use crate::engines::simple_engine::SimpleEngine; +use crate::engines::simple::engine::SimpleEngine; use crate::stores::sketch_db::simple_map_store::SimpleMapStore; use crate::stores::Store; use crate::AggregateCore;