diff --git a/Cargo.lock b/Cargo.lock index 471b047ab..1523a0f4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "asap-aware-mapping" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" dependencies = [ "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "asap-frontend-promql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" dependencies = [ "asap-types", "promql-parser 0.10.0 (git+https://github.com/ProjectASAP/promql-parser?rev=9fede7eecca923c9882fe256484d00d37f8706cb)", @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "asap-frontend-sql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" dependencies = [ "asap-sql-function-catalog", "asap-types", @@ -396,7 +396,7 @@ dependencies = [ [[package]] name = "asap-physical-operators" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" dependencies = [ "asap-types", "asap_sketch_codec", @@ -416,12 +416,12 @@ dependencies = [ [[package]] name = "asap-sql-function-catalog" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" [[package]] name = "asap-types" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" dependencies = [ "serde", "serde_json", @@ -443,7 +443,7 @@ dependencies = [ [[package]] name = "asap_sketch_codec" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" dependencies = [ "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib?rev=5f03ccbd798ed5fec62bdd839bcb331123cab369)", "prost", diff --git a/Cargo.toml b/Cargo.toml index 9040dbf29..2be96caea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,10 @@ version = "0.1.0" [workspace.dependencies] # Keep Planner frontends, selection, and IR on the same immutable revision. # Alias upstream asap-types because this workspace also defines asap_types. -planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "4b0839ce1733aab8231eb90944c876063a4551f9" } -asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "4b0839ce1733aab8231eb90944c876063a4551f9" } -asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "4b0839ce1733aab8231eb90944c876063a4551f9" } -asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "4b0839ce1733aab8231eb90944c876063a4551f9" } +planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "76fbbf16cc44b19f56a780bfdb47a95327e84711" } +asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "76fbbf16cc44b19f56a780bfdb47a95327e84711" } +asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "76fbbf16cc44b19f56a780bfdb47a95327e84711" } +asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "76fbbf16cc44b19f56a780bfdb47a95327e84711" } # Shared external deps (used by 2+ crates) serde = { version = "1.0", features = ["derive"] } @@ -37,8 +37,8 @@ arc-swap = "1.7" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # Internal crates -asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "4b0839ce1733aab8231eb90944c876063a4551f9" } -asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "4b0839ce1733aab8231eb90944c876063a4551f9" } +asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "76fbbf16cc44b19f56a780bfdb47a95327e84711" } +asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "76fbbf16cc44b19f56a780bfdb47a95327e84711" } asap_types = { path = "crates/asap_types" } asap_otel_proto = { path = "crates/asap_otel_proto" } indexmap = { version = "2.0", features = ["serde"] } 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/clickhouse.rs b/control_plane/src/clickhouse.rs index 6b5f8cd88..dc421e778 100644 --- a/control_plane/src/clickhouse.rs +++ b/control_plane/src/clickhouse.rs @@ -534,7 +534,7 @@ where "SQL did not produce a summary DAG".into(), )); }; - let semantic = planner_types::post_asap::compile_executable_dag_with_node_ids(&root) + let semantic = planner_types::post_asap::compile_post_asap_dag_with_node_ids(&root) .map_err(|error| ClickHousePlanningError::Lower(error.to_string()))?; let mut materialization_nodes = std::collections::BTreeMap::new(); let mut query_nodes = std::collections::BTreeMap::new(); diff --git a/control_plane/src/main.rs b/control_plane/src/main.rs index fae7acbb2..ef5ac2254 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", @@ -586,10 +590,11 @@ fn compile_physical_plan_request( query_id: query.query_id, query_string: query.query_string, selected_plan_root: post_asap, + physical_candidate: None, 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 +603,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 +691,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 +974,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 d90199c2a..af7c34bba 100644 --- a/control_plane/src/physical/compiler.rs +++ b/control_plane/src/physical/compiler.rs @@ -16,7 +16,7 @@ use asap_aware_mapping::{ SummaryMaintenanceLifecycleCostInputs, WorkloadDemand, }; use planner_types::post_asap::{ - CompositionOperator, EvaluationSchedule, ExecutableDagCompilation, OutputRepresentation, + CompositionOperator, EvaluationSchedule, OutputRepresentation, PostAsapDagCompilation, PostAsapNodeId, SketchAlgorithm, SketchParams, SketchQuery, SummaryExpr, SummaryFamilyType, SummaryMaintenanceLifecycle, SummaryMaintenanceMode, SummaryNode, SummaryWindowFramework, }; @@ -67,11 +67,14 @@ pub struct QueryCompilationInput { /// Planner-selected post-ASAP DAG. The physical compiler must not /// re-select a summary family from pre-ASAP input. pub selected_plan_root: Rc, + /// Encoded Planner PhysicalCandidate retained before deployment pricing. + /// Installation decodes this candidate; it must not compile another graph. + pub physical_candidate: Option>, /// Legacy catalog source retained for request compatibility. Physical /// 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, @@ -88,6 +91,46 @@ pub struct QueryCompilationInput { pub materialization_runtime_policy: RuntimeRulePolicy, } +impl QueryCompilationInput { + /// Physical planning precedes deployment feasibility and cost selection. + pub(crate) fn retain_physical_candidate(&mut self) -> Result<(), CompileError> { + use asap_physical_operators::physical_planner::{promql_rows, PhysicalCandidate}; + let candidate = + promql_rows::compile_fixed_window_rate_aggregation(&self.selected_plan_root) + .or_else(|_| { + promql_rows::compile_current_series_readout(&self.selected_plan_root) + .or_else(|_| { + promql_rows::compile_rate_ranking(&self.selected_plan_root) + .map(|(_, dag)| dag) + }) + .map(|query| PhysicalCandidate { + precompute: None, + query, + materialized_outputs: BTreeMap::new(), + }) + }) + .ok(); + self.physical_candidate = candidate + .map(|candidate| candidate.encode()) + .transpose() + .map_err(|e| CompileError::Snapshot(e.to_string()))?; + Ok(()) + } + + fn retained_physical( + &self, + ) -> Result, CompileError> + { + self.physical_candidate + .as_ref() + .map(|bytes| { + asap_physical_operators::physical_planner::PhysicalCandidate::decode(bytes) + .map_err(|e| CompileError::Snapshot(e.to_string())) + }) + .transpose() + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct WindowRealizationCostQuote { @@ -154,10 +197,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 +213,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 +244,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 { @@ -241,7 +418,6 @@ pub struct BackendLocalPlanningInput { /// Must equal [`WORKLOAD_SNAPSHOT_VERSION`]. #[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")] @@ -255,6 +431,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, @@ -279,6 +458,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")] @@ -480,7 +665,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)] @@ -499,7 +684,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 { @@ -518,24 +703,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 } } @@ -595,6 +835,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 { @@ -640,6 +903,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 { @@ -651,14 +915,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(); @@ -686,14 +945,26 @@ 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(), selected_plan_root: post_asap, + physical_candidate: None, 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, @@ -701,6 +972,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 @@ -722,13 +1003,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, @@ -737,11 +1100,49 @@ impl BackendLocalPlanningInput { self.physical_inputs.query_retention_margin_ms, )?; } - // Composable lowering defers unsafe leaves to query time individually; retain Planner siblings. + 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); + } + for query in queries + .iter_mut() + .chain(planner_candidate_forests.iter_mut().flatten()) + { + query.retain_physical_candidate()?; + } + // 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), @@ -1064,6 +1465,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()); @@ -1073,7 +1475,7 @@ impl DeploymentPlanCompiler { // materializations, then consumed by QueryPlan lowering. The key is // the planner DAG node identity across the workload; serving never scans // downstream components to rediscover this decision. - let mut executable_dags = vec![None::; request.queries.len()]; + let mut executable_dags = vec![None::; request.queries.len()]; let mut node_bindings = BTreeMap::<(usize, PostAsapNodeId), asap_types::PolicyFingerprint>::new(); let mut query_node_bindings = @@ -1100,15 +1502,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| { @@ -1156,19 +1565,17 @@ 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( + let executable = planner_types::post_asap::compile_post_asap_dag_with_node_ids( &query.selected_plan_root, ) .map_err(|error| CompileError::Query { @@ -1215,6 +1622,9 @@ impl DeploymentPlanCompiler { if matches!(&state.node.expr, SummaryExpr::SummaryAgg { reduction: planner_types::pre_asap::Reduction::Reduce(keys), .. } if keys.is_empty()) + || query + .retained_physical()? + .is_some_and(|candidate| candidate.precompute.is_some()) { if let Some(sources) = immutable_materialization_sources(&state.node) { canonical_nodes.insert(Rc::as_ptr(&state.node) as usize); @@ -1225,10 +1635,15 @@ impl DeploymentPlanCompiler { } } let cohort_nodes = windows::cohort_nodes(&selected); + let native_cohort = query + .retained_physical()? + .is_some_and(|candidate| candidate.precompute.is_some()); 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() @@ -1236,9 +1651,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 } @@ -1323,7 +1749,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, @@ -1454,7 +1880,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() }); @@ -1466,14 +1894,15 @@ impl DeploymentPlanCompiler { .node_ids .node_id(child) .expect("selected input node"); - let document = asap_types::executable_plan::OwnedPostAsapDag::from_executable( - query.query_id.clone(), - &compiled.dag, - ) - .map_err(|reason| CompileError::Query { - query_id: query.query_id.clone(), - reason, - })?; + let document = + asap_types::executable_plan::OwnedPostAsapDag::from_post_asap_dag( + query.query_id.clone(), + &compiled.dag, + ) + .map_err(|reason| CompileError::Query { + query_id: query.query_id.clone(), + reason, + })?; runtime_materialization.derived_input = Some( asap_types::derived_input::DerivedInputIdentity::from_dag( &document, input_node, &frontiers, @@ -1745,7 +2174,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!( @@ -1768,7 +2197,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, }; @@ -1789,15 +2218,138 @@ impl DeploymentPlanCompiler { } else { false }; - let mut entry = if let Some(operator) = - super::maintained_population::operator(&request, query)? - { + let native_rate = if population_operators[query_index].is_none() { + query + .retained_physical()? + .map(|physical| { + let program = physical.query; + let sources = program + .input_contracts() + .map(|(id, _)| id) + .collect::>(); + let [source] = sources.as_slice() else { + return Err(CompileError::Snapshot( + "native candidate requires one bound input".into(), + )); + }; + let source = executable_dags[query_index] + .as_ref() + .and_then(|compiled| { + u32::try_from(*source).ok().and_then(|id| { + compiled.node_ids.summary_node(PostAsapNodeId(id)) + }) + }) + .cloned() + .ok_or_else(|| { + CompileError::Snapshot( + "selected physical input has no semantic binding".into(), + ) + })?; + Ok((source, program)) + }) + .transpose()? + } else { + None + }; + 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(), @@ -1829,6 +2381,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(), @@ -1888,6 +2441,21 @@ impl DeploymentPlanCompiler { if frontend == QueryFrontend::MetricsQl { entry.language = crate::query_plan::QueryLanguage::MetricsQl; } + super::maintained_population::install_native_topk( + &mut entry, + query + .retained_physical()? + .as_ref() + .map(|candidate| &candidate.query), + )?; + let linked = crate::query_plan::physical_values::compile(&mut entry)?; + for ((index, _), node) in &mut query_node_bindings { + if *index == query_index { + if let Some(root) = linked.get(node) { + *node = *root; + } + } + } let catalog_key = QueryPlan::catalog_key(entry.language, &canonical); if query_entries.insert(catalog_key, entry).is_some() { return Err(CompileError::Query { @@ -1913,7 +2481,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, @@ -1929,11 +2497,56 @@ impl DeploymentPlanCompiler { query_id: query_id.clone(), reason, })?; + if let Some(program) = request.queries[query_index] + .retained_physical()? + .and_then(|physical| physical.precompute) + { + let sink = PostAsapNodeId( + u32::try_from(program.roots()[0]) + .map_err(|_| CompileError::Snapshot("precompute 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()))?, + ); + } + super::executable_binding::compile_precompute_programs( + &mut installed, + &materializations, + ) + .map_err(|reason| CompileError::Query { + query_id: query_id.clone(), + reason, + })?; 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 @@ -1942,6 +2555,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( @@ -2075,6 +2689,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::query_time::QueryTimeOperator::ExactSubquery { .. } + | crate::query_plan::query_time::QueryTimeOperator::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, @@ -2150,7 +2778,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 { @@ -2213,6 +2841,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() { @@ -2226,6 +2896,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 @@ -2279,6 +2950,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 { @@ -2286,15 +2958,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 { @@ -2474,7 +3187,11 @@ pub fn select_post_asap( expr, &CollectorFixtureModel(model), &DefaultAccuracyModel, - &QueryEvidence(evidence), + &QueryEvidence { + topk: evidence, + scoped: None, + now_ms: 0, + }, ) } @@ -2634,10 +3351,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()) @@ -2666,17 +3382,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) @@ -2822,7 +3531,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) @@ -2885,8 +3594,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; @@ -2897,7 +3606,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) @@ -2929,7 +3638,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 { @@ -2944,6 +3653,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 { @@ -3034,8 +3748,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, }, @@ -3085,6 +3799,13 @@ fn select_lifecycle( query_id: query.query_id.clone(), reason: error.to_string(), })?; + // Planner lists the passed SummaryAgg first. Nested source states are + // selected and priced by their own calls, and the backend cost model gives + // every deployment the same window framework, so later entries add nothing. + debug_assert!(plan + .deployments + .first() + .is_none_or(|deployment| Rc::ptr_eq(&deployment.summary, &plan.root))); let guarantee = plan .deployments .first() @@ -3124,12 +3845,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(), @@ -3262,7 +3983,7 @@ fn immutable_materialization_sources(node: &SummaryNode) -> Option Option) -> Result<(), String> { - let executable = planner_types::post_asap::compile_executable_dag(node) + let executable = planner_types::post_asap::compile_post_asap_dag(node) .map_err(|error| format!("invalid executable subDAG: {error}"))?; if let Some(edge) = executable.edges.iter().find(|edge| { edge.grouping == planner_types::post_asap::GroupingEdgeCompatibility::Incompatible @@ -3350,8 +4102,12 @@ fn validate_executable_subdag(node: &Rc) -> Result<(), String> { Ok(()) } -fn reject_uncertified_readouts(query_id: &str, root: &Rc) -> Result<(), CompileError> { - let dag = planner_types::post_asap::compile_executable_dag(root).map_err(|error| { +fn reject_uncertified_readouts( + query_id: &str, + root: &Rc, + accuracy: &AccuracyTarget, +) -> Result<(), CompileError> { + let dag = planner_types::post_asap::compile_post_asap_dag(root).map_err(|error| { CompileError::Query { query_id: query_id.into(), reason: format!("invalid executable subDAG: {error}"), @@ -3360,7 +4116,7 @@ fn reject_uncertified_readouts(query_id: &str, root: &Rc) -> Result for node in &dag.nodes { if matches!( node.payload, - planner_types::post_asap::ExecutableOperatorPayload::SummaryEstimate { .. } + planner_types::post_asap::PostAsapOperatorPayload::SummaryEstimate { .. } ) && node .guarantee .as_ref() @@ -3372,6 +4128,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::PostAsapOperatorPayload::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(()) } @@ -3385,7 +4160,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 @@ -3423,6 +4200,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 @@ -3575,6 +4368,7 @@ fn collect_selected_materializations( node: &Rc, readout: Option<&SketchQuery>, composable: bool, + native_maintenance: bool, inherited_grouping: Option>, selected: &mut Vec, ) -> Result<(), String> { @@ -3608,9 +4402,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 { @@ -3621,24 +4432,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, @@ -3646,15 +4506,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, _), @@ -3667,12 +4535,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 { @@ -3769,7 +4645,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)); @@ -3780,7 +4667,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 { @@ -3879,6 +4766,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 @@ -4003,7 +4891,7 @@ pub(crate) mod tests { std::slice::from_ref(&root), ); let candidate = rule.candidate(&root).unwrap(); - planner_types::post_asap::compile_executable_dag(&candidate).unwrap(); + planner_types::post_asap::compile_post_asap_dag(&candidate).unwrap(); assert!(!super::super::maintained_population::supported_node( &candidate )); @@ -4023,24 +4911,18 @@ pub(crate) mod tests { let plan = DeploymentPlanCompiler .compile_promql(request, environment) .unwrap(); - assert!( - plan.query_plan - .entries - .values() - .flat_map(|e| e.nodes.values()) - .any(|node| matches!( + assert!(plan + .query_plan + .entries + .values() + .flat_map(|entry| entry.nodes.values()) + .flat_map( + |node| crate::query_plan::physical_values::operator_parameters( node, - crate::query_plan::QueryPlanNode::Logical { - operator: asap_types::query_plan::query_time::QueryTimeOperator::Binary { - operation: - asap_types::query_plan::query_time::BinaryOperation::FiniteDiv, - .. - }, - .. - } - )), - "{plan:#?}" - ); + "VectorBinary" + ) + ) + .any(|parameters| parameters["operator"]["checked_finite_division"] == true)); } // The Planner's minimum state lowers without reconstructing direction from text. @@ -4311,17 +5193,16 @@ pub(crate) mod tests { .find(|node| { matches!( node.payload, - planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { .. } + planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { .. } ) }) .unwrap(); - if let planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { - reduction, .. - } = &mut node.payload + if let planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { reduction, .. } = + &mut node.payload { *reduction = planner_types::pre_asap::Reduction::by(vec![]); } - installed.document = asap_types::executable_plan::OwnedPostAsapDag::from_executable( + installed.document = asap_types::executable_plan::OwnedPostAsapDag::from_post_asap_dag( installed.document.query_id.clone(), &dag, ) @@ -4455,9 +5336,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" )) @@ -4465,13 +5347,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. @@ -4550,13 +5447,12 @@ pub(crate) mod tests { .unwrap(); let entry = plan.query_plan.lookup(query).unwrap(); use crate::query_plan::{query_time::QueryTimeOperator, QueryPlanNode}; - assert!(matches!( + let limits = crate::query_plan::physical_values::operator_parameters( &entry.nodes[&entry.root], - QueryPlanNode::Logical { - operator: QueryTimeOperator::Limit { n: 2, .. }, - .. - } - )); + "Limit", + ); + assert_eq!(limits.len(), 1); + assert_eq!(limits[0]["n"], 2); assert_eq!( entry .nodes @@ -4750,9 +5646,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, @@ -4760,8 +5658,9 @@ pub(crate) mod tests { query_id: query_id.into(), query_string: promql.into(), selected_plan_root: post_asap, + physical_candidate: None, 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, @@ -5014,6 +5913,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!( @@ -5697,13 +6773,14 @@ pub(crate) mod tests { .query_plan .entries .values() - .any(|entry| entry.nodes.values().any(|node| matches!( - node, - crate::query_plan::QueryPlanNode::Logical { - operator: crate::query_plan::query_time::QueryTimeOperator::Binary { .. }, - .. - } - )))); + .flat_map(|entry| entry.nodes.values()) + .any( + |node| !crate::query_plan::physical_values::operator_parameters( + node, + "VectorBinary" + ) + .is_empty() + )); } #[test] @@ -5959,49 +7036,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 @@ -6041,6 +7095,119 @@ pub(crate) mod tests { assert!(full.weighted_cost >= lifecycle.horizon_seconds * 11.0); } + // One selected semantic diamond must survive physical compilation; outputs + // with a different deployment window cannot share the same execution run. + #[test] + fn precompute_compilation_preserves_shared_roots_and_window_boundaries() { + use asap_types::executable_plan::*; + use planner_types::post_asap::*; + let bundle = DeploymentPlanCompiler + .compile_promql(request("q", "sum_over_time(m[1m])"), environment(10_000)) + .unwrap(); + let mut source = bundle.precompute_plan.materializations[0].clone(); + source.stored_output_id = Some(asap_types::sds::StoredOutputId(1)); + source.population_key_encoding = asap_types::PopulationKeyEncoding::CanonicalLabelsV1; + let mut configs = vec![source.clone()]; + for id in [3, 4] { + let mut output = source.clone(); + output.stored_output_id = Some(asap_types::sds::StoredOutputId(id)); + output.derived_input = Some(asap_types::derived_input::DerivedInputIdentity { + inputs: BTreeSet::from([asap_types::sds::StoredOutputId(1)]), + program_sha256: "0".repeat(64), + }); + configs.push(output); + } + let schema = SummarySchema { + fields: vec![SummaryField { + name: "state".into(), + dtype: SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), + nullable: false, + }], + time_index: None, + }; + let dag = PostAsapDag { + nodes: (1..=5) + .map(|id| PostAsapDagNode { + id: PostAsapNodeId(id), + payload: PostAsapOperatorPayload::SummaryMerge, + output_state: ExecutionDataState::INGESTION_SUMMARY, + output_schema: schema.clone(), + guarantee: None, + }) + .collect(), + edges: [(1, 2), (2, 3), (2, 4), (3, 5), (4, 5)] + .into_iter() + .map(|(a, b)| PostAsapDagEdge { + producer: PostAsapNodeId(a), + consumer: PostAsapNodeId(b), + role: EdgeRole::Input, + intermediate_schema: schema.clone(), + data_state: ExecutionDataState::INGESTION_SUMMARY, + grouping: GroupingEdgeCompatibility::Identical, + window: WindowEdgeCompatibility::NotApplicable, + }) + .collect(), + root: PostAsapNodeId(5), + }; + let mut installed = InstalledPostAsapDag { + document: OwnedPostAsapDag::from_post_asap_dag("shared".into(), &dag).unwrap(), + native_programs: BTreeMap::new(), + binding: BackendExecutableBinding { + nodes: (1..=5) + .map(|id| { + ( + PostAsapNodeId(id), + if id == 2 || id == 5 { + BackendNodeBinding::MaintenanceInput + } else { + BackendNodeBinding::Materialization { + stored_output: asap_types::sds::StoredOutputId(id as u64), + } + }, + ) + }) + .collect(), + precompute_sinks: vec![PostAsapNodeId(3), PostAsapNodeId(4)], + query_sink: PostAsapNodeId(5), + query_plan_sink: QueryNodeId(5), + }, + }; + super::super::executable_binding::compile_precompute_programs(&mut installed, &configs) + .unwrap(); + assert_eq!( + installed + .native_program(PostAsapNodeId(3)) + .unwrap() + .unwrap() + .roots(), + &[3, 4] + ); + assert_eq!( + installed.native_programs[&PostAsapNodeId(3)], + installed.native_programs[&PostAsapNodeId(4)] + ); + configs[2].pane_origin_ms = Some(configs[1].pane_origin_ms.unwrap_or(0) + 1_000); + installed.native_programs.clear(); + super::super::executable_binding::compile_precompute_programs(&mut installed, &configs) + .unwrap(); + assert_eq!( + installed + .native_program(PostAsapNodeId(3)) + .unwrap() + .unwrap() + .roots(), + &[3] + ); + assert_eq!( + installed + .native_program(PostAsapNodeId(4)) + .unwrap() + .unwrap() + .roots(), + &[4] + ); + } + // Temporal SUM readouts share raw state only for identical source populations. #[test] fn derived_window_regression_shared_sum_panes() { @@ -6179,7 +7346,8 @@ pub(crate) mod tests { .unwrap() .0 .queries[0] - .query_lookback_seconds + .query_lookback_ms + / 1_000 } fn set_data_ingestion_interval( @@ -6204,7 +7372,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. @@ -6244,7 +7412,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. @@ -6279,31 +7447,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(); @@ -6425,9 +7612,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])", @@ -6462,7 +7649,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, @@ -6862,7 +8049,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) @@ -7333,6 +8521,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, @@ -7346,6 +8535,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, }, @@ -7468,7 +8659,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"); @@ -7535,8 +8726,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"); @@ -7782,9 +8974,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..7d6ad4724 100644 --- a/control_plane/src/physical/executable_binding.rs +++ b/control_plane/src/physical/executable_binding.rs @@ -10,7 +10,7 @@ enum OperatorExecution { /// Every physical operator uses its node's placement; payload kind does not /// restrict execution phase. Runtime capability is checked separately. -fn operator_execution(node: &planner_types::post_asap::ExecutableDagNode) -> OperatorExecution { +fn operator_execution(node: &planner_types::post_asap::PostAsapDagNode) -> OperatorExecution { match node.output_state.timing { planner_types::post_asap::ExecutionTiming::IngestionTime => OperatorExecution::Ingestion, planner_types::post_asap::ExecutionTiming::QueryTime => OperatorExecution::Query, @@ -20,7 +20,7 @@ fn operator_execution(node: &planner_types::post_asap::ExecutableDagNode) -> Ope /// Assign backend phases to a selected semantic DAG without changing its nodes. pub fn install_selected_dag( query_id: String, - dag: &planner_types::post_asap::ExecutableDag, + dag: &planner_types::post_asap::PostAsapDag, query_plan_sink: QueryNodeId, materialization: impl Fn( planner_types::post_asap::PostAsapNodeId, @@ -30,7 +30,7 @@ pub fn install_selected_dag( let mut nodes = std::collections::BTreeMap::new(); let mut precompute_sinks = Vec::new(); for node in &dag.nodes { - if let planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { + if let planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { family, input, grouping, @@ -55,7 +55,8 @@ pub fn install_selected_dag( } precompute_sinks.sort(); let installed = InstalledPostAsapDag { - document: OwnedPostAsapDag::from_executable(query_id, dag)?, + native_programs: std::collections::BTreeMap::new(), + document: OwnedPostAsapDag::from_post_asap_dag(query_id, dag)?, binding: BackendExecutableBinding { nodes, query_sink: dag.root, @@ -89,3 +90,64 @@ pub fn validate_query_plan( } Ok(()) } + +/// Retain Planner's complete state-to-state graph before candidate pricing. +pub(super) fn compile_precompute_programs( + installed: &mut asap_types::executable_plan::InstalledPostAsapDag, + configs: &[asap_types::PrecomputeMaterialization], +) -> Result<(), String> { + use asap_types::executable_plan::BackendNodeBinding; + let dag = installed.document.decode()?; + let mut groups = std::collections::BTreeMap::<_, Vec>::new(); + for sink in &installed.binding.precompute_sinks { + if installed.native_programs.contains_key(sink) { + continue; + } + let Some(BackendNodeBinding::Materialization { stored_output }) = + installed.binding.node(*sink) + else { + continue; + }; + let config = configs + .iter() + .find(|c| c.policy_fingerprint() == stored_output.fingerprint()) + .ok_or("precompute output configuration missing")?; + let Some(derived) = &config.derived_input else { + continue; + }; + let frontiers = installed.binding.nodes.iter().filter_map(|(id, binding)| { + matches!(binding, BackendNodeBinding::Materialization { stored_output } if derived.inputs.contains(stored_output)).then_some(u64::from(id.0)) + }).collect::>(); + // Only co-schedule outputs with identical immutable input and window + // contracts. Legacy population bindings are kept as individual runs. + let separate = config.population_key_encoding.is_legacy().then_some(sink.0); + groups + .entry(( + frontiers, + config.stored_window_ms(), + config.slide_interval, + config.pane_origin_ms, + separate, + )) + .or_default() + .push(u64::from(sink.0)); + } + for ((frontiers, _, _, _, _), roots) in groups { + let program = asap_physical_operators::physical_planner::precompute::compile( + &dag, &frontiers, &roots, + ) + .map_err(|e| e.to_string())?; + let encoded: serde_json::Value = + serde_json::from_slice(&program.encode().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + for root in roots { + installed.native_programs.insert( + planner_types::post_asap::PostAsapNodeId( + u32::try_from(root).map_err(|_| "physical output id overflow")?, + ), + encoded.clone(), + ); + } + } + Ok(()) +} diff --git a/control_plane/src/physical/maintained_population.rs b/control_plane/src/physical/maintained_population.rs index abe0d16f7..7bfee745f 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}, query_time::{Grouping, LabelMatch, LabelMatcher, QueryTimeOperator}, @@ -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_post_asap_dag(&std::rc::Rc::new(node.clone())).ok()?; + let populations = dag + .nodes + .iter() + .filter_map(|node| match &node.payload { + planner_types::post_asap::PostAsapOperatorPayload::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,63 @@ 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, + compiled: Option<&asap_physical_operators::physical_planner::CompiledPhysicalDag>, +) -> Result<(), CompileError> { + use asap_types::query_plan::QueryPlanNode; + let Some(QueryPlanNode::Logical { + operator: + QueryTimeOperator::CurrentSeries { + population, + readout: SeriesReadout::TopK { .. }, + }, + .. + }) = entry.nodes.get(&entry.root) + else { + return Ok(()); + }; + if population.grouping.without { + return Ok(()); + } + let compiled = compiled.ok_or_else(|| CompileError::Query { + query_id: entry.query_id.clone(), + reason: "selected TopK candidate has no retained physical DAG".into(), + })?; + 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: QueryTimeOperator::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 22dc9ed99..98bf25509 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,7 +183,11 @@ fn escape(value: &str) -> String { fn query_node_label(node: &QueryPlanNode) -> String { match node { - QueryPlanNode::PhysicalFragment { dag, .. } => { + QueryPlanNode::Physical { source_nodes, .. } => { + format!("Planner Physical DAG\nbound sources {source_nodes:?}") + } + QueryPlanNode::PhysicalRelation { dag, .. } + | QueryPlanNode::PhysicalFragment { dag, .. } => { asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) .map(|plan| { format!( @@ -149,8 +197,6 @@ fn query_node_label(node: &QueryPlanNode) -> String { }) .unwrap_or_else(|_| "Invalid physical DAG".into()) } - QueryPlanNode::RelationalJoin { .. } => "RelationalJoin".into(), - QueryPlanNode::Relational { .. } => "Relational".into(), QueryPlanNode::Logical { operator, .. } => { format!("Logical\n{}", query_time_label(operator)) } diff --git a/control_plane/src/physical/post_asap/cost_model.rs b/control_plane/src/physical/post_asap/cost_model.rs index 9610ed0a1..98ee99864 100644 --- a/control_plane/src/physical/post_asap/cost_model.rs +++ b/control_plane/src/physical/post_asap/cost_model.rs @@ -20,7 +20,7 @@ use asap_aware_mapping::{ SummaryMaintenanceLifecycleCostInputs, TargetSubDAG, ValueOperationCapabilities, }; use planner_types::post_asap::{ - ExecutableOperatorPayload, GroupingStrategy, SketchAlgorithm, SketchParams, SketchQuery, + GroupingStrategy, PostAsapOperatorPayload, SketchAlgorithm, SketchParams, SketchQuery, SummaryFamilyType, SummaryWindowFramework, }; use planner_types::pre_asap::expr_ir::ColumnRef; @@ -190,13 +190,13 @@ impl ControlPlaneCostModel { // Exact compositions have a separate measured rate model. return None; }; - let dag = planner_types::post_asap::compile_executable_dag(root).ok()?; + let dag = planner_types::post_asap::compile_post_asap_dag(root).ok()?; let mut value = 0.0; let mut states = 0; for node in &dag.nodes { let family = match &node.payload { - ExecutableOperatorPayload::SummaryAgg { family, .. } - | ExecutableOperatorPayload::SummaryJoin { family, .. } => family, + PostAsapOperatorPayload::SummaryAgg { family, .. } + | PostAsapOperatorPayload::SummaryJoin { family, .. } => family, _ => continue, }; states += 1; @@ -527,12 +527,12 @@ impl CostModel for ControlPlaneCostModel { ) -> Option { use planner_types::post_asap::{NonNegativeWeightProof, WeightDomain}; let dag = - planner_types::post_asap::compile_executable_dag(&std::rc::Rc::new(summary.clone())) + planner_types::post_asap::compile_post_asap_dag(&std::rc::Rc::new(summary.clone())) .ok()?; // Counter-weighted heaps now consume explicit rate values. The raw // ingestion adapter cannot bind that frontier as counter deltas. let requires_rate_values = dag.nodes.iter().any(|node| matches!(&node.payload, - ExecutableOperatorPayload::SummaryAgg { input, family: SummaryFamilyType::Sketch(kind, _), .. } + PostAsapOperatorPayload::SummaryAgg { input, family: SummaryFamilyType::Sketch(kind, _), .. } if matches!(kind.algorithm(), SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap) && matches!(input.weight_domain, WeightDomain::NonNegative { proof: NonNegativeWeightProof::ResetAwareCounterDerivative }))); requires_rate_values.then_some(false) diff --git a/control_plane/src/physical/workload_cost.rs b/control_plane/src/physical/workload_cost.rs index 045882c94..c6ce7e028 100644 --- a/control_plane/src/physical/workload_cost.rs +++ b/control_plane/src/physical/workload_cost.rs @@ -11,9 +11,12 @@ pub use status::{CandidateEvaluationStatus, CandidateSearchScope}; #[cfg(test)] use super::compiler::DeploymentPlanCompiler; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::rc::Rc; +use crate::types::AccuracyTarget; use asap_aware_mapping::cost_model::Cost; +use planner_types::post_asap::SummaryNode; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -38,6 +41,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 +112,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 +215,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 +331,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 +344,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(), @@ -467,17 +499,41 @@ impl WorkloadCostEvidence { } } -fn candidate_description(candidate: &PhysicalCompilationRequest) -> CandidatePlanEvaluation { - let root_ids = candidate - .queries +/// Planner forests clone query inputs, so candidates share root `Rc`s. The +/// memo borrows every candidate, which keeps each keyed address alive. +fn candidate_descriptions( + candidates: &[PhysicalCompilationRequest], +) -> Vec { + let mut root_ids = HashMap::<*const SummaryNode, Vec<(&AccuracyTarget, Option)>>::new(); + candidates .iter() - .map(|query| { - crate::planner_selection::explained_root_id( - &query.selected_plan_root, - &query.accuracy_target, - ) + .map(|candidate| { + candidate_description(candidate, |query| { + let known = root_ids + .entry(Rc::as_ptr(&query.selected_plan_root)) + .or_default(); + if let Some((_, id)) = known + .iter() + .find(|(accuracy, _)| *accuracy == &query.accuracy_target) + { + return id.clone(); + } + let id = crate::planner_selection::explained_root_id( + &query.selected_plan_root, + &query.accuracy_target, + ); + known.push((&query.accuracy_target, id.clone())); + id + }) }) - .collect::>(); + .collect() +} + +fn candidate_description<'a>( + candidate: &'a PhysicalCompilationRequest, + root_id: impl FnMut(&'a QueryCompilationInput) -> Option, +) -> CandidatePlanEvaluation { + let root_ids = candidate.queries.iter().map(root_id).collect::>(); let complete = root_ids.iter().all(Option::is_some); let mut logical_root_ids = root_ids.into_iter().flatten().collect::>(); logical_root_ids.sort(); @@ -485,7 +541,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, @@ -506,6 +562,7 @@ fn candidate_description(candidate: &PhysicalCompilationRequest) -> CandidatePla fn compile_candidate_for_pricing( candidate: PhysicalCompilationRequest, + mut description: CandidatePlanEvaluation, env: PhysicalDeploymentContext, frontend: super::compiler::QueryFrontend, ) -> Result< @@ -516,7 +573,6 @@ fn compile_candidate_for_pricing( ), Box, > { - let mut description = candidate_description(&candidate); let queries = candidate.queries.clone(); let compiled = super::realization::RealizationProvider::compile( &super::realization::ExistingRealizations, @@ -554,6 +610,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 +628,7 @@ fn compile_candidate_for_pricing( bindings, placement, &plan.precompute_plan.ingest, + native_programs, ), ) }); @@ -590,8 +653,9 @@ pub fn compile_candidates_for_pricing( ) -> (Vec, Vec) { let mut manifests = Vec::new(); let mut candidate_evaluations = Vec::new(); - for candidate in candidates { - match compile_candidate_for_pricing(candidate, env.clone(), frontend) { + let descriptions = candidate_descriptions(&candidates); + for (candidate, description) in candidates.into_iter().zip(descriptions) { + match compile_candidate_for_pricing(candidate, description, env.clone(), frontend) { Ok((_, manifest, description)) => { manifests.push(manifest); candidate_evaluations.push(description); @@ -637,9 +701,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,16 +726,11 @@ 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; - for candidate in candidates { + let mut priced_candidates = Vec::new(); + let descriptions = candidate_descriptions(&candidates); + for (candidate, description) in candidates.into_iter().zip(descriptions) { let (plan, manifest, mut description) = - match compile_candidate_for_pricing(candidate, env.clone(), frontend) { + match compile_candidate_for_pricing(candidate, description, env.clone(), frontend) { Ok(bound) => bound, Err(description) => { candidate_evaluations.push(*description); @@ -695,10 +754,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 +769,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 +807,58 @@ 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() + .map(|query| &query.selected_plan_root) + .eq(candidate + .queries + .iter() + .map(|query| &query.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 +873,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 { @@ -786,13 +907,30 @@ pub fn enumerate_exact_and_materialized_candidates( && existing .queries .iter() - .zip(&candidate.queries) - .all(|(a, b)| a.selected_plan_root == b.selected_plan_root) + .map(|query| &query.selected_plan_root) + .eq(candidate + .queries + .iter() + .map(|query| &query.selected_plan_root)) }) { candidates.push(candidate); } } } + // A retained physical candidate depends only on its root, and candidates + // share root `Rc`s. `candidates` keeps every keyed address alive. + let mut physical = HashMap::<*const SummaryNode, Option>>::new(); + for candidate in &mut candidates { + for query in &mut candidate.queries { + let root = Rc::as_ptr(&query.selected_plan_root); + if let Some(bytes) = physical.get(&root) { + query.physical_candidate = bytes.clone(); + } else { + query.retain_physical_candidate()?; + physical.insert(root, query.physical_candidate.clone()); + } + } + } Ok(candidates) } @@ -819,8 +957,8 @@ fn materialization_candidates( && request .queries .iter() - .zip(&exact.queries) - .all(|(a, b)| a.selected_plan_root == b.selected_plan_root) + .map(|query| &query.selected_plan_root) + .eq(exact.queries.iter().map(|query| &query.selected_plan_root)) { Ok(vec![request]) } else { @@ -836,7 +974,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 +1006,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::query_time::QueryTimeOperator::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 +1222,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, @@ -987,6 +1257,93 @@ mod tests { assert!(!report["logical_selection"].as_array().unwrap().is_empty()); } + /// Memoized root identities and retained physical candidates select the + /// same plan, manifest and candidate identities as independent compiles. + #[test] + fn shared_root_memoization_preserves_selected_plan_and_manifest() { + use planner_types::workload::{AccuracyRequirement, Query}; + let mut input = fixture(); + let queries = input.query_workload.repeating_queries.as_mut().unwrap(); + let template = queries[0].clone(); + *queries = [ + ("sum(sum_over_time(m[1m]))", AccuracyTarget::Exact), + ( + "quantile_over_time(0.9, m[1m])", + AccuracyTarget::Epsilon(0.05), + ), + ("count_over_time(m[1m])", AccuracyTarget::Exact), + ] + .into_iter() + .map(|(query, accuracy)| { + let mut entry = template.clone(); + entry.query = Query(query.into()); + entry.requirements.accuracy = AccuracyRequirement::Explicit(accuracy); + entry + }) + .collect(); + let input = with_unit_quotes(input); + let evidence = input.workload_cost_evidence.clone().unwrap(); + let (request, env) = input.into_physical_compilation_request().unwrap(); + let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + assert!(candidates.len() > 2); + assert!(candidates[1..].iter().any(|candidate| candidate + .queries + .iter() + .zip(&candidates[0].queries) + .any(|(a, b)| Rc::ptr_eq(&a.selected_plan_root, &b.selected_plan_root)))); + + let reference = candidates + .iter() + .map(|candidate| { + for query in &candidate.queries { + let mut fresh = query.clone(); + fresh.retain_physical_candidate().unwrap(); + assert_eq!(fresh.physical_candidate, query.physical_candidate); + } + let description = candidate_description(candidate, |query| { + crate::planner_selection::explained_root_id( + &query.selected_plan_root, + &query.accuracy_target, + ) + }); + let plan = DeploymentPlanCompiler + .compile_promql(candidate.clone(), env.clone()) + .ok(); + (description, plan) + }) + .collect::>(); + let selected = select_lowest_cost_candidate(candidates.clone(), env, &evidence).unwrap(); + let mut selected_json = serde_json::to_value(&selected).unwrap(); + selected_json["cost_comparison"] = Value::Null; + let report = selected.cost_comparison.unwrap(); + assert_eq!(report.candidate_evaluations.len(), reference.len()); + for (index, (actual, (expected, plan))) in report + .candidate_evaluations + .iter() + .zip(&reference) + .enumerate() + { + assert_eq!(actual.candidate_id, expected.candidate_id); + assert_eq!(actual.logical_root_ids, expected.logical_root_ids); + assert_eq!( + actual.identity_unavailable_reason, + expected.identity_unavailable_reason + ); + assert_eq!( + actual.plan_id, + plan.as_ref().map(|plan| plan.envelope.plan_id) + ); + if actual.status == CandidateEvaluationStatus::Selected { + let plan = plan.as_ref().unwrap(); + assert_eq!(selected_json, serde_json::to_value(plan).unwrap()); + assert_eq!( + report.selected_manifest, + manifest(plan, &candidates[index].queries).unwrap() + ); + } + } + } + // Explanation records the same minimum quote selected by the existing algorithm. #[test] fn explain_links_selected_roots_without_changing_cost_choice() { @@ -1078,7 +1435,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, @@ -1156,7 +1513,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| { @@ -1188,9 +1545,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]; @@ -1245,19 +1641,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()) @@ -1373,7 +1764,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 @@ -1469,7 +1862,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..8bcc79fdf 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; @@ -77,10 +77,10 @@ fn target_identity(target: &QueryExpr, accuracy: &AccuracyTarget) -> Option Option { // Canonical exporter owns operator payloads and edge semantics. Hash its // structure, not assigned node IDs or the incidental sharing of Rc values. - let dag = planner_types::post_asap::compile_executable_dag(&Rc::new(node.clone())).ok()?; + let dag = planner_types::post_asap::compile_post_asap_dag(&Rc::new(node.clone())).ok()?; lossless_json(&dag)?; fn visit( - dag: &planner_types::post_asap::ExecutableDag, + dag: &planner_types::post_asap::PostAsapDag, id: planner_types::post_asap::PostAsapNodeId, memo: &mut std::collections::HashMap, ) -> String { @@ -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 04a7afd85..e6aadf931 100644 --- a/control_plane/src/query_plan.rs +++ b/control_plane/src/query_plan.rs @@ -2,6 +2,7 @@ //! Serving consumes asap_types::query_plan; compilation stays in this component. mod clickhouse_exact; +pub mod physical_values; pub mod query_time; pub use asap_types::query_plan::*; @@ -41,6 +42,7 @@ where }; let root = compiler.lower(root)?; Ok(QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id, canonical_query, @@ -79,8 +81,9 @@ where preserve_relational: true, lowered: Some(&mut lowered), }; - let root = compiler.lower(root)?; + let root = compiler.lower_relation(root)?; Ok(QueryPlanEntry { + physical_dag: None, language: QueryLanguage::ClickHouseSql, query_id, canonical_query, @@ -123,6 +126,7 @@ where }; let root = compiler.lower(root)?; let mut entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id, canonical_query, @@ -141,9 +145,9 @@ fn compile_native_fragment( query_inputs: &[QueryNodeId], ) -> Result { use asap_physical_operators::physical_planner::{compile, InputContract}; - use planner_types::post_asap::{compile_executable_dag, EdgeRole}; + use planner_types::post_asap::{compile_post_asap_dag, EdgeRole}; let invalid = |e: String| QueryPlanError::Invalid(e); - let dag = compile_executable_dag(root).map_err(|e| invalid(e.to_string()))?; + let dag = compile_post_asap_dag(root).map_err(|e| invalid(e.to_string()))?; let mut edges = dag .edges .iter() @@ -174,9 +178,8 @@ fn compile_native_fragment( let physical = compile(&dag, contracts, &[u64::from(dag.root.0)]).map_err(|e| invalid(e.to_string()))?; let row_input = physical - .input_contracts() - .position(|(id, _)| bindings[&id] == query_inputs[0]) - .unwrap(); + .row_source(physical.roots()[0]) + .and_then(|source| physical.input_contracts().position(|(id, _)| id == source)); let pruning = if let SummaryExpr::RelationalJoin { left, right, @@ -229,6 +232,97 @@ where &SummaryFamilyType, ) -> Result, { + fn lower_relation(&mut self, root: &Rc) -> Result { + use asap_physical_operators::physical_planner::{compile, InputContract}; + use planner_types::post_asap::{ + compile_post_asap_dag_with_node_ids, PostAsapOperatorPayload as Payload, + }; + let compilation = compile_post_asap_dag_with_node_ids(root) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + let mut pending = vec![compilation.dag.root]; + let mut visited = std::collections::BTreeSet::new(); + let mut contracts = BTreeMap::new(); + let mut bindings = BTreeMap::new(); + let mut computed = Vec::new(); + while let Some(id) = pending.pop() { + if !visited.insert(id) { + continue; + } + let node = compilation + .dag + .nodes + .iter() + .find(|n| n.id == id) + .ok_or_else(|| QueryPlanError::Invalid("missing Planner physical node".into()))?; + let relation = matches!( + &node.payload, + Payload::RelationalJoin { .. } + | Payload::Value { + operation: planner_types::post_asap::ValueOperation::Project { .. } + | planner_types::post_asap::ValueOperation::Filter { .. } + | planner_types::post_asap::ValueOperation::Sort { .. } + | planner_types::post_asap::ValueOperation::Limit { .. } + | planner_types::post_asap::ValueOperation::Exact( + planner_types::post_asap::ExactOperation::Aggregate { .. } + ) + } + ); + if relation { + computed.push(id); + pending.extend( + compilation + .dag + .edges + .iter() + .filter(|e| e.consumer == id) + .map(|e| e.producer), + ); + } else { + let semantic = compilation.node_ids.summary_node(id).ok_or_else(|| { + QueryPlanError::Invalid("missing Planner source identity".into()) + })?; + let source = self.lower(semantic)?; + bindings.insert(u64::from(id.0), source); + contracts.insert( + u64::from(id.0), + InputContract::bounded(std::sync::Arc::new(node.output_schema.clone())), + ); + } + } + if computed.is_empty() { + return self.lower(root); + } + let physical = compile( + &compilation.dag, + contracts, + &[u64::from(compilation.dag.root.0)], + ) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + let id = QueryNodeId(self.next_id); + self.next_id += 1; + self.nodes.insert( + id, + QueryPlanNode::PhysicalRelation { + inputs: physical + .input_contracts() + .map(|(id, _)| bindings[&id]) + .collect(), + dag: physical + .encode() + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?, + }, + ); + for node in computed { + if let Some(semantic) = compilation.node_ids.summary_node(node) { + self.seen.insert(Rc::as_ptr(semantic) as usize, id); + if let Some(lowered) = &mut self.lowered { + lowered(semantic, id); + } + } + } + Ok(id) + } + fn graft( &mut self, id: QueryNodeId, @@ -248,7 +342,9 @@ where } for (local, mut physical) in nodes { match &mut physical { - QueryPlanNode::PhysicalFragment { inputs, .. } + QueryPlanNode::Physical { inputs, .. } + | QueryPlanNode::PhysicalRelation { inputs, .. } + | QueryPlanNode::PhysicalFragment { inputs, .. } | QueryPlanNode::Logical { inputs, .. } | QueryPlanNode::SummaryMerge { inputs } | QueryPlanNode::ExternalExact { inputs, .. } => { @@ -256,16 +352,14 @@ where *input = remap[input]; } } - QueryPlanNode::Binary { inputs, .. } - | QueryPlanNode::RelationalJoin { inputs, .. } => { + QueryPlanNode::Binary { inputs, .. } => { for input in inputs { *input = remap[input]; } } QueryPlanNode::SummaryEstimate { input, .. } | QueryPlanNode::ExactReadout { input, .. } - | QueryPlanNode::ReduceSum { input, .. } - | QueryPlanNode::Relational { input, .. } => *input = remap[input], + | QueryPlanNode::ReduceSum { input, .. } => *input = remap[input], QueryPlanNode::Scalar { .. } | QueryPlanNode::ReadMaterialization { .. } | QueryPlanNode::ExactFallback { .. } => {} @@ -328,109 +422,36 @@ where } let physical = match &node.expr { - SummaryExpr::RelationalJoin { - left, - right, - kind, - pred, - pruning, - } if self.preserve_relational => QueryPlanNode::RelationalJoin { - inputs: [self.lower(left)?, self.lower(right)?], - join_kind: kind.clone(), - pruning: pruning.clone(), - pred: serde_json::to_value(pred).map_err(|error| { - QueryPlanError::Invalid(format!( - "cannot serialize relational join predicate: {error}" - )) - })?, - left_schema: left.schema.clone(), - right_schema: right.schema.clone(), - output_schema: node.schema.clone(), - }, - SummaryExpr::ValueOperation { - child, operation, .. - } if self.preserve_relational - && matches!( - operation, - planner_types::post_asap::ValueOperation::Project { .. } - | planner_types::post_asap::ValueOperation::Filter { .. } - | planner_types::post_asap::ValueOperation::Sort { .. } - | planner_types::post_asap::ValueOperation::Limit { .. } - | planner_types::post_asap::ValueOperation::Exact( - planner_types::post_asap::ExactOperation::Aggregate { .. } - ) - ) => + SummaryExpr::RelationalJoin { .. } if self.preserve_relational => { + return self.lower_relation(node); + } + SummaryExpr::ValueOperation { operation, .. } + if self.preserve_relational + && matches!( + operation, + planner_types::post_asap::ValueOperation::Project { .. } + | planner_types::post_asap::ValueOperation::Filter { .. } + | planner_types::post_asap::ValueOperation::Sort { .. } + | planner_types::post_asap::ValueOperation::Limit { .. } + | planner_types::post_asap::ValueOperation::Exact( + planner_types::post_asap::ExactOperation::Aggregate { .. } + ) + ) => { - QueryPlanNode::Relational { - input: self.lower(child)?, - operation: serde_json::to_value(operation).map_err(|error| { - QueryPlanError::UnsupportedNode(format!( - "cannot serialize relational operation: {error}" - )) - })?, - input_schema: child.schema.clone(), - output_schema: node.schema.clone(), - } + return self.lower_relation(node); } SummaryExpr::ValueOperation { child, operation: planner_types::post_asap::ValueOperation::Exact( planner_types::post_asap::ExactOperation::Aggregate { - reduction, - measures, - having: None, - .. + having: None, .. }, ), timing: planner_types::post_asap::ExecutionTiming::QueryTime, - } if measures.len() == 1 => { - use planner_types::pre_asap::AggIntent; - let operation = match &measures[0] { - AggIntent::Sum { .. } => Some(query_time::Aggregation::Sum), - AggIntent::Count { .. } => Some(query_time::Aggregation::Count), - AggIntent::Min { .. } => Some(query_time::Aggregation::Min), - AggIntent::Max { .. } => Some(query_time::Aggregation::Max), - AggIntent::Avg { .. } => Some(query_time::Aggregation::Avg), - _ => { - return Err(QueryPlanError::Invalid( - "unsupported exact value aggregation".into(), - )) - } - }; - let keys = reduction.group_keys().ok_or_else(|| { - QueryPlanError::Invalid( - "per-entity exact value aggregation has no grouping".into(), - ) - })?; - let labels = keys - .keys() - .iter() - .map(|&column| { - child - .schema - .fields - .get(column) - .map(|field| field.name.clone()) - .ok_or_else(|| { - QueryPlanError::Invalid( - "unresolved exact aggregation column".into(), - ) - }) - }) - .collect::, _>>()?; - let grouping = query_time::Grouping { - labels, - without: keys.is_without(), - }; - let operator = query_time::QueryTimeOperator::Aggregate { - operation: operation.expect("aggregate operation"), - grouping, - }; - QueryPlanNode::Logical { - operator, - inputs: vec![self.lower(child)?], - } + } => { + let input = self.lower(child)?; + compile_native_fragment(node, &[input])? } SummaryExpr::ValueOperation { child, @@ -995,6 +1016,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(), @@ -1250,6 +1272,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(), @@ -1277,6 +1300,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(), @@ -1372,6 +1396,7 @@ mod tests { ) .unwrap(); let entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "q".into(), canonical_query: "topk(1, m)".into(), @@ -1389,7 +1414,7 @@ mod tests { QueryPlanNode::PhysicalFragment { inputs: vec![QueryNodeId(0)], dag: compiled.encode().unwrap(), - row_input: 0, + row_input: Some(0), pruning: None, }, ), @@ -1423,6 +1448,7 @@ mod tests { }, ); let entry = QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: "q".into(), canonical_query: "up".into(), @@ -1449,6 +1475,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(), @@ -1468,37 +1495,76 @@ mod tests { }], time_index: None, }; - QueryPlanNode::RelationalJoin { - inputs: [QueryNodeId(1), QueryNodeId(0)], - join_kind: planner_types::pre_asap::JoinKind::Semi, - pred: serde_json::to_value(planner_types::pre_asap::Predicate( - std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Compare { - left: std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Column( - 0, - )), - op: planner_types::pre_asap::CompareOpKind::Eq, - right: std::rc::Rc::new( - planner_types::pre_asap::QueryExpr::Column(1), - ), - }), - )) - .unwrap(), - pruning: Some(CandidateCompleteness::Certified { - guarantee: planner_types::post_asap::ResultGuarantee { - metric: planner_types::post_asap::ErrorMetric::Frequency, - bound: planner_types::post_asap::BoundExpr::Unknown { - statistic: "membership margin".into(), + { + let schemas = vec![ + std::sync::Arc::new(schema.clone()), + std::sync::Arc::new(schema.clone()), + ]; + let node = planner_types::post_asap::PostAsapDagNode { + id: planner_types::post_asap::PostAsapNodeId(2), + payload: + planner_types::post_asap::PostAsapOperatorPayload::RelationalJoin { + join_kind: planner_types::pre_asap::JoinKind::Semi, + pred: serde_json::from_value( + serde_json::to_value(planner_types::pre_asap::Predicate( + std::rc::Rc::new( + planner_types::pre_asap::QueryExpr::Compare { + left: std::rc::Rc::new( + planner_types::pre_asap::QueryExpr::Column( + 0, + ), + ), + op: planner_types::pre_asap::CompareOpKind::Eq, + right: std::rc::Rc::new( + planner_types::pre_asap::QueryExpr::Column( + 1, + ), + ), + }, + ), + )) + .unwrap(), + ) + .unwrap(), + pruning: None, }, - failure_probability: - planner_types::post_asap::ProbabilityExpr::Unknown { - statistic: "membership confidence".into(), + output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, + output_schema: schema, + guarantee: None, + }; + let operator = asap_physical_operators::physical_planner::compile_node( + &node, &schemas, + ) + .unwrap(); + let compiled = asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + schemas.into_iter().enumerate().map(|(id, schema)| (id as u64, asap_physical_operators::physical_planner::InputContract::bounded(schema))).collect(), + [(2, ((0..2).collect(), operator))].into(), vec![2], + ).unwrap(); + QueryPlanNode::PhysicalFragment { + inputs: [QueryNodeId(1), QueryNodeId(0)].to_vec(), + dag: compiled.encode().unwrap(), + row_input: Some(0), + pruning: (Some(CandidateCompleteness::Certified { + guarantee: planner_types::post_asap::ResultGuarantee { + metric: planner_types::post_asap::ErrorMetric::Frequency, + bound: planner_types::post_asap::BoundExpr::Unknown { + statistic: "membership margin".into(), }, - provenance: vec![], - }, - }), - left_schema: schema.clone(), - right_schema: schema.clone(), - output_schema: schema, + failure_probability: + planner_types::post_asap::ProbabilityExpr::Unknown { + statistic: "membership confidence".into(), + }, + provenance: vec![], + }, + })) + .map(|completeness| { + asap_types::query_plan::PruningInputContract { + candidate_input: 1, + keys: vec![(0, 0)], + completeness, + } + }), + } } }), ]), diff --git a/control_plane/src/query_plan/clickhouse_exact.rs b/control_plane/src/query_plan/clickhouse_exact.rs index bcd31fb85..6d0538c0b 100644 --- a/control_plane/src/query_plan/clickhouse_exact.rs +++ b/control_plane/src/query_plan/clickhouse_exact.rs @@ -350,20 +350,23 @@ mod original_tests { .nodes .values() .any(|node| matches!(node, QueryPlanNode::Logical { .. })), - "SQL must not acquire PromQL operators" + "SQL installation must not persist logical or uncompiled relational operators" + ); + let QueryPlanNode::PhysicalRelation { dag, .. } = &entry.nodes[&entry.root] else { + panic!("SQL computation must be installed as a complete physical DAG"); + }; + let physical = + asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) + .unwrap(); + assert_eq!(physical.roots().len(), 1); + assert!(!physical.input_contracts().collect::>().is_empty()); + let encoded: serde_json::Value = serde_json::from_slice(dag).unwrap(); + assert!( + encoded["nodes"].as_object().unwrap().keys().any(|id| { + physical.operator_name(id.parse().unwrap()) == Some("Aggregate") + }), + "native aggregate must remain inside the compiled physical DAG" ); - assert!(entry.nodes.values().any(|node| match node { - QueryPlanNode::Relational { operation, .. } => matches!( - serde_json::from_value::( - operation.clone() - ) - .unwrap(), - planner_types::post_asap::ValueOperation::Exact( - planner_types::post_asap::ExactOperation::Aggregate { .. } - ) - ), - _ => false, - })); } } } diff --git a/control_plane/src/query_plan/physical_values.rs b/control_plane/src/query_plan/physical_values.rs new file mode 100644 index 000000000..f3855896c --- /dev/null +++ b/control_plane/src/query_plan/physical_values.rs @@ -0,0 +1,444 @@ +//! Retain Planner-compiled scalar/vector fragments before publishing an installed plan. +use super::*; +use asap_physical_operators::physical_planner::{promql_values as physical, CompiledPhysicalDag}; +use planner_types::{ + post_asap::BinaryOperator, + pre_asap::{AggIntent, ArithmeticOpKind, BinaryOpKind, ColumnRef, CompareOpKind, GroupKeys}, +}; +use query_time::{Aggregation, BinaryOperation, Grouping, QueryTimeOperator as Operation}; + +fn invalid(error: impl std::fmt::Display) -> QueryPlanError { + QueryPlanError::Invalid(error.to_string()) +} +fn grouping(value: &Grouping) -> GroupKeys { + let keys = value.labels.iter().cloned().map(ColumnRef::Named).collect(); + if value.without { + GroupKeys::without(keys) + } else { + GroupKeys::by(keys) + } +} +fn binary(operation: BinaryOperation) -> BinaryOperator { + use ArithmeticOpKind as A; + use BinaryOperation as O; + use CompareOpKind as C; + let kind = match operation { + O::Add => BinaryOpKind::Arithmetic(A::Add), + O::Sub => BinaryOpKind::Arithmetic(A::Sub), + O::Mul => BinaryOpKind::Arithmetic(A::Mul), + O::Div | O::CheckedDiv | O::FiniteDiv => BinaryOpKind::Arithmetic(A::Div), + O::Mod => BinaryOpKind::Arithmetic(A::Mod), + O::Pow => BinaryOpKind::Arithmetic(A::Pow), + O::Equal => BinaryOpKind::Compare(C::Eq), + O::NotEqual => BinaryOpKind::Compare(C::Ne), + O::Less => BinaryOpKind::Compare(C::Lt), + O::LessEqual => BinaryOpKind::Compare(C::Le), + O::Greater => BinaryOpKind::Compare(C::Gt), + O::GreaterEqual => BinaryOpKind::Compare(C::Ge), + }; + BinaryOperator { + kind, + vector_match: None, + checked_relative_division: operation == O::CheckedDiv, + checked_finite_division: operation == O::FiniteDiv, + } +} + +pub fn compile( + entry: &mut QueryPlanEntry, +) -> Result, QueryPlanError> { + if entry.language == QueryLanguage::ClickHouseSql || entry.physical_dag.is_some() { + return Ok(BTreeMap::new()); + } + let mut scalars = BTreeMap::new(); + for id in entry.topological_order()? { + let node = &entry.nodes[&id]; + let inputs = node.inputs().to_vec(); + let scalar_input = |position: usize| { + inputs + .get(position) + .and_then(|id| scalars.get(id)) + .copied() + .unwrap_or(false) + }; + let mut scalar = false; + let compiled = match node { + QueryPlanNode::Scalar { .. } => { + scalar = true; + let QueryPlanNode::Scalar { value } = node else { + unreachable!() + }; + Some(physical::compile_scalar(*value).map_err(invalid)?) + } + QueryPlanNode::Logical { + operator: + Operation::ExactSubquery { query } | Operation::CandidateExactSubquery { query, .. }, + .. + } => { + scalar = promql_parser::parser::parse(query) + .map_err(invalid)? + .value_type() + == promql_parser::parser::value::ValueType::Scalar; + None + } + QueryPlanNode::Logical { operator, .. } => match operator { + Operation::Binary { + operation, + return_bool, + } => { + scalar = scalar_input(0) && scalar_input(1); + Some( + physical::compile_binary( + &binary(*operation), + *return_bool, + scalar_input(0), + scalar_input(1), + ) + .map_err(invalid)?, + ) + } + Operation::UnaryNegate => { + scalar = scalar_input(0); + Some(physical::compile_negate(scalar).map_err(invalid)?) + } + Operation::VectorToScalar => { + scalar = true; + Some(physical::compile_vector_to_scalar().map_err(invalid)?) + } + Operation::Aggregate { + operation, + grouping: groups, + } => { + let intent = match operation { + Aggregation::Sum => AggIntent::Sum { col: None }, + Aggregation::Avg => AggIntent::Avg { col: None }, + Aggregation::Min => AggIntent::Min { col: None }, + Aggregation::Max => AggIntent::Max { col: None }, + Aggregation::Count => AggIntent::Count { + accuracy: planner_types::types::AccuracyTarget::Exact, + }, + }; + Some(physical::compile_aggregate(&intent, &grouping(groups)).map_err(invalid)?) + } + Operation::Sort { + descending, + grouping: groups, + } => Some(physical::compile_sort(*descending, &grouping(groups)).map_err(invalid)?), + Operation::Limit { + n, + offset, + grouping: groups, + } => { + Some(physical::compile_limit(*n, *offset, &grouping(groups)).map_err(invalid)?) + } + Operation::Temporal { operation } => { + use query_time::TemporalOperation as T; + let intent = match operation { + T::Rate => AggIntent::Rate, + T::Increase => AggIntent::Increase, + T::Sum => AggIntent::Sum { col: None }, + T::Avg => AggIntent::Avg { col: None }, + T::Min => AggIntent::Min { col: None }, + T::Max => AggIntent::Max { col: None }, + T::Count => AggIntent::Count { + accuracy: planner_types::types::AccuracyTarget::Exact, + }, + }; + let preserve = entry.language == QueryLanguage::MetricsQl + && matches!(operation, T::Min | T::Max | T::Avg); + Some(physical::compile_temporal(&intent, preserve).map_err(invalid)?) + } + Operation::HistogramQuantile => { + Some(physical::compile_histogram_quantile().map_err(invalid)?) + } + _ => None, + }, + QueryPlanNode::ReduceSum { + grouping: groups, .. + } => Some(match groups { + PhysicalGrouping::Reduce(labels) => physical::compile_aggregate( + &AggIntent::Sum { col: None }, + &GroupKeys::by(labels.iter().cloned().map(ColumnRef::Named).collect()), + ) + .map_err(invalid)?, + PhysicalGrouping::PerEntity => CompiledPhysicalDag::from_operators( + BTreeMap::from([( + 0, + asap_physical_operators::physical_planner::InputContract::bounded( + physical::vector_schema(), + ), + )]), + BTreeMap::from([( + 1, + ( + vec![0], + asap_physical_operators::operators::Operator::project( + physical::vector_schema(), + vec![ + ( + "labels".into(), + asap_physical_operators::expressions::Expression::Column(0), + ), + ( + "value".into(), + asap_physical_operators::expressions::Expression::Column(1), + ), + ], + ) + .map_err(invalid)?, + ), + )]), + vec![1], + ) + .map_err(invalid)?, + }), + QueryPlanNode::Binary { operator, .. } => { + scalar = scalar_input(0) && scalar_input(1); + Some( + physical::compile_binary( + &BinaryOperator { + kind: BinaryOpKind::Arithmetic(operator.clone()), + vector_match: None, + checked_relative_division: false, + checked_finite_division: false, + }, + false, + scalar_input(0), + scalar_input(1), + ) + .map_err(invalid)?, + ) + } + QueryPlanNode::PhysicalFragment { dag, .. } => { + let compiled = CompiledPhysicalDag::decode(dag).map_err(invalid)?; + scalar = compiled + .output_contract(compiled.roots()[0]) + .map_err(invalid)? + .schema + == physical::scalar_schema(); + None + } + _ => None, + }; + scalars.insert(id, scalar); + if let Some(compiled) = compiled { + entry.nodes.insert( + id, + QueryPlanNode::PhysicalFragment { + inputs, + dag: compiled.encode().map_err(invalid)?, + row_input: None, + pruning: None, + }, + ); + } + } + combine(entry) +} + +fn combine( + entry: &mut QueryPlanEntry, +) -> Result, QueryPlanError> { + use asap_physical_operators::physical_planner::InputContract; + use std::collections::BTreeSet; + let supported = |schema: &asap_physical_operators::values::Schema| { + schema == &physical::scalar_schema() + || schema == &physical::vector_schema() + || schema == &physical::matrix_schema() + }; + let mut programs = BTreeMap::new(); + for (&id, node) in &entry.nodes { + if let QueryPlanNode::PhysicalFragment { + dag, + row_input: None, + pruning: None, + .. + } = node + { + let graph = CompiledPhysicalDag::decode(dag).map_err(invalid)?; + if graph.roots().len() == 1 + && graph + .input_contracts() + .all(|(_, input)| supported(&input.schema)) + && supported( + &graph + .output_contract(graph.roots()[0]) + .map_err(invalid)? + .schema, + ) + { + programs.insert(id, graph); + } + } + } + let mut roots = BTreeSet::new(); + if programs.contains_key(&entry.root) { + roots.insert(entry.root); + } + for (id, node) in &entry.nodes { + if !programs.contains_key(id) { + roots.extend( + node.inputs() + .iter() + .filter(|id| programs.contains_key(id)) + .copied(), + ); + } + } + let reachable = |root: QueryNodeId, boundaries: &BTreeSet| { + let mut pending = vec![root]; + let mut seen = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !programs.contains_key(&id) + || (id != root && boundaries.contains(&id)) + || !seen.insert(id) + { + continue; + } + pending.extend(entry.nodes[&id].inputs()); + } + seen + }; + // A producer consumed across an I/O boundary remains one separately scheduled + // physical output, rather than being duplicated into both downstream graphs. + let mut owners = BTreeMap::::new(); + for &root in &roots { + for id in reachable(root, &BTreeSet::new()) { + *owners.entry(id).or_default() += 1; + } + } + roots.extend( + owners + .into_iter() + .filter_map(|(id, count)| (count > 1).then_some(id)), + ); + let mut replacements = BTreeMap::new(); + let mut remap = BTreeMap::new(); + for &root in &roots { + let members = reachable(root, &roots); + let mut sources = BTreeMap::::new(); + let mut fragments = BTreeMap::new(); + for &id in &members { + let graph = &programs[&id]; + let inputs = entry.nodes[&id].inputs(); + for ((_, contract), input) in graph.input_contracts().zip(inputs) { + if !members.contains(input) + && sources + .insert(input.0, contract.clone()) + .is_some_and(|previous| previous.schema != contract.schema) + { + return Err(invalid("shared physical input has inconsistent schemas")); + } + } + fragments.insert( + id.0, + (inputs.iter().map(|id| id.0).collect(), graph.clone()), + ); + if id != root { + remap.insert(id, root); + } + } + let graph = + CompiledPhysicalDag::compose(sources, fragments, vec![root.0]).map_err(invalid)?; + replacements.insert( + root, + QueryPlanNode::PhysicalFragment { + inputs: graph + .input_contracts() + .map(|(id, _)| QueryNodeId(id)) + .collect(), + dag: graph.encode().map_err(invalid)?, + row_input: None, + pruning: None, + }, + ); + } + for id in remap.keys() { + entry.nodes.remove(id); + } + entry.nodes.extend(replacements); + Ok(remap) +} + +#[cfg(test)] +pub(crate) fn operator_parameters(node: &QueryPlanNode, kind: &str) -> Vec { + let QueryPlanNode::PhysicalFragment { dag, .. } = node else { + return vec![]; + }; + CompiledPhysicalDag::decode(dag).unwrap(); + let document: serde_json::Value = serde_json::from_slice(dag).unwrap(); + document["nodes"] + .as_object() + .unwrap() + .values() + .filter_map(|node| { + node.get("Operator")? + .get("operator")? + .get("kind")? + .get(kind) + .cloned() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // A sum over finalized summaries must be priced and installed as computation, + // never reconstructed by the query worker. + #[test] + fn finalized_summary_rollup_is_retained_as_a_physical_graph() { + for grouping in [ + PhysicalGrouping::PerEntity, + PhysicalGrouping::Reduce(vec!["service".into()]), + ] { + let mut entry = QueryPlanEntry { + physical_dag: None, + language: QueryLanguage::PromQl, + query_id: "rollup".into(), + canonical_query: "sum by (service) (foo)".into(), + fixed_evaluation: None, + root: QueryNodeId(1), + nodes: BTreeMap::from([ + ( + QueryNodeId(0), + QueryPlanNode::Logical { + operator: Operation::ExactSubquery { + query: "foo".into(), + }, + inputs: vec![], + }, + ), + ( + QueryNodeId(1), + QueryPlanNode::ReduceSum { + input: QueryNodeId(0), + grouping, + }, + ), + ]), + instant: InstantExecution { + lookback_ms: 1000, + full_history: false, + cumulative_readout: true, + }, + fallback: FallbackPolicy::Reject, + }; + compile(&mut entry).unwrap(); + let QueryPlanNode::PhysicalFragment { dag, inputs, .. } = &entry.nodes[&entry.root] + else { + panic!("rollup was left for Backend execution"); + }; + let compiled = CompiledPhysicalDag::decode(dag).unwrap(); + assert_eq!(inputs, &[QueryNodeId(0)]); + assert_eq!( + compiled + .output_contract(compiled.roots()[0]) + .unwrap() + .schema, + physical::vector_schema() + ); + } + } +} diff --git a/control_plane/src/query_plan/query_time.rs b/control_plane/src/query_plan/query_time.rs index 7f1afc89e..705871aad 100644 --- a/control_plane/src/query_plan/query_time.rs +++ b/control_plane/src/query_plan/query_time.rs @@ -302,6 +302,7 @@ pub fn compile_logical( }; let root = lower.lower(&expr)?; let entry = QueryPlanEntry { + physical_dag: None, language: super::QueryLanguage::PromQl, query_id, canonical_query, @@ -517,7 +518,7 @@ pub(super) fn query_time_nodes( } } Err(invalid( - "Planner fragment does not match any original query subtree", + "Planner logical fragment does not match any original query subtree", )) } @@ -546,7 +547,7 @@ pub(super) fn binary_operator( }); } if operator.vector_match.is_some() { - return Err(invalid("explicit fragment vector matching unsupported")); + return Err(invalid("explicit logical vector matching unsupported")); } let operation = match operator.kind.to_string().as_str() { "+" => BinaryOperation::Add, @@ -596,7 +597,7 @@ pub(super) fn selected_native_expression( ) -> Result { if !selected.guarantee.as_ref().is_some_and(|g| g.is_exact()) { return Err(invalid( - "native fragment substitution requires an exact selected value", + "native subtree substitution requires an exact selected value", )); } let selected = match &selected.expr { @@ -870,7 +871,11 @@ mod planner_workload_tests { let plan = asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) .unwrap(); - assert_eq!(plan.operator_name(plan.roots()[0]), Some("Limit")); + assert!( + plan.operator_name(plan.roots()[0]) == Some("Limit") + || super::super::physical_values::operator_parameters(node, "Limit").len() + == 1 + ); } QueryPlanNode::Logical { operator: QueryTimeOperator::Limit { .. }, 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..e8cc2de57 --- /dev/null +++ b/control_plane/tests/native_rate_topk.rs @@ -0,0 +1,326 @@ +//! 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:#?}" + ); +} + +// Deployment must install the exact retained Planner graphs, including roots, +// typed inputs and materialization frontiers, rather than compiling replacements. +#[test] +fn selected_native_graphs_survive_deployment_unchanged() { + use asap_physical_operators::physical_planner::PhysicalCandidate; + let (request, environment) = fixture(true).into_physical_compilation_request().unwrap(); + let mut checked = 0; + let mut checked_precompute = 0; + for candidate in enumerate_exact_and_materialized_candidates(request).unwrap() { + let expected = candidate + .queries + .iter() + .filter_map(|query| { + query.physical_candidate.as_ref().map(|bytes| { + ( + query.query_id.clone(), + PhysicalCandidate::decode(bytes).unwrap(), + ) + }) + }) + .collect::>(); + let Ok(plan) = DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) else { + continue; + }; + for entry in plan.query_plan.entries.values() { + let Some(expected) = expected.get(&entry.query_id) else { + continue; + }; + let actual = entry + .physical_dag + .as_ref() + .expect("retained candidate must be installed"); + let encoded: Value = serde_json::from_slice(&expected.query.encode().unwrap()).unwrap(); + assert_eq!(*actual, encoded); + if let Some(precompute) = &expected.precompute { + let encoded: Value = serde_json::from_slice(&precompute.encode().unwrap()).unwrap(); + assert!(plan.precompute_plan.executable_dags[&entry.query_id] + .native_programs + .values() + .any(|actual| actual == &encoded)); + checked_precompute += 1; + } + checked += 1; + } + } + assert!(checked > 0 && checked_precompute > 0); +} 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/derived_input.rs b/crates/asap_types/src/derived_input.rs index e7aba6906..ffd1edea0 100644 --- a/crates/asap_types/src/derived_input.rs +++ b/crates/asap_types/src/derived_input.rs @@ -52,7 +52,7 @@ impl DerivedInputIdentity { .filter_map(|node| { matches!( &node.payload, - planner_types::post_asap::ExecutableOperatorPayload::Fallback { + planner_types::post_asap::PostAsapOperatorPayload::Fallback { expression: planner_types::pre_asap::QueryExpr::Literal(_), } ) @@ -355,13 +355,13 @@ mod tests { #[test] fn literal_leaves_are_hashed_without_inventing_materialization_references() { use planner_types::{ - post_asap::ExecutableOperatorPayload, + post_asap::PostAsapOperatorPayload, pre_asap::{QueryExpr, ScalarValue}, }; let mut dag = program(1, 2); let mut literal = dag.nodes[0].clone(); literal.id = PostAsapNodeId(3); - literal.payload = serde_json::to_value(ExecutableOperatorPayload::Fallback { + literal.payload = serde_json::to_value(PostAsapOperatorPayload::Fallback { expression: QueryExpr::Literal(ScalarValue::Int64(2)), }) .unwrap(); @@ -373,7 +373,7 @@ mod tests { let frontiers = BTreeMap::from([(PostAsapNodeId(1), source)]); let first = DerivedInputIdentity::from_dag(&dag, dag.root, &frontiers).unwrap(); assert_eq!(first.inputs, BTreeSet::from([source])); - dag.nodes[2].payload = serde_json::to_value(ExecutableOperatorPayload::Fallback { + dag.nodes[2].payload = serde_json::to_value(PostAsapOperatorPayload::Fallback { expression: QueryExpr::Literal(ScalarValue::Int64(3)), }) .unwrap(); diff --git a/crates/asap_types/src/executable_plan.rs b/crates/asap_types/src/executable_plan.rs index aff1ced0d..60220341c 100644 --- a/crates/asap_types/src/executable_plan.rs +++ b/crates/asap_types/src/executable_plan.rs @@ -10,8 +10,8 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::sds::StoredOutputId; use planner_types::post_asap::{ - EdgeRole, ExecutableDag, ExecutableDagEdge, ExecutableDagNode, ExecutionDataState, - ExecutionTiming, GroupingEdgeCompatibility, PostAsapNodeId, WindowEdgeCompatibility, + EdgeRole, ExecutionDataState, ExecutionTiming, GroupingEdgeCompatibility, PostAsapDag, + PostAsapDagEdge, PostAsapDagNode, PostAsapNodeId, WindowEdgeCompatibility, }; use serde::{Deserialize, Serialize}; @@ -62,7 +62,7 @@ pub struct OwnedPostAsapEdge { } impl OwnedPostAsapDag { - pub fn from_executable(query_id: String, dag: &ExecutableDag) -> Result { + pub fn from_post_asap_dag(query_id: String, dag: &PostAsapDag) -> Result { let nodes = dag .nodes .iter() @@ -107,7 +107,7 @@ impl OwnedPostAsapDag { }) } - pub fn decode(&self) -> Result { + pub fn decode(&self) -> Result { let node_ids = self .nodes .iter() @@ -134,9 +134,9 @@ impl OwnedPostAsapDag { .nodes .iter() .map(|node| { - let payload: planner_types::post_asap::ExecutableOperatorPayload = + let payload: planner_types::post_asap::PostAsapOperatorPayload = serde_json::from_value(node.payload.clone()).map_err(|e| e.to_string())?; - Ok(ExecutableDagNode { + Ok(PostAsapDagNode { id: node.id, payload, output_state: node.output_state, @@ -155,7 +155,7 @@ impl OwnedPostAsapDag { .edges .iter() .map(|edge| { - Ok(ExecutableDagEdge { + Ok(PostAsapDagEdge { producer: edge.producer, consumer: edge.consumer, role: edge.role, @@ -167,7 +167,7 @@ impl OwnedPostAsapDag { }) }) .collect::, String>>()?; - Ok(ExecutableDag { + Ok(PostAsapDag { nodes, edges, root: self.root, @@ -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,87 @@ impl InstalledPostAsapDag { if self.document.schema_version != PRECOMPUTE_DAG_SCHEMA_VERSION { return Err("unsupported maintenance DAG document version".into()); } - self.binding.validate_precompute(&self.document.decode()?) + self.binding.validate_precompute(&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().contains(&u64::from(sink.0)) { + return Err("native precompute program omits its installed output".into()); + } + for root in program.roots() { + let root = + PostAsapNodeId(u32::try_from(*root).map_err(|_| "physical output id overflow")?); + if !self.binding.precompute_sinks.contains(&root) + || !matches!( + self.binding.node(root), + Some(BackendNodeBinding::Materialization { .. }) + ) + || self.native_programs.get(&root) != Some(value) + { + return Err( + "native precompute program differs across its installed outputs".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 program.roots().contains(&u64::from(id.0)) + || !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 + && asap_physical_operators::physical_planner::precompute::source_schema( + &n.output_schema, + ) + .map_or(true, |schema| schema != contract.schema) + }) + { + return Err( + "native maintenance source differs from installed state boundary".into(), + ); + } + } + if program.input_contracts().count() == 0 { + return Err("native precompute program has no bound inputs".into()); + } + for root in program.roots() { + let output = program.output_contract(*root).map_err(|e| e.to_string())?; + if dag + .nodes + .iter() + .find(|n| u64::from(n.id.0) == *root) + .is_none_or(|n| { + n.output_schema != *output.schema + && asap_physical_operators::physical_planner::precompute::source_schema( + &n.output_schema, + ) + .map_or(true, |schema| schema != output.schema) + }) + { + return Err("native precompute output differs from semantic schema".into()); + } + } + Ok(Some(program)) } /// Project the selected semantic DAG onto the maintenance ancestors of its @@ -268,7 +352,7 @@ impl BackendExecutableBinding { self.nodes.get(&id) } - pub fn validate_precompute(&self, dag: &ExecutableDag) -> Result<(), String> { + pub fn validate_precompute(&self, dag: &PostAsapDag) -> Result<(), String> { let ids = dag .nodes .iter() @@ -317,7 +401,7 @@ impl BackendExecutableBinding { Ok(()) } - pub fn validate(&self, dag: &ExecutableDag) -> Result<(), String> { + pub fn validate(&self, dag: &PostAsapDag) -> Result<(), String> { let semantic = dag .nodes .iter() @@ -376,9 +460,96 @@ mod tests { assert_eq!(serde_json::to_value(QueryNodeId(9)).unwrap(), 9); } + // Recovery must retain every output of a shared producer graph and reject + // a missing or replaced output binding, rather than discard another root. + #[test] + fn shared_precompute_graph_recovers_all_output_bindings() { + use planner_types::post_asap::*; + let schema = SummarySchema { + fields: vec![SummaryField { + name: "state".into(), + dtype: SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), + nullable: false, + }], + time_index: None, + }; + let dag = PostAsapDag { + nodes: (1..=5) + .map(|id| PostAsapDagNode { + id: PostAsapNodeId(id), + payload: PostAsapOperatorPayload::SummaryMerge, + output_state: ExecutionDataState::INGESTION_SUMMARY, + output_schema: schema.clone(), + guarantee: None, + }) + .collect(), + edges: [(1, 2), (2, 3), (2, 4), (3, 5), (4, 5)] + .into_iter() + .map(|(a, b)| PostAsapDagEdge { + producer: PostAsapNodeId(a), + consumer: PostAsapNodeId(b), + role: EdgeRole::Input, + intermediate_schema: schema.clone(), + data_state: ExecutionDataState::INGESTION_SUMMARY, + grouping: GroupingEdgeCompatibility::Identical, + window: WindowEdgeCompatibility::NotApplicable, + }) + .collect(), + root: PostAsapNodeId(5), + }; + let program = + asap_physical_operators::physical_planner::precompute::compile(&dag, &[1], &[3, 4]) + .unwrap(); + let encoded: serde_json::Value = + serde_json::from_slice(&program.encode().unwrap()).unwrap(); + let mut document = OwnedPostAsapDag::from_post_asap_dag("shared".into(), &dag).unwrap(); + document.schema_version = PRECOMPUTE_DAG_SCHEMA_VERSION; + let mut installed = InstalledPostAsapDag { + document, + native_programs: BTreeMap::from([ + (PostAsapNodeId(3), encoded.clone()), + (PostAsapNodeId(4), encoded), + ]), + binding: BackendExecutableBinding { + nodes: (1..=5) + .map(|id| { + ( + PostAsapNodeId(id), + if id == 2 || id == 5 { + BackendNodeBinding::MaintenanceInput + } else { + BackendNodeBinding::Materialization { + stored_output: crate::policy_fingerprint::PolicyFingerprint( + id as u64, + ) + .into(), + } + }, + ) + }) + .collect(), + precompute_sinks: vec![PostAsapNodeId(3), PostAsapNodeId(4)], + query_sink: PostAsapNodeId(3), + query_plan_sink: QueryNodeId(3), + }, + }; + installed.validate().unwrap(); + assert_eq!( + installed + .native_program(PostAsapNodeId(4)) + .unwrap() + .unwrap() + .roots(), + &[3, 4] + ); + installed.native_programs.remove(&PostAsapNodeId(4)); + assert!(installed.validate().is_err()); + } + #[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 05227b679..e9cb9f253 100644 --- a/crates/asap_types/src/precompute_plan.rs +++ b/crates/asap_types/src/precompute_plan.rs @@ -14,16 +14,16 @@ use thiserror::Error; /// and are not admitted by this bounded capability check. pub fn validate_maintenance_reduction( config: &crate::PrecomputeMaterialization, - node: &planner_types::post_asap::ExecutableDagNode, + node: &planner_types::post_asap::PostAsapDagNode, ) -> Result<(), String> { use crate::sds::PopulationPartitioning; - use planner_types::{post_asap::ExecutableOperatorPayload, pre_asap::Reduction}; + use planner_types::{post_asap::PostAsapOperatorPayload, pre_asap::Reduction}; match &node.payload { - ExecutableOperatorPayload::SummaryAgg { + PostAsapOperatorPayload::SummaryAgg { reduction: Reduction::PerEntity, .. } if config.partitioning == Some(PopulationPartitioning::PerEntity) => Ok(()), - ExecutableOperatorPayload::SummaryAgg { + PostAsapOperatorPayload::SummaryAgg { reduction: Reduction::Reduce(keys), .. } if keys.is_empty() @@ -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, @@ -520,6 +522,12 @@ impl PrecomputePlan { .chain(input.inputs.iter().copied()) }) .collect(); + // Decode each DAG at most once; errors still surface only where used. + let dags = self + .executable_dags + .values() + .map(|installed| (installed, std::cell::OnceCell::new())) + .collect::>(); for config in &self.materializations { if !config.population_key_encoding.is_legacy() && (self.ingest.protocol != IngestProtocol::PrometheusRemoteWriteV1 @@ -569,21 +577,12 @@ 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 - .document - .decode() - .map_err(PrecomputePlanError::CatalogContract)?; + for (installed, dag) in &dags { + let dag = dag + .get_or_init(|| installed.document.decode()) + .as_ref() + .map_err(|error| PrecomputePlanError::CatalogContract(error.clone()))?; for sink in &installed.binding.precompute_sinks { if !matches!(installed.binding.node(*sink), Some(crate::executable_plan::BackendNodeBinding::Materialization { stored_output }) @@ -596,8 +595,62 @@ impl PrecomputePlan { .iter() .find(|node| node.id == *sink) .ok_or_else(invalid)?; - validate_maintenance_reduction(config, target_node) - .map_err(PrecomputePlanError::CatalogContract)?; + let native = installed + .native_program(*sink) + .map_err(PrecomputePlanError::CatalogContract)? + .ok_or_else(invalid)?; + for root in native.roots() { + let root = planner_types::post_asap::PostAsapNodeId( + u32::try_from(*root).map_err(|_| invalid())?, + ); + let Some(crate::executable_plan::BackendNodeBinding::Materialization { + stored_output, + }) = installed.binding.node(root) + else { + return Err(invalid()); + }; + let other = self + .materializations + .iter() + .find(|c| c.policy_fingerprint() == stored_output.fingerprint()) + .ok_or_else(invalid)?; + if other.stored_window_ms() != config.stored_window_ms() + || other.slide_interval != config.slide_interval + || other.pane_origin_ms != config.pane_origin_ms + || other.population_key_encoding != config.population_key_encoding + || other.derived_input.as_ref().map(|d| &d.inputs) + != Some(&derived.inputs) + || (native.roots().len() > 1 + && config.population_key_encoding.is_legacy()) + { + return Err(invalid()); + } + } + let native = native.output_contract(u64::from(sink.0)) + .map_err(|e| PrecomputePlanError::CatalogContract(e.to_string())) + .map(|contract| !asap_physical_operators::physical_planner::precompute::is_population_schema(&contract.schema))? + .then_some(native); + 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() @@ -645,7 +698,7 @@ impl PrecomputePlan { .filter(|edge| edge.consumer == id) .collect(); use planner_types::post_asap::{ - ExecutableOperatorPayload as Payload, ValueOperation, + PostAsapOperatorPayload as Payload, ValueOperation, }; if node.output_state != planner_types::post_asap::ExecutionDataState::INGESTION_ROWS @@ -726,7 +779,7 @@ impl PrecomputePlan { crate::AggregationType::HLL | crate::AggregationType::UnivMon ) { - if let planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { + if let planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { input, .. } = &node.payload @@ -749,7 +802,7 @@ impl PrecomputePlan { } } if let Some(partitioning) = config.partitioning { - if let planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { + if let planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { reduction, .. } = &node.payload @@ -877,7 +930,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(), @@ -925,8 +978,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, @@ -954,7 +1021,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)] @@ -972,6 +1057,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 { @@ -1064,9 +1179,9 @@ mod source_window_cohort_tests { use planner_types::pre_asap::Reduction; let mut config = full_window(); config.partitioning = Some(crate::sds::PopulationPartitioning::Grouped); - let mut node = ExecutableDagNode { + let mut node = PostAsapDagNode { id: PostAsapNodeId(1), - payload: ExecutableOperatorPayload::SummaryAgg { + payload: PostAsapOperatorPayload::SummaryAgg { family: SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), input: SummaryUpdate { item: None, @@ -1086,18 +1201,18 @@ mod source_window_cohort_tests { assert!(validate_maintenance_reduction(&config, &node).is_ok()); config.partitioning = Some(crate::sds::PopulationPartitioning::PerEntity); assert!(validate_maintenance_reduction(&config, &node).is_err()); - if let ExecutableOperatorPayload::SummaryAgg { reduction, .. } = &mut node.payload { + if let PostAsapOperatorPayload::SummaryAgg { reduction, .. } = &mut node.payload { *reduction = Reduction::PerEntity; } assert!(validate_maintenance_reduction(&config, &node).is_ok()); config.partitioning = Some(crate::sds::PopulationPartitioning::Grouped); - if let ExecutableOperatorPayload::SummaryAgg { reduction, .. } = &mut node.payload { + if let PostAsapOperatorPayload::SummaryAgg { reduction, .. } = &mut node.payload { *reduction = Reduction::by(vec![0]); } assert!(validate_maintenance_reduction(&config, &node).is_err()); config.partitioning = None; assert!(validate_maintenance_reduction(&config, &node).is_err()); - node.payload = ExecutableOperatorPayload::SummaryMerge; + node.payload = PostAsapOperatorPayload::SummaryMerge; assert!(validate_maintenance_reduction(&config, &node).is_err()); } diff --git a/crates/asap_types/src/precompute_plan/catalog.rs b/crates/asap_types/src/precompute_plan/catalog.rs index bad95e7e5..80bfbcaa3 100644 --- a/crates/asap_types/src/precompute_plan/catalog.rs +++ b/crates/asap_types/src/precompute_plan/catalog.rs @@ -55,6 +55,12 @@ impl PrecomputePlan { "stored output semantics differ from installed writer computation", )); } + // Decode each DAG at most once; errors still surface only where used. + let dags = self + .executable_dags + .values() + .map(|installed| (installed, std::cell::OnceCell::new())) + .collect::>(); for config in &self.materializations { if config.derived_input.is_some() && config.semantic_fragment.is_none() { return Err(invalid( @@ -63,16 +69,19 @@ impl PrecomputePlan { } if let Some(expected) = &config.semantic_fragment { let mut found = false; - for installed in self.executable_dags.values() { - let dag = installed.document.decode().map_err(invalid)?; + for (installed, dag) in &dags { + let dag = dag + .get_or_init(|| installed.document.decode()) + .as_ref() + .map_err(|error| invalid(error.clone()))?; for (id, binding) in &installed.binding.nodes { if matches!(binding, crate::executable_plan::BackendNodeBinding::Materialization { stored_output } if stored_output.fingerprint() == config.policy_fingerprint()) { found = true; let actual = match &self.ingest.dataset_identity { - Some(dataset) => crate::semantic_fragment::SemanticFragment::from_stored_output_in_dataset(&dag, *id, dataset.clone()), - None => crate::semantic_fragment::SemanticFragment::from_stored_output(&dag, *id), + Some(dataset) => crate::semantic_fragment::SemanticFragment::from_stored_output_in_dataset(dag, *id, dataset.clone()), + None => crate::semantic_fragment::SemanticFragment::from_stored_output(dag, *id), }.map_err(invalid)?; if &actual != expected { return Err(invalid( @@ -206,7 +215,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 8cea4e1a5..35c4349eb 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 query_time; use std::collections::{BTreeMap, BTreeSet}; @@ -162,6 +163,17 @@ impl QueryPlan { } } for node in entry.nodes.values() { + if matches!( + node, + QueryPlanNode::Scalar { .. } + | QueryPlanNode::Binary { .. } + | QueryPlanNode::ReduceSum { .. } + ) { + return Err(QueryPlanError::Invalid( + "installed value computation requires a retained Planner physical graph" + .into(), + )); + } let QueryPlanNode::ExactReadout { input, readout } = node else { continue; }; @@ -295,6 +307,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, @@ -422,8 +437,52 @@ 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.physical_dag.is_some() { + return Err(QueryPlanError::Invalid( + "physical program has no deployment input bindings".into(), + )); + } for (id, node) in &self.nodes { - validate_native_relation(*id, node)?; + if let QueryPlanNode::PhysicalRelation { inputs, dag } = node { + if self.language != QueryLanguage::ClickHouseSql { + return Err(QueryPlanError::Invalid( + "physical relation requires a SQL result binding".into(), + )); + } + let compiled = + asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + for ((_, contract), input) in compiled.input_contracts().zip(inputs) { + if let Some(QueryPlanNode::ExternalExact { request, .. }) = + self.nodes.get(input) + { + let ExternalExactOutput::Relation { schema } = &request.output else { + return Err(QueryPlanError::Invalid( + "physical relation requires a relational external input".into(), + )); + }; + let schema: planner_types::post_asap::SummarySchema = + serde_json::from_value(schema.clone()) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + if &schema != contract.schema.as_ref() { + return Err(QueryPlanError::Invalid( + "physical relation input differs from its bound source schema" + .into(), + )); + } + } + } + if compiled.roots().len() != 1 || compiled.input_contracts().count() != inputs.len() + { + return Err(QueryPlanError::Invalid( + "physical relation boundary arity mismatch".into(), + )); + } + } if let QueryPlanNode::PhysicalFragment { inputs, dag, @@ -434,39 +493,106 @@ impl QueryPlanEntry { let compiled = asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; - for (_, contract) in compiled.input_contracts() { - let mut samples = 0; - for (index, field) in contract.schema.fields.iter().enumerate() { - use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; - match &field.dtype { - SummaryFamilyType::Plain(DataType::Float64 | DataType::Int64) => { - samples += 1 - } - SummaryFamilyType::Plain(DataType::Utf8) => {} - SummaryFamilyType::Plain(DataType::Timestamp) - if contract.schema.time_index == Some(index) => {} - _ => { - return Err(QueryPlanError::Invalid(format!( - "PromQL input binding cannot supply field {}", - field.name - ))) + use asap_physical_operators::physical_planner::promql_values; + let value_schema = |schema: &asap_physical_operators::values::Schema| { + schema == &promql_values::scalar_schema() + || schema == &promql_values::vector_schema() + || schema == &promql_values::matrix_schema() + }; + let canonical_values = compiled + .input_contracts() + .all(|(_, input)| value_schema(&input.schema)) + && compiled.roots().len() == 1 + && value_schema( + &compiled + .output_contract(compiled.roots()[0]) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))? + .schema, + ); + if canonical_values { + if row_input.is_some() || pruning.is_some() { + return Err(QueryPlanError::Invalid("complete label-map computation cannot carry an external row-identity adapter".into())); + } + } else { + for (_, contract) in compiled.input_contracts() { + let mut samples = 0; + for (index, field) in contract.schema.fields.iter().enumerate() { + use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; + match &field.dtype { + SummaryFamilyType::Plain(DataType::Float64 | DataType::Int64) => { + samples += 1 + } + SummaryFamilyType::Plain(DataType::Utf8) => {} + SummaryFamilyType::Plain(DataType::Timestamp) + if contract.schema.time_index == Some(index) => {} + _ => { + return Err(QueryPlanError::Invalid(format!( + "PromQL input binding cannot supply field {}", + field.name + ))) + } } } + if samples > 1 { + return Err(QueryPlanError::Invalid( + "PromQL vector input has only one numeric sample per row".into(), + )); + } } - if samples > 1 { + if compiled.roots().len() != 1 { return Err(QueryPlanError::Invalid( - "PromQL vector input has only one numeric sample per row".into(), + "physical vector requires one root".into(), )); } - } - let source = compiled.input_contracts().nth(*row_input).map(|(id, _)| id); - if compiled.roots().len() != 1 - || source.is_none() - || compiled.row_source(compiled.roots()[0]) != source - { - return Err(QueryPlanError::Invalid( - "physical vector output must preserve its bound input rows".into(), - )); + if let Some(row_input) = row_input { + let source = compiled.input_contracts().nth(*row_input).map(|(id, _)| id); + if source.is_none() || compiled.row_source(compiled.roots()[0]) != source { + return Err(QueryPlanError::Invalid( + "physical vector output must preserve its bound input rows".into(), + )); + } + } else { + if compiled.row_source(compiled.roots()[0]).is_some() { + return Err(QueryPlanError::Invalid( + "row-preserving physical output requires its input identity binding" + .into(), + )); + } + let output = compiled + .output_contract(compiled.roots()[0]) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + let numeric = output + .schema + .fields + .iter() + .filter(|field| { + matches!( + field.dtype, + planner_types::post_asap::SummaryFamilyType::Plain( + planner_types::pre_asap::DataType::Float64 + | planner_types::pre_asap::DataType::Int64 + ) + ) + }) + .count(); + if numeric != 1 + || output.schema.fields.iter().any(|field| { + !matches!( + field.dtype, + planner_types::post_asap::SummaryFamilyType::Plain( + planner_types::pre_asap::DataType::Float64 + | planner_types::pre_asap::DataType::Int64 + | planner_types::pre_asap::DataType::Utf8 + | planner_types::pre_asap::DataType::Timestamp + ) + ) + }) + { + return Err(QueryPlanError::Invalid( + "physical output cannot bind to a PromQL vector".into(), + )); + } + } } if let Some(pruning) = pruning { if matches!(&pruning.completeness, CandidateCompleteness::Certified { guarantee } @@ -478,23 +604,42 @@ impl QueryPlanEntry { } let contracts = compiled.input_contracts().collect::>(); let left = contracts - .get(*row_input) + .get(row_input.ok_or_else(|| { + QueryPlanError::Invalid("pruning requires preserved input rows".into()) + })?) .ok_or_else(|| QueryPlanError::Invalid("invalid row input".into()))? .1; let right = contracts .get(pruning.candidate_input) .ok_or_else(|| QueryPlanError::Invalid("invalid candidate input".into()))? .1; - asap_physical_operators::operators::Operator::semi_join( - left.schema.clone(), - right.schema.clone(), - pruning.keys.clone(), - ) - .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + if matches!( + pruning.completeness, + CandidateCompleteness::Certified { .. } + ) && compiled.certified_pruning_keys(compiled.roots()[0]) + != Some(pruning.keys.as_slice()) + { + return Err(QueryPlanError::Invalid( + "certified pruning binding requires native coverage validation".into(), + )); + } + for &(l, r) in &pruning.keys { + if left + .schema + .fields + .get(l) + .zip(right.schema.fields.get(r)) + .is_none_or(|(l, r)| l.dtype != r.dtype) + { + return Err(QueryPlanError::Invalid( + "invalid pruning key types".into(), + )); + } + } } if compiled.input_contracts().count() != inputs.len() || compiled.roots().len() != 1 - || *row_input >= inputs.len() + || row_input.is_some_and(|index| index >= inputs.len()) { return Err(QueryPlanError::Invalid( "physical input/root binding mismatch".into(), @@ -527,30 +672,7 @@ impl QueryPlanEntry { )); } } - if let QueryPlanNode::RelationalJoin { - pruning: Some(completeness), - join_kind, - .. - } = node - { - if *join_kind != planner_types::pre_asap::JoinKind::Semi { - return Err(QueryPlanError::Invalid( - "pruning evidence requires a semi-join".into(), - )); - } - if matches!( - completeness, - CandidateCompleteness::Certified { guarantee } - if guarantee.metric - != planner_types::post_asap::ErrorMetric::TopKMembership - || guarantee.bound.evaluate().is_none() - || guarantee.failure_probability.evaluate().is_none() - ) { - return Err(QueryPlanError::Invalid( - "invalid semi-join pruning certificate".into(), - )); - } - } + for input in node.inputs() { if !self.nodes.contains_key(input) { return Err(QueryPlanError::Invalid(format!( @@ -708,31 +830,25 @@ pub struct PruningInputContract { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] pub enum QueryPlanNode { + /// Typed deployment inputs for the Planner-provided vector computation. + Physical { + inputs: Vec, + source_nodes: Vec, + max_bytes: u64, + }, + /// Complete Planner-compiled relation computation; inputs follow its typed slots. + PhysicalRelation { + inputs: Vec, + dag: Vec, + }, /// Planner-compiled computation. Input order follows the physical input contracts. PhysicalFragment { inputs: Vec, dag: Vec, - row_input: usize, + row_input: Option, pruning: Option, }, - RelationalJoin { - inputs: [QueryNodeId; 2], - join_kind: planner_types::pre_asap::JoinKind, - pruning: Option, - pred: serde_json::Value, - left_schema: planner_types::post_asap::SummarySchema, - right_schema: planner_types::post_asap::SummarySchema, - output_schema: planner_types::post_asap::SummarySchema, - }, - Relational { - input: QueryNodeId, - /// Serialized planner-owned operation. Keeping the wire form here makes - /// the published catalog Send + Sync even though the planner AST uses Rc. - operation: serde_json::Value, - input_schema: planner_types::post_asap::SummarySchema, - output_schema: planner_types::post_asap::SummarySchema, - }, Logical { operator: query_time::QueryTimeOperator, inputs: Vec, @@ -779,12 +895,13 @@ impl QueryPlanNode { Self::Scalar { .. } | Self::ReadMaterialization { .. } | Self::ExactFallback { .. } => { &[] } - Self::Binary { inputs, .. } | Self::RelationalJoin { inputs, .. } => inputs, + Self::Binary { inputs, .. } => inputs, Self::ReduceSum { input, .. } - | Self::Relational { input, .. } | Self::SummaryEstimate { input, .. } | Self::ExactReadout { input, .. } => std::slice::from_ref(input), - Self::PhysicalFragment { inputs, .. } + Self::Physical { inputs, .. } + | Self::PhysicalRelation { inputs, .. } + | Self::PhysicalFragment { inputs, .. } | Self::SummaryMerge { inputs } | Self::Logical { inputs, .. } | Self::ExternalExact { inputs, .. } => inputs, @@ -894,98 +1011,134 @@ mod contract_tests { } } -/// Bind portable relation semantics before an installed plan can access its sources. -fn validate_native_relation(id: QueryNodeId, node: &QueryPlanNode) -> Result<(), QueryPlanError> { - use planner_types::post_asap::{ - ExecutableDagNode, ExecutableOperatorPayload as Payload, ExecutionDataState, PostAsapNodeId, - }; - use std::sync::Arc; - let invalid = |error: String| QueryPlanError::Invalid(format!("query node {}: {error}", id.0)); - let (payload, inputs, output) = match node { - QueryPlanNode::Relational { - operation, - input_schema, - output_schema, - .. - } => ( - Payload::Value { - operation: serde_json::from_value(operation.clone()) - .map_err(|e| invalid(e.to_string()))?, +#[cfg(test)] +mod retired_plan_tests { + // Recovery cannot reactivate the removed request-time scalar compiler. + #[test] + fn catalog_rejects_uncompiled_value_computation() { + use super::*; + let catalog = + crate::summary_catalog::SummaryCatalog::from_materializations(1, 1, &[]).unwrap(); + let entry = QueryPlanEntry { + physical_dag: None, + language: QueryLanguage::PromQl, + query_id: "scalar".into(), + canonical_query: "1".into(), + fixed_evaluation: None, + root: QueryNodeId(0), + nodes: BTreeMap::from([(QueryNodeId(0), QueryPlanNode::Scalar { value: 1. })]), + instant: InstantExecution { + lookback_ms: 0, + full_history: false, + cumulative_readout: false, }, - vec![Arc::new(input_schema.clone())], - output_schema, - ), - QueryPlanNode::RelationalJoin { - join_kind, - pred, - left_schema, - right_schema, - output_schema, - pruning, - .. - } => ( - Payload::RelationalJoin { - join_kind: join_kind.clone(), - pred: serde_json::from_value(pred.clone()).map_err(|e| invalid(e.to_string()))?, - pruning: serde_json::from_value( - serde_json::to_value(pruning).map_err(|e| invalid(e.to_string()))?, - ) - .map_err(|e| invalid(e.to_string()))?, + fallback: FallbackPolicy::Reject, + }; + let mut plan = QueryPlan { + plan_id: 1, + plan_version: 1, + clickhouse_context: None, + selected_dags: BTreeMap::new(), + entries: BTreeMap::from([("1".into(), entry)]), + }; + assert!(plan + .validate_against_catalog(&catalog) + .unwrap_err() + .to_string() + .contains("retained Planner physical graph")); + plan.entries.get_mut("1").unwrap().nodes.insert( + QueryNodeId(0), + QueryPlanNode::PhysicalFragment { + inputs: vec![], + dag: asap_physical_operators::physical_planner::promql_values::compile_scalar(1.) + .unwrap() + .encode() + .unwrap(), + row_input: None, + pruning: None, }, - vec![ - Arc::new(left_schema.clone()), - Arc::new(right_schema.clone()), - ], - output_schema, - ), - _ => return Ok(()), - }; - let node = ExecutableDagNode { - id: PostAsapNodeId(0), - payload, - output_state: ExecutionDataState::QUERY_ROWS, - output_schema: output.clone(), - guarantee: None, - }; - asap_physical_operators::physical_planner::compile_node(&node, &inputs) - .map_err(|e| invalid(e.to_string()))?; - Ok(()) -} + ); + plan.validate_against_catalog(&catalog).unwrap(); + } -#[cfg(test)] -mod native_binding_tests { - use super::*; - use planner_types::{ - post_asap::{SummaryFamilyType, SummaryField, SummarySchema, ValueOperation}, - pre_asap::{DataType, Predicate, QueryExpr}, - }; - - // Unsupported expressions fail installation without evaluating any source. + // Row-preserving operators cannot opt out of the original vector identity. #[test] - fn rejects_unimplemented_relation_predicate_before_execution() { - let schema = SummarySchema { + fn row_preserving_graph_requires_its_identity_binding() { + use super::*; + use asap_physical_operators::{ + operators::Operator, + physical_planner::{CompiledPhysicalDag, InputContract}, + }; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::DataType, + }; + let schema = std::sync::Arc::new(SummarySchema { fields: vec![SummaryField { name: "value".into(), dtype: SummaryFamilyType::Plain(DataType::Float64), nullable: false, }], time_index: None, - }; - let node = QueryPlanNode::Relational { - input: QueryNodeId(0), - operation: serde_json::to_value(ValueOperation::Filter { - pred: Predicate( - QueryExpr::FunctionCall { - name: "unimplemented_predicate".into(), - args: vec![], - } - .into(), + }); + let program = CompiledPhysicalDag::from_operators( + [(0, InputContract::bounded(schema.clone()))].into(), + [(1, (vec![0], Operator::limit(schema, 1, 0, vec![]).unwrap()))].into(), + vec![1], + ) + .unwrap(); + let mut entry = QueryPlanEntry { + language: QueryLanguage::PromQl, + query_id: "identity".into(), + canonical_query: "m".into(), + fixed_evaluation: None, + physical_dag: None, + root: QueryNodeId(1), + nodes: [ + ( + QueryNodeId(0), + QueryPlanNode::ExactFallback { + reason: "bound vector".into(), + }, ), - }) - .unwrap(), - input_schema: schema.clone(), - output_schema: schema, + ( + QueryNodeId(1), + QueryPlanNode::PhysicalFragment { + inputs: vec![QueryNodeId(0)], + dag: program.encode().unwrap(), + row_input: None, + pruning: None, + }, + ), + ] + .into(), + instant: InstantExecution { + lookback_ms: 0, + full_history: false, + cumulative_readout: false, + }, + fallback: FallbackPolicy::Reject, }; - assert!(validate_native_relation(QueryNodeId(1), &node).is_err()); + assert!(entry + .validate(&BTreeSet::new()) + .unwrap_err() + .to_string() + .contains("identity binding")); + if let QueryPlanNode::PhysicalFragment { row_input, .. } = + entry.nodes.get_mut(&QueryNodeId(1)).unwrap() + { + *row_input = Some(0); + } + entry.validate(&BTreeSet::new()).unwrap(); + } + + #[test] + fn uncompiled_relation_variants_are_not_accepted() { + for kind in ["relational", "relational_join"] { + let error = + serde_json::from_value::(serde_json::json!({"op":kind})) + .unwrap_err(); + assert!(error.to_string().contains("unknown variant"), "{error}"); + } } } diff --git a/crates/asap_types/src/query_plan/current_series.rs b/crates/asap_types/src/query_plan/current_series.rs index b109756b6..55fb01f67 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..70c81ca3f --- /dev/null +++ b/crates/asap_types/src/query_plan/native.rs @@ -0,0 +1,327 @@ +//! Retained physical programs for SQL relations and PromQL vectors. +use super::*; +use asap_physical_operators::physical_planner::CompiledPhysicalDag; +use planner_types::post_asap::SummarySchema; + +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: + query_time::QueryTimeOperator::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, + }; + let physical = CompiledPhysicalDag::from_operators( + [( + 0, + asap_physical_operators::physical_planner::InputContract::bounded( + std::sync::Arc::new(schema), + ), + )] + .into(), + BTreeMap::new(), + vec![0], + ) + .unwrap(); + entry.root = QueryNodeId(1); + entry.nodes.insert( + entry.root, + QueryPlanNode::PhysicalRelation { + inputs: vec![QueryNodeId(0)], + dag: physical.encode().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(); + if let QueryPlanNode::PhysicalRelation { dag, .. } = + corrupt.nodes.get_mut(&corrupt.root).unwrap() + { + let mut encoded: serde_json::Value = serde_json::from_slice(dag).unwrap(); + encoded["version"] = serde_json::json!(99); + *dag = serde_json::to_vec(&encoded).unwrap(); + } + assert!(corrupt.validate(&BTreeSet::new()).is_err()); + let mut corrupt = restored.clone(); + corrupt.root = QueryNodeId(99); + 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; + if let QueryPlanNode::PhysicalRelation { dag, .. } = + missing.nodes.get_mut(&missing.root).unwrap() + { + dag.clear(); + } + assert!(missing.validate(&BTreeSet::new()).is_err()); + } +} diff --git a/data_plane/src/drivers/ingest/prometheus_remote_write.rs b/data_plane/src/drivers/ingest/prometheus_remote_write.rs index 6f2c2ba2a..3f05d9b2d 100644 --- a/data_plane/src/drivers/ingest/prometheus_remote_write.rs +++ b/data_plane/src/drivers/ingest/prometheus_remote_write.rs @@ -1871,6 +1871,31 @@ impl PrometheusRemoteWriteReceiver { if bytes > runtime.policy.max_checkpoint_bytes { return Err(Box::new(asap_physical_operators::Error::MemoryLimit)); } } } + // This captured revision contains every accepted local input. + // Empty counter panes between observed panes are therefore known + // empty for this revision, not missing input. Preserve the native + // zero-sample state so counter readout can merge sparse histories; + // a later correction rebuilds these panes in a new revision. + if matches!(&program.family, planner_types::post_asap::SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Rate | planner_types::post_asap::ExactKind::Increase, _ + )) && !matches!(config.window_layout, asap_types::WindowMaterializationLayout::FullWindow) { + let bounds = windows.keys().next().zip(windows.keys().next_back()) + .map(|(first, last)| (first.0, last.0)); + if let Some((mut start, last)) = bounds { + let width = config.stored_window_ms(); + if width == 0 { return Err("counter pane width is zero".into()); } + while start < last { + let end = start.checked_add(width).ok_or("counter pane timestamp overflow")?; + if let std::collections::btree_map::Entry::Vacant(entry) = windows.entry((start, end)) { + entry.insert(program.updater()?); + if windows.values().map(|u| u.memory_usage_bytes()).sum::() > runtime.policy.max_checkpoint_bytes { + return Err(Box::new(asap_physical_operators::Error::MemoryLimit)); + } + } + start = end; + } + } + } let reference = plan.installed_precompute_plan.stored_output_reference(policy_fp.into()).ok_or("revision raw binding missing")?; let group = group_key.as_population_labels(); let states: BTreeMap<_, Arc> = windows.into_iter().map(|(w,updater)|(w,Arc::from(updater.into_accumulator()))).collect(); diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 3ce4596de..9e6107e41 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -1,16 +1,11 @@ -//! Production adapter from installed post-ASAP maintenance DAGs to summary state. +//! Bind immutable inputs to retained physical graphs and publish their stored outputs. use super::output_sink::OutputSink; -use super::subdag_scheduler::{ - execute_precompute_sink, execute_precompute_sinks, IdempotentCommitSink, - MaterializationCommitKey, PrecomputeOperatorRegistry, ScheduleError, -}; use crate::storage_engines::types::{ AggregateCore, InstalledPrecomputePlanHandle, PrecomputedOutput, }; -use asap_physical_operators::dag::RunContext; use asap_types::executable_plan::{BackendExecutableBinding, BackendNodeBinding}; -use planner_types::post_asap::{ExecutableDagNode, ExecutableOperatorPayload, PostAsapNodeId}; +use planner_types::post_asap::{PostAsapDagNode, PostAsapNodeId, PostAsapOperatorPayload}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Mutex}; @@ -19,466 +14,41 @@ type MaintenanceError = Box; type SummaryState = Arc; type Population = BTreeMap; -type PopulationStates = BTreeMap>; -type PopulationRows = BTreeMap>; - -fn maintenance_context( - start: i64, - end: i64, - revision: u64, -) -> Result { - RunContext::new( - asap_physical_operators::dag::Scope::Ingestion { - window_start_ms: start, - window_end_ms: end, - revision, - }, - asap_physical_operators::dag::Limits::default(), - ) - .map_err(Into::into) -} -#[derive(Clone)] -enum MaintenanceValue { - Summary { - state: SummaryState, - family: Option, - }, - // A collection is retained until the DAG explicitly reduces it. Evaluating - // the whole DAG once per source pane would change nested reductions. - SummaryWindows { - states: PopulationStates, - family: planner_types::post_asap::SummaryFamilyType, - }, - Rows { - values: PopulationRows, - name: String, - timestamped: bool, - }, +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct PublicationKey { + plan_id: u64, + plan_version: u64, + stored_output: asap_types::sds::StoredOutputId, + window_start_ms: i64, + window_end_ms: i64, + input_lineage: Vec, } -impl MaintenanceValue { - fn summary(state: SummaryState) -> Self { - Self::Summary { - state, - family: None, - } - } - - fn state(&self) -> Result<&SummaryState, MaintenanceError> { - match self { - Self::Summary { state, .. } => Ok(state), - Self::SummaryWindows { states, .. } - if states.len() == 1 - && states - .values() - .next() - .is_some_and(|windows| windows.len() == 1) => - { - Ok(&states.values().next().unwrap()[0].1) - } - Self::Rows { .. } | Self::SummaryWindows { .. } => { - Err("maintenance sink requires an explicit reduction to one summary state".into()) - } - } - } -} type PendingOutput = ( - Option<(MaterializationCommitKey, u64)>, + Option<(PublicationKey, u64)>, PrecomputedOutput, Box, ); enum MaintenanceInputs<'a> { - Live { - definition: asap_types::sds::StoredOutputId, - state: SummaryState, - }, Frozen(&'a [crate::storage_engines::sketch_db::index::FrozenExactWindows]), Complete(&'a crate::storage_engines::sketch_db::index::CompleteRawMaintenanceCohort), - Captured(&'a [crate::storage_engines::sketch_db::index::FrozenExactWindows]), } impl MaintenanceInputs<'_> { - fn frozen_inputs( - &self, - ) -> Option<&[crate::storage_engines::sketch_db::index::FrozenExactWindows]> { + fn frozen_inputs(&self) -> &[crate::storage_engines::sketch_db::index::FrozenExactWindows] { match self { - Self::Live { .. } => None, - Self::Frozen(inputs) | Self::Captured(inputs) => Some(inputs), - Self::Complete(cohort) => Some(cohort.inputs()), - } - } -} - -fn frozen_population_value( - inputs: &[crate::storage_engines::sketch_db::index::FrozenExactWindows], - definition: asap_types::sds::StoredOutputId, - family: Option, - complete: bool, -) -> Result, MaintenanceError> { - let mut states = PopulationStates::new(); - for input in inputs.iter().filter(|input| input.definition == definition) { - if !complete && !states.is_empty() { - return Err("maintenance frontier requires explicit population routing".into()); - } - let windows = input - .windows - .iter() - .map(|((_, end), state)| (*end as i64, Arc::clone(state))) - .collect::>() - .into(); - if states.insert(input.group.clone(), windows).is_some() { - return Err("maintenance frontier repeats a logical population".into()); - } - } - if states.is_empty() { - return Ok(None); - } - Ok(Some(MaintenanceValue::SummaryWindows { - states, - family: family.ok_or("immutable source lacks a summary schema")?, - })) -} - -struct OperatorAdapter<'a> { - binding: &'a BackendExecutableBinding, - inputs: MaintenanceInputs<'a>, - configs: &'a [asap_types::aggregation_config::PrecomputeMaterialization], -} - -impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { - type Error = MaintenanceError; - - fn materialized_input( - &self, - node: &ExecutableDagNode, - ) -> Result, MaintenanceError> { - let definition = match self.binding.node(node.id) { - Some(BackendNodeBinding::Materialization { stored_output }) => *stored_output, - _ => return Ok(None), - }; - let family = node.output_schema.fields.iter().find_map(|field| { - (!matches!( - field.dtype, - planner_types::post_asap::SummaryFamilyType::Plain(_) - )) - .then(|| field.dtype.clone()) - }); - match &self.inputs { - MaintenanceInputs::Live { - definition: source, - state, - } if definition == *source => Ok(Some(MaintenanceValue::Summary { - state: Arc::clone(state), - family, - })), - MaintenanceInputs::Live { .. } => Ok(None), - MaintenanceInputs::Frozen(inputs) => { - frozen_population_value(inputs, definition, family, false) - } - MaintenanceInputs::Captured(inputs) => { - frozen_population_value(inputs, definition, family, true) - } - MaintenanceInputs::Complete(cohort) => { - frozen_population_value(cohort.inputs(), definition, family, true) - } - } - } - - fn output_bytes(&self, value: &MaintenanceValue) -> usize { - fn labels(group: &Population) -> usize { - group.iter().map(|(k, v)| k.len() + v.len()).sum() - } - match value { - MaintenanceValue::Summary { state, .. } => state.approx_memory_bytes(), - MaintenanceValue::SummaryWindows { states, .. } => states - .iter() - .map(|(group, windows)| { - labels(group) - + windows - .iter() - .map(|(_, state)| 8 + state.approx_memory_bytes()) - .sum::() - }) - .sum(), - MaintenanceValue::Rows { values, name, .. } => { - name.len() - + values - .iter() - .map(|(group, rows)| { - labels(group) + rows.len() * std::mem::size_of::<(i64, f64)>() - }) - .sum::() - } - } - } - - fn execute( - &self, - node: &ExecutableDagNode, - inputs: &[Arc], - context: RunContext, - ) -> Result { - if node.output_state.timing != planner_types::post_asap::ExecutionTiming::IngestionTime { - return Err("ingestion executor received a query-time node".into()); - } - match &node.payload { - ExecutableOperatorPayload::SummaryMerge => merge_inputs(inputs, &context), - ExecutableOperatorPayload::Binary { operator } => { - if !self.inputs.frozen_inputs().is_some() - || node.output_state - != planner_types::post_asap::ExecutionDataState::INGESTION_ROWS - { - return Err("maintenance binary requires immutable completed row inputs".into()); - } - evaluate_aligned_binary(node, operator, inputs, &context) - } - - ExecutableOperatorPayload::Value { - operation: planner_types::post_asap::ValueOperation::FinalizeExactAccumulator, - } => { - if !self.inputs.frozen_inputs().is_some() { - return Err( - "maintenance finalization requires immutable completed input windows" - .into(), - ); - } - finalize_exact(node, inputs, &context) - } - ExecutableOperatorPayload::SummaryAgg { - family, - input, - grouping, - .. - } => { - let [value] = inputs else { - return Err("maintenance SummaryAgg requires exactly one row input".into()); - }; - let MaintenanceValue::Rows { values, name, .. } = value.as_ref() else { - return Err("maintenance SummaryAgg requires a typed update evaluator; finalize summary state before applying an update".into()); - }; - if !self.inputs.frozen_inputs().is_some() { - return Err( - "maintenance aggregation requires immutable completed input windows".into(), - ); - } - let target = match self.binding.node(node.id) { - Some(BackendNodeBinding::Materialization { stored_output }) => stored_output, - _ => { - return Err( - "maintenance SummaryAgg lacks installed materialization binding".into(), - ) - } - }; - let config = self - .configs - .iter() - .find(|config| config.policy_fingerprint() == target.fingerprint()) - .ok_or("maintenance SummaryAgg lacks installed accumulator configuration")?; - asap_types::precompute_plan::validate_maintenance_reduction(config, node)?; - let sources = self - .inputs - .frozen_inputs() - .ok_or("maintenance aggregation requires frozen sources")?; - for source in sources { - let source_config = self - .configs - .iter() - .find(|config| { - config.policy_fingerprint() == source.definition.fingerprint() - }) - .ok_or("maintenance input lacks installed source configuration")?; - validate_maintenance_grouping( - config, - source_config, - node, - source.singleton_population_complete - || matches!(&self.inputs, MaintenanceInputs::Complete(_)), - )?; - } - if config.accumulator_spec()?.family != *family { - return Err( - "maintenance SummaryAgg family differs from installed configuration".into(), - ); - } - if input.item.is_some() { - return Err( - "keyed maintenance updates require explicit row identity routing".into(), - ); - } - let _ = grouping; - let output_groups = values - .keys() - .map(|group| { - if config.partitioning - == Some(asap_types::sds::PopulationPartitioning::PerEntity) - { - return Ok(group.clone()); - } - config - .grouping_labels - .names() - .into_iter() - .map(|key| { - group - .get(&key) - .cloned() - .map(|value| (key.to_string(), value)) - .ok_or_else(|| { - "maintenance output grouping key is absent".to_string() - }) - }) - .collect::>() - }) - .collect::, String>>()?; - if output_groups.len() != 1 { - return Err( - "maintenance sink requires one explicitly reduced output population".into(), - ); - } - use asap_physical_operators::dag::{operators::Operator, values::Value}; - let schema = native_schema(vec![ - ( - "value", - planner_types::post_asap::SummaryFamilyType::Plain( - planner_types::pre_asap::DataType::Float64, - ), - ), - ( - "time", - planner_types::post_asap::SummaryFamilyType::Plain( - planner_types::pre_asap::DataType::Timestamp, - ), - ), - ]); - let rows = values - .values() - .flatten() - .map(|(time, value)| { - Ok(vec![ - Value::Float64(evaluate_weight(&input.weight, *value, name)?), - Value::Timestamp(*time), - ]) - }) - .collect::, MaintenanceError>>()?; - let builder = - Operator::summary_build(schema.clone(), family.clone(), 0, Some(1), vec![])?; - let mut result = native_rows(schema, rows, vec![builder], &context)?; - let Some(Value::Summary { state, .. }) = result.pop().and_then(|mut row| row.pop()) - else { - return Err("native summary builder did not return state".into()); - }; - let timestamp = values - .values() - .flatten() - .map(|(timestamp, _)| *timestamp) - .max() - .ok_or("maintenance aggregation has no input rows")?; - Ok(MaintenanceValue::SummaryWindows { - states: BTreeMap::from([( - output_groups.into_iter().next().unwrap(), - vec![(timestamp, state)].into(), - )]), - family: family.clone(), - }) - } - payload => Err(format!( - "maintenance operator {payload:?} has no summary-state implementation" - ) - .into()), + Self::Frozen(inputs) => inputs, + Self::Complete(cohort) => cohort.inputs(), } } } -// These functions translate deployment values into native batches. Window and -// catalog checks stay here; the library owns all computation on batch values. -fn native_schema( - fields: Vec<(&str, planner_types::post_asap::SummaryFamilyType)>, -) -> asap_physical_operators::dag::values::Schema { - Arc::new(planner_types::post_asap::SummarySchema { - fields: fields - .into_iter() - .map(|(name, dtype)| planner_types::post_asap::SummaryField { - name: name.into(), - dtype, - nullable: false, - }) - .collect(), - time_index: None, - }) -} -fn native_rows( - schema: asap_physical_operators::dag::values::Schema, - rows: Vec>, - operators: Vec, - context: &RunContext, -) -> Result>, MaintenanceError> { - use asap_physical_operators::dag::{batch_execution::evaluate_batch, values::Batch}; - let input = Batch::try_new(schema, rows)?; - Ok(evaluate_batch(input, operators, context.clone())? - .into_iter() - .flat_map(|b| b.rows().to_vec()) - .collect()) -} -fn native_arithmetic( - op: &planner_types::post_asap::BinaryOperator, - inputs: Vec<(i64, f64, f64)>, - context: &RunContext, -) -> Result, MaintenanceError> { - use asap_physical_operators::dag::{ - operators::{Expression, Operator}, - values::Value, - }; - use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; - let schema = native_schema(vec![ - ("time", SummaryFamilyType::Plain(DataType::Timestamp)), - ("left", SummaryFamilyType::Plain(DataType::Float64)), - ("right", SummaryFamilyType::Plain(DataType::Float64)), - ]); - let project = Operator::project( - schema.clone(), - vec![ - ("time".into(), Expression::Column(0)), - ( - "value".into(), - Expression::Binary { - operator: op.clone(), - left: Box::new(Expression::Column(1)), - right: Box::new(Expression::Column(2)), - }, - ), - ], - )?; - let rows = native_rows( - schema, - inputs - .into_iter() - .map(|(time, left, right)| { - vec![ - Value::Timestamp(time), - Value::Float64(left), - Value::Float64(right), - ] - }) - .collect(), - vec![project], - context, - )?; - rows.into_iter() - .map(|row| match row.as_slice() { - [Value::Timestamp(time), Value::Float64(value)] if value.is_finite() => { - Ok((*time, *value)) - } - _ => Err("maintenance binary produced a non-finite update".into()), - }) - .collect() -} - fn validate_maintenance_grouping( target: &asap_types::PrecomputeMaterialization, source: &asap_types::PrecomputeMaterialization, - node: &ExecutableDagNode, + node: &PostAsapDagNode, singleton_population_complete: bool, ) -> Result<(), MaintenanceError> { if target.grouping_labels == source.grouping_labels @@ -491,7 +61,7 @@ fn validate_maintenance_grouping( && target.partitioning == Some(asap_types::sds::PopulationPartitioning::Grouped) && source.partitioning == Some(asap_types::sds::PopulationPartitioning::PerEntity) && target.stored_window_ms() == source.stored_window_ms() - && matches!(&node.payload, ExecutableOperatorPayload::SummaryAgg { + && matches!(&node.payload, PostAsapOperatorPayload::SummaryAgg { reduction: planner_types::pre_asap::Reduction::Reduce(keys), .. } if keys.is_empty()) { @@ -500,340 +70,6 @@ fn validate_maintenance_grouping( Err("maintenance population reduction requires a complete singleton source or synchronized grouping".into()) } -fn evaluate_weight( - expression: &planner_types::post_asap::SummaryInputExpr, - value: f64, - name: &str, -) -> Result { - use planner_types::{post_asap::SummaryInputExpr, pre_asap::ColumnRef}; - let weight = match expression { - SummaryInputExpr::Constant(value) => *value, - SummaryInputExpr::Column(ColumnRef::SampleValue) => value, - SummaryInputExpr::Column(ColumnRef::Named(column)) if column == name => value, - _ => { - return Err("maintenance update does not resolve against the supplied typed row".into()) - } - }; - if !weight.is_finite() { - return Err("maintenance update weight is not finite".into()); - } - Ok(weight) -} - -// Rows carry only a value and its window timestamp. Reject any schema that -// would require silently dropping another column or manufacturing a timestamp. -fn maintenance_float64_column( - node: &ExecutableDagNode, -) -> Result<&planner_types::post_asap::SummaryField, MaintenanceError> { - use planner_types::post_asap::SummaryFamilyType; - let fields = &node.output_schema.fields; - let field = match node.output_schema.time_index { - None if fields.len() == 1 => &fields[0], - Some(time_index) if fields.len() == 2 && time_index < 2 => { - let timestamp = &fields[time_index]; - let value = &fields[1 - time_index]; - if timestamp.nullable - || timestamp.name == value.name - || !matches!( - timestamp.dtype, - SummaryFamilyType::Plain(planner_types::pre_asap::DataType::Timestamp) - ) - { - return Err("finalization timestamp column differs from its typed schema".into()); - } - value - } - _ => { - return Err( - "exact maintenance finalization requires one value and optional declared timestamp" - .into(), - ) - } - }; - if field.nullable - || !matches!( - field.dtype, - SummaryFamilyType::Plain(planner_types::pre_asap::DataType::Float64) - ) - { - return Err( - "exact maintenance finalization currently requires a Float64 output column".into(), - ); - } - Ok(field) -} - -fn evaluate_aligned_binary( - node: &ExecutableDagNode, - operator: &planner_types::post_asap::BinaryOperator, - inputs: &[Arc], - context: &RunContext, -) -> Result { - use planner_types::pre_asap::BinaryOpKind; - let BinaryOpKind::Arithmetic(_) = &operator.kind else { - return Err("maintenance binary currently requires arithmetic".into()); - }; - if operator.vector_match.is_some() { - return Err( - "maintenance binary requires explicit population routing for vector matching".into(), - ); - } - let name = maintenance_float64_column(node)?.name.clone(); - if node.output_schema.time_index.is_none() { - return Err("maintenance binary requires declared window timestamps".into()); - } - let [left, right] = inputs else { - return Err("maintenance binary requires two row inputs".into()); - }; - let ( - MaintenanceValue::Rows { - values: left, - timestamped: true, - .. - }, - MaintenanceValue::Rows { - values: right, - timestamped: true, - .. - }, - ) = (left.as_ref(), right.as_ref()) - else { - return Err("maintenance binary requires finalized row inputs".into()); - }; - if left.is_empty() || left.keys().ne(right.keys()) { - return Err("maintenance binary requires matching nonempty population sets".into()); - } - let mut values = PopulationRows::new(); - for (group, left) in left { - let right = &right[group]; - if left.is_empty() || left.len() != right.len() { - return Err("maintenance binary requires matching nonempty timestamp sets".into()); - } - // Canonical timestamp maps accept arrival-order differences, but never - // collapse duplicate updates or pair unrelated source windows by position. - let mut left_rows = BTreeMap::new(); - let mut right_rows = BTreeMap::new(); - for (rows, index) in [(left, &mut left_rows), (right, &mut right_rows)] { - for &(timestamp, value) in rows { - if !value.is_finite() || index.insert(timestamp, value).is_some() { - return Err( - "maintenance binary input has duplicate timestamps or non-finite values" - .into(), - ); - } - } - } - let mut joined = Vec::with_capacity(left.len()); - for (timestamp, left) in left_rows { - let right = right_rows - .get(×tamp) - .ok_or("maintenance binary requires matching timestamp sets")?; - joined.push((timestamp, left, *right)); - } - let joined = native_arithmetic(operator, joined, context)?; - values.insert(group.clone(), joined); - } - Ok(MaintenanceValue::Rows { - values, - name, - timestamped: true, - }) -} - -fn finalize_exact( - node: &ExecutableDagNode, - inputs: &[Arc], - context: &RunContext, -) -> Result { - use planner_types::post_asap::{ExactKind, SummaryFamilyType}; - let [input] = inputs else { - return Err("exact maintenance finalization requires one summary input".into()); - }; - let (groups, family) = match input.as_ref() { - MaintenanceValue::Summary { - state, - family: Some(family), - } => { - if node.output_schema.time_index.is_some() { - return Err("timestamped finalization requires source window timestamps".into()); - } - (vec![(Population::new(), vec![(0, state)])], family) - } - MaintenanceValue::SummaryWindows { states, family } => ( - states - .iter() - .map(|(group, windows)| { - ( - group.clone(), - windows.iter().map(|(time, state)| (*time, state)).collect(), - ) - }) - .collect(), - family, - ), - _ => { - return Err("exact maintenance finalization requires a typed exact accumulator".into()) - } - }; - let SummaryFamilyType::ExactAggregate(kind, _) = family else { - return Err("exact maintenance finalization requires a typed exact accumulator".into()); - }; - let statistic = match kind { - ExactKind::Sum => asap_types::Statistic::Sum, - ExactKind::Count => asap_types::Statistic::Count, - _ => { - return Err( - "exact maintenance readout requires explicit operator/time semantics".into(), - ) - } - }; - let field = maintenance_float64_column(node)?; - let mut values = PopulationRows::new(); - for (group, states) in groups { - use asap_physical_operators::dag::{operators::Operator, values::Value}; - let schema = native_schema(vec![("state", family.clone())]); - let readout = Operator::readout(schema.clone(), 0, statistic, Default::default())?; - let rows = native_rows( - schema, - states - .iter() - .map(|(_, state)| { - vec![Value::Summary { - family: family.clone(), - state: Arc::clone(state), - }] - }) - .collect(), - vec![readout], - context, - )?; - let rows = states - .into_iter() - .zip(rows) - .map(|((timestamp, _), row)| { - let value = - match row.first() { - Some(Value::Float64(value)) => *value, - Some(Value::Int64(value)) if value.unsigned_abs() <= (1u64 << 53) => { - *value as f64 - } - _ => return Err( - "exact readout cannot be represented by the installed Float64 schema" - .to_string(), - ), - }; - if !value.is_finite() { - return Err("exact maintenance finalization produced a non-finite value".into()); - } - Ok((timestamp, value)) - }) - .collect::, String>>()?; - values.insert(group, rows); - } - Ok(MaintenanceValue::Rows { - values, - name: field.name.clone(), - timestamped: node.output_schema.time_index.is_some() - && matches!(input.as_ref(), MaintenanceValue::SummaryWindows { .. }), - }) -} - -fn merge_inputs( - inputs: &[Arc], - context: &RunContext, -) -> Result { - let mut grouped = BTreeMap::, Option)>::new(); - let mut expected_groups = None; - let mut family = None; - for input in inputs { - let (groups, input_family) = match input.as_ref() { - MaintenanceValue::Summary { state, family } => ( - vec![(Population::new(), vec![(None, state)])], - family.as_ref(), - ), - MaintenanceValue::SummaryWindows { states, family } => ( - states - .iter() - .map(|(group, windows)| { - ( - group.clone(), - windows - .iter() - .map(|(time, state)| (Some(*time), state)) - .collect(), - ) - }) - .collect(), - Some(family), - ), - MaintenanceValue::Rows { .. } => return Err("summary merge cannot consume rows".into()), - }; - let keys: BTreeSet<_> = groups.iter().map(|(group, _)| group.clone()).collect(); - if expected_groups - .as_ref() - .is_some_and(|expected| expected != &keys) - { - return Err("summary merge cannot collapse different populations".into()); - } - expected_groups = Some(keys); - for (group, windows) in groups { - let (states, end) = grouped.entry(group).or_default(); - for (timestamp, state) in windows { - states.push(state); - *end = (*end).max(timestamp); - } - } - if let Some(input_family) = input_family { - if family.as_ref().is_some_and(|family| family != input_family) { - return Err("summary merge input families differ".into()); - } - family = Some(input_family.clone()); - } - } - if grouped.is_empty() { - return Err("summary maintenance node has no input state".into()); - } - let mut result = PopulationStates::new(); - for (group, (states, timestamp)) in grouped { - let Some((first, rest)) = states.split_first() else { - return Err("summary maintenance population has no input state".into()); - }; - let state_family = family.clone().or_else(|| { - first.as_any().downcast_ref::().map(|state| state.family().clone()) - }).or_else(|| first.as_any().is::().then_some( - planner_types::post_asap::SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, planner_types::post_asap::ExactParams::Sum) - )).ok_or("summary merge requires a registered state family")?; - use asap_physical_operators::dag::{operators::Operator, values::Value}; - let schema = native_schema(vec![("state", state_family.clone())]); - let rows = std::iter::once(*first) - .chain(rest.iter().copied()) - .map(|state| { - vec![Value::Summary { - family: state_family.clone(), - state: Arc::clone(state), - }] - }) - .collect(); - let merge = Operator::summary_merge(schema.clone(), 0, vec![])?; - let mut output = native_rows(schema, rows, vec![merge], context)?; - let Some(Value::Summary { state: merged, .. }) = output.pop().and_then(|mut row| row.pop()) - else { - return Err("native summary merge did not return state".into()); - }; - let Some(timestamp) = timestamp else { - return Ok(MaintenanceValue::Summary { - state: merged, - family, - }); - }; - result.insert(group, vec![(timestamp, merged)].into()); - } - Ok(MaintenanceValue::SummaryWindows { - states: result, - family: family.ok_or("merged immutable state lacks a family")?, - }) -} - fn frozen_cohort_lineage( inputs: &[crate::storage_engines::sketch_db::index::FrozenExactWindows], expected: &asap_types::derived_input::DerivedInputIdentity, @@ -906,13 +142,7 @@ fn prepare_frozen_maintenance_sink( sink: PostAsapNodeId, inputs: &[crate::storage_engines::sketch_db::index::FrozenExactWindows], output_window: (u64, u64), -) -> Result< - ( - planner_types::post_asap::ExecutableDag, - MaterializationCommitKey, - ), - MaintenanceError, -> { +) -> Result<(planner_types::post_asap::PostAsapDag, PublicationKey), MaintenanceError> { installed.validate()?; let target = match installed.binding.node(sink) { Some(BackendNodeBinding::Materialization { stored_output }) => *stored_output, @@ -975,7 +205,7 @@ fn prepare_frozen_maintenance_sink( .first() .ok_or("immutable input cohort is empty")? .generation; - let key = MaterializationCommitKey { + let key = PublicationKey { plan_id: generation.plan_id, plan_version: generation.plan_version, stored_output: target, @@ -993,32 +223,34 @@ fn execute_prepared_frozen_sink( configs: &[asap_types::PrecomputeMaterialization], sink: PostAsapNodeId, inputs: MaintenanceInputs<'_>, - dag: &planner_types::post_asap::ExecutableDag, - key: MaterializationCommitKey, + dag: &planner_types::post_asap::PostAsapDag, + key: PublicationKey, ) -> Result<(SummaryState, Population), MaintenanceError> { - let adapter = OperatorAdapter { - binding: &installed.binding, + let _ = (configs, dag); + let program = installed + .native_program(sink)? + .ok_or("installed precompute sink lacks its Planner physical graph")?; + let inputs = inputs.frozen_inputs(); + let batch = super::native_precompute::execute( + installed, + &program, inputs, - configs, - }; - let value = execute_precompute_sink( - dag, - &installed.binding, - sink, - key.clone(), - &adapter, - &CommitRegistry::default(), - maintenance_context(key.window_start_ms, key.window_end_ms, key.plan_version)?, - ) - .map_err(schedule_error)?; - let group = match value.as_ref() { - MaintenanceValue::SummaryWindows { states, .. } if states.len() == 1 => { - states.keys().next().unwrap().clone() - } - MaintenanceValue::Summary { .. } => Population::new(), - _ => return Err("maintenance sink has multiple or missing output populations".into()), - }; - Ok((Arc::clone(value.state()?), group)) + ( + u64::try_from(key.window_start_ms)?, + u64::try_from(key.window_end_ms)?, + ), + asap_physical_operators::runtime::Limits::default().max_bytes, + key.plan_version, + )? + .remove(&u64::from(sink.0)) + .ok_or("native precompute output missing")?; + let mut populations = + super::native_precompute::population_states(&batch, u64::try_from(key.window_end_ms)?)?; + if populations.len() != 1 { + return Err("precompute sink must explicitly reduce to one output population".into()); + } + let (group, state) = populations.pop().unwrap(); + Ok((state, group)) } #[cfg(test)] @@ -1428,9 +660,7 @@ fn execute_finite_source_cohort( Ok(()) } -/// Evaluate every raw population before one global immutable publication. -/// Canonical source identities are mandatory; legacy plans retain their -/// existing singleton path and cannot silently reinterpret old resolver keys. +/// Evaluate a retained graph once per complete input window and publish all roots. fn execute_finite_complete_populations( store: &crate::storage_engines::sketch_db::index::SketchStore, resolver: &crate::drivers::ingest::series_resolver::SeriesIdResolver, @@ -1464,29 +694,14 @@ fn execute_finite_complete_populations( .collect::, _>>()?; asap_types::precompute_plan::validated_source_window_cohort(config, &sources) .map_err(|error| error.to_string())?; + let program = installed + .native_program(sink)? + .ok_or("installed precompute output lacks its Planner physical graph")?; 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()) - }) - { - return Err( - "complete population execution requires canonical nonoverlapping full windows".into(), - ); - } - let dag = installed.document.decode()?; - let target_node = dag - .nodes - .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 { - reduction: planner_types::pre_asap::Reduction::Reduce(keys), .. - } if keys.is_empty()) + .any(|c| c.population_key_encoding != asap_types::PopulationKeyEncoding::CanonicalLabelsV1) { - return Err("complete population execution currently requires one global reduction".into()); + return Err("complete population execution requires canonical population bindings".into()); } let mut common_windows: Option> = None; let mut common_groups: Option> = None; @@ -1503,11 +718,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( @@ -1532,65 +752,81 @@ 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)?; - let (dag, key) = prepare_frozen_maintenance_sink( + let max_bytes = asap_physical_operators::runtime::Limits::default().max_bytes; + let outputs = super::native_precompute::execute( installed, - &plan.materializations, - sink, + &program, cohort.inputs(), window, + max_bytes, + 0, )?; - let digest: [u8; 32] = key - .input_lineage - .as_slice() - .try_into() - .map_err(|_| "complete maintenance digest is invalid")?; - let output_group = Population::new(); - let attrs = crate::drivers::ingest::population_attrs_fingerprint( - config.population_key_encoding, - &[], - )?; - let target_sid = - store.resolve_output_storage_handle(resolver, target, &attrs, Some(generation))?; - if store - .completed_maintenance_coordinates(target, generation)? - .get(&target_sid) - .and_then(|groups| groups.get(&output_group)) - .is_some_and(|windows| windows.contains(&window)) - { - continue; - } - if store - .recover_complete_raw_maintenance_output(target_sid, config, &cohort, digest, window)? - { - continue; - } - let (state, computed_group) = execute_prepared_frozen_sink( - installed, - &plan.materializations, - sink, - MaintenanceInputs::Complete(&cohort), - &dag, - key, - )?; - if computed_group != output_group { - return Err("computed complete population differs from the bound global output".into()); - } - let mut output = crate::storage_engines::types::PrecomputedOutput::new( - window.0, - window.1, - Some(crate::storage_engines::types::KeyByLabelValues { labels: Vec::new() }), - target.fingerprint(), - ); - output.population_labels = Some(computed_group); - output.catalog_generation = Some(Arc::clone(generation)); - store.publish_complete_raw_maintenance_output( - target_sid, - config, - &output, - state.as_ref(), - &cohort, - digest, - )?; + for (root, batch) in outputs { + let root = PostAsapNodeId(u32::try_from(root)?); + let Some(BackendNodeBinding::Materialization { + stored_output: target, + }) = installed.binding.node(root) + else { + return Err("physical output lacks its storage binding".into()); + }; + let config = plan + .materializations + .iter() + .find(|c| c.policy_fingerprint() == target.fingerprint()) + .ok_or("physical output configuration missing")?; + let mut output = PrecomputedOutput::new(window.0, window.1, None, target.fingerprint()); + output.catalog_generation = Some(Arc::clone(generation)); + if !asap_physical_operators::physical_planner::precompute::is_population_schema( + batch.schema(), + ) { + output.population_labels = Some(Population::new()); + store.publish_native_summary_output(resolver, config, &output, batch, max_bytes)?; + continue; + } + let derived = config + .derived_input + .as_ref() + .ok_or("physical output input identity missing")?; + let digest = frozen_cohort_lineage(cohort.inputs(), derived)?; + for (group, state) in super::native_precompute::population_states(&batch, window.1)? { + let pairs = group + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect::>(); + let attrs = crate::drivers::ingest::population_attrs_fingerprint( + config.population_key_encoding, + &pairs, + )?; + let target_sid = store.resolve_output_storage_handle( + resolver, + *target, + &attrs, + Some(generation), + )?; + if store + .completed_maintenance_coordinates(*target, generation)? + .get(&target_sid) + .and_then(|groups| groups.get(&group)) + .is_some_and(|windows| windows.contains(&window)) + { + continue; + } + if store.recover_complete_raw_maintenance_output( + target_sid, config, &cohort, digest, window, + )? { + continue; + } + output.population_labels = Some(group); + store.publish_complete_raw_maintenance_output( + target_sid, + config, + &output, + state.as_ref(), + &cohort, + digest, + )?; + } + } } Ok(()) } @@ -1613,7 +849,11 @@ pub(crate) fn execute_finite_maintenance( return Err("finite maintenance catalog generation changed".into()); } for installed in plan.executable_dags.values() { + let mut executed = BTreeSet::new(); for sink in &installed.binding.precompute_sinks { + if executed.contains(sink) { + continue; + } let Some(BackendNodeBinding::Materialization { stored_output: target, }) = installed.binding.node(*sink) @@ -1639,6 +879,11 @@ pub(crate) fn execute_finite_maintenance( *sink, &active_generation, )?; + if let Some(program) = installed.native_program(*sink)? { + for root in program.roots() { + executed.insert(PostAsapNodeId(u32::try_from(*root)?)); + } + } continue; } if derived.inputs.len() > 1 { @@ -1741,22 +986,22 @@ pub(crate) fn execute_finite_maintenance( } struct CommittedState { - value: Option>, + value: Option, published: bool, } #[derive(Default)] struct CommitRegistryState { generation: Option<(u64, u64)>, - entries: BTreeMap, + entries: BTreeMap, frontiers: BTreeMap, pending_batch: Option<[u8; 32]>, batch_has_published: bool, - admitted_keys: BTreeSet, + admitted_keys: BTreeSet, } impl CommitRegistryState { - fn validate_key(&self, key: &MaterializationCommitKey) -> Result<(), String> { + fn validate_key(&self, key: &PublicationKey) -> Result<(), String> { if self .generation .is_some_and(|generation| generation != (key.plan_id, key.plan_version)) @@ -1840,7 +1085,7 @@ impl CommitRegistry { fn complete_batch( &self, digest: [u8; 32], - completed: &[(MaterializationCommitKey, u64)], + completed: &[(PublicationKey, u64)], ) -> Result<(), String> { let mut state = self.0.lock().map_err(|_| "commit registry poisoned")?; if state.pending_batch != Some(digest) { @@ -1876,7 +1121,7 @@ impl CommitRegistry { Ok(()) } - fn pin_admitted(&self, key: &MaterializationCommitKey) -> Result<(), String> { + fn pin_admitted(&self, key: &PublicationKey) -> Result<(), String> { let mut state = self.0.lock().map_err(|_| "commit registry poisoned")?; if state.pending_batch.is_none() || state.generation != Some((key.plan_id, key.plan_version)) @@ -1887,14 +1132,14 @@ impl CommitRegistry { Ok(()) } - fn is_published(&self, key: &MaterializationCommitKey) -> Result { + fn is_published(&self, key: &PublicationKey) -> Result { let state = self.0.lock().map_err(|_| "commit registry poisoned")?; state.validate_key(key)?; Ok(state.entries.get(key).is_some_and(|entry| entry.published)) } fn publish( &self, - key: &MaterializationCommitKey, + key: &PublicationKey, emit: impl FnOnce() -> Result<(), Box>, ) -> Result<(), Box> { // Serialize acknowledgement with publication so a concurrent replay @@ -1917,13 +1162,8 @@ impl CommitRegistry { } } -impl IdempotentCommitSink for CommitRegistry { - type Error = String; - - fn get( - &self, - key: &MaterializationCommitKey, - ) -> Result>, Self::Error> { +impl CommitRegistry { + fn get(&self, key: &PublicationKey) -> Result, String> { let state = self.0.lock().map_err(|_| "commit registry poisoned")?; state.validate_key(key)?; Ok(state @@ -1934,9 +1174,9 @@ impl IdempotentCommitSink for CommitRegistry { fn commit_if_absent( &self, - key: MaterializationCommitKey, - value: Arc, - ) -> Result, Self::Error> { + key: PublicationKey, + value: SummaryState, + ) -> Result { let mut commits = self.0.lock().map_err(|_| "commit registry poisoned")?; commits.validate_key(&key)?; let committed = commits @@ -2015,14 +1255,6 @@ impl PrecomputeDagSink { if source_nodes.is_empty() { continue; } - let adapter = OperatorAdapter { - binding: &installed.binding, - inputs: MaintenanceInputs::Live { - definition: source_definition, - state: Arc::clone(&source), - }, - configs: &plan.precompute_plan.materializations, - }; let mut selected_outputs = Vec::new(); let mut horizons = Vec::new(); for sink_node in &installed.binding.precompute_sinks { @@ -2074,7 +1306,7 @@ impl PrecomputeDagSink { Some(BackendNodeBinding::Materialization { stored_output }) => *stored_output, _ => return Err("precompute sink lacks materialization binding".into()), }; - let key = MaterializationCommitKey { + let key = PublicationKey { plan_id: plan.plan_id(), plan_version: plan.plan_version(), stored_output: target, @@ -2108,19 +1340,19 @@ impl PrecomputeDagSink { selected_outputs.push((*sink_node, key)); horizons.push(horizon_ms); } - let values = execute_precompute_sinks( - &dag, - &installed.binding, - &selected_outputs, - &adapter, - &self.commits, - maintenance_context( - output.start_timestamp as i64, - output.end_timestamp as i64, - plan.plan_version(), - )?, - ) - .map_err(schedule_error)?; + let values = selected_outputs + .iter() + .map(|(sink, key)| { + if !source_nodes.contains(sink) { + return Err( + "derived outputs require an installed immutable precompute graph" + .to_string(), + ); + } + self.commits + .commit_if_absent(key.clone(), Arc::clone(&source)) + }) + .collect::, _>>()?; for (((_, key), horizon_ms), value) in selected_outputs.into_iter().zip(horizons).zip(values) { @@ -2131,7 +1363,7 @@ impl PrecomputeDagSink { derived.push(( Some((key, horizon_ms)), target_output, - value.state()?.clone_boxed_core(), + value.clone_boxed_core(), )); } } @@ -2143,16 +1375,8 @@ impl PrecomputeDagSink { } } -fn schedule_error(error: ScheduleError) -> MaintenanceError { - match error { - ScheduleError::Invalid(e) | ScheduleError::Sink(e) => e.into(), - ScheduleError::Operator(e) => e, - ScheduleError::Execution(e) => Box::new(e), - } -} - fn depends_on_any( - dag: &planner_types::post_asap::ExecutableDag, + dag: &planner_types::post_asap::PostAsapDag, sink: PostAsapNodeId, sources: &BTreeSet, ) -> bool { @@ -2160,14 +1384,14 @@ fn depends_on_any( } fn dependencies( - dag: &planner_types::post_asap::ExecutableDag, + dag: &planner_types::post_asap::PostAsapDag, sink: PostAsapNodeId, ) -> BTreeSet { dependencies_until(dag, sink, &BTreeSet::new()) } fn dependencies_until( - dag: &planner_types::post_asap::ExecutableDag, + dag: &planner_types::post_asap::PostAsapDag, sink: PostAsapNodeId, frontier: &BTreeSet, ) -> BTreeSet { @@ -2334,34 +1558,44 @@ pub(crate) fn affected_materializations( #[cfg(test)] mod tests { - fn test_context() -> asap_physical_operators::dag::RunContext { - use asap_physical_operators::dag::{Limits, RunContext, Scope}; - RunContext::new( - Scope::Ingestion { - window_start_ms: 0, - window_end_ms: 10_000, - revision: 1, - }, - Limits::default(), - ) - .unwrap() - } use super::*; use asap_physical_operators::summary_kernels::SumAccumulator; use planner_types::post_asap::{ - EdgeRole, ExecutableDag, ExecutableDagEdge, GroupingEdgeCompatibility, SummarySchema, + EdgeRole, GroupingEdgeCompatibility, PostAsapDag, PostAsapDagEdge, SummarySchema, WindowEdgeCompatibility, }; + // A sixty-second window updated every ten seconds must publish every + // scheduled overlap, including after a correction and with a phase offset. + #[test] + fn revised_sliding_windows_follow_cadence_not_extent() { + for origin in [0, 5_000] { + let starts = [0, 10_000, 20_000, 60_000].map(|t| t + origin); + assert_eq!( + revision_windows(60_000, 10_000, origin as i64, starts.into_iter()).unwrap(), + starts + .into_iter() + .map(|start| (start, start + 60_000)) + .collect() + ); + assert!( + revision_windows(60_000, 10_000, origin as i64, [origin + 1].into_iter()) + .unwrap() + .is_empty() + ); + } + assert!(revision_windows(60_000, 0, 0, [0].into_iter()).is_err()); + } + fn definition(value: u64) -> asap_types::sds::StoredOutputId { asap_types::PolicyFingerprint(value).into() } fn maintenance_only( - mut dag: ExecutableDag, + mut dag: PostAsapDag, mut binding: BackendExecutableBinding, - ) -> (ExecutableDag, BackendExecutableBinding) { + ) -> (PostAsapDag, BackendExecutableBinding) { let retained = dag .nodes .iter() @@ -2454,45 +1688,11 @@ mod tests { } // Ingestion adapters must execute native operators in the parent's scope. - #[test] - fn native_merge_uses_the_parent_budget_and_cancellation() { - let input = Arc::new(MaintenanceValue::summary(Arc::new( - SumAccumulator::with_sum(3.), - ))); - let context = test_context(); - let output = merge_inputs(&[Arc::clone(&input)], &context).unwrap(); - assert_eq!( - output - .state() - .unwrap() - .query_statistic(asap_types::Statistic::Sum, &None, &Default::default()) - .unwrap(), - 3. - ); - assert!(context.peak_bytes() > 0); - context.cancel(); - let error = merge_inputs(&[input], &context).err().unwrap(); - let error = error - .downcast_ref::() - .expect("native cancellation must retain its physical error type"); - fn cause( - error: &asap_physical_operators::dag::Error, - ) -> &asap_physical_operators::dag::Error { - match error { - asap_physical_operators::dag::Error::AtNode { source, .. } => cause(source), - error => error, - } - } - assert_eq!( - cause(error), - &asap_physical_operators::dag::Error::Cancelled - ); - } - fn node(id: u32) -> ExecutableDagNode { - ExecutableDagNode { + fn node(id: u32) -> PostAsapDagNode { + PostAsapDagNode { id: PostAsapNodeId(id), - payload: ExecutableOperatorPayload::SummaryMerge, + payload: PostAsapOperatorPayload::SummaryMerge, output_state: planner_types::post_asap::ExecutionDataState::INGESTION_SUMMARY, output_schema: SummarySchema { fields: vec![], @@ -2502,8 +1702,8 @@ mod tests { } } - fn edge(producer: u32, consumer: u32) -> ExecutableDagEdge { - ExecutableDagEdge { + fn edge(producer: u32, consumer: u32) -> PostAsapDagEdge { + PostAsapDagEdge { producer: PostAsapNodeId(producer), consumer: PostAsapNodeId(consumer), role: EdgeRole::Input, @@ -2523,94 +1723,6 @@ mod tests { Arc::new(accumulator) } - #[test] - fn frozen_adapter_resolves_each_materialized_frontier_without_aliasing() { - use planner_types::post_asap::{ExactKind, ExactParams, SummaryFamilyType, SummaryField}; - let make = |id| crate::storage_engines::sketch_db::index::FrozenExactWindows { - stored_output_reference: asap_types::sds::StoredOutputReference::for_output( - definition(id), - ), - storage_handle: id, - definition: definition(id), - generation: Arc::new(asap_types::sds::CatalogGeneration { - schema_version: 2, - plan_id: 1, - plan_version: 1, - snapshot_sha256: "0".repeat(64), - }), - group: BTreeMap::new(), - windows: BTreeMap::from([((0, 1000), sum(id as f64))]), - singleton_population_complete: false, - }; - let inputs = [make(1), make(2)]; - let binding = BackendExecutableBinding { - nodes: [(1, definition(1)), (2, definition(2)), (3, definition(3))] - .into_iter() - .map(|(id, stored_output)| { - ( - PostAsapNodeId(id), - BackendNodeBinding::Materialization { stored_output }, - ) - }) - .collect(), - query_sink: PostAsapNodeId(3), - query_plan_sink: asap_types::query_plan::QueryNodeId(3), - precompute_sinks: vec![PostAsapNodeId(3)], - }; - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Frozen(&inputs), - configs: &[], - }; - let source_node = |id| { - let mut source = node(id); - source.output_schema.fields = vec![SummaryField { - name: "state".into(), - dtype: SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), - nullable: false, - }]; - source - }; - let first = adapter - .materialized_input(&source_node(1)) - .unwrap() - .unwrap(); - let second = adapter - .materialized_input(&source_node(2)) - .unwrap() - .unwrap(); - assert!(adapter - .materialized_input(&source_node(3)) - .unwrap() - .is_none()); - let merged = adapter - .execute( - &node(3), - &[Arc::new(first), Arc::new(second)], - test_context(), - ) - .unwrap(); - assert_eq!( - merged - .state() - .unwrap() - .query_statistic( - asap_types::Statistic::Sum, - &None, - &std::collections::HashMap::new() - ) - .unwrap(), - 3.0 - ); - let ambiguous = [make(1), make(1)]; - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Frozen(&ambiguous), - configs: &[], - }; - assert!(adapter.materialized_input(&source_node(1)).is_err()); - } - #[test] fn finalized_summary_update_builds_a_different_installed_family() { use planner_types::post_asap::{ @@ -2671,31 +1783,8 @@ mod tests { query_plan_sink: control_plane::query_plan::QueryNodeId(3), precompute_sinks: vec![PostAsapNodeId(3)], }; - let frozen_inputs = [ - crate::storage_engines::sketch_db::index::FrozenExactWindows { - stored_output_reference: asap_types::sds::StoredOutputReference::for_output( - source_definition, - ), - storage_handle: 1, - definition: source_definition, - generation: Arc::new(asap_types::sds::CatalogGeneration { - schema_version: 2, - plan_id: 1, - plan_version: 1, - snapshot_sha256: "0".repeat(64), - }), - group: BTreeMap::new(), - windows: BTreeMap::from([((0, 1000), sum(7.0))]), - singleton_population_complete: false, - }, - ]; - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Frozen(&frozen_inputs), - configs: &configs, - }; let mut read = node(2); - read.payload = ExecutableOperatorPayload::Value { + read.payload = PostAsapOperatorPayload::Value { operation: planner_types::post_asap::ValueOperation::FinalizeExactAccumulator, }; read.output_schema.fields = vec![SummaryField { @@ -2703,16 +1792,8 @@ mod tests { dtype: SummaryFamilyType::Plain(DataType::Float64), nullable: false, }]; - let source = Arc::new(MaintenanceValue::Summary { - state: sum(7.0), - family: Some(SummaryFamilyType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )), - }); - let row = adapter.execute(&read, &[source], test_context()).unwrap(); let mut aggregate = node(3); - aggregate.payload = ExecutableOperatorPayload::SummaryAgg { + aggregate.payload = PostAsapOperatorPayload::SummaryAgg { family: target_family, input: SummaryUpdate { item: None, @@ -2722,21 +1803,7 @@ mod tests { reduction: Reduction::by(vec![]), grouping: GroupingStrategy::default(), }; - let result = adapter - .execute(&aggregate, &[Arc::new(row)], test_context()) - .unwrap(); - let mut kwargs = std::collections::HashMap::new(); - kwargs.insert("quantile".into(), "0.5".into()); - assert_eq!( - result - .state() - .unwrap() - .query_statistic(asap_types::Statistic::Quantile, &None, &kwargs) - .unwrap(), - 3.0 - ); - // Exercise the same registry through the production topological - // scheduler, including the precomputed source frontier and commit. + let kwargs = std::collections::HashMap::from([("quantile".into(), "0.5".into())]); let mut source_node = node(1); source_node.output_schema.fields = vec![SummaryField { name: "state".into(), @@ -2758,7 +1825,7 @@ mod tests { second_edge.data_state = read.output_state; let mut query_edge = edge(3, 4); query_edge.intermediate_schema = aggregate.output_schema.clone(); - let dag = ExecutableDag { + let dag = PostAsapDag { nodes: vec![source_node, read, aggregate, query], edges: vec![first_edge, second_edge, query_edge], root: PostAsapNodeId(4), @@ -2782,36 +1849,6 @@ mod tests { scheduled_binding.query_sink = PostAsapNodeId(4); scheduled_binding.query_plan_sink = control_plane::query_plan::QueryNodeId(4); let (dag, scheduled_binding) = maintenance_only(dag, scheduled_binding); - let scheduled_adapter = OperatorAdapter { - binding: &scheduled_binding, - ..adapter - }; - let key = MaterializationCommitKey { - plan_id: 1, - plan_version: 1, - stored_output: target, - window_start_ms: 0, - window_end_ms: 1000, - input_lineage: vec![1], - }; - let committed = execute_precompute_sink( - &dag, - &scheduled_binding, - PostAsapNodeId(3), - key, - &scheduled_adapter, - &CommitRegistry::default(), - test_context(), - ) - .unwrap(); - assert_eq!( - committed - .state() - .unwrap() - .query_statistic(asap_types::Statistic::Quantile, &None, &kwargs) - .unwrap(), - 3.0 - ); // The real store provides the completion proof. Execute, persist, // restart, and retry the same installed subDAG without additive append. use crate::storage_engines::sketch_db::index::{ @@ -2819,7 +1856,7 @@ mod tests { }; use asap_types::executable_plan::{InstalledPostAsapDag, OwnedPostAsapDag}; let mut document = - OwnedPostAsapDag::from_executable("immutable-chain".into(), &dag).unwrap(); + OwnedPostAsapDag::from_post_asap_dag("immutable-chain".into(), &dag).unwrap(); let mut durable_configs = configs.to_vec(); durable_configs[1].derived_input = Some( asap_types::derived_input::DerivedInputIdentity::from_dag( @@ -2837,7 +1874,14 @@ mod tests { stored_output: durable_configs[1].policy_fingerprint().into(), }, ); + let program = + asap_physical_operators::physical_planner::precompute::compile(&dag, &[1], &[3]) + .unwrap(); let installed = InstalledPostAsapDag { + native_programs: BTreeMap::from([( + PostAsapNodeId(3), + serde_json::from_slice(&program.encode().unwrap()).unwrap(), + )]), document, binding: durable_binding, }; @@ -3121,16 +2165,10 @@ mod tests { exercise_two_source_completed_sink(&dag, &configs, &scheduled_binding, false, false); exercise_two_source_completed_sink(&dag, &configs, &scheduled_binding, true, true); exercise_two_source_completed_sink(&dag, &configs, &scheduled_binding, false, true); - assert!(evaluate_weight( - &SummaryInputExpr::Column(planner_types::pre_asap::ColumnRef::Named("missing".into())), - 7.0, - "value" - ) - .is_err()); } fn exercise_two_source_completed_sink( - template: &ExecutableDag, + template: &PostAsapDag, configs: &[asap_types::PrecomputeMaterialization], binding: &BackendExecutableBinding, matching_windows: bool, @@ -3162,7 +2200,7 @@ mod tests { second_node.id = PostAsapNodeId(5); let mut merge = second_node.clone(); merge.id = PostAsapNodeId(6); - merge.payload = ExecutableOperatorPayload::SummaryMerge; + merge.payload = PostAsapOperatorPayload::SummaryMerge; dag.nodes.extend([second_node, merge]); let original = dag .edges @@ -3179,7 +2217,7 @@ mod tests { edge.consumer = PostAsapNodeId(consumer); dag.edges.push(edge); } - if let ExecutableOperatorPayload::SummaryAgg { input, .. } = &mut dag + if let PostAsapOperatorPayload::SummaryAgg { input, .. } = &mut dag .nodes .iter_mut() .find(|node| node.id == PostAsapNodeId(3)) @@ -3191,7 +2229,7 @@ mod tests { ); } let mut document = - OwnedPostAsapDag::from_executable("two-source-fixture".into(), &dag).unwrap(); + OwnedPostAsapDag::from_post_asap_dag("two-source-fixture".into(), &dag).unwrap(); let mut target = configs[1].clone(); if complete_groups { target.population_key_encoding = asap_types::PopulationKeyEncoding::CanonicalLabelsV1; @@ -3222,7 +2260,20 @@ mod tests { binding .nodes .insert(PostAsapNodeId(6), BackendNodeBinding::MaintenanceInput); - let installed = InstalledPostAsapDag { document, binding }; + let program = asap_physical_operators::physical_planner::precompute::compile( + &document.decode().unwrap(), + &[1, 5], + &[3], + ) + .unwrap(); + let installed = InstalledPostAsapDag { + native_programs: BTreeMap::from([( + PostAsapNodeId(3), + serde_json::from_slice(&program.encode().unwrap()).unwrap(), + )]), + document, + binding, + }; let configs = [first, second, target]; let catalog = Arc::new( asap_types::summary_catalog::SummaryCatalog::from_materializations(2, 1, &configs) @@ -3557,458 +2608,11 @@ mod tests { persistence.shutdown(); } - #[test] - fn maintenance_binary_aligns_windows_and_rejects_incomplete_or_ambiguous_rows() { - use planner_types::post_asap::{BinaryOperator, SummaryFamilyType, SummaryField}; - use planner_types::pre_asap::{ArithmeticOpKind, BinaryOpKind, DataType}; - let mut operation = node(10); - operation.output_schema.fields = vec![ - SummaryField { - name: "ts".into(), - dtype: SummaryFamilyType::Plain(DataType::Timestamp), - nullable: false, - }, - SummaryField { - name: "value".into(), - dtype: SummaryFamilyType::Plain(DataType::Float64), - nullable: false, - }, - ]; - operation.output_schema.time_index = Some(0); - let mut operator = BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Sub), - vector_match: None, - }; - let rows = |values: Vec<(i64, f64)>| { - Arc::new(MaintenanceValue::Rows { - values: BTreeMap::from([(BTreeMap::new(), values)]), - name: "value".into(), - timestamped: true, - }) - }; - let left = rows(vec![(2_000, 7.0), (1_000, 5.0)]); - let right = rows(vec![(1_000, 2.0), (2_000, 3.0)]); - // Arrival order cannot exchange windows, and subtraction retains edge order. - let MaintenanceValue::Rows { values, .. } = evaluate_aligned_binary( - &operation, - &operator, - &[left.clone(), right.clone()], - &test_context(), - ) - .unwrap() else { - panic!("expected rows") - }; - assert_eq!(values[&BTreeMap::new()], vec![(1_000, 3.0), (2_000, 4.0)]); - // Equal timestamps in different populations are separate rows, never - // added together before the DAG explicitly reduces those populations. - let a = BTreeMap::from([("instance".to_string(), "a".to_string())]); - let b = BTreeMap::from([("instance".to_string(), "b".to_string())]); - let grouped = |values| { - Arc::new(MaintenanceValue::Rows { - values, - name: "value".into(), - timestamped: true, - }) - }; - let grouped_left = grouped(BTreeMap::from([ - (a.clone(), vec![(1_000, 5.0)]), - (b.clone(), vec![(1_000, 9.0)]), - ])); - let grouped_right = grouped(BTreeMap::from([ - (a.clone(), vec![(1_000, 2.0)]), - (b.clone(), vec![(1_000, 4.0)]), - ])); - let MaintenanceValue::Rows { values, .. } = evaluate_aligned_binary( - &operation, - &operator, - &[grouped_left.clone(), grouped_right], - &test_context(), - ) - .unwrap() else { - panic!("expected grouped rows") - }; - assert_eq!( - values, - BTreeMap::from([(a.clone(), vec![(1_000, 3.0)]), (b, vec![(1_000, 5.0)]),]) - ); - assert!(evaluate_aligned_binary( - &operation, - &operator, - &[ - grouped_left, - grouped(BTreeMap::from([(a, vec![(1_000, 2.0)])])) - ], - &test_context() - ) - .is_err()); - let binding = BackendExecutableBinding { - nodes: BTreeMap::new(), - query_sink: PostAsapNodeId(10), - query_plan_sink: asap_types::query_plan::QueryNodeId(10), - precompute_sinks: vec![], - }; - let frozen = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Frozen(&[]), - configs: &[], - }; - operation.payload = ExecutableOperatorPayload::Binary { - operator: operator.clone(), - }; - operation.output_state = planner_types::post_asap::ExecutionDataState::INGESTION_ROWS; - assert!(frozen - .execute(&operation, &[left.clone(), right.clone()], test_context()) - .is_ok()); - let live = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Live { - definition: definition(1), - state: sum(1.0), - }, - configs: &[], - }; - assert!(live - .execute(&operation, &[left.clone(), right.clone()], test_context()) - .is_err()); - operation.payload = ExecutableOperatorPayload::Binary { - operator: operator.clone(), - }; - operation.output_state = planner_types::post_asap::ExecutionDataState::QUERY_ROWS; - assert!(frozen - .execute(&operation, &[left.clone(), right.clone()], test_context()) - .is_err()); - operation.output_state = planner_types::post_asap::ExecutionDataState::INGESTION_ROWS; - - for invalid in [ - rows(vec![]), - rows(vec![(1_000, 2.0)]), - rows(vec![(1_000, 2.0), (3_000, 3.0)]), - rows(vec![(1_000, 2.0), (1_000, 3.0)]), - rows(vec![(1_000, f64::NAN), (2_000, 3.0)]), - Arc::new(MaintenanceValue::Rows { - values: BTreeMap::from([(BTreeMap::new(), vec![(1_000, 2.0), (2_000, 3.0)])]), - name: "value".into(), - timestamped: false, - }), - Arc::new(MaintenanceValue::summary(sum(2.0))), - ] { - assert!(evaluate_aligned_binary( - &operation, - &operator, - &[left.clone(), invalid], - &test_context() - ) - .is_err()); - } - operator.kind = BinaryOpKind::Arithmetic(ArithmeticOpKind::Div); - assert!(evaluate_aligned_binary( - &operation, - &operator, - &[left.clone(), rows(vec![(1_000, 0.0), (2_000, 3.0)])], - &test_context() - ) - .is_err()); - operation.output_schema.fields[1].dtype = SummaryFamilyType::Plain(DataType::Int64); - assert!( - evaluate_aligned_binary(&operation, &operator, &[left, right], &test_context()) - .is_err() - ); - } - - #[test] - fn finalization_preserves_windows_until_an_explicit_merge() { - use planner_types::post_asap::{ExactKind, ExactParams, SummaryFamilyType, SummaryField}; - let family = SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); - let inputs = Arc::new(MaintenanceValue::SummaryWindows { - states: BTreeMap::from([( - BTreeMap::new(), - vec![(1_000, sum(2.0)), (2_000, sum(7.0))].into(), - )]), - family: family.clone(), - }); - let different_group = Arc::new(MaintenanceValue::SummaryWindows { - states: BTreeMap::from([( - BTreeMap::from([("instance".into(), "other".into())]), - vec![(1_000, sum(3.0))].into(), - )]), - family, - }); - assert!(merge_inputs(&[inputs.clone(), different_group], &test_context()).is_err()); - let mut read = node(2); - read.output_schema.fields = vec![SummaryField { - name: "value".into(), - dtype: SummaryFamilyType::Plain(planner_types::pre_asap::DataType::Float64), - nullable: false, - }]; - let MaintenanceValue::Rows { values, .. } = - finalize_exact(&read, &[inputs.clone()], &test_context()).unwrap() - else { - panic!("expected finalized rows") - }; - assert_eq!(values[&BTreeMap::new()], vec![(1_000, 2.0), (2_000, 7.0)]); - // Merge is a semantic DAG operation, not an implicit batch optimization. - // Finalizing after it emits exactly one value instead of two updates. - let merged = Arc::new(merge_inputs(&[inputs], &test_context()).unwrap()); - let MaintenanceValue::Rows { values, .. } = - finalize_exact(&read, &[merged], &test_context()).unwrap() - else { - panic!("expected finalized row") - }; - assert_eq!(values[&BTreeMap::new()], vec![(2_000, 9.0)]); - read.output_schema.fields[0].dtype = - SummaryFamilyType::Plain(planner_types::pre_asap::DataType::Int64); - let integer_state = Arc::new(MaintenanceValue::Summary { - state: sum(9.0), - family: Some(SummaryFamilyType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )), - }); - assert!( - matches!(finalize_exact(&read, &[integer_state], &test_context()), Err(error) if error.to_string().contains("Float64")) - ); - } - - #[test] - fn finalization_preserves_declared_timestamp_and_rejects_ambiguous_columns() { - use planner_types::post_asap::{ExactKind, ExactParams, SummaryFamilyType, SummaryField}; - use planner_types::pre_asap::DataType; - let input = Arc::new(MaintenanceValue::SummaryWindows { - states: BTreeMap::from([(BTreeMap::new(), vec![(60_000, sum(10.0))].into())]), - family: SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), - }); - let mut read = node(2); - read.output_schema.fields = vec![ - SummaryField { - name: "ts".into(), - dtype: SummaryFamilyType::Plain(DataType::Timestamp), - nullable: false, - }, - SummaryField { - name: "value".into(), - dtype: SummaryFamilyType::Plain(DataType::Float64), - nullable: false, - }, - ]; - read.output_schema.time_index = Some(0); - let MaintenanceValue::Rows { values, name, .. } = - finalize_exact(&read, &[Arc::clone(&input)], &test_context()).unwrap() - else { - panic!("expected typed rows") - }; - assert_eq!(values[&BTreeMap::new()], vec![(60_000, 10.0)]); - assert_eq!(name, "value"); - let mut malformed = Vec::new(); - let mut copy = read.clone(); - copy.output_schema.time_index = None; - malformed.push(copy); - let mut copy = read.clone(); - copy.output_schema.time_index = Some(2); - malformed.push(copy); - let mut copy = read.clone(); - copy.output_schema.time_index = Some(1); - malformed.push(copy); - let mut copy = read.clone(); - copy.output_schema - .fields - .push(copy.output_schema.fields[1].clone()); - malformed.push(copy); - let mut copy = read.clone(); - copy.output_schema.fields[1].nullable = true; - malformed.push(copy); - let mut copy = read.clone(); - copy.output_schema.fields[1].name = "ts".into(); - malformed.push(copy); - for malformed in malformed { - assert!(finalize_exact(&malformed, &[Arc::clone(&input)], &test_context()).is_err()); - } - let untimed = Arc::new(MaintenanceValue::Summary { - state: sum(10.0), - family: Some(SummaryFamilyType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )), - }); - assert!(finalize_exact(&read, &[untimed], &test_context()).is_err()); - } - - #[test] - fn summary_aggregation_does_not_silently_reuse_input_family() { - use planner_types::post_asap::{ - ExactKind, ExactParams, GroupingStrategy, SummaryFamilyType, SummaryUpdate, - }; - use planner_types::pre_asap::{ColumnRef, Reduction}; - - let binding = BackendExecutableBinding { - nodes: BTreeMap::new(), - query_sink: PostAsapNodeId(2), - query_plan_sink: asap_types::query_plan::QueryNodeId(2), - precompute_sinks: vec![PostAsapNodeId(1)], - }; - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Live { - definition: definition(1), - state: sum(7.0), - }, - configs: &[], - }; - let mut aggregate = node(1); - aggregate.payload = ExecutableOperatorPayload::SummaryAgg { - family: SummaryFamilyType::ExactAggregate(ExactKind::Count, ExactParams::Count), - input: SummaryUpdate::column(ColumnRef::SampleValue), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - }; - let error = adapter.execute( - &aggregate, - &[Arc::new(MaintenanceValue::summary(sum(7.0)))], - test_context(), - ); - assert!( - matches!(error, Err(reason) if reason.to_string().contains("typed update evaluator")) - ); - } - - #[test] - fn summary_update_rejects_multiple_output_populations_before_updating() { - use planner_types::post_asap::{GroupingStrategy, SummaryUpdate}; - use planner_types::pre_asap::{ColumnRef, Reduction}; - let snapshot: control_plane::physical::compiler::BackendLocalPlanningInput = - serde_json::from_str(include_str!( - "../../../docs/examples/asapquery-planning-snapshot.json" - )) - .unwrap(); - let mut config = crate::tests::test_utilities::planning::quoted_snapshot(snapshot, false) - .compile_promql() - .unwrap() - .precompute_plan - .materializations[0] - .clone(); - config.aggregation_type = asap_types::AggregationType::Sum; - config.aggregation_sub_type = "sum".into(); - config.grouping_labels = ["instance".to_string()].into_iter().collect(); - config.partitioning = Some(asap_types::sds::PopulationPartitioning::PerEntity); - let family = config.accumulator_spec().unwrap().family; - let binding = BackendExecutableBinding { - nodes: BTreeMap::from([( - PostAsapNodeId(1), - BackendNodeBinding::Materialization { - stored_output: config.policy_fingerprint().into(), - }, - )]), - query_sink: PostAsapNodeId(1), - query_plan_sink: asap_types::query_plan::QueryNodeId(1), - precompute_sinks: vec![PostAsapNodeId(1)], - }; - let configs = [config]; - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Frozen(&[]), - configs: &configs, - }; - let mut aggregate = node(1); - aggregate.payload = ExecutableOperatorPayload::SummaryAgg { - family, - input: SummaryUpdate::column(ColumnRef::SampleValue), - reduction: Reduction::PerEntity, - grouping: GroupingStrategy::default(), - }; - let rows = MaintenanceValue::Rows { - values: ["a", "b"] - .into_iter() - .map(|name| { - ( - BTreeMap::from([("instance".into(), name.into())]), - vec![(1_000, 5.0)], - ) - }) - .collect(), - name: "value".into(), - timestamped: true, - }; - assert!( - matches!(adapter.execute(&aggregate, &[Arc::new(rows)], test_context()), - Err(error) if error.to_string().contains("one explicitly reduced output population")) - ); - } - - #[test] - fn dds_maintenance_rejects_nonpositive_population_before_returning_summary() { - use planner_types::post_asap::{GroupingStrategy, SummaryUpdate}; - use planner_types::pre_asap::{ColumnRef, Reduction}; - let snapshot: control_plane::physical::compiler::BackendLocalPlanningInput = - serde_json::from_str(include_str!( - "../../../docs/examples/asapquery-planning-snapshot.json" - )) - .unwrap(); - let mut config = crate::tests::test_utilities::planning::quoted_snapshot(snapshot, false) - .compile_promql() - .unwrap() - .precompute_plan - .materializations[0] - .clone(); - config.aggregation_type = asap_types::AggregationType::DDSketch; - config.parameters.clear(); - config - .parameters - .insert("relative_accuracy".into(), "0.01".into()); - config.aggregation_sub_type.clear(); - config.grouping_labels = std::iter::empty::().collect(); - config.partitioning = Some(asap_types::sds::PopulationPartitioning::Grouped); - let family = config.accumulator_spec().unwrap().family; - let binding = BackendExecutableBinding { - nodes: BTreeMap::from([( - PostAsapNodeId(1), - BackendNodeBinding::Materialization { - stored_output: config.policy_fingerprint().into(), - }, - )]), - query_sink: PostAsapNodeId(1), - query_plan_sink: asap_types::query_plan::QueryNodeId(1), - precompute_sinks: vec![PostAsapNodeId(1)], - }; - let configs = [config]; - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Frozen(&[]), - configs: &configs, - }; - let mut aggregate = node(1); - aggregate.payload = ExecutableOperatorPayload::SummaryAgg { - family, - input: SummaryUpdate::column(ColumnRef::SampleValue), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - }; - for rejected in [-20.0, 0.0, f64::MAX] { - let rows = MaintenanceValue::Rows { - values: [("a", 20.0), ("b", rejected)] - .into_iter() - .map(|(group, value)| { - ( - BTreeMap::from([("instance".into(), group.into())]), - vec![(1000, value)], - ) - }) - .collect(), - name: "value".into(), - timestamped: true, - }; - assert!( - matches!(adapter.execute(&aggregate, &[Arc::new(rows)], test_context()), - Err(error) if error.to_string().contains("positive representable domain")) - ); - } - } - #[test] fn admitted_slow_worker_can_publish_behind_another_workers_replay_floor() { let commits = CommitRegistry::default(); commits.0.lock().unwrap().generation = Some((7, 1)); - let key = |end| MaterializationCommitKey { + let key = |end| PublicationKey { plan_id: 7, plan_version: 1, stored_output: definition(2), @@ -4018,18 +2622,14 @@ mod tests { }; let fast = key(1000); commits.begin_batch([1; 32]).unwrap(); - commits - .commit_if_absent(fast.clone(), Arc::new(MaintenanceValue::summary(sum(2.0)))) - .unwrap(); + commits.commit_if_absent(fast.clone(), sum(2.0)).unwrap(); commits.publish(&fast, || Ok(())).unwrap(); commits.complete_batch([1; 32], &[(fast, 30)]).unwrap(); let slow = key(10); assert!(commits.is_published(&slow).is_err()); commits.begin_batch([2; 32]).unwrap(); commits.pin_admitted(&slow).unwrap(); - commits - .commit_if_absent(slow.clone(), Arc::new(MaintenanceValue::summary(sum(3.0)))) - .unwrap(); + commits.commit_if_absent(slow.clone(), sum(3.0)).unwrap(); commits.publish(&slow, || Ok(())).unwrap(); commits .complete_batch([2; 32], &[(slow.clone(), 30)]) @@ -4043,7 +2643,7 @@ mod tests { #[test] fn maintenance_receipts_are_bounded_and_expired_retries_fail_closed() { let commits = CommitRegistry::default(); - let key = |end| MaterializationCommitKey { + let key = |end| PublicationKey { plan_id: 7, plan_version: 1, stored_output: definition(2), @@ -4054,9 +2654,7 @@ mod tests { for end in (10..=1_000).step_by(10) { let key = key(end); commits.begin_batch([0; 32]).unwrap(); - commits - .commit_if_absent(key.clone(), Arc::new(MaintenanceValue::summary(sum(2.0)))) - .unwrap(); + commits.commit_if_absent(key.clone(), sum(2.0)).unwrap(); commits.publish(&key, || Ok(())).unwrap(); commits.complete_batch([0; 32], &[(key, 30)]).unwrap(); let state = commits.0.lock().unwrap(); @@ -4071,9 +2669,7 @@ mod tests { .is_err()); assert!(commits.is_published(&key(980)).unwrap()); commits.0.lock().unwrap().generation = Some((7, 2)); - assert!(commits - .commit_if_absent(key(1_000), Arc::new(MaintenanceValue::summary(sum(2.0)))) - .is_err()); + assert!(commits.commit_if_absent(key(1_000), sum(2.0)).is_err()); } // Local node IDs are reused in separate query DAGs. Receipts must be scoped @@ -4082,7 +2678,7 @@ mod tests { fn different_materializations_have_independent_publication_receipts() { let commits = CommitRegistry::default(); for target in [2, 3] { - let key = MaterializationCommitKey { + let key = PublicationKey { plan_id: 7, plan_version: 1, stored_output: definition(target), @@ -4091,9 +2687,7 @@ mod tests { input_lineage: vec![0; 32], }; assert!(!commits.is_published(&key).unwrap()); - commits - .commit_if_absent(key.clone(), Arc::new(MaintenanceValue::summary(sum(2.0)))) - .unwrap(); + commits.commit_if_absent(key.clone(), sum(2.0)).unwrap(); commits.publish(&key, || Ok(())).unwrap(); } assert_eq!(commits.0.lock().unwrap().entries.len(), 2); @@ -4151,7 +2745,7 @@ mod tests { .into(); let mut query = node(2); query.output_state = planner_types::post_asap::ExecutionDataState::QUERY_ROWS; - let dag = ExecutableDag { + let dag = PostAsapDag { nodes: vec![node(0), node(1), query], edges: vec![edge(0, 1), edge(1, 2)], root: PostAsapNodeId(2), @@ -4161,7 +2755,7 @@ mod tests { ( PostAsapNodeId(0), BackendNodeBinding::Materialization { - stored_output: definition(1), + stored_output: target_definition, }, ), ( @@ -4185,9 +2779,10 @@ 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(); + OwnedPostAsapDag::from_post_asap_dag("retry".into(), &dag).unwrap(); document.schema_version = asap_types::executable_plan::PRECOMPUTE_DAG_SCHEMA_VERSION; document @@ -4228,7 +2823,7 @@ mod tests { i * step, (i + 1) * step, None, - asap_types::PolicyFingerprint(1), + target_definition.fingerprint(), ), sum(2.0).clone_boxed_core(), ) @@ -4239,7 +2834,7 @@ mod tests { let oversized = (0..65_537) .map(|_| { ( - PrecomputedOutput::new(0, step, None, asap_types::PolicyFingerprint(1)), + PrecomputedOutput::new(0, step, None, target_definition.fingerprint()), sum(2.0).clone_boxed_core(), ) }) @@ -4259,7 +2854,7 @@ mod tests { 999_000, 1_000_000, None, - asap_types::PolicyFingerprint(1), + target_definition.fingerprint(), ), sum(3.0).clone_boxed_core() )]) @@ -4285,152 +2880,28 @@ mod tests { ); } } +} - #[test] - fn shared_summary_node_executes_once_and_summary_over_summary_merges() { - // source 0 is shared by both branches; root therefore contains two - // copies of its value while node 0 itself is evaluated once. - let mut query = node(4); - query.output_state = planner_types::post_asap::ExecutionDataState::QUERY_ROWS; - let dag = ExecutableDag { - nodes: (0..4).map(node).chain([query]).collect(), - edges: vec![edge(0, 1), edge(0, 2), edge(1, 3), edge(2, 3), edge(3, 4)], - root: PostAsapNodeId(4), - }; - let binding = BackendExecutableBinding { - nodes: (0..4) - .map(|id| { - ( - PostAsapNodeId(id), - BackendNodeBinding::Materialization { - stored_output: definition(if id == 0 { 1 } else { id as u64 + 1 }), - }, - ) - }) - .chain([( - PostAsapNodeId(4), - BackendNodeBinding::Query { - query_node: asap_types::query_plan::QueryNodeId(9), - }, - )]) - .collect(), - query_sink: PostAsapNodeId(4), - query_plan_sink: asap_types::query_plan::QueryNodeId(9), - precompute_sinks: vec![PostAsapNodeId(3)], - }; - let (dag, binding) = maintenance_only(dag, binding); - let source = sum(2.0); - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Live { - definition: definition(1), - state: source, - }, - configs: &[], - }; - let commits = CommitRegistry::default(); - let key = MaterializationCommitKey { - plan_id: 7, - plan_version: 2, - stored_output: definition(4), - window_start_ms: 0, - window_end_ms: 10, - input_lineage: b"batch:1".to_vec(), - }; - let result = execute_precompute_sink( - &dag, - &binding, - PostAsapNodeId(3), - key.clone(), - &adapter, - &commits, - test_context(), - ) - .unwrap(); - assert_eq!(result.state().unwrap().aux_stats().sum, Some(4.0)); - commits.publish(&key, || Ok(())).unwrap(); - assert!( - commits.get(&key).unwrap().is_none(), - "accepted payload must not remain in the retry registry" - ); - assert!(commits.is_published(&key).unwrap()); - commits - .publish(&key, || panic!("accepted lineage must not publish twice")) - .unwrap(); - } - - #[test] - fn unsupported_maintenance_operator_propagates_failure_without_commit() { - let mut unsupported = node(1); - unsupported.payload = ExecutableOperatorPayload::SummarySubtract; - let mut query = node(2); - query.output_state = planner_types::post_asap::ExecutionDataState::QUERY_ROWS; - let dag = ExecutableDag { - nodes: vec![node(0), unsupported, query], - edges: vec![edge(0, 1), edge(1, 2)], - root: PostAsapNodeId(2), - }; - let binding = BackendExecutableBinding { - nodes: BTreeMap::from([ - ( - PostAsapNodeId(0), - BackendNodeBinding::Materialization { - stored_output: definition(1), - }, - ), - ( - PostAsapNodeId(1), - BackendNodeBinding::Materialization { - stored_output: definition(2), - }, - ), - ( - PostAsapNodeId(2), - BackendNodeBinding::Query { - query_node: asap_types::query_plan::QueryNodeId(9), - }, - ), - ]), - query_sink: PostAsapNodeId(2), - query_plan_sink: asap_types::query_plan::QueryNodeId(9), - precompute_sinks: vec![PostAsapNodeId(1)], - }; - let (dag, binding) = maintenance_only(dag, binding); - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Live { - definition: definition(1), - state: sum(2.0), - }, - configs: &[], - }; - let commits = CommitRegistry::default(); - let key = MaterializationCommitKey { - plan_id: 7, - plan_version: 2, - stored_output: definition(2), - window_start_ms: 0, - window_end_ms: 10, - input_lineage: b"batch:1".to_vec(), - }; - assert!(matches!( - execute_precompute_sink( - &dag, - &binding, - PostAsapNodeId(1), - key.clone(), - &adapter, - &commits, - test_context() - ), - Err(ScheduleError::Operator(_)) - )); - assert!(commits.get(&key).unwrap().is_none()); - } +fn revision_windows( + width: u64, + cadence: u64, + origin: i64, + starts: impl Iterator, +) -> Result, MaintenanceError> { + if width == 0 || cadence == 0 { + return Err("revision output has zero window extent or cadence".into()); + } + Ok(starts + .filter_map(|start| { + ((start as i128 - origin as i128).rem_euclid(cadence as i128) == 0) + .then(|| start.checked_add(width).map(|end| (start, end))) + .flatten() + }) + .collect()) } /// Captured local input provides fixed population membership for this revision. -/// All compatible sinks share one native execution cache for each output window. +/// Each retained producer graph executes against the frozen inputs for its output window. pub(crate) fn execute_revision_outputs( plan: &crate::storage_engines::types::RuntimePhysicalPlan, raw: &[crate::storage_engines::sketch_db::index::FrozenExactWindows], @@ -4445,11 +2916,13 @@ pub(crate) fn execute_revision_outputs( .as_ref() .ok_or("revision generation missing")?; for installed in plan.precompute_plan.executable_dags.values() { - let mut windows: BTreeMap<(u64, u64), Vec<(PostAsapNodeId, MaterializationCommitKey)>> = - BTreeMap::new(); let mut result: BTreeMap> = BTreeMap::new(); + let mut executed = BTreeSet::new(); for sink in &installed.binding.precompute_sinks { + if executed.contains(sink) { + continue; + } let Some(BackendNodeBinding::Materialization { stored_output: target, }) = installed.binding.node(*sink) @@ -4465,26 +2938,34 @@ pub(crate) fn execute_revision_outputs( let Some(derived) = &config.derived_input else { continue; }; - result.entry(*target).or_default(); + let program = installed + .native_program(*sink)? + .ok_or("installed revision producer lacks its Planner physical graph")?; + for root in program.roots() { + let root = PostAsapNodeId(u32::try_from(*root)?); + executed.insert(root); + let Some(BackendNodeBinding::Materialization { stored_output }) = + installed.binding.node(root) + else { + return Err("physical output storage binding missing".into()); + }; + result.entry(*stored_output).or_default(); + } let inputs: Vec<_> = raw .iter() .filter(|r| derived.inputs.contains(&r.definition)) .collect(); - let width = config.stored_window_ms(); - if width == 0 { - return Err("revision output has zero window extent".into()); - } - let possible: BTreeSet<_> = inputs - .iter() - .flat_map(|i| i.windows.keys()) - .filter_map(|(start, _)| { - ((*start as i128 - config.pane_origin_ms.unwrap_or(0) as i128) - .rem_euclid(width as i128) - == 0) - .then(|| start.checked_add(width).map(|end| (*start, end))) - .flatten() - }) - .collect(); + let possible = revision_windows( + config.stored_window_ms(), + config + .slide_interval + .checked_mul(1000) + .ok_or("revision cadence overflow")?, + config.pane_origin_ms.unwrap_or(0), + inputs + .iter() + .flat_map(|input| input.windows.keys().map(|(start, _)| *start)), + )?; for window in possible { let selected: Vec<_> = inputs .iter() @@ -4520,102 +3001,63 @@ pub(crate) fn execute_revision_outputs( { continue; } - let (_, mut key) = prepare_frozen_maintenance_sink( - installed, - &plan.precompute_plan.materializations, - *sink, - &selected, - window, + let outputs = super::native_precompute::execute( + installed, &program, &selected, window, limit, revision, )?; - // The captured input revision, rather than a sink-specific digest, - // identifies the common evaluation frontier for shared producers. - key.input_lineage = revision.to_be_bytes().to_vec(); - windows.entry(window).or_default().push((*sink, key)); - } - } - let dag = installed.document.decode()?; - for (window, sinks) in windows { - let inputs: Vec<_> = raw - .iter() - .filter_map(|input| { - let windows: BTreeMap<_, _> = input - .windows + for (root, batch) in outputs { + let root = PostAsapNodeId(u32::try_from(root)?); + let Some(BackendNodeBinding::Materialization { + stored_output: target, + }) = installed.binding.node(root) + else { + return Err("physical output storage binding missing".into()); + }; + let config = plan + .precompute_plan + .materializations .iter() - .filter(|((s, e), _)| *s >= window.0 && *e <= window.1) - .map(|(w, s)| (*w, Arc::clone(s))) - .collect(); - (!windows.is_empty()).then(|| { - crate::storage_engines::sketch_db::index::FrozenExactWindows { - stored_output_reference: input.stored_output_reference.clone(), - storage_handle: input.storage_handle, - definition: input.definition, - generation: Arc::clone(&input.generation), - group: input.group.clone(), - windows, - singleton_population_complete: true, + .find(|c| c.policy_fingerprint() == target.fingerprint()) + .ok_or("physical output configuration missing")?; + if asap_physical_operators::physical_planner::precompute::is_population_schema( + batch.schema(), + ) { + for (group, state) in + super::native_precompute::population_states(&batch, window.1)? + { + let payload = encode_state(state, config.accumulator_spec()?.family)?; + if payload.len() > limit { + return Err(asap_physical_operators::Error::MemoryLimit.into()); + } + result.get_mut(target).unwrap().push(RevisionRecord { + reference: plan + .installed_precompute_plan + .stored_output_reference(*target) + .ok_or("precompute output binding missing")?, + group, + start_ms: window.0, + end_ms: window.1, + payload, + }); } - }) - }) - .collect(); - let adapter = OperatorAdapter { - binding: &installed.binding, - inputs: MaintenanceInputs::Captured(&inputs), - configs: &plan.precompute_plan.materializations, - }; - let context = RunContext::new( - asap_physical_operators::dag::Scope::Ingestion { - window_start_ms: window.0 as i64, - window_end_ms: window.1 as i64, - revision, - }, - asap_physical_operators::dag::Limits { - max_bytes: limit, - ..Default::default() - }, - )?; - let values = execute_precompute_sinks( - &dag, - &installed.binding, - &sinks, - &adapter, - &CommitRegistry::default(), - context, - ) - .map_err(schedule_error)?; - for ((_, key), value) in sinks.iter().zip(values) { - let group = match value.as_ref() { - MaintenanceValue::SummaryWindows { states, .. } if states.len() == 1 => { - states.keys().next().unwrap().clone() + continue; } - MaintenanceValue::Summary { .. } => BTreeMap::new(), - _ => { - return Err( - "revision sink must explicitly reduce its output population".into() - ) + let payload = + asap_physical_operators::stored_state::native::encode_batch(&batch)?; + if payload.len() > limit { + return Err(asap_physical_operators::Error::MemoryLimit.into()); } - }; - let config = plan - .precompute_plan - .materializations - .iter() - .find(|c| c.policy_fingerprint() == key.stored_output.fingerprint()) - .ok_or("revision output configuration missing")?; - result - .get_mut(&key.stored_output) - .unwrap() - .push(RevisionRecord { + result.get_mut(target).unwrap().push(RevisionRecord { reference: plan .installed_precompute_plan - .stored_output_reference(key.stored_output) - .ok_or("revision output binding missing")?, - group, + .stored_output_reference(*target) + .ok_or("native revision output has no installed binding")?, + group: BTreeMap::new(), start_ms: window.0, end_ms: window.1, - payload: encode_state( - Arc::clone(value.state()?), - config.accumulator_spec()?.family, - )?, + payload, }); + continue; + } } } for (output, records) in result { diff --git a/data_plane/src/precompute_engine/mod.rs b/data_plane/src/precompute_engine/mod.rs index 2a684d470..d0eb18daf 100644 --- a/data_plane/src/precompute_engine/mod.rs +++ b/data_plane/src/precompute_engine/mod.rs @@ -6,10 +6,10 @@ pub mod group_key; pub mod ingest_handler; pub mod maintenance_runtime; pub(crate) mod metrics; +mod native_precompute; pub mod output_sink; pub mod raw_dag; pub mod series_router; -pub mod subdag_scheduler; pub mod window_manager; pub mod worker; diff --git a/data_plane/src/precompute_engine/native_precompute.rs b/data_plane/src/precompute_engine/native_precompute.rs new file mode 100644 index 000000000..a669e4042 --- /dev/null +++ b/data_plane/src/precompute_engine/native_precompute.rs @@ -0,0 +1,346 @@ +//! Bind a complete durable counter cohort to a Planner-owned precompute 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, + revision: u64, +) -> Result, Box> { + 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 precompute input has no stored binding".into()); + }; + let mut rows = Vec::new(); + let bound_inputs = inputs + .iter() + .filter(|input| input.definition == *stored_output) + .collect::>(); + if bound_inputs.is_empty() { + return Err("native precompute input has no eligible stored population".into()); + } + for input in bound_inputs { + if input.stored_output_reference.stored_output_id != *stored_output + || input.windows.is_empty() + { + return Err( + "native precompute input differs from its stored-output binding".into(), + ); + } + if asap_physical_operators::physical_planner::precompute::is_population_schema( + &contract.schema, + ) { + let family = contract.schema.fields[2].dtype.clone(); + for (&(start, end), state) in &input.windows { + if start < window.0 || end > window.1 || start >= end { + return Err("native precompute pane is outside its bound window".into()); + } + rows.push(vec![ + Value::Map( + input + .group + .iter() + .map(|(k, v)| { + (Value::Utf8(k.clone().into()), Value::Utf8(v.clone().into())) + }) + .collect::>() + .into(), + ), + Value::Timestamp( + i64::try_from(end).map_err(|_| "native pane timestamp overflow")?, + ), + Value::Summary { + family: family.clone(), + state: Arc::clone(state), + }, + ]); + } + continue; + } + let state = input + .windows + .get(&window) + .ok_or("native precompute 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 precompute stored input field".to_string()), + }) + .collect::, String>>()?; + rows.push(row); + } + let batch = Batch::try_new(contract.schema.clone(), rows)?; + input_bytes = input_bytes + .checked_add(batch.bytes()) + .ok_or("native input size overflow")?; + if input_bytes > max_bytes { + return Err(asap_physical_operators::Error::MemoryLimit.into()); + } + sources.insert( + id, + Box::new(Operator::source(contract.schema.clone(), vec![batch])?) as Source<'_>, + ); + } + let graph = program.instantiate(sources)?; + 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, + }, + Limits { + max_bytes, + ..Limits::default() + }, + )?; + // Source buffers and retained publication output share the operator budget. + let _inputs = context.reserve(input_bytes)?; + // Drain all roots together: a bounded shared producer can otherwise block + // while the first root waits for an unpolled sibling to consume its queue. + let streams = graph.execute(program.roots(), context.clone())?; + block_on(async { + let outputs = + futures::future::try_join_all(program.roots().iter().copied().zip(streams).map( + |(root, mut stream)| { + let context = context.clone(); + async move { + let schema = program.output_contract(root)?.schema; + let mut retained = context.reserve(0)?; + let mut rows = Vec::new(); + let mut bytes = 0usize; + while let Some(batch) = stream.next().await { + let batch = batch?; + bytes = bytes + .checked_add(batch.bytes()) + .ok_or(asap_physical_operators::Error::MemoryLimit)?; + retained.resize(bytes)?; + rows.extend(batch.rows().iter().cloned()); + } + let batch = Batch::try_new(schema, rows)?; + Ok::<_, asap_physical_operators::Error>((root, batch, retained)) + } + }, + )) + .await?; + // Keep every root's reservation until all roots have finished. + Ok(outputs + .into_iter() + .map(|(id, batch, _)| (id, batch)) + .collect()) + }) +} + +/// Decode output identities without regrouping or evaluating their states. +pub(super) fn population_states( + batch: &Batch, + window_end: u64, +) -> Result< + Vec<( + BTreeMap, + Arc, + )>, + Box, +> { + if !asap_physical_operators::physical_planner::precompute::is_population_schema(batch.schema()) + { + return Err("precompute output is not a population state batch".into()); + } + let mut result = BTreeMap::new(); + for row in batch.rows() { + let [Value::Map(labels), Value::Timestamp(end), Value::Summary { state, .. }] = + row.as_slice() + else { + return Err("invalid population output row".into()); + }; + if u64::try_from(*end).ok() != Some(window_end) { + return Err("precompute output window differs from publication".into()); + } + let mut group = BTreeMap::new(); + for (key, value) in labels.iter() { + let (Value::Utf8(key), Value::Utf8(value)) = (key, value) else { + return Err("invalid population labels".into()); + }; + if group.insert(key.to_string(), value.to_string()).is_some() { + return Err("duplicate population label".into()); + } + } + if result.insert(group, Arc::clone(state)).is_some() { + return Err("repeated precompute output population".into()); + } + } + Ok(result.into_iter().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use asap_physical_operators::{physical_planner::InputContract, plan::PhysicalOperator}; + use asap_types::executable_plan::{BackendExecutableBinding, OwnedPostAsapDag}; + + // Both persisted roots must receive the same producer result, while a later + // revision gets fresh state. A failed run must return no partial outputs. + #[test] + fn shared_outputs_use_one_producer_and_isolate_revisions() { + use planner_types::post_asap::{ExactKind, ExactParams}; + let family = SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); + let schema = + asap_physical_operators::physical_planner::precompute::population_schema(family); + let merge = Operator::summary_merge(schema.clone(), 2, vec![0]).unwrap(); + let output = merge.output_schema(); + let project = || { + Operator::project( + output.clone(), + output + .fields + .iter() + .enumerate() + .map(|(index, field)| { + ( + field.name.clone(), + asap_physical_operators::expressions::Expression::Column(index), + ) + }) + .collect(), + ) + .unwrap() + }; + let program = CompiledPhysicalDag::from_operators( + BTreeMap::from([(1, InputContract::bounded(schema))]), + BTreeMap::from([ + (2, (vec![1], merge)), + (3, (vec![2], project())), + (4, (vec![2], project())), + ]), + vec![3, 4], + ) + .unwrap(); + let program = CompiledPhysicalDag::decode(&program.encode().unwrap()).unwrap(); + let definition = asap_types::sds::StoredOutputId(1); + let installed = InstalledPostAsapDag { + document: OwnedPostAsapDag { + schema_version: asap_types::executable_plan::PRECOMPUTE_DAG_SCHEMA_VERSION, + query_id: "shared".into(), + nodes: vec![], + edges: vec![], + root: PostAsapNodeId(4), + }, + binding: BackendExecutableBinding { + nodes: BTreeMap::from([( + PostAsapNodeId(1), + BackendNodeBinding::Materialization { + stored_output: definition, + }, + )]), + query_sink: PostAsapNodeId(4), + query_plan_sink: asap_types::executable_plan::QueryNodeId(4), + precompute_sinks: vec![PostAsapNodeId(3), PostAsapNodeId(4)], + }, + native_programs: BTreeMap::new(), + }; + let generation = Arc::new(asap_types::sds::CatalogGeneration { + schema_version: 6, + plan_id: 1, + plan_version: 1, + snapshot_sha256: "0".repeat(64), + }); + let state = |value| { + let mut sum = asap_physical_operators::summary_kernels::SumAccumulator::new(); + sum.update(value); + Arc::new(sum) as Arc + }; + for (revision, value) in [(1, 2.0), (2, 7.0)] { + let inputs = [ + crate::storage_engines::sketch_db::index::FrozenExactWindows { + stored_output_reference: asap_types::sds::StoredOutputReference { + stored_output_id: definition, + definition_id: serde_json::from_value(serde_json::json!(format!( + "sds-v1:{}", + "0".repeat(64) + ))) + .unwrap(), + }, + storage_handle: 1, + definition, + generation: generation.clone(), + group: BTreeMap::new(), + windows: BTreeMap::from([ + ((0, 1000), state(value)), + ((1000, 2000), state(3.0)), + ]), + singleton_population_complete: true, + }, + ]; + let outputs = + execute(&installed, &program, &inputs, (0, 2000), 1 << 20, revision).unwrap(); + assert_eq!(outputs.keys().copied().collect::>(), vec![3, 4]); + let state_at = |root| match &outputs[&root].rows()[0][1] { + Value::Summary { state, .. } => state, + _ => panic!("expected retained summary state"), + }; + assert!( + Arc::ptr_eq(state_at(3), state_at(4)), + "shared merge must execute once, not once per output" + ); + assert_eq!( + state_at(3) + .query_statistic(asap_types::Statistic::Sum, &None, &Default::default(),) + .unwrap(), + value + 3.0 + ); + let error = execute(&installed, &program, &inputs, (0, 2000), 1, revision).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(asap_physical_operators::Error::MemoryLimit) + )); + } + } +} 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/precompute_engine/raw_dag.rs b/data_plane/src/precompute_engine/raw_dag.rs index fac1323a1..993716d09 100644 --- a/data_plane/src/precompute_engine/raw_dag.rs +++ b/data_plane/src/precompute_engine/raw_dag.rs @@ -3,7 +3,7 @@ use crate::storage_engines::types::KeyByLabelValues; use asap_physical_operators::factory::{create_planner_accumulator, AccumulatorUpdater}; use asap_types::{executable_plan::BackendNodeBinding, PrecomputeMaterialization}; use planner_types::post_asap::{ - EdgeRole, ExecutableOperatorPayload, GroupingStrategy, PostAsapNodeId, SummaryFamilyType, + EdgeRole, GroupingStrategy, PostAsapNodeId, PostAsapOperatorPayload, SummaryFamilyType, SummaryInputExpr, SummaryUpdate, }; use planner_types::pre_asap::{ColumnRef, QueryExpr, Source}; @@ -35,7 +35,7 @@ impl RawDagProgram { { continue; } - let ExecutableOperatorPayload::SummaryAgg { + let PostAsapOperatorPayload::SummaryAgg { family, input, grouping, @@ -61,7 +61,7 @@ impl RawDagProgram { .iter() .find(|n| n.id == edge.producer) .ok_or("missing raw DAG input")?; - let ExecutableOperatorPayload::Fallback { expression } = &source.payload else { + let PostAsapOperatorPayload::Fallback { expression } = &source.payload else { return Err("raw producer requires an executable source input; maintenance edges cannot be bypassed".into()); }; let scan = match expression { diff --git a/data_plane/src/precompute_engine/revisions.rs b/data_plane/src/precompute_engine/revisions.rs index 02a624622..8aca75d3d 100644 --- a/data_plane/src/precompute_engine/revisions.rs +++ b/data_plane/src/precompute_engine/revisions.rs @@ -473,6 +473,54 @@ pub(crate) fn decode_state( } } +/// Decode the complete persisted output schema selected by Planner. A native +/// batch may carry many groups; it is not a scalar accumulator record. +pub(crate) fn decode_native_record( + plan: &RuntimePhysicalPlan, + record: &RevisionRecord, + max_bytes: usize, +) -> Result, RevisionError> { + use asap_types::executable_plan::BackendNodeBinding; + let mut expected = None; + for installed in plan.precompute_plan.executable_dags.values() { + for sink in installed.native_programs.keys() { + if !matches!(installed.binding.node(*sink), Some(BackendNodeBinding::Materialization { stored_output }) if *stored_output == record.reference.stored_output_id) + { + continue; + } + let program = installed + .native_program(*sink)? + .ok_or("native output program missing")?; + let schema = program.output_contract(u64::from(sink.0))?.schema; + if asap_physical_operators::physical_planner::precompute::is_population_schema(&schema) + { + continue; + } + if expected + .as_ref() + .is_some_and(|previous| previous != &schema) + { + return Err("shared native output has conflicting schemas".into()); + } + expected = Some(schema); + } + } + let Some(schema) = expected else { + return Ok(None); + }; + if !record.group.is_empty() { + return Err("native revision must contain the whole grouped output".into()); + } + if record.payload.len() > max_bytes { + return Err(asap_physical_operators::Error::MemoryLimit.into()); + } + let batch = native::decode_batch(&record.payload, schema, max_bytes)?; + if batch.bytes() > max_bytes { + return Err(asap_physical_operators::Error::MemoryLimit.into()); + } + Ok(Some(batch)) +} + /// A single local owner serializes input revisions for the installed plan. /// Opening is lazy so activation/recovery does not depend on receiving new data. pub struct RevisionRuntime { @@ -579,7 +627,11 @@ impl RevisionRuntime { .into(), ); } - decode_state(record, config)?; + if decode_native_record(&plan, record, self.policy.max_checkpoint_bytes)? + .is_none() + { + decode_state(record, config)?; + } } } } diff --git a/data_plane/src/precompute_engine/subdag_scheduler.rs b/data_plane/src/precompute_engine/subdag_scheduler.rs deleted file mode 100644 index 6f0d6c40a..000000000 --- a/data_plane/src/precompute_engine/subdag_scheduler.rs +++ /dev/null @@ -1,780 +0,0 @@ -use asap_physical_operators::dag as execution; -use asap_types::executable_plan::{BackendExecutableBinding, BackendNodeBinding}; -use futures::StreamExt; -use planner_types::post_asap::PostAsapNodeId; -use planner_types::post_asap::{ - EdgeRole, ExecutableDag, ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, -}; -use std::{cell::RefCell, rc::Rc}; -use std::{ - collections::{BTreeMap, BTreeSet}, - sync::Arc, -}; - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct MaterializationCommitKey { - pub plan_id: u64, - pub plan_version: u64, - pub stored_output: asap_types::sds::StoredOutputId, - pub window_start_ms: i64, - pub window_end_ms: i64, - /// Producer lineage identity, including source and immutable input payload. - /// Production uses a domain-separated SHA-256 digest to avoid retaining - /// another full copy of every source summary. - pub input_lineage: Vec, -} - -pub trait PrecomputeOperatorRegistry { - type Error; - /// A materialized input is an execution frontier: its absorbed semantic - /// dependencies have already run and must not be evaluated again. - fn materialized_input(&self, _node: &ExecutableDagNode) -> Result, Self::Error> { - Ok(None) - } - fn output_bytes(&self, _value: &V) -> usize { - std::mem::size_of::().max(1) - } - fn execute( - &self, - node: &ExecutableDagNode, - inputs: &[Arc], - context: execution::RunContext, - ) -> Result; -} - -/// Atomic persistence boundary. Implementations must return the already -/// committed value when the same lineage key is replayed after a retry or -/// restart, and must never publish two values for one key. -pub trait IdempotentCommitSink { - type Error; - fn get(&self, key: &MaterializationCommitKey) -> Result>, Self::Error>; - fn commit_if_absent( - &self, - key: MaterializationCommitKey, - value: Arc, - ) -> Result, Self::Error>; -} - -#[derive(Debug)] -pub enum ScheduleError { - Invalid(String), - Operator(OperatorError), - Sink(SinkError), - Execution(execution::Error), -} - -/// Execute one precompute sink and its transitive dependencies in topological -/// order. Intermediates use `Arc`, so a shared upstream node is computed once -/// without copying summary payloads. Only the sink is committed; upstream -/// materialization sinks are committed by their own lineage-keyed invocation. -pub fn execute_precompute_sink( - dag: &ExecutableDag, - binding: &BackendExecutableBinding, - sink_node: PostAsapNodeId, - key: MaterializationCommitKey, - registry: &R, - sink: &S, - context: execution::RunContext, -) -> Result, ScheduleError> -where - R: PrecomputeOperatorRegistry, - S: IdempotentCommitSink, -{ - let mut outputs = - execute_precompute_sinks(dag, binding, &[(sink_node, key)], registry, sink, context)?; - Ok(outputs.remove(0)) -} - -/// Evaluate all selected stored outputs with one dependency cache. Keys must -/// describe the same input revision and window; only their output identity may -/// differ. Validation finishes before executing or committing any output. -pub fn execute_precompute_sinks( - dag: &ExecutableDag, - binding: &BackendExecutableBinding, - outputs: &[(PostAsapNodeId, MaterializationCommitKey)], - registry: &R, - sink: &S, - context: execution::RunContext, -) -> Result>, ScheduleError> -where - R: PrecomputeOperatorRegistry, - S: IdempotentCommitSink, -{ - let mut unique = BTreeSet::new(); - for (node, key) in outputs { - if !unique.insert(node.0) - || !binding.precompute_sinks.contains(node) - || !matches!(binding.node(*node), Some(BackendNodeBinding::Materialization { stored_output }) if *stored_output == key.stored_output) - { - return Err(ScheduleError::Invalid( - "commit key does not match a unique stored output binding".into(), - )); - } - let first = &outputs[0].1; - if ( - key.plan_id, - key.plan_version, - key.window_start_ms, - key.window_end_ms, - &key.input_lineage, - ) != ( - first.plan_id, - first.plan_version, - first.window_start_ms, - first.window_end_ms, - &first.input_lineage, - ) { - return Err(ScheduleError::Invalid( - "stored outputs require one evaluation window and input revision".into(), - )); - } - } - binding - .validate_precompute(dag) - .map_err(ScheduleError::Invalid)?; - let nodes = dag - .nodes - .iter() - .map(|n| (n.id.0, n)) - .collect::>(); - let mut incoming = BTreeMap::>::new(); - for edge in &dag.edges { - incoming.entry(edge.consumer.0).or_default().push(edge); - } - for node in &dag.nodes { - if matches!(node.payload, ExecutableOperatorPayload::Binary { .. }) { - incoming.entry(node.id.0).or_default(); - } - } - let mut inputs = BTreeMap::>::new(); - for (consumer, edges) in incoming { - let ordered = if nodes - .get(&consumer) - .is_some_and(|node| matches!(node.payload, ExecutableOperatorPayload::Binary { .. })) - { - // Wire order is not operand order. Preserve noncommutative binary - // semantics even when a valid transport reorders its edge list. - let left = edges - .iter() - .filter(|edge| edge.role == EdgeRole::Left) - .collect::>(); - let right = edges - .iter() - .filter(|edge| edge.role == EdgeRole::Right) - .collect::>(); - if edges.len() != 2 || left.len() != 1 || right.len() != 1 { - return Err(ScheduleError::Invalid( - "binary maintenance input roles must be exactly Left and Right".into(), - )); - } - vec![left[0].producer.0, right[0].producer.0] - } else { - edges.iter().map(|edge| edge.producer.0).collect() - }; - inputs.insert(consumer, ordered); - } - if outputs.is_empty() { - return Ok(Vec::new()); - } - let error = Rc::new(RefCell::new(None)); - let mut sources = BTreeMap::new(); - for (node, key) in outputs { - if let Some(value) = sink.get(key).map_err(ScheduleError::Sink)? { - sources.insert(node.0, value); - } - } - let committed = sources.keys().copied().collect::>(); - let mut graph = execution::PhysicalDag::default(); - let mut pending = outputs.iter().map(|(id, _)| id.0).collect::>(); - let mut added = BTreeSet::new(); - while let Some(id) = pending.pop() { - if !added.insert(id) { - continue; - } - let node = *nodes - .get(&id) - .ok_or_else(|| ScheduleError::Invalid(format!("missing node {id}")))?; - if node.output_state.timing == planner_types::post_asap::ExecutionTiming::QueryTime { - return Err(ScheduleError::Invalid(format!( - "query-time node {id} in precompute dependency path" - ))); - } - let source = if let Some(value) = sources.remove(&id) { - Some(value) - } else { - registry - .materialized_input(node) - .map_err(ScheduleError::Operator)? - .map(Arc::new) - }; - let children = if source.is_some() { - vec![] - } else { - inputs.get(&id).cloned().unwrap_or_default() - }; - let schemas = children - .iter() - .map(|child| { - nodes - .get(child) - .map(|n| n.output_schema.clone()) - .ok_or_else(|| ScheduleError::Invalid(format!("missing node {child}"))) - }) - .collect::, _>>()?; - pending.extend(children.iter().copied()); - graph - .add( - u64::from(id), - children.into_iter().map(u64::from).collect(), - IngestionOperator { - node, - registry, - source, - schemas, - error: error.clone(), - }, - ) - .map_err(ScheduleError::Execution)?; - } - let roots = outputs - .iter() - .map(|(id, _)| u64::from(id.0)) - .collect::>(); - let streams = graph - .execute(&roots, context) - .map_err(ScheduleError::Execution)?; - futures::executor::block_on(futures::future::try_join_all( - streams - .into_iter() - .zip(outputs) - .map(|(mut stream, (node, key))| { - let error = &error; - let committed = &committed; - async move { - let result = stream.next().await.ok_or_else(|| { - ScheduleError::Invalid("ingestion root produced no value".into()) - })?; - let value = match result { - Ok(value) => Arc::clone(value.value()), - Err(failure) => { - return Err(match error.borrow_mut().take() { - Some(error) => ScheduleError::Operator(error), - None => ScheduleError::Execution(failure), - }) - } - }; - if committed.contains(&node.0) { - Ok(value) - } else { - sink.commit_if_absent(key.clone(), value) - .map_err(ScheduleError::Sink) - } - } - }), - )) -} - -struct IngestionOperator<'a, V, R: PrecomputeOperatorRegistry> { - node: &'a ExecutableDagNode, - registry: &'a R, - source: Option>, - schemas: Vec, - error: Rc>>, -} -impl> - execution::PhysicalOperator, planner_types::post_asap::SummarySchema> - for IngestionOperator<'_, V, R> -{ - fn name(&self) -> &str { - "InstalledIngestionOperator" - } - fn input_schemas(&self) -> Vec { - self.schemas.clone() - } - fn output_schema(&self) -> planner_types::post_asap::SummarySchema { - self.node.output_schema.clone() - } - fn output_bytes(&self, value: &Arc) -> usize { - self.registry.output_bytes(value) - } - fn start<'a>( - &'a self, - inputs: Vec>>, - context: execution::RunContext, - ) -> Result>, execution::Error> { - Ok(futures::stream::once(async move { - if let Some(source) = &self.source { - return Ok(Arc::clone(source)); - } - let inputs = - futures::future::try_join_all(inputs.into_iter().map(|mut input| async move { - input.next().await.ok_or_else(|| { - execution::Error::Operator("ingestion input produced no value".into()) - })? - })) - .await?; - let values = inputs - .iter() - .map(|value| Arc::clone(value.value())) - .collect::>(); - self.registry - .execute(self.node, &values, context) - .map(Arc::new) - .map_err(|e| { - *self.error.borrow_mut() = Some(e); - execution::Error::Operator(format!("ingestion node {} failed", self.node.id.0)) - }) - }) - .boxed_local()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use planner_types::post_asap::{EdgeRole, ExecutionDataState}; - use planner_types::post_asap::{ - ExecutableDagEdge, ExecutableOperatorPayload, GroupingEdgeCompatibility, SummarySchema, - WindowEdgeCompatibility, - }; - use std::sync::Mutex; - - fn test_context() -> execution::RunContext { - execution::RunContext::new( - execution::Scope::Ingestion { - window_start_ms: 10, - window_end_ms: 20, - revision: 1, - }, - execution::Limits::default(), - ) - .unwrap() - } - - // Caller cancellation or exhaustion must terminate without committing state. - #[test] - fn caller_control_terminates_before_publication() { - for cancelled in [true, false] { - let context = execution::RunContext::new( - execution::Scope::Ingestion { - window_start_ms: 10, - window_end_ms: 20, - revision: 1, - }, - execution::Limits { - max_bytes: 1, - ..Default::default() - }, - ) - .unwrap(); - if cancelled { - context.cancel(); - } - let dag = ExecutableDag { - nodes: (0..4).map(node).collect(), - edges: vec![edge(0, 1), edge(1, 3)], - root: PostAsapNodeId(3), - }; - let (dag, binding) = maintenance_only(dag, binding(), PostAsapNodeId(3)); - let registry = Registry::default(); - let sink = Sink::default(); - let result = execute_precompute_sink( - &dag, - &binding, - PostAsapNodeId(3), - key(3), - ®istry, - &sink, - context, - ); - fn cause(error: &execution::Error) -> &execution::Error { - match error { - execution::Error::AtNode { source, .. } => cause(source), - error => error, - } - } - let Err(ScheduleError::Execution(error)) = result else { - panic!("expected a typed execution error, got {result:?}"); - }; - assert_eq!( - cause(&error), - if cancelled { - &execution::Error::Cancelled - } else { - &execution::Error::MemoryLimit - } - ); - assert!(sink.0.lock().unwrap().is_empty()); - if cancelled { - assert!(registry.0.lock().unwrap().is_empty()); - } - } - } - - fn binding() -> BackendExecutableBinding { - BackendExecutableBinding { - nodes: (0..4) - .map(|id| { - ( - PostAsapNodeId(id), - BackendNodeBinding::Materialization { - stored_output: asap_types::PolicyFingerprint(u64::from(id) + 1).into(), - }, - ) - }) - .chain([( - PostAsapNodeId(4), - BackendNodeBinding::Query { - query_node: asap_types::query_plan::QueryNodeId(9), - }, - )]) - .collect(), - query_sink: PostAsapNodeId(4), - query_plan_sink: asap_types::query_plan::QueryNodeId(9), - precompute_sinks: vec![PostAsapNodeId(3)], - } - } - - fn maintenance_only( - mut dag: ExecutableDag, - mut binding: BackendExecutableBinding, - sink: PostAsapNodeId, - ) -> (ExecutableDag, BackendExecutableBinding) { - let retained = dag - .nodes - .iter() - .filter(|node| { - node.output_state.timing == planner_types::post_asap::ExecutionTiming::IngestionTime - }) - .map(|node| node.id) - .collect::>(); - dag.nodes.retain(|node| retained.contains(&node.id)); - dag.edges - .retain(|edge| retained.contains(&edge.producer) && retained.contains(&edge.consumer)); - binding.nodes.retain(|id, _| retained.contains(id)); - dag.root = sink; - (dag, binding) - } - - fn node(id: u32) -> ExecutableDagNode { - ExecutableDagNode { - id: PostAsapNodeId(id), - payload: ExecutableOperatorPayload::SummarySubtract, - output_state: ExecutionDataState::INGESTION_SUMMARY, - output_schema: SummarySchema { - fields: Vec::new(), - time_index: None, - }, - guarantee: None, - } - } - - fn edge(producer: u32, consumer: u32) -> ExecutableDagEdge { - ExecutableDagEdge { - producer: PostAsapNodeId(producer), - consumer: PostAsapNodeId(consumer), - role: EdgeRole::Input, - intermediate_schema: SummarySchema { - fields: Vec::new(), - time_index: None, - }, - data_state: ExecutionDataState::INGESTION_SUMMARY, - grouping: GroupingEdgeCompatibility::Identical, - window: WindowEdgeCompatibility::NotApplicable, - } - } - - #[derive(Default)] - struct Registry(Mutex>); - - impl PrecomputeOperatorRegistry for Registry { - type Error = String; - - fn execute( - &self, - node: &ExecutableDagNode, - inputs: &[Arc], - _context: execution::RunContext, - ) -> Result { - *self.0.lock().unwrap().entry(node.id.0).or_default() += 1; - Ok(node.id.0 + inputs.iter().map(|v| **v).sum::()) - } - } - - #[derive(Default)] - struct Sink(Mutex>>); - - impl IdempotentCommitSink for Sink { - type Error = String; - - fn get(&self, key: &MaterializationCommitKey) -> Result>, Self::Error> { - Ok(self.0.lock().unwrap().get(key).cloned()) - } - - fn commit_if_absent( - &self, - key: MaterializationCommitKey, - value: Arc, - ) -> Result, Self::Error> { - Ok(Arc::clone( - self.0.lock().unwrap().entry(key).or_insert(value), - )) - } - } - - fn key(node_id: u32) -> MaterializationCommitKey { - MaterializationCommitKey { - plan_id: 7, - plan_version: 1, - stored_output: asap_types::PolicyFingerprint(u64::from(node_id) + 1).into(), - window_start_ms: 10, - window_end_ms: 20, - input_lineage: b"checkpoint:3".to_vec(), - } - } - - // Two stored sinks in one evaluation reuse their shared upstream work. - #[test] - fn stored_sinks_share_one_evaluation() { - let mut query = node(4); - query.output_state = ExecutionDataState::QUERY_ROWS; - let dag = ExecutableDag { - nodes: vec![node(0), node(1), node(2), node(3), query], - edges: vec![edge(0, 1), edge(1, 2), edge(1, 3)], - root: PostAsapNodeId(4), - }; - let mut bindings = binding(); - bindings.precompute_sinks = vec![PostAsapNodeId(2), PostAsapNodeId(3)]; - let (dag, bindings) = maintenance_only(dag, bindings, PostAsapNodeId(3)); - let registry = Registry::default(); - let sink = Sink::default(); - execute_precompute_sinks( - &dag, - &bindings, - &[(PostAsapNodeId(2), key(2)), (PostAsapNodeId(3), key(3))], - ®istry, - &sink, - test_context(), - ) - .unwrap(); - assert_eq!( - *registry.0.lock().unwrap(), - BTreeMap::from([(0, 1), (1, 1), (2, 1), (3, 1)]) - ); - } - - #[test] - fn binary_operand_roles_survive_edge_reordering_and_reject_duplicates() { - use planner_types::post_asap::BinaryOperator; - use planner_types::pre_asap::{ArithmeticOpKind, BinaryOpKind}; - struct Subtract; - impl PrecomputeOperatorRegistry for Subtract { - type Error = String; - fn execute( - &self, - node: &ExecutableDagNode, - inputs: &[Arc], - _context: execution::RunContext, - ) -> Result { - match node.id.0 { - 0 => Ok(10), - 1 => Ok(3), - 3 => Ok(*inputs[0] - *inputs[1]), - _ => Err("unexpected node".into()), - } - } - } - let mut binary = node(3); - binary.payload = ExecutableOperatorPayload::Binary { - operator: BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Sub), - vector_match: None, - }, - }; - let mut left = edge(0, 3); - left.role = EdgeRole::Left; - let mut right = edge(1, 3); - right.role = EdgeRole::Right; - let mut dag = ExecutableDag { - nodes: vec![node(0), node(1), node(2), binary, { - let mut query = node(4); - query.output_state = ExecutionDataState::QUERY_ROWS; - query - }], - edges: vec![right, left], - root: PostAsapNodeId(3), - }; - let execute = |dag: &ExecutableDag| { - let (dag, binding) = maintenance_only(dag.clone(), binding(), PostAsapNodeId(3)); - execute_precompute_sink( - &dag, - &binding, - PostAsapNodeId(3), - key(3), - &Subtract, - &Sink::default(), - test_context(), - ) - }; - assert_eq!(*execute(&dag).unwrap(), 7); - dag.edges.reverse(); - assert_eq!(*execute(&dag).unwrap(), 7); - dag.edges[1].role = EdgeRole::Left; - assert!(matches!(execute(&dag), Err(ScheduleError::Invalid(_)))); - dag.edges.pop(); - assert!(matches!(execute(&dag), Err(ScheduleError::Invalid(_)))); - dag.edges.clear(); - assert!(matches!(execute(&dag), Err(ScheduleError::Invalid(_)))); - } - - #[test] - fn shared_dependency_executes_once_and_replay_reads_committed_value() { - // 0 is shared by 1 and 2; 3 consumes both branches. - let dag = ExecutableDag { - nodes: (0..4) - .map(node) - .chain([{ - let mut query = node(4); - query.output_state = ExecutionDataState::QUERY_ROWS; - query - }]) - .collect(), - edges: vec![edge(0, 1), edge(0, 2), edge(1, 3), edge(2, 3), edge(3, 4)], - root: PostAsapNodeId(4), - }; - let registry = Registry::default(); - let sink = Sink::default(); - let (dag, binding) = maintenance_only(dag, binding(), PostAsapNodeId(3)); - let first = execute_precompute_sink( - &dag, - &binding, - PostAsapNodeId(3), - key(3), - ®istry, - &sink, - test_context(), - ) - .unwrap(); - assert_eq!(*first, 6); - assert_eq!(registry.0.lock().unwrap().values().sum::(), 4); - - let replay = execute_precompute_sink( - &dag, - &binding, - PostAsapNodeId(3), - key(3), - ®istry, - &sink, - test_context(), - ) - .unwrap(); - assert!(Arc::ptr_eq(&first, &replay)); - assert_eq!(registry.0.lock().unwrap().values().sum::(), 4); - } - - #[test] - fn supplied_materialization_cuts_absorbed_dependencies_and_is_shared() { - struct FrontierRegistry(Registry); - impl PrecomputeOperatorRegistry for FrontierRegistry { - type Error = String; - fn materialized_input(&self, node: &ExecutableDagNode) -> Result, String> { - Ok((node.id == PostAsapNodeId(1)).then_some(10)) - } - fn execute( - &self, - node: &ExecutableDagNode, - inputs: &[Arc], - context: execution::RunContext, - ) -> Result { - assert_ne!( - node.id, - PostAsapNodeId(0), - "absorbed source subtree must not execute" - ); - self.0.execute(node, inputs, context) - } - } - let mut raw = node(0); - raw.output_state = ExecutionDataState::QUERY_ROWS; - let mut query = node(4); - query.output_state = ExecutionDataState::QUERY_ROWS; - let dag = ExecutableDag { - nodes: vec![raw, node(1), node(2), node(3), query], - edges: vec![edge(0, 1), edge(1, 2), edge(1, 3), edge(2, 3), edge(3, 4)], - root: PostAsapNodeId(4), - }; - let mut bindings = binding(); - bindings - .nodes - .insert(PostAsapNodeId(0), BackendNodeBinding::QueryInput); - let (dag, bindings) = maintenance_only(dag, bindings, PostAsapNodeId(3)); - let registry = FrontierRegistry(Registry::default()); - let sink = Sink::default(); - let result = execute_precompute_sink( - &dag, - &bindings, - PostAsapNodeId(3), - key(3), - ®istry, - &sink, - test_context(), - ) - .unwrap(); - assert_eq!(*result, 25); - assert_eq!( - *registry.0 .0.lock().unwrap(), - BTreeMap::from([(2, 1), (3, 1)]) - ); - } - - #[test] - fn rejects_query_node_in_precompute_path_and_mismatched_lineage_key() { - let mut query_child = node(0); - query_child.output_state = ExecutionDataState::QUERY_ROWS; - let dag = ExecutableDag { - nodes: vec![query_child, node(1)], - edges: vec![edge(0, 1)], - root: PostAsapNodeId(0), - }; - let registry = Registry::default(); - let sink = Sink::default(); - let invalid_path_binding = BackendExecutableBinding { - nodes: [ - ( - PostAsapNodeId(0), - BackendNodeBinding::Query { - query_node: asap_types::query_plan::QueryNodeId(1), - }, - ), - ( - PostAsapNodeId(1), - BackendNodeBinding::Materialization { - stored_output: asap_types::PolicyFingerprint(2).into(), - }, - ), - ] - .into_iter() - .collect(), - query_sink: PostAsapNodeId(0), - query_plan_sink: asap_types::query_plan::QueryNodeId(1), - precompute_sinks: vec![PostAsapNodeId(1)], - }; - assert!(matches!( - execute_precompute_sink(&dag, &invalid_path_binding, PostAsapNodeId(1), key(1), ®istry, &sink, test_context()), - Err(ScheduleError::Invalid(message)) if message.contains("query-owned node") - )); - let mut summary_dag = dag.clone(); - summary_dag.nodes[0].output_state.primitive = - planner_types::post_asap::DataPrimitive::SummaryState; - assert!(matches!( - execute_precompute_sink(&summary_dag, &invalid_path_binding, PostAsapNodeId(1), key(1), ®istry, &sink, test_context()), - Err(ScheduleError::Invalid(message)) if message.contains("query-owned node") - )); - assert!(matches!( - execute_precompute_sink(&dag, &invalid_path_binding, PostAsapNodeId(1), key(0), ®istry, &sink, test_context()), - Err(ScheduleError::Invalid(message)) if message.contains("does not match") - )); - } -} diff --git a/data_plane/src/precompute_engine/worker.rs b/data_plane/src/precompute_engine/worker.rs index 5d71f6645..f2da34982 100644 --- a/data_plane/src/precompute_engine/worker.rs +++ b/data_plane/src/precompute_engine/worker.rs @@ -4707,14 +4707,13 @@ mod dag_execution_tests { let installed = plan.executable_dags.values_mut().next().unwrap(); let mut dag = installed.document.decode().unwrap(); for node in &mut dag.nodes { - if let planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { - input, .. - } = &mut node.payload + if let planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { input, .. } = + &mut node.payload { input.weight = planner_types::post_asap::SummaryInputExpr::Constant(99.0); } } - installed.document = asap_types::executable_plan::OwnedPostAsapDag::from_executable( + installed.document = asap_types::executable_plan::OwnedPostAsapDag::from_post_asap_dag( installed.document.query_id.clone(), &dag, ) 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 d1c2e7cd8..709372075 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 @@ -620,50 +620,113 @@ mod tests { }, inputs: vec![], }); - entry.nodes.insert( - join, - QueryPlanNode::RelationalJoin { - inputs: [left, external], - join_kind: planner_types::pre_asap::JoinKind::Inner, - pruning: None, - pred: serde_json::to_value(Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), - op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Column(2)), - }))) - .unwrap(), - left_schema, - right_schema, + entry.nodes.insert(join, { + let schemas = vec![ + std::sync::Arc::new(left_schema), + std::sync::Arc::new(right_schema), + ]; + let node = planner_types::post_asap::PostAsapDagNode { + id: planner_types::post_asap::PostAsapNodeId(2), + payload: planner_types::post_asap::PostAsapOperatorPayload::RelationalJoin { + join_kind: planner_types::pre_asap::JoinKind::Inner, + pred: serde_json::from_value( + serde_json::to_value(Predicate(Rc::new(QueryExpr::Compare { + left: Rc::new(QueryExpr::Column(0)), + op: CompareOpKind::Eq, + right: Rc::new(QueryExpr::Column(2)), + }))) + .unwrap(), + ) + .unwrap(), + pruning: None, + }, + output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, output_schema: joined_schema.clone(), - }, - ); - entry.nodes.insert( - project, - QueryPlanNode::Relational { - input: join, - operation: serde_json::to_value(ValueOperation::Project { - cols: vec![ - ProjectItem { - alias: Some("timestamp".into()), - expr: QueryExpr::Column(0), - }, - ProjectItem { - alias: Some("ratio".into()), - expr: QueryExpr::Arithmetic { - op: ArithmeticOpKind::Div, - left: Rc::new(QueryExpr::Column(1)), - right: Rc::new(QueryExpr::Column(3)), - }, - }, - ], - qualifier: None, - }) - .unwrap(), - input_schema: joined_schema, - output_schema, - }, - ); + guarantee: None, + }; + let operator = + asap_physical_operators::physical_planner::compile_node(&node, &schemas).unwrap(); + let compiled = + asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + schemas + .into_iter() + .enumerate() + .map(|(id, schema)| { + ( + id as u64, + asap_physical_operators::physical_planner::InputContract::bounded( + schema, + ), + ) + }) + .collect(), + [(2, ((0..2).collect(), operator))].into(), + vec![2], + ) + .unwrap(); + QueryPlanNode::PhysicalRelation { + inputs: [left, external].to_vec(), + dag: compiled.encode().unwrap(), + } + }); + entry.nodes.insert(project, { + let schemas = vec![std::sync::Arc::new(joined_schema)]; + let node = planner_types::post_asap::PostAsapDagNode { + id: planner_types::post_asap::PostAsapNodeId(2), + payload: planner_types::post_asap::PostAsapOperatorPayload::Value { + operation: serde_json::from_value( + serde_json::to_value(ValueOperation::Project { + cols: vec![ + ProjectItem { + alias: Some("timestamp".into()), + expr: QueryExpr::Column(0), + }, + ProjectItem { + alias: Some("ratio".into()), + expr: QueryExpr::Arithmetic { + op: ArithmeticOpKind::Div, + left: Rc::new(QueryExpr::Column(1)), + right: Rc::new(QueryExpr::Column(3)), + }, + }, + ], + qualifier: None, + }) + .unwrap(), + ) + .unwrap(), + }, + output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, + output_schema: output_schema, + guarantee: None, + }; + let operator = + asap_physical_operators::physical_planner::compile_node(&node, &schemas).unwrap(); + let compiled = + asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + schemas + .into_iter() + .enumerate() + .map(|(id, schema)| { + ( + id as u64, + asap_physical_operators::physical_planner::InputContract::bounded( + schema, + ), + ) + }) + .collect(), + [(2, ((0..1).collect(), operator))].into(), + vec![2], + ) + .unwrap(); + QueryPlanNode::PhysicalRelation { + inputs: vec![join], + dag: compiled.encode().unwrap(), + } + }); entry.root = project; + entry } @@ -683,6 +746,47 @@ mod tests { } } + fn physical_relation_chain( + source: QueryNodeId, + schema: SummarySchema, + operations: Vec<(ValueOperation, SummarySchema)>, + ) -> QueryPlanNode { + use asap_physical_operators::physical_planner::{ + compile_node, CompiledPhysicalDag, InputContract, + }; + use planner_types::post_asap::{ + ExecutionDataState, PostAsapDagNode, PostAsapNodeId, PostAsapOperatorPayload, + }; + let input = std::sync::Arc::new(schema); + let mut previous_schema = input.clone(); + let mut previous = 0; + let mut nodes = BTreeMap::new(); + for (index, (operation, output_schema)) in operations.into_iter().enumerate() { + let id = index as u64 + 1; + let node = PostAsapDagNode { + id: PostAsapNodeId(id as u32), + payload: PostAsapOperatorPayload::Value { operation }, + output_state: ExecutionDataState::QUERY_ROWS, + output_schema: output_schema.clone(), + guarantee: None, + }; + let operator = compile_node(&node, &[previous_schema]).unwrap(); + nodes.insert(id, (vec![previous], operator)); + previous = id; + previous_schema = std::sync::Arc::new(output_schema); + } + let physical = CompiledPhysicalDag::from_operators( + [(0, InputContract::bounded(input))].into(), + nodes, + vec![previous], + ) + .unwrap(); + QueryPlanNode::PhysicalRelation { + inputs: vec![source], + dag: physical.encode().unwrap(), + } + } + async fn fixture(end_ms: u64) -> (CatalogClickHouseAccelerator, ClickHouseQueryRequest) { fixture_with_store(end_ms, Arc::new(SketchStore::new()), true).await } @@ -737,11 +841,9 @@ mod tests { ("bucket", DataType::Timestamp), ("score", DataType::Float64), ]); - let filter = QueryNodeId(2); - let project = QueryNodeId(3); - 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(), @@ -774,24 +876,13 @@ mod tests { readout: ExactReadout::Sum, }, ), - ( - filter, - QueryPlanNode::Relational { - input: readout, - operation: serde_json::json!({"Filter": {"pred": Predicate(Rc::new(QueryExpr::Compare { + (root, physical_relation_chain(readout, input_schema.clone(), vec![ + (serde_json::from_value(serde_json::json!({"Filter": {"pred": Predicate(Rc::new(QueryExpr::Compare { left: Rc::new(QueryExpr::Column(1)), op: CompareOpKind::Gt, right: Rc::new(QueryExpr::Literal(ScalarValue::Float64(1.0))), - }))}}), - input_schema: input_schema.clone(), - output_schema: input_schema.clone(), - }, - ), - ( - project, - QueryPlanNode::Relational { - input: filter, - operation: serde_json::to_value(ValueOperation::Project { + }))}})).unwrap(), input_schema.clone()), + (serde_json::from_value(serde_json::to_value(ValueOperation::Project { cols: vec![ ProjectItem { alias: Some("bucket".into()), @@ -810,16 +901,8 @@ mod tests { ], qualifier: None, }) - .unwrap(), - input_schema: input_schema, - output_schema: projected_schema.clone(), - }, - ), - ( - sort, - QueryPlanNode::Relational { - input: project, - operation: serde_json::to_value(ValueOperation::Sort { + .unwrap()).unwrap(), projected_schema.clone()), + (serde_json::from_value(serde_json::to_value(ValueOperation::Sort { keys: vec![SortKey { expr: QueryExpr::Column(1), ascending: false, @@ -827,21 +910,10 @@ mod tests { }], partition_by: GroupKeys::none(), }) - .unwrap(), - input_schema: projected_schema.clone(), - output_schema: projected_schema.clone(), - }, - ), - ( - root, - QueryPlanNode::Relational { - input: sort, - operation: serde_json::to_value(ValueOperation::Limit { n: 1, offset: 0, partition_by: planner_types::pre_asap::GroupKeys::none() }) - .unwrap(), - input_schema: projected_schema.clone(), - output_schema: projected_schema, - }, - ), + .unwrap()).unwrap(), projected_schema.clone()), + (serde_json::from_value(serde_json::to_value(ValueOperation::Limit { n: 1, offset: 0, partition_by: planner_types::pre_asap::GroupKeys::none() }) + .unwrap()).unwrap(), projected_schema), + ])), ] .into_iter() .collect(), @@ -875,7 +947,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, @@ -889,6 +962,7 @@ mod tests { instant: executable.instant, fallback: executable.fallback, }; + let query_plan = QueryPlan { plan_id: 41, plan_version: 1, @@ -1125,7 +1199,7 @@ mod tests { // A publication made before time templates keeps its fixed lookup semantics. #[tokio::test] - async fn legacy_bounded_sql_plan_still_executes() { + async fn fixed_window_physical_plan_executes_without_a_template() { let (accelerator, mut request) = fixture_with_sql(1_000, Arc::new(SketchStore::new()), true, true).await; let active = accelerator.active_physical_plan.as_ref().unwrap(); @@ -1156,7 +1230,7 @@ mod tests { let ClickHouseAccelerationOutcome::Accelerated(response) = accelerator.execute(&request).await else { - panic!("old fixed identity must remain executable"); + panic!("fixed-window physical plan must execute without a template"); }; assert_eq!(response.body, "1970-01-01T00:00:01\t20.0\n"); request.sql = 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 27edd68ae..81afff3bf 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 @@ -119,118 +119,48 @@ impl RelationDagExecutor<'_> { ) -> Result { use super::relational_adapter::native; use asap_physical_operators::dag::{self, operators::Operator}; + use asap_physical_operators::physical_planner::{CompiledPhysicalDag, InputContract}; 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()), - ], - ) + let (compiled, source_bindings) = match self.entry.nodes.get(&root) { + Some(QueryPlanNode::PhysicalRelation { inputs, dag }) => { + let compiled = CompiledPhysicalDag::decode(dag)?; + if compiled.roots().len() != 1 || compiled.input_contracts().count() != inputs.len() + { + return Err("physical relation boundary arity mismatch".into()); } - Some(_) => { - sources.push(id); - continue; + if compiled + .output_contract(compiled.roots()[0])? + .schema + .as_ref() + != expected + { + return Err("physical relation output schema mismatch".into()); } - 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())?; + let bindings = compiled + .input_contracts() + .zip(inputs) + .map(|((slot, contract), id)| (slot, *id, contract.schema.clone())) + .collect::>(); + (compiled, bindings) + } + Some(QueryPlanNode::ExternalExact { .. }) => { + let schema = Arc::new(expected.clone()); + ( + CompiledPhysicalDag::from_operators( + [(root.0, InputContract::bounded(schema.clone()))].into(), + BTreeMap::new(), + vec![root.0], + )?, + vec![(root.0, root, schema)], + ) + } + _ => return Err("SQL execution requires a Planner-compiled physical relation".into()), + }; + let sources = source_bindings + .iter() + .map(|(_, id, _)| *id) + .collect::>(); let mut resolved_inputs = BTreeMap::new(); let context = crate::query_engines::request::context(dag::Scope::Query { evaluation_time_ms: i64::try_from(self.t1_ms) @@ -263,8 +193,8 @@ impl RelationDagExecutor<'_> { ).map_err(SqlExecutionError::from)?; let mut coverage = None; let mut first = true; - for id in sources { - let relation = self.execute_source(id, &schemas[&id], &stored)?; + for (slot, id, schema) in source_bindings { + let relation = self.execute_source(id, &schema, &stored)?; coverage = if first { first = false; relation.coverage @@ -276,9 +206,9 @@ impl RelationDagExecutor<'_> { _ => None, } }; - let batch = native::batch(&relation, &schemas[&id]).map_err(|e| e.to_string())?; + let batch = native::batch(&relation, &schema).map_err(|e| e.to_string())?; resolved_inputs.insert( - id.0, + slot, Box::new( Operator::source(batch.schema().clone(), vec![batch]) .map_err(|e| e.to_string())?, @@ -288,7 +218,7 @@ impl RelationDagExecutor<'_> { let graph = compiled .instantiate(resolved_inputs) .map_err(|e| e.to_string())?; - let mut output = graph.execute(&[root.0], context)?.remove(0); + let mut output = graph.execute(compiled.roots(), context)?.remove(0); let mut batches = Vec::new(); loop { crate::query_engines::request::check()?; @@ -449,18 +379,26 @@ fn execute_sql_dag_with_external_unfenced( ids.iter().any(|id| { matches!( entry.nodes.get(id), - Some( - QueryPlanNode::Relational { .. } - | QueryPlanNode::RelationalJoin { .. } - | QueryPlanNode::ExternalExact { .. } - ) + Some(QueryPlanNode::PhysicalRelation { .. } | QueryPlanNode::ExternalExact { .. }) ) }) }); if has_relational_join { let root_schema = match entry.nodes.get(&entry.root) { - Some(QueryPlanNode::Relational { output_schema, .. }) - | Some(QueryPlanNode::RelationalJoin { output_schema, .. }) => output_schema.clone(), + Some(QueryPlanNode::PhysicalRelation { dag, .. }) => { + match asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) + .and_then(|compiled| { + let root = compiled.roots().first().ok_or_else(|| { + asap_physical_operators::dag::Error::Invalid( + "physical relation has no root".into(), + ) + })?; + compiled.output_contract(*root) + }) { + Ok(contract) => contract.schema.as_ref().clone(), + Err(error) => return ClickHouseDagOutcome::Failed(error), + } + } Some(QueryPlanNode::ExternalExact { request, .. }) => { let asap_types::query_plan::ExternalExactOutput::Relation { schema } = &request.output @@ -628,6 +566,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(), @@ -753,30 +692,76 @@ mod tests { assert!(error.to_string().contains("inconsistent relation schemas")); } + fn physical_cross_join( + inputs: [QueryNodeId; 2], + schema: &planner_types::post_asap::SummarySchema, + output: &planner_types::post_asap::SummarySchema, + ) -> QueryPlanNode { + use asap_physical_operators::physical_planner::{ + compile_node, CompiledPhysicalDag, InputContract, + }; + use planner_types::post_asap::{ + ExecutionDataState, PostAsapDagNode, PostAsapNodeId, PostAsapOperatorPayload, + }; + use planner_types::pre_asap::{JoinKind, Predicate, QueryExpr, ScalarValue}; + let schema = std::sync::Arc::new(schema.clone()); + let op = compile_node( + &PostAsapDagNode { + id: PostAsapNodeId(100), + payload: PostAsapOperatorPayload::RelationalJoin { + join_kind: JoinKind::Cross, + pred: Predicate(QueryExpr::Literal(ScalarValue::Boolean(true)).into()), + pruning: None, + }, + output_state: ExecutionDataState::QUERY_ROWS, + output_schema: output.clone(), + guarantee: None, + }, + &[schema.clone(), schema.clone()], + ) + .unwrap(); + let physical = CompiledPhysicalDag::from_operators( + inputs + .iter() + .map(|id| (id.0, InputContract::bounded(schema.clone()))) + .collect(), + [(100, (inputs.iter().map(|id| id.0).collect(), op))].into(), + vec![100], + ) + .unwrap(); + QueryPlanNode::PhysicalRelation { + inputs: physical + .input_contracts() + .map(|(id, _)| QueryNodeId(id)) + .collect(), + dag: physical.encode().unwrap(), + } + } + // A SQL diamond binds one source to both join inputs in the shared DAG. #[test] fn relation_dag_executes_a_shared_source_join() { - use planner_types::pre_asap::{JoinKind, Predicate, QueryExpr, ScalarValue}; let schema = relation_schema("x"); let mut entry = external_entry(&schema); let mut output = schema.clone(); output.fields.push(schema.fields[0].clone()); entry.nodes.insert( QueryNodeId(1), - QueryPlanNode::RelationalJoin { - inputs: [QueryNodeId(0), QueryNodeId(0)], - join_kind: JoinKind::Cross, - pred: serde_json::to_value(Predicate::( - QueryExpr::Literal(ScalarValue::Boolean(true)).into(), - )) - .unwrap(), - left_schema: schema.clone(), - right_schema: schema.clone(), - output_schema: output.clone(), - pruning: None, - }, + physical_cross_join([QueryNodeId(0), QueryNodeId(0)], &schema, &output), ); entry.root = QueryNodeId(1); + entry.validate(&BTreeSet::new()).unwrap(); + let entry: QueryPlanEntry = + serde_json::from_slice(&serde_json::to_vec(&entry).unwrap()).unwrap(); + let QueryPlanNode::PhysicalRelation { inputs, .. } = &entry.nodes[&entry.root] else { + unreachable!() + }; + assert_eq!( + inputs, + &[QueryNodeId(0)], + "both join edges share one physical input slot" + ); + let relation = ClickHouseRelation::from_json_compact( &schema, br#"{"meta":[{"name":"x","type":"Int64"}],"data":[[1],[2]]}"#, @@ -813,7 +798,6 @@ mod tests { /// A publication between query branches invalidates the entire result. #[test] fn query_wide_fence_rejects_publication_between_join_branches() { - use planner_types::pre_asap::{JoinKind, Predicate, QueryExpr, ScalarValue}; let schema = relation_schema("x"); let mut entry = external_entry(&schema); entry @@ -823,18 +807,7 @@ mod tests { output.fields.push(schema.fields[0].clone()); entry.nodes.insert( QueryNodeId(1), - QueryPlanNode::RelationalJoin { - inputs: [QueryNodeId(0), QueryNodeId(2)], - join_kind: JoinKind::Cross, - pred: serde_json::to_value(Predicate::( - QueryExpr::Literal(ScalarValue::Boolean(true)).into(), - )) - .unwrap(), - left_schema: schema.clone(), - right_schema: schema.clone(), - output_schema: output, - pruning: None, - }, + physical_cross_join([QueryNodeId(0), QueryNodeId(2)], &schema, &output), ); entry.root = QueryNodeId(1); let relation = ClickHouseRelation::from_json_compact( diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs index 9b55f7ea0..dd2e32d0a 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs @@ -17,7 +17,7 @@ use arrow::{ }; use chrono::{DateTime, NaiveDateTime, TimeZone}; use planner_types::{ - post_asap::{SummaryFamilyType, SummarySchema, ValueOperation}, + post_asap::{SummaryFamilyType, SummarySchema}, pre_asap::DataType, }; @@ -398,8 +398,13 @@ fn clickhouse_type_matches(actual: Option<&str>, expected: &DataType, nullable: } } +#[cfg(test)] +use planner_types::post_asap::ValueOperation; + +#[cfg(test)] pub struct ClickHouseRelationalAdapter; +#[cfg(test)] impl ClickHouseRelationalAdapter { pub fn apply_join( &self, @@ -410,7 +415,7 @@ impl ClickHouseRelationalAdapter { right: ClickHouseRelation, ) -> Result { native::execute( - planner_types::post_asap::ExecutableOperatorPayload::RelationalJoin { + planner_types::post_asap::PostAsapOperatorPayload::RelationalJoin { join_kind: kind.clone(), pred: pred.clone(), pruning: None, @@ -440,7 +445,7 @@ impl ClickHouseRelationalAdapter { input: ClickHouseRelation, ) -> Result { native::execute( - planner_types::post_asap::ExecutableOperatorPayload::Value { + planner_types::post_asap::PostAsapOperatorPayload::Value { operation: operation.clone(), }, output_schema, diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs index 1888e30c6..3604f74f5 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs @@ -4,10 +4,7 @@ use asap_physical_operators::dag::{ self, values::{Batch, Value}, }; -use planner_types::post_asap::{ - ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, - SummaryFamilyType, SummaryField, SummarySchema, -}; +use planner_types::post_asap::{SummaryFamilyType, SummaryField, SummarySchema}; use std::sync::Arc; fn error(error: impl std::fmt::Display) -> ClickHouseRelationalError { ClickHouseRelationalError::Invalid(error.to_string()) @@ -62,8 +59,14 @@ pub(super) fn cell(value: &Value) -> Result { _ => return Err(error("native value has no ClickHouse result transport")), }) } +#[cfg(test)] +use planner_types::post_asap::{ + ExecutionDataState, PostAsapDagNode, PostAsapNodeId, PostAsapOperatorPayload, +}; + +#[cfg(test)] pub(crate) fn execute( - payload: ExecutableOperatorPayload, + payload: PostAsapOperatorPayload, output: &SummarySchema, inputs: Vec, ) -> Result { @@ -89,7 +92,7 @@ pub(crate) fn execute( }) .collect::, _>>() .map_err(error)?; - let node = ExecutableDagNode { + let node = PostAsapDagNode { id: PostAsapNodeId(0), payload, output_state: ExecutionDataState::QUERY_ROWS, 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 9a72bfcd2..ad016dfc7 100644 --- a/data_plane/src/query_engines/asap_query_engine/engine.rs +++ b/data_plane/src/query_engines/asap_query_engine/engine.rs @@ -119,6 +119,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, @@ -311,6 +312,23 @@ impl ASAPQueryEngine { self } + fn request_limits( + &self, + entry: &asap_types::query_plan::QueryPlanEntry, + ) -> asap_physical_operators::dag::Limits { + let mut limits = self.execution_limits.clone(); + if let Some(bytes) = entry + .physical_vector_binding() + .map(|(_, _, bytes)| bytes) + .or_else(|| entry.population_snapshot().map(|p| p.max_bytes)) + { + limits.max_bytes = limits + .max_bytes + .min(usize::try_from(bytes).unwrap_or(usize::MAX)); + } + limits + } + async fn execute_installed_instant( &self, physical: &crate::storage_engines::types::RuntimePhysicalPlan, @@ -321,7 +339,7 @@ impl ASAPQueryEngine { let engine = self.clone(); let physical = physical.clone(); let entry = entry.clone(); - crate::query_engines::request::run(self.execution_limits.clone(), move |handle| { + crate::query_engines::request::run(self.request_limits(&entry), move |handle| { handle.block_on(async { entry.validate_snapshot_sources().map_err(|e| { crate::query_engines::EngineError::capability_miss("query_plan", e.to_string()) @@ -495,11 +513,29 @@ impl ASAPQueryEngine { .flatten(); let index = pinned.as_ref().or(self.summary_store.as_deref()); let revision = index.map(|index| index.summary_update_revision()); - let result = super::logical_dag::execute_installed( - entry, - leaves, - at, - |root, evaluation_ms| { + let native_stored = entry + .physical_vector_binding() + .is_some_and(|(inputs, _, _)| { + inputs.iter().all(|id| { + matches!( + entry.nodes.get(id), + Some(asap_types::query_plan::QueryPlanNode::ReadMaterialization { .. }) + ) + }) + }); + let result = if native_stored { + let store = index.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::query_time::QueryTimeOperator::CurrentSeries { @@ -611,8 +647,8 @@ impl ASAPQueryEngine { evaluation_ms, false, )) - }, - ); + }) + }; let current = index.map(|index| index.summary_update_revision()); finish_query( result, @@ -636,7 +672,7 @@ impl ASAPQueryEngine { let engine = self.clone(); let physical = physical.clone(); let entry = entry.clone(); - crate::query_engines::request::run(self.execution_limits.clone(), move |handle| { + crate::query_engines::request::run(self.request_limits(&entry), move |handle| { handle.block_on(engine.execute_logical_range_inner(&physical, &entry, start, end, step)) }) .await @@ -2855,6 +2891,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(), @@ -2879,6 +2916,12 @@ mod range_stitch_tests { fallback: FallbackPolicy::ExactBackend, }, ); + let key = + asap_types::query_plan::QueryPlan::catalog_key(QueryLanguage::MetricsQl, &identity); + control_plane::query_plan::physical_values::compile( + plan.query_plan.entries.get_mut(&key).unwrap(), + ) + .unwrap(); let mut active = crate::drivers::query::servers::http::validate_and_build_runtime_plan( crate::drivers::query::servers::http::PhysicalPlanInstallRequest { summary_catalog: plan.summary_catalog, @@ -2903,7 +2946,7 @@ mod range_stitch_tests { assert!( error .to_string() - .contains("bound subtree requires one explicit positive window"), + .contains("scalar root requires native response adapter"), "{error}" ); } 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 1b5fef65d..f583463c0 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,12 +87,11 @@ 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, .. } => { - pending.extend(inputs.iter().map(|input| (*input, at))); - } - QueryPlanNode::RelationalJoin { inputs, .. } => { + QueryPlanNode::PhysicalFragment { inputs, .. } + | QueryPlanNode::Physical { inputs, .. } => { pending.extend(inputs.iter().map(|input| (*input, at))); } + QueryPlanNode::ExternalExact { request, inputs } => { pending.extend(inputs.iter().map(|input| (*input, at))); result.insert((id, at), ExactLeaf::External(request.clone())); @@ -477,6 +476,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(), @@ -694,22 +694,28 @@ mod tests { }], time_index: None, }; - QueryPlanNode::RelationalJoin { - inputs: [QueryNodeId(0), QueryNodeId(1)], - join_kind: planner_types::pre_asap::JoinKind::Semi, - pred: serde_json::to_value(planner_types::pre_asap::Predicate(std::rc::Rc::new( + { + let schemas = vec![std::sync::Arc::new(schema.clone()), std::sync::Arc::new(schema.clone())]; + let node = planner_types::post_asap::PostAsapDagNode { + id: planner_types::post_asap::PostAsapNodeId(2), + payload: planner_types::post_asap::PostAsapOperatorPayload::RelationalJoin { join_kind: planner_types::pre_asap::JoinKind::Semi, pred: serde_json::from_value(serde_json::to_value(planner_types::pre_asap::Predicate(std::rc::Rc::new( planner_types::pre_asap::QueryExpr::Compare { left: std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Column(0)), op: planner_types::pre_asap::CompareOpKind::Eq, right: std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Column(1)), }, ))) - .unwrap(), - pruning: Some(CandidateCompleteness::BestEffort { guarantee: None }), - left_schema: schema.clone(), - right_schema: schema.clone(), - output_schema: schema, - } + .unwrap()).unwrap(), pruning: None }, + output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, + output_schema: schema, guarantee: None, + }; + let operator = asap_physical_operators::physical_planner::compile_node(&node, &schemas).unwrap(); + let compiled = asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + schemas.into_iter().enumerate().map(|(id, schema)| (id as u64, asap_physical_operators::physical_planner::InputContract::bounded(schema))).collect(), + [(2, ((0..2).collect(), operator))].into(), vec![2], + ).unwrap(); + QueryPlanNode::PhysicalFragment { inputs: [QueryNodeId(0), QueryNodeId(1)].to_vec(), dag: compiled.encode().unwrap(), row_input: Some(0), pruning: (Some(CandidateCompleteness::BestEffort { guarantee: None })).map(|completeness| asap_types::query_plan::PruningInputContract { candidate_input: 1, keys: vec![(0,0)], completeness }) } + } }); entry.nodes.insert( QueryNodeId(3), @@ -848,7 +854,7 @@ mod tests { let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - let entry = entry(BTreeMap::from([ + let mut entry = entry(BTreeMap::from([ ( QueryNodeId(0), QueryPlanNode::Logical { @@ -871,6 +877,7 @@ mod tests { }, ), ])); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); let leaves = prepare( &entry, &[1000], 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 103580df0..c81ad52a4 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,13 +1,15 @@ //! 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, }; use crate::storage_engines::types::KeyByLabelValues; use asap_physical_operators::dag as physical; +use asap_types::query_plan::query_time::QueryTimeOperator; +#[cfg(test)] use asap_types::query_plan::query_time::{ - Aggregation, BinaryOperation, Grouping, QueryTimeOperator, TemporalOperation, + Aggregation, BinaryOperation, Grouping, TemporalOperation, }; use asap_types::query_plan::{CandidateCompleteness, QueryNodeId, QueryPlanEntry, QueryPlanNode}; use futures::{FutureExt, StreamExt}; @@ -61,6 +63,7 @@ pub struct ExecutionStats { fn miss(detail: impl Into) -> EngineError { EngineError::capability_miss("installed_logical_dag", detail) } +#[cfg(test)] fn no_name(mut labels: Labels) -> Labels { labels.remove("__name__"); labels @@ -127,6 +130,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) } @@ -287,7 +298,14 @@ impl Result> ValueRuntim return Ok(value); } let value = match node.clone() { - QueryPlanNode::Scalar { value } => Value::Scalar(native_scalar(value, context)?), + QueryPlanNode::Scalar { .. } + | QueryPlanNode::Binary { .. } + | QueryPlanNode::ReduceSum { .. } => { + return Err(physical::Error::Invalid( + "installed computation requires a retained Planner physical graph".into(), + ) + .into()); + } QueryPlanNode::Logical { operator: QueryTimeOperator::CurrentSeries { .. }, .. @@ -317,19 +335,15 @@ impl Result> ValueRuntim pruning, .. } => { + if let Some(value) = native_values::complete_values(&dag, inputs, context.clone())? + { + return Ok(value); + } let values = inputs .iter() .map(|value| vector((**value).clone())) .collect::, _>>()?; if let Some(contract) = &pruning { - native_values::validate_pruning( - &dag, - &values, - row_input, - contract, - at, - context.clone(), - )?; if let Some(warning) = pruning_warning(Some(&contract.completeness)) { self.warnings.push(warning); } @@ -342,35 +356,7 @@ impl Result> ValueRuntim context.clone(), )?) } - QueryPlanNode::RelationalJoin { - join_kind: planner_types::pre_asap::JoinKind::Semi, - pred, - pruning, - left_schema, - right_schema, - output_schema, - .. - } => { - let [values, candidates] = inputs else { - return Err(miss("semi-join requires two inputs")); - }; - let selected = native_values::relation( - vector((**values).clone())?, - vector((**candidates).clone())?, - serde_json::from_value(pred) - .map_err(|error| physical::Error::Invalid(error.to_string()))?, - std::sync::Arc::new(left_schema), - std::sync::Arc::new(right_schema), - std::sync::Arc::new(output_schema), - pruning.clone(), - at, - context.clone(), - )?; - if let Some(warning) = pruning_warning(pruning.as_ref()) { - self.warnings.push(warning); - } - Value::Vector(selected) - } + _ => { self.stats.summary_readout_evaluations += 1; from_result((self.callback)( @@ -389,12 +375,6 @@ impl Result> ValueRuntim at: i64, context: &physical::RunContext, ) -> Result { - let input = |index: usize| { - inputs - .get(index) - .map(|value| (**value).clone()) - .ok_or_else(|| miss("missing logical input")) - }; match operator { QueryTimeOperator::ExactSubquery { .. } | QueryTimeOperator::CandidateExactSubquery { .. } => { @@ -406,108 +386,17 @@ impl Result> ValueRuntim QueryTimeOperator::Scan { .. } => { Err(miss("local raw Scan is forbidden in deployed plans")) } - QueryTimeOperator::UnaryNegate => negate(input(0)?, context), - QueryTimeOperator::VectorToScalar => vector_to_scalar(vector(input(0)?)?, context), - QueryTimeOperator::Aggregate { - operation, - grouping, - } => { - let values = vector(input(0)?)?; - Ok(Value::Vector(aggregate( - operation, &grouping, values, context, - )?)) - } - QueryTimeOperator::Limit { - n, - offset, - grouping, - } => { - let values = vector(input(0)?)?; - Ok(Value::Vector(native_values::limit( - values, - &grouping, - n, - offset, - context.clone(), - )?)) - } - QueryTimeOperator::Binary { - operation, - return_bool, - } => { - let left = input(0)?; - let right = input(1)?; - binary_in_context(operation, return_bool, left, right, context) - } - QueryTimeOperator::Temporal { operation } => { - let Value::Matrix(values, start, end) = input(0)? else { - return Err(miss("temporal operator requires range vector")); - }; - let preserve_name = self.entry.language - == control_plane::query_plan::QueryLanguage::MetricsQl - && matches!( - operation, - TemporalOperation::Min | TemporalOperation::Max | TemporalOperation::Avg - ); - let result = native_temporal(values, operation, start, end, context)?; - Ok(Value::Vector( - result - .into_iter() - .map(|(labels, value)| { - ( - if preserve_name { - labels - } else { - no_name(labels) - }, - value, - ) - }) - .collect(), - )) - } - - QueryTimeOperator::Sort { - descending, - grouping, - } => { - let values = vector(input(0)?)?; - Ok(Value::Vector(native_values::sort( - values, - &grouping, - descending, - context.clone(), - )?)) - } - QueryTimeOperator::HistogramQuantile => { - let Value::Scalar(quantile) = input(0)? else { - return Err(miss("quantile requires scalar")); - }; - let mut groups: BTreeMap> = BTreeMap::new(); - for (mut labels, value) in vector(input(1)?)? { - if let Some(le) = labels.remove("le").and_then(|s| s.parse::().ok()) { - groups.entry(no_name(labels)).or_default().push((le, value)); - } - } - let rows = groups - .into_iter() - .flat_map(|(labels, buckets)| { - buckets.into_iter().map(move |(bound, count)| { - vec![ - native_labels(&labels), - physical::values::Value::Float64(bound), - physical::values::Value::Float64(count), - ] - }) - }) - .collect(); - Ok(Value::Vector(native_window( - rows, - planner_types::pre_asap::AggIntent::HistogramQuantile { q: quantile }, - None, - context, - )?)) - } + QueryTimeOperator::UnaryNegate + | QueryTimeOperator::VectorToScalar + | QueryTimeOperator::Aggregate { .. } + | QueryTimeOperator::Limit { .. } + | QueryTimeOperator::Binary { .. } + | QueryTimeOperator::Temporal { .. } + | QueryTimeOperator::Sort { .. } + | QueryTimeOperator::HistogramQuantile => Err(physical::Error::Invalid( + "installed computation must contain Planner physical operators".into(), + ) + .into()), QueryTimeOperator::Subquery { range_ms, step_ms, @@ -593,9 +482,7 @@ fn expanded_inputs(node: &QueryPlanNode, at: i64) -> Result { Ok(inputs.iter().map(|&id| (id, at)).collect()) } - QueryPlanNode::RelationalJoin { inputs, .. } => { - Ok(inputs.iter().map(|&id| (id, at)).collect()) - } + _ => Ok(vec![]), } } @@ -737,27 +624,6 @@ fn semi_join( Ok((rows, pruning_warning(completeness))) } -pub(super) fn native_scalar( - value: f64, - context: &physical::RunContext, -) -> Result { - use physical::{batch_execution::evaluate_source, operators::Operator, values::Value as Cell}; - let source = Operator::scalar( - Cell::Float64(value), - planner_types::pre_asap::DataType::Float64, - ) - .map_err(EngineError::from)?; - let batches = evaluate_source(source, context.clone()).map_err(EngineError::from)?; - match batches - .first() - .and_then(|b| b.rows().first()) - .and_then(|r| r.first()) - { - Some(Cell::Float64(value)) => Ok(*value), - _ => Err(miss("native scalar source returned invalid output")), - } -} - fn native_labels(labels: &Labels) -> physical::values::Value { physical::values::Value::Map( labels @@ -772,6 +638,7 @@ fn native_labels(labels: &Labels) -> physical::values::Value { .into(), ) } +#[cfg(test)] fn native_vector_batch( values: Vector, grouping: &Grouping, @@ -813,6 +680,7 @@ fn native_vector_batch( .collect(); Batch::try_new(schema, rows).map_err(EngineError::from) } +#[cfg(test)] fn native_batch_rows( batch: physical::values::Batch, ops: Vec, @@ -858,6 +726,7 @@ fn native_vector_output( }) .collect() } +#[cfg(test)] fn aggregate( operation: Aggregation, grouping: &Grouping, @@ -882,6 +751,7 @@ fn aggregate( native_vector_output(native_batch_rows(batch, vec![operator], context)?, 0, 1) } +#[cfg(test)] fn negate(value: Value, context: &physical::RunContext) -> Result { use physical::operators::{Expression, Operator}; let scalar = matches!(value, Value::Scalar(_)); @@ -915,24 +785,7 @@ fn negate(value: Value, context: &physical::RunContext) -> Result Result { - use physical::{operators::Operator, values::Value as Cell}; - let batch = native_vector_batch( - values, - &Grouping { - labels: vec![], - without: false, - }, - )?; - let operator = - Operator::vector_to_scalar(batch.schema().clone(), 2).map_err(EngineError::from)?; - let rows = native_batch_rows(batch, vec![operator], context)?; - match rows.first().and_then(|row| row.first()) { - Some(Cell::Float64(value)) => Ok(Value::Scalar(*value)), - _ => Err(miss("native scalar conversion returned invalid output")), - } -} - +#[cfg(test)] fn grouping_key(labels: &Labels, grouping: &Grouping) -> Labels { labels .iter() @@ -987,6 +840,7 @@ fn binary( ) -> Result { binary_in_context(operation, boolean, left, right, &test_native_context()) } +#[cfg(test)] fn binary_in_context( operation: BinaryOperation, boolean: bool, @@ -1145,83 +999,21 @@ fn binary_in_context( Ok(Value::Vector(vector(Value::Vector(output))?)) } -fn native_temporal( - values: Matrix, - operation: TemporalOperation, - start: i64, - end: i64, - context: &physical::RunContext, -) -> Result { - use planner_types::pre_asap::AggIntent; - let intent = match operation { - TemporalOperation::Rate => AggIntent::Rate, - TemporalOperation::Increase => AggIntent::Increase, - TemporalOperation::Sum => AggIntent::Sum { col: None }, - TemporalOperation::Avg => AggIntent::Avg { col: None }, - TemporalOperation::Min => AggIntent::Min { col: None }, - TemporalOperation::Max => AggIntent::Max { col: None }, - TemporalOperation::Count => AggIntent::Count { - accuracy: planner_types::types::AccuracyTarget::Exact, +#[cfg(test)] +fn test_state_binding() -> QueryPlanNode { + let output = asap_types::sds::StoredOutputId(99); + QueryPlanNode::ReadMaterialization { + binding: asap_types::query_plan::MaterializationBinding { + stored_output_reference: asap_types::sds::StoredOutputReference::for_output(output), + materialization: output, + output_grouping: asap_types::query_plan::PhysicalGrouping::PerEntity, + item_labels: vec![], + window_ms: 1000, + pane_origin_ms: Some(0), + readout_lookback_ms: Some(300_000), + full_window_slide_ms: None, }, - }; - let rows = values - .into_iter() - .flat_map(|(labels, points)| { - points.into_iter().map(move |(time, value)| { - vec![ - native_labels(&labels), - physical::values::Value::Timestamp(time), - physical::values::Value::Float64(value), - ] - }) - }) - .collect(); - native_window(rows, intent, Some((start, end)), context) -} -fn native_window( - rows: Vec>, - intent: planner_types::pre_asap::AggIntent, - window: Option<(i64, i64)>, - context: &physical::RunContext, -) -> Result { - use planner_types::{ - post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, - pre_asap::DataType, - }; - let schema = std::sync::Arc::new(SummarySchema { - fields: vec![ - ( - "labels", - DataType::Map { - key: Box::new(DataType::Utf8), - value: Box::new(DataType::Utf8), - value_nullable: false, - }, - ), - ( - "coordinate", - if window.is_some() { - DataType::Timestamp - } else { - DataType::Float64 - }, - ), - ("value", DataType::Float64), - ] - .into_iter() - .map(|(name, dtype)| SummaryField { - name: name.into(), - dtype: SummaryFamilyType::Plain(dtype), - nullable: false, - }) - .collect(), - time_index: None, - }); - let batch = - physical::values::Batch::try_new(schema.clone(), rows).map_err(EngineError::from)?; - let operator = physical::operators::Operator::window(schema, intent, 1, 2, vec![0], window) - .map_err(EngineError::from)?; - native_vector_output(native_batch_rows(batch, vec![operator], context)?, 0, 1) + } } #[cfg(test)] @@ -1440,6 +1232,8 @@ mod topk_tests { )] .into_iter() .collect(); + let mut entry = entry.clone(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); let (result, stats) = execute_installed(&entry, &leaves, at, |_, _| { panic!("summary callback must not run for an exact-child topk") }) @@ -1475,6 +1269,7 @@ mod topk_tests { TemporalOperation::Rate, ] { let entry = QueryPlanEntry { + physical_dag: None, language, query_id: "labels".into(), canonical_query: "test".into(), @@ -1521,6 +1316,8 @@ mod topk_tests { remote_rpcs: 1, }, )]); + let mut entry = entry.clone(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); let (result, _) = execute_installed(&entry, &leaves, 1000, |_, _| { panic!("external child supplied") }) @@ -1552,12 +1349,14 @@ 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(), fixed_evaluation: None, root, nodes: BTreeMap::from([ + (QueryNodeId(99), test_state_binding()), ( summary, QueryPlanNode::ExactReadout { @@ -1600,6 +1399,8 @@ mod topk_tests { }, fallback: FallbackPolicy::ExactBackend, }; + let mut entry = entry.clone(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); let (result, stats) = execute_installed(&entry, &BTreeMap::new(), 300_000, |id, at| { assert_eq!(id, summary); assert_eq!(at, 300_000); @@ -1752,6 +1553,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(), @@ -1781,27 +1583,66 @@ mod topk_tests { }], time_index: None, }; - QueryPlanNode::RelationalJoin { - inputs: [value_id, candidate_id], - join_kind: planner_types::pre_asap::JoinKind::Semi, - pred: serde_json::to_value(planner_types::pre_asap::Predicate( - std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Compare { - left: std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Column( - 0, - )), - op: planner_types::pre_asap::CompareOpKind::Eq, - right: std::rc::Rc::new( - planner_types::pre_asap::QueryExpr::Column(1), - ), + { + let schemas = vec![ + std::sync::Arc::new(schema.clone()), + std::sync::Arc::new(schema.clone()), + ]; + let node = planner_types::post_asap::PostAsapDagNode { + id: planner_types::post_asap::PostAsapNodeId(2), + payload: + planner_types::post_asap::PostAsapOperatorPayload::RelationalJoin { + join_kind: planner_types::pre_asap::JoinKind::Semi, + pred: serde_json::from_value( + serde_json::to_value(planner_types::pre_asap::Predicate( + std::rc::Rc::new( + planner_types::pre_asap::QueryExpr::Compare { + left: std::rc::Rc::new( + planner_types::pre_asap::QueryExpr::Column( + 0, + ), + ), + op: planner_types::pre_asap::CompareOpKind::Eq, + right: std::rc::Rc::new( + planner_types::pre_asap::QueryExpr::Column( + 1, + ), + ), + }, + ), + )) + .unwrap(), + ) + .unwrap(), + pruning: None, + }, + output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, + output_schema: schema, + guarantee: None, + }; + let operator = asap_physical_operators::physical_planner::compile_node( + &node, &schemas, + ) + .unwrap(); + let compiled = asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + schemas.into_iter().enumerate().map(|(id, schema)| (id as u64, asap_physical_operators::physical_planner::InputContract::bounded(schema))).collect(), + [(2, ((0..2).collect(), operator))].into(), vec![2], + ).unwrap(); + QueryPlanNode::PhysicalFragment { + inputs: [value_id, candidate_id].to_vec(), + dag: compiled.encode().unwrap(), + row_input: Some(0), + pruning: (Some(CandidateCompleteness::Certified { + guarantee: topk_membership_guarantee(), + })) + .map(|completeness| { + asap_types::query_plan::PruningInputContract { + candidate_input: 1, + keys: vec![(0, 0)], + completeness, + } }), - )) - .unwrap(), - pruning: Some(CandidateCompleteness::Certified { - guarantee: topk_membership_guarantee(), - }), - left_schema: schema.clone(), - right_schema: schema.clone(), - output_schema: schema, + } } }), ( @@ -1867,6 +1708,8 @@ mod topk_tests { }, ), ]); + let mut entry = entry.clone(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); let (result, stats) = execute_installed(&entry, &leaves, at as u64, |_, _| { panic!("both inputs are prepared") }) @@ -1918,14 +1761,15 @@ mod shared_runtime_tests { fn entry() -> QueryPlanEntry { QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "shared-grid".into(), canonical_query: "shared-grid".into(), fixed_evaluation: None, root: QueryNodeId(3), - // The callback owns the absorbed summary dependencies. Only its - // declared readout boundary participates in this value graph. + // The callback binds a readout boundary backed by the declared stored source. nodes: BTreeMap::from([ + (QueryNodeId(99), test_state_binding()), ( QueryNodeId(0), QueryPlanNode::ExactReadout { @@ -1976,7 +1820,25 @@ mod shared_runtime_tests { // A shared time-grid node runs once per query; distinct times and runs stay isolated. #[test] fn shared_subquery_scopes_do_not_duplicate_or_leak_values() { - let entry = entry(); + let mut entry = entry(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); + let QueryPlanNode::PhysicalFragment { dag, .. } = &entry.nodes[&entry.root] else { + panic!("compiled root required") + }; + let graph: serde_json::Value = serde_json::from_slice(dag).unwrap(); + let shared = graph["nodes"] + .as_object() + .unwrap() + .values() + .find_map(|node| { + let operator = node.get("Operator")?; + operator["operator"]["kind"] + .get("VectorBinary") + .map(|_| operator["inputs"].as_array().unwrap()) + }) + .unwrap(); + assert_eq!(shared.len(), 2); + assert_eq!(shared[0], shared[1]); let mut calls = Vec::new(); for (at, expected) in [(3000, 10.), (4000, 14.)] { let (result, stats) = execute_installed(&entry, &BTreeMap::new(), at, |id, time| { @@ -1997,7 +1859,6 @@ mod shared_runtime_tests { }; assert_eq!(result.values[0].value, expected); assert_eq!(stats.summary_readout_evaluations, 2); - assert!(stats.memo_hits >= 1); } assert_eq!(calls, vec![2000, 3000, 3000, 4000]); } @@ -2006,7 +1867,14 @@ mod shared_runtime_tests { #[test] fn native_scalar_and_aggregation_share_parent_resource_control() { let context = test_native_context(); - assert_eq!(native_scalar(7., &context).unwrap(), 7.); + let graph = asap_physical_operators::physical_planner::promql_values::compile_scalar(7.) + .unwrap() + .encode() + .unwrap(); + assert!(matches!( + native_values::complete_values(&graph, &[], context.clone()).unwrap(), + Some(Value::Scalar(7.)) + )); let output = aggregate( Aggregation::Sum, &Grouping { @@ -2020,14 +1888,16 @@ mod shared_runtime_tests { assert_eq!(output, vec![(Labels::new(), 7.)]); assert!(context.peak_bytes() > 0); context.cancel(); - assert!(native_scalar(7., &context).is_err()); + assert!(native_values::complete_values(&graph, &[], context.clone()).is_err()); assert!(negate(Value::Scalar(1.), &context).is_err()); } // Source failures keep their routing classification across the shared runtime. #[test] fn source_error_classification_survives_execution() { - let error = execute_installed(&entry(), &BTreeMap::new(), 3000, |_, _| { + let mut entry = entry(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); + let error = execute_installed(&entry, &BTreeMap::new(), 3000, |_, _| { Err(EngineError::capability_miss("source", "failed")) }) .unwrap_err(); 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 e9c709ac9..93e5359e1 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,15 +1,113 @@ -//! Bind protocol vectors to native batch operators; computation stays in Planner. -use super::{grouping_key, miss, EngineError, Grouping, Labels, Vector}; +//! Bind deployment inputs to retained native programs and decode PromQL results. +use super::{miss, EngineError, Labels, Vector}; use asap_physical_operators::dag::{ - self, batch_execution, - operators::{Operator, SortKey}, + self, + operators::Operator, values::{Batch, Schema, Value}, }; -use planner_types::{ - post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, - pre_asap::DataType, -}; +#[cfg(test)] +use planner_types::post_asap::{SummaryField, SummarySchema}; +use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; +#[cfg(test)] use std::sync::Arc; + +/// Bind protocol values without choosing matching, grouping or arithmetic behavior. +pub(in crate::query_engines::asap_query_engine) fn complete_values( + encoded: &[u8], + inputs: &[&super::Value], + context: dag::RunContext, +) -> Result, EngineError> { + use asap_physical_operators::physical_planner::{promql_values, CompiledPhysicalDag, Source}; + use futures::StreamExt; + let program = CompiledPhysicalDag::decode(encoded)?; + let scalar = promql_values::scalar_schema(); + let vector = promql_values::vector_schema(); + let matrix = promql_values::matrix_schema(); + let compatible = |schema: &Schema| schema == &scalar || schema == &vector || schema == &matrix; + if program.roots().len() != 1 + || !program + .input_contracts() + .all(|(_, input)| compatible(&input.schema)) + || !compatible(&program.output_contract(program.roots()[0])?.schema) + { + return Ok(None); + } + if program.input_contracts().count() != inputs.len() { + return Err(miss("physical value input arity mismatch")); + } + let mut sources = std::collections::BTreeMap::new(); + let mut retained_inputs = context.reserve(0)?; + let mut input_bytes = 0usize; + for ((id, contract), input) in program.input_contracts().zip(inputs) { + let rows = match input { + super::Value::Scalar(value) if contract.schema == scalar => { + vec![vec![Value::Float64(*value)]] + } + super::Value::Vector(values) if contract.schema == vector => values + .iter() + .map(|(labels, value)| vec![super::native_labels(labels), Value::Float64(*value)]) + .collect(), + super::Value::Matrix(values, start, end) if contract.schema == matrix => values + .iter() + .flat_map(|(labels, points)| { + points.iter().map(move |(time, value)| { + vec![ + super::native_labels(labels), + Value::Timestamp(*time), + Value::Float64(*value), + Value::Timestamp(*start), + Value::Timestamp(*end), + ] + }) + }) + .collect(), + _ => { + return Err(miss( + "protocol input differs from compiled scalar/vector contract", + )) + } + }; + let batch = Batch::try_new(contract.schema.clone(), rows)?; + input_bytes = input_bytes + .checked_add(batch.bytes()) + .ok_or(dag::Error::MemoryLimit)?; + retained_inputs.resize(input_bytes)?; + sources.insert( + id, + Box::new(Operator::source(contract.schema.clone(), vec![batch])?) as Source<'_>, + ); + } + let output_schema = program.output_contract(program.roots()[0])?.schema; + let graph = program.instantiate(sources)?; + let mut retained = context.reserve(0)?; + let mut stream = graph.execute(program.roots(), context)?.remove(0); + let rows = crate::query_engines::request::drive(async { + let mut rows = Vec::new(); + let mut bytes = 0usize; + while let Some(batch) = stream.next().await { + let batch = batch?; + bytes = bytes + .checked_add(batch.bytes()) + .ok_or(dag::Error::MemoryLimit)?; + retained.resize(bytes)?; + rows.extend(batch.rows().iter().cloned()); + } + Ok::<_, EngineError>(rows) + })??; + if output_schema == scalar { + let [row] = rows.as_slice() else { + return Err(miss("physical scalar output must have exactly one row")); + }; + let [Value::Float64(value)] = row.as_slice() else { + return Err(miss("invalid scalar output")); + }; + Ok(Some(super::Value::Scalar(*value))) + } else { + super::native_vector_output(rows, 0, 1).map(|values| Some(super::Value::Vector(values))) + } +} + +#[cfg(test)] fn schema(fields: &[(&str, DataType)]) -> Schema { Arc::new(SummarySchema { fields: fields @@ -23,84 +121,7 @@ fn schema(fields: &[(&str, DataType)]) -> Schema { time_index: None, }) } -fn key(value: &T) -> Value { - Value::Utf8( - serde_json::to_string(value) - .expect("string keys serialize") - .into(), - ) -} -fn ranked_batch(values: &Vector, grouping: &Grouping) -> Result { - let schema = schema(&[ - ("index", DataType::Int64), - ("group", DataType::Utf8), - ("value", DataType::Float64), - ]); - Batch::try_new( - schema, - values - .iter() - .enumerate() - .map(|(i, (labels, v))| { - vec![ - Value::Int64(i as i64), - key(&grouping_key(labels, grouping)), - Value::Float64(*v), - ] - }) - .collect(), - ) - .map_err(EngineError::from) -} -fn output(values: Vector, batches: Vec>) -> Result { - batches - .iter() - .flat_map(|b| b.rows()) - .map(|row| match row.first() { - Some(Value::Int64(index)) => values - .get(*index as usize) - .cloned() - .ok_or_else(|| miss("native result index outside input")), - _ => Err(miss("native result has no row identity")), - }) - .collect() -} -pub(super) fn sort( - values: Vector, - grouping: &Grouping, - descending: bool, - context: dag::RunContext, -) -> Result { - let batch = ranked_batch(&values, grouping)?; - let op = Operator::sort( - batch.schema().clone(), - vec![SortKey { - column: 2, - descending, - nulls_first: false, - }], - vec![1], - ) - .map_err(EngineError::from)?; - let result = - batch_execution::evaluate_batch(batch, vec![op], context).map_err(EngineError::from)?; - output(values, result) -} -pub(super) fn limit( - values: Vector, - grouping: &Grouping, - n: u64, - offset: u64, - context: dag::RunContext, -) -> Result { - let batch = ranked_batch(&values, grouping)?; - let op = - Operator::limit(batch.schema().clone(), n, offset, vec![1]).map_err(EngineError::from)?; - let result = - batch_execution::evaluate_batch(batch, vec![op], context).map_err(EngineError::from)?; - output(values, result) -} -/// Relational boundary used by explicit row plans. Planner owns predicate lowering. +#[cfg(test)] pub(super) fn relation( values: Vector, candidates: Vector, @@ -116,26 +137,14 @@ pub(super) fn relation( compile_node, CompiledPhysicalDag, InputContract, }; use planner_types::post_asap::{ - ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, + ExecutionDataState, PostAsapDagNode, PostAsapNodeId, PostAsapOperatorPayload, }; - let pruning = completeness - .as_ref() - .map(|completeness| { - Ok::<_, EngineError>(asap_types::query_plan::PruningInputContract { - candidate_input: 1, - keys: asap_physical_operators::physical_planner::equijoin_keys( - &predicate, &left, &right, - )?, - completeness: completeness.clone(), - }) - }) - .transpose()?; - let node = ExecutableDagNode { + let node = PostAsapDagNode { id: PostAsapNodeId(2), output_state: ExecutionDataState::QUERY_ROWS, output_schema: (*output_schema).clone(), guarantee: None, - payload: ExecutableOperatorPayload::RelationalJoin { + payload: PostAsapOperatorPayload::RelationalJoin { join_kind: planner_types::pre_asap::JoinKind::Semi, pred: predicate, pruning: completeness, @@ -153,10 +162,7 @@ pub(super) fn relation( )?; let encoded = compiled.encode()?; let inputs = vec![values, candidates]; - if let Some(pruning) = pruning { - validate_pruning(&encoded, &inputs, 0, &pruning, at, context.clone())?; - } - physical(&encoded, inputs, 0, at, context) + physical(&encoded, inputs, Some(0), at, context) } // Equality keys canonicalize signed zero and NaNs; row transport must preserve their bits. @@ -174,7 +180,7 @@ fn identity_key(value: &Value) -> Result, asap_physical_operators::Error pub(super) fn physical( encoded: &[u8], inputs: Vec, - row_input: usize, + row_input: Option, at: i64, context: dag::RunContext, ) -> Result { @@ -183,7 +189,7 @@ pub(super) fn physical( use std::collections::{BTreeMap, VecDeque}; let compiled = CompiledPhysicalDag::decode(encoded)?; let contracts = compiled.input_contracts().collect::>(); - if contracts.len() != inputs.len() || row_input >= inputs.len() { + if contracts.len() != inputs.len() || row_input.is_some_and(|index| index >= inputs.len()) { return Err(asap_physical_operators::Error::Invalid( "physical input arity mismatch".into(), ) @@ -236,7 +242,7 @@ pub(super) fn physical( .collect::, EngineError>>() }) .collect::, _>>()?; - if position == row_input { + if Some(position) == row_input { for (row, original) in rows.iter().zip(values) { let key = row .iter() @@ -251,6 +257,7 @@ pub(super) fn physical( Box::new(Operator::source(contract.schema.clone(), vec![batch])?) as Source<'_>, ); } + let output_schema = compiled.output_contract(compiled.roots()[0])?.schema; let graph = compiled.instantiate(sources)?; let mut streams = graph.execute(compiled.roots(), context)?; if streams.len() != 1 { @@ -265,6 +272,33 @@ pub(super) fn physical( match stream.next().now_or_never() { Some(Some(batch)) => { for row in batch?.rows() { + if row_input.is_none() { + let mut labels = Labels::new(); + let mut sample = None; + for (field, cell) in output_schema.fields.iter().zip(row) { + match cell { + Value::Utf8(value) => { + if !value.is_empty() { + labels.insert(field.name.clone(), value.to_string()); + } + } + Value::Float64(value) => sample = Some(*value), + Value::Int64(value) if value.unsigned_abs() <= (1u64 << 53) => { + sample = Some(*value as f64) + } + Value::Timestamp(_) | Value::Null => {} + _ => return Err(miss( + "native output cannot be represented by the PromQL protocol", + )), + } + } + result.push(( + labels, + sample + .ok_or_else(|| miss("native vector output has no numeric value"))?, + )); + continue; + } let key = row .iter() .map(identity_key) @@ -286,71 +320,107 @@ pub(super) fn physical( } } -pub(super) fn validate_pruning( - encoded: &[u8], - inputs: &[Vector], - row_input: usize, - contract: &asap_types::query_plan::PruningInputContract, - at: i64, - context: dag::RunContext, -) -> Result<(), EngineError> { - use asap_physical_operators::physical_planner::{CompiledPhysicalDag, InputContract}; - use planner_types::post_asap::CandidateCompleteness; - if !matches!( - contract.completeness, - CandidateCompleteness::Certified { .. } - ) { - return Ok(()); - } - let compiled = CompiledPhysicalDag::decode(encoded)?; - let schemas = compiled - .input_contracts() - .map(|(_, c)| c.schema.clone()) - .collect::>(); - let candidates = inputs - .get(contract.candidate_input) - .ok_or_else(|| miss("missing candidate input"))?; - let values = inputs - .get(row_input) - .ok_or_else(|| miss("missing authoritative input"))?; - let coverage = Operator::semi_join( - schemas[contract.candidate_input].clone(), - schemas[row_input].clone(), - contract.keys.iter().map(|&(l, r)| (r, l)).collect(), - )?; - let check = CompiledPhysicalDag::from_operators( - [ - ( - 0, - InputContract::bounded(schemas[contract.candidate_input].clone()), - ), - (1, InputContract::bounded(schemas[row_input].clone())), - ] - .into(), - [(2, (vec![0, 1], coverage))].into(), - vec![2], - )?; - let matched = physical( - &check.encode()?, - vec![candidates.clone(), values.clone()], - 0, - at, - context, - )?; - if matched.len() != candidates.len() { - return Err(miss("certified pruning key has no authoritative value")); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; + use asap_physical_operators::operators::SortKey; use asap_physical_operators::{ physical_planner::{CompiledPhysicalDag, InputContract}, Error, }; + // An installed computation binds complete label maps; ranking and division are native. + #[test] + fn compiled_vector_composition_preserves_grouping_and_runs_independently() { + use super::super::{ + execute_installed, PreparedLeaf, PreparedLeaves, Value as ProtocolValue, + }; + use asap_types::query_plan::{ + query_time::QueryTimeOperator, FallbackPolicy, InstantExecution, QueryPlanNode, + }; + let query = "topk by (job) (1, sum without(instance) (left_metric) / sum without(instance) (right_metric))"; + let mut entry = control_plane::query_plan::query_time::compile_logical( + "compiled-values".into(), + query.into(), + InstantExecution { + lookback_ms: 300_000, + full_history: false, + cumulative_readout: false, + }, + FallbackPolicy::Reject, + ) + .unwrap(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); + entry.validate(&Default::default()).unwrap(); + assert!(entry.nodes.values().all(|node| matches!( + node, + QueryPlanNode::PhysicalFragment { .. } + | QueryPlanNode::Logical { + operator: QueryTimeOperator::Scan { .. }, + .. + } + ))); + for scale in [1., 2.] { + let mut leaves = PreparedLeaves::new(); + for (id, node) in &entry.nodes { + if let QueryPlanNode::Logical { + operator: QueryTimeOperator::Scan { metric, .. }, + .. + } = node + { + let left = metric.as_deref() == Some("left_metric"); + let values = [ + ("api", "a", if left { 4. * scale } else { 2. }), + ("api", "b", if left { 2. * scale } else { 1. }), + ("worker", "c", if left { 8. * scale } else { 2. }), + ]; + leaves.insert( + (*id, 1000), + PreparedLeaf { + value: ProtocolValue::Vector( + values + .into_iter() + .map(|(job, instance, value)| { + ( + Labels::from([ + ("__name__".into(), metric.clone().unwrap()), + ("job".into(), job.into()), + ("instance".into(), instance.into()), + ]), + value, + ) + }) + .collect(), + ), + remote: true, + remote_evaluations: 1, + remote_rpcs: 1, + }, + ); + } + } + let (result, _) = execute_installed(&entry, &leaves, 1000, |_, _| { + panic!("all inputs were bound") + }) + .unwrap(); + let crate::query_engines::query_result::QueryResult::Vector(result) = result else { + panic!("expected vector") + }; + let actual = result + .values + .into_iter() + .map(|point| (point.labels.labels[0].clone(), point.value)) + .collect::>(); + assert_eq!( + actual, + std::collections::BTreeMap::from([ + ("api".into(), 2. * scale), + ("worker".into(), 4. * scale) + ]) + ); + } + } + // An available label is bound by Backend; Planner evaluates its predicate. #[test] fn planner_filter_compiles_and_executes_bound_labels() { @@ -493,7 +563,7 @@ mod tests { let error = physical( &sorted(), vec![vec![(Labels::new(), 2.), (Labels::new(), 1.)]], - 0, + Some(0), 42, run.clone(), ) @@ -513,6 +583,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() { @@ -535,7 +622,7 @@ mod tests { let result = physical( &compiled, vec![vec![(Labels::new(), sample)]], - 0, + Some(0), 42, context(4096), ); @@ -550,13 +637,77 @@ mod tests { } } + // Aggregation changes both rows and labels; decoding must use the declared + // output schema instead of looking up an unchanged input row. + #[test] + fn physical_aggregate_returns_grouped_values_and_labels() { + use asap_physical_operators::{ + operators::Reduction, + physical_planner::{CompiledPhysicalDag, InputContract}, + }; + let input = schema(&[("job", DataType::Utf8), ("value", DataType::Float64)]); + let aggregate = Operator::aggregate( + input.clone(), + vec![0], + vec![("value".into(), Reduction::Sum(1))], + ) + .unwrap(); + let program = CompiledPhysicalDag::from_operators( + [(0, InputContract::bounded(input))].into(), + [(1, (vec![0], aggregate))].into(), + vec![1], + ) + .unwrap(); + let values = vec![ + (Labels::from([("instance".into(), "c".into())]), 5.), + ( + Labels::from([ + ("job".into(), "api".into()), + ("instance".into(), "a".into()), + ]), + 1., + ), + ( + Labels::from([ + ("job".into(), "api".into()), + ("instance".into(), "b".into()), + ]), + 2., + ), + ( + Labels::from([ + ("job".into(), "worker".into()), + ("instance".into(), "a".into()), + ]), + 4., + ), + ]; + let mut output = physical( + &program.encode().unwrap(), + vec![values], + None, + 42, + context(1 << 20), + ) + .unwrap(); + output.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!( + output, + vec![ + (Labels::new(), 5.), + (Labels::from([("job".into(), "api".into())]), 3.), + (Labels::from([("job".into(), "worker".into())]), 4.), + ] + ); + } + // Transport identity preserves the exact value selected by native total-order sorting. #[test] fn native_sort_preserves_signed_zero_bits() { let rows = physical( &sorted(), vec![vec![(Labels::new(), 0.), (Labels::new(), -0.)]], - 0, + Some(0), 42, context(4096), ) @@ -567,3 +718,292 @@ 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(|error| match error { + crate::storage_engines::sketch_db::index::NativeReadError::Unavailable(message) => { + miss(message) + } + crate::storage_engines::sketch_db::index::NativeReadError::Physical(error) => { + EngineError::from(error) + } + }) + }) +} + +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::StreamExt; + use std::collections::BTreeMap; + let at_signed = i64::try_from(at).map_err(|_| miss("evaluation timestamp overflow"))?; + let context = crate::query_engines::request::context_with_limits( + 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 sources = BTreeMap::new(); + let mut prepared = context.reserve(0)?; + let mut input_bytes = 0usize; + for (input_id, input) in program.input_contracts() { + crate::query_engines::request::check()?; + 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()); + } + prepared.resize(input_bytes)?; + 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 mut retained = context.reserve(0)?; + let mut result_bytes = 0usize; + let mut stream = graph + .execute(program.roots(), context) + .map_err(EngineError::from)? + .remove(0); + let values = crate::query_engines::request::drive(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() + }; + let point = InstantVectorElement::new( + KeyByLabelValues::new_with_labels(labels.values().cloned().collect()), + *sample, + ) + .with_label_keys_override(labels.into_keys().collect()); + result_bytes = result_bytes + .checked_add(point.retained_bytes()) + .ok_or(dag::Error::MemoryLimit)?; + retained.resize(result_bytes)?; + values.push(point); + } + } + Ok(values) + })??; + Ok(( + QueryResult::vector(values, at), + super::ExecutionStats { + summary_readout_evaluations: input_count, + ..Default::default() + }, + )) +} + +#[cfg(test)] +mod request_contract_tests { + use super::*; + + // A native candidate must use the enclosing request budget, even if its + // deployment binding permits a larger standalone execution. + #[tokio::test] + async fn native_candidate_cannot_escape_request_memory_limit() { + let result = crate::query_engines::request::run( + dag::Limits { + max_bytes: 1, + ..dag::Limits::default() + }, + |_| { + let schema = schema(&[("value", DataType::Float64)]); + let program = + asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + [( + 0, + asap_physical_operators::physical_planner::InputContract::bounded( + schema.clone(), + ), + )] + .into(), + Default::default(), + vec![0], + )?; + execute_batches(&program, 64 * 1024, 1, 0, |_, _| { + Batch::try_new(schema.clone(), vec![vec![Value::Float64(1.0)]]) + .map_err(EngineError::from) + }) + }, + ) + .await; + assert!(matches!( + result, + Err(EngineError::Physical(dag::Error::MemoryLimit)) + )); + } +} 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 023318b70..5bf9304a5 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 @@ -2,6 +2,7 @@ use std::collections::BTreeMap; +#[cfg(test)] use asap_physical_operators::arithmetic::evaluate_float64_arithmetic as arithmetic; use asap_types::query_plan::{QueryNodeId, QueryPlanNode}; @@ -153,26 +154,19 @@ impl PhysicalQueryRuntime<'_> { context: &dag::RunContext, ) -> Result { match node { - QueryPlanNode::Scalar { value } => super::logical_dag::native_scalar(*value, context) - .map(PhysicalQueryOutput::Scalar) - .map_err(|error| match error { - crate::query_engines::EngineError::Physical(error) => { - PhysicalNodeError::Physical(error) - } - other => PhysicalNodeError::Fallback(other.to_string()), - }), - QueryPlanNode::Binary { operator, .. } => { - let [lhs, rhs] = inputs else { - return Err(PhysicalNodeError::ExpectedState); - }; - binary_values(operator, lhs, rhs) - } - QueryPlanNode::ReduceSum { grouping, .. } => { - let [PhysicalQueryOutput::Value(values, coverage)] = inputs else { - return Err(PhysicalNodeError::ExpectedState); - }; - reduce_sum_values_in_context(grouping, values, *coverage, context) + QueryPlanNode::Scalar { .. } + | QueryPlanNode::Binary { .. } + | QueryPlanNode::ReduceSum { .. } => { + Err(PhysicalNodeError::Physical(dag::Error::Invalid( + "installed value computation requires a retained Planner physical graph".into(), + ))) } + QueryPlanNode::PhysicalFragment { + dag, + row_input: None, + pruning: None, + .. + } => execute_value_fragment(dag, inputs, context), QueryPlanNode::ReadMaterialization { binding } => { let mut groups = self .context @@ -352,11 +346,11 @@ impl PhysicalQueryRuntime<'_> { item_labels: merged_item_labels.unwrap_or_default(), }) } - QueryPlanNode::Logical { .. } - | QueryPlanNode::Relational { .. } + QueryPlanNode::PhysicalFragment { .. } + | QueryPlanNode::Physical { .. } + | QueryPlanNode::Logical { .. } | QueryPlanNode::ExternalExact { .. } - | QueryPlanNode::PhysicalFragment { .. } - | QueryPlanNode::RelationalJoin { .. } => Err(PhysicalNodeError::Fallback( + | QueryPlanNode::PhysicalRelation { .. } => Err(PhysicalNodeError::Fallback( "logical node requires installed logical runtime".into(), )), QueryPlanNode::ExactFallback { reason } => { @@ -416,111 +410,122 @@ fn expand_item_readout( Ok((expand_item_rows(group_key, value, item_labels)?, coverage)) } -fn binary_values( - operator: &planner_types::pre_asap::ArithmeticOpKind, - lhs: &PhysicalQueryOutput, - rhs: &PhysicalQueryOutput, +// Binding preserves timestamps and coverage; all value computation is in the +// installed native program. No operator is constructed in this serving path. +fn execute_value_fragment( + encoded: &[u8], + inputs: &[&PhysicalQueryOutput], + context: &dag::RunContext, ) -> Result { - type Labels = BTreeMap; - type Samples = BTreeMap<(Labels, i64), (f64, Option<(u64, u64)>)>; - fn flatten(values: &[(Labels, SummaryValue)]) -> Result { - let mut result = BTreeMap::new(); - for (labels, value) in values { - let SummaryValue::Points(points, coverage) = value else { - return Err(PhysicalNodeError::ExpectedState); - }; - let mut labels = labels.clone(); - labels.remove("__name__"); - for (timestamp, value) in points { - if result - .insert((labels.clone(), *timestamp), (*value, *coverage)) - .is_some() - { - return Err(PhysicalNodeError::Fallback( - "ambiguous default vector matching".into(), - )); + use super::logical_dag::{native_values, Value}; + let mut times = std::collections::BTreeSet::new(); + let mut time_memory = context.reserve(0)?; + let mut output_memory = context.reserve(0)?; + let mut output_bytes = 0usize; + let mut coverage = None; + let mut first_coverage = true; + for input in inputs { + match input { + PhysicalQueryOutput::Scalar(_) => {} + PhysicalQueryOutput::Value(values, input_coverage) => { + coverage = if first_coverage { + *input_coverage + } else { + intersect_coverage(coverage, *input_coverage) + }; + first_coverage = false; + for (_, value) in values { + let SummaryValue::Points(points, _) = value else { + return Err(PhysicalNodeError::ExpectedState); + }; + for (time, _) in points { + if context.is_cancelled() { + return Err(dag::Error::Cancelled.into()); + } + times.insert(*time); + time_memory + .resize(times.len().checked_mul(32).ok_or(dag::Error::MemoryLimit)?)?; + } } } + PhysicalQueryOutput::State { .. } => return Err(PhysicalNodeError::ExpectedState), } - Ok(result) } - fn points(values: Samples) -> PhysicalQueryOutput { - PhysicalQueryOutput::Value( - values - .into_iter() - .map(|((labels, timestamp), (value, coverage))| { - ( - labels, - SummaryValue::Points(vec![(timestamp, value)], coverage), - ) - }) - .collect(), - None, - ) + if times.is_empty() { + times.insert(match context.scope { + dag::Scope::Query { + evaluation_time_ms, .. + } => evaluation_time_ms, + dag::Scope::Ingestion { window_end_ms, .. } => window_end_ms, + }); } - match (lhs, rhs) { - (PhysicalQueryOutput::Scalar(a), PhysicalQueryOutput::Scalar(b)) => { - Ok(PhysicalQueryOutput::Scalar(arithmetic(operator, *a, *b))) + let mut result = Vec::new(); + for time in × { + if context.is_cancelled() { + return Err(dag::Error::Cancelled.into()); } - (PhysicalQueryOutput::Value(values, coverage), PhysicalQueryOutput::Scalar(scalar)) => { - Ok({ - let mut output = points( - flatten(values)? - .into_iter() - .map(|(key, (value, coverage))| { - (key, (arithmetic(operator, value, *scalar), coverage)) + let values = inputs + .iter() + .map(|input| match input { + PhysicalQueryOutput::Scalar(value) => Value::Scalar(*value), + PhysicalQueryOutput::Value(values, _) => Value::Vector( + values + .iter() + .flat_map(|(labels, value)| { + let SummaryValue::Points(points, _) = value else { + unreachable!() + }; + points + .iter() + .filter(|(at, _)| at == time) + .map(|(_, value)| (labels.clone(), *value)) }) .collect(), - ); - if let PhysicalQueryOutput::Value(_, out_coverage) = &mut output { - *out_coverage = *coverage; - } - output + ), + PhysicalQueryOutput::State { .. } => unreachable!(), }) - } - (PhysicalQueryOutput::Scalar(scalar), PhysicalQueryOutput::Value(values, coverage)) => { - Ok({ - let mut output = points( - flatten(values)? - .into_iter() - .map(|(key, (value, coverage))| { - (key, (arithmetic(operator, *scalar, value), coverage)) - }) - .collect(), - ); - if let PhysicalQueryOutput::Value(_, out_coverage) = &mut output { - *out_coverage = *coverage; + .collect::>(); + let input_bytes = values.iter().try_fold(0usize, |bytes, value| { + bytes + .checked_add(value.retained_bytes()) + .ok_or(dag::Error::MemoryLimit) + })?; + let _input_memory = context.reserve(input_bytes)?; + let output = native_values::complete_values( + encoded, + &values.iter().collect::>(), + context.clone(), + ) + .map_err(|error| match error { + crate::query_engines::EngineError::Physical(error) => { + PhysicalNodeError::Physical(error) + } + other => PhysicalNodeError::Fallback(other.to_string()), + })? + .ok_or(PhysicalNodeError::ExpectedState)?; + match output { + Value::Scalar(value) if times.len() == 1 => { + return Ok(PhysicalQueryOutput::Scalar(value)) + } + Value::Vector(values) => { + for (labels, value) in values { + let bytes = labels.iter().try_fold(64usize, |bytes, (key, value)| { + bytes + .checked_add(key.len()) + .and_then(|n| n.checked_add(value.len())) + .ok_or(dag::Error::MemoryLimit) + })?; + output_bytes = output_bytes + .checked_add(bytes) + .ok_or(dag::Error::MemoryLimit)?; + output_memory.resize(output_bytes)?; + result.push((labels, SummaryValue::Points(vec![(*time, value)], coverage))); } - output - }) - } - ( - PhysicalQueryOutput::Value(left, left_coverage), - PhysicalQueryOutput::Value(right, right_coverage), - ) => { - let right = flatten(right)?; - let mut output = points( - flatten(left)? - .into_iter() - .filter_map(|(key, (left, coverage))| { - let (right, right_coverage) = right.get(&key)?; - Some(( - key, - ( - arithmetic(operator, left, *right), - intersect_coverage(coverage, *right_coverage), - ), - )) - }) - .collect(), - ); - if let PhysicalQueryOutput::Value(_, coverage) = &mut output { - *coverage = intersect_coverage(*left_coverage, *right_coverage); } - Ok(output) + _ => return Err(PhysicalNodeError::ExpectedState), } - _ => Err(PhysicalNodeError::ExpectedState), } + Ok(PhysicalQueryOutput::Value(result, coverage)) } fn intersect_coverage(left: Option<(u64, u64)>, right: Option<(u64, u64)>) -> Option<(u64, u64)> { @@ -530,95 +535,56 @@ fn intersect_coverage(left: Option<(u64, u64)>, right: Option<(u64, u64)>) -> Op } #[cfg(test)] -fn reduce_sum_values( - grouping: &asap_types::query_plan::PhysicalGrouping, - values: &[(BTreeMap, SummaryValue)], - coverage: Option<(u64, u64)>, +fn binary_values( + operation: &planner_types::pre_asap::ArithmeticOpKind, + lhs: &PhysicalQueryOutput, + rhs: &PhysicalQueryOutput, ) -> Result { - let context = dag::RunContext::new( + let graph = asap_physical_operators::physical_planner::promql_values::compile_binary( + &planner_types::post_asap::BinaryOperator { + kind: planner_types::pre_asap::BinaryOpKind::Arithmetic(operation.clone()), + vector_match: None, + checked_relative_division: false, + checked_finite_division: false, + }, + false, + matches!(lhs, PhysicalQueryOutput::Scalar(_)), + matches!(rhs, PhysicalQueryOutput::Scalar(_)), + )?; + execute_value_fragment(&graph.encode()?, &[lhs, rhs], &test_value_context()) +} + +#[cfg(test)] +fn test_value_context() -> dag::RunContext { + dag::RunContext::new( dag::Scope::Query { - evaluation_time_ms: 0, + evaluation_time_ms: 2000, revision: 0, }, dag::Limits::default(), ) - .unwrap(); - reduce_sum_values_in_context(grouping, values, coverage, &context) + .unwrap() } -fn reduce_sum_values_in_context( + +#[cfg(test)] +fn reduce_sum_values( grouping: &asap_types::query_plan::PhysicalGrouping, values: &[(BTreeMap, SummaryValue)], coverage: Option<(u64, u64)>, - context: &dag::RunContext, ) -> Result { - use dag::{ - operators::{Operator, Reduction}, - values::{Batch, Value}, - }; - use planner_types::{ - post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, - pre_asap::DataType, - }; - let asap_types::query_plan::PhysicalGrouping::Reduce(keys) = grouping else { - return Ok(PhysicalQueryOutput::Value(values.to_vec(), coverage)); + use planner_types::pre_asap::{AggIntent, ColumnRef, GroupKeys}; + let asap_types::query_plan::PhysicalGrouping::Reduce(labels) = grouping else { + panic!("reduce fixture required") }; - let mut groups = BTreeMap::new(); - for (labels, value) in values { - let SummaryValue::Points(points, row_coverage) = value else { - return Err(PhysicalNodeError::ExpectedState); - }; - let labels = labels - .iter() - .filter(|(name, _)| keys.contains(name)) - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>(); - for (timestamp, value) in points { - let group = groups - .entry((labels.clone(), *timestamp)) - .or_insert_with(|| (Vec::new(), *row_coverage)); - group.0.push(*value); - group.1 = intersect_coverage(group.1, *row_coverage); - } - } - let groups = groups.into_iter().collect::>(); - let schema = std::sync::Arc::new(SummarySchema { - fields: vec![("group", DataType::Int64), ("value", DataType::Float64)] - .into_iter() - .map(|(name, dtype)| SummaryField { - name: name.into(), - dtype: SummaryFamilyType::Plain(dtype), - nullable: false, - }) - .collect(), - time_index: None, - }); - let rows = groups - .iter() - .enumerate() - .flat_map(|(index, (_, (values, _)))| { - values - .iter() - .map(move |value| vec![Value::Int64(index as i64), Value::Float64(*value)]) - }) - .collect(); - let error = PhysicalNodeError::Physical; - let batch = Batch::try_new(schema.clone(), rows).map_err(error)?; - let operator = Operator::aggregate(schema, vec![0], vec![("value".into(), Reduction::Sum(1))]) - .map_err(error)?; - let output = dag::batch_execution::evaluate_batch(batch, vec![operator], context.clone()) - .map_err(error)?; - let mut result = Vec::new(); - for row in output.iter().flat_map(|batch| batch.rows()) { - let [Value::Int64(index), Value::Float64(sum)] = row.as_slice() else { - return Err(PhysicalNodeError::ExpectedState); - }; - let ((labels, time), (_, row_coverage)) = &groups[*index as usize]; - result.push(( - labels.clone(), - SummaryValue::Points(vec![(*time, *sum)], *row_coverage), - )); - } - Ok(PhysicalQueryOutput::Value(result, coverage)) + let graph = asap_physical_operators::physical_planner::promql_values::compile_aggregate( + &AggIntent::Sum { col: None }, + &GroupKeys::by(labels.iter().cloned().map(ColumnRef::Named).collect()), + )?; + execute_value_fragment( + &graph.encode()?, + &[&PhysicalQueryOutput::Value(values.to_vec(), coverage)], + &test_value_context(), + ) } fn execute_physical_query_plan( @@ -983,6 +949,52 @@ mod tests { use asap_types::query_plan::{ExactReadout, PhysicalGrouping, QueryReadout}; use planner_types::pre_asap::ArithmeticOpKind; + fn native_scalar_node(value: f64) -> QueryPlanNode { + QueryPlanNode::PhysicalFragment { + inputs: vec![], + dag: asap_physical_operators::physical_planner::promql_values::compile_scalar(value) + .unwrap() + .encode() + .unwrap(), + row_input: None, + pruning: None, + } + } + + // Old computation nodes must fail explicitly rather than construct operators during a request. + #[test] + fn uncompiled_value_nodes_are_rejected() { + let store = SketchStore::new(); + let runtime = PhysicalQueryRuntime { + counter_parameters: Default::default(), + language: asap_types::query_plan::QueryLanguage::PromQl, + catalog: None, + context: QueryExecutionContext { + index: &store, + t0_ms: 0, + t1_ms: 2000, + is_cumulative: true, + allowed_materializations: None, + }, + }; + for node in [ + QueryPlanNode::Scalar { value: 1. }, + QueryPlanNode::Binary { + inputs: [QueryNodeId(0), QueryNodeId(0)], + operator: ArithmeticOpKind::Add, + }, + QueryPlanNode::ReduceSum { + input: QueryNodeId(0), + grouping: PhysicalGrouping::Reduce(vec![]), + }, + ] { + assert!( + matches!(runtime.execute_node(QueryNodeId(1), &node, &[], &test_value_context()), + Err(PhysicalNodeError::Physical(dag::Error::Invalid(message))) if message.contains("retained Planner physical graph")) + ); + } + } + // Real DAG resource failures survive both summary-readout and engine error adapters. #[test] fn summary_dag_resource_failures_never_become_capability_misses() { @@ -1003,12 +1015,13 @@ mod tests { }, }; let entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "resource-test".into(), canonical_query: "1".into(), fixed_evaluation: None, root: QueryNodeId(0), - nodes: [(QueryNodeId(0), QueryPlanNode::Scalar { value: 1. })].into(), + nodes: [(QueryNodeId(0), native_scalar_node(1.))].into(), instant: InstantExecution { lookback_ms: 0, full_history: false, @@ -1283,18 +1296,25 @@ 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(), fixed_evaluation: None, root: QueryNodeId(1), nodes: BTreeMap::from([ - (QueryNodeId(0), QueryPlanNode::Scalar { value: 1. }), + (QueryNodeId(0), native_scalar_node(1.)), ( QueryNodeId(1), - QueryPlanNode::Binary { - operator: ArithmeticOpKind::Add, - inputs: [QueryNodeId(0), QueryNodeId(0)], + QueryPlanNode::PhysicalFragment { + inputs: vec![QueryNodeId(0), QueryNodeId(0)], + dag: asap_physical_operators::physical_planner::promql_values::compile_binary( + &planner_types::post_asap::BinaryOperator { + kind: planner_types::pre_asap::BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), + vector_match: None, checked_relative_division: false, checked_finite_division: false, + }, false, true, true, + ).unwrap().encode().unwrap(), + row_input: None, pruning: None, }, ), ]), @@ -1417,6 +1437,39 @@ mod tests { assert_eq!(intersect_coverage(Some((1000, 2000)), None), None); } + // Stored points at different evaluation times are separate native invocations, + // with one parent resource budget and no cross-time label matching. + #[test] + fn retained_value_graph_preserves_time_alignment() { + let points = |values| { + PhysicalQueryOutput::Value( + vec![( + BTreeMap::from([("service".into(), "api".into())]), + SummaryValue::Points(values, Some((1000, 2000))), + )], + Some((1000, 2000)), + ) + }; + let left = points(vec![(1000, 2.), (2000, 6.)]); + let right = points(vec![(1000, 1.), (2000, 3.)]); + let PhysicalQueryOutput::Value(values, coverage) = + binary_values(&ArithmeticOpKind::Div, &left, &right).unwrap() + else { + panic!("vector required") + }; + let actual = values + .iter() + .flat_map(|(_, value)| { + let SummaryValue::Points(points, _) = value else { + panic!("points required") + }; + points.clone() + }) + .collect::>(); + assert_eq!(actual, vec![(1000, 2.), (2000, 2.)]); + assert_eq!(coverage, Some((1000, 2000))); + } + // Rollup adds partial counts rather than counting the number of series. #[test] fn exact_rollup_adds_uneven_observation_counts() { @@ -1567,6 +1620,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(), @@ -2005,6 +2059,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(), @@ -2106,6 +2161,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/request_tests.rs b/data_plane/src/query_engines/asap_query_engine/request_tests.rs index f70712abf..265e3b8cb 100644 --- a/data_plane/src/query_engines/asap_query_engine/request_tests.rs +++ b/data_plane/src/query_engines/asap_query_engine/request_tests.rs @@ -8,6 +8,7 @@ use std::{collections::BTreeMap, sync::Arc}; fn external_entry() -> QueryPlanEntry { QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "m".into(), canonical_query: "m".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/query_engines/request.rs b/data_plane/src/query_engines/request.rs index c7ccc2929..9ed97e24a 100644 --- a/data_plane/src/query_engines/request.rs +++ b/data_plane/src/query_engines/request.rs @@ -91,6 +91,11 @@ pub(crate) fn check() -> Result<(), Error> { } pub(crate) fn context(scope: Scope) -> Result { + context_with_limits(scope, Limits::default()) +} + +/// A request always shares its active control; standalone callers supply their limit. +pub(crate) fn context_with_limits(scope: Scope, limits: Limits) -> Result { check()?; ACTIVE.with(|slot| match slot.borrow().as_ref() { Some(active) => { @@ -98,7 +103,7 @@ pub(crate) fn context(scope: Scope) -> Result { context.scope = scope; Ok(context) } - None => RunContext::new(scope, Limits::default()), + None => RunContext::new(scope, limits), }) } 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 e72ebe171..f9bd10728 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. // `QueryTimeOperator::validate` rejects the mismatched pairing at @@ -598,6 +607,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..a7c2333a5 100644 --- a/data_plane/src/storage_engines/sketch_db/index/maintenance.rs +++ b/data_plane/src/storage_engines/sketch_db/index/maintenance.rs @@ -276,6 +276,28 @@ impl SketchStore { 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 + From<&'static str>>( + &self, + sid: u64, + definition: StoredOutputId, + generation: &Arc, + expected_windows: &BTreeSet<(u64, u64)>, + group: &BTreeMap, + mut decode: impl FnMut(&str, u8, &[u8]) -> Result, E>, + ) -> Result { let start_ms = expected_windows .iter() .map(|window| window.0) @@ -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 d831682f2..3c0ed9c27 100644 --- a/data_plane/src/storage_engines/sketch_db/index/mod.rs +++ b/data_plane/src/storage_engines/sketch_db/index/mod.rs @@ -613,6 +613,9 @@ impl Drop for StateMutation<'_> { #[derive(Default)] pub struct SketchStore { + /// Only populated in an immutable, query-wide revision view. + native_revision_batches: + Option>, pub(crate) revisions: RwLock>>, pub current_series: std::sync::Mutex, /// Held through each state append; completion takes the exclusive guard. @@ -2750,7 +2753,9 @@ impl SketchStore { /// surface; it is O(N sids) and meant for the 30 s diagnostic tick, not /// the hot path. pub fn approx_resident_bytes(&self) -> usize { - let mut total = 0usize; + let mut total = self.native_revision_batches.as_ref().map_or(0, |batches| { + batches.values().map(|batch| batch.bytes()).sum::() + }); // 1. SeriesId bindings, compatibility metadata, and shared SDS descriptors. if let Ok(insts) = self.instances.read() { @@ -3843,7 +3848,9 @@ impl crate::storage_engines::sketch_db::index::persistence::EpochSource for Sket // over. The hot-window seal (`seal_aged_epochs`) handles the // common case; this keeps the memory-pressure backstop honest for // a burst that outruns the hot window. - let mut total = 0usize; + let mut total = self.native_revision_batches.as_ref().map_or(0, |batches| { + batches.values().map(|batch| batch.bytes()).sum() + }); for entry in self.series.iter() { let Ok(data) = entry.value().read() else { continue; @@ -3869,7 +3876,9 @@ pub use SummarySeriesMetadata as SketchInstanceMetadata; // alongside the store that uses it. mod admission; mod maintenance; +mod native; pub(crate) use maintenance::{CompleteRawMaintenanceCohort, FrozenExactWindows}; +pub use native::NativeReadError; pub mod epoch_columnar; // `persistence` moved up to `sketch_db::persistence`. Re-exported here @@ -6833,7 +6842,8 @@ impl SketchStore { pinned: &crate::precompute_engine::revisions::PinnedRevision, ) -> Result { use crate::storage_engines::types::{KeyByLabelValues, PrecomputedOutput}; - let view = Self::new(); + let mut view = Self::new(); + view.native_revision_batches = Some(BTreeMap::new()); view.install_precompute_plan( plan.summary_catalog .clone() @@ -6856,6 +6866,25 @@ impl SketchStore { if expected != record.reference { return Err("revision view semantic binding mismatch".into()); } + if let Some(batch) = + crate::precompute_engine::revisions::decode_native_record(plan, record, usize::MAX)? + { + let key = ( + definition, + i64::try_from(record.start_ms)?, + i64::try_from(record.end_ms)?, + ); + if view + .native_revision_batches + .as_mut() + .unwrap() + .insert(key, batch) + .is_some() + { + return Err("duplicate native record in pinned revision".into()); + } + continue; + } let config = plan .precompute_plan .materializations 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..397bb3e63 --- /dev/null +++ b/data_plane/src/storage_engines/sketch_db/index/native.rs @@ -0,0 +1,733 @@ +//! 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; + +/// Preserve execution failures across the storage boundary; missing state remains +/// distinguishable from a terminal resource failure. +#[derive(Debug, thiserror::Error)] +pub enum NativeReadError { + #[error("{0}")] + Unavailable(String), + #[error("{0}")] + Physical(#[from] asap_physical_operators::Error), +} +impl From for NativeReadError { + fn from(message: String) -> Self { + Self::Unavailable(message) + } +} +impl From<&'static str> for NativeReadError { + fn from(message: &'static str) -> Self { + Self::Unavailable(message.into()) + } +} + +#[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()); + } + if let Some(batches) = &self.native_revision_batches { + address.validate().map_err(|e| e.to_string())?; + let generation = self + .active_catalog_generation() + .ok_or("native revision has no generation")?; + if (address.plan_id, address.plan_version) + != (generation.plan_id, generation.plan_version) + || address.stored_output_id != reference.stored_output_id + || self + .descriptors + .stored_output_reference(address.stored_output_id) + .as_ref() + != Some(reference) + { + return Err("native revision differs from installed binding".into()); + } + let batch = batches + .get(&( + address.stored_output_id, + address.window.start_ms, + address.window.end_ms, + )) + .ok_or("native output is absent from pinned revision")?; + if batch.schema() != &expected_schema { + return Err("native revision schema differs from installed contract".into()); + } + if batch.bytes() > max_bytes { + return Err(asap_physical_operators::Error::MemoryLimit.into()); + } + return Ok(batch.clone()); + } + 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()); + } + if bytes.len() > max_bytes { + return Err(NativeReadError::Physical( + asap_physical_operators::Error::MemoryLimit, + )); + } + let batch = decode_batch(bytes, expected_schema.clone(), max_bytes)?; + if batch.bytes() > max_bytes { + return Err(NativeReadError::Physical( + asap_physical_operators::Error::MemoryLimit, + )); + } + 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!(matches!( + store.read_native_summary_output( + resolver, + &address, + &reference, + expected_schema.clone(), + 1 + ), + Err(NativeReadError::Physical( + asap_physical_operators::Error::MemoryLimit + )) + )); + 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 47d0ddb29..b624d8164 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())) @@ -761,6 +764,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 b18ad599a..fa69c4ccf 100644 --- a/data_plane/tests/asapquery_compatibility_process_e2e.rs +++ b/data_plane/tests/asapquery_compatibility_process_e2e.rs @@ -27,6 +27,8 @@ mod distinct_planning_process; mod durable_summary_process; #[path = "support/immutable_maintenance_process.rs"] mod immutable_maintenance_process; +#[path = "support/native_revision_process.rs"] +mod native_revision_process; #[path = "support/revisable_maintenance_process.rs"] mod revisable_maintenance_process; @@ -212,7 +214,13 @@ fn unused_port() -> u16 { } async fn wait_until_ready(client: &reqwest::Client, url: &str, child: &mut Child) { - for _ in 0..120 { + let scale = std::env::var("ASAP_TEST_TIMEOUT_SCALE") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(1) + .max(1); + let deadline = tokio::time::Instant::now() + Duration::from_secs(30 * scale); + while tokio::time::Instant::now() < deadline { if let Some(status) = child.try_wait().expect("inspect backend process") { panic!("backend exited before readiness: {status}"); } @@ -688,12 +696,11 @@ async fn registered_temporal_topk(algorithm: planner_types::post_asap::SketchAlg ) { return Some(false); } - let dag = planner_types::post_asap::compile_executable_dag(&std::rc::Rc::new( - summary.clone(), - )) - .ok()?; + let dag = + planner_types::post_asap::compile_post_asap_dag(&std::rc::Rc::new(summary.clone())) + .ok()?; if dag.nodes.iter().any(|node| matches!(&node.payload, - planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { family: SummaryFamilyType::Sketch(kind, _), .. } if kind.algorithm() != &self.1)) { + planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { family: SummaryFamilyType::Sketch(kind, _), .. } if kind.algorithm() != &self.1)) { return Some(false); } self.0.summary_support_evidence(summary) diff --git a/data_plane/tests/clickhouse_differential_e2e.rs b/data_plane/tests/clickhouse_differential_e2e.rs index b9a6523d4..00ecd0bdf 100644 --- a/data_plane/tests/clickhouse_differential_e2e.rs +++ b/data_plane/tests/clickhouse_differential_e2e.rs @@ -634,10 +634,7 @@ async fn moving_windows_match_clickhouse_after_automatic_publication() { #[tokio::test] async fn collection_sql_executes_local_elements_after_typed_exact_leaf() { use asap_types::query_plan::QueryPlanNode; - use planner_types::{ - post_asap::ValueOperation, - pre_asap::{Column, DataType, QueryExpr, Schema}, - }; + use planner_types::pre_asap::{Column, DataType, Schema}; let Ok(clickhouse_url) = std::env::var("CLICKHOUSE_URL") else { eprintln!("skipping collection process E2E because CLICKHOUSE_URL is unset"); return; @@ -711,11 +708,19 @@ async fn collection_sql_executes_local_elements_after_typed_exact_leaf() { .values() .any(|node| matches!(node, QueryPlanNode::ExternalExact { .. }))); for function in ["asap_element_access", "asap_struct_field"] { - assert!(entry.nodes.values().any(|node| { - let QueryPlanNode::Relational { operation, .. } = node else { return false; }; - let ValueOperation::Project { cols, .. } = serde_json::from_value(operation.clone()).unwrap() else { return false; }; - cols.iter().any(|column| matches!(&column.expr, QueryExpr::FunctionCall { name, .. } if name == function)) - }), "Planner-selected DAG must preserve local {function} evaluation"); + assert!( + entry.nodes.values().any(|node| { + let QueryPlanNode::PhysicalRelation { dag, .. } = node else { + return false; + }; + let graph = + asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) + .unwrap(); + assert_eq!(graph.roots().len(), 1); + String::from_utf8(dag.clone()).unwrap().contains(function) + }), + "Planner physical DAG must preserve local {function} evaluation" + ); } eprintln!( "collection Planner selection: {}", diff --git a/data_plane/tests/support/issue_701_702_process.rs b/data_plane/tests/support/issue_701_702_process.rs index 533b0d4c4..0c555bcaf 100644 --- a/data_plane/tests/support/issue_701_702_process.rs +++ b/data_plane/tests/support/issue_701_702_process.rs @@ -346,10 +346,15 @@ fn issue_701_702_uncertified_ratios_require_exact_fallback() { let candidates = workload_cost::enumerate_exact_and_materialized_candidates(request).unwrap(); assert!(!candidates.is_empty()); + let mut exact_count = 0; for candidate in candidates { - let plan = DeploymentPlanCompiler - .compile_promql(candidate, environment.clone()) - .unwrap(); + let plan = match DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) { + Ok(plan) => plan, + Err(control_plane::physical::compiler::CompileError::Query { reason, .. }) + if reason == "selected query result has no certified accuracy guarantee satisfying the requested accuracy" => continue, + Err(error) => panic!("unexpected candidate rejection for {query}: {error}"), + }; + exact_count += 1; assert!(plan.precompute_plan.materializations.is_empty(), "{query}"); assert!( plan.query_plan.entries.values().all(|entry| matches!( @@ -359,6 +364,7 @@ fn issue_701_702_uncertified_ratios_require_exact_fallback() { "{query}" ); } + assert!(exact_count > 0, "no executable exact candidate for {query}"); } } @@ -392,21 +398,27 @@ async fn temporal_average_overflow_falls_back_after_state_is_warm() { .into(); let snapshot = quote_snapshot_for_test(serde_json::from_value(fixture).unwrap()); let plan = snapshot.clone().compile_promql().unwrap(); - assert!(plan - .query_plan - .entries - .values() - .flat_map(|entry| entry.nodes.values()) - .any(|node| matches!( - node, - control_plane::query_plan::QueryPlanNode::Logical { - operator: control_plane::query_plan::query_time::QueryTimeOperator::Binary { - operation: control_plane::query_plan::query_time::BinaryOperation::FiniteDiv, - .. - }, - .. - } - ))); + assert!( + plan.query_plan + .entries + .values() + .flat_map(|entry| entry.nodes.values()) + .any(|node| { + let control_plane::query_plan::QueryPlanNode::PhysicalFragment { dag, .. } = node + else { + return false; + }; + asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) + .unwrap(); + let document: serde_json::Value = serde_json::from_slice(dag).unwrap(); + document["nodes"].as_object().unwrap().values().any(|node| { + node["Operator"]["operator"]["kind"]["VectorBinary"]["operator"] + ["checked_finite_division"] + == true + }) + }), + "average must retain its native finite-division contract" + ); let output = tempfile::tempdir().unwrap(); let path = output.path().join("snapshot.json"); std::fs::write(&path, serde_json::to_vec(&snapshot).unwrap()).unwrap(); diff --git a/data_plane/tests/support/native_revision_process.rs b/data_plane/tests/support/native_revision_process.rs new file mode 100644 index 000000000..32e7eab0e --- /dev/null +++ b/data_plane/tests/support/native_revision_process.rs @@ -0,0 +1,278 @@ +use super::*; + +// One deployed query ensemble reads shared counters. Adding a late series must +// revise both the persisted grouped sum and the query-time ranking, including +// after restart, without mixing snapshots or falling back to an external engine. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn native_rate_ensemble_revises_and_recovers() { + run_native_ensemble(None, 60_000, 0).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn native_cms_rate_ensemble_revises_and_recovers() { + run_native_ensemble(Some("CmsWithHeap"), 60_000, 0).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn native_count_sketch_rate_ensemble_revises_and_recovers() { + run_native_ensemble(Some("CountSketchWithHeap"), 60_000, 0).await; +} + +// Overlapping sixty-second windows must publish at the selected ten-second +// cadence, including a late series and same-version restart. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sliding_native_rate_ensemble_revises_and_recovers() { + run_native_ensemble(None, 10_000, 5_000).await; +} + +async fn run_native_ensemble(family: Option<&str>, cadence_ms: u64, phase_ms: u64) { + use control_plane::physical::{ + compiler::{ + BackendLocalPlanningInput, DeploymentPlanCompiler, BACKEND_REVISION, PLANNER_REVISION, + }, + workload_cost::{ + enumerate_exact_and_materialized_candidates, manifest, WorkloadCostEvidence, + WorkloadQuote, + }, + }; + let queries = [ + "sum by (job) (rate(native_revision_counter[1m]))", + "topk by (job) (1, rate(native_revision_counter[1m]))", + ]; + let mut fixture: Value = serde_json::from_str(include_str!( + "../../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let template = fixture["query_workload"]["repeating_queries"][3].clone(); + fixture["query_workload"]["repeating_queries"] = Value::Array( + queries + .iter() + .map(|query| { + let mut entry = template.clone(); + entry["query"] = (*query).into(); + entry["demand"]["fixed_interval_at"]["interval"] = cadence_ms.into(); + entry["demand"]["fixed_interval_at"]["evaluation_phase"] = phase_ms.into(); + entry["requirements"]["accuracy"] = serde_json::json!({"explicit":"Exact"}); + entry + }) + .collect(), + ); + fixture["implementation"]["data_snapshot_id"] = "native-revision-ensemble".into(); + if family.is_some() { + fixture["query_workload"]["repeating_queries"][1]["requirements"]["accuracy"] = + serde_json::json!({"explicit":{"EpsilonDelta":{"epsilon":0.1,"delta":0.1}}}); + fixture["implementation"]["accuracy_evidence"] = serde_json::json!({queries[1]: { + "query_string": queries[1], "data_snapshot_id":"native-revision-ensemble", + "data_workload": fixture["data_workload"], "source":"enforced-fixture-contract", + "observed_at_unix_ms":9500, "valid_for_ms":60000, + "topk_max_distinct_items":1000, + "topk_selected_lower_bound":3.9, "topk_excluded_upper_bound":1.1, + "topk_interval_failure_probability":0.001 + }}); + } + let mut snapshot: BackendLocalPlanningInput = serde_json::from_value(fixture).unwrap(); + let (request, environment) = snapshot + .clone() + .into_physical_compilation_request() + .unwrap(); + let preferred = |plan: &control_plane::physical::compiler::CompiledPhysicalPlan| { + plan.precompute_plan.executable_dags.values().any(|dag| { + dag.native_programs + .values() + .any(|program| family.is_none_or(|family| program.to_string().contains(family))) + }) && plan.query_plan.entries.values().all(|entry| { + !entry.nodes.values().any(|node| { + matches!( + node, + asap_types::query_plan::QueryPlanNode::ExactFallback { .. } + | asap_types::query_plan::QueryPlanNode::ExternalExact { .. } + ) + }) + }) + }; + let mut eligible = 0; + 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 select = preferred(&plan); + if select { + eligible += 1; + } + let manifest = manifest(&plan, &candidate.queries).unwrap(); + Some(WorkloadQuote { + unit_costs: manifest + .components + .keys() + .map(|key| (key.clone(), if select { 1.0 } else { 1e12 })) + .collect(), + manifest, + executable: true, + }) + }) + .collect(); + assert!( + eligible > 0, + "ensemble has no feasible native precompute candidate" + ); + snapshot.workload_cost_evidence = Some(WorkloadCostEvidence { + backend_revision: BACKEND_REVISION.into(), + planner_revision: PLANNER_REVISION.into(), + data_snapshot_id: "native-revision-ensemble".into(), + model_version: "native-ensemble-fixture-cost".into(), + observed_at_unix_ms: 10000, + valid_for_ms: 60000, + quotes, + }); + let plan = snapshot.compile_promql().unwrap(); + assert!( + preferred(&plan), + "synthetic costs must select the native ensemble" + ); + assert_eq!(plan.query_plan.entries.len(), 2); + assert!( + plan.precompute_plan.materializations.iter().any(|config| { + config.derived_input.is_some() + && config.stored_window_ms() == 60_000 + && config.slide_interval * 1_000 == cadence_ms + }), + "the fixture must actually deploy the selected overlapping windows" + ); + let installed = 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"); + std::fs::write(&artifact, serde_json::to_vec(&installed).unwrap()).unwrap(); + let spawn = |port: u16| { + ChildGuard( + Command::new(env!("CARGO_BIN_EXE_data_plane")) + .arg("--physical-plan") + .arg(&artifact) + .arg("--http-port") + .arg(port.to_string()) + .arg("--output-dir") + .arg(directory.path()) + .arg("--enable-remote-write") + .arg("--remote-write-revision-dir") + .arg(directory.path().join("revisions")) + .args([ + "--remote-write-correction-horizon-ms", + "300000", + "--remote-write-revision-freshness-ms", + "60000", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(), + ) + }; + let client = reqwest::Client::new(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let start = if cadence_ms == 60_000 { + (now / 60_000 - 1) * 60_000 + } else { + // An eligible cadence boundary that is not a window-extent boundary. + (now / 60_000 - 2) * 60_000 + phase_ms as i64 + cadence_ms as i64 + }; + let end = start + 60_000; + let request = |instance: &str, last: f64| WriteRequest { + timeseries: vec![series_with_labels( + "native_revision_counter", + &[("instance", instance), ("job", "worker")], + &[(start + 1000, 100.), (end - 1000, last)], + )], + }; + let port = unused_port(); + let mut child = spawn(port); + let backend = format!("http://127.0.0.1:{port}"); + wait_until_ready(&client, &format!("{backend}/api/v1/health"), &mut child.0).await; + let (first, second, expected, winner) = if family.is_some() { + (332., 158., [5., 4.], "a") + } else { + (158., 216., [3., 2.], "b") + }; + assert_eq!( + remote_write(&client, &backend, &request("a", first)).await, + 204 + ); + assert_ensemble( + &client, + &backend, + &queries, + end, + if family.is_some() { [4., 4.] } else { [1., 1.] }, + "a", + ) + .await; + assert_eq!( + remote_write(&client, &backend, &request("b", second)).await, + 204 + ); + assert_ensemble(&client, &backend, &queries, end, expected, winner).await; + // Replay is idempotent; the newly admitted series must not be counted twice. + assert_eq!( + remote_write(&client, &backend, &request("b", second)).await, + 204 + ); + assert_ensemble(&client, &backend, &queries, end, expected, winner).await; + drop(child); + let port = unused_port(); + let mut child = spawn(port); + let backend = format!("http://127.0.0.1:{port}"); + wait_until_ready(&client, &format!("{backend}/api/v1/health"), &mut child.0).await; + assert_ensemble(&client, &backend, &queries, end, expected, winner).await; +} + +async fn assert_ensemble( + client: &reqwest::Client, + backend: &str, + queries: &[&str; 2], + end: i64, + expected: [f64; 2], + winner: &str, +) { + for (index, query) in queries.iter().enumerate() { + let response = client + .get(format!("{backend}/api/v1/query")) + .query(&[ + ("query", query.to_string()), + ("time", (end / 1000).to_string()), + ]) + .send() + .await + .unwrap(); + let status = response.status(); + let body: Value = response.json().await.unwrap(); + assert!(status.is_success() && is_warm(&body), "{query}: {body}"); + assert_eq!( + body["data"]["result"].as_array().unwrap().len(), + 1, + "{body}" + ); + let point = &body["data"]["result"][0]; + let value: f64 = point["value"][1].as_str().unwrap().parse().unwrap(); + assert!( + (value - expected[index]).abs() < 1e-9, + "{query}: expected {}, got {body}", + expected[index] + ); + assert_eq!(point["metric"]["job"], "worker"); + if index == 1 { + assert_eq!(point["metric"]["instance"], winner); + } + } +} 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/docs/design_docs/README.md b/docs/design_docs/README.md index 6570d0bec..6100142d8 100644 --- a/docs/design_docs/README.md +++ b/docs/design_docs/README.md @@ -13,6 +13,8 @@ integration. - [Summary definitions table and SDS](summary-catalog-sds-architecture.md) owns definition and instance identity, version-scoped state references, read eligibility and committed-state metadata. +- [Physical candidate handoff](physical-candidate-handoff.md) defines removal of + Backend operator lowering while preserving deployment and SDS contracts. - [QueryPlan DAG execution](query-dag-execution.md) explains how the query engine evaluates installed query sub-DAGs and reads bound stored summaries. - [Architecture migration delivery plan](asapplanner-migration-plan.md) defines diff --git a/docs/design_docs/physical-candidate-handoff.md b/docs/design_docs/physical-candidate-handoff.md new file mode 100644 index 000000000..f58b2ba5c --- /dev/null +++ b/docs/design_docs/physical-candidate-handoff.md @@ -0,0 +1,66 @@ +# Bind Planner physical candidates without Backend operator lowering + +Status: target design. The current Backend still performs the lowering described below. + +## Problem + +Backend shares Planner's operators and runtime, but it still reconstructs parts +of the computation. `QueryPlanNode` includes logical and relational operations; +control-plane query lowering creates those nodes; SQL and PromQL serving paths +compile some of them into physical fragments. This duplicates responsibility even +though execution eventually uses the shared library. + +## Required boundary + +```text +Logical Post-ASAP DAG + Summary Maintenance Lifecycle + → Planner Physical Plan Compiler + → supported Physical DAG candidates + → Backend feasibility, workload costs and selection + → Deployment Plan / DAG: PrecomputePlan + QueryPlan + → bind concrete inputs and execute the selected Physical DAGs +``` + +`CompiledPhysicalDag` is an implementation type, not another architectural layer. +Backend must preserve candidate operators, dependencies, sharing, roots and +materialization boundaries. Runtime input binding may select eligible records; +it must not select algorithms, rebuild operators, or move work between phases. + +## Implementation changes + +| Component | Change | +| --- | --- | +| Planner handoff | Consume complete executable physical candidates and their typed boundaries; report missing Planner implementations explicitly | +| Deployment compiler | Price feasible candidates, select one, and install source/SDS bindings without logical lowering | +| Installed query | Store the selected physical computation and deployment metadata, removing the parallel Backend computation node representation | +| SQL execution | Remove request-time `PostAsapDagNode`, `compile_node` and physical graph reconstruction | +| PromQL execution | Move computation currently lowered by `residual.rs` and `logical_dag/native_values.rs` to Planner; retain protocol and input conversion | +| Precomputation | Bind the candidate's precompute graph and stored outputs without reclassifying operators by kind | +| Persistence | Update the installed-plan schema; reject incompatible stored plans explicitly rather than retaining a legacy execution path | + +SDS semantic identity, deployed-output identity, same-version recovery, query-wide +revision pinning, freshness, correction horizons, request resource budgets and +terminal cancellation/resource errors remain deployment contracts. + +A gap in Planner support must be fixed and tested in Planner before deleting the +corresponding Backend path. A missing connector is deployment infeasibility; +losing an operator's semantics in an adapter is not. + +## Acceptance + +1. Structural tests compare each selected candidate with its installed graph: + operators, parameters, typed edges, shared producers, roots and frontiers remain + unchanged. Include a shared-producer query ensemble. +2. Synthetic-cost tests reverse candidate selection without changing a candidate's + computation or introducing unpriced work. +3. Deployment E2E tests compile, install, ingest, publish and query each supported + candidate, checking exact results or admitted approximation guarantees. +4. Recovery tests preserve SDS identity and common-revision reads; a new plan + version requires its own warm-up. +5. Request tests retain cancellation and memory exhaustion as terminal errors, + including nested DAGs, range evaluation and concurrent revision publication. +6. Production serving modules do not import semantic lowering or compile nodes. + Decoder validation and binding to typed sources remain permitted. + +This work is complete only when the installed production path uses the handoff, +not when a new API or isolated test executes a physical fragment successfully. diff --git a/docs/design_docs/precompute-dag-execution.md b/docs/design_docs/precompute-dag-execution.md index f7c1610e7..9bd7f6050 100644 --- a/docs/design_docs/precompute-dag-execution.md +++ b/docs/design_docs/precompute-dag-execution.md @@ -2,6 +2,9 @@ Audience: backend designers and developers. +Status: target design. The [physical handoff](physical-candidate-handoff.md) +tracks the remaining migration from Backend computation representations. + This document specifies the precompute engine design: how it receives a Planner-provided physical candidate selected by Backend, runs its DAGs over data partitions, and publishes the @@ -33,7 +36,8 @@ Once the required output is ready, query latency excludes its construction work. “Compute once” refers to a particular output, population and window: subsequent windows still require construction or updates under the selected schedule. -The physical compiler splits the selected DAG at its stored outputs: +Planner compiles candidate Physical DAGs with explicit stored-output boundaries. +Backend selects a feasible candidate and binds those boundaries: ```mermaid flowchart LR @@ -56,9 +60,8 @@ the compiler adds concrete source and storage bindings. A worker runs the **whole precompute subgraph for its assigned data partition**. For this example, one worker handles `service=api` and another can handle -`service=worker`. Both execute the same graph. Each worker runs its nodes -sequentially in dependency order; workers process independent partitions -concurrently. A worker is an execution task, not necessarily a dedicated OS +`service=worker`. Both execute the same graph. The shared Planner runtime executes dependencies within a DAG run; Backend +workers may process independent partitions concurrently. A worker is an execution task, not necessarily a dedicated OS thread. Only outputs selected for persistence become stored summaries. Intermediate @@ -68,9 +71,11 @@ can read the same output. ## 2. Inputs and outputs -The physical compiler receives the selected post-ASAP DAG and the selected -deployment guarantee and schedule/retention. It emits one coherent plan version -containing definition rows, a PrecomputePlan, and matching QueryPlans. +The Backend deployment compiler receives Planner Physical DAG candidates and +their lifecycle requirements. It selects a feasible candidate using workload +costs and binds concrete inputs and stored outputs. The installed plan version +contains definition rows, a PrecomputePlan and matching QueryPlans. Backend +does not lower Post-ASAP operations or choose unpriced computation at installation. | Input to the precompute engine | Purpose | | --- | --- | @@ -99,29 +104,42 @@ Installation performs four steps: 1. Validate the graph and operator input/output types, including ordered edge roles for operators with multiple inputs. Validate source, definition and stored-output bindings together with the matching QueryPlans. -2. Bind supported runtime operators and compile a dependency execution order. - Preserve Planner families and parameters: Rate and Increase remain distinct, - and grouping does not create a separate backend family. +2. Decode and validate the retained Planner Physical DAG. Preserve its operators, + parameters, dependency edges, shared producers and roots. Bind typed input + contracts without recompiling operators or deriving another execution graph. 3. Derive a partitioning rule that keeps all required dependencies and reductions local to a worker, as described below. Check that execution can satisfy the selected deployment guarantee and schedule/retention. 4. Build node and source-routing indexes, register the validated definition rows, and activate the coherent plan version for routing and execution. +A retained graph may have several stored-output roots. Outputs with the same +input frontiers, window extent, cadence and phase execute together against one +immutable input snapshot. The runtime consumes every root concurrently under +one memory budget, so a shared producer executes once. Both finite-input +publication and continuous revision publication use these results. Outputs +with different window contracts require separate runs. Recovery validates all +roots and their bindings; it must not silently select only the first root. + +Window extent and publication cadence are independent. A 60-second window +published every 10 seconds includes the ranges 0–60, 10–70 and 20–80 seconds. +Continuous revisions recompute each affected window on that cadence, preserving +the selected phase; they must not publish only disjoint 60-second windows. + Unsupported operators, incompatible bindings, cycles and unsatisfied deployment requirements fail installation before the plan becomes active. | Object | Lifetime and contents | | --- | --- | | `PrecomputePlan` | Compiler-supplied computation and bindings for one plan version. | -| `InstalledPrecomputePlan` | Backend-derived execution order, immutable operator programs, bindings, routing indexes and validated partitioning rule. Shared by the router and workers. | +| `InstalledPrecomputePlan` | Retained Planner Physical DAGs, deployment bindings, routing indexes and validated partitioning rule. Shared by the router and workers. | | Worker execution state | Mutable node state, input ordering buffers, windows and intermediate results for that worker's assigned partitions. | `InstalledPrecomputePlan` is internal runtime data, not another serialized configuration or independently installable plan. All execution lookup tables come from the validated DAG and its bindings. There is no separately maintained -aggregation list. Operator fusion is permitted only when it preserves the DAG's -semantics and physical-to-semantic provenance. +aggregation list. Physical optimization belongs to Planner; Backend does not +fuse, split or substitute operators after candidate pricing. ## 4. Route data so each worker can execute the whole subgraph @@ -183,9 +201,9 @@ arrows denote shared immutable program access; solid arrows denote work or data. ```mermaid flowchart TB - D[Selected Planner DAG] --> C[Physical compiler: split at stored outputs] - C --> P[PrecomputePlan: Read -> Group -> KLL -> Write] - C --> QP[QueryPlans: Read stored KLL -> Estimate] + D[Planner Physical DAG candidate] --> C[Backend: price, select and bind] + C --> P[PrecomputePlan: retained precompute DAG + bindings] + C --> QP[QueryPlans: retained query DAG + bindings] P --> IP[InstalledPrecomputePlan: shared immutable program] T[Scheduled window and source partitions] --> R[Route by service] @@ -237,18 +255,16 @@ Installed precompute DAGs execute through its `PhysicalDag` runtime. The backend storage frontiers, declared edge order, window completeness and durable commit keys. It does not own a second dependency walker. -For completed-window DAGs, SummaryAgg uses the native summary builder, -SummaryMerge uses the native state merge, finalization uses native typed readout, -and Binary lowers aligned rows to native Project using the Planner binary contract, including checked division. Batch conversion -preserves the installed population and timestamp bindings. Native calls receive -the surrounding execution context, so they share its memory budget and -cancellation. The scheduler accepts the caller's run context rather than creating -an independent budget. Physical cancellation and memory errors retain their -error types through maintenance execution. A failed worker stops processing -input and publishing outputs, and closes admission; the failing drain reports -the original failure and shutdown -does not flush more state. Recovery requires restarting the failed execution. -Query execution uses the same library's operations. +Completed-window execution binds immutable population/window state to the +retained graph. Native summary build, merge, finalization and aligned binary +operators execute inside the shared runtime. Backend converts stored input and +output representations without interpreting operator payloads. + +One run context supplies cancellation and a memory budget to sources, operators +and retained outputs. Resource exhaustion and cancellation retain their error +types. A failed worker closes admission and stops publishing; drain reports the +original failure and shutdown does not flush additional state. Recovery requires +restarting the failed execution. Raw ingestion retains per-window accumulator state through shared-library updaters; worker routing and window completion remain backend responsibilities. @@ -256,17 +272,13 @@ Storage publication occurs only after successful DAG execution. It is separate from the library's request-local caching of intermediate results. ```text -execute(partition, evaluation_window, bound_inputs): - check input identities and required completeness - create one intermediate-result map for this evaluation - for node in installed dependency order: - if this evaluation does not require node: continue - obtain inputs in their declared edge-role order - if a required input is incomplete: keep dependent work pending - otherwise: - execute node using this partition's node state - retain its result for every downstream consumer - publish completed selected outputs with their stored-output bindings +execute(partition, evaluation_window, input_revision): + pin eligible input records for every required boundary + validate identities, coverage and revision compatibility + bind typed sources to the retained Planner Physical DAG + execute all selected roots in one shared-runtime run + validate output contracts + publish successful outputs through their installed stored-output bindings ``` A shared upstream node is evaluated once for the same partition, window and