From ceba1e56d146e5743731a3367b254488f318af06 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 01:23:49 +0000 Subject: [PATCH 1/3] feat: install, execute and recover native candidates over dataset-bound SDS Adopt the Planner #462 physical candidates and typed persistence codecs. Install native TopK, Rate heap and grouped Sum programs, run native maintenance over durable counter SDS, and recover bound outputs by output identity and window range without re-lowering operators. Bind Planner candidate forests to deployment candidates, accept scoped accuracy evidence (quantile domains, TopK separation), check composed query accuracy before admission, and scope SDS definitions and cost manifests to the logical dataset. Candidates are still selected by complete provider quotes; ERP/analytical costing is a separate change. Co-Authored-By: Claude Opus 5.5 --- control_plane/Cargo.toml | 2 +- control_plane/src/main.rs | 74 +- control_plane/src/physical/compiler.rs | 1312 ++++++++++++++--- .../src/physical/compiler/windows.rs | 17 +- .../src/physical/executable_binding.rs | 1 + .../src/physical/maintained_population.rs | 142 +- control_plane/src/physical/plan_dot.rs | 47 + control_plane/src/physical/workload_cost.rs | 355 ++++- control_plane/src/planner_selection.rs | 46 +- control_plane/src/query_plan.rs | 20 +- control_plane/src/query_plan/residual.rs | 7 +- crates/asap_types/src/executable_plan.rs | 64 +- crates/asap_types/src/precompute_plan.rs | 101 +- .../asap_types/src/precompute_plan/catalog.rs | 2 +- crates/asap_types/src/query_plan.rs | 26 +- .../src/query_plan/current_series.rs | 10 +- crates/asap_types/src/query_plan/native.rs | 540 +++++++ .../precompute_engine/maintenance_runtime.rs | 48 +- data_plane/src/precompute_engine/mod.rs | 1 + .../precompute_engine/native_maintenance.rs | 130 ++ .../src/precompute_engine/partitioning.rs | 20 +- .../accelerator.rs | 6 +- .../asap_clickhouse_query_engine/execution.rs | 167 +-- .../query_engines/asap_query_engine/engine.rs | 33 +- .../asap_query_engine/exact_subqueries.rs | 4 +- .../asap_query_engine/live_serve.rs | 1 + .../asap_query_engine/logical_dag.rs | 14 +- .../logical_dag/native_values.rs | 252 +++- .../asap_query_engine/post_asap_readout.rs | 10 +- .../asap_query_engine/test_plan.rs | 1 + .../sketch_db/current_series.rs | 10 + .../sketch_db/index/maintenance.rs | 45 +- .../storage_engines/sketch_db/index/mod.rs | 1 + .../storage_engines/sketch_db/index/native.rs | 670 +++++++++ .../sketch_db/persistence/cache.rs | 4 +- .../sketch_db/persistence/part.rs | 122 +- .../types/hot_reload_config.rs | 4 + 37 files changed, 3845 insertions(+), 464 deletions(-) create mode 100644 crates/asap_types/src/query_plan/native.rs create mode 100644 data_plane/src/precompute_engine/native_maintenance.rs create mode 100644 data_plane/src/storage_engines/sketch_db/index/native.rs diff --git a/control_plane/Cargo.toml b/control_plane/Cargo.toml index 63b866090..4004000bc 100644 --- a/control_plane/Cargo.toml +++ b/control_plane/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" tokio = { version = "1", features = ["full"] } axum = { version = "0.7", features = ["ws"] } futures-util = "0.3" -serde = { version = "1", features = ["derive"] } +serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" sha2 = "0.10" serde_yaml = "0.9" diff --git a/control_plane/src/main.rs b/control_plane/src/main.rs index fae7acbb2..74a185f00 100644 --- a/control_plane/src/main.rs +++ b/control_plane/src/main.rs @@ -204,8 +204,12 @@ struct CompileAndPublishPhysicalPlanRequest { target_collector_ids: Vec, capability_snapshot_id: String, #[serde(default)] + data_snapshot_id: Option, + #[serde(default)] evidence: HashMap, #[serde(default)] + accuracy_evidence: HashMap, + #[serde(default)] exact_composition_costs: HashMap>, #[serde(default)] @@ -378,7 +382,7 @@ async fn compile_and_publish_physical_plan( Json(CompileAndPublishPhysicalPlanResponse { cost_comparison: bundle.cost_comparison, - planner_selection_trace: bundle.planner_selection_trace, + planner_selection_trace: bundle.planner_selection_trace.as_ref().clone(), plan_id: bundle.envelope.plan_id, plan_version: bundle.envelope.plan_version, status: "active", @@ -589,7 +593,7 @@ fn compile_physical_plan_request( legacy_query_source: planner_types::pre_asap::Source::TimeSeries { metric: query.metric, }, - query_lookback_seconds: query.window_secs, + query_lookback_ms: query.window_secs.saturating_mul(1_000), group_by_labels: query.group_by, accuracy_target: query.accuracy, summary_lifecycle_inputs: query.lifecycle, @@ -598,29 +602,69 @@ fn compile_physical_plan_request( }); } - let planner_selection_trace = match physical::compiler::select_logical_roots_with_trace( - &mut queries, - canonical_roots.clone(), - &request.evidence, - &request.exact_composition_costs, - request.erp.as_ref(), - ) { - Ok(trace) => trace, - Err(error) => return Err((StatusCode::UNPROCESSABLE_ENTITY, error.to_string().into())), - }; + let scoped_snapshot_id = request.data_snapshot_id.as_deref().or_else(|| { + request + .workload_cost_evidence + .as_ref() + .map(|evidence| evidence.data_snapshot_id.as_str()) + }); + if request.data_snapshot_id.as_ref().is_some_and(|id| { + request + .workload_cost_evidence + .as_ref() + .is_some_and(|evidence| evidence.data_snapshot_id != *id) + }) { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "accuracy evidence data snapshot differs from workload cost evidence".into(), + )); + } + for (query_id, evidence) in &request.accuracy_evidence { + let Some(query) = queries.iter().find(|query| &query.query_id == query_id) else { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + format!("accuracy evidence names unknown query {query_id}").into(), + )); + }; + evidence + .validate( + query_id, + &query.query_string, + &request.data_workload, + scoped_snapshot_id, + now, + request.max_evidence_age_ms, + ) + .map_err(|error| (StatusCode::UNPROCESSABLE_ENTITY, error.to_string().into()))?; + } + let planner_selection_trace = + match physical::compiler::select_logical_roots_with_scoped_evidence_and_trace( + &mut queries, + canonical_roots.clone(), + &request.evidence, + &request.accuracy_evidence, + &request.exact_composition_costs, + request.erp.as_ref(), + now, + ) { + Ok(trace) => trace, + Err(error) => return Err((StatusCode::UNPROCESSABLE_ENTITY, error.to_string().into())), + }; for (query, model) in queries.iter_mut().zip(window_models) { physical::compiler::prepare_window_implementations(query, &model, request.target, 0) .map_err(|error| (StatusCode::UNPROCESSABLE_ENTITY, error.to_string().into()))?; } let compilation_request = physical::compiler::PhysicalCompilationRequest { - planner_selection_trace, + planner_candidate_forests: Vec::new(), + planner_selection_trace: planner_selection_trace.into(), query_workload: Some(query_workload), data_workload: Some(request.data_workload), canonical_roots, queries, allow_mixed_summary_and_exact_execution: request.target == physical::compiler::PhysicalDeploymentTarget::BackendLocalRemoteWrite, + require_backend_local_execution: false, enabled_materialization_keys: None, topk_membership_evidence_by_query_id: request.evidence, exact_composition_costs: request.exact_composition_costs, @@ -646,7 +690,7 @@ fn compile_physical_plan_request( compilation_request.clone(), ) .map_err(|error| (StatusCode::UNPROCESSABLE_ENTITY, error.to_string().into()))?; - let planner_selection_trace = compilation_request.planner_selection_trace.clone(); + let planner_selection_trace = compilation_request.planner_selection_trace.as_ref().clone(); let (manifests, candidate_evaluations) = physical::workload_cost::compile_candidates_for_pricing( candidates.clone(), @@ -929,7 +973,7 @@ mod api_tests { "target": "backend_local_remote_write", "queries": [{ "query_id": query.query_id, "query_string": query.query_string, - "metric": metric, "window_secs": query.query_lookback_seconds, "accuracy": query.accuracy_target, + "metric": metric, "window_secs": query.query_lookback_ms / 1_000, "accuracy": query.accuracy_target, "lifecycle": query.summary_lifecycle_inputs, "evaluation_phase_ms": 0, "window_cost_model": { "implementation_id": "test", "cost": query.window_realization_candidates[0].cost } }], "dataset_identity": snapshot.environment.dataset_identity, diff --git a/control_plane/src/physical/compiler.rs b/control_plane/src/physical/compiler.rs index 2082a91aa..11f892647 100644 --- a/control_plane/src/physical/compiler.rs +++ b/control_plane/src/physical/compiler.rs @@ -71,7 +71,7 @@ pub struct QueryCompilationInput { /// materialization sources are derived from each post-ASAP SummaryAgg; /// it also remains part of the cost manifest workload identity. pub legacy_query_source: Source, - pub query_lookback_seconds: u64, + pub query_lookback_ms: u64, /// Label names are deployment metadata because Planner's canonical IR /// currently carries positional column IDs at this boundary. pub group_by_labels: Vec, @@ -154,10 +154,15 @@ pub struct SummaryLifecyclePlanningInputs { #[derive(Debug, Clone, Default)] pub struct PhysicalCompilationRequest { - /// Diagnostic projections of the original Planner search; never consumed by selection. - pub planner_selection_trace: Vec, + /// Complete Planner candidates retained until deployment admission/pricing. + /// Empty for callers that explicitly supply one already-selected forest. + pub planner_candidate_forests: Vec>, + /// Immutable search diagnostics shared across candidates; never consumed by selection. + pub planner_selection_trace: std::sync::Arc>, /// Enable a composable DAG with SummaryStore materializations and Prometheus exact subtrees. pub allow_mixed_summary_and_exact_execution: bool, + /// Deployment feasibility: external exact dependencies cannot be bound. + pub require_backend_local_execution: bool, /// Enabled optional candidate keys: None enables all eligible keys; an /// explicitly empty set enables none. These are not catalog definition IDs. pub enabled_materialization_keys: Option>, @@ -165,7 +170,7 @@ pub struct PhysicalCompilationRequest { pub query_workload: Option, /// Source evidence supplied independently from query demand. pub data_workload: Option, - /// Workload-lowered roots retained across physical alternative enumeration. + /// Workload-lowered roots retained across physical candidate enumeration. pub canonical_roots: Vec>, pub queries: Vec, pub topk_membership_evidence_by_query_id: HashMap, @@ -196,6 +201,135 @@ pub struct TopKMembershipEvidence { pub source: String, } +/// A proof for one exact Planner operand, including the enforced source +/// domain rather than an observed sample minimum or maximum. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct QuantileOperandDomainEvidence { + pub operand: Value, + pub lower: f64, + pub upper: f64, + pub max_samples: u64, + pub contract: String, +} + +/// Optional accuracy facts bound to one registered query and data snapshot. +/// Missing fields stay unknown to Planner. The data workload and snapshot +/// identity prevent reusing a proof for a different source population. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ScopedAccuracyEvidence { + pub query_string: String, + pub data_snapshot_id: String, + pub data_workload: DataWorkload, + pub source: String, + pub observed_at_unix_ms: u64, + pub valid_for_ms: u64, + #[serde(default)] + pub quantile_operand_domains: Vec, + #[serde(default)] + pub values_non_negative: Option, + #[serde(default)] + pub input_row_count: Option, + #[serde(default)] + pub hydra_shared_grid_collision_bound: Option, + #[serde(default)] + pub hydra_shared_grid_failure_probability: Option, + /// Enforced upper bound across all partition/item identities for this query + /// snapshot. An observed cardinality estimate is not this contract. + #[serde(default)] + pub topk_max_distinct_items: Option, + #[serde(default)] + pub topk_selected_lower_bound: Option, + #[serde(default)] + pub topk_excluded_upper_bound: Option, + #[serde(default)] + pub topk_interval_failure_probability: Option, +} + +impl ScopedAccuracyEvidence { + pub fn validate( + &self, + query_id: &str, + query_string: &str, + data: &DataWorkload, + snapshot_id: Option<&str>, + now_ms: u64, + max_age_ms: u64, + ) -> Result<(), CompileError> { + let valid_scope = self.query_string == query_string + && &self.data_workload == data + && snapshot_id.is_some_and(|id| id == self.data_snapshot_id) + && !self.data_snapshot_id.trim().is_empty() + && !self.source.trim().is_empty(); + let valid_time = self.observed_at_unix_ms <= now_ms + && self.valid_for_ms > 0 + && now_ms - self.observed_at_unix_ms <= self.valid_for_ms.min(max_age_ms); + let topk_bounds = ( + self.topk_selected_lower_bound, + self.topk_excluded_upper_bound, + self.topk_interval_failure_probability, + ); + let valid_topk = match topk_bounds { + (None, None, None) => true, + (Some(lower), Some(upper), Some(failure)) => { + lower.is_finite() + && upper.is_finite() + && lower > upper + && (0.0..=1.0).contains(&failure) + } + _ => false, + }; + let valid_stats = valid_topk + && self + .topk_max_distinct_items + .is_none_or(|n| n > 0 && n <= (1_u64 << 53)) + && self.input_row_count != Some(0) + && self + .hydra_shared_grid_collision_bound + .is_none_or(|v| v.is_finite() && v >= 0.0) + && self + .hydra_shared_grid_failure_probability + .is_none_or(|v| (0.0..=1.0).contains(&v)) + && self + .quantile_operand_domains + .iter() + .enumerate() + .all(|(index, domain)| { + domain.lower.is_finite() + && domain.upper.is_finite() + && domain.lower <= domain.upper + && (1..=(1u64 << 53)).contains(&domain.max_samples) + && !domain.contract.trim().is_empty() + && !self.quantile_operand_domains[..index] + .iter() + .any(|earlier| earlier.operand == domain.operand) + }); + if valid_scope && valid_time && valid_stats { + return Ok(()); + } + let reason = if self.query_string != query_string { + "accuracy evidence belongs to a different query" + } else if &self.data_workload != data { + "accuracy evidence belongs to a different data workload" + } else if !snapshot_id.is_some_and(|id| id == self.data_snapshot_id) + || self.data_snapshot_id.trim().is_empty() + { + "accuracy evidence belongs to a different or unspecified data snapshot" + } else if self.source.trim().is_empty() { + "accuracy evidence has no provenance source" + } else if !valid_time { + "accuracy evidence is expired, future-dated, or has no validity window" + } else { + "accuracy evidence contains invalid or ambiguous bounds" + }; + Err(CompileError::InvalidEvidence { + query_id: query_id.into(), + reason: reason.into(), + }) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct PhysicalDeploymentContext { @@ -230,7 +364,6 @@ pub enum PhysicalDeploymentTarget { pub struct BackendLocalPlanningInput { #[serde(rename = "snapshot_version")] pub schema_version: u32, - /// May be absent during candidate discovery, never during deployment. #[serde(default, skip_serializing_if = "Option::is_none")] pub workload_cost_evidence: Option, #[serde(deserialize_with = "deserialize_snapshot_query_workload")] @@ -244,6 +377,9 @@ pub struct BackendLocalPlanningInput { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct BackendLocalPhysicalInputs { + /// Constrain selection to local execution when no exact upstream is available. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub require_backend_local_execution: bool, pub lifecycle_costs: LifecycleUnitCosts, pub evidence_observed_at_unix_ms: u64, pub evidence_valid_for_ms: u64, @@ -268,6 +404,12 @@ pub struct BackendLocalPhysicalInputs { /// before workload selection so one query cannot borrow another's evidence. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub topk_evidence: HashMap, + /// Data generation used by scoped accuracy certificates during candidate + /// discovery, before complete workload quotes are available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_snapshot_id: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub accuracy_evidence: HashMap, /// Measured exact/summary composition profiles keyed by registered /// PromQL. Missing rows keep the corresponding Planner site opaque. #[serde(default, skip_serializing_if = "HashMap::is_empty")] @@ -469,7 +611,7 @@ pub struct CompiledPhysicalPlan { /// Lifecycle component only, not a complete physical-plan comparison. pub lifecycle_estimates: Vec, pub cost_comparison: Option, - pub planner_selection_trace: Vec, + pub planner_selection_trace: std::sync::Arc>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -488,7 +630,7 @@ pub struct MaterializationLifecycleEstimate { pub enum CompileError { #[error("invalid backend-local workload snapshot: {0}")] Snapshot(String), - #[error("no feasible completely costed alternative: {0}")] + #[error("no feasible completely costed candidate: {0}")] Candidates(serde_json::Value), #[error("planner revision mismatch: request={request}, compiler={compiler}")] PlannerRevision { @@ -507,24 +649,79 @@ pub enum CompileError { QueryPlan(#[from] crate::query_plan::QueryPlanError), } -struct QueryEvidence<'a>(Option<&'a TopKMembershipEvidence>); +struct QueryEvidence<'a> { + topk: Option<&'a TopKMembershipEvidence>, + scoped: Option<&'a ScopedAccuracyEvidence>, + now_ms: u64, +} impl AccuracyEvidenceProvider for QueryEvidence<'_> { + fn topk_max_distinct_items(&self, _: &QueryExpr) -> Option { + self.scoped + .and_then(|evidence| evidence.topk_max_distinct_items) + } + fn quantile_input_domain( + &self, + operand: &QueryExpr, + ) -> Option { + let operand = serde_json::to_value(operand).ok()?; + let domain = self + .scoped? + .quantile_operand_domains + .iter() + .find(|entry| entry.operand == operand)?; + Some(asap_aware_mapping::accuracy::QuantileInputDomain { + lower: domain.lower, + upper: domain.upper, + max_samples: domain.max_samples, + contract: domain.contract.clone(), + }) + } + fn propagation_stats( &self, op: &CompositionOperator, _family: &SummaryFamilyType, _query: Option<&SketchQuery>, ) -> PropagationStats { - match (op, self.0) { - (CompositionOperator::TopKSelection, Some(e)) => PropagationStats { - topk_selected_lower_bound: Some(e.selected_lower_bound), - topk_excluded_upper_bound: Some(e.excluded_upper_bound), - topk_interval_failure_probability: Some(e.interval_failure_probability), + let mut stats = self + .scoped + .map_or_else(PropagationStats::default, |evidence| PropagationStats { + values_non_negative: evidence.values_non_negative, + input_row_count: evidence.input_row_count.or_else(|| { + evidence + .data_workload + .input_cardinality + .value_at(self.now_ms) + .copied() + }), + data_distribution: evidence + .data_workload + .distribution + .value_at(self.now_ms) + .cloned(), + hydra_shared_grid_collision_bound: evidence.hydra_shared_grid_collision_bound, + hydra_shared_grid_failure_probability: evidence + .hydra_shared_grid_failure_probability, ..Default::default() - }, - _ => PropagationStats::default(), + }); + if matches!(op, CompositionOperator::TopKSelection) { + if let Some(evidence) = self + .scoped + .filter(|e| e.topk_selected_lower_bound.is_some()) + { + stats.topk_selected_lower_bound = evidence.topk_selected_lower_bound; + stats.topk_excluded_upper_bound = evidence.topk_excluded_upper_bound; + stats.topk_interval_failure_probability = + evidence.topk_interval_failure_probability; + } else if let Some(evidence) = self.topk { + stats.topk_selected_lower_bound = Some(evidence.selected_lower_bound); + stats.topk_excluded_upper_bound = Some(evidence.excluded_upper_bound); + stats.topk_interval_failure_probability = + Some(evidence.interval_failure_probability); + } } + stats } } @@ -584,6 +781,29 @@ impl BackendLocalPlanningInput { } let workload = self.query_workload; let mut data_workload = self.data_workload.clone(); + let scoped_snapshot_id = self + .physical_inputs + .data_snapshot_id + .as_deref() + .or_else(|| { + self.workload_cost_evidence + .as_ref() + .map(|evidence| evidence.data_snapshot_id.as_str()) + }); + if self + .physical_inputs + .data_snapshot_id + .as_ref() + .is_some_and(|id| { + self.workload_cost_evidence + .as_ref() + .is_some_and(|evidence| evidence.data_snapshot_id != *id) + }) + { + return Err(CompileError::Snapshot( + "accuracy evidence data snapshot differs from workload cost evidence".into(), + )); + } if data_workload.data_ingestion_interval.value.is_none() && self.physical_inputs.scrape_interval_ms > 0 { @@ -629,6 +849,7 @@ impl BackendLocalPlanningInput { let mut queries = Vec::with_capacity(entries.len()); let mut canonical_roots = Vec::with_capacity(entries.len()); let mut topk_evidence_by_id = HashMap::new(); + let mut scoped_evidence_by_id = HashMap::new(); for (index, (entry, parsed)) in entries.into_iter().zip(canonical_queries).enumerate() { let evaluation_interval_ms = match entry.recurrence { QueryRecurrence::Repeated(RepeatedDemand::FixedIntervalAt { @@ -640,14 +861,9 @@ impl BackendLocalPlanningInput { ))) } }; - if self.physical_inputs.scrape_interval_ms == 0 - || !self - .physical_inputs - .scrape_interval_ms - .is_multiple_of(1_000) - { + if self.physical_inputs.scrape_interval_ms == 0 { return Err(CompileError::Snapshot( - "scrape_interval_ms must be a positive whole number of seconds".into(), + "scrape_interval_ms must be positive".into(), )); } let accuracy = entry.requirements.accuracy.target(); @@ -675,6 +891,17 @@ impl BackendLocalPlanningInput { if let Some(evidence) = self.physical_inputs.topk_evidence.get(&query_string) { topk_evidence_by_id.insert(query_id.clone(), evidence.clone()); } + if let Some(evidence) = self.physical_inputs.accuracy_evidence.get(&query_string) { + evidence.validate( + &query_id, + &query_string, + &data_workload, + scoped_snapshot_id, + self.environment.observed_at_unix_ms, + self.environment.max_evidence_age_ms, + )?; + scoped_evidence_by_id.insert(query_id.clone(), evidence.clone()); + } queries.push(QueryCompilationInput { query_id, query_string: query_string.clone(), @@ -682,7 +909,7 @@ impl BackendLocalPlanningInput { legacy_query_source: Source::TimeSeries { metric: source_hint, }, - query_lookback_seconds: lookback_ms / 1_000, + query_lookback_ms: lookback_ms, group_by_labels: metadata.group_by_labels, accuracy_target: accuracy, summary_lifecycle_inputs: lifecycle, @@ -690,6 +917,16 @@ impl BackendLocalPlanningInput { materialization_runtime_policy: RuntimeRulePolicy::default(), }); } + if self + .physical_inputs + .accuracy_evidence + .keys() + .any(|query| !queries.iter().any(|entry| &entry.query_string == query)) + { + return Err(CompileError::Snapshot( + "accuracy evidence names a query absent from this workload".into(), + )); + } let mut exact_costs_by_id = HashMap::new(); for (index, entry) in workload.entries().enumerate() { if let Some(rows) = self @@ -711,13 +948,95 @@ impl BackendLocalPlanningInput { exact_costs_by_id.insert(format!("compat-query-{index}"), rows.clone()); } } - let planner_selection_trace = select_logical_roots_with_trace( + let mut candidate_roots = Vec::new(); + let mut planner_selection_trace = logical_roots_and_candidates( &mut queries, canonical_roots.clone(), &topk_evidence_by_id, + &scoped_evidence_by_id, &exact_costs_by_id, self.physical_inputs.erp.as_ref(), + self.environment.observed_at_unix_ms, + Some(&mut candidate_roots), )?; + // Resolve physical row identity before asking Planner for snapshot heap + // candidates. Keep canonical query semantics and exact candidates intact. + for (index, (query, root)) in queries.iter().zip(&canonical_roots).enumerate() { + let Ok(typed) = + asap_physical_operators::physical_planner::promql_rows::with_series_identity(root) + else { + continue; + }; + let evidence = QueryEvidence { + topk: topk_evidence_by_id.get(&query.query_id), + scoped: scoped_evidence_by_id.get(&query.query_id), + now_ms: self.environment.observed_at_unix_ms, + }; + let strategy = + asap_aware_mapping::SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + &asap_aware_mapping::cost_model::DefaultCostModel, + &asap_aware_mapping::accuracy::DefaultAccuracyModel, + &asap_aware_mapping::accuracy::EqualSplitAllocator, + &evidence, + ); + let typed = Rc::new(typed); + let mut proposed = + strategy.current_series_topk_candidates(&typed, &query.accuracy_target); + let direct = asap_aware_mapping::ReplacementStrategy::propose( + &strategy, + &asap_aware_mapping::TargetSubDAG::new(&typed), + ); + proposed + .candidates + .extend(strategy.fixed_window_rate_candidates(&typed).candidates); + proposed.candidates.extend( + strategy + .query_time_rate_aggregation_candidates(&typed) + .candidates, + ); + proposed.candidates.extend(direct.candidates); + proposed.rejected.extend(direct.rejected); + for candidate in proposed.candidates { + let asap_aware_mapping::Replacement::Summary(root) = candidate.replacement else { + continue; + }; + let root = asap_aware_mapping::replacement::finalize_query_candidate(root, &typed) + .map_err(|error| CompileError::Snapshot(error.to_string()))?; + let compiled = asap_physical_operators::physical_planner::promql_rows::compile_current_series_readout(&root) + .or_else(|_| asap_physical_operators::physical_planner::promql_rows::compile_rate_ranking(&root).map(|(_, program)| program)); + if let Ok(physical) = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&root) { + planner_selection_trace.push(serde_json::json!({ + "stage":"planner.physical_candidate", "query_id":query.query_id, + "logical_root_id":crate::planner_selection::explained_root_id(&root, &query.accuracy_target), + "rationale":candidate.rationale, "physical_candidate":serde_json::from_slice::(&physical.encode().map_err(|e| CompileError::Snapshot(e.to_string()))?).map_err(|e| CompileError::Snapshot(e.to_string()))?, + "guarantee":root.guarantee, + })); + candidate_roots.push(vec![(index, root)]); + continue; + } + match compiled { + Ok(program) => { + planner_selection_trace.push(serde_json::json!({ + "stage": "planner.physical_candidate", "query_id": query.query_id, + "logical_root_id": crate::planner_selection::explained_root_id(&root, &query.accuracy_target), + "rationale": candidate.rationale, "physical_dag": serde_json::from_slice::(&program.encode().map_err(|error| CompileError::Snapshot(error.to_string()))?).map_err(|error| CompileError::Snapshot(error.to_string()))?, + "guarantee": root.guarantee, + })); + candidate_roots.push(vec![(index, root)]); + } + Err(error) => planner_selection_trace.push(serde_json::json!({ + "stage": "planner.physical_candidate", "query_id": query.query_id, + "status": "unsupported", "reason": error.to_string(), + })), + } + } + for rejected in proposed.rejected { + planner_selection_trace.push(serde_json::json!({ + "stage": "planner.physical_candidate", "query_id": query.query_id, + "status": "rejected", "reason": rejected.error.to_string(), + })); + } + } for query in &mut queries { prepare_window_implementations( query, @@ -726,11 +1045,43 @@ impl BackendLocalPlanningInput { self.physical_inputs.query_retention_margin_ms, )?; } + let mut planner_candidate_forests = Vec::new(); + for roots in candidate_roots { + let mut forest = queries.clone(); + let changed: BTreeSet<_> = roots.iter().map(|(index, _)| *index).collect(); + for (index, root) in roots { + forest[index].selected_plan_root = root; + } + // Unchanged roots already have prepared windows in `queries`. + let preparation = changed.into_iter().try_for_each(|index| { + prepare_window_implementations( + &mut forest[index], + &self.physical_inputs.window_cost_model, + self.environment.target, + self.physical_inputs.query_retention_margin_ms, + ) + }); + if let Err(error) = preparation { + planner_selection_trace.push(serde_json::json!({ + "stage": "deployment.window_feasibility", + "status": "rejected", + "logical_root_ids": forest.iter().map(|query| crate::planner_selection::explained_root_id( + &query.selected_plan_root, &query.accuracy_target)).collect::>(), + "reason": error.to_string(), + })); + continue; + } + planner_candidate_forests.push(forest); + } // Composable lowering residualizes unsafe leaves individually; retain Planner siblings. Ok(( PhysicalCompilationRequest { - planner_selection_trace, + planner_candidate_forests, + planner_selection_trace: planner_selection_trace.into(), allow_mixed_summary_and_exact_execution: true, + require_backend_local_execution: self + .physical_inputs + .require_backend_local_execution, enabled_materialization_keys: None, query_workload: Some(workload), data_workload: Some(data_workload), @@ -1053,6 +1404,7 @@ impl DeploymentPlanCompiler { for (id, root) in planner_types::post_asap::share_common_summary_subtrees(roots) { request.queries[id].selected_plan_root = root; } + let population_operators = super::maintained_population::operators(&request)?; let mut compiled_materializations = Vec::with_capacity(request.queries.len()); let mut collector_materializations = Vec::with_capacity(request.queries.len()); let mut plan_materializations = Vec::with_capacity(request.queries.len()); @@ -1089,15 +1441,22 @@ impl DeploymentPlanCompiler { validate_evidence(&query.query_id, e, &environment)?; } let node = query.selected_plan_root.clone(); - reject_uncertified_readouts(&query.query_id, &node)?; - let selected = collect_selected_materializations( - &node, - request.allow_mixed_summary_and_exact_execution, - ) - .map_err(|reason| CompileError::Query { - query_id: query.query_id.clone(), - reason, - })?; + reject_uncertified_readouts(&query.query_id, &node, &query.accuracy_target)?; + let selected = if super::maintained_population::supported_node(&node) { + Vec::new() + } else { + collect_selected_materializations( + &node, + request.allow_mixed_summary_and_exact_execution, + ) + .map_err(|reason| CompileError::Query { + query_id: query.query_id.clone(), + reason, + })? + }; + if !selected.is_empty() { + reject_uncertified_readouts(&query.query_id, &node, &query.accuracy_target)?; + } let selected = selected .into_iter() .filter(|state| { @@ -1145,16 +1504,14 @@ impl DeploymentPlanCompiler { .flatten() }); // Masks enumerate counter/max choices only. Other selected - // summaries remain required by this physical alternative. + // summaries remain required by this physical candidate. key.is_none_or(|key| policy.contains(&key)) }) }) .collect::>(); // An exact native fallback has no maintained state and must not // depend on evidence for unused window/state implementations. - if selected.is_empty() - && super::maintained_population::operator(&request, query)?.is_none() - { + if selected.is_empty() && population_operators[query_index].is_none() { continue; } let executable = planner_types::post_asap::compile_executable_dag_with_node_ids( @@ -1203,7 +1560,7 @@ impl DeploymentPlanCompiler { for state in &selected { if matches!(&state.node.expr, SummaryExpr::SummaryAgg { reduction: planner_types::pre_asap::Reduction::Reduce(keys), .. - } if keys.is_empty()) + } if keys.is_empty()) || asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&query.selected_plan_root).is_ok() { if let Some(sources) = immutable_materialization_sources(&state.node) { canonical_nodes.insert(Rc::as_ptr(&state.node) as usize); @@ -1214,10 +1571,13 @@ impl DeploymentPlanCompiler { } } let cohort_nodes = windows::cohort_nodes(&selected); + let native_cohort = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&query.selected_plan_root).is_ok(); for (ordinal, selected) in selected.into_iter().enumerate() { let mut branch_query = query.clone(); - branch_query.query_lookback_seconds = - selected.window_secs.unwrap_or(query.query_lookback_seconds); + branch_query.query_lookback_ms = selected + .window_secs + .map(|seconds| seconds.saturating_mul(1_000)) + .unwrap_or(query.query_lookback_ms); branch_query.group_by_labels = selected .group_by .clone() @@ -1225,9 +1585,20 @@ impl DeploymentPlanCompiler { branch_query .window_realization_candidates .retain(|candidate| { - candidate.window_secs == branch_query.query_lookback_seconds + candidate.window_secs.saturating_mul(1_000) + == branch_query.query_lookback_ms && if cohort_nodes.contains(&(Rc::as_ptr(&selected.node) as usize)) { - windows::is_full_cohort(candidate) + if native_cohort { + windows::is_complete_window(candidate) + && candidate.slide_secs.saturating_mul(1000) + == u64::from( + query + .summary_lifecycle_inputs + .evaluation_interval_ms, + ) + } else { + windows::is_full_cohort(candidate) + } } else { !candidate.cohort_only } @@ -1312,7 +1683,7 @@ impl DeploymentPlanCompiler { let cadence = u64::from( query.summary_lifecycle_inputs.evaluation_interval_ms, ); - let window = query.query_lookback_seconds.saturating_mul(1_000); + let window = query.query_lookback_ms; a % cadence == b % cadence && a % window == b % window } _ => index == query_index, @@ -1443,7 +1814,9 @@ impl DeploymentPlanCompiler { query_id: query.query_id.clone(), reason: error.to_string(), })?; - if runtime_materialization.window_size != runtime_materialization.slide_interval + if !native_cohort + && runtime_materialization.window_size + != runtime_materialization.slide_interval { return Err(CompileError::Query { query_id: query.query_id.clone(), reason: "immutable scalar composition runtime requires nonoverlapping windows".into() }); @@ -1734,7 +2107,7 @@ impl DeploymentPlanCompiler { let window_ms = materialization.window_size.saturating_mul(1_000); if materialization_family != *node_family || window_ms == 0 - || source_window.unwrap_or(query.query_lookback_seconds).saturating_mul(1_000) + || source_window.map(|seconds| seconds.saturating_mul(1_000)).unwrap_or(query.query_lookback_ms) % window_ms != 0 { return Err(crate::query_plan::QueryPlanError::Invalid(format!( @@ -1757,7 +2130,7 @@ impl DeploymentPlanCompiler { }) }; let instant = InstantExecution { - lookback_ms: query.query_lookback_seconds.saturating_mul(1_000), + lookback_ms: query.query_lookback_ms, full_history: false, cumulative_readout: true, }; @@ -1778,15 +2151,122 @@ impl DeploymentPlanCompiler { } else { false }; - let mut entry = if let Some(operator) = - super::maintained_population::operator(&request, query)? - { + let native_rate = + asap_physical_operators::physical_planner::promql_rows::compile_rate_ranking( + &query.selected_plan_root, + ) + .ok(); + let native_rate = native_rate.or_else(|| { + let physical = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&query.selected_plan_root).ok()?; + fn heap(node: &Rc) -> Option> { + match &node.expr { + SummaryExpr::SummaryAgg { family: SummaryFamilyType::Sketch(..) | SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _), .. } => Some(node.clone()), + SummaryExpr::ValueOperation { child, .. } => heap(child), + SummaryExpr::SummaryEstimate { summary_input, .. } => heap(summary_input), + _ => None, + } + } + Some((heap(&query.selected_plan_root)?, physical.query)) + }); + let mut entry = if let Some((source, program)) = native_rate { + let native_state_binding = if let SummaryExpr::SummaryAgg { + family: + SummaryFamilyType::Sketch(..) + | SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _), + .. + } = &source.expr + { + let SummaryExpr::SummaryAgg { family, .. } = &source.expr else { + unreachable!() + }; + Some(binding(&source, family)?) + } else { + None + }; + let mut entry = if let Some(binding) = native_state_binding { + let root = crate::query_plan::QueryNodeId(0); + if let Some(id) = executable_dags[query_index] + .as_ref() + .and_then(|compiled| compiled.node_ids.node_id(&source)) + { + query_node_bindings.insert((query_index, id), root); + } + crate::query_plan::QueryPlanEntry { + physical_dag: None, + language: crate::query_plan::QueryLanguage::PromQl, + query_id: query.query_id.clone(), + canonical_query: canonical.clone(), + fixed_evaluation: None, + root, + nodes: BTreeMap::from([( + root, + crate::query_plan::QueryPlanNode::ReadMaterialization { binding }, + )]), + instant, + fallback: FallbackPolicy::ExactBackend, + } + } else { + crate::query_plan::compile_bound_composable_mapped( + query.query_id.clone(), + canonical.clone(), + &source, + instant, + FallbackPolicy::ExactBackend, + binding, + |node, query_node| { + if let Some(id) = executable_dags[query_index] + .as_ref() + .and_then(|compiled| compiled.node_ids.node_id(node)) + { + query_node_bindings.insert((query_index, id), query_node); + } + }, + )? + }; + let root = crate::query_plan::QueryNodeId( + entry.nodes.keys().map(|id| id.0).max().unwrap_or(0) + 1, + ); + let sources = program + .input_contracts() + .map(|(id, _)| id) + .collect::>(); + if sources.len() != 1 { + return Err(CompileError::Snapshot( + "Rate physical candidate requires one source binding".into(), + )); + } + entry.nodes.insert( + root, + crate::query_plan::QueryPlanNode::Physical { + inputs: vec![entry.root], + source_nodes: sources, + max_bytes: request + .retained_summary_memory_budget_bytes + .unwrap_or(64 * 1024 * 1024), + }, + ); + entry.root = root; + entry.physical_dag = Some( + serde_json::from_slice( + &program + .encode() + .map_err(|e| CompileError::Snapshot(e.to_string()))?, + ) + .map_err(|e| CompileError::Snapshot(e.to_string()))?, + ); + entry.recover_vector_physical_dag()?; + if let Some(compiled) = &executable_dags[query_index] { + query_node_bindings.insert((query_index, compiled.dag.root), root); + } + Ok(entry) + } else if let Some(operator) = population_operators[query_index].clone() { let root = crate::query_plan::QueryNodeId(0); let compiled = executable_dags[query_index] .as_ref() .expect("compiled Planner DAG"); query_node_bindings.insert((query_index, compiled.dag.root), root); Ok(crate::query_plan::QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: query.query_id.clone(), canonical_query: canonical.clone(), @@ -1818,6 +2298,7 @@ impl DeploymentPlanCompiler { // Keep native semantics instead of lowering an unbound summary. let root = crate::query_plan::QueryNodeId(0); Ok(crate::query_plan::QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: query.query_id.clone(), canonical_query: canonical.clone(), @@ -1877,6 +2358,10 @@ impl DeploymentPlanCompiler { if frontend == QueryFrontend::MetricsQl { entry.language = crate::query_plan::QueryLanguage::MetricsQl; } + super::maintained_population::install_native_topk( + &mut entry, + &query.selected_plan_root, + )?; let catalog_key = QueryPlan::catalog_key(entry.language, &canonical); if query_entries.insert(catalog_key, entry).is_some() { return Err(CompileError::Query { @@ -1902,7 +2387,7 @@ impl DeploymentPlanCompiler { .find(|entry| entry.query_id == query_id) .expect("compiled query entry exists") .root; - let installed = super::executable_binding::install_selected_dag( + let mut installed = super::executable_binding::install_selected_dag( query_id.clone(), &compiled.dag, query_plan_sink, @@ -1918,11 +2403,35 @@ impl DeploymentPlanCompiler { query_id: query_id.clone(), reason, })?; + if let Ok(physical) = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&request.queries[query_index].selected_plan_root) { + let program = physical.precompute.ok_or_else(|| CompileError::Snapshot("fixed-window candidate has no maintenance program".into()))?; + let sink = PostAsapNodeId(u32::try_from(program.roots()[0]).map_err(|_| CompileError::Snapshot("maintenance sink overflow".into()))?); + installed.native_programs.insert(sink, serde_json::from_slice(&program.encode().map_err(|e| CompileError::Snapshot(e.to_string()))?).map_err(|e| CompileError::Snapshot(e.to_string()))?); + } query_plan .selected_dags .insert(query_id.clone(), installed.document.clone()); installed_dags.insert(query_id, installed); } + let maintenance_lookbacks = materializations + .iter() + .filter_map(|target| { + target + .derived_input + .as_ref() + .map(|input| (input, target.stored_window_ms())) + }) + .flat_map(|(input, window)| input.inputs.iter().map(move |source| (*source, window))) + .fold( + BTreeMap::::new(), + |mut windows, (source, window)| { + windows + .entry(source) + .and_modify(|old| *old = (*old).max(window)) + .or_insert(window); + windows + }, + ); for materialization in &mut materializations { let fingerprint = materialization.policy_fingerprint(); let max_lookback_ms = query_plan @@ -1931,6 +2440,7 @@ impl DeploymentPlanCompiler { .flat_map(QueryPlanEntry::materialization_bindings) .filter(|binding| binding.materialization.fingerprint() == fingerprint) .filter_map(|binding| binding.readout_lookback_ms) + .chain(maintenance_lookbacks.get(&fingerprint.into()).copied()) .max(); if let Some(lookback_ms) = max_lookback_ms { materialization.num_aggregates_to_retain = Some(retained_state_count( @@ -2064,6 +2574,20 @@ impl DeploymentPlanCompiler { reason: error.to_string(), })?; } + if request.require_backend_local_execution { + for entry in query_plan.entries.values() { + if entry.nodes.values().any(|node| matches!(node, + crate::query_plan::QueryPlanNode::ExactFallback { .. } + | crate::query_plan::QueryPlanNode::Logical { + operator: crate::query_plan::residual::ResidualQueryOperator::ExactSubquery { .. } + | crate::query_plan::residual::ResidualQueryOperator::CandidateExactSubquery { .. }, .. })) { + return Err(CompileError::Query { + query_id: entry.query_id.clone(), + reason: "external execution is unavailable in this deployment".into(), + }); + } + } + } query_plan.bind_catalog(&summary_catalog)?; let storage_routing = crate::emit::backend_wire::storage_routing_document( crate::emit::backend_wire::DEFAULT_TENANT, @@ -2139,7 +2663,7 @@ pub fn select_logical_roots_for_queries( select_logical_roots_with_error_resource_profiles(queries, roots, evidence, exact_costs, None) } -fn observed_population_matches_root( +pub(crate) fn observed_population_matches_root( policy: &super::erp::ErpPlanningInput, root: &QueryExpr, ) -> bool { @@ -2202,6 +2726,48 @@ pub fn select_logical_roots_with_trace( evidence: &HashMap, exact_costs: &HashMap>, erp: Option<&super::erp::ErpPlanningInput>, +) -> Result, CompileError> { + select_logical_roots_with_scoped_evidence_and_trace( + queries, + roots, + evidence, + &HashMap::new(), + exact_costs, + erp, + 0, + ) +} + +pub fn select_logical_roots_with_scoped_evidence_and_trace( + queries: &mut [QueryCompilationInput], + roots: Vec>, + evidence: &HashMap, + scoped_evidence: &HashMap, + exact_costs: &HashMap>, + erp: Option<&super::erp::ErpPlanningInput>, + now_ms: u64, +) -> Result, CompileError> { + logical_roots_and_candidates( + queries, + roots, + evidence, + scoped_evidence, + exact_costs, + erp, + now_ms, + None, + ) +} + +fn logical_roots_and_candidates( + queries: &mut [QueryCompilationInput], + roots: Vec>, + evidence: &HashMap, + scoped_evidence: &HashMap, + exact_costs: &HashMap>, + erp: Option<&super::erp::ErpPlanningInput>, + now_ms: u64, + mut candidates_out: Option<&mut Vec)>>>, ) -> Result, CompileError> { let mut traces = Vec::new(); if roots.len() != queries.len() { @@ -2215,6 +2781,7 @@ pub fn select_logical_roots_with_trace( for (index, root) in roots.into_iter().enumerate() { let accuracy = &queries[index].accuracy_target; let certificate_scope = (evidence.contains_key(&queries[index].query_id) + || scoped_evidence.contains_key(&queries[index].query_id) || exact_costs.contains_key(&queries[index].query_id)) .then(|| queries[index].query_id.clone()); if let Some((_, _, roots)) = cohorts @@ -2268,6 +2835,7 @@ pub fn select_logical_roots_with_trace( model = model.with_erp(erp.clone()); } let certificate = scope.as_ref().and_then(|id| evidence.get(id)); + let scoped_certificate = scope.as_ref().and_then(|id| scoped_evidence.get(id)); let accuracy_model = super::erp::ErpAccuracyModel { policy: erp, max_error: match accuracy { @@ -2275,15 +2843,56 @@ pub fn select_logical_roots_with_trace( AccuracyTarget::Exact => 0.0, }, }; + let mut candidate_assembly_rejections = Vec::new(); + if let Some(output) = candidates_out.as_deref_mut() { + let inventory = crate::planner_selection::enumerate_workload_candidates( + roots.clone(), + accuracy.clone(), + &model, + &QueryEvidence { + topk: certificate, + scoped: scoped_certificate, + now_ms, + }, + &accuracy_model, + ) + .map_err(|error| CompileError::Snapshot(error.to_string()))?; + candidate_assembly_rejections = inventory.rejected_assemblies; + let candidates = inventory.candidates; + // Retain every root's candidates without multiplying independent + // cohorts. Deployment evaluates each root substitution in the + // preferred workload context; this is not exhaustive joint search. + output.extend(candidates); + } let (selected, mut trace) = crate::planner_selection::select_workload_with_accuracy_model_and_trace( roots, accuracy, &model, - &QueryEvidence(certificate), + &QueryEvidence { + topk: certificate, + scoped: scoped_certificate, + now_ms, + }, &accuracy_model, ) .map_err(|error| CompileError::Snapshot(error.to_string()))?; + trace["candidate_assembly_rejections"] = serde_json::json!(candidate_assembly_rejections); + if candidates_out.is_some() { + trace["computation_search_scope"] = serde_json::json!({ + "inventory": "all_root_candidates", + "deployment_evaluation": "single_root_substitutions_in_preferred_workload", + "joint_workload_search_exhaustive": false, + }); + } + if let Some(evidence) = scoped_certificate { + trace["accuracy_evidence_scope"] = serde_json::json!({ + "query_id": scope, + "source": evidence.source, + "data_snapshot_id": evidence.data_snapshot_id, + "observed_at_unix_ms": evidence.observed_at_unix_ms, + }); + } trace["deployment_overrides"] = serde_json::json!([]); let selected_indices = selected.iter().map(|(index, _)| *index).collect::>(); for (index, node) in selected { @@ -2463,7 +3072,11 @@ pub fn select_post_asap( expr, &CollectorFixtureModel(model), &DefaultAccuracyModel, - &QueryEvidence(evidence), + &QueryEvidence { + topk: evidence, + scoped: None, + now_ms: 0, + }, ) } @@ -2623,10 +3236,9 @@ pub(super) fn derived_window_cost( /// example, `a[1m] offset 1h` selects `(t - 61m, t - 60m]`. fn query_history_window_ms(expr: &QueryExpr, scrape_interval_ms: u64) -> Result { fn duration_ms(duration: std::time::Duration) -> Result { - if duration.subsec_nanos() != 0 { + if !duration.subsec_nanos().is_multiple_of(1_000_000) { return Err(CompileError::Snapshot( - "PromQL ranges must be a whole number of seconds in the backend-local profile" - .into(), + "PromQL ranges must have millisecond precision".into(), )); } u64::try_from(duration.as_millis()) @@ -2655,17 +3267,10 @@ fn query_history_window_ms(expr: &QueryExpr, scrape_interval_ms: u64) -> Result< duration_ms(*range)?, visit(child, scrape_interval_ms, true)?, ), - QueryExpr::TimeShift { shift, child } => { - if !shift.offset_ms.unsigned_abs().is_multiple_of(1_000) { - return Err(CompileError::Snapshot( - "PromQL offsets must be a whole number of seconds in the backend-local profile".into(), - )); - } - add( - shift.offset_ms.max(0) as u64, - visit(child, scrape_interval_ms, in_range)?, - ) - } + QueryExpr::TimeShift { shift, child } => add( + shift.offset_ms.max(0) as u64, + visit(child, scrape_interval_ms, in_range)?, + ), QueryExpr::PromqlScalarBridge(child) | QueryExpr::PromqlVectorFromScalar(child) | QueryExpr::PromqlScalarFromVector(child) @@ -2811,7 +3416,7 @@ pub(super) fn validate_window_implementations( && evidence.cpu_cost >= 0.0 && evidence.weighted_cost.is_finite() && evidence.weighted_cost >= 0.0 - && candidate.window_secs == query.query_lookback_seconds + && candidate.window_secs.saturating_mul(1_000) == query.query_lookback_ms && candidate .layout .validate(candidate.window_secs, candidate.slide_secs) @@ -2874,8 +3479,8 @@ fn retained_state_bytes(materialization: &asap_types::PrecomputeMaterialization) ) } -pub(super) fn estimated_state_bytes( - aggregation: &asap_types::AggregationType, +pub(crate) fn estimated_state_bytes( + aggregation_type: &asap_types::AggregationType, parameters: &HashMap, ) -> u128 { use asap_types::AggregationType as A; @@ -2886,7 +3491,7 @@ pub(super) fn estimated_state_bytes( .find_map(|name| parameters.get(*name).and_then(Value::as_u64)) .unwrap_or(fallback) as u128 }; - match aggregation { + match aggregation_type { A::CountMinSketch | A::CountSketch => { parameter(&["width", "w", "col_num", "col"], 1) * parameter(&["depth", "d", "row_num", "row"], 1) @@ -2918,7 +3523,7 @@ pub(super) fn estimated_state_bytes( } } -fn retained_partition_count( +pub(crate) fn retained_partition_count( materialization: &asap_types::PrecomputeMaterialization, input_cardinality: Option, ) -> u128 { @@ -2933,6 +3538,11 @@ fn retained_partition_count( A::Increase | A::Rate | A::Min | A::Max ) || !materialization.grouping_labels.names().is_empty() + || (materialization.derived_input.is_some() + && matches!( + materialization.aggregation_type, + A::CountMinSketchWithHeap | A::CountSketchWithHeap | A::Sum + )) { u128::from(input_cardinality.unwrap_or(1).max(1)) } else { @@ -3023,8 +3633,8 @@ fn select_lifecycle( raw_materialization_input_contract(node) .ok() .and_then(|(_, window, _)| window) - .unwrap_or(query.query_lookback_seconds) - .saturating_mul(1_000), + .map(|seconds| seconds.saturating_mul(1_000)) + .unwrap_or(query.query_lookback_ms), )), as_of: None, }, @@ -3113,12 +3723,12 @@ fn select_lifecycle( lifecycle_cost: plan.deployments[0] .alternatives .iter() - .find(|alternative| { - alternative.rejection.is_none() - && alternative.summary_maintenance_lifecycle + .find(|candidate| { + candidate.rejection.is_none() + && candidate.summary_maintenance_lifecycle == guarantee.summary_maintenance_lifecycle }) - .and_then(|alternative| alternative.total_cost) + .and_then(|candidate| candidate.total_cost) .ok_or_else(|| CompileError::Lifecycle { query_id: query.query_id.clone(), reason: "missing selected lifecycle cost".into(), @@ -3251,7 +3861,7 @@ fn immutable_materialization_sources(node: &SummaryNode) -> Option Option) -> Result<(), String> { Ok(()) } -fn reject_uncertified_readouts(query_id: &str, root: &Rc) -> Result<(), CompileError> { +fn reject_uncertified_readouts( + query_id: &str, + root: &Rc, + accuracy: &AccuracyTarget, +) -> Result<(), CompileError> { let dag = planner_types::post_asap::compile_executable_dag(root).map_err(|error| { CompileError::Query { query_id: query_id.into(), @@ -3361,6 +4006,25 @@ fn reject_uncertified_readouts(query_id: &str, root: &Rc) -> Result }); } } + // Leaf guarantees do not certify a composition (for example, division of + // two approximate quantiles). Admission checks the complete query result. + if dag.nodes.iter().any(|node| { + matches!( + node.payload, + planner_types::post_asap::ExecutableOperatorPayload::SummaryEstimate { .. } + ) + }) && root.guarantee.as_ref().is_none_or(|guarantee| { + !asap_aware_mapping::accuracy::AccuracyModel::satisfies( + &asap_aware_mapping::DefaultAccuracyModel, + guarantee, + accuracy, + ) + }) { + return Err(CompileError::Query { + query_id: query_id.into(), + reason: "selected query result has no certified accuracy guarantee satisfying the requested accuracy".into(), + }); + } Ok(()) } @@ -3374,7 +4038,9 @@ fn physical_aggregation( aggregation_id, metric_name: selected.metric.clone(), family: selected.family.clone(), - window_secs: selected.window_secs.unwrap_or(query.query_lookback_seconds), + window_secs: selected + .window_secs + .unwrap_or(query.query_lookback_ms.div_ceil(1_000)), spatial_filter: selected.spatial_filter.clone(), grouping: selected .group_by @@ -3412,6 +4078,22 @@ fn scoped_materialization( let SummaryExpr::SummaryAgg { reduction, .. } = &node.expr else { anyhow::bail!("materialization lacks SummaryAgg partition contract"); }; + if immutable_materialization_sources(node).is_some_and(|sources| { + sources.iter().any(|source| { + matches!( + &source.expr, + SummaryExpr::SummaryAgg { + family: SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Rate, + _ + ), + .. + } + ) + }) + }) { + config.grouping_labels = asap_types::KeyByLabelNames { labels: Vec::new() }.into(); + } config.partitioning = Some(match reduction { planner_types::pre_asap::Reduction::PerEntity => { asap_types::sds::PopulationPartitioning::PerEntity @@ -3564,6 +4246,7 @@ fn collect_selected_materializations( node: &Rc, readout: Option<&SketchQuery>, composable: bool, + native_maintenance: bool, inherited_grouping: Option>, selected: &mut Vec, ) -> Result<(), String> { @@ -3597,9 +4280,26 @@ fn collect_selected_materializations( } else { None }; - if let Some(source) = immutable_materialization_sources(node) { + // A nested legacy Sum is a query reduction unless Planner supplied a + // complete native maintenance graph for this selected root. + let immutable_sources = if !native_maintenance + && matches!( + &node.expr, + SummaryExpr::SummaryAgg { + family: SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Sum, + _ + ), + .. + } + ) { + None + } else { + immutable_materialization_sources(node) + }; + if let Some(source) = &immutable_sources { for source in source { - walk(&source, None, composable, None, selected)?; + walk(source, None, composable, native_maintenance, None, selected)?; } } match &node.expr { @@ -3610,24 +4310,73 @@ fn collect_selected_materializations( pruning: Some(_), .. } => { - walk(candidates, readout, composable, grouping.clone(), selected)?; + walk( + candidates, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; // Explicit external authoritative values do not need duplicate local state. if !composable { - walk(values, readout, composable, grouping.clone(), selected)?; + walk( + values, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; } } SummaryExpr::ValueOperation { child, .. } => { - walk(child, readout, composable, grouping.clone(), selected)?; + walk( + child, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; } SummaryExpr::RelationalJoin { left, right, .. } => { - walk(left, readout, composable, grouping.clone(), selected)?; - walk(right, readout, composable, grouping.clone(), selected)?; + walk( + left, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; + walk( + right, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; } SummaryExpr::BinaryOp { lhs, rhs, .. } if composable || crate::query_plan::exact_value_executable(node) => { - walk(lhs, readout, composable, grouping.clone(), selected)?; - walk(rhs, readout, composable, grouping.clone(), selected)?; + walk( + lhs, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; + walk( + rhs, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; } SummaryExpr::SummaryAgg { child, @@ -3635,15 +4384,23 @@ fn collect_selected_materializations( SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _), .. } if !matches!(child.expr, SummaryExpr::KeepPreAsap(_)) + && immutable_sources.is_none() && ((composable && crate::query_plan::exact_accumulator_value_source(child).is_some()) || crate::query_plan::exact_value_executable(node)) => { - walk(child, readout, composable, grouping.clone(), selected)?; + walk( + child, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; } SummaryExpr::SummaryAgg { child, .. } if !matches!(child.expr, SummaryExpr::KeepPreAsap(_)) - && immutable_materialization_sources(node).is_none() => {} + && immutable_sources.is_none() => {} SummaryExpr::SummaryAgg { family: SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Count, _), @@ -3656,12 +4413,20 @@ fn collect_selected_materializations( summary_input, Some(query), composable, + native_maintenance, grouping.clone(), selected, )?, SummaryExpr::SummaryMerge { children, .. } => { for child in children { - walk(child, readout, composable, grouping.clone(), selected)?; + walk( + child, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; } } SummaryExpr::SummaryAgg { @@ -3758,7 +4523,18 @@ fn collect_selected_materializations( } let mut selected = Vec::new(); - walk(node, None, composable, None, &mut selected)?; + let physical_source = + asap_physical_operators::physical_planner::promql_rows::compile_rate_ranking(node) + .ok() + .map(|(source, _)| source); + walk( + physical_source.as_ref().unwrap_or(node), + None, + composable, + asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(node).is_ok(), + None, + &mut selected, + )?; if composable { selected .retain(|state| !has_unsafe_raw_entity_leaf(node, &[Rc::clone(&state.node)], false)); @@ -3769,7 +4545,7 @@ fn collect_selected_materializations( Ok(selected) } -pub(super) fn sketch_params_json(params: &planner_types::post_asap::SketchParams) -> Value { +pub(crate) fn sketch_params_json(params: &planner_types::post_asap::SketchParams) -> Value { use planner_types::post_asap::SketchParams as P; match params { P::UnivMon { @@ -3868,6 +4644,7 @@ pub(crate) mod tests { "quantile by (job) (0.99, a)", "topk by (job) (1, a)", "topk by (job) (5, a)", + "count by (job) (a)", ]; snapshot.query_workload.repeating_queries = Some( queries @@ -4435,9 +5212,10 @@ pub(crate) mod tests { ); } - // Capability normalization precedes candidate enumeration, avoiding duplicate exact quotes. + // A larger computation inventory must retain both maintained and native + // execution; its cardinality is not the correctness contract. #[test] - fn counter_only_snapshot_has_distinct_local_and_native_cost_alternatives() { + fn counter_only_snapshot_has_distinct_local_and_native_cost_candidates() { let mut snapshot: BackendLocalPlanningInput = serde_json::from_str(include_str!( "../../../docs/examples/asapquery-planning-snapshot.json" )) @@ -4445,13 +5223,28 @@ pub(crate) mod tests { let entry = &mut snapshot.query_workload.repeating_queries.as_mut().unwrap()[0]; entry.query = Query("rate(m[1m])".into()); entry.requirements.accuracy = AccuracyRequirement::Explicit(AccuracyTarget::Exact); - let (request, _) = snapshot.into_physical_compilation_request().unwrap(); - assert_eq!( + let (request, environment) = snapshot.into_physical_compilation_request().unwrap(); + let candidates = super::super::workload_cost::enumerate_exact_and_materialized_candidates(request) - .unwrap() - .len(), - 3 + .unwrap(); + assert_eq!( + candidates + .iter() + .filter(|candidate| !candidate.allow_mixed_summary_and_exact_execution) + .count(), + 1 ); + let plans = candidates + .into_iter() + .map(|candidate| DeploymentPlanCompiler.compile_promql(candidate, environment.clone())) + .collect::, _>>() + .unwrap(); + assert!(plans + .iter() + .any(|plan| !plan.precompute_plan.materializations.is_empty())); + assert!(plans + .iter() + .any(|plan| plan.precompute_plan.materializations.is_empty())); } // Count and value rankings must configure different state update contracts. @@ -4730,9 +5523,11 @@ pub(crate) mod tests { evidence_by_query.insert(query_id.to_string(), evidence); } Ok(PhysicalCompilationRequest { - planner_selection_trace: Vec::new(), + planner_candidate_forests: Vec::new(), + planner_selection_trace: Default::default(), canonical_roots: Vec::new(), allow_mixed_summary_and_exact_execution: false, + require_backend_local_execution: false, enabled_materialization_keys: None, query_workload: None, data_workload: None, @@ -4741,7 +5536,7 @@ pub(crate) mod tests { query_string: promql.into(), selected_plan_root: post_asap, legacy_query_source: Source::TimeSeries { metric: "m".into() }, - query_lookback_seconds: 60, + query_lookback_ms: 60_000, group_by_labels: vec![], accuracy_target: accuracy, summary_lifecycle_inputs: lifecycle, @@ -4994,6 +5789,183 @@ pub(crate) mod tests { assert!(result.unwrap().precompute_plan.materializations.is_empty()); } + /// Accuracy facts must belong to the same query, workload, snapshot and + /// evidence window before they enter Planner. + #[test] + fn scoped_accuracy_evidence_rejects_wrong_or_stale_facts() { + let snapshot: BackendLocalPlanningInput = serde_json::from_str(include_str!( + "../../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let evidence = ScopedAccuracyEvidence { + query_string: "quantile_over_time(0.9,m[1m])".into(), + data_snapshot_id: "snapshot-a".into(), + data_workload: snapshot.data_workload.clone(), + source: "enforced-source-contract".into(), + observed_at_unix_ms: 9_000, + valid_for_ms: 2_000, + quantile_operand_domains: vec![], + values_non_negative: Some(true), + input_row_count: Some(10), + hydra_shared_grid_collision_bound: None, + hydra_shared_grid_failure_probability: None, + topk_max_distinct_items: None, + topk_selected_lower_bound: None, + topk_excluded_upper_bound: None, + topk_interval_failure_probability: None, + }; + let validate = |evidence: &ScopedAccuracyEvidence, query: &str, snapshot_id: &str, now| { + evidence.validate( + "q", + query, + &snapshot.data_workload, + Some(snapshot_id), + now, + 2_000, + ) + }; + assert!(validate(&evidence, &evidence.query_string, "snapshot-a", 10_000).is_ok()); + assert!(validate(&evidence, "another query", "snapshot-a", 10_000).is_err()); + assert!(validate(&evidence, &evidence.query_string, "snapshot-b", 10_000).is_err()); + assert!(validate(&evidence, &evidence.query_string, "snapshot-a", 12_000).is_err()); + let mut invalid = evidence.clone(); + invalid.hydra_shared_grid_failure_probability = Some(1.5); + assert!(validate(&invalid, &invalid.query_string, "snapshot-a", 10_000).is_err()); + let mut partial_topk = evidence.clone(); + partial_topk.topk_selected_lower_bound = Some(10.0); + assert!(validate( + &partial_topk, + &partial_topk.query_string, + "snapshot-a", + 10_000 + ) + .is_err()); + let mut valid_topk = partial_topk; + valid_topk.topk_excluded_upper_bound = Some(8.0); + valid_topk.topk_interval_failure_probability = Some(0.01); + assert!(validate(&valid_topk, &valid_topk.query_string, "snapshot-a", 10_000).is_ok()); + let stats = QueryEvidence { + topk: None, + scoped: Some(&valid_topk), + now_ms: 10_000, + } + .propagation_stats( + &CompositionOperator::TopKSelection, + &SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Count, + planner_types::post_asap::ExactParams::Count, + ), + None, + ); + assert_eq!(stats.topk_selected_lower_bound, Some(10.0)); + assert_eq!(stats.topk_excluded_upper_bound, Some(8.0)); + } + + /// Quantile domain proof applies only to the operand named by its AST, + /// even when both operands read the same metric. + #[test] + fn scoped_quantile_domain_matches_one_exact_operand() { + let snapshot: BackendLocalPlanningInput = serde_json::from_str(include_str!( + "../../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let accuracy = AccuracyTarget::EpsilonDelta { + epsilon: 0.05, + delta: 0.01, + }; + let root = crate::query_parser::parse_query_expr_canonical( + "quantile_over_time(0.9,m[5m]) / quantile_over_time(0.5,m[5m])", + accuracy.clone(), + ) + .unwrap(); + let QueryExpr::BinaryOp { lhs, rhs, .. } = &root else { + panic!("expected ratio expression") + }; + let scoped = ScopedAccuracyEvidence { + query_string: "quantile_over_time(0.9,m[5m]) / quantile_over_time(0.5,m[5m])".into(), + data_snapshot_id: "snapshot-a".into(), + data_workload: snapshot.data_workload, + source: "enforced-source-contract".into(), + observed_at_unix_ms: 9_000, + valid_for_ms: 2_000, + quantile_operand_domains: vec![QuantileOperandDomainEvidence { + operand: serde_json::to_value(lhs.as_ref()).unwrap(), + lower: 1.0, + upper: 100.0, + max_samples: 10_000, + contract: "source-schema-v1".into(), + }], + values_non_negative: None, + input_row_count: None, + hydra_shared_grid_collision_bound: None, + hydra_shared_grid_failure_probability: None, + topk_max_distinct_items: None, + topk_selected_lower_bound: None, + topk_excluded_upper_bound: None, + topk_interval_failure_probability: None, + }; + let provider = QueryEvidence { + topk: None, + scoped: Some(&scoped), + now_ms: 10_000, + }; + assert!(provider.quantile_input_domain(lhs).is_some()); + assert!(provider.quantile_input_domain(rhs).is_none()); + let inspect = |provider: &dyn AccuracyEvidenceProvider| { + let (_, trace) = + crate::planner_selection::select_workload_with_accuracy_model_and_trace( + vec![(0, Rc::new(root.clone()))], + accuracy.clone(), + &ControlPlaneCostModel::new(accuracy.clone()), + provider, + &DefaultAccuracyModel, + ) + .unwrap(); + trace + }; + let unknown = inspect(&provider); + assert!(unknown["groups"].as_array().unwrap().iter().any(|group| { + group["candidates"] + .as_array() + .unwrap() + .iter() + .any(|candidate| { + candidate["accuracy_status"] == "unknown" && candidate["selected"] == false + }) + })); + let mut complete = scoped.clone(); + complete + .quantile_operand_domains + .push(QuantileOperandDomainEvidence { + operand: serde_json::to_value(rhs.as_ref()).unwrap(), + lower: 1.0, + upper: 100.0, + max_samples: 10_000, + contract: "source-schema-v1".into(), + }); + let complete_provider = QueryEvidence { + topk: None, + scoped: Some(&complete), + now_ms: 10_000, + }; + assert!(complete_provider.quantile_input_domain(rhs).is_some()); + let complete_trace = inspect(&complete_provider); + assert!(complete_trace["groups"] + .as_array() + .unwrap() + .iter() + .any(|group| { + group["candidates"] + .as_array() + .unwrap() + .iter() + .any(|candidate| { + candidate["accuracy_status"] == "known" + && candidate["replacement_kind"] == "summary" + }) + })); + } + #[test] fn snapshot_metricsql_entry_uses_the_shared_serving_language_contract() { let snapshot: BackendLocalPlanningInput = serde_json::from_str(include_str!( @@ -5957,49 +6929,26 @@ pub(crate) mod tests { interval: RepetitionInterval(45_000), evaluation_phase: planner_types::workload::TimestampMs(0), }; - let mut right = snapshot.clone(); - right.query_workload.repeating_queries.as_mut().unwrap()[0].query = - Query("sum_over_time(n[1m])".into()); - let (mut request, env) = snapshot.into_physical_compilation_request().unwrap(); - let (right, _) = right.into_physical_compilation_request().unwrap(); - let left = request.queries[0].selected_plan_root.clone(); - let right = right.queries[0].selected_plan_root.clone(); - request.queries[0].selected_plan_root = Rc::new(SummaryNode { - expr: SummaryExpr::BinaryOp { - timing: planner_types::post_asap::ExecutionTiming::QueryTime, - lhs: left.clone(), - rhs: right, - operator: planner_types::post_asap::BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: planner_types::pre_asap::BinaryOpKind::Arithmetic( - planner_types::pre_asap::ArithmeticOpKind::Add, - ), - vector_match: None, - }, - }, - schema: left.schema.clone(), - guarantee: None, - }); - let text = "quantile(0.9, sum_over_time(m[1m])) + sum_over_time(n[1m])"; - request.queries[0].query_string = text.into(); - request + // Keep both outputs independent: composing a quantile with a sum would + // require an additional accuracy proof unrelated to cadence selection. + let mut raw_entry = entry.clone(); + raw_entry.query = Query("sum_over_time(n[1m])".into()); + snapshot .query_workload - .as_mut() - .unwrap() .repeating_queries .as_mut() - .unwrap()[0] - .query = Query(text.into()); - prepare_window_implementations(&mut request.queries[0], &model, env.target, 0).unwrap(); - request.queries[0] - .window_realization_candidates - .retain(|candidate| { + .unwrap() + .push(raw_entry); + let (mut request, env) = snapshot.into_physical_compilation_request().unwrap(); + for query in &mut request.queries { + prepare_window_implementations(query, &model, env.target, 0).unwrap(); + query.window_realization_candidates.retain(|candidate| { matches!( candidate.layout, asap_types::WindowMaterializationLayout::Pane { .. } ) }); + } let plan = DeploymentPlanCompiler.compile_promql(request, env).unwrap(); assert!(plan .precompute_plan @@ -6177,7 +7126,8 @@ pub(crate) mod tests { .unwrap() .0 .queries[0] - .query_lookback_seconds + .query_lookback_ms + / 1_000 } fn set_data_ingestion_interval( @@ -6202,7 +7152,7 @@ pub(crate) mod tests { set_data_ingestion_interval(&mut snapshot, Some(1_000)); let (request, _) = snapshot.into_physical_compilation_request().unwrap(); - assert_eq!(request.queries[0].query_lookback_seconds, 1); + assert_eq!(request.queries[0].query_lookback_ms, 1_000); } // Snapshot lowering must use the environment clock for cadence freshness. @@ -6242,7 +7192,7 @@ pub(crate) mod tests { request.data_workload.unwrap().data_ingestion_interval.value, Some(DurationMs(5_000)) ); - assert_eq!(request.queries[0].query_lookback_seconds, 5); + assert_eq!(request.queries[0].query_lookback_ms, 5_000); } // An explicitly invalid interval must not be replaced by the migration value. @@ -6277,31 +7227,50 @@ pub(crate) mod tests { assert_eq!(derived_query_window_secs("sum_over_time(a[1s])"), 1); } - // The seconds-based backend must fail explicitly instead of shrinking history. + /// Fractional ranges, subqueries and offsets preserve their exact history. #[test] - fn derived_history_rejects_fractional_seconds() { - for query in [ - "sum_over_time(a[1500ms])", - "sum_over_time(a[500ms])", - "sum_over_time(a[1500ms] offset 500ms)", - "sum_over_time(a[1s]) + sum(sum_over_time(b[500ms]))", - "avg_over_time((sum(a))[1500ms:])", - "sum(a offset 500ms)", + fn derived_history_preserves_milliseconds() { + for (query, expected) in [ + ("sum_over_time(a[1500ms])", 1500), + ("sum_over_time(a[500ms])", 500), + ("sum_over_time(a[1500ms] offset 500ms)", 2000), + ("sum_over_time(a[1s]) + sum(sum_over_time(b[500ms]))", 1000), + ("avg_over_time((sum(a))[1500ms:])", 6500), + ("sum(a offset 500ms)", 5500), ] { let mut snapshot = planning_snapshot(); snapshot.query_workload.repeating_queries.as_mut().unwrap()[0].query = Query(query.into()); - let error = snapshot - .into_physical_compilation_request() - .expect_err(query) - .to_string(); - assert!( - error.contains("whole number of seconds"), - "{query}: {error}" - ); + let (request, _) = snapshot.into_physical_compilation_request().expect(query); + assert_eq!(request.queries[0].query_lookback_ms, expected, "{query}"); } } + /// A real 100 ms source stays at 100 ms through lowering and query publication. + #[test] + fn hundred_millisecond_source_compiles_without_rounding() { + let mut snapshot = planning_snapshot(); + snapshot.query_workload.repeating_queries.as_mut().unwrap()[0].query = + Query("sum(data)".into()); + snapshot.physical_inputs.scrape_interval_ms = 100; + set_data_ingestion_interval(&mut snapshot, Some(100)); + let (request, environment) = snapshot.into_physical_compilation_request().unwrap(); + assert_eq!(request.queries[0].query_lookback_ms, 100); + let plan = DeploymentPlanCompiler + .compile_promql(request, environment) + .unwrap(); + assert_eq!( + plan.query_plan + .entries + .values() + .next() + .unwrap() + .instant + .lookback_ms, + 100 + ); + } + #[test] fn snapshot_rejects_manual_lookback() { let mut wire = serde_json::to_value(planning_snapshot()).unwrap(); @@ -6423,9 +7392,9 @@ pub(crate) mod tests { } // A tumbling shape pairs only with `Pane` in the validator's - // framework/layout table, so there is no alternative to price against it. + // framework/layout table, so there is no candidate to price against it. #[test] - fn tumbling_shapes_have_no_layout_alternative_to_rank() { + fn tumbling_shapes_have_no_layout_candidate_to_rank() { let cost = planning_snapshot().physical_inputs.window_cost_model.cost; let expr = crate::query_parser::parse_query_expr_canonical( "quantile_over_time(0.5, data[5m])", @@ -6460,7 +7429,7 @@ pub(crate) mod tests { (60_000, 90_000), ] { let mut query = request.queries[0].clone(); - query.query_lookback_seconds = lookback_ms / 1_000; + query.query_lookback_ms = lookback_ms; let expr = crate::query_parser::parse_query_expr_canonical( &format!("quantile_over_time(0.5, data[{}s])", lookback_ms / 1_000), AccuracyTarget::Exact, @@ -6860,7 +7829,8 @@ pub(crate) mod tests { request = super::super::workload_cost::enumerate_exact_and_materialized_candidates(request) .unwrap() - .pop() + .into_iter() + .find(|candidate| !candidate.allow_mixed_summary_and_exact_execution) .unwrap(); let bundle = DeploymentPlanCompiler .compile_promql(request, environment) @@ -7331,6 +8301,7 @@ pub(crate) mod tests { query_workload, data_workload, physical_inputs: BackendLocalPhysicalInputs { + require_backend_local_execution: false, lifecycle_costs: template.summary_lifecycle_inputs.costs, evidence_observed_at_unix_ms: 9_500, evidence_valid_for_ms: 60_000, @@ -7344,6 +8315,8 @@ pub(crate) mod tests { query_retention_margin_ms: 0, retained_summary_memory_budget_bytes: DEFAULT_RETAINED_SUMMARY_MEMORY_BUDGET_BYTES, topk_evidence: HashMap::new(), + data_snapshot_id: None, + accuracy_evidence: HashMap::new(), exact_composition_costs: HashMap::new(), erp: None, }, @@ -7466,7 +8439,7 @@ pub(crate) mod tests { } #[test] - fn checked_in_per_entity_snapshot_preserves_native_alternative() { + fn checked_in_per_entity_snapshot_preserves_native_candidate() { let source = include_str!("../../../docs/examples/asapquery-planning-snapshot.json"); let snapshot: BackendLocalPlanningInput = serde_json::from_str(source).expect("strict canonical workload fixture"); @@ -7533,8 +8506,9 @@ pub(crate) mod tests { let native = crate::physical::workload_cost::enumerate_exact_and_materialized_candidates(request) .unwrap() - .pop() - .unwrap(); + .into_iter() + .find(|candidate| !candidate.allow_mixed_summary_and_exact_execution) + .expect("native candidate retained"); let plan = DeploymentPlanCompiler .compile_promql(native, environment) .expect("native demo compiles"); @@ -7780,9 +8754,9 @@ pub(crate) mod tests { let mut second = request("q40", "sum(sum_over_time(m[40s]))") .queries .remove(0); - workload.queries[0].query_lookback_seconds = 20; + workload.queries[0].query_lookback_ms = 20_000; workload.queries[0].window_realization_candidates[0].window_secs = 20; - second.query_lookback_seconds = 40; + second.query_lookback_ms = 40_000; second.summary_lifecycle_inputs.evaluation_interval_ms = 20_000; second.window_realization_candidates[0].window_secs = 40; workload.queries.push(second); diff --git a/control_plane/src/physical/compiler/windows.rs b/control_plane/src/physical/compiler/windows.rs index 072ee7c2e..fcf2987ae 100644 --- a/control_plane/src/physical/compiler/windows.rs +++ b/control_plane/src/physical/compiler/windows.rs @@ -28,6 +28,10 @@ pub(super) fn is_full_cohort(candidate: &WindowRealizationCandidate) -> bool { } } +pub(super) fn is_complete_window(candidate: &WindowRealizationCandidate) -> bool { + matches!(candidate.layout, WindowMaterializationLayout::FullWindow) || is_full_cohort(candidate) +} + pub(super) fn cohort_nodes(states: &[SelectedMaterialization]) -> BTreeSet { let mut nodes = BTreeSet::new(); for state in states { @@ -153,11 +157,14 @@ pub fn prepare_window_implementations( reason, })?; let cohorts = cohort_nodes(&states); + let native_cohort = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&query.selected_plan_root).is_ok(); let requirements = states .iter() .map(|state| { ( - state.window_secs.unwrap_or(query.query_lookback_seconds), + state + .window_secs + .unwrap_or(query.query_lookback_ms.div_ceil(1_000)), cohorts.contains(&(Rc::as_ptr(&state.node) as usize)), ) }) @@ -169,7 +176,7 @@ pub fn prepare_window_implementations( &model, &query.summary_lifecycle_inputs, window, - cohort, + cohort && !native_cohort, target, staleness_margin_ms, ) @@ -194,7 +201,11 @@ pub fn prepare_window_implementations( } let applicable = requirements.iter().any(|&(window, cohort)| { quote.window_secs == window - && if cohort { + && if cohort && native_cohort { + is_complete_window(quote) + && quote.slide_secs.saturating_mul(1000) + == u64::from(query.summary_lifecycle_inputs.evaluation_interval_ms) + } else if cohort { is_full_cohort(quote) } else { quote.slide_secs.saturating_mul(1_000) diff --git a/control_plane/src/physical/executable_binding.rs b/control_plane/src/physical/executable_binding.rs index 93056ad3a..9c09bf26b 100644 --- a/control_plane/src/physical/executable_binding.rs +++ b/control_plane/src/physical/executable_binding.rs @@ -55,6 +55,7 @@ pub fn install_selected_dag( } precompute_sinks.sort(); let installed = InstalledPostAsapDag { + native_programs: std::collections::BTreeMap::new(), document: OwnedPostAsapDag::from_executable(query_id, dag)?, binding: BackendExecutableBinding { nodes, diff --git a/control_plane/src/physical/maintained_population.rs b/control_plane/src/physical/maintained_population.rs index b5b7664e6..7844c5665 100644 --- a/control_plane/src/physical/maintained_population.rs +++ b/control_plane/src/physical/maintained_population.rs @@ -1,5 +1,7 @@ //! Lower typed population operators according to executor membership capabilities. -use super::compiler::{CompileError, PhysicalCompilationRequest, QueryCompilationInput}; +#[cfg(test)] +use super::compiler::QueryCompilationInput; +use super::compiler::{CompileError, PhysicalCompilationRequest}; use asap_types::query_plan::{ current_series::{SeriesPopulation, SeriesReadout}, residual::{Grouping, LabelMatch, LabelMatcher, ResidualQueryOperator}, @@ -8,23 +10,50 @@ use planner_types::post_asap::{ maintained_population::*, SummaryExpr, SummaryNode, ValueOperation, }; -fn selected(node: &SummaryNode) -> Option<(&MaintainedPopulation, &PopulationReadout)> { - let SummaryExpr::ValueOperation { +fn selected(node: &SummaryNode) -> Option<(MaintainedPopulation, PopulationReadout)> { + if let SummaryExpr::ValueOperation { child, operation: ValueOperation::ReadPopulation { readout }, .. } = &node.expr - else { - return None; - }; + { + if let SummaryExpr::ValueOperation { + operation: ValueOperation::MaintainPopulation { population }, + .. + } = &child.expr + { + return Some((population.clone(), readout.clone())); + } + } + // The source remains a maintained population when Planner places a heap, + // projection and ranking above it. Backend binds that source only. let SummaryExpr::ValueOperation { - operation: ValueOperation::MaintainPopulation { population }, + operation: ValueOperation::Limit { n, offset: 0, .. }, .. - } = &child.expr + } = &node.expr else { return None; }; - Some((population, readout)) + let dag = + planner_types::post_asap::compile_executable_dag(&std::rc::Rc::new(node.clone())).ok()?; + let populations = dag + .nodes + .iter() + .filter_map(|node| match &node.payload { + planner_types::post_asap::ExecutableOperatorPayload::Value { + operation: ValueOperation::MaintainPopulation { population }, + } => Some(population), + _ => None, + }) + .collect::>(); + let [population] = populations.as_slice() else { + return None; + }; + asap_physical_operators::physical_planner::promql_rows::compile_current_series_readout( + &std::rc::Rc::new(node.clone()), + ) + .ok()?; + Some(((*population).clone(), PopulationReadout::TopK { k: *n })) } pub(super) fn supported_node(node: &SummaryNode) -> bool { @@ -40,22 +69,21 @@ pub(super) fn supported(request: &PhysicalCompilationRequest) -> bool { .any(|q| supported_node(&q.selected_plan_root)) } -pub(super) fn operator( +/// Resolve population bindings once per candidate. Scanning every workload +/// root for each consumer recompiles the same native ranking graphs quadratically. +pub(super) fn operators( request: &PhysicalCompilationRequest, - query: &QueryCompilationInput, -) -> Result, CompileError> { - let Some((spec, readout)) = selected(&query.selected_plan_root) else { - return Ok(None); - }; - let PopulationInput::CurrentSeries(input) = &spec.input else { - return Err(CompileError::Query { query_id: query.query_id.clone(), reason: "maintained table-row populations require a row-update executor; remote-write current-series state is incompatible".into() }); - }; - let populations: std::collections::BTreeSet<_> = request +) -> Result>, CompileError> { + let selected = request .queries .iter() - .filter_map(|q| { - selected(&q.selected_plan_root) - .map(|(p, _)| serde_json::to_string(p).expect("typed population serializes")) + .map(|query| selected(&query.selected_plan_root)) + .collect::>(); + let populations: std::collections::BTreeSet<_> = selected + .iter() + .flatten() + .map(|(population, _)| { + serde_json::to_string(population).expect("typed population serializes") }) .collect(); let max_bytes = request @@ -63,6 +91,11 @@ pub(super) fn operator( .unwrap_or(64 * 1024 * 1024) .min(1_073_741_824) / populations.len().max(1) as u64; + selected.into_iter().zip(&request.queries).map(|(selected, query)| { + let Some((spec, readout)) = selected else { return Ok(None); }; + let PopulationInput::CurrentSeries(input) = &spec.input else { + return Err(CompileError::Query { query_id: query.query_id.clone(), reason: "maintained table-row populations require a row-update executor; remote-write current-series state is incompatible".into() }); + }; let population = SeriesPopulation { metric: input.metric.clone(), matchers: input @@ -97,7 +130,7 @@ pub(super) fn operator( .min(input.lookback_ms), }; population.validate()?; - let readout = match readout { + let readout = match &readout { PopulationReadout::Quantile { q } => SeriesReadout::Quantile { q: *q }, PopulationReadout::TopK { k } => SeriesReadout::TopK { k: *k as u64 }, PopulationReadout::Sum => SeriesReadout::Sum, @@ -108,4 +141,67 @@ pub(super) fn operator( population, readout, })) + }).collect() +} + +#[cfg(test)] +pub(super) fn operator( + request: &PhysicalCompilationRequest, + query: &QueryCompilationInput, +) -> Result, CompileError> { + let index = request + .queries + .iter() + .position(|q| q.query_id == query.query_id) + .expect("query belongs to the compilation request"); + Ok(operators(request)?.remove(index)) +} + +/// The maintained population is a deployment source; ranking is compiled by +/// Planner before this candidate is priced or installed. +pub(super) fn install_native_topk( + entry: &mut asap_types::query_plan::QueryPlanEntry, + selected: &std::rc::Rc, +) -> Result<(), CompileError> { + use asap_types::query_plan::QueryPlanNode; + let Some(QueryPlanNode::Logical { + operator: + ResidualQueryOperator::CurrentSeries { + population, + readout: SeriesReadout::TopK { .. }, + }, + .. + }) = entry.nodes.get(&entry.root) + else { + return Ok(()); + }; + if population.grouping.without { + return Ok(()); + } + let compiled = + asap_physical_operators::physical_planner::promql_rows::compile_current_series_readout( + selected, + ) + .map_err(|error| CompileError::Query { + query_id: entry.query_id.clone(), + reason: error.to_string(), + })?; + let encoded = compiled.encode().map_err(|error| CompileError::Query { + query_id: entry.query_id.clone(), + reason: error.to_string(), + })?; + entry.physical_dag = Some( + serde_json::from_slice(&encoded) + .map_err(|error| CompileError::Snapshot(error.to_string()))?, + ); + let Some(QueryPlanNode::Logical { + operator: ResidualQueryOperator::CurrentSeries { readout, .. }, + .. + }) = entry.nodes.get_mut(&entry.root) + else { + unreachable!() + }; + *readout = SeriesReadout::Snapshot; + entry.recover_population_physical_dag()?; + Ok(()) } diff --git a/control_plane/src/physical/plan_dot.rs b/control_plane/src/physical/plan_dot.rs index e0753c716..eed772a65 100644 --- a/control_plane/src/physical/plan_dot.rs +++ b/control_plane/src/physical/plan_dot.rs @@ -66,6 +66,14 @@ pub fn render(plan: &CompiledPhysicalPlan) -> String { edge.role )); } + for (sink, program) in &installed.native_programs { + render_native( + &mut dot, + &format!("maintenance_{dag_index}_{}", sink.0), + "Planner maintenance operators", + program, + ); + } dot.push_str(" }\n"); } dot.push_str(" }\n"); @@ -104,12 +112,48 @@ pub fn render(plan: &CompiledPhysicalPlan) -> String { )); } } + if let Some(program) = &entry.physical_dag { + render_native( + &mut dot, + &format!("native_query_{query_index}"), + "Planner query operators", + program, + ); + } dot.push_str(" }\n"); } dot.push_str("}\n"); dot } +fn render_native(dot: &mut String, prefix: &str, label: &str, program: &serde_json::Value) { + let Some(nodes) = program["nodes"].as_object() else { + return; + }; + dot.push_str(&format!( + " subgraph cluster_{prefix} {{\n label=\"{}\";\n", + escape(label) + )); + for (id, node) in nodes { + let operator = node.get("Operator"); + let label = operator + .map(|op| op["operator"]["kind"].to_string()) + .unwrap_or_else(|| "Bound physical input".into()); + emit_node( + dot, + &format!("{prefix}_{id}"), + &format!("#{id}\n{label}"), + "", + ); + if let Some(inputs) = operator.and_then(|op| op["inputs"].as_array()) { + for input in inputs { + dot.push_str(&format!(" {prefix}_{input} -> {prefix}_{id};\n")); + } + } + } + dot.push_str(" }\n"); +} + fn materialization_node(id: u64) -> String { format!("materialization_{id:016x}") } @@ -139,6 +183,9 @@ fn escape(value: &str) -> String { fn query_node_label(node: &QueryPlanNode) -> String { match node { + QueryPlanNode::Physical { source_nodes, .. } => { + format!("Planner Physical DAG\nbound sources {source_nodes:?}") + } QueryPlanNode::PhysicalFragment { dag, .. } => { asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) .map(|plan| { diff --git a/control_plane/src/physical/workload_cost.rs b/control_plane/src/physical/workload_cost.rs index 963ebccac..5a0cef586 100644 --- a/control_plane/src/physical/workload_cost.rs +++ b/control_plane/src/physical/workload_cost.rs @@ -38,6 +38,7 @@ pub struct CostComponentDemand { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct WorkloadCostManifest { + pub dataset_identity: planner_types::post_asap::LogicalDatasetIdentity, pub plan_id: u64, pub plan_version: u64, pub planner_revision: String, @@ -108,7 +109,7 @@ pub struct MaterializationSearchCoverage { pub struct CandidatePlanSelectionReport { #[serde(default)] #[serde(rename = "logical_selection")] - pub planner_selection_trace: Vec, + pub planner_selection_trace: std::sync::Arc>, #[serde(default)] pub materialization_search_coverage: Option, pub data_snapshot_id: String, @@ -211,6 +212,28 @@ pub fn manifest( } } } + for installed in plan.precompute_plan.executable_dags.values() { + for (sink, program) in &installed.native_programs { + let Some(asap_types::executable_plan::BackendNodeBinding::Materialization { + stored_output, + }) = installed.binding.node(*sink) + else { + return Err(invalid("native maintenance sink is unbound")); + }; + let config = plan + .precompute_plan + .materializations + .iter() + .find(|m| m.policy_fingerprint() == stored_output.fingerprint()) + .ok_or_else(|| invalid("native maintenance config is absent"))?; + add( + format!("maintenance:{}", stored_output.0), + json!({"physical_program":program,"stored_output":stored_output,"window_ms":config.stored_window_ms(),"interval_ms":config.slide_interval.saturating_mul(1000)}), + "horizon", + 1.0, + ); + } + } for rule in &plan.transmission_plan.rules { add( format!("transport:{}:{}", rule.producer_id, rule.materialization.0), @@ -305,7 +328,7 @@ pub fn manifest( for node_id in entry.topological_order()? { add( format!("query:{}:{}", entry.query_id, node_id.0), - json!({"node": entry.nodes[&node_id], "query": entry.canonical_query, "instant": entry.instant}), + json!({"node": entry.nodes[&node_id], "query": entry.canonical_query, "instant": entry.instant, "physical_dag": entry.physical_dag}), "query_evaluation", evaluations, ); @@ -318,6 +341,12 @@ pub fn manifest( ); } Ok(WorkloadCostManifest { + dataset_identity: plan + .precompute_plan + .ingest + .dataset_identity + .clone() + .ok_or_else(|| CompileError::Snapshot("cost manifest lacks dataset identity".into()))?, plan_id: plan.envelope.plan_id, plan_version: plan.envelope.plan_version, planner_revision: plan.envelope.planner_revision.clone(), @@ -485,7 +514,7 @@ fn candidate_description(candidate: &PhysicalCompilationRequest) -> CandidatePla CandidatePlanEvaluation { candidate_id: complete.then(|| { crate::planner_selection::explain_identity( - "alternative", + "candidate", &( &logical_root_ids, candidate.allow_mixed_summary_and_exact_execution, @@ -554,6 +583,12 @@ fn compile_candidate_for_pricing( }) .collect::>(); placement.sort(); + let native_programs = plan + .query_plan + .entries + .values() + .map(|entry| (&entry.query_id, &entry.physical_dag)) + .collect::>(); description.physical_candidate_id = description .candidate_id @@ -566,6 +601,7 @@ fn compile_candidate_for_pricing( bindings, placement, &plan.precompute_plan.ingest, + native_programs, ), ) }); @@ -637,9 +673,9 @@ fn select_candidates( frontend: super::compiler::QueryFrontend, ) -> Result { evidence.validate(&env)?; - if candidates.is_empty() || candidates.len() > 64 { + if candidates.is_empty() || candidates.len() > 4096 { return Err(invalid( - "candidate inventory must contain 1..=64 candidates", + "candidate inventory must contain 1..=4096 candidates", )); } let candidate_key_sets: BTreeSet<_> = candidates @@ -662,13 +698,7 @@ fn select_candidates( let planner_selection_trace = candidates[0].planner_selection_trace.clone(); let mut comparison_workload = None; let mut candidate_evaluations = Vec::new(); - let mut best_index = 0; - let mut best: Option<( - Cost, - CompiledPhysicalPlan, - WorkloadCostManifest, - BTreeMap, - )> = None; + let mut priced_candidates = Vec::new(); for candidate in candidates { let (plan, manifest, mut description) = match compile_candidate_for_pricing(candidate, env.clone(), frontend) { @@ -695,10 +725,13 @@ fn select_candidates( description.status = CandidateEvaluationStatus::Unselected; description.total_cost = Some(cost.0); candidate_evaluations.push(description); - if best.as_ref().is_none_or(|(previous, ..)| cost < *previous) { - best_index = candidate_evaluations.len() - 1; - best = Some((cost, plan, manifest, components)); - } + priced_candidates.push(Ok(( + cost, + plan, + manifest, + components, + candidate_evaluations.len() - 1, + ))); } Err((status, reason)) => { description.status = status; @@ -707,13 +740,27 @@ fn select_candidates( } } } - let (_, mut plan, selected_manifest, component_costs) = best.ok_or_else(|| { + let selected = asap_physical_operators::physical_planner::select_candidate( + priced_candidates, + |(cost, _, manifest, _, _)| { + Ok(Some( + asap_physical_operators::physical_planner::CandidateCost { + workload_scope: serde_json::to_string(&manifest.workload).map_err(|error| { + asap_physical_operators::Error::Invalid(error.to_string()) + })?, + horizon_seconds: manifest.horizon_seconds, + total_cost: cost.0, + }, + )) + }, + ) + .map_err(|error| { CompileError::Candidates( json!({"status": "all_infeasible", "logical_selection": planner_selection_trace, - "candidates": candidate_evaluations}), + "candidates": candidate_evaluations, "selection_error": error.to_string()}), ) })?; - // Exactly the winner retained by the existing strict-less-than selector. + let (_, mut plan, selected_manifest, component_costs, best_index) = selected.candidate; candidate_evaluations[best_index].status = CandidateEvaluationStatus::Selected; plan.cost_comparison = Some(CandidatePlanSelectionReport { planner_selection_trace, @@ -731,21 +778,55 @@ fn select_candidates( /// The current executor exposes continuously maintained state and the native /// exact backend. Additional Planner-produced forests can use `select_lowest_cost_candidate` directly. pub fn enumerate_exact_and_materialized_candidates( - request: PhysicalCompilationRequest, + mut request: PhysicalCompilationRequest, ) -> Result, CompileError> { - let already_selected = super::maintained_population::supported(&request); - let mut candidates = materialization_candidates(request)?; - if already_selected { - return Ok(candidates); + let forests = std::mem::take(&mut request.planner_candidate_forests); + if forests.is_empty() { + return enumerate_frontier_candidates(request); + } + // Keep the existing deterministic ordering for equal-cost candidates; + // every discovered forest still reaches deployment admission and pricing. + let mut candidates: Vec = Vec::new(); + for forest in std::iter::once(request.queries.clone()).chain(forests) { + let mut candidate = request.clone(); + candidate.queries = forest; + for candidate in enumerate_frontier_candidates(candidate)? { + if !candidates.iter().any(|existing| { + existing.allow_mixed_summary_and_exact_execution + == candidate.allow_mixed_summary_and_exact_execution + && existing.enabled_materialization_keys + == candidate.enabled_materialization_keys + && existing + .queries + .iter() + .zip(&candidate.queries) + .all(|(a, b)| a.selected_plan_root == b.selected_plan_root) + }) { + candidates.push(candidate); + } + if candidates.len() > 4096 { + return Err(invalid( + "deployment candidate inventory exceeds budget; no partial inventory selected", + )); + } + } } + Ok(candidates) +} + +fn enumerate_frontier_candidates( + mut request: PhysicalCompilationRequest, +) -> Result, CompileError> { + request.planner_candidate_forests.clear(); + let mut candidates = materialization_candidates(request)?; let roots: Vec<_> = candidates .last() - .expect("exact alternative") + .expect("exact candidate") .queries .iter() .map(|q| match &q.selected_plan_root.expr { planner_types::post_asap::SummaryExpr::KeepPreAsap(root) => std::rc::Rc::clone(root), - _ => unreachable!("native alternative retains canonical roots"), + _ => unreachable!("native candidate retains canonical roots"), }) .collect(); let strategy = @@ -760,21 +841,29 @@ pub fn enumerate_exact_and_materialized_candidates( .collect(); if maintained_roots.iter().any(Option::is_some) { // Current-series rules are compatible with window summaries in other - // workload roots. Preserve each priced temporal alternative and mask. + // workload roots. Preserve each priced temporal candidate and mask. let maintained: Vec<_> = candidates .iter() - .map(|alternative| { - let mut candidate = alternative.clone(); + .filter_map(|candidate| { + let mut candidate = candidate.clone(); + let mut changed = false; for (query, selected) in candidate.queries.iter_mut().zip(&maintained_roots) { if let Some(selected) = selected { - query.selected_plan_root = std::rc::Rc::clone(selected); + if !super::maintained_population::supported_node(&query.selected_plan_root) + { + query.selected_plan_root = std::rc::Rc::clone(selected); + changed = true; + } } } + if !changed { + return None; + } if maintained_roots.iter().all(Option::is_some) { candidate.allow_mixed_summary_and_exact_execution = false; candidate.enabled_materialization_keys = None; } - candidate + Some(candidate) }) .collect(); for candidate in maintained { @@ -836,7 +925,7 @@ fn materialization_candidates( &query.selected_plan_root, ) { Ok(found) => keys.extend(found), - // A failed local projection must not make the native alternative + // A failed local projection must not make the native candidate // disappear. Compile/select_lowest_cost_candidate retains its concrete unavailability. Err(_) => return Ok(vec![request, exact]), } @@ -868,6 +957,138 @@ mod tests { use super::super::compiler::BackendLocalPlanningInput; use super::*; + /// Deployment pricing must see candidate sketch families, not only an + /// upstream winner chosen before runtime resource evidence is applied. + #[test] + fn planner_quantile_inventory_reaches_backend_before_family_selection() { + let mut input = fixture(); + let queries = input.query_workload.repeating_queries.as_mut().unwrap(); + queries.truncate(1); + queries[0].query = planner_types::workload::Query("quantile_over_time(0.9,m[1m])".into()); + queries[0].requirements.accuracy = planner_types::workload::AccuracyRequirement::Explicit( + crate::types::AccuracyTarget::Epsilon(0.05), + ); + let (request, _) = input.into_physical_compilation_request().unwrap(); + let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + let roots = candidates + .iter() + .flat_map(|c| &c.queries) + .map(|q| format!("{:?}", q.selected_plan_root)) + .collect::>(); + assert!( + roots.iter().any(|r| r.contains("Kll")), + "KLL disappeared before backend pricing" + ); + assert!( + roots.iter().any(|r| r.contains("DDSketch")), + "DDSketch disappeared before backend pricing" + ); + } + + /// A deployment without external execution rejects native candidates before pricing. + #[test] + fn local_only_deployment_rejects_external_candidate_before_pricing() { + let mut value = serde_json::to_value(fixture()).unwrap(); + value["implementation"]["require_backend_local_execution"] = json!(true); + let input: BackendLocalPlanningInput = serde_json::from_value(value).unwrap(); + let (request, environment) = input.clone().into_physical_compilation_request().unwrap(); + let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + let native = candidates + .iter() + .find(|candidate| { + candidate.queries.iter().all(|query| { + matches!( + query.selected_plan_root.expr, + planner_types::post_asap::SummaryExpr::KeepPreAsap(_) + ) + }) + }) + .expect("native candidate remains inspectable") + .clone(); + let error = DeploymentPlanCompiler + .compile_promql(native, environment) + .unwrap_err(); + assert!( + error + .to_string() + .contains("external execution is unavailable"), + "{error}" + ); + let selected = with_unit_quotes(input).compile_promql().unwrap(); + assert!(selected.query_plan.entries.values().all(|entry| entry + .nodes + .values() + .all(|node| !matches!(node, crate::query_plan::QueryPlanNode::ExactFallback { .. })))); + let report = selected.cost_comparison.unwrap(); + assert!(report + .candidate_evaluations + .iter() + .any(|candidate| candidate + .unavailable_reason + .as_ref() + .is_some_and(|reason| reason.contains("external execution is unavailable")) + && candidate.total_cost.is_none())); + } + + /// Selecting one population must not suppress candidates for other roots. + #[test] + fn existing_population_does_not_suppress_other_population_candidates() { + let mut input = fixture(); + let queries = input.query_workload.repeating_queries.as_mut().unwrap(); + let template = queries[0].clone(); + *queries = ["quantile by(job)(0.9,m)", "count by(job)(m)"] + .into_iter() + .map(|q| { + let mut entry = template.clone(); + entry.query = planner_types::workload::Query(q.into()); + entry + }) + .collect(); + let (mut request, _) = input.into_physical_compilation_request().unwrap(); + let strategy = asap_aware_mapping::maintained_population::MaintainedPopulationStrategy::new( + &request.canonical_roots, + ); + request.queries[0].selected_plan_root = + strategy.candidate(&request.canonical_roots[0]).unwrap(); + request.queries[1].selected_plan_root = + crate::planner_selection::keep_pre_asap(&request.canonical_roots[1]).unwrap(); + let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + assert!(candidates + .iter() + .any(|candidate| candidate.queries.iter().all(|query| { + super::super::maintained_population::supported_node(&query.selected_plan_root) + }))); + } + + /// Instant counts select current membership, never accumulated observations. + #[test] + fn local_grouped_count_has_a_bindable_candidate() { + let mut input = fixture(); + input.workload_cost_evidence = None; + input.physical_inputs.require_backend_local_execution = true; + input.data_workload.data_ingestion_interval.value = + Some(planner_types::workload::DurationMs(60_000)); + input.physical_inputs.scrape_interval_ms = 60_000; + let queries = input.query_workload.repeating_queries.as_mut().unwrap(); + queries.truncate(1); + queries[0].query = planner_types::workload::Query("count by(job)(m)".into()); + let plan = with_unit_quotes(input).compile_promql().unwrap(); + assert!(plan + .query_plan + .entries + .values() + .all(|entry| entry.nodes.values().any(|node| matches!( + node, + crate::query_plan::QueryPlanNode::Logical { + operator: crate::query_plan::residual::ResidualQueryOperator::CurrentSeries { + readout: asap_types::query_plan::current_series::SeriesReadout::Count, + .. + }, + .. + } + )))); + } + fn fixture() -> BackendLocalPlanningInput { let mut snapshot: BackendLocalPlanningInput = serde_json::from_str(include_str!( "../../../docs/examples/asapquery-planning-snapshot.json" @@ -952,7 +1173,7 @@ mod tests { let (mut request, env) = fixture().into_physical_compilation_request().unwrap(); request.allow_mixed_summary_and_exact_execution = false; request.queries[0].window_realization_candidates.clear(); - let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + let candidates = enumerate_frontier_candidates(request).unwrap(); let (manifests, explanations) = compile_candidates_for_pricing( candidates, env, @@ -1078,7 +1299,7 @@ mod tests { crate::types::AccuracyTarget::Exact, ); let (request, environment) = snapshot.into_physical_compilation_request().unwrap(); - let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + let candidates = enumerate_frontier_candidates(request).unwrap(); assert_eq!( candidates.len(), 5, @@ -1142,7 +1363,7 @@ mod tests { WorkloadCostEvidence, ) { let (request, env) = fixture().into_physical_compilation_request().unwrap(); - let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + let candidates = enumerate_frontier_candidates(request).unwrap(); let quotes = candidates .iter() .map(|candidate| { @@ -1174,9 +1395,48 @@ mod tests { (candidates, env, evidence) } + /// Quote every bindable candidate at unit cost, leaving admission to decide. + fn with_unit_quotes(mut input: BackendLocalPlanningInput) -> BackendLocalPlanningInput { + let (request, env) = input.clone().into_physical_compilation_request().unwrap(); + let quotes = enumerate_exact_and_materialized_candidates(request) + .unwrap() + .into_iter() + .filter_map(|candidate| { + let plan = DeploymentPlanCompiler + .compile_promql(candidate.clone(), env.clone()) + .ok()?; + let manifest = manifest(&plan, &candidate.queries).unwrap(); + let unit_costs = manifest + .components + .keys() + .map(|id| (id.clone(), 1.0)) + .collect(); + Some(WorkloadQuote { + manifest, + executable: true, + unit_costs, + }) + }) + .collect(); + input.workload_cost_evidence = Some(WorkloadCostEvidence { + backend_revision: crate::physical::compiler::BACKEND_REVISION.into(), + planner_revision: crate::physical::compiler::PLANNER_REVISION.into(), + data_snapshot_id: input + .physical_inputs + .data_snapshot_id + .clone() + .unwrap_or_else(|| "fixture-data-v1".into()), + model_version: "test-only-unit-costs".into(), + observed_at_unix_ms: env.observed_at_unix_ms, + valid_for_ms: env.max_evidence_age_ms, + quotes, + }); + input + } + // Retained local input is priced once per metric, separate from the native service. #[test] - fn counter_materialization_manifest_prices_owned_state_and_distinct_native_alternative() { + fn counter_materialization_manifest_prices_owned_state_and_distinct_native_candidate() { use planner_types::workload::{AccuracyRequirement, Query}; let mut snapshot = fixture(); let entry = &mut snapshot.query_workload.repeating_queries.as_mut().unwrap()[0]; @@ -1231,19 +1491,14 @@ mod tests { ), ("sum_over_time(m[1m]) + count_over_time(m[1m])", vec!["m"]), ] { - let (mut request, env) = fixture().into_physical_compilation_request().unwrap(); - request.queries[0].query_string = query.into(); - request - .query_workload - .as_mut() - .unwrap() - .repeating_queries - .as_mut() - .unwrap()[0] - .query = planner_types::workload::Query(query.into()); + let mut input = fixture(); + input.query_workload.repeating_queries.as_mut().unwrap()[0].query = + planner_types::workload::Query(query.into()); + let (request, env) = input.into_physical_compilation_request().unwrap(); let exact = enumerate_exact_and_materialized_candidates(request) .unwrap() - .pop() + .into_iter() + .find(|candidate| !candidate.allow_mixed_summary_and_exact_execution) .unwrap(); let plan = DeploymentPlanCompiler .compile_promql(exact.clone(), env.clone()) @@ -1359,7 +1614,9 @@ mod tests { assert!(plan.cost_comparison.unwrap().candidate_evaluations[0] .unavailable_reason .is_some()); - evidence.quotes[1].executable = false; + for quote in &mut evidence.quotes[1..] { + quote.executable = false; + } assert!(select_lowest_cost_candidate(candidates.clone(), env.clone(), &evidence).is_err()); let (_, _, mut evidence) = quoted(); evidence @@ -1455,7 +1712,7 @@ mod tests { } #[test] - fn exact_alternative_does_not_require_unused_state_implementation_evidence() { + fn exact_candidate_does_not_require_unused_state_implementation_evidence() { let (candidates, env, evidence) = quoted(); let mut exact = candidates[1].clone(); assert_eq!( diff --git a/control_plane/src/planner_selection.rs b/control_plane/src/planner_selection.rs index 81ead342b..9e73361db 100644 --- a/control_plane/src/planner_selection.rs +++ b/control_plane/src/planner_selection.rs @@ -1,8 +1,8 @@ -//! Deployment-owned selection at the latest ASAPPlanner boundary. +//! Supplies deployment capabilities and cost/accuracy evidence to ASAPPlanner. //! -//! ASAPPlanner enumerates a ranked candidate space and deliberately does not -//! commit to one deployment plan. The backend owns that decision because it -//! also owns placement, runtime capabilities, and the physical wire contract. +//! Planner constructs, evaluates, and selects computation candidates. This +//! adapter registers the supported strategies and retains selection evidence; +//! DeploymentPlanCompiler binds the resulting computation and lifecycle. use std::rc::Rc; @@ -331,6 +331,44 @@ pub fn select_workload_with_accuracy_model_and_trace( Ok((selected, trace)) } +/// Preserve Planner candidates for deployment admission and pricing. This +/// does not select a winner or interpret an absent runtime quote as illegality. +/// Each returned forest has one root; independent roots remain factored rather +/// than materializing the workload's Cartesian product. +pub fn enumerate_workload_candidates( + roots: Vec<(usize, Rc)>, + accuracy: AccuracyTarget, + cost_model: &ControlPlaneCostModel, + evidence: &dyn AccuracyEvidenceProvider, + accuracy_model: &dyn AccuracyModel, +) -> Result, SelectionError> { + let strategies = replacement_strategies(cost_model, evidence, accuracy_model); + let space = asap_aware_mapping::search_workload_with_targets( + roots + .into_iter() + .map(|(id, root)| (id, root, Some(accuracy.clone()))) + .collect(), + &strategies, + accuracy_model, + ); + let mut inventory = asap_aware_mapping::replacement::CandidateDagInventory { + candidates: Vec::new(), + rejected_assemblies: Vec::new(), + }; + for (id, _) in &space.roots { + let root = space + .enumerate_candidate_dags_for_root(id, 65_536) + .map_err(|error| SelectionError::Workload(error.to_string()))?; + inventory.candidates.extend(root.candidates); + inventory.rejected_assemblies.extend( + root.rejected_assemblies + .into_iter() + .map(|reason| format!("query {id}: {reason}")), + ); + } + Ok(inventory) +} + /// The [`ReplacementStrategy`] set this deployment registers, carrying its own /// cost and accuracy models. This is deliberately not Planner's /// `default_strategies_with`: two of the strategies that list would give us are diff --git a/control_plane/src/query_plan.rs b/control_plane/src/query_plan.rs index a1e70c590..a2583e171 100644 --- a/control_plane/src/query_plan.rs +++ b/control_plane/src/query_plan.rs @@ -41,6 +41,7 @@ where }; let root = compiler.lower(root)?; Ok(QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id, canonical_query, @@ -80,7 +81,8 @@ where lowered: Some(&mut lowered), }; let root = compiler.lower(root)?; - Ok(QueryPlanEntry { + let mut entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::ClickHouseSql, query_id, canonical_query, @@ -89,7 +91,9 @@ where nodes: compiler.nodes, instant, fallback, - }) + }; + entry.compile_relational_physical_dag()?; + Ok(entry) } /// Compile a composable query while exposing the stable mapping from @@ -123,6 +127,7 @@ where }; let root = compiler.lower(root)?; let mut entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id, canonical_query, @@ -248,8 +253,9 @@ where } for (local, mut physical) in nodes { match &mut physical { - QueryPlanNode::PhysicalFragment { inputs, .. } - | QueryPlanNode::Logical { inputs, .. } + QueryPlanNode::Logical { inputs, .. } + | QueryPlanNode::Physical { inputs, .. } + | QueryPlanNode::PhysicalFragment { inputs, .. } | QueryPlanNode::SummaryMerge { inputs } | QueryPlanNode::ExternalExact { inputs, .. } => { for input in inputs { @@ -994,6 +1000,7 @@ mod catalog_binding_tests { config.pane_origin_ms = Some(0); let catalog = SummaryCatalog::from_materializations(7, 2, &[config.clone()]).unwrap(); let entry = QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: "q".into(), canonical_query: "sum_over_time(m[1m])".into(), @@ -1249,6 +1256,7 @@ mod tests { #[test] fn language_tag_preserves_query_entry_serde() { let entry = QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: "q".into(), canonical_query: canonical_promql("up").unwrap(), @@ -1276,6 +1284,7 @@ mod tests { #[test] fn language_catalog_keys_keep_equal_query_text_distinct() { let base = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "prom".into(), canonical_query: "shared".into(), @@ -1371,6 +1380,7 @@ mod tests { ) .unwrap(); let entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "q".into(), canonical_query: "topk(1, m)".into(), @@ -1422,6 +1432,7 @@ mod tests { }, ); let entry = QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: "q".into(), canonical_query: "up".into(), @@ -1448,6 +1459,7 @@ mod tests { reason: "prepared".into(), }; let entry = QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: "q".into(), canonical_query: "topk(2, rate(m[5m]))".into(), diff --git a/control_plane/src/query_plan/residual.rs b/control_plane/src/query_plan/residual.rs index 39926848c..ed75bce4d 100644 --- a/control_plane/src/query_plan/residual.rs +++ b/control_plane/src/query_plan/residual.rs @@ -298,6 +298,7 @@ pub fn compile_logical( }; let root = lower.lower(&expr)?; let entry = QueryPlanEntry { + physical_dag: None, language: super::QueryLanguage::PromQl, query_id, canonical_query, @@ -513,7 +514,7 @@ pub(super) fn residual_nodes( } } Err(invalid( - "Planner residual does not match any original query subtree", + "Planner logical fragment does not match any original query subtree", )) } @@ -542,7 +543,7 @@ pub(super) fn binary_operator( }); } if operator.vector_match.is_some() { - return Err(invalid("explicit residual vector matching unsupported")); + return Err(invalid("explicit logical vector matching unsupported")); } let operation = match operator.kind.to_string().as_str() { "+" => BinaryOperation::Add, @@ -592,7 +593,7 @@ pub(super) fn selected_native_expression( ) -> Result { if !selected.guarantee.as_ref().is_some_and(|g| g.is_exact()) { return Err(invalid( - "native residual substitution requires an exact selected value", + "native subtree substitution requires an exact selected value", )); } let selected = match &selected.expr { diff --git a/crates/asap_types/src/executable_plan.rs b/crates/asap_types/src/executable_plan.rs index 1c89096c6..8427b9942 100644 --- a/crates/asap_types/src/executable_plan.rs +++ b/crates/asap_types/src/executable_plan.rs @@ -178,6 +178,10 @@ impl OwnedPostAsapDag { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct InstalledPostAsapDag { + /// Planner-compiled bounded programs keyed by persisted output node. + /// Deployment bindings below identify their stored input/output instances. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub native_programs: BTreeMap, pub document: OwnedPostAsapDag, pub binding: BackendExecutableBinding, } @@ -190,7 +194,64 @@ impl InstalledPostAsapDag { if self.document.schema_version != MAINTENANCE_DAG_SCHEMA_VERSION { return Err("unsupported maintenance DAG document version".into()); } - self.binding.validate_maintenance(&self.document.decode()?) + self.binding + .validate_maintenance(&self.document.decode()?)?; + for sink in self.native_programs.keys() { + self.native_program(*sink)?; + } + Ok(()) + } + + /// Recovery validates the physical producer's typed storage boundaries; + /// it never lowers the semantic provenance document again. + pub fn native_program( + &self, + sink: PostAsapNodeId, + ) -> Result, String> + { + let Some(value) = self.native_programs.get(&sink) else { + return Ok(None); + }; + let program = asap_physical_operators::physical_planner::CompiledPhysicalDag::decode( + &serde_json::to_vec(value).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + if program.roots() != [u64::from(sink.0)] || !self.binding.precompute_sinks.contains(&sink) + { + return Err("native maintenance program differs from its installed sink".into()); + } + let dag = self.document.decode()?; + for (id, contract) in program.input_contracts() { + let id = PostAsapNodeId(u32::try_from(id).map_err(|_| "physical source id overflow")?); + if id == sink + || !matches!( + self.binding.node(id), + Some(BackendNodeBinding::Materialization { .. }) + ) + || dag + .nodes + .iter() + .find(|n| n.id == id) + .is_none_or(|n| n.output_schema != *contract.schema) + { + return Err( + "native maintenance source differs from installed state boundary".into(), + ); + } + } + let output = program + .output_contract(u64::from(sink.0)) + .map_err(|e| e.to_string())?; + if program.input_contracts().count() == 0 + || dag + .nodes + .iter() + .find(|n| n.id == sink) + .is_none_or(|n| n.output_schema != *output.schema) + { + return Err("native maintenance output differs from semantic schema".into()); + } + Ok(Some(program)) } /// Project the selected semantic DAG onto the maintenance ancestors of its @@ -379,6 +440,7 @@ mod tests { #[test] fn installed_maintenance_dag_rejects_complete_dag_version() { let installed = InstalledPostAsapDag { + native_programs: std::collections::BTreeMap::new(), document: OwnedPostAsapDag { schema_version: OWNED_POST_ASAP_DAG_SCHEMA_VERSION, query_id: "q".into(), diff --git a/crates/asap_types/src/precompute_plan.rs b/crates/asap_types/src/precompute_plan.rs index f360ab4b9..279c6eb66 100644 --- a/crates/asap_types/src/precompute_plan.rs +++ b/crates/asap_types/src/precompute_plan.rs @@ -153,6 +153,8 @@ pub struct IngestContract { pub enum StateEncoding { SketchlibProtobufV1, SketchCoreMsgpackV1, + /// Backend-produced typed physical output, distinct from legacy sketch frames. + NativeBatchV1, ExactAccumulatorV1, /// Persisted backend state with explicit Planner family and population layout. PlannerExactAccumulatorV1, @@ -561,15 +563,6 @@ impl PrecomputePlan { return Err(invalid()); } validated_source_window_cohort(config, &sources)?; - if sources - .iter() - .any(|source| !matches!(source.aggregation_type, crate::AggregationType::Sum)) - { - return Err(invalid()); - } - if config.window_size != config.slide_interval { - return Err(invalid()); - } let mut matched = false; for installed in self.executable_dags.values() { let dag = installed @@ -588,8 +581,30 @@ impl PrecomputePlan { .iter() .find(|node| node.id == *sink) .ok_or_else(invalid)?; - validate_maintenance_reduction(config, target_node) + let native = installed + .native_program(*sink) .map_err(PrecomputePlanError::CatalogContract)?; + if sources.iter().any(|source| { + !matches!(source.aggregation_type, crate::AggregationType::Sum) + && !(native.is_some() + && matches!(source.aggregation_type, crate::AggregationType::Rate)) + }) { + return Err(invalid()); + } + if native.is_none() { + if config.window_size != config.slide_interval { + return Err(invalid()); + } + validate_maintenance_reduction(config, target_node) + .map_err(PrecomputePlanError::CatalogContract)?; + } else if !matches!( + config.aggregation_type, + crate::AggregationType::CountMinSketchWithHeap + | crate::AggregationType::CountSketchWithHeap + | crate::AggregationType::Sum + ) { + return Err(invalid()); + } let inputs: Vec<_> = dag .edges .iter() @@ -869,7 +884,7 @@ impl PrecomputePlan { crate::WindowKind::Session => None, } || schema.window.pane_origin_ms != materialization.pane_origin_ms - || schema.encodings != state_encodings(&accumulator.family) + || !state_encodings_match(&accumulator.family, &schema.encodings) { return Err(PrecomputePlanError::InvalidSchema { schema_id: schema.schema_id.clone(), @@ -917,8 +932,22 @@ pub(crate) fn state_schema_id(fingerprint: crate::PolicyFingerprint) -> String { format!("{}:summary-state:v1:{}", BACKEND_COMPAT, fingerprint.0) } +// Persisted schemas may declare a subset of formats. Adding a decoder must +// not invalidate existing installed plans that use only older supported codecs. +pub(crate) fn state_encodings_match( + family: &SummaryFamilyType, + encodings: &[StateEncoding], +) -> bool { + let supported = state_encodings(family); + !encodings.is_empty() + && encodings.iter().collect::>().len() == encodings.len() + && encodings + .iter() + .all(|encoding| supported.contains(encoding)) +} + pub(crate) fn state_encodings(family: &SummaryFamilyType) -> Vec { - match family { + let mut encodings = match family { SummaryFamilyType::ExactAggregate( planner_types::post_asap::ExactKind::Increase | planner_types::post_asap::ExactKind::Rate, @@ -946,7 +975,25 @@ pub(crate) fn state_encodings(family: &SummaryFamilyType) -> Vec StateEncoding::SketchCoreMsgpackV1, ], _ => Vec::new(), + }; + if matches!( + family, + SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Sum + | planner_types::post_asap::ExactKind::Count + | planner_types::post_asap::ExactKind::Min + | planner_types::post_asap::ExactKind::Max + | planner_types::post_asap::ExactKind::Rate + | planner_types::post_asap::ExactKind::Increase, + _ + ) + ) || matches!(family, SummaryFamilyType::Sketch(kind, _) if matches!(kind.algorithm(), + SketchAlgorithm::Kll | SketchAlgorithm::DDSketch | SketchAlgorithm::Hll | + SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap)) + { + encodings.push(StateEncoding::NativeBatchV1); } + encodings } #[cfg(test)] @@ -964,6 +1011,36 @@ mod source_window_cohort_tests { })) .unwrap() } + // New native-format support must not invalidate persisted older codec subsets. + #[test] + fn older_encoding_subsets_remain_valid_and_unknown_codecs_fail() { + use planner_types::post_asap::{SketchKind, SketchParams}; + let family = SummaryFamilyType::Sketch( + SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 200 }), + Default::default(), + ); + assert!(state_encodings_match( + &family, + &[ + StateEncoding::SketchlibProtobufV1, + StateEncoding::SketchCoreMsgpackV1 + ] + )); + assert!(state_encodings_match( + &family, + &[StateEncoding::NativeBatchV1] + )); + assert!(!state_encodings_match(&family, &[])); + assert!(!state_encodings_match( + &family, + &[StateEncoding::ExactAccumulatorV1] + )); + assert!(!state_encodings_match( + &family, + &[StateEncoding::NativeBatchV1, StateEncoding::NativeBatchV1] + )); + } + #[test] fn producer_roster_roundtrip_and_watermark_scope() { let envelope = PlanEnvelope { diff --git a/crates/asap_types/src/precompute_plan/catalog.rs b/crates/asap_types/src/precompute_plan/catalog.rs index bad95e7e5..ad086c2c0 100644 --- a/crates/asap_types/src/precompute_plan/catalog.rs +++ b/crates/asap_types/src/precompute_plan/catalog.rs @@ -206,7 +206,7 @@ impl PrecomputePlan { schema_id: schema.schema_id.clone(), }); } - if schema.encodings != state_encodings(&family) { + if !state_encodings_match(&family, &schema.encodings) { return Err(invalid("encoding does not match state family")); } if config.num_aggregates_to_retain == Some(0) { diff --git a/crates/asap_types/src/query_plan.rs b/crates/asap_types/src/query_plan.rs index 41abe328d..1e214b67f 100644 --- a/crates/asap_types/src/query_plan.rs +++ b/crates/asap_types/src/query_plan.rs @@ -6,6 +6,7 @@ //! searching for compatible materializations. pub mod current_series; +mod native; pub mod residual; use std::collections::{BTreeMap, BTreeSet}; @@ -295,6 +296,9 @@ pub use crate::executable_plan::QueryNodeId; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct QueryPlanEntry { + /// Planner-selected native computation, persisted before activation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub physical_dag: Option, #[serde(default)] pub language: QueryLanguage, pub query_id: String, @@ -390,8 +394,14 @@ impl QueryPlanEntry { self.query_id, self.root.0 ))); } + if self.physical_vector_binding().is_some() { + self.recover_vector_physical_dag()?; + } else if self.population_snapshot().is_some() { + self.recover_population_physical_dag()?; + } else if self.relation_output_schema()?.is_some() || self.physical_dag.is_some() { + self.recover_relational_physical_dag()?; + } for (id, node) in &self.nodes { - validate_native_relation(*id, node)?; if let QueryPlanNode::PhysicalFragment { inputs, dag, @@ -676,6 +686,14 @@ pub struct PruningInputContract { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] pub enum QueryPlanNode { + /// Bind deployment-provided vectors or stored batches to the compiled physical DAG. + /// Input positions correspond to `source_nodes`; operators live only in + /// QueryPlanEntry.physical_dag, never in this binding. + Physical { + inputs: Vec, + source_nodes: Vec, + max_bytes: u64, + }, /// Planner-compiled computation. Input order follows the physical input contracts. PhysicalFragment { inputs: Vec, @@ -752,8 +770,9 @@ impl QueryPlanNode { | Self::Relational { input, .. } | Self::SummaryEstimate { input, .. } | Self::ExactReadout { input, .. } => std::slice::from_ref(input), - Self::PhysicalFragment { inputs, .. } - | Self::SummaryMerge { inputs } + Self::SummaryMerge { inputs } + | Self::Physical { inputs, .. } + | Self::PhysicalFragment { inputs, .. } | Self::Logical { inputs, .. } | Self::ExternalExact { inputs, .. } => inputs, } @@ -863,6 +882,7 @@ mod contract_tests { } /// Bind portable relation semantics before an installed plan can access its sources. +#[cfg(test)] fn validate_native_relation(id: QueryNodeId, node: &QueryPlanNode) -> Result<(), QueryPlanError> { use planner_types::post_asap::{ ExecutableDagNode, ExecutableOperatorPayload as Payload, ExecutionDataState, PostAsapNodeId, diff --git a/crates/asap_types/src/query_plan/current_series.rs b/crates/asap_types/src/query_plan/current_series.rs index c99498457..9aed86961 100644 --- a/crates/asap_types/src/query_plan/current_series.rs +++ b/crates/asap_types/src/query_plan/current_series.rs @@ -49,8 +49,14 @@ impl SeriesPopulation { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum SeriesReadout { - Quantile { q: f64 }, - TopK { k: u64 }, + /// All eligible members for a Planner-compiled physical readout. + Snapshot, + Quantile { + q: f64, + }, + TopK { + k: u64, + }, Sum, Count, Average, diff --git a/crates/asap_types/src/query_plan/native.rs b/crates/asap_types/src/query_plan/native.rs new file mode 100644 index 000000000..06ec40294 --- /dev/null +++ b/crates/asap_types/src/query_plan/native.rs @@ -0,0 +1,540 @@ +//! Retained physical programs for SQL relations and PromQL vectors. +use super::*; +use asap_physical_operators::{dag, physical_planner::CompiledPhysicalDag}; +use planner_types::post_asap::{ + ExecutableDagNode, ExecutableOperatorPayload as Payload, ExecutionDataState, PostAsapNodeId, + SummarySchema, +}; +use std::sync::Arc; + +impl QueryPlanEntry { + /// Invoke during plan compilation, after external/storage boundaries are fixed. + pub fn compile_relational_physical_dag(&mut self) -> Result<(), QueryPlanError> { + let Some(expected) = self.relation_output_schema()? else { + self.physical_dag = None; + return Ok(()); + }; + let compiled = self + .compile_relation(self.root, &expected) + .map_err(QueryPlanError::Invalid)?; + self.physical_dag = Some( + serde_json::from_slice( + &compiled + .encode() + .map_err(|error| QueryPlanError::Invalid(error.to_string()))?, + ) + .map_err(|error| QueryPlanError::Invalid(error.to_string()))?, + ); + Ok(()) + } + + fn compile_relation( + &self, + root: QueryNodeId, + expected: &SummarySchema, + ) -> Result { + let mut pending = vec![(root, expected.clone())]; + let mut schemas = BTreeMap::::new(); + let mut operations = BTreeMap::new(); + let mut sources = Vec::new(); + // Bind the entire computation before reading any storage source. + while let Some((id, expected)) = pending.pop() { + if let Some(previous) = schemas.get(&id) { + if previous != &expected { + return Err("inconsistent relation schemas".into()); + } + continue; + } + schemas.insert(id, expected.clone()); + let (payload, inputs) = match self.nodes.get(&id) { + Some(QueryPlanNode::Relational { + input, + operation, + input_schema, + output_schema, + }) => { + if output_schema != &expected { + return Err("relational output schema mismatch".into()); + } + let operation = + serde_json::from_value(operation.clone()).map_err(|e| e.to_string())?; + ( + Payload::Value { operation }, + vec![(*input, input_schema.clone())], + ) + } + Some(QueryPlanNode::RelationalJoin { + inputs, + join_kind, + pred, + left_schema, + right_schema, + output_schema, + pruning, + }) => { + if output_schema != &expected { + return Err("join output schema mismatch".into()); + } + if pruning.is_some() { + return Err( + "candidate pruning is not bound for this relation source".into() + ); + } + ( + Payload::RelationalJoin { + join_kind: join_kind.clone(), + pred: serde_json::from_value(pred.clone()) + .map_err(|e| e.to_string())?, + pruning: None, + }, + vec![ + (inputs[0], left_schema.clone()), + (inputs[1], right_schema.clone()), + ], + ) + } + Some(_) => { + sources.push(id); + continue; + } + None => return Err("missing relation node".into()), + }; + let node = ExecutableDagNode { + id: PostAsapNodeId( + u32::try_from(id.0).map_err(|_| "relation node ID exceeds Planner range")?, + ), + payload, + output_state: ExecutionDataState::QUERY_ROWS, + output_schema: expected, + guarantee: None, + }; + let op = dag::planner::compile_node( + &node, + &inputs + .iter() + .map(|(_, schema)| Arc::new(schema.clone())) + .collect::>(), + ) + .map_err(|e| e.to_string())?; + operations.insert( + id, + (inputs.iter().map(|(id, _)| id.0).collect::>(), op), + ); + pending.extend(inputs); + } + let compiled = + asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + sources + .iter() + .map(|id| { + ( + id.0, + asap_physical_operators::physical_planner::InputContract::bounded( + Arc::new(schemas[id].clone()), + ), + ) + }) + .collect(), + operations.into_iter().map(|(id, op)| (id.0, op)).collect(), + vec![root.0], + ) + .map_err(|e| e.to_string())?; + Ok(compiled) + } + + pub fn relation_output_schema(&self) -> Result, QueryPlanError> { + match self.nodes.get(&self.root) { + Some( + QueryPlanNode::Relational { output_schema, .. } + | QueryPlanNode::RelationalJoin { output_schema, .. }, + ) => Ok(Some(output_schema.clone())), + Some(QueryPlanNode::ExternalExact { request, .. }) => match &request.output { + ExternalExactOutput::Relation { schema } => serde_json::from_value(schema.clone()) + .map(Some) + .map_err(|error| QueryPlanError::Invalid(error.to_string())), + _ => Ok(None), + }, + _ => Ok(None), + } + } + + /// Validate persisted computation and boundary schemas without logical lowering. + pub fn recover_relational_physical_dag(&self) -> Result { + let invalid = |message: String| QueryPlanError::Invalid(message); + let value = self + .physical_dag + .as_ref() + .ok_or_else(|| invalid("missing installed physical DAG".into()))?; + let dag = CompiledPhysicalDag::decode( + &serde_json::to_vec(value).map_err(|e| invalid(e.to_string()))?, + ) + .map_err(|e| invalid(e.to_string()))?; + if dag.roots() != [self.root.0] { + return Err(invalid( + "installed physical root differs from query root".into(), + )); + } + let expected = self + .relation_output_schema()? + .ok_or_else(|| invalid("physical relation has no output schema".into()))?; + if *dag + .output_contract(self.root.0) + .map_err(|e| invalid(e.to_string()))? + .schema + != expected + { + return Err(invalid("installed physical output schema mismatch".into())); + } + let mut expected_inputs = BTreeMap::new(); + let mut pending = vec![(self.root, expected)]; + let mut seen = BTreeMap::new(); + while let Some((id, schema)) = pending.pop() { + if let Some(previous) = seen.insert(id, schema.clone()) { + if previous != schema { + return Err(invalid("inconsistent relation schemas".into())); + } + continue; + } + match self.nodes.get(&id) { + Some(QueryPlanNode::Relational { + input, + input_schema, + output_schema, + .. + }) => { + if output_schema != &schema { + return Err(invalid("relation output schema mismatch".into())); + } + pending.push((*input, input_schema.clone())); + } + Some(QueryPlanNode::RelationalJoin { + inputs, + left_schema, + right_schema, + output_schema, + .. + }) => { + if output_schema != &schema { + return Err(invalid("join output schema mismatch".into())); + } + pending.extend([ + (inputs[0], left_schema.clone()), + (inputs[1], right_schema.clone()), + ]); + } + Some(_) => { + expected_inputs.insert(id.0, schema); + } + None => { + return Err(invalid( + "physical input references absent query node".into(), + )) + } + } + } + let actual: BTreeMap<_, _> = dag + .input_contracts() + .map(|(id, c)| (id, c.schema.as_ref().clone())) + .collect(); + if actual != expected_inputs { + return Err(invalid( + "physical input boundaries differ from installed bindings".into(), + )); + } + Ok(dag) + } +} + +impl QueryPlanEntry { + pub fn population_snapshot(&self) -> Option<¤t_series::SeriesPopulation> { + if self.nodes.len() != 1 { + return None; + } + match self.nodes.get(&self.root) { + Some(QueryPlanNode::Logical { + operator: + residual::ResidualQueryOperator::CurrentSeries { + population, + readout: current_series::SeriesReadout::Snapshot, + }, + inputs, + }) if inputs.is_empty() => Some(population), + _ => None, + } + } + + /// Recover an installed population readout; the source binds the complete + /// maintained vector, while the physical program owns ranking and limiting. + pub fn recover_population_physical_dag(&self) -> Result { + let invalid = |message: &str| QueryPlanError::Invalid(message.into()); + let population = self + .population_snapshot() + .ok_or_else(|| invalid("missing population source binding"))?; + population.validate()?; + let value = self + .physical_dag + .as_ref() + .ok_or_else(|| invalid("missing installed population physical DAG"))?; + let dag = CompiledPhysicalDag::decode( + &serde_json::to_vec(value) + .map_err(|error| QueryPlanError::Invalid(error.to_string()))?, + ) + .map_err(|error| QueryPlanError::Invalid(error.to_string()))?; + let inputs = dag.input_contracts().collect::>(); + let [(_, input)] = inputs.as_slice() else { + return Err(invalid("population physical DAG requires one source")); + }; + let [root] = dag.roots() else { + return Err(invalid("population physical DAG requires one root")); + }; + let output = dag + .output_contract(*root) + .map_err(|error| QueryPlanError::Invalid(error.to_string()))?; + if input.schema != output.schema { + return Err(invalid( + "population ranking must preserve complete source rows", + )); + } + use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; + let fields = &input.schema.fields; + let column = |name: &str, dtype: DataType| { + fields.iter().any(|field| { + field.name == name + && field.dtype == SummaryFamilyType::Plain(dtype.clone()) + && !field.nullable + }) + }; + if !column( + asap_physical_operators::physical_planner::promql_rows::SERIES_IDENTITY_COLUMN, + DataType::Utf8, + ) || !column("value", DataType::Float64) + || input.schema.time_index.is_none_or(|index| { + fields[index].dtype != SummaryFamilyType::Plain(DataType::Timestamp) + }) + || population.grouping.without + || population.grouping.labels.iter().any(|label| { + !fields.iter().any(|field| { + &field.name == label && field.dtype == SummaryFamilyType::Plain(DataType::Utf8) + }) + }) + { + return Err(invalid( + "population physical source loses identity, value, timestamp or grouping", + )); + } + Ok(dag) + } +} + +impl QueryPlanEntry { + pub fn physical_vector_binding(&self) -> Option<(&[QueryNodeId], &[u64], u64)> { + match self.nodes.get(&self.root) { + Some(QueryPlanNode::Physical { + inputs, + source_nodes, + max_bytes, + }) => Some((inputs, source_nodes, *max_bytes)), + _ => None, + } + } + + /// Validate bound counter vectors or stored aggregate batches against the + /// retained physical program; recovery never lowers logical operators. + pub fn recover_vector_physical_dag(&self) -> Result { + let invalid = |message: &str| QueryPlanError::Invalid(message.into()); + let (inputs, source_nodes, max_bytes) = self + .physical_vector_binding() + .ok_or_else(|| invalid("missing physical vector binding"))?; + if max_bytes == 0 + || usize::try_from(max_bytes).is_err() + || inputs.is_empty() + || inputs.len() != source_nodes.len() + || source_nodes.iter().copied().collect::>().len() != source_nodes.len() + { + return Err(invalid("invalid physical vector source mapping or budget")); + } + for input in inputs { + if let Some(QueryPlanNode::ReadMaterialization { binding }) = self.nodes.get(input) { + if binding.readout_lookback_ms != Some(self.instant.lookback_ms) + || binding.window_ms != self.instant.lookback_ms + || binding.window_ms == 0 + || !matches!(&binding.output_grouping, PhysicalGrouping::Reduce(labels) if labels.is_empty()) + { + return Err(invalid(&format!("stored native batch requires one complete bound window: {binding:?}, query lookback {}", self.instant.lookback_ms))); + } + continue; + } + let Some(QueryPlanNode::ExactReadout { + input: state, + readout: ExactReadout::Rate, + }) = self.nodes.get(input) + else { + return Err(invalid( + "physical vector requires an exact per-series Rate readout", + )); + }; + let Some(QueryPlanNode::ReadMaterialization { binding }) = self.nodes.get(state) else { + return Err(invalid( + "physical Rate input requires its installed stored output", + )); + }; + if binding + .readout_lookback_ms + .is_none_or(|window| window == 0 || window != self.instant.lookback_ms) + || !matches!(&binding.output_grouping, PhysicalGrouping::PerEntity) + { + return Err(invalid( + "physical Rate input must preserve every series and its window", + )); + } + } + let value = self + .physical_dag + .as_ref() + .ok_or_else(|| invalid("missing installed vector physical DAG"))?; + let dag = CompiledPhysicalDag::decode( + &serde_json::to_vec(value).map_err(|e| QueryPlanError::Invalid(e.to_string()))?, + ) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + if dag + .input_contracts() + .map(|(id, _)| id) + .collect::>() + != source_nodes.iter().copied().collect() + || dag.roots().len() != 1 + { + return Err(invalid( + "physical vector program differs from installed source mapping", + )); + } + use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; + let vector_schema = |schema: &SummarySchema| { + [ + ( + asap_physical_operators::physical_planner::promql_rows::SERIES_IDENTITY_COLUMN, + DataType::Utf8, + ), + ("value", DataType::Float64), + ] + .into_iter() + .all(|(name, dtype)| { + schema.fields.iter().any(|f| { + f.name == name + && f.dtype == SummaryFamilyType::Plain(dtype.clone()) + && !f.nullable + }) + }) && schema.time_index.is_some_and(|i| { + schema + .fields + .get(i) + .is_some_and(|f| f.dtype == SummaryFamilyType::Plain(DataType::Timestamp)) + }) + }; + if dag + .input_contracts() + .any(|(id, input)| { + let position = source_nodes.iter().position(|source| *source == id).unwrap(); + if matches!(self.nodes.get(&inputs[position]), Some(QueryPlanNode::ReadMaterialization { .. })) { + let summaries = input.schema.fields.iter().filter(|field| !matches!(field.dtype, SummaryFamilyType::Plain(_))).collect::>(); + !matches!(summaries.as_slice(), [field] if match &field.dtype { + SummaryFamilyType::Sketch(kind, _) => matches!(kind.algorithm(), planner_types::post_asap::SketchAlgorithm::CmsWithHeap | planner_types::post_asap::SketchAlgorithm::CountSketchWithHeap), + SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _) => true, + _ => false, + }) + } else { !vector_schema(&input.schema) } + }) + || { + let output = dag.output_contract(dag.roots()[0]).map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + output.schema.fields.iter().filter(|field| field.dtype == SummaryFamilyType::Plain(DataType::Float64)).count() != 1 + || output.schema.fields.iter().any(|field| !matches!(field.dtype, SummaryFamilyType::Plain(DataType::Utf8 | DataType::Float64 | DataType::Timestamp))) + } + { + return Err(invalid(&format!( + "physical program has incompatible input or result schema: inputs {:?}; output {:?}", + dag.input_contracts().map(|(id, contract)| (id, &contract.schema)).collect::>(), + dag.output_contract(dag.roots()[0]).map_err(|error| QueryPlanError::Invalid(error.to_string()))?.schema, + ))); + } + Ok(dag) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField}, + pre_asap::DataType, + }; + + fn installed() -> QueryPlanEntry { + let schema = SummarySchema { + fields: vec![SummaryField { + name: "value".into(), + dtype: SummaryFamilyType::Plain(DataType::Float64), + nullable: false, + }], + time_index: None, + }; + let mut entry = QueryPlanEntry { + physical_dag: None, + language: QueryLanguage::ClickHouseSql, + query_id: "native".into(), + canonical_query: "SELECT value".into(), + fixed_evaluation: None, + root: QueryNodeId(0), + nodes: BTreeMap::from([( + QueryNodeId(0), + QueryPlanNode::ExternalExact { + request: ExternalExactRequest { + language: QueryLanguage::ClickHouseSql, + expression: "SELECT value".into(), + output: ExternalExactOutput::Relation { + schema: serde_json::to_value(schema).unwrap(), + }, + parameters: BTreeMap::new(), + start_parameter: None, + end_parameter: None, + input_contracts: vec![], + }, + inputs: vec![], + }, + )]), + instant: InstantExecution { + lookback_ms: 0, + full_history: false, + cumulative_readout: false, + }, + fallback: FallbackPolicy::Reject, + }; + entry.compile_relational_physical_dag().unwrap(); + entry + } + + // Restart keeps the physical program; unknown formats and stale bindings fail activation. + #[test] + fn recovery_validates_physical_version_roots_and_input_schema() { + let entry = installed(); + let encoded = serde_json::to_vec(&entry).unwrap(); + let restored: QueryPlanEntry = serde_json::from_slice(&encoded).unwrap(); + restored.validate(&BTreeSet::new()).unwrap(); + let mut corrupt = restored.clone(); + corrupt.physical_dag.as_mut().unwrap()["version"] = serde_json::json!(99); + assert!(corrupt.validate(&BTreeSet::new()).is_err()); + let mut corrupt = restored.clone(); + corrupt.root = QueryNodeId(1); + assert!(corrupt.validate(&BTreeSet::new()).is_err()); + let mut corrupt = restored.clone(); + if let QueryPlanNode::ExternalExact { request, .. } = + corrupt.nodes.get_mut(&QueryNodeId(0)).unwrap() + { + if let ExternalExactOutput::Relation { schema } = &mut request.output { + schema["fields"][0]["name"] = serde_json::json!("different"); + } + } + assert!(corrupt.validate(&BTreeSet::new()).is_err()); + let mut missing = restored; + missing.physical_dag = None; + assert!(missing.validate(&BTreeSet::new()).is_err()); + } +} diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 3206809e0..a5719c471 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -1443,15 +1443,17 @@ fn execute_finite_complete_populations( .collect::, _>>()?; asap_types::precompute_plan::validated_source_window_cohort(config, &sources) .map_err(|error| error.to_string())?; + let native_program = installed.native_program(sink)?; if std::iter::once(config) .chain(sources.iter().copied()) .any(|config| { config.population_key_encoding != asap_types::PopulationKeyEncoding::CanonicalLabelsV1 - || config.slide_interval.checked_mul(1000) != Some(config.stored_window_ms()) + || (native_program.is_none() + && config.slide_interval.checked_mul(1000) != Some(config.stored_window_ms())) }) { return Err( - "complete population execution requires canonical nonoverlapping full windows".into(), + "complete population execution requires canonical windows supported by the bound program".into(), ); } let dag = installed.document.decode()?; @@ -1460,8 +1462,11 @@ fn execute_finite_complete_populations( .iter() .find(|node| node.id == sink) .ok_or("complete target node is absent")?; - asap_types::precompute_plan::validate_maintenance_reduction(config, target_node)?; - if !matches!(&target_node.payload, ExecutableOperatorPayload::SummaryAgg { + if native_program.is_none() { + asap_types::precompute_plan::validate_maintenance_reduction(config, target_node)?; + } + if native_program.is_none() + && !matches!(&target_node.payload, ExecutableOperatorPayload::SummaryAgg { reduction: planner_types::pre_asap::Reduction::Reduce(keys), .. } if keys.is_empty()) { @@ -1482,11 +1487,16 @@ fn execute_finite_complete_populations( } for (start, end) in windows { let width = source.stored_window_ms(); + let stride = source + .slide_interval + .checked_mul(1000) + .ok_or("source cadence overflow")?; if width == 0 + || stride == 0 || end.checked_sub(*start) != Some(width) || *end > i64::MAX as u64 || (*start as i128 - source.pane_origin_ms.unwrap_or(0) as i128) - .rem_euclid(width as i128) + .rem_euclid(stride as i128) != 0 { return Err( @@ -1511,6 +1521,26 @@ fn execute_finite_complete_populations( for window in common_windows.unwrap_or_default() { let cohort = store.read_complete_raw_maintenance_cohort(generation, &derived.inputs, window)?; + if let Some(program) = &native_program { + let max_bytes = asap_physical_operators::runtime::Limits::default().max_bytes; + let batch = super::native_maintenance::execute( + installed, + program, + cohort.inputs(), + window, + max_bytes, + )?; + let mut output = crate::storage_engines::types::PrecomputedOutput::new( + window.0, + window.1, + None, + target.fingerprint(), + ); + output.population_labels = Some(Population::new()); + output.catalog_generation = Some(Arc::clone(generation)); + store.publish_native_summary_output(resolver, config, &output, batch, max_bytes)?; + continue; + } let (dag, key) = prepare_frozen_maintenance_sink( installed, &plan.materializations, @@ -2797,6 +2827,7 @@ mod tests { }, ); let installed = InstalledPostAsapDag { + native_programs: std::collections::BTreeMap::new(), document, binding: durable_binding, }; @@ -3181,7 +3212,11 @@ mod tests { binding .nodes .insert(PostAsapNodeId(6), BackendNodeBinding::MaintenanceInput); - let installed = InstalledPostAsapDag { document, binding }; + let installed = InstalledPostAsapDag { + native_programs: BTreeMap::new(), + document, + binding, + }; let configs = [first, second, target]; let catalog = Arc::new( asap_types::summary_catalog::SummaryCatalog::from_materializations(2, 1, &configs) @@ -4142,6 +4177,7 @@ mod tests { bundle.precompute_plan.executable_dags = BTreeMap::from([( "retry".into(), InstalledPostAsapDag { + native_programs: BTreeMap::new(), document: { let mut document = OwnedPostAsapDag::from_executable("retry".into(), &dag).unwrap(); diff --git a/data_plane/src/precompute_engine/mod.rs b/data_plane/src/precompute_engine/mod.rs index 726801a29..8019d2568 100644 --- a/data_plane/src/precompute_engine/mod.rs +++ b/data_plane/src/precompute_engine/mod.rs @@ -8,6 +8,7 @@ pub mod ingest_handler; pub mod maintenance_runtime; pub(crate) mod metrics; pub mod multisource_coordinator; +mod native_maintenance; pub mod output_sink; pub mod raw_dag; pub mod series_buffer; diff --git a/data_plane/src/precompute_engine/native_maintenance.rs b/data_plane/src/precompute_engine/native_maintenance.rs new file mode 100644 index 000000000..dba59db09 --- /dev/null +++ b/data_plane/src/precompute_engine/native_maintenance.rs @@ -0,0 +1,130 @@ +//! Bind a complete durable counter cohort to a Planner-owned maintenance graph. +use std::{collections::BTreeMap, sync::Arc}; + +use asap_physical_operators::{ + operators::Operator, + physical_planner::{CompiledPhysicalDag, Source}, + runtime::{Limits, RunContext, Scope}, + values::{Batch, Value}, +}; +use asap_types::executable_plan::{BackendNodeBinding, InstalledPostAsapDag}; +use futures::{executor::block_on, StreamExt}; +use planner_types::{ + post_asap::{PostAsapNodeId, SummaryFamilyType}, + pre_asap::DataType, +}; + +pub(super) fn execute( + installed: &InstalledPostAsapDag, + program: &CompiledPhysicalDag, + inputs: &[crate::storage_engines::sketch_db::index::FrozenExactWindows], + window: (u64, u64), + max_bytes: usize, +) -> Result { + let mut sources = BTreeMap::new(); + let mut input_bytes = 0usize; + for (id, contract) in program.input_contracts() { + let node = PostAsapNodeId(u32::try_from(id).map_err(|_| "native source id overflow")?); + let Some(BackendNodeBinding::Materialization { stored_output }) = + installed.binding.node(node) + else { + return Err("native maintenance input has no stored binding".into()); + }; + let mut rows = Vec::new(); + for input in inputs + .iter() + .filter(|input| input.stored_output_reference.stored_output_id == *stored_output) + { + let state = input + .windows + .get(&window) + .ok_or("native maintenance requires an exact complete counter window")?; + let row = contract + .schema + .fields + .iter() + .map(|field| match &field.dtype { + SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Rate, + _, + ) => Ok(Value::Summary { + family: field.dtype.clone(), + state: Arc::clone(state), + }), + SummaryFamilyType::Plain(DataType::Timestamp) => Ok(Value::Timestamp( + i64::try_from(window.1).map_err(|_| "native window overflow")?, + )), + SummaryFamilyType::Plain(DataType::Utf8) + if field.name == "$promql_series_identity" => + { + Ok(Value::Utf8( + serde_json::to_string(&input.group) + .map_err(|e| e.to_string())? + .into(), + )) + } + SummaryFamilyType::Plain(DataType::Utf8) => Ok(Value::Utf8( + input + .group + .get(&field.name) + .cloned() + .unwrap_or_default() + .into(), + )), + _ => Err("unsupported native maintenance stored input field".to_string()), + }) + .collect::, String>>()?; + rows.push(row); + } + let batch = Batch::try_new(contract.schema.clone(), rows).map_err(|e| e.to_string())?; + input_bytes = input_bytes + .checked_add(batch.bytes()) + .ok_or("native input size overflow")?; + if input_bytes > max_bytes { + return Err("native maintenance input exceeds run budget".into()); + } + sources.insert( + id, + Box::new( + Operator::source(contract.schema.clone(), vec![batch]) + .map_err(|e| e.to_string())?, + ) as Source<'_>, + ); + } + let graph = program.instantiate(sources).map_err(|e| e.to_string())?; + let context = RunContext::new( + Scope::Ingestion { + window_start_ms: i64::try_from(window.0).map_err(|_| "native window overflow")?, + window_end_ms: i64::try_from(window.1).map_err(|_| "native window overflow")?, + revision: 0, + }, + Limits { + max_bytes, + ..Limits::default() + }, + ) + .map_err(|e| e.to_string())?; + let schema = program + .output_contract(program.roots()[0]) + .map_err(|e| e.to_string())? + .schema; + let mut stream = graph + .execute(program.roots(), context) + .map_err(|e| e.to_string())? + .remove(0); + block_on(async { + let mut rows = Vec::new(); + let mut bytes = 0usize; + while let Some(batch) = stream.next().await { + let batch = batch.map_err(|e| e.to_string())?; + bytes = bytes + .checked_add(batch.bytes()) + .ok_or("native output size overflow")?; + if bytes > max_bytes { + return Err("native maintenance output exceeds publication budget".into()); + } + rows.extend(batch.rows().iter().cloned()); + } + Batch::try_new(schema, rows).map_err(|e| e.to_string()) + }) +} diff --git a/data_plane/src/precompute_engine/partitioning.rs b/data_plane/src/precompute_engine/partitioning.rs index 09ef174fd..fe29a12a7 100644 --- a/data_plane/src/precompute_engine/partitioning.rs +++ b/data_plane/src/precompute_engine/partitioning.rs @@ -68,15 +68,15 @@ impl DagPartitioning { Self::Labels(names) => { let pairs = names .iter() + // Missing and empty PromQL grouping labels denote the same + // group. Ownership must colocate them without adding a label. .map(|name| { - population - .get(name) - .map(|value| (name.as_str(), value.as_str())) - .ok_or_else(|| { - format!("DAG partition label {name} is absent from population") - }) + ( + name.as_str(), + population.get(name).map(String::as_str).unwrap_or(""), + ) }) - .collect::, _>>()?; + .collect::>(); let key = super::group_key::GroupKey::new(pairs); Ok(xxh64(key.canonical_bytes(), 0) as usize % workers) } @@ -99,7 +99,11 @@ mod tests { series.insert("instance".into(), instance.into()); assert_eq!(rule.owner(&series, 4).unwrap(), expected); } - assert!(rule.owner(&BTreeMap::new(), 4).is_err()); + assert_eq!( + rule.owner(&BTreeMap::new(), 4).unwrap(), + rule.owner(&BTreeMap::from([("service".into(), "".into())]), 4) + .unwrap() + ); assert!(rule.owner(&group, 0).is_err()); let owners = (0..64) .map(|i| { diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs index 3e2f11496..29fb3715d 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs @@ -496,6 +496,7 @@ mod tests { }, ); entry.root = project; + entry.compile_relational_physical_dag().unwrap(); entry } @@ -574,6 +575,7 @@ mod tests { let sort = QueryNodeId(4); let root = QueryNodeId(5); let executable = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::ClickHouseSql, query_id: "SELECT value FROM samples".into(), canonical_query: "SELECT value FROM samples".into(), @@ -707,7 +709,8 @@ mod tests { } else { BTreeMap::new() }; - let entry = QueryPlanEntry { + let mut entry = QueryPlanEntry { + physical_dag: None, query_id: sql.clone(), canonical_query: canonical_sql.clone(), language: QueryLanguage::ClickHouseSql, @@ -721,6 +724,7 @@ mod tests { instant: executable.instant, fallback: executable.fallback, }; + entry.compile_relational_physical_dag().unwrap(); let query_plan = QueryPlan { plan_id: 41, plan_version: 1, diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs index f08565a26..c8583c0ea 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs @@ -72,9 +72,6 @@ impl RelationDagExecutor<'_> { #[cfg(test)] fn record_evaluation(&mut self, id: QueryNodeId) { *self.evaluations.entry(id).or_default() += 1; - if let Some(publish) = AFTER_BRANCH.with(|hook| hook.take()) { - publish(self.index); - } } #[cfg(not(test))] @@ -88,117 +85,25 @@ impl RelationDagExecutor<'_> { use super::relational_adapter::native; use asap_physical_operators::dag::{self, operators::Operator}; use futures::{FutureExt, StreamExt}; - use planner_types::post_asap::{ - ExecutableDagNode, ExecutableOperatorPayload as Payload, ExecutionDataState, - PostAsapNodeId, SummarySchema, - }; - use std::sync::Arc; - let mut pending = vec![(root, expected.clone())]; - let mut schemas = BTreeMap::::new(); - let mut operations = BTreeMap::new(); - let mut sources = Vec::new(); - // Bind the entire computation before reading any storage source. - while let Some((id, expected)) = pending.pop() { - if let Some(previous) = schemas.get(&id) { - if previous != &expected { - return Err("inconsistent relation schemas".into()); - } - continue; - } - schemas.insert(id, expected.clone()); - let (payload, inputs) = match self.entry.nodes.get(&id) { - Some(QueryPlanNode::Relational { - input, - operation, - input_schema, - output_schema, - }) => { - if output_schema != &expected { - return Err("relational output schema mismatch".into()); - } - let operation = - serde_json::from_value(operation.clone()).map_err(|e| e.to_string())?; - ( - Payload::Value { operation }, - vec![(*input, input_schema.clone())], - ) - } - Some(QueryPlanNode::RelationalJoin { - inputs, - join_kind, - pred, - left_schema, - right_schema, - output_schema, - pruning, - }) => { - if output_schema != &expected { - return Err("join output schema mismatch".into()); - } - if pruning.is_some() { - return Err( - "candidate pruning is not bound for this relation source".into() - ); - } - ( - Payload::RelationalJoin { - join_kind: join_kind.clone(), - pred: serde_json::from_value(pred.clone()) - .map_err(|e| e.to_string())?, - pruning: None, - }, - vec![ - (inputs[0], left_schema.clone()), - (inputs[1], right_schema.clone()), - ], - ) - } - Some(_) => { - sources.push(id); - continue; - } - None => return Err("missing relation node".into()), - }; - let node = ExecutableDagNode { - id: PostAsapNodeId( - u32::try_from(id.0).map_err(|_| "relation node ID exceeds Planner range")?, - ), - payload, - output_state: ExecutionDataState::QUERY_ROWS, - output_schema: expected, - guarantee: None, - }; - let op = dag::planner::compile_node( - &node, - &inputs - .iter() - .map(|(_, schema)| Arc::new(schema.clone())) - .collect::>(), - ) + let compiled = self + .entry + .recover_relational_physical_dag() .map_err(|e| e.to_string())?; - operations.insert( - id, - (inputs.iter().map(|(id, _)| id.0).collect::>(), op), - ); - pending.extend(inputs); + if compiled.roots() != [root.0] + || compiled + .output_contract(root.0) + .map_err(|e| e.to_string())? + .schema + .as_ref() + != expected + { + return Err("requested relation differs from installed physical root".into()); } - let compiled = - asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( - sources - .iter() - .map(|id| { - ( - id.0, - asap_physical_operators::physical_planner::InputContract::bounded( - Arc::new(schemas[id].clone()), - ), - ) - }) - .collect(), - operations.into_iter().map(|(id, op)| (id.0, op)).collect(), - vec![root.0], - ) - .map_err(|e| e.to_string())?; + let schemas: BTreeMap<_, _> = compiled + .input_contracts() + .map(|(id, contract)| (QueryNodeId(id), contract.schema.as_ref().clone())) + .collect(); + let sources: Vec<_> = schemas.keys().copied().collect(); let mut resolved_inputs = BTreeMap::new(); let context = dag::RunContext::new( dag::Scope::Query { @@ -237,6 +142,10 @@ impl RelationDagExecutor<'_> { let mut first = true; for id in sources { let relation = self.execute_source(id, &schemas[&id], &stored)?; + #[cfg(test)] + if let Some(publish) = AFTER_BRANCH.with(|hook| hook.take()) { + publish(self.index); + } coverage = if first { first = false; relation.coverage @@ -584,6 +493,7 @@ mod tests { fn external_entry(schema: &SummarySchema) -> QueryPlanEntry { QueryPlanEntry { + physical_dag: None, language: QueryLanguage::ClickHouseSql, query_id: "shared-external".into(), canonical_query: "SELECT x".into(), @@ -618,7 +528,8 @@ mod tests { #[test] fn relation_dag_memoizes_a_shared_node_and_enforces_one_edge_schema() { let schema = relation_schema("x"); - let entry = external_entry(&schema); + let mut entry = external_entry(&schema); + entry.compile_relational_physical_dag().unwrap(); let relation = ClickHouseRelation::from_json_compact( &schema, br#"{"meta":[{"name":"x","type":"Int64"}],"data":[[1]]}"#, @@ -673,6 +584,9 @@ mod tests { }, ); entry.root = QueryNodeId(1); + entry.compile_relational_physical_dag().unwrap(); + let encoded = serde_json::to_vec(&entry).unwrap(); + let entry: QueryPlanEntry = serde_json::from_slice(&encoded).unwrap(); let relation = ClickHouseRelation::from_json_compact( &schema, br#"{"meta":[{"name":"x","type":"Int64"}],"data":[[1],[2]]}"#, @@ -706,6 +620,32 @@ mod tests { ); } + // Missing installed computation must not trigger serving-time re-lowering. + #[test] + fn relation_execution_requires_an_installed_physical_dag() { + let schema = relation_schema("x"); + let entry = external_entry(&schema); + let relation = ClickHouseRelation::from_json_compact( + &schema, + br#"{"meta":[{"name":"x","type":"Int64"}],"data":[[1]]}"#, + ) + .unwrap(); + let prepared = BTreeMap::from([(QueryNodeId(0), relation)]); + let index = SketchStore::new(); + let mut executor = RelationDagExecutor { + index: &index, + entry: &entry, + prepared: &prepared, + t0_ms: 0, + t1_ms: 1, + is_cumulative: false, + memo: BTreeMap::new(), + schemas: BTreeMap::new(), + evaluations: BTreeMap::new(), + }; + assert!(executor.execute(entry.root, &schema).is_err()); + } + /// A publication between query branches invalidates the entire result. #[test] fn query_wide_fence_rejects_publication_between_join_branches() { @@ -733,6 +673,7 @@ mod tests { }, ); entry.root = QueryNodeId(1); + entry.compile_relational_physical_dag().unwrap(); let relation = ClickHouseRelation::from_json_compact( &schema, br#"{"meta":[{"name":"x","type":"Int64"}],"data":[[1],[2]]}"#, diff --git a/data_plane/src/query_engines/asap_query_engine/engine.rs b/data_plane/src/query_engines/asap_query_engine/engine.rs index 0d8ca7ec2..df980fcbb 100644 --- a/data_plane/src/query_engines/asap_query_engine/engine.rs +++ b/data_plane/src/query_engines/asap_query_engine/engine.rs @@ -105,6 +105,7 @@ mod forwarding_policy_tests { let query = "sum(rate(m[5m]))"; let canonical = asap_types::query_plan::canonical_promql(query).unwrap(); let entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: canonical.clone(), canonical_query: canonical, @@ -399,11 +400,28 @@ impl ASAPQueryEngine { .summary_store .as_ref() .map(|index| index.summary_update_revision()); - let result = super::logical_dag::execute_installed( - entry, - leaves, - at, - |root, evaluation_ms| { + let result = if entry + .physical_vector_binding() + .is_some_and(|(inputs, _, _)| { + inputs.iter().all(|input| { + matches!( + entry.nodes.get(input), + Some(asap_types::query_plan::QueryPlanNode::ReadMaterialization { .. }) + ) + }) + }) { + let store = self.summary_store.as_ref().ok_or_else(|| { + EngineError::capability_miss("native_stored", "summary store unavailable") + })?; + super::logical_dag::native_values::execute_stored( + entry, + physical.query_plan.plan_id, + physical.query_plan.plan_version, + store, + at, + ) + } else { + super::logical_dag::execute_installed(entry, leaves, at, |root, evaluation_ms| { if let Some(asap_types::query_plan::QueryPlanNode::Logical { operator: asap_types::query_plan::residual::ResidualQueryOperator::CurrentSeries { @@ -515,8 +533,8 @@ impl ASAPQueryEngine { evaluation_ms, false, )) - }, - ); + }) + }; let current = self .summary_store .as_ref() @@ -2739,6 +2757,7 @@ mod range_stitch_tests { plan.query_plan.entries.insert( asap_types::query_plan::QueryPlan::catalog_key(QueryLanguage::MetricsQl, &identity), QueryPlanEntry { + physical_dag: None, language: QueryLanguage::MetricsQl, query_id: "vm-scalar".into(), canonical_query: identity.clone(), diff --git a/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs b/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs index 8e9fe425b..a782eb936 100644 --- a/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs +++ b/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs @@ -87,7 +87,8 @@ fn leaves( }, // A join is a typed composition node rather than a Logical // wrapper, but its value input can still be a Prometheus leaf. - QueryPlanNode::PhysicalFragment { inputs, .. } => { + QueryPlanNode::PhysicalFragment { inputs, .. } + | QueryPlanNode::Physical { inputs, .. } => { pending.extend(inputs.iter().map(|input| (*input, at))); } QueryPlanNode::RelationalJoin { inputs, .. } => { @@ -450,6 +451,7 @@ mod tests { use asap_types::query_plan::{FallbackPolicy, InstantExecution}; fn entry(nodes: BTreeMap) -> QueryPlanEntry { QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "remote-cut".into(), canonical_query: "a / b".into(), diff --git a/data_plane/src/query_engines/asap_query_engine/live_serve.rs b/data_plane/src/query_engines/asap_query_engine/live_serve.rs index 8c9e26466..fdf481272 100644 --- a/data_plane/src/query_engines/asap_query_engine/live_serve.rs +++ b/data_plane/src/query_engines/asap_query_engine/live_serve.rs @@ -174,6 +174,7 @@ mod tests { ); } let entry = asap_types::query_plan::QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "q-sum".into(), canonical_query: "sum_over_time(bytes[1s])".into(), diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs index befee8a21..6c74edc79 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs @@ -1,5 +1,5 @@ //! Executes the installed typed logical DAG. No serving-time PromQL parsing. -mod native_values; +pub(super) mod native_values; use crate::query_engines::{ query_result::{InstantVectorElement, QueryResult}, EngineError, @@ -96,6 +96,14 @@ pub(crate) fn execute_installed( where F: FnMut(QueryNodeId, u64) -> Result, { + if entry.population_snapshot().is_some() || entry.physical_vector_binding().is_some() { + if !leaves.is_empty() { + return Err(miss( + "population physical input must use its installed source binding", + )); + } + return native_values::execute_vectors(entry, at, callback); + } execute_values(entry, leaves, at, callback) } @@ -1446,6 +1454,7 @@ mod topk_tests { TemporalOperation::Rate, ] { let entry = QueryPlanEntry { + physical_dag: None, language, query_id: "labels".into(), canonical_query: "test".into(), @@ -1523,6 +1532,7 @@ mod topk_tests { let summary = QueryNodeId(0); let root = QueryNodeId(1); let entry = QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "summary-rate-topk".into(), canonical_query: "topk(2, rate(requests_total[5m]))".into(), @@ -1723,6 +1733,7 @@ mod topk_tests { let filter = QueryNodeId(2); let root = QueryNodeId(3); let entry = QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "candidate-topk".into(), canonical_query: "topk(1, rate(requests_total[5m]))".into(), @@ -1889,6 +1900,7 @@ mod shared_runtime_tests { fn entry() -> QueryPlanEntry { QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "shared-grid".into(), canonical_query: "shared-grid".into(), diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs index 621562ef6..b0dd8febd 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs @@ -1,4 +1,4 @@ -//! Bind protocol vectors to native batch operators; computation stays in Planner. +//! Bind deployment inputs to retained native programs and decode PromQL results. use super::{grouping_key, miss, EngineError, Grouping, Labels, Vector}; use asap_physical_operators::dag::{ self, batch_execution, @@ -512,6 +512,23 @@ mod tests { } } + // The complete selected-candidate adapter must not classify a byte budget as capability. + #[test] + fn selected_candidate_input_budget_is_a_terminal_error() { + let plan = CompiledPhysicalDag::decode(&sorted()).unwrap(); + let error = execute_batches(&plan, 1, 1, 42, |_, schema| { + Ok(Batch::try_new( + schema.clone(), + vec![vec![Value::Float64(1.)]], + )?) + }) + .expect_err("input must exceed the byte budget"); + assert!( + matches!(error, EngineError::Physical(Error::MemoryLimit)), + "{error}" + ); + } + // Count-like values are bound as integers only when the protocol sample is exact. #[test] fn integer_input_binding_preserves_type_and_rejects_rounding() { @@ -566,3 +583,236 @@ mod tests { ); } } + +pub(super) fn execute_vectors( + entry: &asap_types::query_plan::QueryPlanEntry, + at: u64, + mut callback: F, +) -> Result< + ( + crate::query_engines::query_result::QueryResult, + super::ExecutionStats, + ), + EngineError, +> +where + F: FnMut( + asap_types::query_plan::QueryNodeId, + u64, + ) -> Result, +{ + use asap_physical_operators::physical_planner::promql_rows::series_row; + use std::collections::BTreeMap; + let (program, bindings, max_bytes) = + if let Some((inputs, source_nodes, budget)) = entry.physical_vector_binding() { + ( + entry + .recover_vector_physical_dag() + .map_err(|e| miss(e.to_string()))?, + source_nodes + .iter() + .copied() + .zip(inputs.iter().copied()) + .collect::>(), + budget, + ) + } else { + let program = entry + .recover_population_physical_dag() + .map_err(|e| miss(e.to_string()))?; + let source = program.input_contracts().next().unwrap().0; + ( + program, + BTreeMap::from([(source, entry.root)]), + entry.population_snapshot().unwrap().max_bytes, + ) + }; + execute_batches( + &program, + max_bytes, + bindings.len(), + at, + |input_id, schema| { + let values = super::vector(super::from_result(callback(bindings[&input_id], at)?)?)?; + let rows = values + .into_iter() + .map(|(labels, value)| { + series_row( + schema, + &labels, + i64::try_from(at).map_err(|_| miss("evaluation timestamp overflow"))?, + value, + ) + .map_err(|e| miss(e.to_string())) + }) + .collect::, _>>()?; + Batch::try_new(schema.clone(), rows).map_err(EngineError::from) + }, + ) +} + +pub(in crate::query_engines::asap_query_engine) fn execute_stored( + entry: &asap_types::query_plan::QueryPlanEntry, + plan_id: u64, + plan_version: u64, + store: &crate::storage_engines::sketch_db::index::SketchStore, + at: u64, +) -> Result< + ( + crate::query_engines::query_result::QueryResult, + super::ExecutionStats, + ), + EngineError, +> { + let (inputs, sources, max_bytes) = entry + .physical_vector_binding() + .ok_or_else(|| miss("missing native stored binding"))?; + let program = entry + .recover_vector_physical_dag() + .map_err(|e| miss(e.to_string()))?; + execute_batches(&program, max_bytes, inputs.len(), at, |id, schema| { + let index = sources + .iter() + .position(|source| *source == id) + .ok_or_else(|| miss("native source is unbound"))?; + let Some(asap_types::query_plan::QueryPlanNode::ReadMaterialization { binding }) = + entry.nodes.get(&inputs[index]) + else { + return Err(miss("native stored source has no deployed summary binding")); + }; + let end = i64::try_from(at).map_err(|_| miss("native timestamp overflow"))?; + let start = at + .checked_sub(binding.window_ms) + .ok_or_else(|| miss("native window underflow"))?; + let address = asap_types::sds::StoredSummaryKey { + plan_id, + plan_version, + stored_output_id: binding.stored_output_reference.stored_output_id, + population: std::collections::BTreeMap::new(), + window: asap_types::sds::HalfOpenTimeRange { + start_ms: start as i64, + end_ms: end, + }, + }; + store + .read_bound_native_summary( + &address, + &binding.stored_output_reference, + schema.clone(), + max_bytes as usize, + ) + .map_err(miss) + }) +} + +fn execute_batches( + program: &asap_physical_operators::physical_planner::CompiledPhysicalDag, + max_bytes: u64, + input_count: usize, + at: u64, + mut input_batch: impl FnMut(u64, &Schema) -> Result, +) -> Result< + ( + crate::query_engines::query_result::QueryResult, + super::ExecutionStats, + ), + EngineError, +> { + use crate::{ + query_engines::query_result::{InstantVectorElement, QueryResult}, + storage_engines::types::KeyByLabelValues, + }; + use asap_physical_operators::physical_planner::{ + promql_rows::{decode_series_identity, SERIES_IDENTITY_COLUMN}, + Source, + }; + use futures::{executor::block_on, StreamExt}; + use std::collections::BTreeMap; + let at_signed = i64::try_from(at).map_err(|_| miss("evaluation timestamp overflow"))?; + let mut sources = BTreeMap::new(); + let mut input_bytes = 0usize; + for (input_id, input) in program.input_contracts() { + let batch = input_batch(input_id, &input.schema)?; + input_bytes = input_bytes + .checked_add(batch.bytes()) + .ok_or(asap_physical_operators::Error::MemoryLimit)?; + if input_bytes > max_bytes as usize { + return Err(asap_physical_operators::Error::MemoryLimit.into()); + } + let source = + Operator::source(input.schema.clone(), vec![batch]).map_err(EngineError::from)?; + sources.insert(input_id, Box::new(source) as Source<'_>); + } + let graph = program.instantiate(sources).map_err(EngineError::from)?; + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: at_signed, + revision: 0, + }, + dag::Limits { + max_bytes: max_bytes as usize, + ..dag::Limits::default() + }, + ) + .map_err(EngineError::from)?; + let mut stream = graph + .execute(program.roots(), context) + .map_err(EngineError::from)? + .remove(0); + let values = block_on(async { + let mut values = Vec::new(); + while let Some(batch) = stream.next().await { + let batch = batch.map_err(EngineError::from)?; + let identity = batch + .schema() + .fields + .iter() + .position(|field| field.name == SERIES_IDENTITY_COLUMN); + let value = batch + .schema() + .fields + .iter() + .position(|field| field.dtype == SummaryFamilyType::Plain(DataType::Float64)) + .ok_or_else(|| miss("physical output loses sample value"))?; + for row in batch.rows() { + let Value::Float64(sample) = &row[value] else { + return Err(miss("invalid physical result value")); + }; + let labels = if let Some(identity) = identity { + let Value::Utf8(encoded) = &row[identity] else { + return Err(miss("invalid physical series identity")); + }; + decode_series_identity(encoded).map_err(EngineError::from)? + } else { + batch + .schema() + .fields + .iter() + .zip(row) + .filter_map(|(field, value)| match value { + Value::Utf8(label) if !label.is_empty() => { + Some((field.name.clone(), label.to_string())) + } + _ => None, + }) + .collect() + }; + values.push( + InstantVectorElement::new( + KeyByLabelValues::new_with_labels(labels.values().cloned().collect()), + *sample, + ) + .with_label_keys_override(labels.into_keys().collect()), + ); + } + } + Ok(values) + })?; + Ok(( + QueryResult::vector(values, at), + super::ExecutionStats { + summary_readout_evaluations: input_count, + ..Default::default() + }, + )) +} diff --git a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs index 10a8f3d80..e2a11491b 100644 --- a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs +++ b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs @@ -352,10 +352,11 @@ impl PhysicalQueryRuntime<'_> { item_labels: merged_item_labels.unwrap_or_default(), }) } - QueryPlanNode::Logical { .. } + QueryPlanNode::PhysicalFragment { .. } + | QueryPlanNode::Physical { .. } + | QueryPlanNode::Logical { .. } | QueryPlanNode::Relational { .. } | QueryPlanNode::ExternalExact { .. } - | QueryPlanNode::PhysicalFragment { .. } | QueryPlanNode::RelationalJoin { .. } => Err(PhysicalNodeError::Fallback( "logical node requires installed logical runtime".into(), )), @@ -964,6 +965,7 @@ mod tests { }, }; let entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "resource-test".into(), canonical_query: "1".into(), @@ -1244,6 +1246,7 @@ mod tests { #[test] fn multi_root_readout_uses_one_parent_context() { let entry = asap_types::query_plan::QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "shared".into(), canonical_query: "1+1".into(), @@ -1528,6 +1531,7 @@ mod tests { .unwrap(); idx.register(metadata); let mut entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::MetricsQl, query_id: "quantile".into(), canonical_query: "quantile_over_time(0.9, latency_ms[1s])".into(), @@ -1966,6 +1970,7 @@ mod tests { } let entry = asap_types::query_plan::QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "q-rate".into(), canonical_query: "rate(requests_total[1m])".into(), @@ -2067,6 +2072,7 @@ mod tests { idx.append_precompute(7, BTreeMap::new(), (0, 60_000), Box::new(accumulator)); let entry = asap_types::query_plan::QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "q-rate".into(), canonical_query: "rate(requests_total[1m])".into(), diff --git a/data_plane/src/query_engines/asap_query_engine/test_plan.rs b/data_plane/src/query_engines/asap_query_engine/test_plan.rs index ef0a54c52..7da038b36 100644 --- a/data_plane/src/query_engines/asap_query_engine/test_plan.rs +++ b/data_plane/src/query_engines/asap_query_engine/test_plan.rs @@ -44,6 +44,7 @@ pub(super) fn entry( ) -> QueryPlanEntry { let canonical = canonical_promql(query).unwrap(); QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: canonical.clone(), canonical_query: canonical, diff --git a/data_plane/src/storage_engines/sketch_db/current_series.rs b/data_plane/src/storage_engines/sketch_db/current_series.rs index 2b99ea3ed..d3885dc06 100644 --- a/data_plane/src/storage_engines/sketch_db/current_series.rs +++ b/data_plane/src/storage_engines/sketch_db/current_series.rs @@ -194,6 +194,14 @@ impl Population { } } fn read(&mut self, readout: &SeriesReadout) -> Vector { + if matches!(readout, SeriesReadout::Snapshot) { + return self + .groups + .values() + .flat_map(|group| group.ordered.iter()) + .map(|member| (member.labels.clone(), member.value)) + .collect(); + } let mut result = vec![]; for (labels, group) in &mut self.groups { if group.cached.is_none() { @@ -221,6 +229,7 @@ impl Population { } let (values, top, sum, average) = group.cached.as_ref().unwrap(); match readout { + SeriesReadout::Snapshot => unreachable!("snapshot returned above"), SeriesReadout::Quantile { q } => { // `values` is only populated for a quantile-carrying population. // `ResidualQueryOperator::validate` rejects the mismatched pairing at @@ -586,6 +595,7 @@ mod tests { plan.entries.insert( "test".into(), QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "test".into(), canonical_query: "quantile by (job) (0.5, a)".into(), diff --git a/data_plane/src/storage_engines/sketch_db/index/maintenance.rs b/data_plane/src/storage_engines/sketch_db/index/maintenance.rs index 3224b7248..ec53f5575 100644 --- a/data_plane/src/storage_engines/sketch_db/index/maintenance.rs +++ b/data_plane/src/storage_engines/sketch_db/index/maintenance.rs @@ -275,6 +275,28 @@ impl SketchStore { generation: &Arc, expected_windows: &BTreeSet<(u64, u64)>, group: &BTreeMap, + ) -> Result { + self.read_frozen_windows_with( + sid, + definition, + generation, + expected_windows, + group, + |name, _, bytes| { + reconstruct_exact_agg(name, bytes) + .ok_or_else(|| "immutable input accumulator cannot be decoded".to_string()) + }, + ) + } + + pub(super) fn read_frozen_windows_with( + &self, + sid: u64, + definition: StoredOutputId, + generation: &Arc, + expected_windows: &BTreeSet<(u64, u64)>, + group: &BTreeMap, + mut decode: impl FnMut(&str, u8, &[u8]) -> Result, String>, ) -> Result { let start_ms = expected_windows .iter() @@ -364,8 +386,10 @@ impl SketchStore { .part_cache .get_or_load(part.part_id) .map_err(|e| e.to_string())?; - for record in reader.index_records() { - if record.agg_id != sid || record.start_ts < start_ms || record.end_ts > end_ms { + for record in reader.window_records(sid, start_ms, end_ms) { + // A bound full-window read selects its exact coordinates; other + // overlapping snapshots do not contribute to this computation. + if !expected_windows.contains(&(record.start_ts, record.end_ts)) { continue; } let entry = reader.load_entry(&record).map_err(|e| e.to_string())?; @@ -377,8 +401,11 @@ impl SketchStore { if population != *group { continue; } - let state = reconstruct_exact_agg(&entry.sketch_type_name, &entry.sketch_bytes) - .ok_or("immutable input accumulator cannot be decoded")?; + let state = decode( + &entry.sketch_type_name, + entry.encoding_tag, + &entry.sketch_bytes, + )?; if windows .insert((record.start_ts, record.end_ts), Arc::from(state)) .is_some() @@ -720,9 +747,13 @@ impl SketchStore { labels: labels.into_values().collect(), }), sketch_type_name: state.type_name().to_string(), - encoding_tag: match binding.metadata.agg_kind { - AggKind::Sketch { .. } => encoding_to_tag(SketchEncoding::MsgpackFull), - AggKind::ExactAgg { .. } => 0, + encoding_tag: if state.type_name() == "NativePhysicalOutputV1" { + encoding_to_tag(SketchEncoding::NativeBatchV1) + } else { + match binding.metadata.agg_kind { + AggKind::Sketch { .. } => encoding_to_tag(SketchEncoding::MsgpackFull), + AggKind::ExactAgg { .. } => 0, + } }, sketch_bytes: bytes, }], diff --git a/data_plane/src/storage_engines/sketch_db/index/mod.rs b/data_plane/src/storage_engines/sketch_db/index/mod.rs index 6d5da0176..bd33de294 100644 --- a/data_plane/src/storage_engines/sketch_db/index/mod.rs +++ b/data_plane/src/storage_engines/sketch_db/index/mod.rs @@ -3865,6 +3865,7 @@ pub use SummarySeriesMetadata as SketchInstanceMetadata; // alongside the store that uses it. mod admission; mod maintenance; +mod native; pub(crate) use maintenance::{CompleteRawMaintenanceCohort, FrozenExactWindows}; pub mod epoch_columnar; diff --git a/data_plane/src/storage_engines/sketch_db/index/native.rs b/data_plane/src/storage_engines/sketch_db/index/native.rs new file mode 100644 index 000000000..a93bef09a --- /dev/null +++ b/data_plane/src/storage_engines/sketch_db/index/native.rs @@ -0,0 +1,670 @@ +//! Bound native summary snapshots use the existing immutable publication and +//! recovery path. Window snapshots are not additive hot-state updates. +use super::*; +use crate::drivers::ingest::series_resolver::SeriesIdResolver; +use asap_physical_operators::{ + stored_state::native::{decode_batch, encode_batch}, + values::{Batch, Schema, Value}, + AggregateCore, SerializableToSink, +}; + +const NATIVE_OUTPUT_TYPE: &str = "NativePhysicalOutputV1"; +const NATIVE_OUTPUT_TAG: u8 = persistence::part::encoding_tag::NATIVE_BATCH_V1; + +#[derive(Clone)] +struct NativeSummaryOutput { + batch: Batch, + bytes: Vec, + kind: AggregationType, +} +impl NativeSummaryOutput { + fn new(batch: Batch, max_bytes: usize) -> Result { + let families = batch + .schema() + .fields + .iter() + .filter_map(|field| { + (!matches!( + field.dtype, + planner_types::post_asap::SummaryFamilyType::Plain(_) + )) + .then_some(&field.dtype) + }) + .collect::>(); + let [family] = families.as_slice() else { + return Err("native stored batch requires one summary column".into()); + }; + use planner_types::post_asap::{SketchAlgorithm, SummaryFamilyType}; + let schema_kind = match family { + SummaryFamilyType::Sketch(sketch, _) => match sketch.algorithm() { + SketchAlgorithm::CmsWithHeap => Some(AggregationType::CountMinSketchWithHeap), + SketchAlgorithm::CountSketchWithHeap => Some(AggregationType::CountSketchWithHeap), + _ => None, + }, + SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _) => { + Some(AggregationType::Sum) + } + _ => None, + }; + let mut kind = schema_kind; + for row in batch.rows() { + let states = row + .iter() + .filter_map(|value| match value { + Value::Summary { state, .. } => Some(state), + _ => None, + }) + .collect::>(); + let [state] = states.as_slice() else { + return Err("native stored row requires one summary state".into()); + }; + let row_kind = state.get_accumulator_type(); + if kind.is_some_and(|kind| kind != row_kind) { + return Err("native stored rows have different summary families".into()); + } + kind = Some(row_kind); + } + let kind = kind.ok_or("empty native batch has no supported summary family")?; + let bytes = encode_batch(&batch).map_err(|error| error.to_string())?; + if bytes.len() > max_bytes || batch.bytes() > max_bytes { + return Err("native summary exceeds publication/read budget".into()); + } + Ok(Self { batch, bytes, kind }) + } + fn validate_group(&self, group: &BTreeMap) -> Result<(), String> { + for (key, value) in group { + let column = self + .batch + .schema() + .fields + .iter() + .position(|field| &field.name == key) + .ok_or("native output is missing its stored group key")?; + if self + .batch + .rows() + .iter() + .any(|row| !matches!(&row[column], Value::Utf8(actual) if actual.as_ref() == value)) + { + return Err("native output group differs from stored address".into()); + } + } + Ok(()) + } +} +impl SerializableToSink for NativeSummaryOutput { + fn serialize_to_bytes(&self) -> Vec { + self.bytes.clone() + } + fn serialize_to_json(&self) -> serde_json::Value { + serde_json::json!({"format": NATIVE_OUTPUT_TYPE, "bytes": self.bytes}) + } +} +impl AggregateCore for NativeSummaryOutput { + fn clone_boxed_core(&self) -> Box { + Box::new(self.clone()) + } + fn type_name(&self) -> &'static str { + NATIVE_OUTPUT_TYPE + } + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn get_accumulator_type(&self) -> AggregationType { + self.kind + } + fn get_keys(&self) -> Option> { + None + } + fn approx_memory_bytes(&self) -> usize { + self.bytes.len() + self.batch.bytes() + } + fn merge_with( + &self, + _: &dyn AggregateCore, + ) -> Result, Box> { + Err("native output snapshots require an explicit physical merge operator".into()) + } + fn query_statistic( + &self, + _: asap_types::Statistic, + _: &Option, + _: &HashMap, + ) -> Result> { + Err("native output readout requires the installed physical DAG".into()) + } +} + +impl SketchStore { + /// Publish a finalized native result only after the complete raw input + /// cohort is durable. Existing publication fences prevent duplicate commits. + pub fn publish_native_summary_output( + &self, + resolver: &SeriesIdResolver, + config: &asap_types::PrecomputeMaterialization, + output: &crate::storage_engines::types::PrecomputedOutput, + batch: Batch, + max_bytes: usize, + ) -> Result { + use sha2::{Digest, Sha256}; + let generation = output + .catalog_generation + .as_ref() + .ok_or("native output requires a catalog generation")?; + let program = config + .derived_input + .as_ref() + .ok_or("native output requires installed input lineage")?; + let window = (output.start_timestamp, output.end_timestamp); + let cohort = + self.read_complete_raw_maintenance_cohort(generation, &program.inputs, window)?; + let (population_key, group) = build_attrs_fp_and_label_map(config, output)?; + let state = NativeSummaryOutput::new(batch, max_bytes)?; + let family = config.accumulator_spec().map_err(|e| e.to_string())?.family; + if state + .batch + .schema() + .fields + .iter() + .filter(|field| { + !matches!( + field.dtype, + planner_types::post_asap::SummaryFamilyType::Plain(_) + ) + }) + .any(|field| field.dtype != family) + { + return Err("native output schema differs from installed definition".into()); + } + for value in state.batch.rows().iter().flatten() { + if let Value::Summary { family: actual, .. } = value { + if actual != &family { + return Err("native output family differs from installed definition".into()); + } + } + } + state.validate_group(&group)?; + let sid = self.resolve_output_storage_handle( + resolver, + config.policy_fingerprint().into(), + &population_key, + Some(generation), + )?; + let mut digest = Sha256::new(); + digest.update(serde_json::to_vec(&(program, window)).map_err(|e| e.to_string())?); + for input in cohort.inputs() { + digest.update( + serde_json::to_vec(&(&input.stored_output_reference, &input.group)) + .map_err(|e| e.to_string())?, + ); + for (window, state) in &input.windows { + digest.update(serde_json::to_vec(window).map_err(|e| e.to_string())?); + let bytes = state.serialize_to_bytes(); + digest.update((bytes.len() as u64).to_le_bytes()); + digest.update(bytes); + } + } + self.publish_complete_raw_maintenance_output( + sid, + config, + output, + &state, + &cohort, + digest.finalize().into(), + ) + } + + /// Resolve the installed output/group prefix without minting a new handle, + /// then read exactly the requested immutable window and validate its format. + pub fn read_native_summary_output( + &self, + resolver: &SeriesIdResolver, + address: &asap_types::sds::StoredSummaryKey, + reference: &StoredOutputReference, + expected_schema: Schema, + max_bytes: usize, + ) -> Result { + address.validate().map_err(|e| e.to_string())?; + let (catalog, generation) = self + .descriptors + .authoritative_snapshot() + .ok_or("native read requires an installed catalog")?; + if (address.plan_id, address.plan_version) != (generation.plan_id, generation.plan_version) + || address.stored_output_id != reference.stored_output_id + || catalog + .output_reference(address.stored_output_id) + .map_err(|e| e.to_string())? + != *reference + { + return Err("native read differs from its installed output reference".into()); + } + let identity = &catalog.outputs[&address.stored_output_id]; + let data = &catalog.data_descriptors[&identity.data_descriptor_id]; + let pairs: Vec<_> = address + .population + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + let population_key = crate::drivers::ingest::population_attrs_fingerprint( + data.population_key_encoding, + &pairs, + )?; + let identity = serde_json::to_string(&(address.plan_id, address.plan_version, reference)) + .map_err(|e| e.to_string())?; + let sid = resolver + .lookup("stored-output", &population_key, &identity) + .ok_or("native stored output/group is unavailable")?; + self.read_native_summary_handle(sid, address, reference, expected_schema, max_bytes) + } + + /// A complete native batch is stored atomically under one global address. + /// Logical grouping remains in the batch; reads never allocate a resolver ID. + pub fn read_bound_native_summary( + &self, + address: &asap_types::sds::StoredSummaryKey, + reference: &StoredOutputReference, + expected_schema: Schema, + max_bytes: usize, + ) -> Result { + if !address.population.is_empty() { + return Err("native batch binding requires the complete stored output".into()); + } + let handles = self.storage_handles_for_output(reference); + let [sid] = handles.as_slice() else { + return Err("native stored output is absent or ambiguous".into()); + }; + self.read_native_summary_handle(*sid, address, reference, expected_schema, max_bytes) + } + + fn read_native_summary_handle( + &self, + sid: u64, + address: &asap_types::sds::StoredSummaryKey, + reference: &StoredOutputReference, + expected_schema: Schema, + max_bytes: usize, + ) -> Result { + address.validate().map_err(|e| e.to_string())?; + let generation = self + .active_catalog_generation() + .ok_or("native read has no active generation")?; + if (address.plan_id, address.plan_version) != (generation.plan_id, generation.plan_version) + || address.stored_output_id != reference.stored_output_id + || self.stored_output_for_handle(sid).as_ref() != Some(reference) + { + return Err("native read differs from its installed output binding".into()); + } + let window = ( + u64::try_from(address.window.start_ms).map_err(|_| "negative native window")?, + u64::try_from(address.window.end_ms).map_err(|_| "negative native window")?, + ); + let frozen = self.read_frozen_windows_with( + sid, + address.stored_output_id, + &generation, + &BTreeSet::from([window]), + &address.population, + |name, tag, bytes| { + if name != NATIVE_OUTPUT_TYPE || tag != NATIVE_OUTPUT_TAG { + return Err("stored record is not a native physical output".into()); + } + let batch = decode_batch(bytes, expected_schema.clone(), max_bytes) + .map_err(|e| e.to_string())?; + let output = NativeSummaryOutput::new(batch, max_bytes)?; + output.validate_group(&address.population)?; + Ok(Box::new(output) as Box) + }, + )?; + let state = frozen.windows[&window] + .as_any() + .downcast_ref::() + .ok_or("native output decoder mismatch")?; + Ok(state.batch.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage_engines::types::PrecomputedOutput; + use asap_physical_operators::{ + dag::{operators::Operator, Limits, RunContext, Scope}, + physical_planner::{CompiledPhysicalDag, InputContract, Source}, + summary_kernels::SumAccumulator, + }; + use futures::{executor::block_on, StreamExt}; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::DataType, + }; + + fn run(input: Batch, operator: Operator) -> Batch { + let schema = input.schema().clone(); + let plan = CompiledPhysicalDag::from_operators( + BTreeMap::from([(0, InputContract::bounded(schema.clone()))]), + BTreeMap::from([(1, (vec![0], operator))]), + vec![1], + ) + .unwrap(); + let plan = CompiledPhysicalDag::decode(&plan.encode().unwrap()).unwrap(); + let graph = plan + .instantiate(BTreeMap::from([( + 0, + Box::new(Operator::source(schema, vec![input]).unwrap()) as Source<'_>, + )])) + .unwrap(); + let context = RunContext::new( + Scope::Query { + evaluation_time_ms: 60_000, + revision: 0, + }, + Limits::default(), + ) + .unwrap(); + let stream = graph.execute(&[1], context).unwrap().remove(0); + let output = block_on(stream.collect::>()); + assert_eq!(output.len(), 1); + (*output.into_iter().next().unwrap().unwrap()).clone() + } + + // Group columns in the payload must agree with the bound record address. + #[test] + fn native_summary_payload_cannot_be_relabelled_as_another_group() { + let family = SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Sum, + planner_types::post_asap::ExactParams::Sum, + ); + let schema = Arc::new(SummarySchema { + fields: vec![ + SummaryField { + name: "job".into(), + dtype: SummaryFamilyType::Plain(DataType::Utf8), + nullable: false, + }, + SummaryField { + name: "state".into(), + dtype: family.clone(), + nullable: false, + }, + ], + time_index: None, + }); + let batch = Batch::try_new( + schema, + vec![vec![ + Value::Utf8("api".into()), + Value::Summary { + family, + state: Arc::new(SumAccumulator::new()), + }, + ]], + ) + .unwrap(); + let output = NativeSummaryOutput::new(batch, 1 << 20).unwrap(); + output + .validate_group(&BTreeMap::from([("job".into(), "api".into())])) + .unwrap(); + assert!(output + .validate_group(&BTreeMap::from([("job".into(), "worker".into())])) + .is_err()); + assert!(output + .validate_group(&BTreeMap::from([("unknown".into(), "api".into())])) + .is_err()); + } + + // Real durable source panes -> native build -> immutable publication -> bound + // lookup -> native readout, repeated after both store and resolver restart. + #[test] + fn native_summary_publication_and_bound_recovery_preserve_identity_and_format() { + let mut fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let mut query = fixture["query_workload"]["repeating_queries"][3].clone(); + query["query"] = "quantile(1.0, sum_over_time(immutable_value[1m]))".into(); + query["demand"]["fixed_interval_at"]["interval"] = 60_000.into(); + query["demand"]["fixed_interval_at"]["evaluation_phase"] = 0.into(); + fixture["query_workload"]["repeating_queries"] = serde_json::json!([query]); + let snapshot = serde_json::from_value(fixture).unwrap(); + let plan = crate::tests::test_utilities::planning::quoted_snapshot(snapshot, false) + .compile_promql() + .unwrap(); + let source = plan + .precompute_plan + .materializations + .iter() + .find(|c| c.derived_input.is_none()) + .unwrap(); + let target = plan + .precompute_plan + .materializations + .iter() + .find(|c| c.derived_input.is_some()) + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + let resolver_path = directory.path().join("resolver.jsonl"); + let resolver = SeriesIdResolver::open(resolver_path.clone()).unwrap(); + let persistence_config = || { + let mut config = persistence::config::SketchStorePersistenceConfig::with_memory_limit( + 1 << 24, + directory.path().join("store"), + ); + config.delete_older_than_ms = None; + config.hot_window_ms = None; + config + }; + let store = Arc::new(SketchStore::new()); + store + .install_summary_catalog(Arc::new(plan.summary_catalog.clone())) + .unwrap(); + let generation = store.active_catalog_generation().unwrap(); + let mut persistence = store.start_persistence(persistence_config()).unwrap(); + for (instance, value) in [("a", 0.125), ("b", 0.25)] { + let group = BTreeMap::from([("instance".to_string(), instance.to_string())]); + let coordinate = asap_types::sds::SummaryInstanceCoordinates { + stored_output_id: source.policy_fingerprint().into(), + time_range: HalfOpenTimeRange { + start_ms: 0, + end_ms: 60_000, + }, + group_values: group.clone(), + }; + let revision = store + .admit_summary_updates(&generation, BTreeSet::from([coordinate.clone()])) + .unwrap(); + let mut output = PrecomputedOutput::new(0, 60_000, None, source.policy_fingerprint()); + output.population_labels = Some(group); + output.catalog_generation = Some(generation.clone()); + let mut state = SumAccumulator::new(); + state.update(value); + store + .publish_admitted_summary_update( + &generation, + &coordinate, + revision, + revision, + 120_000, + |writer| writer.ingest_precompute_with_series_id(700, source, &output, &state), + ) + .unwrap(); + } + let deadline = crate::tests::test_utilities::timing::deadline(Duration::from_secs(5)); + while !store.seal_finite_summary_input(&generation).unwrap() { + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(5)); + } + let cohort = store + .read_complete_raw_maintenance_cohort( + &generation, + &target.derived_input.as_ref().unwrap().inputs, + (0, 60_000), + ) + .unwrap(); + let values = cohort + .inputs() + .iter() + .flat_map(|input| input.windows.values()) + .map(|state| { + vec![Value::Float64( + state + .query_statistic(asap_types::Statistic::Sum, &None, &HashMap::new()) + .unwrap(), + )] + }) + .collect(); + let raw_schema = Arc::new(SummarySchema { + fields: vec![SummaryField { + name: "value".into(), + dtype: SummaryFamilyType::Plain(DataType::Float64), + nullable: false, + }], + time_index: None, + }); + let input = Batch::try_new(raw_schema.clone(), values).unwrap(); + let build = Operator::summary_build( + raw_schema, + target.accumulator_spec().unwrap().family, + 0, + None, + vec![], + ) + .unwrap(); + let output_batch = run(input, build); + let expected_schema = output_batch.schema().clone(); + let mut output = PrecomputedOutput::new(0, 60_000, None, target.policy_fingerprint()); + output.catalog_generation = Some(generation.clone()); + let before = persistence.manifest.live_parts().len(); + assert!(store + .publish_native_summary_output( + &resolver, + target, + &output, + output_batch.clone(), + 1 << 20 + ) + .unwrap()); + store + .publish_native_summary_output(&resolver, target, &output, output_batch, 1 << 20) + .unwrap(); + assert_eq!(persistence.manifest.live_parts().len(), before + 1); + let reference = plan + .summary_catalog + .output_reference(target.policy_fingerprint().into()) + .unwrap(); + let address = asap_types::sds::StoredSummaryKey { + plan_id: generation.plan_id, + plan_version: generation.plan_version, + stored_output_id: reference.stored_output_id, + population: BTreeMap::new(), + window: HalfOpenTimeRange { + start_ms: 0, + end_ms: 60_000, + }, + }; + let check = |store: &SketchStore, resolver: &SeriesIdResolver| { + let batch = store + .read_native_summary_output( + resolver, + &address, + &reference, + expected_schema.clone(), + 1 << 20, + ) + .unwrap(); + let direct = store + .read_bound_native_summary(&address, &reference, expected_schema.clone(), 1 << 20) + .unwrap(); + assert_eq!( + encode_batch(&direct).unwrap(), + encode_batch(&batch).unwrap() + ); + let handles = store.storage_handles_for_output(&reference); + assert_eq!(handles.len(), 1); + let frames = store.query_range(handles[0], 0, 60_000); + assert_eq!( + frames[0].samples[&60_000][0].encoding, + SketchEncoding::NativeBatchV1 + ); + let count = resolver.len(); + let mut other_group = address.clone(); + other_group + .population + .insert("instance".into(), "unknown".into()); + assert!(store + .read_native_summary_output( + resolver, + &other_group, + &reference, + expected_schema.clone(), + 1 << 20 + ) + .is_err()); + assert_eq!( + resolver.len(), + count, + "bound reads must not allocate outputs" + ); + let bytes = encode_batch(&batch).unwrap(); + let readout = Operator::readout( + batch.schema().clone(), + 0, + asap_types::Statistic::Quantile, + HashMap::from([("quantile".into(), "1.0".into())]), + ) + .unwrap(); + let values = run(batch, readout); + assert!(matches!(values.rows()[0][0], Value::Float64(v) if (v - 0.25).abs() < 0.01)); + let mut wrong = reference.clone(); + wrong.definition_id = plan + .summary_catalog + .output_reference(source.policy_fingerprint().into()) + .unwrap() + .definition_id; + assert!(store + .read_native_summary_output( + resolver, + &address, + &wrong, + expected_schema.clone(), + 1 << 20 + ) + .is_err()); + let mut missing = address.clone(); + missing.window.end_ms = 120_000; + assert!(store + .read_native_summary_output( + resolver, + &missing, + &reference, + expected_schema.clone(), + 1 << 20 + ) + .is_err()); + assert!(store + .read_native_summary_output( + resolver, + &address, + &reference, + expected_schema.clone(), + 1 + ) + .is_err()); + bytes + }; + let bytes = check(&store, &resolver); + persistence.shutdown(); + drop(store); + drop(resolver); + let recovered = Arc::new(SketchStore::new()); + recovered + .install_summary_catalog(Arc::new(plan.summary_catalog.clone())) + .unwrap(); + let mut persistence = recovered.start_persistence(persistence_config()).unwrap(); + let resolver = SeriesIdResolver::open(resolver_path).unwrap(); + assert_eq!(check(&recovered, &resolver), bytes); + persistence.shutdown(); + } +} diff --git a/data_plane/src/storage_engines/sketch_db/persistence/cache.rs b/data_plane/src/storage_engines/sketch_db/persistence/cache.rs index 0c235a451..ceda43c88 100644 --- a/data_plane/src/storage_engines/sketch_db/persistence/cache.rs +++ b/data_plane/src/storage_engines/sketch_db/persistence/cache.rs @@ -34,9 +34,9 @@ impl PartCache { Some( Cache::builder() .weigher(|_k: &PartId, v: &LoadedPart| -> u32 { - // Weight = sum of mmap'd bytes. Moka's weigher + // Include mmap bytes and the in-memory window index. The weigher // returns u32, so clamp large parts. - let len = v.meta.data_len + v.meta.index_len; + let len = v.resident_bytes(); len.min(u32::MAX as u64) as u32 }) .max_capacity(byte_budget) diff --git a/data_plane/src/storage_engines/sketch_db/persistence/part.rs b/data_plane/src/storage_engines/sketch_db/persistence/part.rs index c760668cb..6eae5f043 100644 --- a/data_plane/src/storage_engines/sketch_db/persistence/part.rs +++ b/data_plane/src/storage_engines/sketch_db/persistence/part.rs @@ -430,13 +430,15 @@ pub struct IndexRecord { pub data_offset: u64, } -/// mmap-backed reader for a single part. Cheap to construct (three -/// mmaps + one header parse), safe to share across threads via `Arc`. +/// mmap-backed reader with a sorted output/window index built once on open. +/// Safe to share across threads via `Arc`. pub struct PartReader { pub meta: PartMeta, pub part_dir: PathBuf, data_mmap: Arc, index_mmap: Arc, + // Older parts preserve flush order, so keep a separate sorted lookup. + window_index: Vec, } impl std::fmt::Debug for PartReader { @@ -479,12 +481,25 @@ impl PartReader { ))); } - Ok(Self { + let mut reader = Self { meta, part_dir: part_dir.to_path_buf(), data_mmap: Arc::new(data_mmap), index_mmap: Arc::new(index_mmap), - }) + window_index: Vec::new(), + }; + let mut positions: Vec<_> = (0..reader.meta.num_entries as usize).collect(); + positions.sort_unstable_by_key(|&position| { + let record = reader.index_record(position); + ( + record.agg_id, + record.start_ts, + record.end_ts, + record.data_offset, + ) + }); + reader.window_index = positions; + Ok(reader) } /// Read and verify just the meta.bin header (cheap — 64 bytes). @@ -537,28 +552,51 @@ impl PartReader { }) } - /// Return all index records. Small (32 B × num_entries) — cheap to - /// materialize. - pub fn index_records(&self) -> Vec { - let n = self.meta.num_entries as usize; - let mut out = Vec::with_capacity(n); - for i in 0..n { - let off = i * INDEX_ENTRY_SIZE; - let agg_id = u64::from_le_bytes(self.index_mmap[off..off + 8].try_into().unwrap()); - let start_ts = - u64::from_le_bytes(self.index_mmap[off + 8..off + 16].try_into().unwrap()); - let end_ts = - u64::from_le_bytes(self.index_mmap[off + 16..off + 24].try_into().unwrap()); - let data_offset = - u64::from_le_bytes(self.index_mmap[off + 24..off + 32].try_into().unwrap()); - out.push(IndexRecord { - agg_id, - start_ts, - end_ts, - data_offset, - }); + fn index_record(&self, position: usize) -> IndexRecord { + let off = position * INDEX_ENTRY_SIZE; + let read = + |offset| u64::from_le_bytes(self.index_mmap[offset..offset + 8].try_into().unwrap()); + IndexRecord { + agg_id: read(off), + start_ts: read(off + 8), + end_ts: read(off + 16), + data_offset: read(off + 24), } - out + } + + /// Preserve disk order for callers that need to inspect the entire part. + pub fn index_records(&self) -> Vec { + (0..self.meta.num_entries as usize) + .map(|position| self.index_record(position)) + .collect() + } + + /// Find records contained in the requested window under one stored-output + /// prefix. Duplicate windows remain visible so callers can reject ambiguity. + pub fn window_records( + &self, + agg_id: u64, + start_ts: u64, + end_ts: u64, + ) -> impl Iterator + '_ { + let first = self.window_index.partition_point(|&position| { + let record = self.index_record(position); + (record.agg_id, record.start_ts) < (agg_id, start_ts) + }); + self.window_index[first..] + .iter() + .map(|&position| self.index_record(position)) + .take_while(move |record| record.agg_id == agg_id && record.start_ts <= end_ts) + .filter(move |record| record.end_ts <= end_ts) + } + + pub fn resident_bytes(&self) -> u64 { + (self.data_mmap.len() as u64) + .saturating_add(self.index_mmap.len() as u64) + .saturating_add( + (self.window_index.capacity() as u64) + .saturating_mul(std::mem::size_of::() as u64), + ) } /// Resolve a single index record into a [`SnapshotEntry`] by reading @@ -658,6 +696,40 @@ mod tests { } } + // Old parts can be unsorted; lookup must isolate the prefix/range while + // preserving duplicate records for the immutable reader's ambiguity check. + #[test] + fn window_lookup_handles_unsorted_parts_duplicates_and_recovery() { + let tmp = TempDir::new().unwrap(); + let part_dir = tmp.path().join("lookup"); + let mut snapshots = Vec::new(); + for id in [99, 42, 1] { + let mut snapshot = make_snapshot(); + snapshot.agg_id = id; + snapshot.entries.reverse(); + if id == 42 { + snapshot.entries.push(snapshot.entries[1].clone()); + } + snapshots.push(snapshot); + } + PartWriter::write_part(&part_dir, 1, &snapshots).unwrap(); + for _ in 0..2 { + let reader = PartReader::open(&part_dir).unwrap(); + let records = reader.window_records(42, 1_000, 1_500).collect::>(); + assert_eq!(records.len(), 2); + assert!(records.iter().all(|record| record.agg_id == 42 + && record.start_ts == 1_000 + && record.end_ts == 1_500)); + assert_ne!(records[0].data_offset, records[1].data_offset); + assert_eq!(reader.window_records(42, 1_500, 2_000).count(), 1); + assert_eq!(reader.window_records(43, 0, u64::MAX).count(), 0); + assert_eq!(reader.window_records(42, 1_001, 1_999).count(), 0); + assert_eq!(reader.window_records(42, 2_000, 1_000).count(), 0); + assert_eq!(reader.index_records()[0].agg_id, 99); + assert!(reader.resident_bytes() > reader.meta.data_len + reader.meta.index_len); + } + } + #[test] fn part_round_trip_writes_and_reads_back() { let tmp = TempDir::new().unwrap(); diff --git a/data_plane/src/storage_engines/types/hot_reload_config.rs b/data_plane/src/storage_engines/types/hot_reload_config.rs index c3f0439fb..091f41b7d 100644 --- a/data_plane/src/storage_engines/types/hot_reload_config.rs +++ b/data_plane/src/storage_engines/types/hot_reload_config.rs @@ -80,6 +80,9 @@ impl RuntimePhysicalPlan { .map_err(|error| error.to_string())?; let mut program = entry.clone(); program.root = root; + // A bound readout is an input to the physical graph, not a second + // invocation of that graph. Keep only its storage/readout contract. + program.physical_dag = None; program.nodes = reachable .into_iter() .map(|id| (id, entry.nodes[&id].clone())) @@ -764,6 +767,7 @@ mod tests { fn readout_programs_follow_replaced_query_plan_bindings() { use asap_types::query_plan::*; let entry = |id: u64| QueryPlanEntry { + physical_dag: None, language: asap_types::QueryLanguage::PromQl, query_id: "sum_over_time(m[1m])".into(), canonical_query: "sum_over_time(m[1m])".into(), From 607a21ec2b3ed9f5feb4aa65a545fcc1d6cea23a Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 01:21:58 +0000 Subject: [PATCH 2/3] test: cover native heap and grouped Rate candidates end to end Candidate tests cover accuracy rejection, quote-driven exact/CMS/ CountSketch selection and both physical graphs. Real-process tests cover counter/heap SDS through HTTP and restart. Co-Authored-By: Claude Opus 5.5 --- control_plane/tests/discovery_snapshot.rs | 2 +- control_plane/tests/native_rate_topk.rs | 278 ++++++++++++ control_plane/tests/native_snapshot_topk.rs | 135 ++++++ .../asapquery_compatibility_process_e2e.rs | 6 + .../tests/support/current_series_process.rs | 22 + data_plane/tests/support/physical_fixture.rs | 1 + data_plane/tests/support/rate_heap_process.rs | 399 ++++++++++++++++++ .../tests/support/spatial_heap_process.rs | 184 ++++++++ 8 files changed, 1026 insertions(+), 1 deletion(-) create mode 100644 control_plane/tests/native_rate_topk.rs create mode 100644 control_plane/tests/native_snapshot_topk.rs create mode 100644 data_plane/tests/support/rate_heap_process.rs create mode 100644 data_plane/tests/support/spatial_heap_process.rs diff --git a/control_plane/tests/discovery_snapshot.rs b/control_plane/tests/discovery_snapshot.rs index c6e4a69b0..ecedfd3fa 100644 --- a/control_plane/tests/discovery_snapshot.rs +++ b/control_plane/tests/discovery_snapshot.rs @@ -55,7 +55,7 @@ fn discovered_snapshot_plans_with_observed_cadence_and_promql_history() { .into_physical_compilation_request() .unwrap(); assert_eq!(request.scrape_interval_ms, Some(60_000)); - assert_eq!(request.queries[0].query_lookback_seconds, 3660); + assert_eq!(request.queries[0].query_lookback_ms, 3_660_000); let roundtrip: BackendLocalPlanningInput = serde_json::from_value(serde_json::to_value(&snapshot).unwrap()).unwrap(); assert_eq!(snapshot, roundtrip); diff --git a/control_plane/tests/native_rate_topk.rs b/control_plane/tests/native_rate_topk.rs new file mode 100644 index 000000000..0ac493186 --- /dev/null +++ b/control_plane/tests/native_rate_topk.rs @@ -0,0 +1,278 @@ +//! Planner owns Rate ranking; Backend binds durable state and prices candidates. +use control_plane::physical::{ + compiler::{BackendLocalPlanningInput, DeploymentPlanCompiler}, + workload_cost::enumerate_exact_and_materialized_candidates, +}; +use serde_json::{json, Value}; + +fn fixture(certified: bool) -> BackendLocalPlanningInput { + let mut wire: Value = serde_json::from_str(include_str!( + "../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let query = "topk by (job) (1, rate(requests_total[1m]))"; + let mut entry = wire["query_workload"]["repeating_queries"][3].clone(); + entry["query"] = query.into(); + entry["requirements"]["accuracy"] = + json!({"explicit":{"EpsilonDelta":{"epsilon":0.1,"delta":0.1}}}); + wire["query_workload"]["repeating_queries"] = json!([entry]); + wire["implementation"]["topk_evidence"] = json!({}); + wire["implementation"]["data_snapshot_id"] = "snapshot-topk-test".into(); + if certified { + wire["implementation"]["accuracy_evidence"] = json!({query: { + "query_string": query, "data_snapshot_id":"snapshot-topk-test", + "data_workload": wire["data_workload"], "source":"enforced-fixture-contract", + "observed_at_unix_ms":9500, "valid_for_ms":60000, + "topk_max_distinct_items":1000, + "topk_selected_lower_bound":101.0, "topk_excluded_upper_bound":100.0, + "topk_interval_failure_probability":0.001 + }}); + } + serde_json::from_value(wire).unwrap() +} + +// An admitted Rate heap reads durable counter state; it must not become an +// external whole-query fallback or accumulate counter samples as heap weights. +#[test] +fn rate_heap_candidates_bind_durable_counter_windows() { + let (request, environment) = fixture(true).into_physical_compilation_request().unwrap(); + let mut families = std::collections::BTreeSet::new(); + for candidate in enumerate_exact_and_materialized_candidates(request).unwrap() { + let Ok(plan) = DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) else { + continue; + }; + if plan + .precompute_plan + .executable_dags + .values() + .any(|dag| !dag.native_programs.is_empty()) + { + continue; + } + let entry = plan.query_plan.entries.values().next().unwrap(); + let Some(program) = &entry.physical_dag else { + continue; + }; + for family in ["CmsWithHeap", "CountSketchWithHeap"] { + if program.to_string().contains(family) { + assert_eq!(entry.instant.lookback_ms, 60_000); + assert!(entry.nodes.values().any(|node| matches!( + node, + asap_types::query_plan::QueryPlanNode::ExactReadout { + readout: asap_types::query_plan::ExactReadout::Rate, + .. + } + ))); + assert_eq!(entry.materialization_bindings().len(), 1); + assert_eq!( + entry.materialization_bindings()[0].readout_lookback_ms, + Some(60_000) + ); + assert!(!plan.precompute_plan.materializations.is_empty()); + let mut restored: asap_types::query_plan::QueryPlanEntry = + serde_json::from_value(serde_json::to_value(entry).unwrap()).unwrap(); + restored.recover_vector_physical_dag().unwrap(); + restored.physical_dag = None; + assert!(restored.recover_vector_physical_dag().is_err()); + let mut drifted = entry.clone(); + if let asap_types::query_plan::QueryPlanNode::Physical { source_nodes, .. } = + drifted.nodes.get_mut(&drifted.root).unwrap() + { + source_nodes[0] += 100; + } + assert!(drifted.recover_vector_physical_dag().is_err()); + let mut wrong_window = entry.clone(); + wrong_window.instant.lookback_ms = 5_000; + assert!(wrong_window.recover_vector_physical_dag().is_err()); + families.insert(family); + } + } + } + assert_eq!( + families, + std::collections::BTreeSet::from(["CmsWithHeap", "CountSketchWithHeap"]) + ); +} + +// Scoped evidence is required even when a physical heap can be compiled. +#[test] +fn missing_rate_heap_proof_leaves_exact_candidate_available() { + let (request, environment) = fixture(false).into_physical_compilation_request().unwrap(); + let mut exact = false; + for candidate in enumerate_exact_and_materialized_candidates(request).unwrap() { + if let Ok(plan) = DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) { + for entry in plan.query_plan.entries.values() { + if let Some(program) = &entry.physical_dag { + assert!(!program.to_string().contains("WithHeap")); + exact = true; + } + } + } + } + assert!(exact); +} + +// Provider costs may choose exact ranking, CMS or CountSketch over the same +// counter population; no family is selected before deployment costs exist. +#[test] +fn rate_heap_costs_can_select_each_compiled_candidate() { + use control_plane::physical::{ + compiler::{BACKEND_REVISION, PLANNER_REVISION}, + workload_cost::{manifest, WorkloadCostEvidence, WorkloadQuote}, + }; + for preferred in ["exact", "CmsWithHeap", "CountSketchWithHeap"] { + let mut input = fixture(true); + let (request, environment) = input.clone().into_physical_compilation_request().unwrap(); + let quotes = enumerate_exact_and_materialized_candidates(request) + .unwrap() + .into_iter() + .filter_map(|candidate| { + let plan = DeploymentPlanCompiler + .compile_promql(candidate.clone(), environment.clone()) + .ok()?; + let entry = plan.query_plan.entries.values().next().unwrap(); + let program = entry + .physical_dag + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(); + let preferred_plan = entry.physical_vector_binding().is_some() + && !plan + .precompute_plan + .executable_dags + .values() + .any(|dag| !dag.native_programs.is_empty()) + && if preferred == "exact" { + !program.contains("WithHeap") + } else { + program.contains(preferred) + }; + let manifest = manifest(&plan, &candidate.queries).unwrap(); + Some(WorkloadQuote { + unit_costs: manifest + .components + .keys() + .map(|key| (key.clone(), if preferred_plan { 1.0 } else { 1e12 })) + .collect(), + manifest, + executable: true, + }) + }) + .collect(); + input.workload_cost_evidence = Some(WorkloadCostEvidence { + backend_revision: BACKEND_REVISION.into(), + planner_revision: PLANNER_REVISION.into(), + data_snapshot_id: "snapshot-topk-test".into(), + model_version: "controlled-rate-ranking-cost".into(), + observed_at_unix_ms: 10000, + valid_for_ms: 60000, + quotes, + }); + let plan = input.compile_promql().unwrap(); + let entry = plan.query_plan.entries.values().next().unwrap(); + assert!(entry.physical_vector_binding().is_some()); + let program = entry.physical_dag.as_ref().unwrap().to_string(); + if preferred == "exact" { + assert!(!program.contains("WithHeap")); + } else { + assert!(program.contains(preferred)); + } + } +} + +// Fixed-window candidates retain a native maintenance graph and read its heap +// state directly; removing that graph must make recovered installation invalid. +#[test] +fn fixed_window_rate_heap_candidates_install_both_physical_graphs() { + let mut input = fixture(true); + let mut wire = serde_json::to_value(&input).unwrap(); + wire["query_workload"]["repeating_queries"][0]["demand"]["fixed_interval_at"]["interval"] = + 60_000.into(); + wire["query_workload"]["repeating_queries"][0]["demand"]["fixed_interval_at"] + ["evaluation_phase"] = 0.into(); + input = serde_json::from_value(wire).unwrap(); + let (request, environment) = input.into_physical_compilation_request().unwrap(); + let mut families = std::collections::BTreeSet::new(); + let mut errors = Vec::new(); + for candidate in enumerate_exact_and_materialized_candidates(request).unwrap() { + let plan = match DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) { + Ok(plan) => plan, + Err(error) => { + errors.push(error.to_string()); + continue; + } + }; + for installed in plan.precompute_plan.executable_dags.values() { + for sink in installed.native_programs.keys() { + let program = installed.native_program(*sink).unwrap().unwrap(); + let encoded = String::from_utf8(program.encode().unwrap()).unwrap(); + for family in ["CmsWithHeap", "CountSketchWithHeap"] { + if encoded.contains(family) { + families.insert(family); + } + } + assert!(encoded.contains("Rate")); + let entry = plan.query_plan.entries.values().next().unwrap(); + let query = entry.recover_vector_physical_dag().unwrap(); + assert!(!String::from_utf8(query.encode().unwrap()) + .unwrap() + .contains("KeyedSummaryBuild")); + let mut broken = plan.precompute_plan.clone(); + for installed in broken.executable_dags.values_mut() { + installed.native_programs.clear(); + } + assert!(broken.validate().is_err()); + } + } + } + assert_eq!( + families, + std::collections::BTreeSet::from(["CmsWithHeap", "CountSketchWithHeap"]), + "{errors:#?}" + ); +} + +// Rate must precede grouped Sum in both placements; deployment chooses ownership. +#[test] +fn grouped_rate_has_native_query_and_maintenance_candidates() { + let mut wire = serde_json::to_value(fixture(false)).unwrap(); + let entry = &mut wire["query_workload"]["repeating_queries"][0]; + entry["query"] = "sum by(job)(rate(requests_total[1m]))".into(); + entry["requirements"]["accuracy"] = json!({"explicit":"Exact"}); + entry["demand"]["fixed_interval_at"]["interval"] = 5_000.into(); + entry["demand"]["fixed_interval_at"]["evaluation_phase"] = 0.into(); + wire["implementation"]["accuracy_evidence"] = json!({}); + let input: BackendLocalPlanningInput = serde_json::from_value(wire).unwrap(); + let (request, environment) = input.into_physical_compilation_request().unwrap(); + let mut placements = std::collections::BTreeSet::new(); + let mut errors = Vec::new(); + for candidate in enumerate_exact_and_materialized_candidates(request).unwrap() { + let plan = match DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) { + Ok(plan) => plan, + Err(error) => { + errors.push(error.to_string()); + continue; + } + }; + let entry = plan.query_plan.entries.values().next().unwrap(); + let Some(program) = &entry.physical_dag else { + continue; + }; + if entry.physical_vector_binding().is_none() { + continue; + } + entry.recover_vector_physical_dag().unwrap(); + let stored = plan + .precompute_plan + .executable_dags + .values() + .any(|dag| !dag.native_programs.is_empty()); + assert_eq!(program.to_string().contains("SummaryBuild"), !stored); + placements.insert(stored); + } + assert_eq!( + placements, + std::collections::BTreeSet::from([false, true]), + "{errors:#?}" + ); +} diff --git a/control_plane/tests/native_snapshot_topk.rs b/control_plane/tests/native_snapshot_topk.rs new file mode 100644 index 000000000..c4a03c7b1 --- /dev/null +++ b/control_plane/tests/native_snapshot_topk.rs @@ -0,0 +1,135 @@ +//! Planner owns the snapshot heap program; Backend admits and prices it. +use control_plane::physical::{ + compiler::{ + BackendLocalPlanningInput, DeploymentPlanCompiler, BACKEND_REVISION, PLANNER_REVISION, + }, + workload_cost::{ + enumerate_exact_and_materialized_candidates, manifest, WorkloadCostEvidence, WorkloadQuote, + }, +}; +use serde_json::{json, Value}; + +fn fixture(certified: bool) -> BackendLocalPlanningInput { + let mut wire: Value = serde_json::from_str(include_str!( + "../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let query = "topk by (job) (1, spatial_value)"; + let mut entry = wire["query_workload"]["repeating_queries"][3].clone(); + entry["query"] = query.into(); + entry["requirements"]["accuracy"] = + json!({"explicit":{"EpsilonDelta":{"epsilon":0.1,"delta":0.1}}}); + wire["query_workload"]["repeating_queries"] = json!([entry]); + wire["implementation"]["topk_evidence"] = json!({}); + wire["implementation"]["data_snapshot_id"] = "snapshot-topk-test".into(); + if certified { + wire["implementation"]["accuracy_evidence"] = json!({query: { + "query_string": query, "data_snapshot_id":"snapshot-topk-test", + "data_workload": wire["data_workload"], "source":"enforced-fixture-contract", + "observed_at_unix_ms":9500, "valid_for_ms":60000, + "topk_max_distinct_items":1000, + "topk_selected_lower_bound":101.0, "topk_excluded_upper_bound":100.0, + "topk_interval_failure_probability":0.001 + }}); + } + serde_json::from_value(wire).unwrap() +} + +fn heap(plan: &control_plane::physical::compiler::CompiledPhysicalPlan) -> bool { + plan.query_plan.entries.values().any(|entry| { + entry + .physical_dag + .as_ref() + .is_some_and(|program| program.to_string().contains("CountSketchWithHeap")) + }) +} + +// Same physical inventory, different deployment quotes: selection must reverse. +#[test] +fn snapshot_heap_and_exact_candidates_reach_deployment_cost_selection() { + for prefer_heap in [false, true] { + let mut input = fixture(true); + let (request, environment) = input.clone().into_physical_compilation_request().unwrap(); + assert!(request + .planner_selection_trace + .iter() + .any(|event| event["stage"] == "planner.physical_candidate" + && event.get("physical_dag").is_some())); + let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + let mut saw_heap = false; + let mut saw_exact = false; + let quotes = candidates + .into_iter() + .filter_map(|candidate| { + let plan = DeploymentPlanCompiler + .compile_promql(candidate.clone(), environment.clone()) + .ok()?; + let is_heap = heap(&plan); + let local = plan + .query_plan + .entries + .values() + .all(|entry| entry.population_snapshot().is_some()); + saw_heap |= is_heap; + saw_exact |= local && !is_heap; + if is_heap { + assert!(plan.precompute_plan.materializations.is_empty()); + let entry = plan.query_plan.entries.values().next().unwrap(); + let restored: asap_types::query_plan::QueryPlanEntry = + serde_json::from_value(serde_json::to_value(entry).unwrap()).unwrap(); + restored.recover_population_physical_dag().unwrap(); + } + let manifest = manifest(&plan, &candidate.queries).unwrap(); + Some(WorkloadQuote { + unit_costs: manifest + .components + .keys() + .map(|key| { + ( + key.clone(), + if local && is_heap == prefer_heap { + 1.0 + } else { + 1e12 + }, + ) + }) + .collect(), + manifest, + executable: true, + }) + }) + .collect(); + assert!( + saw_heap, + "certified signed heap candidate never reached pricing" + ); + assert!(saw_exact, "exact population candidate disappeared"); + input.workload_cost_evidence = Some(WorkloadCostEvidence { + backend_revision: BACKEND_REVISION.into(), + planner_revision: PLANNER_REVISION.into(), + data_snapshot_id: "snapshot-topk-test".into(), + model_version: "fixture-cost-reversal".into(), + observed_at_unix_ms: 10000, + valid_for_ms: 60000, + quotes, + }); + assert_eq!(heap(&input.compile_promql().unwrap()), prefer_heap); + } +} + +// A priced physical graph is not an accuracy proof. Missing population/gap +// evidence must leave the exact candidate available and the heap uninstalled. +#[test] +fn unknown_snapshot_heap_guarantee_cannot_be_installed() { + let (request, environment) = fixture(false).into_physical_compilation_request().unwrap(); + assert!(request + .planner_selection_trace + .iter() + .any(|event| event["stage"] == "planner.physical_candidate")); + for candidate in enumerate_exact_and_materialized_candidates(request).unwrap() { + if let Ok(plan) = DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) { + assert!(!heap(&plan)); + } + } +} diff --git a/data_plane/tests/asapquery_compatibility_process_e2e.rs b/data_plane/tests/asapquery_compatibility_process_e2e.rs index 8e9f2f427..b3cd98c7e 100644 --- a/data_plane/tests/asapquery_compatibility_process_e2e.rs +++ b/data_plane/tests/asapquery_compatibility_process_e2e.rs @@ -1915,3 +1915,9 @@ async fn collector_free_profile_serves_complete_matrix_and_falls_back_exactly() #[path = "support/univmon_erp_process.rs"] mod univmon_erp_process; + +#[path = "support/spatial_heap_process.rs"] +mod spatial_heap_process; + +#[path = "support/rate_heap_process.rs"] +mod rate_heap_process; diff --git a/data_plane/tests/support/current_series_process.rs b/data_plane/tests/support/current_series_process.rs index 4f0dc6992..e350afcf6 100644 --- a/data_plane/tests/support/current_series_process.rs +++ b/data_plane/tests/support/current_series_process.rs @@ -102,6 +102,28 @@ async fn current_series_quantiles_topk_share_and_replace_values() { }); let planned = snapshot.clone().compile_promql().unwrap(); for entry in planned.query_plan.entries.values() { + if entry.canonical_query.starts_with("topk") { + assert!( + entry.population_snapshot().is_some(), + "TopK must bind a complete population source" + ); + let restored: asap_types::query_plan::QueryPlanEntry = + serde_json::from_slice(&serde_json::to_vec(entry).unwrap()).unwrap(); + let physical = restored.recover_population_physical_dag().unwrap(); + let encoded = String::from_utf8(physical.encode().unwrap()).unwrap(); + assert!(encoded.contains("Sort") && encoded.contains("Limit")); + assert!(encoded.contains("$promql_series_identity")); + assert!( + !encoded.contains("CurrentSeries"), + "the stored population boundary must not be recomputed" + ); + let mut missing = restored.clone(); + missing.physical_dag = None; + assert!(missing.recover_population_physical_dag().is_err()); + let mut version = restored; + version.physical_dag.as_mut().unwrap()["version"] = 999.into(); + assert!(version.recover_population_physical_dag().is_err()); + } for node in entry.nodes.values() { if let asap_types::query_plan::QueryPlanNode::Logical { operator: diff --git a/data_plane/tests/support/physical_fixture.rs b/data_plane/tests/support/physical_fixture.rs index 1b9b03823..e86a100e9 100644 --- a/data_plane/tests/support/physical_fixture.rs +++ b/data_plane/tests/support/physical_fixture.rs @@ -147,6 +147,7 @@ pub fn artifact_from_materializations( query_plan.entries.insert( canonical.clone(), QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: canonical.clone(), canonical_query: canonical, diff --git a/data_plane/tests/support/rate_heap_process.rs b/data_plane/tests/support/rate_heap_process.rs new file mode 100644 index 000000000..9862601f6 --- /dev/null +++ b/data_plane/tests/support/rate_heap_process.rs @@ -0,0 +1,399 @@ +use super::*; +use control_plane::physical::{ + compiler::{ + BackendLocalPlanningInput, DeploymentPlanCompiler, BACKEND_REVISION, PLANNER_REVISION, + }, + workload_cost::{ + enumerate_exact_and_materialized_candidates, manifest, WorkloadCostEvidence, WorkloadQuote, + }, +}; + +// Real Remote Write -> persisted counter windows -> exact Rate -> Planner heap +// -> HTTP. Restart reads the same bound SDS and retained physical program. +#[tokio::test] +async fn rate_countsketch_heap_survives_counter_reset_and_durable_restart() { + run("CountSketchWithHeap", false).await; +} + +#[tokio::test] +async fn rate_cms_heap_survives_counter_reset_and_durable_restart() { + run("CmsWithHeap", false).await; +} + +// The maintenance graph publishes the heap itself before HTTP readout and restart. +#[tokio::test] +async fn precomputed_rate_countsketch_heap_survives_durable_restart() { + run("CountSketchWithHeap", true).await; +} +#[tokio::test] +async fn precomputed_rate_cms_heap_survives_durable_restart() { + run("CmsWithHeap", true).await; +} + +#[tokio::test] +async fn precomputed_grouped_rate_sum_survives_durable_restart() { + run("Sum", true).await; +} +#[tokio::test] +async fn query_time_grouped_rate_sum_survives_durable_restart() { + run("Sum", false).await; +} + +async fn run(algorithm: &str, precomputed: bool) { + let query = if algorithm == "Sum" { + "sum by (job) (rate(requests_total[1m]))" + } else { + "topk by (job) (1, rate(requests_total[1m]))" + }; + let mut wire: Value = serde_json::from_str(include_str!( + "../../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let mut entry = wire["query_workload"]["repeating_queries"][3].clone(); + entry["query"] = query.into(); + entry["requirements"]["accuracy"] = + serde_json::json!({"explicit":{"EpsilonDelta":{"epsilon":0.1,"delta":0.1}}}); + entry["demand"]["fixed_interval_at"]["interval"] = if algorithm == "Sum" { + 5_000.into() + } else { + 60_000.into() + }; + if algorithm == "Sum" { + entry["requirements"]["accuracy"] = serde_json::json!({"explicit":"Exact"}); + } + entry["demand"]["fixed_interval_at"]["evaluation_phase"] = 0.into(); + wire["query_workload"]["repeating_queries"] = serde_json::json!([entry]); + wire["implementation"]["topk_evidence"] = serde_json::json!({}); + wire["implementation"]["data_snapshot_id"] = "rate-heap-process".into(); + // Fixture rates: winner >=500, excluded rates <=10, at most three series per ranking group. + wire["implementation"]["accuracy_evidence"] = serde_json::json!({query:{ + "query_string":query,"data_snapshot_id":"rate-heap-process", + "data_workload":wire["data_workload"],"source":"enforced-test-population", + "observed_at_unix_ms":9500,"valid_for_ms":60000,"topk_max_distinct_items":3, + "topk_selected_lower_bound":500.0,"topk_excluded_upper_bound":10.0, + "topk_interval_failure_probability":0.001 + }}); + if algorithm == "Sum" { + wire["implementation"]["accuracy_evidence"] = serde_json::json!({}); + } + let mut snapshot: BackendLocalPlanningInput = serde_json::from_value(wire).unwrap(); + let (request, environment) = snapshot + .clone() + .into_physical_compilation_request() + .unwrap(); + let mut saw_heap = false; + let quotes = enumerate_exact_and_materialized_candidates(request) + .unwrap() + .into_iter() + .filter_map(|candidate| { + let plan = DeploymentPlanCompiler + .compile_promql(candidate.clone(), environment.clone()) + .ok()?; + let heap = plan.query_plan.entries.values().any(|entry| { + entry + .physical_dag + .as_ref() + .is_some_and(|program| program.to_string().contains(algorithm)) + }); + let heap = heap + && plan + .precompute_plan + .executable_dags + .values() + .any(|dag| !dag.native_programs.is_empty()) + == precomputed; + saw_heap |= heap; + let manifest = manifest(&plan, &candidate.queries).unwrap(); + Some(WorkloadQuote { + unit_costs: manifest + .components + .keys() + .map(|key| (key.clone(), if heap { 1.0 } else { 1e12 })) + .collect(), + manifest, + executable: true, + }) + }) + .collect(); + assert!(saw_heap); + snapshot.workload_cost_evidence = Some(WorkloadCostEvidence { + backend_revision: BACKEND_REVISION.into(), + planner_revision: PLANNER_REVISION.into(), + data_snapshot_id: "rate-heap-process".into(), + model_version: "test-only-heap-selection".into(), + observed_at_unix_ms: 10000, + valid_for_ms: 60000, + quotes, + }); + let plan = snapshot.clone().compile_promql().unwrap(); + let entry = plan.query_plan.entries.values().next().unwrap(); + assert!(entry + .physical_dag + .as_ref() + .unwrap() + .to_string() + .contains(algorithm)); + entry.recover_vector_physical_dag().unwrap(); + assert_eq!( + plan.precompute_plan.materializations.len(), + if precomputed { 2 } else { 1 } + ); + assert_eq!( + plan.precompute_plan + .executable_dags + .values() + .any(|dag| !dag.native_programs.is_empty()), + precomputed + ); + + if precomputed { + assert!( + plan.precompute_plan + .materializations + .iter() + .all( + |state| state.slide_interval == if algorithm == "Sum" { 5 } else { 60 } + && state.window_size == 60 + ), + "maintenance must match the query cadence, not publish only every 60 seconds" + ); + } + + let install = data_plane::drivers::query::servers::http::PhysicalPlanInstallRequest { + summary_catalog: plan.summary_catalog, + collector_plans: plan.collector_plans, + precompute_plan: plan.precompute_plan, + transmission_plan: plan.transmission_plan, + query_plan: plan.query_plan, + storage_routing: None, + adaptation_evidence: vec![], + }; + let directory = tempfile::tempdir().unwrap(); + let artifact = directory.path().join("plan.json"); + let disk = directory.path().join("disk"); + std::fs::write(&artifact, serde_json::to_vec(&install).unwrap()).unwrap(); + let spawn = |port: u16| { + ChildGuard( + Command::new(env!("CARGO_BIN_EXE_data_plane")) + .arg("--physical-plan") + .arg(&artifact) + .args(["--http-port", &port.to_string(), "--output-dir"]) + .arg(directory.path()) + .arg("--enable-remote-write") + .arg("--persistence-enabled") + .arg("--persistence-dir") + .arg(&disk) + .args([ + "--persistence-memory-limit-mb", + "1", + "--persistence-hot-window-secs", + "1", + "--persistence-delete-older-than-secs", + "0", + "--persistence-seal-window-count", + "1", + "--persistence-flush-interval-ms", + "10", + "--precompute-allowed-lateness-ms", + "0", + "--precompute-flush-interval-ms", + "25", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(), + ) + }; + let client = reqwest::Client::new(); + let mut expected = Vec::new(); + for restart in [false, true] { + let port = unused_port(); + let backend = format!("http://127.0.0.1:{port}"); + let mut child = spawn(port); + wait_until_ready(&client, &format!("{backend}/api/v1/health"), &mut child.0).await; + if !restart { + let request = WriteRequest { + timeseries: [ + ( + "a", + vec![ + (1000, 1000.), + (20000, 20000.), + (40000, 40000.), + (60000, 60000.), + (61000, 61000.), + (80000, 61000.), + (100000, 61000.), + (120000, 61000.), + ], + ), + ( + "b", + vec![ + (1000, 1.), + (20000, 20.), + (40000, 40.), + (60000, 60.), + (61000, 1000.), + (80000, 20000.), + (100000, 50.), + (120000, 20050.), + ], + ), + ( + "c", + vec![ + (1000, 0.1), + (20000, 2.), + (40000, 4.), + (60000, 6.), + (61000, 6.1), + (80000, 8.), + (100000, 10.), + (120000, 12.), + ], + ), + ] + .into_iter() + .map(|(id, samples)| { + series_with_labels( + "requests_total", + &[("job", "api"), ("unreferenced_instance", id)], + &samples, + ) + }) + .chain(std::iter::once(series_with_labels( + "requests_total", + &[("job", "worker"), ("unreferenced_instance", "d")], + &[ + (1000, 2000.), + (20000, 40000.), + (40000, 80000.), + (60000, 120000.), + (61000, 122000.), + (80000, 160000.), + (100000, 200000.), + (120000, 240000.), + ], + ))) + .chain((algorithm == "Sum").then(|| { + series_with_labels( + "requests_total", + &[("unreferenced_instance", "no-job")], + &[ + (1000, 3.), + (20000, 60.), + (40000, 120.), + (60000, 180.), + (61000, 183.), + (80000, 240.), + (100000, 300.), + (120000, 360.), + ], + ) + })) + .collect(), + }; + assert_eq!(remote_write(&client, &backend, &request).await, 204); + drain_precompute(&client, &backend).await; + } + let evaluations = if algorithm == "Sum" { + vec![ + (60., "a", 1000.), + (65., "a", 1000.), + (120., "b", 39050. / 59.), + ] + } else { + vec![(60., "a", 1000.), (120., "b", 39050. / 59.)] + }; + for (index, (at, winner, score)) in evaluations.into_iter().enumerate() { + let body = wait_for_warm_instant( + &client, + &backend, + query, + at, + &directory.path().join("query_engine.log"), + ) + .await; + let rows = body["data"]["result"].as_array().unwrap(); + assert_eq!(rows.len(), if algorithm == "Sum" { 3 } else { 2 }, "{body}"); + if algorithm == "Sum" { + let empty = rows + .iter() + .find(|row| { + row["metric"] + .as_object() + .is_some_and(|labels| labels.is_empty()) + }) + .unwrap_or_else(|| panic!("missing grouping labels must be omitted: {body}")); + assert!( + (empty["value"][1].as_str().unwrap().parse::().unwrap() - 3.).abs() < 1e-8 + ); + } + assert!( + rows.iter() + .all(|row| row["metric"].get("__name__").is_none()), + "Rate must drop the metric name: {body}" + ); + let api = rows + .iter() + .find(|row| row["metric"]["job"] == "api") + .unwrap(); + let worker = rows + .iter() + .find(|row| row["metric"]["job"] == "worker") + .unwrap(); + if algorithm != "Sum" { + assert_eq!(worker["metric"]["unreferenced_instance"], "d"); + } + assert!( + (worker["value"][1].as_str().unwrap().parse::().unwrap() - 2000.).abs() < 1e-8 + ); + if algorithm == "Sum" { + assert!( + api["metric"].get("unreferenced_instance").is_none(), + "grouped Sum must drop ungrouped labels: {body}" + ); + } else { + assert_eq!(api["metric"]["unreferenced_instance"], winner, "{body}"); + } + let value = api["value"][1].as_str().unwrap().parse::().unwrap(); + let score = score + + if algorithm == "Sum" { + if at == 60. { + 1.1 + } else if at == 65. { + // b's zero-point extrapolation truncates the left edge: + // (980 * 45 / 41 + 20) / 60, plus c's constant 0.1. + (980. * 45. / 41. + 20.) / 60. + 0.1 + } else { + 0.1 + } + } else { + 0. + }; + assert!((value - score).abs() < 1e-8, "{body}, expected {score}"); + if restart { + assert_eq!(body["data"]["result"], expected[index]); + } else { + expected.push(body["data"]["result"].clone()); + } + } + let missing: Value = client + .get(format!("{backend}/api/v1/query")) + .query(&[("query", query), ("time", "180")]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!( + !is_warm(&missing), + "missing counter window was served as complete: {missing}" + ); + // The drained source cohort is durable before the first process exits. + assert!(disk.join("sketch_index/parts").is_dir()); + } +} diff --git a/data_plane/tests/support/spatial_heap_process.rs b/data_plane/tests/support/spatial_heap_process.rs new file mode 100644 index 000000000..5d805725d --- /dev/null +++ b/data_plane/tests/support/spatial_heap_process.rs @@ -0,0 +1,184 @@ +use super::*; +use control_plane::physical::{ + compiler::{ + BackendLocalPlanningInput, DeploymentPlanCompiler, BACKEND_REVISION, PLANNER_REVISION, + }, + workload_cost::{ + enumerate_exact_and_materialized_candidates, manifest, WorkloadCostEvidence, WorkloadQuote, + }, +}; + +// Actual installed heap program: Remote Write -> current snapshot -> native +// CountSketch build/readout -> HTTP result. Updates never accumulate old weights. +#[tokio::test] +async fn spatial_heap_installs_and_serves_replacements_staleness_and_expiry() { + let query = "topk by (job) (1, spatial_value)"; + let mut wire: Value = serde_json::from_str(include_str!( + "../../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let mut entry = wire["query_workload"]["repeating_queries"][3].clone(); + entry["query"] = query.into(); + entry["requirements"]["accuracy"] = + serde_json::json!({"explicit":{"EpsilonDelta":{"epsilon":0.1,"delta":0.1}}}); + wire["query_workload"]["repeating_queries"] = serde_json::json!([entry]); + wire["implementation"]["topk_evidence"] = serde_json::json!({}); + wire["implementation"]["data_snapshot_id"] = "spatial-heap-process".into(); + // Across every tested snapshot, the winner is >=900, every excluded value + // is <=10, and there are at most three complete series identities. + wire["implementation"]["accuracy_evidence"] = serde_json::json!({query:{ + "query_string":query,"data_snapshot_id":"spatial-heap-process", + "data_workload":wire["data_workload"],"source":"enforced-test-population", + "observed_at_unix_ms":9500,"valid_for_ms":60000,"topk_max_distinct_items":3, + "topk_selected_lower_bound":900.0,"topk_excluded_upper_bound":10.0, + "topk_interval_failure_probability":0.001 + }}); + let mut snapshot: BackendLocalPlanningInput = serde_json::from_value(wire).unwrap(); + let (request, environment) = snapshot + .clone() + .into_physical_compilation_request() + .unwrap(); + let mut saw_heap = false; + let quotes = enumerate_exact_and_materialized_candidates(request) + .unwrap() + .into_iter() + .filter_map(|candidate| { + let plan = DeploymentPlanCompiler + .compile_promql(candidate.clone(), environment.clone()) + .ok()?; + let heap = plan.query_plan.entries.values().any(|entry| { + entry + .physical_dag + .as_ref() + .is_some_and(|program| program.to_string().contains("CountSketchWithHeap")) + }); + saw_heap |= heap; + let manifest = manifest(&plan, &candidate.queries).unwrap(); + Some(WorkloadQuote { + unit_costs: manifest + .components + .keys() + .map(|key| (key.clone(), if heap { 1.0 } else { 1e12 })) + .collect(), + manifest, + executable: true, + }) + }) + .collect(); + assert!(saw_heap); + snapshot.workload_cost_evidence = Some(WorkloadCostEvidence { + backend_revision: BACKEND_REVISION.into(), + planner_revision: PLANNER_REVISION.into(), + data_snapshot_id: "spatial-heap-process".into(), + model_version: "test-only-heap-selection".into(), + observed_at_unix_ms: 10000, + valid_for_ms: 60000, + quotes, + }); + let plan = snapshot.clone().compile_promql().unwrap(); + let entry = plan.query_plan.entries.values().next().unwrap(); + assert!(entry + .physical_dag + .as_ref() + .unwrap() + .to_string() + .contains("CountSketchWithHeap")); + entry.recover_population_physical_dag().unwrap(); + assert!(plan.precompute_plan.materializations.is_empty()); + + let calls = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let observed = calls.clone(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let fallback = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + axum::serve(listener,Router::new().route("/-/healthy",get(||async{"healthy"})).route("/api/v1/query",get(move ||{ + let observed=observed.clone();async move { + observed.fetch_add(1,std::sync::atomic::Ordering::Relaxed); + Json(serde_json::json!({"status":"success","data":{"resultType":"vector","result":[]}})) + } + }))).await.unwrap(); + }); + let output = tempfile::tempdir().unwrap(); + let path = output.path().join("snapshot.json"); + std::fs::write(&path, serde_json::to_vec(&snapshot).unwrap()).unwrap(); + let port = unused_port(); + let mut child = ChildGuard( + Command::new(env!("CARGO_BIN_EXE_data_plane")) + .args([ + "--profile", + "asapquery", + "--forward-unsupported-queries", + "--planning-snapshot", + ]) + .arg(path) + .args([ + "--prometheus-server", + &fallback, + "--http-port", + &port.to_string(), + "--output-dir", + ]) + .arg(output.path()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(), + ); + let client = reqwest::Client::new(); + let base = format!("http://127.0.0.1:{port}"); + wait_until_ready(&client, &format!("{base}/api/v1/health"), &mut child.0).await; + let end = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let stale = + f64::from_bits(data_plane::drivers::ingest::prometheus_remote_write::STALE_NAN_BITS); + for (offset, samples, winner, score) in [ + (0, vec![("a", 1000.0), ("b", 10.0), ("c", 1.0)], "a", 1000.0), + (1000, vec![("a", -5.0), ("b", 900.0)], "b", 900.0), + (2000, vec![("b", stale), ("c", 1000.0)], "c", 1000.0), + (7000, vec![("a", 900.0)], "a", 900.0), + ] { + let request = WriteRequest { + timeseries: samples + .into_iter() + .map(|(instance, value)| { + series_with_labels( + "spatial_value", + &[("job", "api"), ("unreferenced_instance", instance)], + &if offset == 0 { + vec![(end - 5000, value), (end, value)] + } else { + vec![(end + offset, value)] + }, + ) + }) + .collect(), + }; + assert_eq!(remote_write(&client, &base, &request).await, 204); + let body: Value = client + .get(format!("{base}/api/v1/query")) + .query(&[ + ("query", query.to_owned()), + ("time", format!("{:.3}", (end + offset) as f64 / 1000.0)), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(is_warm(&body), "{body}"); + let rows = body["data"]["result"].as_array().unwrap(); + assert_eq!(rows.len(), 1, "{body}"); + assert_eq!(rows[0]["metric"]["unreferenced_instance"], winner, "{body}"); + let actual = rows[0]["value"][1] + .as_str() + .unwrap() + .parse::() + .unwrap(); + assert!((actual - score).abs() < 1e-8, "{body}"); + } + assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0); + task.abort(); +} From a198e3a80897fdf4a94f3416c16fbff338b1d4c2 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 01:21:58 +0000 Subject: [PATCH 3/3] docs: describe evidence-dependent candidates and native SDS publication Co-Authored-By: Claude Opus 5.5 --- docs/design_docs/README.md | 1 + .../evidence-dependent-candidates.md | 159 ++++++++++++++++++ .../summary-catalog-sds-architecture.md | 12 ++ .../catalog-physical-plan-runtime.md | 101 +++++++++++ 4 files changed, 273 insertions(+) create mode 100644 docs/design_docs/evidence-dependent-candidates.md diff --git a/docs/design_docs/README.md b/docs/design_docs/README.md index f06a4532f..c2ef4b1a2 100644 --- a/docs/design_docs/README.md +++ b/docs/design_docs/README.md @@ -27,6 +27,7 @@ under [developer docs](../developer_docs/README.md). Other designs and profiles: +- [Evidence-dependent candidate selection](evidence-dependent-candidates.md) defines evidence ownership, logical selection, physical admission, exact fallback, and current proof limits. - [ASAPQuery compatibility profile](asapquery-compatibility-profile.md) - [Shape-aware ERP](shape-aware-erp-v1.md) - [Empirical observability execution plan](empirical-o11y-execution-plan.md) diff --git a/docs/design_docs/evidence-dependent-candidates.md b/docs/design_docs/evidence-dependent-candidates.md new file mode 100644 index 000000000..9e09b1f86 --- /dev/null +++ b/docs/design_docs/evidence-dependent-candidates.md @@ -0,0 +1,159 @@ +# Evidence-dependent candidate selection and deployment + +Status: scoped evidence and native candidate admission implemented by backend +PR #761, building +on the API adaptation in #768, against ASAPPlanner #455 +(`2ec3fc80`). This document defines the backend decision boundary for Planner +issue #454 and backend issue #752. It does not claim that every retained +candidate has a deployable implementation. + +## Decision and ownership + +Keep constructible candidates visible when external evidence is absent. +Separate candidate existence, logical selection, and deployment admission: +none implies the next. Otherwise the backend either loses a candidate it +could prove valid or deploys one whose guarantee has never been established. + +| Decision | Owner | Required behavior | +| --- | --- | --- | +| Construct semantic candidates | Planner | Preserve unknown guarantees; reject known-invalid evidence and impossible shapes | +| Supply external facts | Backend | Bind evidence to the query, data population, snapshot and validity period | +| Derive accuracy and select logical roots | Planner, under backend models and policy | Respect the root accuracy target; missing proof is not certification | +| Bind and admit a deployment | Backend | Verify concrete execution support and require complete workload cost evidence | + +The backend still invokes Planner's workload search and global selection. It +does not introduce a second semantic optimizer. Physical binding preserves the +selected DAG's operators, grouping, windows and dependencies; a semantic change +requires a new selection. Single-query and workload selection use the same +costed Planner search; the first-candidate helper has been removed. + +## Decision flow + +```mermaid +flowchart TD + Input[Queries, accuracy targets and optional backend evidence] --> Validate[Validate evidence scope and validity] + Validate -->|Invalid supplied evidence| Error[Reject request with reason] + Validate -->|Valid or absent evidence| Search[Planner search retains constructible candidates] + Search --> Inspect[Explain known and unknown candidate properties] + Search --> Select[Planner global selection under backend policy] + Select --> Exact[Explicit exact fallback when no certified summary is selected] + Select --> Bind[Bind selected logical DAG to concrete execution] + Bind --> Admit[Check guarantees, runtime support and complete workload cost] + Admit -->|Pass| Publish[Publish coherent physical plan] + Admit -->|Fail| Reject[Reject deployment] +``` + +Exact fallback is part of normal logical planning. A directly supplied summary +plan with missing or unknown readout guarantees fails physical compilation; +there is no promise that every compilation error retries another candidate. +An exact route must itself be available under the deployment's existing policy. + +## Evidence contract + +Evidence belongs to an exact query text, full data workload and data snapshot. +It also names its source, observation time and validity window. The backend +rejects mismatched, expired, future-dated or invalid records before selection. +Discovery without a workload quote needs an explicit data snapshot; when both +are supplied, their snapshot identities must agree. Queries with individual +certificates are isolated during selection so another query cannot borrow them. + +| Family | Facts that can justify a candidate | Missing-proof behavior | +| --- | --- | --- | +| DDSketch quantile ratio | Enforced input bounds and maximum sample count for each exact operand | Ratio remains visible with unknown propagated accuracy | +| Hydra | Shared-grid collision bound and failure probability | Missing bounds remain unknown | +| TopK | Selected lower bound, excluded upper bound and interval failure probability | No membership certificate is invented | +| Relative composition | Applicable non-negativity, cardinality and distribution facts | Unsupported propagation remains unknown | +| HLL / ERP readouts | A valid accuracy model including the required confidence guarantee | RSE or observed maximum error alone cannot certify the readout | + +Quantile domains describe an enforced source contract, not observed sample +extrema. Supplied TopK intervals must be complete and strictly separate selected +from excluded items. Omitted facts remain unknown; malformed supplied evidence +is rejected rather than treated as absent. The older TopK evidence interface +remains compatible; the scoped contract is the path for new producers. + +The backend validates the supplied record's scope and consistency. It does not +derive a source-domain proof from samples or establish the truth of a producer's +claimed contract. Producing valid external proofs remains an upstream duty. + +## Accuracy, runtime and cost remain separate + +A known accuracy guarantee does not establish executor support. The physical +compiler checks the executable DAG and runtime policy, and rejects summary +readouts whose guarantee is absent or contains unknown terms. Unknown support +must not be reported as deployment approval. + +Missing numerical cost remains unavailable, never zero. The backend implements +Planner's `candidate_cost()` directly; it does not enable uncosted legacy +selection. A cost estimate only supports logical selection. Publication requires +complete, applicable workload cost quotes. A cheap candidate cannot bypass +accuracy or runtime admission. + +Explain records accuracy status and symbolic guarantee, runtime support status, +cost availability, and the selection reason separately. A selected candidate +is labelled as pending backend binding. Rejected candidates retain their +reported reasons. This distinguishes missing proof, unsupported execution, +missing comparable cost and a candidate that simply lost the ranking. + +## Example and acceptance behavior + +For `quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])`: + +1. Without operand-domain evidence, the DDSketch ratio remains inspectable but + has unknown accuracy. Normal planning preserves exact execution. +2. With valid, scoped operand contracts, Planner can derive the ratio guarantee + and check the explicit root target. Valid evidence alone does not guarantee + that the target is met or that this candidate wins selection. +3. A selected candidate still needs physical support and complete workload cost + quotes before publication. A stale or cross-query certificate rejects the request. + +Cross-family acceptance includes absent, partial, valid, invalid and stale +evidence, an explicit root accuracy target, unavailable cost, and unsupported +runtime operations. Explain must never call an uncertified candidate approved; +direct physical submission must not bypass the guarantee check. + +The current conservative policy changes behavior for HLL and ERP v1: relative +standard error and benchmark maximum error lack a calibrated tail probability. +Those paths use exact execution when no independent valid guarantee exists. +Re-enabling them requires an appropriate proof model, not merely more benchmark +samples or a favorable shape-match score. + +Related: [integration architecture](asapplanner-integration.md), +[shape-aware ERP](shape-aware-erp-v1.md), +[Planner evidence contract](https://github.com/ProjectASAP/ASAPPlanner/blob/2ec3fc80caa922c8e1f33aa05f60b7d787e73257/docs/design_docs/architecture/evidence-dependent-candidates.md). + +## Time precision at admission + +Source cadence, query lookback, ranges and offsets retain integral millisecond +precision through workload lowering and query publication. A 100 ms source is +not rejected or rewritten to one second before costing. Existing pane layout +contracts still express storage sizes in seconds; rounding a storage sizing +bound must not change the query's read interval. A temporal producer that cannot +implement a fractional range remains unsupported for that binding, while exact +execution preserves the original range. Level-3 acceptance uses the actual +replay cadence and still requires a local executable plan. + +Spatial TopK admission keeps the exact population ranking and the Planner's +CountSketch-with-heap physical candidate separate. The heap requires scoped +score separation and an enforced `topk_max_distinct_items` bound; an estimated +workload cardinality is insufficient. Missing evidence leaves the candidate +visible but ineligible for installation. A complete provider quote can +choose either candidate; neither is forced by operator name. + +Rate TopK follows the same admission and pricing boundary. Nonnegative finalized +counter rates admit CMS as well as CountSketch when the scoped accuracy evidence +is sufficient. Exact ranking remains available. The installed physical program +consumes the complete per-series Rate vector from bound counter SDS; Backend +quotes can select any of the three programs. Operator support alone does not supply accuracy proof. + +A fixed-window Rate heap candidate places Rate finalization and heap construction +in precompute and persists the heap. Query-time construction remains a separate +candidate. This placement requires a complete, durable counter population for the +same window and does not authorize merging rates across windows. The initial +runtime realization uses the finite-source completion barrier. Candidate admission +must reject deployments unable to satisfy that completion requirement. + +For grouped Rate, the exact grouped Sum also has query-time and precompute +physical candidates. A stored complete-window Sum does not combine raw counters +across series before Rate. Its producer cadence must satisfy the query recurrence; +selecting a 60-second lookback cannot silently reduce five-second evaluations to +one output per minute. Overlapping full windows remain independent stored results. diff --git a/docs/design_docs/summary-catalog-sds-architecture.md b/docs/design_docs/summary-catalog-sds-architecture.md index afad2a1fc..d4e221ecb 100644 --- a/docs/design_docs/summary-catalog-sds-architecture.md +++ b/docs/design_docs/summary-catalog-sds-architecture.md @@ -215,6 +215,18 @@ the producer's input contract, not inferred from interval endpoints alone. A replacement snapshot replaces a record's revision; readers must not mix its old metadata with new bytes or count both snapshots as separate inputs. +The stored output also fixes its publication granularity. A native grouped heap or grouped Sum +may publish all groups as one typed batch for a complete window, with an empty +outer `group_key`. Group columns remain inside that batch and in its semantic +definition. The installed query reads the batch and runs Planner's grouped +readout. This makes the complete group set atomic across publication and recovery; +it does not claim that the logical computation has no grouping. Full-window +snapshots may overlap when the evaluation cadence is shorter than the lookback. +An installed full-window read selects its exact endpoints; other overlapping +snapshots are neither combined nor mistaken for ambiguous versions of that record. +A per-group record +layout and a complete-batch layout cannot silently substitute for each other. + ## 5. Semantic identity vs. deployed-output identity SDS uses two identities because they answer different questions: diff --git a/docs/developer_docs/query-engine/catalog-physical-plan-runtime.md b/docs/developer_docs/query-engine/catalog-physical-plan-runtime.md index 890ae627f..cf575abbd 100644 --- a/docs/developer_docs/query-engine/catalog-physical-plan-runtime.md +++ b/docs/developer_docs/query-engine/catalog-physical-plan-runtime.md @@ -52,3 +52,104 @@ accelerated results; the query follows its installed fallback/unavailability policy while cold. Fresh writes allocate a new physical series instead of using the previous generation's completed series, both after restart and during live activation. Cross-version adoption metadata is rejected. + +## Persisted physical computation and outputs + +Installed SQL entries include a serialized native physical DAG. Installation +compiles it once; activation and requests validate its version, roots and input +schemas, then bind concrete batches. Missing physical programs require plan +recompilation. Serving does not lower relational expressions again. + +Native summary snapshots use `native_batch_v1`, with an explicit storage tag +separate from legacy sketch frames. The payload preserves the physical schema, +Float64 values, typed heap identities and the summary codec. Legacy sketch +readers reject this tag. Existing installed schemas may keep a nonempty, unique +subset of supported encodings when new decoders are added. + +`SketchStore::publish_native_summary_output` publishes one summary row for one +group/window through the existing immutable commit path. It requires the complete +durable raw input cohort and validates the installed family and group. Repeated +publication of the same lineage reuses the existing commit. + +`read_native_summary_output` resolves the installed output/group through the +persistent series resolver without allocating an identity. It validates the +plan version, definition, exact window, completeness, encoding, physical schema, +group values and read budget before returning a batch to the physical executor. +The supported path reads a complete immutable window; pane composition remains +an explicitly planned physical operation. + +The storage integration test covers durable per-series sums, native quantile +state construction, publication, bound readout and restart of both the store and +resolver. That test does not establish the full PromQL physical-candidate handoff or +precomputed heap publication. + +Bound frozen reads resolve the stored-output/group prefix through the persistent +series resolver. Each cached part builds a sorted output/window index once on +open, including older parts written in flush order. Range reads inspect only the +matching prefix and start-time range, then validate end times and exact coverage. +Duplicate windows remain visible and are rejected as ambiguous. The index memory +counts toward the part-cache budget; the on-disk part format is unchanged. + +For selected explicit-`by` current-series TopK, the population store now supplies +all eligible members through a snapshot binding. Planner compiles the ranking +above that boundary; the QueryPlan persists that physical program before +candidate pricing and activation. Serving recovers its operators and supplies +full-label native rows without parsing or lowering the query. Provider quote +manifests include the physical program itself. Other population readouts retain +their existing paths. + +Planner also exposes a CountSketch-with-heap candidate over that same snapshot. +Backend requires scoped membership/score evidence, including the enforced +`topk_max_distinct_items` bound, before installation. Source cardinality estimates +do not supply that accuracy bound. Signed sample values cannot authorize CMS. +Backend compares the complete physical programs; it does not replace the selected +heap with Sort/Limit. The heap is rebuilt for each evaluation, so decreases, +staleness and expiry do not accumulate historical weights. Automated real-process +Remote Write/HTTP coverage verifies these changes with no exact-backend fallback. +This is a query-time heap candidate; buffered Rate-to-heap persistence is separate. + +For `topk by (...) (..., rate(metric[1m]))`, Planner also compiles exact ranking, +CMS heap and CountSketch heap above the exact per-series Rate boundary. Backend +binds that input to the installed counter SDS. `QueryPlanNode::Physical` maps +source node IDs to deployment readouts and supplies the run budget; it contains +no second operator representation. Recovery requires the persisted physical +program, matching source IDs, complete identity and the same readout window. + +The process E2E tests ingest raw counters, publish durable counter windows, run +native ranking, restart the process and repeat both window queries. Both heap +families preserve hidden series labels, account for counter resets and rebuild +ranking independently for each window. Missing windows cannot serve a warm +result. This path stores counter state and builds the heap at query time; +publishing the heap itself during precompute requires a separate placement. + +Fixed-window Rate ranking has a second placement: Planner finalizes the complete +per-series counter window and builds the heap during maintenance. The deployment +binds the resulting heap to SDS; the query graph contains readout and ranking +projection only. Counter states are the bounded input buffer, including timestamps +and resets. Rates from different windows are never added as heap updates. + +This placement runs after the finite input cohort is closed and durable. It does +not infer population completeness from a timer or a missing series. Continuous +maintenance needs an explicit population/window completion contract before it can +use this path. Binding requires matching complete source windows at the query's +evaluation cadence. Full windows may overlap: a 60-second lookback evaluated +every five seconds stores independent `(t-60s,t]` outputs every five seconds. +A bound read selects exact window endpoints and never adds neighboring snapshots. + +Each native heap output is one atomic batch record per window. Logical groups +and series identities remain in the typed batch; its outer storage address uses +an empty group. Publishing groups independently would permit recovery to expose +an incomplete group set. The canonical SummaryDefinition still describes the +logical grouping and expressions. This storage granularity does not erase them. + +Installed maintenance programs retain Planner operator definitions and typed +input/output node identities. Recovery validates those boundaries against the +installed semantic document and stored-output bindings. The query resolves its +exact deployed output through the store index, checks the definition, generation, +window, encoding and batch schema, then executes the retained query graph. + +Grouped Rate supports the same placement choice. Planner can emit per-series +Rate readout → grouped Sum → Sum readout entirely at query time, or persist the +complete grouped Sum batch during maintenance. Both preserve grouping and drop +ungrouped series labels in the result. Backend prices both physical programs; +it does not move Sum across Rate or pool raw counters before calculating Rate.