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/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/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(), 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(); +} 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.