From 916add3f5034019af248f88855cd081585d891b5 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:08:06 +0000 Subject: [PATCH 1/8] refactor(planner): remove the summary maintenance lifecycle Stage 2 materialization (#509) will decide per sub-DAG whether and when to materialize, so the Planner no longer chooses a maintenance lifecycle. - MajorPass now runs search_workload_with_targets -> global_selection -> assemble_selected_dag per root -> share_common_sub_dags. - QueryLifecyclePlan becomes QueryPlan { entry_index, root }; PlanOutput's execution_timed_dag times the roots directly. LifecycleInput, the lifecycle errors and UserInput's `lifecycle` field are gone (public API break). - Delete summary_maintenance_lifecycle, summary_maintenance_cost, summary_maintenance_dag_export, post_asap::{summary_maintenance, summary_maintenance_lifecycle} and SummaryWindowFramework (the pane primitives stay), the lifecycle CostModel hooks, CandidateCostOverrides and EmpiricalEvidenceProvider::lifecycle_cost_inputs. - Delete the lifecycle e2e test and the viewer's lifecycle-plan UI; rewire e2e_plan, summary_sharing, operator_design_examples and weighted_topk_binding to the new pipeline. Co-Authored-By: Claude Opus 5.5 --- .../asap-aware-mapping/src/analytical_cost.rs | 4 +- crates/asap-aware-mapping/src/cost_model.rs | 145 +- .../asap-aware-mapping/src/empirical_cost.rs | 92 +- crates/asap-aware-mapping/src/lib.rs | 37 +- crates/asap-aware-mapping/src/pass/major.rs | 152 +- crates/asap-aware-mapping/src/pass/mod.rs | 157 +- crates/asap-aware-mapping/src/replacement.rs | 139 +- .../src/summary_maintenance_cost/estimator.rs | 1219 ----- .../src/summary_maintenance_cost/evidence.rs | 386 -- .../src/summary_maintenance_cost/mod.rs | 51 - .../src/summary_maintenance_cost/model.rs | 3660 --------------- .../src/summary_maintenance_cost/window.rs | 252 -- .../src/summary_maintenance_dag_export.rs | 132 - .../src/summary_maintenance_lifecycle.rs | 3925 ----------------- .../tests/weighted_topk_binding.rs | 129 +- .../tests/operator_design_examples.rs | 37 +- .../summary_maintenance_lifecycle_e2e.rs | 1155 ----- crates/planner/src/lib.rs | 33 +- crates/planner/tests/e2e_plan.rs | 133 +- crates/planner/tests/summary_sharing.rs | 311 +- crates/types/src/post_asap/mod.rs | 12 +- .../src/post_asap/summary_maintenance.rs | 38 - .../summary_maintenance_lifecycle.rs | 74 - crates/types/src/post_asap/summary_window.rs | 50 +- .../workload-demand-and-summary-lifecycle.md | 758 ---- tools/dag-viewer/README.md | 9 - .../lifecycle-summary-maintenance.png | Bin 174559 -> 0 bytes tools/dag-viewer/render.py | 17 - tools/dag-viewer/test_render.py | 37 - tools/dag-viewer/viewer.js | 77 +- 30 files changed, 288 insertions(+), 12933 deletions(-) delete mode 100644 crates/asap-aware-mapping/src/summary_maintenance_cost/estimator.rs delete mode 100644 crates/asap-aware-mapping/src/summary_maintenance_cost/evidence.rs delete mode 100644 crates/asap-aware-mapping/src/summary_maintenance_cost/mod.rs delete mode 100644 crates/asap-aware-mapping/src/summary_maintenance_cost/model.rs delete mode 100644 crates/asap-aware-mapping/src/summary_maintenance_cost/window.rs delete mode 100644 crates/asap-aware-mapping/src/summary_maintenance_dag_export.rs delete mode 100644 crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs delete mode 100644 crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs delete mode 100644 crates/types/src/post_asap/summary_maintenance.rs delete mode 100644 crates/types/src/post_asap/summary_maintenance_lifecycle.rs delete mode 100644 docs/design_docs/proposals/asap-aware-mapping/workload-demand-and-summary-lifecycle.md delete mode 100644 tools/dag-viewer/lifecycle-summary-maintenance.png diff --git a/crates/asap-aware-mapping/src/analytical_cost.rs b/crates/asap-aware-mapping/src/analytical_cost.rs index d705d323f..0756106b8 100644 --- a/crates/asap-aware-mapping/src/analytical_cost.rs +++ b/crates/asap-aware-mapping/src/analytical_cost.rs @@ -2140,8 +2140,6 @@ pub enum AnalyticalCostError { UnsupportedDataArrival(DataArrival), #[error("ingestion rate must be finite and non-negative, got {0}")] InvalidIngestionRate(f64), - #[error("summary lifecycle, maintenance mode, and evaluation schedule are inconsistent")] - IncompatibleLifecycleGuarantee, #[error("bootstrap row and byte evidence must either both be zero or both be non-zero")] InconsistentBootstrapEvidence, #[error("required summary operation cost {0} must be finite and positive, got {1}")] @@ -2172,7 +2170,7 @@ pub enum AnalyticalCostError { UnsupportedQueryOperator, #[error("inconsistent operator statistics: {0}")] InconsistentOperatorStatistics(&'static str), - #[error("summary operation {0} has no lifecycle-aware cost formula")] + #[error("summary operation {0} has no cost formula")] UnsupportedSummaryOperation(&'static str), #[error("required comparison-scope field {0} is missing")] MissingComparisonScope(&'static str), diff --git a/crates/asap-aware-mapping/src/cost_model.rs b/crates/asap-aware-mapping/src/cost_model.rs index 9fc170666..9589d1382 100644 --- a/crates/asap-aware-mapping/src/cost_model.rs +++ b/crates/asap-aware-mapping/src/cost_model.rs @@ -51,12 +51,10 @@ use std::rc::Rc; use crate::exact_composition::ExactOperation; use asap_types::ir::{ASAPOp, Operator, OperatorNode}; use asap_types::post_asap::{ - FieldDataType, GroupingStrategy, HydraParams, ResultGuarantee, SketchAlgorithm, SketchParams, - SketchStatistic, SummaryMaintenanceLifecycleGuarantee, SummaryWindowFramework, + FieldDataType, GroupingStrategy, HydraParams, SketchAlgorithm, SketchParams, SketchStatistic, }; use asap_types::pre_asap::agg_intent::AggIntent; use asap_types::pre_asap::expr_ir::ColumnRef; -use asap_types::types::AccuracyTarget; use crate::exact_composition::{ExactComposition, OperationPlacement}; use crate::recurrence::{ @@ -66,9 +64,6 @@ use crate::recurrence::{ use crate::replacement::{ realize_child, Realization, Replacement, ReplacementProvenance, ReplacementSubDAG, TargetSubDAG, }; -use crate::summary_maintenance_lifecycle::{ - SummaryMaintenanceCapabilities, SummaryMaintenanceLifecycleCostInputs, -}; // ── Recurring-cost vocabulary for mixed exact/summary plans (issue #171) ── @@ -298,38 +293,6 @@ pub struct CseCandidate<'a> { #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] pub struct Cost(pub f64); -/// One physical summary state and the lifecycle selected for that exact DAG -/// node. Node identity is preserved so whole-DAG models can bind per-state -/// evidence without relying on traversal order. -pub struct CostedSummaryDeployment<'a> { - pub summary: &'a OperatorNode, - pub guarantee: &'a SummaryMaintenanceLifecycleGuarantee, - pub selected_cost: Cost, -} - -/// Complete candidate estimate returned to lifecycle and global plan search. -/// -/// A deployment-aware model may compare abstract summary-window primitives -/// using evidence supplied by downstream implementations. `window_frameworks` -/// is planner IR: it records the selected semantic realization contract. -/// `physical_plan_id` is separate provider-owned provenance for the concrete -/// implementation whose evidence won; it is not interpreted as planner IR. -#[derive(Debug, Clone, PartialEq)] -pub struct CompleteSummaryCandidateEstimate { - pub cost: Cost, - /// Stable provider identity of the complete implementation whose evidence - /// produced this estimate. - pub physical_plan_id: Option, - /// Window choice for each entry of the `deployments` slice passed to the - /// complete-cost hook. `None` explicitly means that deployment does not - /// use a summary-window framework. - pub window_frameworks: Vec>, - /// End-to-end guarantee supplied by the selected window realization. - /// `None` means that the complete model supplied no window-specific - /// guarantee; `Some` may be exact or approximate. - pub window_accuracy_guarantee: Option, -} - impl Cost { /// The cost of an operation that costs nothing at all. pub const ZERO: Cost = Cost(0.0); @@ -770,112 +733,6 @@ pub trait CostModel { f64::NAN } - /// Primitive build, update, read, retention, and retirement costs used to - /// compare physical summary-state lifecycles. Unknown values stay - /// unknown, preventing long-lived deployments from winning through - /// optimistic zeroes. - fn summary_maintenance_lifecycle_cost_inputs( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - SummaryMaintenanceLifecycleCostInputs::default() - } - - /// Horizon-aware form used by lifecycle planning. Models whose retention - /// objective is capacity rather than byte-seconds can normalize their - /// rate so the horizon integral equals one peak-capacity charge. - fn summary_maintenance_lifecycle_cost_inputs_for_horizon( - &self, - summary: &OperatorNode, - _horizon: Option, - ) -> SummaryMaintenanceLifecycleCostInputs { - self.summary_maintenance_lifecycle_cost_inputs(summary) - } - - /// Physical update/merge/delete support for one concrete summary. The - /// conservative default advertises no long-lived maintenance capability. - fn summary_maintenance_capabilities( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceCapabilities { - SummaryMaintenanceCapabilities::default() - } - - /// Replace the sum of selected per-state lifecycle costs with a complete - /// root-DAG cost. The default preserves legacy models. Evidence-strict - /// models return `None` when any root operation is unavailable; callers - /// must not then reuse the partial per-state sum. - fn complete_summary_candidate_cost( - &self, - _root: &OperatorNode, - _target: Option<&OperatorNode>, - deployments: &[CostedSummaryDeployment<'_>], - _horizon: Option, - _expected_reads: Option, - _required_accuracy: &[AccuracyTarget], - ) -> Option { - Some(Cost( - deployments - .iter() - .map(|deployment| deployment.selected_cost.0) - .sum(), - )) - } - - /// Complete cost together with selected implementation provenance and - /// planner-visible window primitives. The default preserves cost models - /// that do not perform either decision. - fn complete_summary_candidate_estimate( - &self, - root: &OperatorNode, - target: Option<&OperatorNode>, - deployments: &[CostedSummaryDeployment<'_>], - horizon: Option, - expected_reads: Option, - required_accuracy: &[AccuracyTarget], - ) -> Option { - self.complete_summary_candidate_cost( - root, - target, - deployments, - horizon, - expected_reads, - required_accuracy, - ) - .map(|cost| CompleteSummaryCandidateEstimate { - cost, - physical_plan_id: None, - window_frameworks: vec![None; deployments.len()], - window_accuracy_guarantee: None, - }) - } - - /// Whether the complete-candidate hook is authoritative for lifecycle - /// costs. When true, lifecycle alternatives rejected only because their - /// legacy per-state cost is missing remain eligible for complete-DAG - /// evaluation. Semantic and runtime-capability rejections still apply. - fn complete_summary_candidate_estimate_covers_lifecycle_costs(&self) -> bool { - false - } - - /// Cost of evaluating `target` directly from its logical/raw inputs once. - /// When known, lifecycle-aware materialization compares this fallback with - /// the aggregate cost of the selected summary deployments. - fn raw_query_recompute_cost(&self, _target: &OperatorNode) -> Option { - None - } - - /// Complete raw cost over the comparison context. The default preserves - /// per-read models; context-aware models override this when raw input - /// cardinality changes between evaluations. - fn raw_query_recompute_total_cost( - &self, - target: &OperatorNode, - expected_reads: f64, - ) -> Option { - self.raw_query_recompute_cost(target) - .map(|per_read| Cost(per_read.0 * expected_reads)) - } /// Physical feasibility evidence for a complete summary candidate. /// `None` defers admission to physical/deployment compilation; `Some(false)` /// excludes the candidate without changing its computation or parameters. diff --git a/crates/asap-aware-mapping/src/empirical_cost.rs b/crates/asap-aware-mapping/src/empirical_cost.rs index 18d1d7ee4..eb9904f77 100644 --- a/crates/asap-aware-mapping/src/empirical_cost.rs +++ b/crates/asap-aware-mapping/src/empirical_cost.rs @@ -2,16 +2,14 @@ //! configuration and environment; they are neither runtime feedback nor proofs //! of an accuracy guarantee. CPU quantities are nanoseconds, never CPU operations. -use asap_types::ir::{ASAPOp, Operator, OperatorNode}; -use asap_types::post_asap::{FieldDataType, GroupingStrategy, SketchAlgorithm, SketchParams}; +use asap_types::post_asap::{SketchAlgorithm, SketchParams}; use asap_types::pre_asap::AggIntent; use serde::{Deserialize, Serialize}; -use crate::cost_model::{Cost, CostModel, DefaultCostModel}; +use crate::cost_model::{CostModel, DefaultCostModel}; use crate::replacement::{ accuracy_budget, accuracy_target, default_size_params, ReplacementSubDAG, TargetSubDAG, }; -use crate::summary_maintenance_lifecycle::SummaryMaintenanceLifecycleCostInputs; pub const EVIDENCE_SCHEMA_VERSION: u32 = 1; pub const EVIDENCE_MODEL_VERSION: &str = "empirical-update-cpu-v1"; @@ -207,40 +205,6 @@ impl EmpiricalEvidenceProvider { costs.sort_by(|a, b| a.1.total_cmp(&b.1)); costs.into_iter().map(|(algorithm, _)| algorithm).collect() } - - /// Costs for one independently instantiated sketch state, in CPU ns. - /// Unknown retention/retirement remain unavailable; CPU time must not be - /// mixed with an existing deployment's unitless or CPU-operation costs. - pub fn lifecycle_cost_inputs( - &self, - summary: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - let Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, GroupingStrategy::PerSubpopulationInstance), - grouping: GroupingStrategy::PerSubpopulationInstance, - .. - }) = &summary.operator - else { - return SummaryMaintenanceLifecycleCostInputs::default(); - }; - let Ok(row) = self.lookup(kind.algorithm(), kind.params()) else { - return SummaryMaintenanceLifecycleCostInputs::default(); - }; - SummaryMaintenanceLifecycleCostInputs { - build_cost: snapshot_build_cpu(row).map(Cost), - maintenance_cost_per_update: row - .metrics - .resources - .cpu - .update_cpu_ns - .as_ref() - .map(|m| Cost(m.value)), - // A point-frequency benchmark read does not price a total-count - // or quantile read. There is no query request in this hook. - summary_read_cost: None, - ..Default::default() - } - } } /// Standalone adapter for the existing planner boundary. Empirical data changes @@ -276,13 +240,6 @@ impl CostModel for EmpiricalCostModel { fn estimate_cost(&self, candidate: &ReplacementSubDAG, target: &TargetSubDAG<'_>) -> f64 { DefaultCostModel.estimate_cost(candidate, target) } - - fn summary_maintenance_lifecycle_cost_inputs( - &self, - summary: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - self.provider.lifecycle_cost_inputs(summary) - } } impl EvidenceArtifact { @@ -360,14 +317,6 @@ fn nonnegative(value: f64) -> bool { value.is_finite() && value >= 0.0 } -fn snapshot_build_cpu(row: &OfflineMeasurement) -> Option { - let cpu = row.metrics.resources.cpu.build_cpu_ns.as_ref()?.value - + row.metrics.resources.cpu.update_cpu_ns.as_ref()?.value - * row.distribution.sample_count as f64 - + snapshot_prepare_cpu(row)?; - nonnegative(cpu).then_some(cpu) -} - /// The existing fixed-snapshot CMS/CountSketch contract needs no separate /// preparation. Other families must measure that phase, including an explicit /// zero when no preparation is necessary; absence is not free work. @@ -490,30 +439,6 @@ mod tests { ); } - /// Lifecycle build includes all measured snapshot updates, not just an empty - /// allocation. A missing update measurement cannot become free ingestion. - #[test] - fn lifecycle_build_requires_complete_snapshot_ingestion() { - let (mut artifact, _, _) = fixture(); - let row = &mut artifact.records[0]; - row.metrics.resources.cpu.build_cpu_ns = Some(Measurement { - value: 10.0, - stddev: None, - samples: 1, - method: None, - }); - assert_eq!(snapshot_build_cpu(row), Some(20010.0)); - row.metrics.resources.cpu.prepare_cpu_ns = Some(Measurement { - value: 17.0, - stddev: None, - samples: 1, - method: None, - }); - assert_eq!(snapshot_build_cpu(row), Some(20027.0)); - row.metrics.resources.cpu.update_cpu_ns = None; - assert_eq!(snapshot_build_cpu(row), None); - } - /// Newly shared optional dimensions receive the same numeric validation. #[test] fn optional_prepare_and_scan_measurements_are_validated() { @@ -537,28 +462,21 @@ mod tests { /// Only the established frequency-sketch contract can omit preparation. #[test] - fn unmeasured_preparation_for_other_families_keeps_build_unknown() { + fn unmeasured_preparation_for_other_families_stays_unknown() { let (mut artifact, _, _) = fixture(); let row = &mut artifact.records[0]; - row.metrics.resources.cpu.build_cpu_ns = Some(Measurement { - value: 10.0, - stddev: None, - samples: 1, - method: None, - }); - assert_eq!(snapshot_build_cpu(row), Some(20010.0)); row.algorithm = SketchAlgorithm::CountSketch; assert_eq!(snapshot_prepare_cpu(row), Some(0.0)); row.algorithm = SketchAlgorithm::Kll; row.params = SketchParams::Kll { k: 269 }; - assert_eq!(snapshot_build_cpu(row), None); + assert_eq!(snapshot_prepare_cpu(row), None); row.metrics.resources.cpu.prepare_cpu_ns = Some(Measurement { value: 17.0, stddev: None, samples: 1, method: None, }); - assert_eq!(snapshot_build_cpu(row), Some(20027.0)); + assert_eq!(snapshot_prepare_cpu(row), Some(17.0)); } fn fixture() -> (EvidenceArtifact, EvidenceContext, AggIntent) { diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 8f6ffed47..b2a1df3da 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -41,14 +41,11 @@ //! to obtain ranked views, and perform selection downstream. //! - Call [`CandidateLogicalASAPDAGs::global_selection`] once for the workload, then //! [`GlobalSelection::assemble_selected_query`] for each query root. This -//! coordinates logical choices and preserves shared nodes, but makes no -//! summary-maintenance lifecycle decision. -//! - When Planner owns maintenance-versus-recomputation decisions, use -//! [`global_selection_with_summary_maintenance_lifecycles`] followed by -//! [`assemble_selected_dag_with_summary_maintenance_lifecycles`] per root. -//! This alternative workflow returns [`SummaryMaintenanceLifecyclePlan`] -//! values containing DAG roots and maintenance decisions; callers do not need -//! to run ordinary selection/assembly first. +//! coordinates logical choices and preserves shared nodes. Whether and when +//! a summary state is materialized is not decided here: every summary runs +//! at query time until Stage 2 materialization (#509) owns that choice. +//! - Run the whole pipeline through [`optimize`] with [`MajorPass`], which +//! performs the two steps above for every root of a parsed workload. //! //! Models and evidence determine which choices the helpers can justify. //! Physical operator binding, placement, storage, deployment, and execution @@ -172,9 +169,6 @@ pub mod replacement; pub mod rewrite; pub mod rollup; pub mod storage_io; -pub mod summary_maintenance_cost; -pub mod summary_maintenance_dag_export; -pub mod summary_maintenance_lifecycle; #[cfg(test)] mod test_support; pub mod topk_reuse; @@ -185,7 +179,6 @@ pub use accuracy::{ CompositionShape, DefaultAccuracyModel, EqualSplitAllocator, NoAccuracyEvidence, PropagationStats, WorkloadAccuracyEvidence, }; -pub use cost_model::CompleteSummaryCandidateEstimate; pub use cost_model::{ maintenance_operation_plan_cost_rate, raw_recompute_cost_rate, read_operation_plan_cost_rate, CostModel, CostProvenance, CostUnit, DefaultCostModel, ExactCompositionCostInputs, @@ -197,9 +190,8 @@ pub use explanation::{ }; pub use grouping::{has_subpopulations, HydraGroupingStrategy}; pub use pass::{ - optimize, LifecycleInput, MajorPass, OptimizationInput, OptimizationInputError, - OptimizationPass, OptimizeError, PassNameConflict, PassRegistry, PlanOutput, PlanningModels, - QueryLifecyclePlan, + optimize, MajorPass, OptimizationInput, OptimizationInputError, OptimizationPass, + OptimizeError, PassNameConflict, PassRegistry, PlanOutput, PlanningModels, QueryPlan, }; pub use recurrence::{ evaluation_rate_of, total_cost, update_rate_from_data_workload, CostRate, EvaluationRate, @@ -216,21 +208,6 @@ pub use replacement::{ MAX_SEARCH_ITERATIONS, }; pub use rewrite::{AvgToSumOverCountStrategy, SemanticEquivalentRewriteStrategy}; -pub use summary_maintenance_dag_export::{ - export_summary_maintenance_plan, SummaryMaintenanceDAGExport, - SummaryMaintenanceDeploymentExport, SummaryMaintenanceLifecycleAlternativeExport, -}; -pub use summary_maintenance_lifecycle::{ - assemble_selected_dag_with_summary_maintenance_lifecycles, - enumerate_summary_maintenance_lifecycles, global_selection_with_summary_maintenance_lifecycles, - plan_summary_maintenance_lifecycles, SummaryMaintenanceCapabilities, - SummaryMaintenanceDeployment, SummaryMaintenanceLifecycleAlternative, - SummaryMaintenanceLifecycleAssemblyError, SummaryMaintenanceLifecycleCandidates, - SummaryMaintenanceLifecycleCapabilities, SummaryMaintenanceLifecycleChoiceError, - SummaryMaintenanceLifecycleCostInputs, SummaryMaintenanceLifecyclePlan, - SummaryMaintenanceLifecyclePlanError, SummaryMaintenanceLifecycleRejection, - SummaryMaintenanceLifecycleSelectionError, SummaryMaintenanceTimingError, WorkloadDemand, -}; pub use topk_reuse::TopKLimitReuseStrategy; pub mod maintained_population; diff --git a/crates/asap-aware-mapping/src/pass/major.rs b/crates/asap-aware-mapping/src/pass/major.rs index 4d95b18b9..bc3de3d16 100644 --- a/crates/asap-aware-mapping/src/pass/major.rs +++ b/crates/asap-aware-mapping/src/pass/major.rs @@ -12,12 +12,8 @@ use std::rc::Rc; use asap_types::ir::OperatorNode; use asap_types::types::AccuracyTarget; -use super::{OptimizationInput, OptimizationPass, OptimizeError, PlanOutput, QueryLifecyclePlan}; +use super::{OptimizationInput, OptimizationPass, OptimizeError, PlanOutput, QueryPlan}; use crate::replacement::{default_strategies_with_evidence, search_workload_with_targets}; -use crate::summary_maintenance_lifecycle::{ - global_selection_with_summary_maintenance_lifecycles, plan_assembled_dag, shared_state_cost, - summary_states, WorkloadDemand, -}; /// The shipped algorithm. Unit struct: its strategy set is the crate default, /// and a caller who wants a different one now has a better option than @@ -36,9 +32,9 @@ impl OptimizationPass for MajorPass { let strategies = default_strategies_with_evidence(models.cost, models.evidence); // `Id` is the entry's position in `QueryWorkload::entries()`, so the - // search result carries the workload binding the lifecycle stage and - // the output both need. CSE may make two identical queries share one - // `Rc`, but it never drops or reorders a root, so this stays aligned. + // search result carries the workload binding the output needs. CSE + // may make two identical queries share one `Rc`, but it never drops + // or reorders a root, so this stays aligned. let targets: Vec = workload .query_workload() .entries() @@ -83,141 +79,33 @@ impl OptimizationPass for MajorPass { } } - // One index per root, in `CandidateLogicalASAPDAGs::roots` order — which is the order - // the roots went in. - let entry_indices: Vec = roots.iter().map(|(index, _, _)| *index).collect(); - let space = search_workload_with_targets(roots, &strategies, models.accuracy) .map_err(OptimizeError::Search)?; - let lifecycle = input.lifecycle; - let demand = WorkloadDemand { - workload: workload.query_workload(), - data_workload: workload.data_workload(), - entry_indices: &entry_indices, - }; - - let selection = global_selection_with_summary_maintenance_lifecycles( - &space, - demand, - lifecycle.now_ms, - lifecycle.horizon, - lifecycle.capabilities, - models.cost, - ) - .map_err(OptimizeError::LifecycleSelection)?; - // Each root's lifecycle is planned against the entries that consume - // it — the same binding selection costed it with — not the whole - // workload, so one query's reads never amortize another's state. - let bindings = space - .workload_entries_by_target(demand.workload, &entry_indices) - .map_err(|error| OptimizeError::LifecycleSelection(error.into()))?; + let selection = space.global_selection(models.cost); - // Assemble every root, then intern structurally identical summary - // producers across them once, so two queries that selected the same - // `SummaryAgg` reach one `Rc` (consumers dedupe states by pointer). - // `plan_assembled_dag` then reads an exact-state root through its - // finalize, scalar references included: `scalar(v)` reads an instant - // vector's value, not accumulator state. + // Assemble every root as a query result (an exact state is read + // through its finalize, scalar references included: `scalar(v)` reads + // an instant vector's value, not accumulator state), then intern + // structurally identical summary producers across them once, so two + // queries that selected the same `SummaryAgg` reach one `Rc` + // (consumers dedupe states by pointer; a finalize wraps the shared + // state). let mut assembled = Vec::with_capacity(space.roots.len()); for (entry_index, root) in &space.roots { let dag = selection - .assemble_selected_dag(root) - .map_err(|source| OptimizeError::LifecycleAssembly { + .assemble_selected_query(root) + .map_err(|source| OptimizeError::Realization { entry_index: *entry_index, - source: source.into(), + source, })? .ok_or_else(|| self.missing_group(*entry_index))?; - assembled.push(dag); + assembled.push((*entry_index, dag)); } - let interned = share_common_sub_dags(assembled.iter().cloned().enumerate().collect()); - let states: Vec<_> = interned - .iter() - .map(|(_, dag)| summary_states(dag)) + let mut plans: Vec = share_common_sub_dags(assembled) + .into_iter() + .map(|(entry_index, root)| QueryPlan { entry_index, root }) .collect(); - - // A state reached from several roots is planned once against all of - // their entries, in every plan that reaches it, so each plan picks - // the same lifecycle for it. When that union cannot be costed the - // roots keep their own, unshared DAG and entries. - let mut shared_entries: Vec<(Rc, Option>)> = Vec::new(); - for (position, (entry_index, root)) in space.roots.iter().enumerate() { - for state in &states[position] { - if shared_entries.iter().any(|(s, _)| Rc::ptr_eq(s, state)) { - continue; - } - let readers: Vec<_> = (0..space.roots.len()) - .filter(|&other| states[other].iter().any(|s| Rc::ptr_eq(s, state))) - .map(|other| &space.roots[other].1) - .collect(); - if readers.iter().all(|reader| Rc::ptr_eq(reader, root)) { - continue; - } - let mut entries: Vec = readers - .iter() - .flat_map(|reader| bindings[&Rc::as_ptr(reader)].iter().copied()) - .collect(); - entries.sort_unstable(); - entries.dedup(); - let cost = shared_state_cost( - state, - WorkloadDemand { - entry_indices: &entries, - ..demand - }, - lifecycle.now_ms, - lifecycle.horizon, - lifecycle.capabilities, - models.cost, - ) - .map_err(|source| OptimizeError::LifecycleAssembly { - entry_index: *entry_index, - source: source.into(), - })?; - shared_entries.push((Rc::clone(state), cost.map(|_| entries))); - } - } - - let mut plans: Vec = Vec::with_capacity(space.roots.len()); - for (position, (entry_index, root)) in space.roots.iter().enumerate() { - let mut entries = bindings[&Rc::as_ptr(root)].clone(); - let mut dag = Rc::clone(&interned[position].1); - for (state, shared) in &shared_entries { - if !states[position].iter().any(|s| Rc::ptr_eq(s, state)) { - continue; - } - match shared { - Some(shared) => entries.extend(shared), - None => { - entries = bindings[&Rc::as_ptr(root)].clone(); - dag = Rc::clone(&assembled[position]); - break; - } - } - } - entries.sort_unstable(); - entries.dedup(); - let plan = plan_assembled_dag( - dag, - root, - WorkloadDemand { - entry_indices: &entries, - ..demand - }, - lifecycle.now_ms, - lifecycle.horizon, - lifecycle.capabilities, - models.cost, - ) - .map_err(|source| OptimizeError::LifecycleAssembly { - entry_index: *entry_index, - source, - })?; - plans.push(QueryLifecyclePlan { - entry_index: *entry_index, - plan, - }); - } // Rebuild each scalar root over its planned sub-DAGs. let scalar_plans = plans.split_off(workload.exprs().len()); let mut planned = scalar_plans.iter(); @@ -232,7 +120,7 @@ impl OptimizationPass for MajorPass { .iter() .position(|r| same_reference(r, node)) .expect("every reference was planned"); - Rc::clone(&selected[position].plan.root) + Rc::clone(&selected[position].root) }); (*index, expr) }) diff --git a/crates/asap-aware-mapping/src/pass/mod.rs b/crates/asap-aware-mapping/src/pass/mod.rs index beaf421eb..ef74fe5ec 100644 --- a/crates/asap-aware-mapping/src/pass/mod.rs +++ b/crates/asap-aware-mapping/src/pass/mod.rs @@ -17,20 +17,18 @@ mod major; use std::collections::BTreeMap; use std::rc::Rc; -use asap_types::ir::OperatorNode; +use asap_types::ir::physical_export::compile_physical_asap_workload; +use asap_types::ir::timing::{apply_lifecycle_timings, LifecycleAssignment, TimingMemo}; +use asap_types::ir::{OperatorNode, QueryRoot}; use asap_types::parsed_workload::ParsedWorkload; +use asap_types::post_asap::ExecutionDataStateError; use asap_types::workload::WorkloadError; use crate::accuracy::{ AccuracyEvidenceProvider, AccuracyModel, DefaultAccuracyModel, NoAccuracyEvidence, }; use crate::cost_model::{CostModel, DefaultCostModel}; -use crate::recurrence::Horizon; use crate::replacement::RealizationError; -use crate::summary_maintenance_lifecycle::{ - SummaryMaintenanceLifecycleAssemblyError, SummaryMaintenanceLifecycleCapabilities, - SummaryMaintenanceLifecyclePlan, SummaryMaintenanceLifecycleSelectionError, -}; pub use major::MajorPass; @@ -90,66 +88,22 @@ impl<'a> PlanningModels<'a> { } } -/// Supplying this asks the pass to also decide summary maintenance versus raw -/// recomputation; leaving it out asks only for the logical DAG. -#[derive(Clone, Copy)] -#[non_exhaustive] -pub struct LifecycleInput { - /// Planning clock, Unix milliseconds. - pub now_ms: u64, - /// Seconds. Required to turn recurring demand into a finite total. - pub horizon: Option, - pub capabilities: SummaryMaintenanceLifecycleCapabilities, -} - -impl LifecycleInput { - pub fn new(now_ms: u64, capabilities: SummaryMaintenanceLifecycleCapabilities) -> Self { - Self { - now_ms, - horizon: None, - capabilities, - } - } - - pub fn with_horizon(mut self, horizon: Horizon) -> Self { - self.horizon = Some(horizon); - self - } -} - #[derive(Clone, Copy)] #[non_exhaustive] pub struct OptimizationInput<'a> { pub workload: &'a ParsedWorkload, pub models: PlanningModels<'a>, - /// Every plan carries the maintenance-versus-recomputation decision, so - /// the planning clock and runtime capabilities are always required. - pub lifecycle: LifecycleInput, } impl<'a> OptimizationInput<'a> { - pub fn new( - workload: &'a ParsedWorkload, - models: PlanningModels<'a>, - lifecycle: LifecycleInput, - ) -> Self { - Self { - workload, - models, - lifecycle, - } + pub fn new(workload: &'a ParsedWorkload, models: PlanningModels<'a>) -> Self { + Self { workload, models } } pub fn validate(&self) -> Result<(), OptimizationInputError> { self.workload .validate() - .map_err(OptimizationInputError::Workload)?; - if let Some(horizon) = self.lifecycle.horizon { - if !horizon.0.is_finite() || horizon.0 <= 0.0 { - return Err(OptimizationInputError::InvalidHorizon(horizon.0)); - } - } - Ok(()) + .map_err(OptimizationInputError::Workload) } } @@ -158,33 +112,29 @@ impl<'a> OptimizationInput<'a> { pub enum OptimizationInputError { #[error("workload: {0}")] Workload(WorkloadError), - #[error("planning horizon must be finite and positive, got {0}")] - InvalidHorizon(f64), } // ── Output ─────────────────────────────────────────────────────────────── /// One query's selected post-ASAP DAG, or one operator sub-DAG a standalone -/// scalar query reads, plus the maintenance decisions taken for it. The DAG -/// is `plan.root`. +/// scalar query reads. The DAG is `root`. #[derive(Debug, Clone)] -pub struct QueryLifecyclePlan { +pub struct QueryPlan { /// Index into `QueryWorkload::entries()`. pub entry_index: usize, - pub plan: SummaryMaintenanceLifecyclePlan, + pub root: Rc, } -/// One multi-root workload DAG with query/lifecycle bindings in entry order; +/// One multi-root workload DAG with query bindings in entry order; /// [`check_contract`] enforces that. /// /// Plans are not deduplicated across entries: a summary state that several -/// queries share appears in each of their plans as the same `Rc` (with the -/// same lifecycle), so a consumer that deploys or costs the workload must -/// dedupe deployments by `Rc::ptr_eq` on the summary node. +/// queries share appears in each of their plans as the same `Rc`, so a +/// consumer that deploys or costs the workload must dedupe by `Rc::ptr_eq`. #[derive(Debug, Clone)] #[non_exhaustive] pub struct PlanOutput { - pub plans: Vec, + pub plans: Vec, /// Standalone scalar queries, keyed by position in the batch, which is /// the entry index because [`check_contract`] requires dense entry /// indices. Each operator reference is the root of one of @@ -192,11 +142,11 @@ pub struct PlanOutput { pub scalar_roots: Vec<(usize, asap_types::ir::ScalarExpr)>, /// One plan per distinct operator sub-DAG a scalar root reads, keyed by /// that scalar's entry, in `scalar_roots` order. - pub scalar_plans: Vec, + pub scalar_plans: Vec, } impl PlanOutput { - pub fn new(plans: Vec) -> Self { + pub fn new(plans: Vec) -> Self { Self { plans, scalar_roots: Vec::new(), @@ -226,7 +176,7 @@ impl PlanOutput { .map(|p| { ( p.entry_index, - asap_types::ir::QueryRoot::Operator(Rc::clone(&p.plan.root)), + asap_types::ir::QueryRoot::Operator(Rc::clone(&p.root)), ) }) .chain( @@ -241,7 +191,7 @@ impl PlanOutput { /// The selected operator roots. Use `roots()` to include scalar queries. pub fn operator_roots(&self) -> Vec> { - self.plans.iter().map(|p| Rc::clone(&p.plan.root)).collect() + self.plans.iter().map(|p| Rc::clone(&p.root)).collect() } /// Unique operators in the entire workload DAG, including scalar-plan dependencies. @@ -279,17 +229,16 @@ impl PlanOutput { /// its `scalar_roots`. pub fn execution_timed_dag( &self, - ) -> Result< - asap_types::ir::physical_export::PhysicalASAPDAG, - crate::SummaryMaintenanceTimingError, - > { - let plans: Vec<_> = self - .plans + ) -> Result { + // One memo, so a node shared by several roots is timed and exported once. + let mut memo = TimingMemo::new(); + let assignment = LifecycleAssignment::default_maintained(); + let timed = self + .roots() .iter() - .chain(&self.scalar_plans) - .map(|p| &p.plan) - .collect(); - crate::summary_maintenance_lifecycle::execution_timed_workload_dag(&self.roots(), &plans) + .map(|root| time_query_root(root, &assignment, &mut memo)) + .collect::, _>>()?; + compile_physical_asap_workload(&timed) } pub fn len(&self) -> usize { @@ -301,6 +250,28 @@ impl PlanOutput { } } +/// Time one query root: an operator root directly, a standalone scalar by +/// timing each operator it references and rebuilding it over the timed nodes. +fn time_query_root( + root: &QueryRoot, + assignment: &LifecycleAssignment, + memo: &mut TimingMemo, +) -> Result { + Ok(match root { + QueryRoot::Operator(node) => { + QueryRoot::Operator(apply_lifecycle_timings(node, assignment, memo)?) + } + QueryRoot::Scalar(expr) => { + for node in expr.operator_refs() { + apply_lifecycle_timings(node, assignment, memo)?; + } + QueryRoot::Scalar(expr.map_operator_refs(&mut |node| { + Rc::clone(memo.timed(node).expect("scalar reference was timed")) + })) + } + }) +} + #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum OptimizeError { @@ -313,13 +284,6 @@ pub enum OptimizeError { }, #[error("search: {0}")] Search(crate::replacement::SearchError), - #[error("summary-maintenance-lifecycle selection: {0}")] - LifecycleSelection(SummaryMaintenanceLifecycleSelectionError), - #[error("entry {entry_index}: {source}")] - LifecycleAssembly { - entry_index: usize, - source: SummaryMaintenanceLifecycleAssemblyError, - }, /// The pass returned something the downstream contract forbids. This is a /// defect in the pass, not in its input. #[error("pass `{pass}` violated the output contract: {detail}")] @@ -391,8 +355,7 @@ fn check_contract( } for node in refs { match scalar_plans.next() { - Some(plan) - if plan.entry_index == *entry_index && Rc::ptr_eq(&plan.plan.root, node) => {} + Some(plan) if plan.entry_index == *entry_index && Rc::ptr_eq(&plan.root, node) => {} _ => { return Err(violation(format!( "scalar root {entry_index} reads an operator without its scalar plan" @@ -526,22 +489,10 @@ mod tests { } /// A scalar plan read from `root`, keyed by `entry_index`. - fn scalar_plan(entry_index: usize, root: &Rc) -> QueryLifecyclePlan { - QueryLifecyclePlan { + fn scalar_plan(entry_index: usize, root: &Rc) -> QueryPlan { + QueryPlan { entry_index, - plan: crate::SummaryMaintenanceLifecyclePlan { - root: Rc::clone(root), - deployments: vec![], - horizon: None, - evaluation_rate: None, - update_rate: None, - expected_reads: None, - selected_raw_recompute: true, - selected_window_implementation_id: None, - summary_total_cost: None, - window_accuracy_guarantee: None, - raw_recompute_total_cost: None, - }, + root: Rc::clone(root), } } @@ -577,7 +528,7 @@ mod tests { right: Box::new(right), semantics: ExprSemantics::Promql, }; - let with_plans = |plans: Vec| { + let with_plans = |plans: Vec| { let mut output = PlanOutput::new(Vec::new()); // `a - b`, then `a - a`: one plan per distinct reference. output.scalar_roots = vec![ diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 9ea0cd07a..32fe251d1 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -56,9 +56,7 @@ //! does not establish a compatible workload plan or physical deployability. //! For Planner-owned logical selection, call [`CandidateLogicalASAPDAGs::global_selection`] //! once and [`GlobalSelection::assemble_selected_query`] for each wanted query -//! root. Alternatively, use the summary-maintenance-lifecycle-aware helpers -//! when Planner should also compare maintenance against raw recomputation. -//! Physical binding, deployment, and execution remain downstream. +//! root. Physical binding, deployment, and execution remain downstream. //! //! Internally, [`realize_child`] and [`realize_one`] may take a preferred local //! realization while constructing or costing a candidate. That local operation @@ -380,8 +378,8 @@ use crate::accuracy::{ DefaultAccuracyModel, EqualSplitAllocator, NoAccuracyEvidence, }; use crate::cost_model::{ - raw_recompute_cost_rate, Cost, CostModel, CseCandidate, DefaultCostModel, - ExactCompositionCostInputs, ExactCompositionCostRequest, ShareDecision, + raw_recompute_cost_rate, CostModel, CseCandidate, DefaultCostModel, ExactCompositionCostInputs, + ExactCompositionCostRequest, ShareDecision, }; use crate::exact_composition::{ExactComposition, ExactCompositionStrategy, OperationPlacement}; use crate::grouping::HydraGroupingStrategy; @@ -4397,50 +4395,6 @@ impl CandidateLogicalASAPDAGs { } } -/// Lifecycle-aware whole-subplan costs keyed by target and candidate identity. -#[derive(Default, Clone)] -pub(crate) struct CandidateCostOverrides { - costs: HashMap<(*const OperatorNode, *const ReplacementSubDAG), Cost>, - raw_costs: HashMap<*const OperatorNode, Cost>, - /// Targets for which the caller requested an atomic raw-vs-summary - /// decision. Other memo groups continue through ordinary CSE selection. - finalized_targets: HashSet<*const OperatorNode>, -} - -impl CandidateCostOverrides { - pub(crate) fn finalize_target(&mut self, target: &Rc) { - self.finalized_targets.insert(Rc::as_ptr(target)); - } - - fn finalizes(&self, target: &Rc) -> bool { - self.finalized_targets.contains(&Rc::as_ptr(target)) - } - - pub(crate) fn insert( - &mut self, - target: &Rc, - candidate: &ReplacementSubDAG, - cost: Cost, - ) { - self.costs - .insert((Rc::as_ptr(target), candidate as *const _), cost); - } - - fn get(&self, target: &Rc, candidate: &ReplacementSubDAG) -> Option { - self.costs - .get(&(Rc::as_ptr(target), candidate as *const _)) - .copied() - } - - pub(crate) fn insert_raw(&mut self, target: &Rc, cost: Cost) { - self.raw_costs.insert(Rc::as_ptr(target), cost); - } - - fn raw(&self, target: &Rc) -> Option { - self.raw_costs.get(&Rc::as_ptr(target)).copied() - } -} - impl CandidateLogicalASAPDAGs { /// One candidate set per discovered target sub-DAG, in discovery order. pub fn target_subdag_candidates(&self) -> impl Iterator { @@ -4855,54 +4809,6 @@ impl CandidateLogicalASAPDAGs { .map(|rate| UpdateRate(rate.0)); self.recurrence_profiles(&recurrences, update_rate) } - - /// Associate every discovered target with the normalized workload entries - /// whose roots can reach it. - pub(crate) fn workload_entries_by_target( - &self, - workload: &QueryWorkload, - root_workload_entries: &[usize], - ) -> Result>, RecurrenceError> { - let entry_count = workload.entries().count(); - if root_workload_entries.len() != self.roots.len() { - return Err(RecurrenceError::RootCountMismatch { - expected: self.roots.len(), - got: root_workload_entries.len(), - }); - } - let mut bindings: HashMap<*const OperatorNode, HashSet> = HashMap::new(); - for ((_, root), &entry_index) in self.roots.iter().zip(root_workload_entries) { - if entry_index >= entry_count { - return Err(RecurrenceError::InvalidWorkloadEntry { - index: entry_index, - entry_count, - }); - } - let mut seen = HashSet::new(); - let mut queue = VecDeque::from([Rc::as_ptr(root)]); - while let Some(ptr) = queue.pop_front() { - if !seen.insert(ptr) { - continue; - } - bindings.entry(ptr).or_default().insert(entry_index); - if let Some(group) = self.groups.get(&ptr) { - queue.extend( - direct_child_counts(&group.target) - .into_iter() - .map(|(child, _)| child), - ); - } - } - } - Ok(bindings - .into_iter() - .map(|(ptr, entries)| { - let mut entries: Vec<_> = entries.into_iter().collect(); - entries.sort_unstable(); - (ptr, entries) - }) - .collect()) - } } /// Record `times` occurrences of `recurrence` against `ptr` — `times > 1` @@ -5765,7 +5671,7 @@ impl CandidateLogicalASAPDAGs { /// Uncertified DDSketch ratios remain in [`CandidateLogicalASAPDAGs`] for downstream /// inspection but are not chosen automatically by this selector. pub fn global_selection(&self, cost_model: &dyn CostModel) -> GlobalSelection<'_> { - self.global_selection_impl(cost_model, None, None, None) + self.global_selection_impl(cost_model, None, None) .expect("structural global selection cannot produce a recurrence error") } @@ -5779,17 +5685,7 @@ impl CandidateLogicalASAPDAGs { profiles: &RecurrenceProfileMap, horizon: Option, ) -> Result, RecurrenceError> { - self.global_selection_impl(cost_model, Some(profiles), horizon, None) - } - - pub(crate) fn global_selection_with_candidate_costs( - &self, - cost_model: &dyn CostModel, - profiles: &RecurrenceProfileMap, - horizon: Option, - costs: &CandidateCostOverrides, - ) -> Result, RecurrenceError> { - self.global_selection_impl(cost_model, Some(profiles), horizon, Some(costs)) + self.global_selection_impl(cost_model, Some(profiles), horizon) } fn global_selection_impl( @@ -5797,7 +5693,6 @@ impl CandidateLogicalASAPDAGs { cost_model: &dyn CostModel, profiles: Option<&RecurrenceProfileMap>, horizon: Option, - candidate_costs: Option<&CandidateCostOverrides>, ) -> Result, RecurrenceError> { let dag = reference_dag(self); let topo = topological_order(&self.order, &dag); @@ -5858,30 +5753,8 @@ impl CandidateLogicalASAPDAGs { } } - let lifecycle_choice = candidate_costs - .filter(|costs| costs.finalizes(&group.target)) - .map(|costs| { - let summary = group - .candidates - .iter() - .filter(|candidate| !is_composition_candidate(candidate)) - .filter(|candidate| is_automatically_selectable(candidate, cost_model)) - .filter_map(|candidate| { - costs - .get(&group.target, candidate) - .map(|cost| (candidate, cost)) - }) - .min_by(|(_, left), (_, right)| left.0.total_cmp(&right.0)); - match (summary, costs.raw(&group.target)) { - (Some((_, summary_cost)), Some(raw)) if raw.0 <= summary_cost.0 => None, - (Some((candidate, _)), _) => Some(candidate), - (None, _) => None, - } - }); - let complete_plan_choice = (!forced.is_some() && composed.is_none() - && lifecycle_choice.is_none() && cost_model.candidate_cost_covers_complete_plan()) .then(|| { let effective_target = TargetSubDAG::with_consumer_count(&group.target, effective); @@ -5921,8 +5794,6 @@ impl CandidateLogicalASAPDAGs { } else if let Some(option) = composed { composition_decision = Some(option.decision); Some(option.candidate) - } else if let Some(choice) = lifecycle_choice { - choice } else if cost_model.candidate_cost_covers_complete_plan() { complete_plan_choice } else if effective >= 2 && cse_candidate_pair(group).is_some() { diff --git a/crates/asap-aware-mapping/src/summary_maintenance_cost/estimator.rs b/crates/asap-aware-mapping/src/summary_maintenance_cost/estimator.rs deleted file mode 100644 index 911d90275..000000000 --- a/crates/asap-aware-mapping/src/summary_maintenance_cost/estimator.rs +++ /dev/null @@ -1,1219 +0,0 @@ -use super::*; - -pub(super) fn estimate_heterogeneous_summary( - root: &OperatorNode, - deployments: &[CostedSummaryDeployment<'_>], - evidence: &SummaryNodeEvidence, - scope: &ComparisonScope, - raw: &RawInputEvidence, - window_frameworks: &[Option], -) -> Result { - if window_frameworks.len() != deployments.len() { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "window framework assignments", - )); - } - let frameworks_by_node: HashMap<_, _> = deployments - .iter() - .zip(window_frameworks) - .map(|(deployment, framework)| (deployment.summary as *const _, framework)) - .collect(); - validate_summary_edges_and_physical_ids(root, evidence, &frameworks_by_node)?; - let evaluation_count = scope.validate()?; - let by_node: HashMap<_, _> = deployments - .iter() - .map(|deployment| (deployment.summary as *const _, deployment)) - .collect(); - let mut cpu_ops = 0.0; - let mut persistent_bytes = 0_u64; - let mut ephemeral_state_bytes = 0_u64; - let mut scans = HashMap::::new(); - let mut physical_states = HashMap::< - String, - ( - SummaryAggregateEvidence, - SummaryMaintenanceLifecycleGuarantee, - String, - Option, - ), - >::new(); - for deployment in deployments { - let node_evidence = evidence - .aggregation(deployment.summary) - .ok_or(AnalyticalCostError::MissingOrStale("summary_agg"))?; - let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &deployment.summary.operator else { - return Err(AnalyticalCostError::UnsupportedCandidate); - }; - let inputs = node_evidence.inputs.validate()?; - validate_arrival_rate(scope.data_arrival, inputs.ingestion_rate_per_second)?; - match node_evidence.scan_selection_index { - Some(index) => { - let declared = - scope - .sources - .get(index) - .ok_or(AnalyticalCostError::MissingComparisonScope( - "summary scan selection", - ))?; - if !has_retained_subdag_evidence(child, evidence) - || inputs.initial_input_rows != raw.planning_time_input_rows - || inputs.initial_input_bytes != raw.planning_time_input_bytes - || inputs.initial_source_scan_bytes != raw.planning_time_source_scan_bytes - || inputs.ingestion_rate_per_second != raw.ingestion_rate_per_second - { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "source-root bootstrap evolution", - )); - } - let mut actual_selections = Vec::new(); - query_source_selections(child, &mut HashSet::new(), &mut actual_selections)?; - let actual_selections = deduplicate_source_selections(actual_selections); - let expected = ( - declared.source.clone(), - declared.predicates.clone(), - declared.info_matchers.clone(), - ); - if actual_selections.as_slice() != [expected] { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "summary source lineage", - )); - } - } - None => { - if has_retained_subdag_evidence(child, evidence) - || inputs.initial_source_scan_bytes != 0 - || !node_evidence.bootstrap_read_identity.is_empty() - { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "intermediate bootstrap source ownership", - )); - } - } - } - validate_guarantee(deployment.guarantee, scope.data_arrival)?; - let logical_state = format!("{:?}", deployment.summary.operator); - let window_framework = (*frameworks_by_node - .get(&(deployment.summary as *const _)) - .ok_or(AnalyticalCostError::MissingOrStale( - "window framework assignment", - ))?) - .clone(); - match physical_states.entry(node_evidence.physical_id.clone()) { - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert(( - node_evidence.clone(), - deployment.guarantee.clone(), - logical_state, - window_framework, - )); - } - std::collections::hash_map::Entry::Occupied(entry) - if entry.get() - != &( - node_evidence.clone(), - deployment.guarantee.clone(), - logical_state, - window_framework, - ) => - { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "summary physical identity", - )); - } - std::collections::hash_map::Entry::Occupied(_) => continue, - } - let ephemeral = matches!( - deployment.guarantee.summary_maintenance_lifecycle, - SummaryMaintenanceLifecycle::Ephemeral - ); - let (bootstrap, updates, source_scan_bytes) = if ephemeral { - ( - ephemeral_rows_over_horizon(inputs, scope)?, - 0, - ephemeral_scan_bytes_over_horizon(inputs, raw, scope)?, - ) - } else { - let (bootstrap, updates, _) = - lifecycle_row_counts(inputs, deployment.guarantee, scope)?; - let bootstrap_extra_rows = bootstrap - .checked_sub(inputs.initial_input_rows) - .ok_or(AnalyticalCostError::Overflow)?; - let source_scan_bytes = if node_evidence.scan_selection_index.is_some() { - inputs - .initial_source_scan_bytes - .checked_add( - bootstrap_extra_rows - .checked_mul(raw.arriving_source_row_bytes) - .ok_or(AnalyticalCostError::Overflow)?, - ) - .ok_or(AnalyticalCostError::Overflow)? - } else { - 0 - }; - (bootstrap, updates, source_scan_bytes) - }; - let insert = validated_operator_cpu("insert_cpu_ops", node_evidence.insert_cpu_ops)?; - let insert_calls = bootstrap - .checked_mul(inputs.bootstrap_window_count) - .and_then(|calls| { - updates - .checked_mul(inputs.active_window_count) - .and_then(|updates| calls.checked_add(updates)) - }) - .ok_or(AnalyticalCostError::Overflow)?; - cpu_ops += insert_calls as f64 * insert; - let live_window_count = if ephemeral { - inputs.bootstrap_window_count - } else { - inputs - .active_window_count - .checked_add(inputs.retained_window_count) - .ok_or(AnalyticalCostError::Overflow)? - }; - let state_bytes = live_window_count - .checked_mul(inputs.physical_summary_count) - .and_then(|states| states.checked_mul(inputs.state_bytes_per_summary)) - .ok_or(AnalyticalCostError::Overflow)?; - if ephemeral { - ephemeral_state_bytes = ephemeral_state_bytes - .checked_add(state_bytes) - .ok_or(AnalyticalCostError::Overflow)?; - } else { - persistent_bytes = persistent_bytes - .checked_add(state_bytes) - .ok_or(AnalyticalCostError::Overflow)?; - } - if let Some(source_index) = node_evidence.scan_selection_index { - if node_evidence.bootstrap_read_identity.is_empty() { - return Err(AnalyticalCostError::MissingOrStale( - "bootstrap_read_identity", - )); - } - match scans.entry(node_evidence.bootstrap_read_identity.clone()) { - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert((source_index, source_scan_bytes)); - } - std::collections::hash_map::Entry::Occupied(entry) - if *entry.get() != (source_index, source_scan_bytes) => - { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "bootstrap source bytes", - )); - } - _ => {} - } - } - } - let covered_sources: HashSet<_> = scans.values().map(|(index, _)| *index).collect(); - if covered_sources.len() != scope.sources.len() - || !(0..scope.sources.len()).all(|index| covered_sources.contains(&index)) - { - return Err(AnalyticalCostError::ComparisonScopeMismatch("sources")); - } - - #[expect(clippy::too_many_arguments, reason = "CPU and I/O traversal state")] - fn visit_ops( - node: &OperatorNode, - seen: &mut HashSet, - by_node: &HashMap<*const OperatorNode, &CostedSummaryDeployment<'_>>, - evidence: &SummaryNodeEvidence, - scope: &ComparisonScope, - evaluation_count: u64, - cpu_ops: &mut f64, - io_bytes: &mut u64, - ) -> Result<(), AnalyticalCostError> { - let physical_id = summary_physical_id(node, evidence)?; - if !seen.insert(physical_id) { - return Ok(()); - } - if has_retained_subdag_evidence(node, evidence) { - let retained = evidence - .retained_queries - .get(&(node as *const _)) - .ok_or(AnalyticalCostError::MissingOrStale("retain_exact"))?; - if !retained.preprocessing_cpu_ops_over_horizon.is_finite() - || retained.preprocessing_cpu_ops_over_horizon < 0.0 - { - return Err(AnalyticalCostError::InvalidOperationCost( - "retain_exact", - retained.preprocessing_cpu_ops_over_horizon, - )); - } - *cpu_ops += retained.preprocessing_cpu_ops_over_horizon; - return Ok(()); - } - match &node.operator { - Operator::NonASAP(NonASAPOp::BinaryOp { lhs, rhs, .. }) - | Operator::NonASAP(NonASAPOp::Join { - left: lhs, - right: rhs, - .. - }) => { - let operation = summary_operation_evidence(node, evidence)?.resource(); - *cpu_ops += evaluation_count as f64 - * validated_operator_executions("exact_binary", operation)? as f64 - * validated_operator_cpu("exact_binary", operation.cpu_ops)?; - add_operator_io(io_bytes, operation, evaluation_count)?; - visit_ops( - lhs, - seen, - by_node, - evidence, - scope, - evaluation_count, - cpu_ops, - io_bytes, - )?; - visit_ops( - rhs, - seen, - by_node, - evidence, - scope, - evaluation_count, - cpu_ops, - io_bytes, - )?; - } - - Operator::NonASAP(_) - | Operator::ASAP( - ASAPOp::FinalizeExactAccumulator { .. } - | ASAPOp::MaintainPopulation { .. } - | ASAPOp::EvaluatePopulation { .. }, - ) => { - let operation = summary_operation_evidence(node, evidence)?.resource(); - *cpu_ops += evaluation_count as f64 - * validated_operator_executions("value_operation", operation)? as f64 - * validated_operator_cpu("value_operation", operation.cpu_ops)?; - add_operator_io(io_bytes, operation, evaluation_count)?; - for child in node.children() { - visit_ops( - child, - seen, - by_node, - evidence, - scope, - evaluation_count, - cpu_ops, - io_bytes, - )?; - } - } - Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) => { - visit_ops( - child, - seen, - by_node, - evidence, - scope, - evaluation_count, - cpu_ops, - io_bytes, - )?; - } - Operator::ASAP(ASAPOp::SummaryMerge { children }) => { - let operation = summary_operation_evidence(node, evidence)?.resource(); - let merge = validated_operator_cpu("summary_merge", operation.cpu_ops)?; - *cpu_ops += evaluation_count as f64 - * validated_operator_executions("summary_merge", operation)? as f64 - * merge; - add_operator_io(io_bytes, operation, evaluation_count)?; - for child in children { - visit_ops( - child, - seen, - by_node, - evidence, - scope, - evaluation_count, - cpu_ops, - io_bytes, - )?; - } - } - Operator::ASAP(ASAPOp::SummarySubtract { left, right }) => { - let operation = summary_operation_evidence(node, evidence)?.resource(); - *cpu_ops += evaluation_count as f64 - * validated_operator_executions("summary_subtract", operation)? as f64 - * validated_operator_cpu("summary_subtract", operation.cpu_ops)?; - add_operator_io(io_bytes, operation, evaluation_count)?; - visit_ops( - left, - seen, - by_node, - evidence, - scope, - evaluation_count, - cpu_ops, - io_bytes, - )?; - visit_ops( - right, - seen, - by_node, - evidence, - scope, - evaluation_count, - cpu_ops, - io_bytes, - )?; - } - Operator::ASAP(ASAPOp::SummaryDelete { summary_input, .. }) => { - let delete = summary_operation_evidence(node, evidence)?; - let SummaryOperatorEvidence::Delete { - resource: operation, - events_per_second, - routing_fanout, - } = delete - else { - unreachable!("operation kind was validated") - }; - let state_ptr = evidence - .operation_state_owners - .get(&(node as *const _)) - .ok_or(AnalyticalCostError::MissingOrStale("summary_delete_owner"))?; - fn collect_aggs( - node: &OperatorNode, - seen: &mut HashSet<*const OperatorNode>, - out: &mut Vec<*const OperatorNode>, - ) { - if !seen.insert(node as *const _) { - return; - } - if matches!(node.operator, Operator::ASAP(ASAPOp::SummaryAgg { .. })) { - out.push(node as *const _); - } - for child in node.children() { - collect_aggs(child, seen, out); - } - } - let mut reachable = Vec::new(); - collect_aggs(summary_input, &mut HashSet::new(), &mut reachable); - if reachable.as_slice() != [*state_ptr] { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "summary delete owner", - )); - } - let deployment = by_node - .get(state_ptr) - .copied() - .ok_or(AnalyticalCostError::MissingOrStale("summary_delete_owner"))?; - let state = evidence - .aggregation(deployment.summary) - .ok_or(AnalyticalCostError::MissingOrStale("summary_delete_owner"))?; - let (_, _, active_ms) = - lifecycle_row_counts(state.inputs, deployment.guarantee, scope)?; - if !events_per_second.is_finite() || *events_per_second < 0.0 { - return Err(AnalyticalCostError::InvalidIngestionRate( - *events_per_second, - )); - } - if *routing_fanout == 0 { - return Err(AnalyticalCostError::MissingOrZero("delete_routing_fanout")); - } - let delete_events = (events_per_second * active_ms as f64 / 1_000.0).ceil() - * *routing_fanout as f64; - if !delete_events.is_finite() || delete_events > u64::MAX as f64 { - return Err(AnalyticalCostError::Overflow); - } - let delete_events = delete_events as u64; - *cpu_ops += delete_events as f64 - * validated_operator_executions("summary_delete", operation)? as f64 - * validated_operator_cpu("summary_delete", operation.cpu_ops)?; - add_operator_io(io_bytes, operation, delete_events)?; - visit_ops( - summary_input, - seen, - by_node, - evidence, - scope, - evaluation_count, - cpu_ops, - io_bytes, - )?; - } - Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) => { - let operation = summary_operation_evidence(node, evidence)?.resource(); - *cpu_ops += evaluation_count as f64 - * validated_operator_executions("summary_evaluation", operation)? as f64 - * validated_operator_cpu("summary_evaluation", operation.cpu_ops)?; - add_operator_io(io_bytes, operation, evaluation_count)?; - visit_ops( - summary_input, - seen, - by_node, - evidence, - scope, - evaluation_count, - cpu_ops, - io_bytes, - )?; - } - Operator::ASAP(ASAPOp::SummaryJoin { outer, inner, .. }) => { - let join = evidence - .joins - .get(&(node as *const _)) - .ok_or(AnalyticalCostError::MissingOrStale("summary_join"))?; - if !join.cpu_ops_per_execution.is_finite() - || join.cpu_ops_per_execution <= 0.0 - || join.working_memory_bytes == 0 - || join.executions_per_evaluation == 0 - { - return Err(AnalyticalCostError::MissingOrStale("summary_join")); - } - *cpu_ops += evaluation_count as f64 - * join.executions_per_evaluation as f64 - * join.cpu_ops_per_execution; - let join_io = join - .io_bytes_per_execution - .ok_or(AnalyticalCostError::MissingOrStale("summary_join_io"))?; - *io_bytes = io_bytes - .checked_add( - join_io - .checked_mul(join.executions_per_evaluation) - .and_then(|bytes| bytes.checked_mul(evaluation_count)) - .ok_or(AnalyticalCostError::Overflow)?, - ) - .ok_or(AnalyticalCostError::Overflow)?; - visit_ops( - outer, - seen, - by_node, - evidence, - scope, - evaluation_count, - cpu_ops, - io_bytes, - )?; - visit_ops( - inner, - seen, - by_node, - evidence, - scope, - evaluation_count, - cpu_ops, - io_bytes, - )?; - } - Operator::ASAP(ASAPOp::Extension { .. }) => { - return Err(AnalyticalCostError::UnsupportedCandidate); - } - } - Ok(()) - } - - let mut operator_io_bytes = 0; - visit_ops( - root, - &mut HashSet::new(), - &by_node, - evidence, - scope, - evaluation_count, - &mut cpu_ops, - &mut operator_io_bytes, - )?; - let transient_bytes = estimate_transient_liveness(root, evidence)?; - if !cpu_ops.is_finite() { - return Err(AnalyticalCostError::Overflow); - } - Ok(ResourceEstimate::new( - cpu_ops, - persistent_bytes - .checked_add(transient_bytes) - .and_then(|bytes| bytes.checked_add(ephemeral_state_bytes)) - .ok_or(AnalyticalCostError::Overflow)?, - scans - .values() - .try_fold(operator_io_bytes, |sum, (_, bytes)| { - sum.checked_add(*bytes).ok_or(AnalyticalCostError::Overflow) - })?, - )) -} - -fn add_operator_io( - total: &mut u64, - operation: &SummaryOperatorResourceEvidence, - execution_units: u64, -) -> Result<(), AnalyticalCostError> { - let bytes = operation - .io_bytes_per_execution - .ok_or(AnalyticalCostError::MissingOrStale("summary operator io"))?; - if operation.executions_per_evaluation == 0 { - return Err(AnalyticalCostError::MissingOrStale( - "summary operator executions", - )); - } - *total = total - .checked_add( - bytes - .checked_mul(operation.executions_per_evaluation) - .and_then(|value| value.checked_mul(execution_units)) - .ok_or(AnalyticalCostError::Overflow)?, - ) - .ok_or(AnalyticalCostError::Overflow)?; - Ok(()) -} - -fn validate_summary_edges_and_physical_ids( - root: &OperatorNode, - evidence: &SummaryNodeEvidence, - frameworks_by_node: &HashMap<*const OperatorNode, &Option>, -) -> Result<(), AnalyticalCostError> { - fn children<'a>( - node: &'a OperatorNode, - evidence: &SummaryNodeEvidence, - ) -> Vec<&'a OperatorNode> { - summary_children(node, evidence) - } - fn metadata( - node: &OperatorNode, - evidence: &SummaryNodeEvidence, - ) -> Result<(String, Vec, EdgeStatistics), AnalyticalCostError> { - if let Some(retained) = evidence.retained_queries.get(&(node as *const _)) { - if node.contains_asap() { - return Err(AnalyticalCostError::InvalidPhysicalDAG( - "retained sub-DAG evidence covers summary operators", - )); - } - return Ok((retained.physical_id.clone(), vec![], retained.output)); - } - match &node.operator { - Operator::ASAP(ASAPOp::SummaryAgg { .. }) => { - let value = evidence - .aggregations - .get(&(node as *const _)) - .ok_or(AnalyticalCostError::MissingOrStale("summary_agg"))?; - Ok((value.physical_id.clone(), vec![value.input], value.output)) - } - Operator::ASAP(ASAPOp::SummaryJoin { .. }) => { - let value = evidence - .joins - .get(&(node as *const _)) - .ok_or(AnalyticalCostError::MissingOrStale("summary_join"))?; - Ok(( - value.physical_id.clone(), - value.inputs.clone(), - value.output, - )) - } - _ => { - let value = summary_operation_evidence(node, evidence)?.resource(); - Ok(( - value.physical_id.clone(), - value.inputs.clone(), - value.output, - )) - } - } - } - fn visit( - node: &OperatorNode, - evidence: &SummaryNodeEvidence, - frameworks_by_node: &HashMap<*const OperatorNode, &Option>, - seen: &mut HashSet<*const OperatorNode>, - physical: &mut HashMap, EdgeStatistics, String)>, - ) -> Result { - if !seen.insert(node as *const _) { - return metadata(node, evidence).map(|(_, _, output)| output); - } - let child_nodes = children(node, evidence); - let child_outputs = child_nodes - .iter() - .map(|child| visit(child, evidence, frameworks_by_node, seen, physical)) - .collect::, _>>()?; - let child_physical_ids = child_nodes - .iter() - .map(|child| summary_physical_id(child, evidence)) - .collect::, _>>()?; - let (id, inputs, output) = metadata(node, evidence)?; - let local_fingerprint = if has_retained_subdag_evidence(node, evidence) { - format!("{:?}", evidence.retained_queries.get(&(node as *const _))) - } else { - match &node.operator { - Operator::ASAP(ASAPOp::SummaryAgg { .. }) => { - format!("{:?}", evidence.aggregations.get(&(node as *const _))) - } - Operator::ASAP(ASAPOp::SummaryJoin { .. }) => { - format!("{:?}", evidence.joins.get(&(node as *const _))) - } - _ => format!("{:?}", evidence.operations.get(&(node as *const _))), - } - }; - // A provider identity names the complete physical operator, including - // its inputs. Equal local widths/costs do not make operators consuming - // different physical children the same deployment. - let framework = frameworks_by_node.get(&(node as *const _)); - let fingerprint = format!( - "logical={:?}|framework={framework:?}|{local_fingerprint}|children={child_physical_ids:?}", - node.operator - ); - if id.is_empty() - || inputs != child_outputs - || !output.is_consistent() - || inputs.iter().any(|edge| !edge.is_consistent()) - { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "summary physical edge statistics", - )); - } - match physical.entry(id) { - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert((inputs, output, fingerprint)); - } - std::collections::hash_map::Entry::Occupied(entry) - if entry.get() != &(inputs, output, fingerprint) => - { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "summary physical identity", - )); - } - _ => {} - } - Ok(output) - } - visit( - root, - evidence, - frameworks_by_node, - &mut HashSet::new(), - &mut HashMap::new(), - ) - .map(|_| ()) -} - -/// Whether `node` is a retained non-ASAP sub-DAG costed as one unit: the -/// provider bound retained-query evidence to it instead of per-operator -/// evidence. Its children are then not visited. No ASAP descendant may be -/// hidden by this boundary; dag validation rejects such evidence. -fn has_retained_subdag_evidence(node: &OperatorNode, evidence: &SummaryNodeEvidence) -> bool { - evidence.retained_queries.contains_key(&(node as *const _)) && !node.contains_asap() -} - -/// The inputs the estimator visits below `node`: none for a retained -/// sub-DAG, every direct input otherwise. -fn summary_children<'a>( - node: &'a OperatorNode, - evidence: &SummaryNodeEvidence, -) -> Vec<&'a OperatorNode> { - if has_retained_subdag_evidence(node, evidence) { - vec![] - } else { - node.children() - .into_iter() - .map(|child| child.as_ref()) - .collect() - } -} - -fn summary_physical_id( - node: &OperatorNode, - evidence: &SummaryNodeEvidence, -) -> Result { - if has_retained_subdag_evidence(node, evidence) { - return evidence - .retained_queries - .get(&(node as *const _)) - .map(|value| value.physical_id.clone()) - .ok_or(AnalyticalCostError::MissingOrStale( - "summary physical identity", - )); - } - match &node.operator { - Operator::ASAP(ASAPOp::SummaryAgg { .. }) => evidence - .aggregations - .get(&(node as *const _)) - .map(|value| value.physical_id.clone()), - Operator::ASAP(ASAPOp::SummaryJoin { .. }) => evidence - .joins - .get(&(node as *const _)) - .map(|value| value.physical_id.clone()), - _ => summary_operation_evidence(node, evidence) - .ok() - .map(|value| value.resource().physical_id.clone()), - } - .ok_or(AnalyticalCostError::MissingOrStale( - "summary physical identity", - )) -} - -/// Simulate a deterministic child-before-parent physical schedule. Completed -/// child output buffers remain live until their final consumer executes; -/// operator workspace and its output buffer coexist during that execution. -pub(super) fn estimate_transient_liveness( - root: &OperatorNode, - evidence: &SummaryNodeEvidence, -) -> Result { - fn children<'a>( - node: &'a OperatorNode, - evidence: &SummaryNodeEvidence, - ) -> Vec<&'a OperatorNode> { - summary_children(node, evidence) - } - fn visit<'a>( - node: &'a OperatorNode, - evidence: &SummaryNodeEvidence, - seen: &mut HashSet, - uses: &mut HashMap, - order: &mut Vec<&'a OperatorNode>, - ) -> Result<(), AnalyticalCostError> { - if !seen.insert(summary_physical_id(node, evidence)?) { - return Ok(()); - } - for child in children(node, evidence) { - *uses - .entry(summary_physical_id(child, evidence)?) - .or_default() += 1; - visit(child, evidence, seen, uses, order)?; - } - order.push(node); - Ok(()) - } - fn memory( - node: &OperatorNode, - evidence: &SummaryNodeEvidence, - ) -> Result<(u64, u64), AnalyticalCostError> { - if has_retained_subdag_evidence(node, evidence) { - return evidence - .retained_queries - .get(&(node as *const _)) - .map(|value| (value.working_memory_bytes, value.output_buffer_bytes)) - .ok_or(AnalyticalCostError::MissingOrStale("retain_exact")); - } - match &node.operator { - Operator::ASAP(ASAPOp::SummaryAgg { .. }) => Ok((0, 0)), - Operator::ASAP(ASAPOp::SummaryJoin { .. }) => evidence - .joins - .get(&(node as *const _)) - .map(|value| (value.working_memory_bytes, value.output_buffer_bytes)) - .ok_or(AnalyticalCostError::MissingOrStale("summary_join")), - _ => { - let value = summary_operation_evidence(node, evidence)?.resource(); - Ok((value.working_memory_bytes, value.output_buffer_bytes)) - } - } - } - - let mut uses = HashMap::new(); - let mut order = Vec::new(); - visit(root, evidence, &mut HashSet::new(), &mut uses, &mut order)?; - let outputs: HashMap<_, _> = order - .iter() - .map(|node| { - memory(node, evidence) - .and_then(|(_, output)| summary_physical_id(node, evidence).map(|id| (id, output))) - }) - .collect::>()?; - let mut live = 0_u64; - let mut peak = 0_u64; - for node in order { - let (workspace, output) = memory(node, evidence)?; - peak = peak.max( - live.checked_add(workspace) - .and_then(|bytes| bytes.checked_add(output)) - .ok_or(AnalyticalCostError::Overflow)?, - ); - live = live - .checked_add(output) - .ok_or(AnalyticalCostError::Overflow)?; - for child in children(node, evidence) { - let child_id = summary_physical_id(child, evidence)?; - let remaining = - uses.get_mut(&child_id) - .ok_or(AnalyticalCostError::InvalidPhysicalDAG( - "missing summary consumer count", - ))?; - *remaining -= 1; - if *remaining == 0 { - live = live - .checked_sub(outputs[&child_id]) - .ok_or(AnalyticalCostError::Overflow)?; - } - } - } - Ok(peak) -} -#[cfg(test)] -pub(super) fn evidence_nodes(root: &OperatorNode) -> (Vec<&OperatorNode>, Vec<&OperatorNode>) { - fn visit<'a>( - node: &'a OperatorNode, - seen: &mut HashSet<*const OperatorNode>, - aggregations: &mut Vec<&'a OperatorNode>, - joins: &mut Vec<&'a OperatorNode>, - ) { - if !seen.insert(node as *const _) { - return; - } - match &node.operator { - Operator::ASAP(ASAPOp::SummaryAgg { .. }) => aggregations.push(node), - Operator::ASAP(ASAPOp::SummaryJoin { .. }) => joins.push(node), - _ => {} - } - for child in node.children() { - visit(child, seen, aggregations, joins); - } - } - let mut aggregations = Vec::new(); - let mut joins = Vec::new(); - visit(root, &mut HashSet::new(), &mut aggregations, &mut joins); - (aggregations, joins) -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -#[cfg(test)] -struct SummaryOperationCounts { - state_builds: u64, - merges_per_read: u64, - subtracts_per_read: u64, - deletes_per_update: u64, - evaluations_per_read: u64, - joins_per_read: u64, -} - -/// Low-level diagnostic for a homogeneous deployment. Final planner ranking -/// uses the per-node whole-DAG estimator above. Shared `Rc` nodes are visited -/// once; explicit delete frequency comes from deletion evidence. -#[cfg(test)] -pub(super) fn estimate_incremental_summary_maintenance( - root: &OperatorNode, - guarantee: &SummaryMaintenanceLifecycleGuarantee, - inputs: SummaryMaintenanceInputs, - cpu: SummaryOperationCpuEvidence, - scope: &ComparisonScope, -) -> Result { - estimate_incremental_summary_maintenance_with_join(root, guarantee, inputs, cpu, None, scope) -} -#[cfg(test)] -pub(super) fn estimate_incremental_summary_maintenance_with_join( - root: &OperatorNode, - guarantee: &SummaryMaintenanceLifecycleGuarantee, - inputs: SummaryMaintenanceInputs, - cpu: SummaryOperationCpuEvidence, - join: Option, - scope: &ComparisonScope, -) -> Result { - let inputs = inputs.validate()?; - let evaluation_count = scope.validate()?; - validate_guarantee(guarantee, scope.data_arrival)?; - let (bootstrap_input_rows, arriving_input_rows, active_ms) = - lifecycle_row_counts(inputs, guarantee, scope)?; - let counts = count_operations(root)?; - if counts.state_builds == 0 { - return Err(AnalyticalCostError::UnsupportedCandidate); - } - - let insert = required_cpu("insert_cpu_ops", cpu.insert_cpu_ops)?; - let merge = required_cpu_when(counts.merges_per_read, "merge_cpu_ops", cpu.merge_cpu_ops)?; - let subtract = required_cpu_when( - counts.subtracts_per_read, - "subtract_cpu_ops", - cpu.subtract_cpu_ops, - )?; - let delete = required_cpu_when( - counts.deletes_per_update, - "delete_cpu_ops", - cpu.delete_cpu_ops, - )?; - let delete_events = if counts.deletes_per_update == 0 { - 0_u64 - } else { - let rate = cpu - .delete_events_per_second - .filter(|rate| rate.is_finite() && *rate >= 0.0) - .ok_or(AnalyticalCostError::MissingOrStale( - "delete_events_per_second", - ))?; - let fanout = cpu - .delete_routing_fanout - .filter(|fanout| *fanout > 0) - .ok_or(AnalyticalCostError::MissingOrStale("delete_routing_fanout"))?; - let events = (rate * active_ms as f64 / 1_000.0).ceil(); - if !events.is_finite() || events > u64::MAX as f64 { - return Err(AnalyticalCostError::Overflow); - } - (events as u64) - .checked_mul(fanout) - .ok_or(AnalyticalCostError::Overflow)? - }; - let evaluation = required_cpu_when( - counts.evaluations_per_read, - "evaluation_cpu_ops", - cpu.evaluation_cpu_ops, - )?; - let join_cpu = match (counts.joins_per_read, join.as_ref()) { - (0, _) => 0.0, - (_, Some(evidence)) - if evidence.cpu_ops_per_execution.is_finite() - && evidence.cpu_ops_per_execution > 0.0 - && evidence.working_memory_bytes > 0 => - { - evidence.cpu_ops_per_execution - } - (_, Some(evidence)) - if !evidence.cpu_ops_per_execution.is_finite() - || evidence.cpu_ops_per_execution <= 0.0 => - { - return Err(AnalyticalCostError::InvalidOperationCost( - "summary_join_cpu_ops_per_execution", - evidence.cpu_ops_per_execution, - )); - } - _ => return Err(AnalyticalCostError::MissingOrStale("summary_join")), - }; - - let build_inserts = bootstrap_input_rows - .checked_mul(inputs.bootstrap_window_count) - .ok_or(AnalyticalCostError::Overflow)? - .checked_mul(counts.state_builds) - .ok_or(AnalyticalCostError::Overflow)?; - let update_inserts = arriving_input_rows - .checked_mul(inputs.active_window_count) - .and_then(|n| n.checked_mul(counts.state_builds)) - .ok_or(AnalyticalCostError::Overflow)?; - let instances = inputs.physical_summary_count as f64; - let evaluations = evaluation_count as f64; - let cpu_ops = (build_inserts as f64 + update_inserts as f64) * insert - + evaluations * counts.merges_per_read as f64 * instances * merge - + evaluations * counts.subtracts_per_read as f64 * instances * subtract - + delete_events as f64 * counts.deletes_per_update as f64 * delete - + evaluations * counts.evaluations_per_read as f64 * instances * evaluation - + evaluations * counts.joins_per_read as f64 * join_cpu; - if !cpu_ops.is_finite() { - return Err(AnalyticalCostError::Overflow); - } - - let state_instances = inputs - .active_window_count - .checked_add(inputs.retained_window_count) - .and_then(|n| n.checked_mul(inputs.physical_summary_count)) - .and_then(|n| n.checked_mul(counts.state_builds)) - .ok_or(AnalyticalCostError::Overflow)?; - let retained_bytes = state_instances - .checked_mul(inputs.state_bytes_per_summary) - .ok_or(AnalyticalCostError::Overflow)?; - // Merge/subtract may stream over persistent inputs but still needs one - // result state per physical instance. Persistent retained windows are - // already included above and are not loaded a second time. - let transient_bytes = if counts.merges_per_read > 0 || counts.subtracts_per_read > 0 { - inputs - .physical_summary_count - .checked_mul(inputs.state_bytes_per_summary) - .ok_or(AnalyticalCostError::Overflow)? - } else { - 0 - }; - let join_bytes = match (counts.joins_per_read, join.as_ref()) { - (0, _) => 0, - (_, Some(evidence)) => evidence.working_memory_bytes, - _ => return Err(AnalyticalCostError::MissingOrStale("summary_join")), - }; - let bootstrap_row_buffer = if inputs.initial_input_rows == 0 { - 0 - } else { - inputs - .initial_input_bytes - .div_ceil(inputs.initial_input_rows) - }; - Ok(ResourceEstimate::new( - cpu_ops, - retained_bytes - .checked_add(transient_bytes) - .and_then(|bytes| bytes.checked_add(join_bytes)) - .ok_or(AnalyticalCostError::Overflow)? - .max(bootstrap_row_buffer), - inputs.initial_source_scan_bytes, - )) -} - -pub(super) fn lifecycle_row_counts( - inputs: SummaryMaintenanceInputs, - guarantee: &SummaryMaintenanceLifecycleGuarantee, - scope: &ComparisonScope, -) -> Result<(u64, u64, u64), AnalyticalCostError> { - validate_arrival_rate(scope.data_arrival, inputs.ingestion_rate_per_second)?; - let horizon_end = scope - .planning_time - .0 - .checked_add(scope.horizon.0) - .ok_or(AnalyticalCostError::Overflow)?; - let (bootstrap_extra_ms, active_ms) = match guarantee.summary_maintenance_lifecycle { - SummaryMaintenanceLifecycle::Prepared { - activate_at, - retire_at, - } => { - if activate_at.0 >= retire_at.0 { - return Err(AnalyticalCostError::IncompatibleLifecycleGuarantee); - } - let covers_every_evaluation = evaluation_offsets_ms(scope)?.into_iter().all(|offset| { - scope - .planning_time - .0 - .checked_add(offset) - .is_some_and(|at| at >= activate_at.0 && at < retire_at.0) - }); - if !covers_every_evaluation { - return Err(AnalyticalCostError::IncompatibleLifecycleGuarantee); - } - let activation = activate_at.0.max(scope.planning_time.0).min(horizon_end); - let bootstrap_extra_ms = activation.saturating_sub(scope.planning_time.0); - let start = activation; - let end = retire_at.0.min(horizon_end); - (bootstrap_extra_ms, end.saturating_sub(start)) - } - SummaryMaintenanceLifecycle::Shared { .. } => (0, scope.horizon.0), - SummaryMaintenanceLifecycle::ContinuouslyMaintained => (0, scope.horizon.0), - SummaryMaintenanceLifecycle::Ephemeral => { - return Err(AnalyticalCostError::IncompatibleLifecycleGuarantee) - } - }; - let bootstrap_extra = inputs.ingestion_rate_per_second * bootstrap_extra_ms as f64 / 1000.0; - let updates = inputs.ingestion_rate_per_second * active_ms as f64 / 1000.0; - if !bootstrap_extra.is_finite() - || !updates.is_finite() - || bootstrap_extra > u64::MAX as f64 - || updates > u64::MAX as f64 - { - return Err(AnalyticalCostError::Overflow); - } - Ok(( - inputs - .initial_input_rows - .checked_add(bootstrap_extra.ceil() as u64) - .ok_or(AnalyticalCostError::Overflow)?, - updates.ceil() as u64, - active_ms, - )) -} - -fn validate_guarantee( - guarantee: &SummaryMaintenanceLifecycleGuarantee, - arrival: DataArrival, -) -> Result<(), AnalyticalCostError> { - if guarantee.output_representation != asap_types::post_asap::OutputRepresentation::SummaryState - || guarantee.summary_maintenance_mode - != maintenance_mode(&guarantee.summary_maintenance_lifecycle, arrival) - || guarantee.evaluation_schedule - != evaluation_schedule(&guarantee.summary_maintenance_lifecycle, arrival) - { - return Err(AnalyticalCostError::IncompatibleLifecycleGuarantee); - } - Ok(()) -} - -#[cfg(test)] -fn required_cpu(name: &'static str, value: Option) -> Result { - let value = value.ok_or(AnalyticalCostError::MissingOrStale(name))?; - if !value.is_finite() || value <= 0.0 { - return Err(AnalyticalCostError::InvalidOperationCost(name, value)); - } - Ok(value) -} - -pub(super) fn validated_operator_cpu( - name: &'static str, - value: f64, -) -> Result { - if !value.is_finite() || value <= 0.0 { - Err(AnalyticalCostError::InvalidOperationCost(name, value)) - } else { - Ok(value) - } -} - -fn validated_operator_executions( - name: &'static str, - evidence: &SummaryOperatorResourceEvidence, -) -> Result { - if evidence.executions_per_evaluation == 0 { - return Err(AnalyticalCostError::MissingOrZero(name)); - } - Ok(evidence.executions_per_evaluation) -} - -#[cfg(test)] -fn required_cpu_when( - count: u64, - name: &'static str, - value: Option, -) -> Result { - if count == 0 { - return Ok(0.0); - } - required_cpu(name, value) -} - -#[cfg(test)] -fn count_operations(root: &OperatorNode) -> Result { - fn visit( - node: &OperatorNode, - seen: &mut HashSet<*const OperatorNode>, - counts: &mut SummaryOperationCounts, - ) -> Result<(), AnalyticalCostError> { - if !seen.insert(node as *const OperatorNode) { - return Ok(()); - } - match &node.operator { - Operator::ASAP(ASAPOp::SummaryAgg { .. }) => { - counts.state_builds = counts - .state_builds - .checked_add(1) - .ok_or(AnalyticalCostError::Overflow)?; - } - Operator::ASAP(ASAPOp::SummaryMerge { children }) => { - if children.is_empty() { - return Err(AnalyticalCostError::InvalidPhysicalDAG( - "summary merge has no children", - )); - } - counts.merges_per_read = counts - .merges_per_read - .checked_add(children.len().saturating_sub(1) as u64) - .ok_or(AnalyticalCostError::Overflow)?; - } - Operator::ASAP(ASAPOp::SummarySubtract { .. }) => { - counts.subtracts_per_read = counts - .subtracts_per_read - .checked_add(1) - .ok_or(AnalyticalCostError::Overflow)?; - } - Operator::ASAP(ASAPOp::SummaryDelete { .. }) => { - counts.deletes_per_update = counts - .deletes_per_update - .checked_add(1) - .ok_or(AnalyticalCostError::Overflow)?; - } - Operator::ASAP( - ASAPOp::SummaryEstimate { .. } | ASAPOp::FinalizeExactAccumulator { .. }, - ) => { - counts.evaluations_per_read = counts - .evaluations_per_read - .checked_add(1) - .ok_or(AnalyticalCostError::Overflow)?; - } - Operator::ASAP(ASAPOp::SummaryJoin { .. }) => { - counts.joins_per_read = counts - .joins_per_read - .checked_add(1) - .ok_or(AnalyticalCostError::Overflow)?; - } - // Retained relational work, accumulator/population boundaries and - // exact query-time operators add no summary operation. - Operator::NonASAP(_) - | Operator::ASAP( - ASAPOp::MaintainPopulation { .. } - | ASAPOp::EvaluatePopulation { .. } - | ASAPOp::Extension { .. }, - ) => {} - } - for child in node.children() { - visit(child, seen, counts)?; - } - Ok(()) - } - - let mut counts = SummaryOperationCounts::default(); - visit(root, &mut HashSet::new(), &mut counts)?; - Ok(counts) -} diff --git a/crates/asap-aware-mapping/src/summary_maintenance_cost/evidence.rs b/crates/asap-aware-mapping/src/summary_maintenance_cost/evidence.rs deleted file mode 100644 index 6ae5999bd..000000000 --- a/crates/asap-aware-mapping/src/summary_maintenance_cost/evidence.rs +++ /dev/null @@ -1,386 +0,0 @@ -use super::*; - -/// Physical evidence that is not represented by [`DataWorkload`] for one -/// summary deployment. Window counts describe the -/// already-selected physical deployment; this layer does not define another -/// tumbling/sliding policy enum. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct SummaryPhysicalInputEvidence { - /// Logical bytes in the snapshot used to bootstrap the state. - pub initial_input_bytes: u64, - /// Source bytes read while bootstrapping. Arriving stream bytes are not a - /// disk scan and are therefore excluded. - pub initial_source_scan_bytes: u64, - /// Simultaneously open windows receiving each arriving item. - pub active_window_count: u64, - /// Window/state partitions receiving each bootstrap row. - pub bootstrap_window_count: u64, - /// Completed windows retained for query coverage. - pub retained_window_count: u64, - /// Independent state instances per window: one for shared - /// multi-subpopulation state, otherwise the resolved group count. - pub physical_summary_count: u64, - /// Resident bytes of one concrete state instance. - pub state_bytes_per_summary: u64, -} - -/// Workload-normalized inputs for summary construction and maintenance over one finite -/// comparison horizon. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct SummaryMaintenanceInputs { - pub initial_input_rows: u64, - pub initial_input_bytes: u64, - pub initial_source_scan_bytes: u64, - pub ingestion_rate_per_second: f64, - pub active_window_count: u64, - pub bootstrap_window_count: u64, - pub retained_window_count: u64, - pub physical_summary_count: u64, - pub state_bytes_per_summary: u64, -} - -impl SummaryMaintenanceInputs { - /// Resolve snapshot size, arriving rows, and reads from the canonical - /// workload. Positive fractional expected work rounds up conservatively. - /// - /// `AtRest` needs snapshot cardinality and implies zero arrivals; continuous - /// ingestion additionally requires fresh rate evidence. - /// `Mixed` fails closed because today's workload schema cannot distinguish - /// its at-rest backlog from its continuing-arrival cardinality. - pub fn from_workload( - physical: SummaryPhysicalInputEvidence, - data: &DataWorkload, - scope: &ComparisonScope, - ) -> Result { - let _ = scope.validate()?; - if scope.data_arrival != data.arrival { - return Err(AnalyticalCostError::ComparisonScopeMismatch("data arrival")); - } - let initial_input_rows = data - .input_cardinality - .value_at(scope.planning_time.0) - .copied() - .ok_or(AnalyticalCostError::MissingOrStale("input_cardinality"))?; - let ingestion_rate = match data.arrival { - DataArrival::AtRest => { - // A declared snapshot has no arrivals. Reject contradictory fresh - // evidence rather than silently pricing the wrong workload. - if let Some(rate) = data.ingestion_rate.value_at(scope.planning_time.0) { - validate_arrival_rate(data.arrival, rate.0)?; - } - 0.0 - } - DataArrival::ContinuouslyIngesting => { - data.ingestion_rate - .value_at(scope.planning_time.0) - .copied() - .ok_or(AnalyticalCostError::MissingOrStale("ingestion_rate"))? - .0 - } - arrival => return Err(AnalyticalCostError::UnsupportedDataArrival(arrival)), - }; - validate_arrival_rate(data.arrival, ingestion_rate)?; - Self { - initial_input_rows, - initial_input_bytes: physical.initial_input_bytes, - initial_source_scan_bytes: physical.initial_source_scan_bytes, - ingestion_rate_per_second: ingestion_rate, - active_window_count: physical.active_window_count, - bootstrap_window_count: physical.bootstrap_window_count, - retained_window_count: physical.retained_window_count, - physical_summary_count: physical.physical_summary_count, - state_bytes_per_summary: physical.state_bytes_per_summary, - } - .validate() - } - - pub fn validate(self) -> Result { - for (name, value) in [ - ("active_window_count", self.active_window_count), - ("bootstrap_window_count", self.bootstrap_window_count), - ("physical_summary_count", self.physical_summary_count), - ("state_bytes_per_summary", self.state_bytes_per_summary), - ] { - if value == 0 { - return Err(AnalyticalCostError::MissingOrZero(name)); - } - } - if (self.initial_input_rows == 0) != (self.initial_input_bytes == 0) - || (self.initial_input_rows == 0 && self.initial_source_scan_bytes != 0) - { - return Err(AnalyticalCostError::InconsistentBootstrapEvidence); - } - if !self.ingestion_rate_per_second.is_finite() || self.ingestion_rate_per_second < 0.0 { - return Err(AnalyticalCostError::InvalidIngestionRate( - self.ingestion_rate_per_second, - )); - } - Ok(self) - } -} - -/// CPU operations for one concrete state operation on one state instance. -/// Missing evidence is legal only when the selected summary DAG does not use -/// that operation. -#[cfg(test)] -#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] -pub struct SummaryOperationCpuEvidence { - pub insert_cpu_ops: Option, - pub merge_cpu_ops: Option, - pub subtract_cpu_ops: Option, - pub delete_cpu_ops: Option, - /// Expirations/retractions routed to this DAG per second. Required only - /// when an explicit `SummaryDelete` is present. - pub delete_events_per_second: Option, - /// Concrete state instances touched by one delete event. - pub delete_routing_fanout: Option, - pub evaluation_cpu_ops: Option, -} - -/// Physical evidence for one `SummaryJoin` implementation. Total work, -/// cardinality, and memory cannot be inferred from the logical join key alone. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SummaryJoinEvidence { - pub physical_id: String, - pub inputs: Vec, - pub output: EdgeStatistics, - /// Total build, probe, match-production, and output CPU for one complete - /// execution of the selected physical join algorithm. - pub cpu_ops_per_execution: f64, - pub working_memory_bytes: u64, - pub output_buffer_bytes: u64, - pub executions_per_evaluation: u64, - pub io_bytes_per_execution: Option, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct SummaryAggregateEvidence { - pub physical_id: String, - pub input: EdgeStatistics, - pub output: EdgeStatistics, - /// Index into `ComparisonScope.sources` when this state bootstraps directly - /// from storage. `None` means its input is an already-materialized child - /// edge and therefore has no additional source read. - pub scan_selection_index: Option, - /// Provider-owned identity of the physical bootstrap read. Equal source - /// coverage alone does not prove two independent builds share I/O. - pub bootstrap_read_identity: String, - pub inputs: SummaryMaintenanceInputs, - /// CPU operations to insert one routed row into one state instance. - pub insert_cpu_ops: f64, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct SummaryOperatorResourceEvidence { - pub physical_id: String, - pub inputs: Vec, - pub output: EdgeStatistics, - pub cpu_ops: f64, - pub working_memory_bytes: u64, - pub output_buffer_bytes: u64, - /// Executions of this physical operator for one query evaluation. - /// This is provider evidence, not inferred from a descendant state. - pub executions_per_evaluation: u64, - pub io_bytes_per_execution: Option, -} - -/// Evidence is structured by logical summary operation so delete-only facts -/// cannot be attached to merge, subtract, or evaluation nodes. -#[derive(Debug, Clone, PartialEq)] -pub enum SummaryOperatorEvidence { - /// Exact query-time arithmetic over two independently realized operands. - Binary(SummaryOperatorResourceEvidence), - /// Query-time or maintenance-time plain-value work. For `Sort`/`Limit`, - /// providers report the actual comparison/heap work and working set here; - /// the estimator charges it at query multiplicity. - ValueOperation(SummaryOperatorResourceEvidence), - Merge(SummaryOperatorResourceEvidence), - Subtract(SummaryOperatorResourceEvidence), - Delete { - resource: SummaryOperatorResourceEvidence, - events_per_second: f64, - routing_fanout: u64, - }, - Evaluation(SummaryOperatorResourceEvidence), -} - -impl SummaryOperatorEvidence { - pub(super) fn resource(&self) -> &SummaryOperatorResourceEvidence { - match self { - Self::Binary(resource) - | Self::ValueOperation(resource) - | Self::Merge(resource) - | Self::Subtract(resource) - | Self::Delete { resource, .. } - | Self::Evaluation(resource) => resource, - } - } - - #[cfg(test)] - pub(super) fn resource_mut(&mut self) -> &mut SummaryOperatorResourceEvidence { - match self { - Self::Binary(resource) - | Self::ValueOperation(resource) - | Self::Merge(resource) - | Self::Subtract(resource) - | Self::Delete { resource, .. } - | Self::Evaluation(resource) => resource, - } - } -} - -/// Non-aggregation work for a retained pre-ASAP sub-DAG over the comparison -/// horizon. Bootstrap/source I/O belongs exclusively to the owning aggregate, -/// and summary insertion belongs exclusively to its insert evidence. -#[derive(Debug, Clone, PartialEq)] -pub struct RetainedSubDAGEvidence { - pub physical_id: String, - /// Logical output edge consumed by the parent summary operator. - pub output: EdgeStatistics, - pub preprocessing_cpu_ops_over_horizon: f64, - /// Execution workspace, excluding the separately declared output buffer. - pub working_memory_bytes: u64, - pub output_buffer_bytes: u64, -} - -/// Physical evidence bound to the selected DAG's `Rc` identity. A copied, -/// structurally equal node is not silently treated as the same deployment. -#[derive(Debug, Clone, Default)] -pub struct SummaryNodeEvidence { - pub(super) aggregations: HashMap<*const OperatorNode, SummaryAggregateEvidence>, - pub(super) joins: HashMap<*const OperatorNode, SummaryJoinEvidence>, - pub(super) operations: HashMap<*const OperatorNode, SummaryOperatorEvidence>, - pub(super) operation_state_owners: HashMap<*const OperatorNode, *const OperatorNode>, - pub(super) retained_queries: HashMap<*const OperatorNode, RetainedSubDAGEvidence>, -} - -impl SummaryNodeEvidence { - pub fn insert_aggregation( - &mut self, - node: &Rc, - evidence: SummaryAggregateEvidence, - ) { - self.aggregations.insert(Rc::as_ptr(node), evidence); - } - - pub fn insert_join(&mut self, node: &Rc, evidence: SummaryJoinEvidence) { - self.joins.insert(Rc::as_ptr(node), evidence); - } - - pub fn insert_operation(&mut self, node: &Rc, evidence: SummaryOperatorEvidence) { - self.operations.insert(Rc::as_ptr(node), evidence); - } - - /// Bind a stateful operation (currently `SummaryDelete`) to the exact - /// aggregation deployment whose active interval it follows. - pub fn insert_state_operation( - &mut self, - node: &Rc, - state: &Rc, - evidence: SummaryOperatorEvidence, - ) { - self.operations.insert(Rc::as_ptr(node), evidence); - self.operation_state_owners - .insert(Rc::as_ptr(node), Rc::as_ptr(state)); - } - - pub fn insert_retained_query( - &mut self, - node: &Rc, - evidence: RetainedSubDAGEvidence, - ) { - self.retained_queries.insert(Rc::as_ptr(node), evidence); - } - - pub(super) fn aggregation(&self, node: &OperatorNode) -> Option { - self.aggregations.get(&(node as *const _)).cloned() - } -} - -pub(super) fn summary_operation_evidence<'a>( - node: &OperatorNode, - evidence: &'a SummaryNodeEvidence, -) -> Result<&'a SummaryOperatorEvidence, AnalyticalCostError> { - let operation = evidence - .operations - .get(&(node as *const _)) - .ok_or(AnalyticalCostError::MissingOrStale("summary operation"))?; - // A binary operator or join over two inputs is `Binary` evidence; every - // other non-ASAP operator, and the accumulator/population boundaries, - // is a `ValueOperation`. - let matches = match (&node.operator, operation) { - ( - Operator::NonASAP(NonASAPOp::BinaryOp { .. } | NonASAPOp::Join { .. }), - SummaryOperatorEvidence::Binary(_), - ) => true, - (Operator::NonASAP(NonASAPOp::BinaryOp { .. } | NonASAPOp::Join { .. }), _) => false, - ( - Operator::NonASAP(_) - | Operator::ASAP( - ASAPOp::FinalizeExactAccumulator { .. } - | ASAPOp::MaintainPopulation { .. } - | ASAPOp::EvaluatePopulation { .. }, - ), - SummaryOperatorEvidence::ValueOperation(_), - ) => true, - (Operator::ASAP(ASAPOp::SummaryMerge { .. }), SummaryOperatorEvidence::Merge(_)) - | (Operator::ASAP(ASAPOp::SummarySubtract { .. }), SummaryOperatorEvidence::Subtract(_)) - | (Operator::ASAP(ASAPOp::SummaryDelete { .. }), SummaryOperatorEvidence::Delete { .. }) - | ( - Operator::ASAP(ASAPOp::SummaryEstimate { .. }), - SummaryOperatorEvidence::Evaluation(_), - ) => true, - _ => false, - }; - if matches { - Ok(operation) - } else { - Err(AnalyticalCostError::InconsistentOperatorStatistics( - "summary operation evidence kind does not match the operator", - )) - } -} - -/// Evidence for recomputing the raw target over the full comparison horizon. -/// Planning-time dimensions describe the initial snapshot. Each scheduled -/// evaluation adds arrivals since planning time; `physical_dag` is therefore -/// a once-counted DAG whose edge statistics already aggregate all evaluations. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RawInputEvidence { - pub planning_time_input_rows: u64, - pub planning_time_input_bytes: u64, - pub planning_time_source_scan_bytes: u64, - /// Decoded logical bytes added to operator edges by one arriving row. - pub arriving_logical_row_bytes: u64, - /// Physical storage bytes read for one arriving row. Kept separate from - /// logical width so compression and encoding are not silently conflated. - pub arriving_source_row_bytes: u64, - pub ingestion_rate_per_second: f64, - pub physical_dag: EvidenceBackedPhysicalDAG, -} - -/// One complete provider-enumerated physical implementation of the selected -/// summary DAG. The identifier is stable provenance; concrete -/// framework selection is performed by ranking these complete alternatives. -#[derive(Debug, Clone)] -pub struct SummaryPhysicalPlanAlternative { - pub physical_plan_id: String, - pub node_evidence: SummaryNodeEvidence, -} - -/// Apply arrival semantics to both workload-derived and directly bound evidence. -pub(super) fn validate_arrival_rate( - arrival: DataArrival, - rate: f64, -) -> Result<(), AnalyticalCostError> { - if !rate.is_finite() || rate < 0.0 { - return Err(AnalyticalCostError::InvalidIngestionRate(rate)); - } - match arrival { - DataArrival::AtRest if rate != 0.0 => Err(AnalyticalCostError::ComparisonScopeMismatch( - "at-rest ingestion rate", - )), - DataArrival::AtRest | DataArrival::ContinuouslyIngesting => Ok(()), - other => Err(AnalyticalCostError::UnsupportedDataArrival(other)), - } -} diff --git a/crates/asap-aware-mapping/src/summary_maintenance_cost/mod.rs b/crates/asap-aware-mapping/src/summary_maintenance_cost/mod.rs deleted file mode 100644 index ff4c69c1d..000000000 --- a/crates/asap-aware-mapping/src/summary_maintenance_cost/mod.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Analytical resource cost for at-rest and at-rest and incrementally maintained summary deployments. -//! -//! The canonical workload and lifecycle types own deployment semantics. This -//! module only adds physical evidence absent from those schemas: state size, -//! window counts, and per-operation CPU measurements or complexity estimates. - -use std::collections::{HashMap, HashSet}; -use std::rc::Rc; - -use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, Predicate}; -use asap_types::post_asap::{ - BoundExpr, ErrorMetric, ExactKind, FieldDataType, GuaranteeSource, ProbabilityExpr, - ResultGuarantee, SketchAlgorithm, SummaryMaintenanceLifecycle, - SummaryMaintenanceLifecycleGuarantee, SummaryWindowFramework, -}; -use asap_types::pre_asap::{agg_intent::AggIntent, CompareOpKind, InfoMatcher, Source}; -use asap_types::types::AccuracyTarget; -use asap_types::workload::{DataArrival, DataWorkload, QueryRecurrence, RepeatedDemand}; -use serde::{Deserialize, Serialize}; - -use crate::accuracy::{AccuracyModel, DefaultAccuracyModel}; -use crate::analytical_cost::ExecutionMultiplicity; -#[cfg(test)] -use crate::analytical_cost::PhysicalNodeEvidence; -use crate::analytical_cost::{ - estimate_physical_dag, AnalyticalCostError, EvidenceBackedPhysicalDAG, PhysicalDAGNode, - PhysicalOperator, ResourceCalibration, ResourceEstimate, -}; -use crate::cost_model::{ - CompleteSummaryCandidateEstimate, Cost, CostModel, CostedSummaryDeployment, DefaultCostModel, -}; -#[cfg(test)] -use crate::physical_operator_statistics::UnaryEdgeStatistics; -use crate::physical_operator_statistics::{ComparisonScope, EdgeStatistics, OperatorStatistics}; -use crate::recurrence::CostRate; -use crate::replacement::{Replacement, ReplacementSubDAG, TargetSubDAG}; -use crate::summary_maintenance_lifecycle::{ - evaluation_schedule, maintenance_mode, SummaryMaintenanceCapabilities, - SummaryMaintenanceLifecycleCostInputs, -}; - -pub const SUMMARY_MAINTENANCE_COST_MODEL_VERSION: &str = "summary-maintenance-resource-v2"; - -mod estimator; -mod evidence; -mod model; -mod window; - -pub use evidence::*; -pub use model::*; -pub use window::*; diff --git a/crates/asap-aware-mapping/src/summary_maintenance_cost/model.rs b/crates/asap-aware-mapping/src/summary_maintenance_cost/model.rs deleted file mode 100644 index 4eb41339a..000000000 --- a/crates/asap-aware-mapping/src/summary_maintenance_cost/model.rs +++ /dev/null @@ -1,3660 +0,0 @@ -use super::*; -/// Adapter that supplies the existing lifecycle planner with analytical -/// summary costs across at-rest and continuously ingesting workloads. The -/// planner's existing lifecycle enums and legality checks remain authoritative. -#[derive(Debug, Clone)] -pub struct SummaryMaintenanceCostModel { - pub node_evidence: SummaryNodeEvidence, - pub calibration: ResourceCalibration, - pub capabilities: SummaryMaintenanceCapabilities, - target_comparisons: HashMap<*const OperatorNode, SummaryTargetComparison>, - candidate_comparisons: HashMap, - physical_plan_alternatives: - HashMap>, - window_framework_candidates: - HashMap>, -} - -type CandidateComparisonKey = (*const OperatorNode, *const OperatorNode); - -#[derive(Debug, Clone)] -struct BoundCandidateIdentity { - _target: Rc, - _root: Rc, -} - -#[derive(Debug, Clone)] -struct SummaryTargetComparison { - _target: Rc, - scope: ComparisonScope, - raw: RawInputEvidence, -} - -pub(super) type LogicalSourceSelection = (Source, Vec, Vec); - -pub(super) fn deduplicate_source_selections( - values: Vec, -) -> Vec { - values.into_iter().fold(Vec::new(), |mut unique, value| { - if !unique.contains(&value) { - unique.push(value); - } - unique - }) -} - -fn info_source(selector: &[InfoMatcher]) -> Result { - let mut metric: Option<&str> = None; - for matcher in selector - .iter() - .filter(|matcher| matcher.label == "__name__") - { - if matcher.op != CompareOpKind::Eq || metric.is_some_and(|value| value != matcher.value) { - return Err(AnalyticalCostError::UnsupportedQueryOperator); - } - metric = Some(&matcher.value); - } - Ok(Source::TimeSeries { - metric: metric.unwrap_or("target_info").into(), - }) -} - -/// Collect the source selections (scan sources with their predicates, and -/// info-metric selectors) of every leaf reachable from `node`, visiting a -/// shared node once. -pub(super) fn query_source_selections( - node: &OperatorNode, - seen: &mut HashSet<*const OperatorNode>, - out: &mut Vec, -) -> Result<(), AnalyticalCostError> { - if !seen.insert(node as *const _) { - return Ok(()); - } - match &node.operator { - Operator::NonASAP(NonASAPOp::Scan { - source, predicates, .. - }) => out.push((source.clone(), predicates.clone(), vec![])), - Operator::NonASAP(NonASAPOp::PromqlInfoEnrich { selector, child }) => { - query_source_selections(child, seen, out)?; - out.push((info_source(selector)?, vec![], selector.clone())); - } - _ => { - for child in node.children() { - query_source_selections(child, seen, out)?; - } - } - } - Ok(()) -} - -fn validate_query_scope( - target: &OperatorNode, - scope: &ComparisonScope, -) -> Result<(), AnalyticalCostError> { - let mut actual = Vec::new(); - query_source_selections(target, &mut HashSet::new(), &mut actual)?; - let actual = deduplicate_source_selections(actual); - let mut declared: Vec<_> = scope - .sources - .iter() - .map(|coverage| { - ( - coverage.source.clone(), - coverage.predicates.clone(), - coverage.info_matchers.clone(), - ) - }) - .collect(); - for selection in actual { - let Some(index) = declared.iter().position(|value| value == &selection) else { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "raw target source lineage", - )); - }; - declared.swap_remove(index); - } - if !declared.is_empty() { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "raw target source lineage", - )); - } - Ok(()) -} - -fn validate_physical_scope_coverage( - physical: &EvidenceBackedPhysicalDAG, - scope: &ComparisonScope, -) -> Result<(), AnalyticalCostError> { - let nodes = reachable_physical_nodes(physical)?; - let mut covered = HashSet::new(); - for node in nodes - .into_iter() - .filter(|node| node.operator == PhysicalOperator::Scan) - { - let coverage = node - .scan_selection - .as_ref() - .ok_or_else(|| AnalyticalCostError::MissingScanSelection(node.id.clone()))?; - let Some(index) = scope.sources.iter().position(|value| value == coverage) else { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "physical scan selection", - )); - }; - covered.insert(index); - } - if covered.len() != scope.sources.len() { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "physical scan selection", - )); - } - Ok(()) -} - -fn reachable_physical_nodes( - physical: &EvidenceBackedPhysicalDAG, -) -> Result, AnalyticalCostError> { - let by_id: HashMap<_, _> = physical - .nodes - .iter() - .map(|node| (node.id.as_str(), node)) - .collect(); - if by_id.len() != physical.nodes.len() { - return Err(AnalyticalCostError::InvalidPhysicalDAG("duplicate node id")); - } - fn visit<'a>( - id: &'a str, - by_id: &HashMap<&'a str, &'a PhysicalDAGNode>, - visiting: &mut HashSet<&'a str>, - visited: &mut HashSet<&'a str>, - nodes: &mut Vec<&'a PhysicalDAGNode>, - ) -> Result<(), AnalyticalCostError> { - if visited.contains(id) { - return Ok(()); - } - if !visiting.insert(id) { - return Err(AnalyticalCostError::InvalidPhysicalDAG("cycle")); - } - let node = by_id - .get(id) - .copied() - .ok_or(AnalyticalCostError::InvalidPhysicalDAG("missing node"))?; - for child in &node.children { - visit(child, by_id, visiting, visited, nodes)?; - } - visiting.remove(id); - visited.insert(id); - nodes.push(node); - Ok(()) - } - let mut nodes = Vec::new(); - visit( - physical.root.as_str(), - &by_id, - &mut HashSet::new(), - &mut HashSet::new(), - &mut nodes, - )?; - Ok(nodes) -} - -fn validate_raw_snapshot_dimensions( - raw: &RawInputEvidence, - scope: &ComparisonScope, -) -> Result<(), AnalyticalCostError> { - validate_arrival_rate(scope.data_arrival, raw.ingestion_rate_per_second)?; - if scope.sources.len() != 1 { - return Err(AnalyticalCostError::MissingComparisonScope( - "single-source raw evolution", - )); - } - if !raw.ingestion_rate_per_second.is_finite() || raw.ingestion_rate_per_second < 0.0 { - return Err(AnalyticalCostError::InvalidIngestionRate( - raw.ingestion_rate_per_second, - )); - } - let bootstrap_is_consistent = if raw.planning_time_input_rows == 0 { - raw.planning_time_input_bytes == 0 && raw.planning_time_source_scan_bytes == 0 - } else { - raw.planning_time_input_bytes > 0 && raw.planning_time_source_scan_bytes > 0 - }; - if !bootstrap_is_consistent - || (raw.ingestion_rate_per_second > 0.0 - && (raw.arriving_logical_row_bytes == 0 || raw.arriving_source_row_bytes == 0)) - { - return Err(AnalyticalCostError::InconsistentBootstrapEvidence); - } - let mut rows = 0_u64; - let mut bytes = 0_u64; - let mut scan = 0_u64; - for offset in evaluation_offsets_ms(scope)? { - let arrivals = (raw.ingestion_rate_per_second * offset as f64 / 1_000.0).ceil(); - if !arrivals.is_finite() || arrivals < 0.0 || arrivals > u64::MAX as f64 { - return Err(AnalyticalCostError::Overflow); - } - let arrivals = arrivals as u64; - rows = rows - .checked_add(raw.planning_time_input_rows) - .and_then(|value| value.checked_add(arrivals)) - .ok_or(AnalyticalCostError::Overflow)?; - bytes = bytes - .checked_add(raw.planning_time_input_bytes) - .and_then(|value| { - arrivals - .checked_mul(raw.arriving_logical_row_bytes) - .and_then(|arriving| value.checked_add(arriving)) - }) - .ok_or(AnalyticalCostError::Overflow)?; - scan = scan - .checked_add(raw.planning_time_source_scan_bytes) - .and_then(|value| { - arrivals - .checked_mul(raw.arriving_source_row_bytes) - .and_then(|arriving| value.checked_add(arriving)) - }) - .ok_or(AnalyticalCostError::Overflow)?; - } - let reachable = reachable_physical_nodes(&raw.physical_dag)?; - if reachable - .iter() - .any(|node| node.execution != ExecutionMultiplicity::Once) - { - return Err(AnalyticalCostError::InvalidPhysicalDAG( - "streaming raw horizon evidence must use once-counted aggregate statistics", - )); - } - let expected = EdgeStatistics { rows, bytes }; - let mut scan_count = 0; - for scan_node in reachable - .into_iter() - .filter(|node| node.operator == PhysicalOperator::Scan) - { - scan_count += 1; - let evidence = raw - .physical_dag - .evidence - .get(&scan_node.id) - .ok_or_else(|| AnalyticalCostError::MissingOperatorStatistics(scan_node.id.clone()))?; - let statistics = &evidence.statistics; - let OperatorStatistics::Scan { - edges, - source_read_bytes, - } = statistics - else { - return Err(AnalyticalCostError::InvalidOperatorStatistics { - node: scan_node.id.clone(), - reason: "raw scan evidence uses the wrong statistics variant", - }); - }; - if edges.input != expected || edges.output != expected || *source_read_bytes != scan { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "raw source evolution", - )); - } - } - if scan_count == 0 { - return Err(AnalyticalCostError::MissingComparisonScope("raw scan")); - } - Ok(()) -} - -pub(super) fn ephemeral_rows_over_horizon( - inputs: SummaryMaintenanceInputs, - scope: &ComparisonScope, -) -> Result { - evaluation_offsets_ms(scope)? - .into_iter() - .try_fold(0_u64, |total, offset| { - let arrivals = (inputs.ingestion_rate_per_second * offset as f64 / 1_000.0).ceil(); - if !arrivals.is_finite() || arrivals < 0.0 || arrivals > u64::MAX as f64 { - return Err(AnalyticalCostError::Overflow); - } - total - .checked_add(inputs.initial_input_rows) - .and_then(|value| value.checked_add(arrivals as u64)) - .ok_or(AnalyticalCostError::Overflow) - }) -} - -pub(super) fn ephemeral_scan_bytes_over_horizon( - inputs: SummaryMaintenanceInputs, - raw: &RawInputEvidence, - scope: &ComparisonScope, -) -> Result { - if inputs.initial_source_scan_bytes == 0 { - return Ok(0); - } - evaluation_offsets_ms(scope)? - .into_iter() - .try_fold(0_u64, |total, offset| { - let arrivals = (inputs.ingestion_rate_per_second * offset as f64 / 1_000.0).ceil(); - if !arrivals.is_finite() || arrivals < 0.0 || arrivals > u64::MAX as f64 { - return Err(AnalyticalCostError::Overflow); - } - total - .checked_add(inputs.initial_source_scan_bytes) - .and_then(|value| { - (arrivals as u64) - .checked_mul(raw.arriving_source_row_bytes) - .and_then(|arriving| value.checked_add(arriving)) - }) - .ok_or(AnalyticalCostError::Overflow) - }) -} - -pub(super) fn evaluation_offsets_ms( - scope: &ComparisonScope, -) -> Result, AnalyticalCostError> { - let count = scope.validate()?; - match &scope.recurrence { - QueryRecurrence::OneTime { - invocations, - execute_at, - } => Ok(vec![ - execute_at.map_or(0, |at| at - .0 - .saturating_sub(scope.planning_time.0)); - *invocations as usize - ]), - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(interval)) - | QueryRecurrence::Repeated(RepeatedDemand::FixedIntervalAt { interval, .. }) => { - Ok((1..=count).map(|n| n * u64::from(interval.0)).collect()) - } - QueryRecurrence::Repeated(RepeatedDemand::Scheduled(schedule)) => Ok(schedule - .iter() - .filter(|at| { - at.0 >= scope.planning_time.0 - && at.0 <= scope.planning_time.0.saturating_add(scope.horizon.0) - }) - .map(|at| at.0 - scope.planning_time.0) - .collect()), - QueryRecurrence::Repeated(RepeatedDemand::EstimatedRate(_)) => Ok((1..=count) - .map(|n| scope.horizon.0.saturating_mul(n) / count) - .collect()), - QueryRecurrence::Unknown => Err(AnalyticalCostError::InvalidRecurrence), - } -} - -impl SummaryMaintenanceCostModel { - pub fn new( - calibration: ResourceCalibration, - capabilities: SummaryMaintenanceCapabilities, - ) -> Self { - Self { - node_evidence: SummaryNodeEvidence::default(), - calibration, - capabilities, - target_comparisons: HashMap::new(), - candidate_comparisons: HashMap::new(), - physical_plan_alternatives: HashMap::new(), - window_framework_candidates: HashMap::new(), - } - } - - /// Bind one candidate and its raw baseline to the same target-specific - /// comparison context. Rebinding a target to different evidence is - /// rejected rather than silently replacing the canonical context. - pub fn bind_candidate_comparison( - &mut self, - target: &Rc, - root: &Rc, - scope: ComparisonScope, - raw: RawInputEvidence, - ) -> Result<(), AnalyticalCostError> { - scope.validate()?; - validate_query_scope(target, &scope)?; - validate_physical_scope_coverage(&raw.physical_dag, &scope)?; - validate_raw_snapshot_dimensions(&raw, &scope)?; - estimate_physical_dag( - &raw.physical_dag.nodes, - &raw.physical_dag.root, - &scope, - &raw.physical_dag, - )?; - let target_ptr = Rc::as_ptr(target); - if let Some(existing) = self.target_comparisons.get(&target_ptr) { - if existing.scope != scope || existing.raw != raw { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "target comparison", - )); - } - } - // Commit only after every validation above succeeds. Shared nodes do - // not carry one owning target; context identity is `(target, root)`. - self.target_comparisons - .entry(target_ptr) - .or_insert(SummaryTargetComparison { - _target: Rc::clone(target), - scope, - raw, - }); - self.candidate_comparisons.insert( - (target_ptr, Rc::as_ptr(root)), - BoundCandidateIdentity { - _target: Rc::clone(target), - _root: Rc::clone(root), - }, - ); - Ok(()) - } - - /// Add one complete physical implementation for an already-bound logical - /// candidate. Duplicate or empty provider identities are rejected. - pub fn bind_physical_plan_alternative( - &mut self, - target: &Rc, - root: &Rc, - alternative: SummaryPhysicalPlanAlternative, - ) -> Result<(), AnalyticalCostError> { - let key = (Rc::as_ptr(target), Rc::as_ptr(root)); - if !self.candidate_comparisons.contains_key(&key) { - return Err(AnalyticalCostError::MissingOrStale( - "candidate comparison binding", - )); - } - if alternative.physical_plan_id.trim().is_empty() { - return Err(AnalyticalCostError::MissingOrZero("physical_plan_id")); - } - if self.window_framework_candidates.contains_key(&key) { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "physical alternative binding mode", - )); - } - let alternatives = self.physical_plan_alternatives.entry(key).or_default(); - if alternatives - .iter() - .any(|existing| existing.physical_plan_id == alternative.physical_plan_id) - { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "physical plan identity", - )); - } - alternatives.push(alternative); - Ok(()) - } - - /// Add one complete abstract window assignment to Planner candidate search. - /// - /// The provider may bind multiple executor-feasible implementations for - /// the same framework assignment; their stable identities and complete - /// evidence keep the implementations distinct during ranking. - pub fn bind_window_framework_candidate( - &mut self, - target: &Rc, - root: &Rc, - candidate: SummaryWindowFrameworkCandidate, - ) -> Result<(), AnalyticalCostError> { - let key = (Rc::as_ptr(target), Rc::as_ptr(root)); - if !self.candidate_comparisons.contains_key(&key) { - return Err(AnalyticalCostError::MissingOrStale( - "candidate comparison binding", - )); - } - if candidate.physical_plan_id.trim().is_empty() { - return Err(AnalyticalCostError::MissingOrZero("physical_plan_id")); - } - if self.physical_plan_alternatives.contains_key(&key) { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "physical alternative binding mode", - )); - } - if candidate.assignments.is_empty() { - return Err(AnalyticalCostError::MissingOrZero( - "window framework assignments", - )); - } - let mut assigned = HashSet::new(); - if candidate - .assignments - .iter() - .any(|assignment| !assigned.insert(Rc::as_ptr(&assignment.summary))) - { - return Err(AnalyticalCostError::MissingOrZero( - "unique window framework assignments", - )); - } - if assigned != summary_aggregation_identities(root) { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "window framework assignments", - )); - } - let candidates = self.window_framework_candidates.entry(key).or_default(); - if candidates - .iter() - .any(|existing| existing.physical_plan_id == candidate.physical_plan_id) - { - return Err(AnalyticalCostError::ComparisonScopeMismatch( - "window framework candidate", - )); - } - candidates.push(candidate); - Ok(()) - } - - fn comparison_context( - &self, - root: &OperatorNode, - target: Option<&OperatorNode>, - horizon: Option, - expected_reads: Option, - ) -> Option<(CandidateComparisonKey, &SummaryTargetComparison)> { - let root_ptr = root as *const _; - let target_ptr = match target { - Some(target) => target as *const _, - None => { - let mut targets = self - .candidate_comparisons - .keys() - .filter_map(|(target, candidate)| (*candidate == root_ptr).then_some(*target)); - let only = targets.next()?; - if targets.next().is_some() { - return None; - } - only - } - }; - let key = (target_ptr, root_ptr); - if !self.candidate_comparisons.contains_key(&key) { - return None; - } - let comparison = self.target_comparisons.get(&target_ptr)?; - if horizon.map(|value| value.0 * 1_000.0) != Some(comparison.scope.horizon.0 as f64) - || expected_reads != Some(comparison.scope.validate().ok()? as f64) - { - return None; - } - Some((key, comparison)) - } - - fn complete_cost_with_evidence( - &self, - root: &OperatorNode, - deployments: &[CostedSummaryDeployment<'_>], - comparison: &SummaryTargetComparison, - evidence: &SummaryNodeEvidence, - window_frameworks: &[Option], - ) -> Option { - self.calibrated( - estimate_heterogeneous_summary( - root, - deployments, - evidence, - &comparison.scope, - &comparison.raw, - window_frameworks, - ) - .ok()?, - ) - } - - fn canonical_inputs(&self, summary: &OperatorNode) -> Option { - let evidence = self.node_evidence.aggregation(summary)?; - evidence.inputs.validate().ok()?; - Some(evidence) - } - - fn calibrated(&self, estimate: ResourceEstimate) -> Option { - estimate.calibrated_cost(&self.calibration).ok().map(Cost) - } - - fn lifecycle_inputs( - &self, - summary: &OperatorNode, - horizon: Option, - ) -> Option { - let evidence = self.canonical_inputs(summary)?; - let inputs = evidence.inputs; - let insert = validated_operator_cpu("insert_cpu_ops", evidence.insert_cpu_ops).ok()?; - let build = self.calibrated(ResourceEstimate::new( - inputs.initial_input_rows as f64 * inputs.bootstrap_window_count as f64 * insert, - 0, - inputs.initial_source_scan_bytes, - ))?; - let maintenance = self.calibrated(ResourceEstimate::new( - inputs.active_window_count as f64 * insert, - 0, - 0, - ))?; - let retained = inputs - .active_window_count - .checked_add(inputs.retained_window_count)? - .checked_mul(inputs.physical_summary_count)? - .checked_mul(inputs.state_bytes_per_summary)?; - let retention_total = self.calibrated(ResourceEstimate::new(0.0, retained, 0))?; - let horizon_seconds = horizon.filter(|value| value.0 > 0.0)?.0; - Some(SummaryMaintenanceLifecycleCostInputs { - build_cost: Some(build), - maintenance_cost_per_update: Some(maintenance), - // Evaluation is a separate physical operator in the complete DAG. - // A state-only candidate therefore does not fabricate evaluation - // evidence merely to keep a lifecycle alternative selectable. - summary_read_cost: Some(Cost::ZERO), - retention_cost_rate: Some(CostRate(retention_total.0 / horizon_seconds)), - // Releasing memory has no modeled CPU or I/O. This is not an - // implicit expiration/rebuild policy; those require an explicit - // SummaryDelete or future authoritative lifecycle evidence. - retirement_cost: Some(Cost::ZERO), - }) - } -} - -impl CostModel for SummaryMaintenanceCostModel { - fn candidate_cost( - &self, - candidate: &ReplacementSubDAG, - _target: &TargetSubDAG<'_>, - ) -> Option { - match &candidate.replacement { - Replacement::ExactComposition(_) => None, - // Lifecycle selection supplies a complete override. If it cannot, - // the candidate remains unavailable rather than receiving this - // trait's structural fallback. - Replacement::SubDAG(_) => None, - } - } - - fn rank_candidates( - &self, - intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - DefaultCostModel.rank_candidates(intent, candidates) - } - - fn estimate_cost(&self, _candidate: &ReplacementSubDAG, _target: &TargetSubDAG<'_>) -> f64 { - f64::INFINITY - } - - fn summary_maintenance_lifecycle_cost_inputs( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - SummaryMaintenanceLifecycleCostInputs::default() - } - - fn summary_maintenance_lifecycle_cost_inputs_for_horizon( - &self, - summary: &OperatorNode, - horizon: Option, - ) -> SummaryMaintenanceLifecycleCostInputs { - self.lifecycle_inputs(summary, horizon).unwrap_or_default() - } - - fn summary_maintenance_capabilities( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceCapabilities { - self.capabilities - } - - fn complete_summary_candidate_cost( - &self, - root: &OperatorNode, - target: Option<&OperatorNode>, - deployments: &[CostedSummaryDeployment<'_>], - horizon: Option, - expected_reads: Option, - required_accuracy: &[AccuracyTarget], - ) -> Option { - self.complete_summary_candidate_estimate( - root, - target, - deployments, - horizon, - expected_reads, - required_accuracy, - ) - .map(|estimate| estimate.cost) - } - - fn complete_summary_candidate_estimate( - &self, - root: &OperatorNode, - target: Option<&OperatorNode>, - deployments: &[CostedSummaryDeployment<'_>], - horizon: Option, - expected_reads: Option, - required_accuracy: &[AccuracyTarget], - ) -> Option { - let (key, comparison) = self.comparison_context(root, target, horizon, expected_reads)?; - if let Some(candidates) = self.window_framework_candidates.get(&key) { - return candidates - .iter() - .filter_map(|candidate| { - if candidate.assignments.len() != deployments.len() { - return None; - } - if !candidate - .accuracy - .matches_assignments(&candidate.assignments) - { - return None; - } - let window_frameworks = deployments - .iter() - .map(|deployment| { - candidate - .assignments - .iter() - .find(|assignment| { - std::ptr::eq(assignment.summary.as_ref(), deployment.summary) - }) - .map(|assignment| assignment.framework.clone()) - }) - .collect::>>()?; - let uses_exponential_histogram = window_frameworks.iter().any(|framework| { - matches!( - framework, - Some(SummaryWindowFramework::ExponentialHistogram) - ) - }); - let window_accuracy_guarantee = candidate.accuracy.end_to_end_guarantee( - uses_exponential_histogram, - root.guarantee.as_ref(), - )?; - if !required_accuracy.iter().all(|target| { - DefaultAccuracyModel.satisfies(&window_accuracy_guarantee, target) - }) { - return None; - } - self.complete_cost_with_evidence( - root, - deployments, - comparison, - &candidate.node_evidence, - &window_frameworks, - ) - .map(|cost| CompleteSummaryCandidateEstimate { - cost, - physical_plan_id: Some(candidate.physical_plan_id.clone()), - window_frameworks, - window_accuracy_guarantee: Some(window_accuracy_guarantee), - }) - }) - .min_by(|left, right| left.cost.0.total_cmp(&right.cost.0)); - } - if let Some(alternatives) = self.physical_plan_alternatives.get(&key) { - return alternatives - .iter() - .filter_map(|alternative| { - let frameworks = vec![None; deployments.len()]; - self.complete_cost_with_evidence( - root, - deployments, - comparison, - &alternative.node_evidence, - &frameworks, - ) - .map(|cost| CompleteSummaryCandidateEstimate { - cost, - physical_plan_id: Some(alternative.physical_plan_id.clone()), - window_frameworks: frameworks, - window_accuracy_guarantee: None, - }) - }) - .min_by(|left, right| left.cost.0.total_cmp(&right.cost.0)); - } - let frameworks = vec![None; deployments.len()]; - self.complete_cost_with_evidence( - root, - deployments, - comparison, - &self.node_evidence, - &frameworks, - ) - .map(|cost| CompleteSummaryCandidateEstimate { - cost, - physical_plan_id: None, - window_frameworks: frameworks, - window_accuracy_guarantee: None, - }) - } - - fn complete_summary_candidate_estimate_covers_lifecycle_costs(&self) -> bool { - true - } - - fn raw_query_recompute_cost(&self, target: &OperatorNode) -> Option { - let _ = target; - None - } - - fn raw_query_recompute_total_cost( - &self, - target: &OperatorNode, - expected_reads: f64, - ) -> Option { - let target_ptr = target as *const _; - let comparison = self.target_comparisons.get(&target_ptr)?; - let evaluations = comparison.scope.validate().ok()?; - if expected_reads != evaluations as f64 { - return None; - } - self.calibrated( - estimate_physical_dag( - &comparison.raw.physical_dag.nodes, - &comparison.raw.physical_dag.root, - &comparison.scope, - &comparison.raw.physical_dag, - ) - .ok()?, - ) - } -} - -use super::estimator::*; -#[cfg(test)] -mod tests { - use std::rc::Rc; - - use asap_types::ir::{ASAPOp, BinaryOperator, NonASAPOp, Operator, OperatorNode}; - use asap_types::post_asap::{ - EvaluationSchedule, ExactKind, ExactParams, Field, FieldDataType, GroupingStrategy, - OutputRepresentation, Schema, SummaryMaintenanceLifecycle, - SummaryMaintenanceLifecycleGuarantee, SummaryMaintenanceMode, SummaryUpdate, - }; - use asap_types::pre_asap::{ - agg_intent::AggIntent, ArithmeticOpKind, BinaryOpKind, DataType, Reduction, Source, - }; - use asap_types::workload::{ - DataWorkload, Evidence, EvidenceSource, Predictability, Query, QueryLanguage, - QueryRecurrence, QueryRequirements, QueryTimeScope, QueryWorkload, QueryWorkloadEntry, - Rate, RepeatedDemand, RepeatingEntry, RepetitionInterval, TimeSelection, - }; - - use super::*; - use crate::recurrence::Horizon; - use crate::summary_maintenance_lifecycle::{ - assemble_selected_dag_with_summary_maintenance_lifecycles, - global_selection_with_summary_maintenance_lifecycles, plan_summary_maintenance_lifecycles, - SummaryMaintenanceLifecycleCapabilities, WorkloadDemand, - }; - - fn estimate_test( - root: &OperatorNode, - guarantee: &SummaryMaintenanceLifecycleGuarantee, - inputs: SummaryMaintenanceInputs, - cpu: SummaryOperationCpuEvidence, - ) -> Result { - estimate_incremental_summary_maintenance(root, guarantee, inputs, cpu, &streaming_scope()) - } - - fn estimate_join_test( - root: &OperatorNode, - guarantee: &SummaryMaintenanceLifecycleGuarantee, - inputs: SummaryMaintenanceInputs, - cpu: SummaryOperationCpuEvidence, - join: Option, - ) -> Result { - estimate_incremental_summary_maintenance_with_join( - root, - guarantee, - inputs, - cpu, - join, - &streaming_scope(), - ) - } - - fn scope_for( - data: &DataWorkload, - query: &QueryWorkloadEntry, - planning_time_ms: u64, - horizon_ms: u64, - ) -> ComparisonScope { - ComparisonScope::from_workload( - data, - query, - asap_types::workload::TimestampMs(planning_time_ms), - asap_types::workload::DurationMs(horizon_ms), - vec![crate::physical_operator_statistics::ScanSelection { - source: Source::TimeSeries { - metric: "metrics".into(), - }, - source_snapshot_id: "stream-start".into(), - predicates: vec![], - info_matchers: vec![], - }], - ) - .unwrap() - } - - fn physical() -> SummaryPhysicalInputEvidence { - SummaryPhysicalInputEvidence { - initial_input_bytes: 640, - initial_source_scan_bytes: 640, - active_window_count: 2, - bootstrap_window_count: 1, - retained_window_count: 3, - physical_summary_count: 2, - state_bytes_per_summary: 100, - } - } - - fn query() -> QueryWorkloadEntry { - QueryWorkloadEntry { - query: Query("streaming count".into()), - requirements: QueryRequirements::default(), - predictability: Predictability::Unknown, - recurrence: QueryRecurrence::Repeated(RepeatedDemand::FixedInterval( - RepetitionInterval(1_000), - )), - time_selection: TimeSelection { - scope: QueryTimeScope::Unknown, - lookback: None, - as_of: None, - }, - } - } - - fn continuous_guarantee() -> SummaryMaintenanceLifecycleGuarantee { - SummaryMaintenanceLifecycleGuarantee { - summary_maintenance_lifecycle: SummaryMaintenanceLifecycle::ContinuouslyMaintained, - summary_maintenance_mode: SummaryMaintenanceMode::Incremental, - evaluation_schedule: EvaluationSchedule::PerUpdate, - output_representation: OutputRepresentation::SummaryState, - } - } - - /// A fixed snapshot needs cardinality evidence, but no stream-rate evidence. - #[test] - fn at_rest_workload_adapter_builds_once_without_arrivals() { - let mut data = streaming_data_workload(); - data.arrival = DataArrival::AtRest; - data.ingestion_rate = Evidence::default(); - data.input_cardinality = Evidence { - value: Some(10), - source: EvidenceSource::Declared, - ..Default::default() - }; - let scope = scope_for(&data, &query(), 0, 5_000); - let inputs = SummaryMaintenanceInputs::from_workload(physical(), &data, &scope).unwrap(); - assert_eq!(inputs.initial_input_rows, 10); - assert_eq!(inputs.ingestion_rate_per_second, 0.0); - let guarantee = SummaryMaintenanceLifecycleGuarantee { - summary_maintenance_lifecycle: SummaryMaintenanceLifecycle::Shared { - retention: asap_types::workload::DurationMs(5_000), - }, - summary_maintenance_mode: SummaryMaintenanceMode::DirectBuild, - evaluation_schedule: EvaluationSchedule::OnRead, - output_representation: OutputRepresentation::SummaryState, - }; - assert_eq!( - lifecycle_row_counts(inputs, &guarantee, &scope).unwrap(), - (10, 0, 5_000) - ); - } - - /// Arrival semantics cannot be overridden by missing or contradictory rate evidence. - #[test] - fn workload_adapter_checks_arrival_scope_and_rate_evidence() { - let mut data = streaming_data_workload(); - data.input_cardinality = Evidence { - value: Some(10), - source: EvidenceSource::Declared, - ..Default::default() - }; - let mut scope = scope_for(&data, &query(), 0, 5_000); - data.ingestion_rate = Evidence::default(); - assert_eq!( - SummaryMaintenanceInputs::from_workload(physical(), &data, &scope), - Err(AnalyticalCostError::MissingOrStale("ingestion_rate")) - ); - data.arrival = DataArrival::AtRest; - assert_eq!( - SummaryMaintenanceInputs::from_workload(physical(), &data, &scope), - Err(AnalyticalCostError::ComparisonScopeMismatch("data arrival")) - ); - scope.data_arrival = DataArrival::AtRest; - for rate in [1.0, -1.0, f64::INFINITY, f64::NAN] { - data.ingestion_rate = Evidence { - value: Some(Rate(rate)), - source: EvidenceSource::Declared, - ..Default::default() - }; - assert!(SummaryMaintenanceInputs::from_workload(physical(), &data, &scope).is_err()); - } - data.ingestion_rate = Evidence::default(); - data.input_cardinality = Evidence::default(); - assert_eq!( - SummaryMaintenanceInputs::from_workload(physical(), &data, &scope), - Err(AnalyticalCostError::MissingOrStale("input_cardinality")) - ); - } - - /// The real lifecycle planner costs a fixed snapshot with the same node evidence API. - #[test] - fn lifecycle_planner_selects_fully_costed_at_rest_summary() { - let workload = streaming_workload(); - let mut data = streaming_data_workload(); - data.arrival = DataArrival::AtRest; - data.ingestion_rate = Evidence::default(); - data.input_cardinality = Evidence { - value: Some(10), - source: EvidenceSource::Declared, - ..Default::default() - }; - let mut scope = streaming_scope(); - scope.data_arrival = DataArrival::AtRest; - let inputs = SummaryMaintenanceInputs::from_workload(physical(), &data, &scope).unwrap(); - let target = streaming_sum_query(); - let root = summary_with_operations(false, false, false); - let mut provider = streaming_model(); - bind_aggregations(&mut provider, &target, &root, inputs, streaming_cpu()); - let mut model = streaming_model(); - model.node_evidence = provider.node_evidence; - let mut raw = streaming_raw(); - raw.ingestion_rate_per_second = 0.0; - let edge = EdgeStatistics { - rows: 50, - bytes: 3_200, - }; - raw.physical_dag - .evidence - .get_mut("raw-scan") - .unwrap() - .statistics = OperatorStatistics::Scan { - source_read_bytes: 3_200, - edges: UnaryEdgeStatistics { - input: edge, - output: edge, - promql: None, - }, - }; - model - .bind_candidate_comparison(&target, &root, scope.clone(), raw.clone()) - .unwrap(); - let plan = plan_summary_maintenance_lifecycles( - Rc::clone(&root), - WorkloadDemand::new_with_data(&workload, &data, &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert!(plan.summary_total_cost.is_some()); - assert!(!plan.selected_raw_recompute); - assert_eq!( - plan.deployments[0] - .summary_maintenance_lifecycle_guarantee - .as_ref() - .unwrap() - .summary_maintenance_mode, - SummaryMaintenanceMode::DirectBuild - ); - - // Directly supplied raw evidence must not bypass the workload invariant. - raw.ingestion_rate_per_second = 1.0; - assert_eq!( - streaming_model().bind_candidate_comparison(&target, &root, scope, raw), - Err(AnalyticalCostError::ComparisonScopeMismatch( - "at-rest ingestion rate" - )) - ); - // Nor may a provider hide arrivals on a summary edge. - for aggregation in model.node_evidence.aggregations.values_mut() { - aggregation.inputs.ingestion_rate_per_second = 1.0; - } - let invalid = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &data, &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(invalid.summary_total_cost, None); - } - - #[test] - fn workload_adapter_derives_updates_and_reads_over_one_horizon() { - let data = DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - ingestion_rate: Evidence { - value: Some(Rate(2.0)), - source: EvidenceSource::Observed, - observed_at_ms: Some(100), - valid_for_ms: Some(10_000), - }, - input_cardinality: Evidence { - value: Some(10), - source: EvidenceSource::Observed, - observed_at_ms: Some(100), - valid_for_ms: Some(10_000), - }, - ..DataWorkload::default() - }; - - let scope = scope_for(&data, &query(), 100, 5_000); - let inputs = SummaryMaintenanceInputs::from_workload(physical(), &data, &scope).unwrap(); - assert_eq!(inputs.initial_input_rows, 10); - assert_eq!( - lifecycle_row_counts(inputs, &continuous_guarantee(), &scope) - .unwrap() - .1, - 10 - ); - assert_eq!(scope.validate().unwrap(), 5); - } - - #[test] - fn pure_streaming_can_bootstrap_from_an_empty_state() { - let data = DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - ingestion_rate: Evidence { - value: Some(Rate(2.0)), - source: EvidenceSource::Declared, - observed_at_ms: None, - valid_for_ms: None, - }, - input_cardinality: Evidence { - value: Some(0), - source: EvidenceSource::Declared, - observed_at_ms: None, - valid_for_ms: None, - }, - ..DataWorkload::default() - }; - let mut empty = physical(); - empty.initial_input_bytes = 0; - empty.initial_source_scan_bytes = 0; - let scope = scope_for(&data, &query(), 0, 5_000); - let inputs = SummaryMaintenanceInputs::from_workload(empty, &data, &scope).unwrap(); - let estimate = estimate_test( - &summary_with_operations(false, false, false), - &continuous_guarantee(), - inputs, - SummaryOperationCpuEvidence { - insert_cpu_ops: Some(2.0), - evaluation_cpu_ops: Some(1.0), - ..SummaryOperationCpuEvidence::default() - }, - ) - .unwrap(); - // 10 arrivals * 2 active windows * 2 insert ops + 5 reads * 2 summaries. - assert_eq!(estimate.cpu_ops(), 50.0); - assert_eq!(estimate.scan_bytes(), 0); - } - - #[test] - fn bootstrap_rows_and_bytes_must_be_present_together() { - let mut inputs = SummaryMaintenanceInputs { - initial_input_rows: 0, - initial_input_bytes: 8, - initial_source_scan_bytes: 0, - ingestion_rate_per_second: 1.0, - active_window_count: 1, - bootstrap_window_count: 1, - retained_window_count: 1, - physical_summary_count: 1, - state_bytes_per_summary: 8, - }; - assert_eq!( - inputs.validate(), - Err(AnalyticalCostError::InconsistentBootstrapEvidence) - ); - inputs.initial_input_rows = 1; - inputs.initial_input_bytes = 0; - assert_eq!( - inputs.validate(), - Err(AnalyticalCostError::InconsistentBootstrapEvidence) - ); - } - - #[test] - fn no_completed_windows_is_a_valid_streaming_deployment() { - let mut inputs = streaming_inputs(); - inputs.retained_window_count = 0; - assert!(inputs.validate().is_ok()); - } - - #[test] - fn bootstrap_rows_are_routed_to_declared_window_assignments() { - let mut inputs = streaming_inputs(); - inputs.ingestion_rate_per_second = 0.0; - inputs.bootstrap_window_count = 3; - let estimate = estimate_test( - &summary_with_operations(false, false, false), - &continuous_guarantee(), - inputs, - SummaryOperationCpuEvidence { - insert_cpu_ops: Some(2.0), - evaluation_cpu_ops: Some(1.0), - ..SummaryOperationCpuEvidence::default() - }, - ) - .unwrap(); - // 10 bootstrap rows * 3 windows * 2 insert ops + 5 reads * 2 summaries. - assert_eq!(estimate.cpu_ops(), 70.0); - } - - #[test] - fn lifecycle_output_must_remain_summary_state() { - let mut guarantee = continuous_guarantee(); - guarantee.output_representation = OutputRepresentation::FinalizedValue; - assert_eq!( - estimate_test( - &summary_with_operations(false, false, false), - &guarantee, - streaming_inputs(), - streaming_cpu(), - ), - Err(AnalyticalCostError::IncompatibleLifecycleGuarantee) - ); - } - - #[test] - fn existing_lifecycle_planner_selects_a_fully_costed_streaming_alternative() { - let inputs = SummaryMaintenanceInputs { - initial_input_rows: 10, - initial_input_bytes: 640, - initial_source_scan_bytes: 640, - ingestion_rate_per_second: 2.0, - active_window_count: 2, - bootstrap_window_count: 1, - retained_window_count: 3, - physical_summary_count: 2, - state_bytes_per_summary: 100, - }; - let mut model = streaming_model(); - let workload = QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: None, - repeating_queries: Some(vec![RepeatingEntry { - query: Query("streaming count".into()), - demand: RepeatedDemand::FixedInterval(RepetitionInterval(1_000)), - requirements: QueryRequirements::default(), - predictability: Predictability::Predictable { known_at: None }, - time_selection: TimeSelection::default(), - }]), - }; - let root = summary_with_operations(false, false, false); - let target = streaming_sum_query(); - bind_aggregations(&mut model, &target, &root, inputs, streaming_cpu()); - let plan = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - let selected = plan.deployments[0] - .summary_maintenance_lifecycle_guarantee - .as_ref() - .unwrap(); - assert_eq!( - selected.summary_maintenance_mode, - SummaryMaintenanceMode::Incremental - ); - assert!(matches!( - selected.summary_maintenance_lifecycle, - SummaryMaintenanceLifecycle::Shared { .. } - | SummaryMaintenanceLifecycle::ContinuouslyMaintained - )); - assert!(plan.summary_total_cost.is_some()); - assert_eq!(model.raw_query_recompute_cost(&target), None); - } - - #[test] - fn complete_streaming_cost_can_select_an_ephemeral_direct_build() { - let workload = streaming_workload(); - let target = streaming_sum_query(); - let root = summary_with_operations(false, false, false); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - - let plan = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities { - supports_ephemeral: true, - supports_prepared: false, - supports_shared: false, - supports_continuously_maintained: false, - }, - &model, - ) - .unwrap(); - - assert!(plan.summary_total_cost.is_some()); - assert!(matches!( - plan.deployments[0] - .summary_maintenance_lifecycle_guarantee - .as_ref() - .map(|guarantee| &guarantee.summary_maintenance_lifecycle), - Some(SummaryMaintenanceLifecycle::Ephemeral) - )); - } - - #[test] - fn complete_streaming_cost_ranks_provider_owned_physical_plans() { - let workload = streaming_workload(); - let target = streaming_sum_query(); - let root = summary_with_operations(false, false, false); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - - let mut high_retention = model.node_evidence.clone(); - for aggregate in high_retention.aggregations.values_mut() { - aggregate.inputs.retained_window_count = 20; - } - let mut low_retention = model.node_evidence.clone(); - for aggregate in low_retention.aggregations.values_mut() { - aggregate.inputs.retained_window_count = 2; - } - for alternative in [ - SummaryPhysicalPlanAlternative { - physical_plan_id: "high-retention-layout".into(), - node_evidence: high_retention, - }, - SummaryPhysicalPlanAlternative { - physical_plan_id: "low-retention-layout".into(), - node_evidence: low_retention, - }, - ] { - model - .bind_physical_plan_alternative(&target, &root, alternative) - .unwrap(); - } - - let plan = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities { - supports_ephemeral: false, - supports_prepared: false, - supports_shared: false, - supports_continuously_maintained: true, - }, - &model, - ) - .unwrap(); - - assert_eq!( - plan.selected_window_implementation_id.as_deref(), - Some("low-retention-layout") - ); - assert_eq!( - crate::summary_maintenance_dag_export::export_summary_maintenance_plan(&plan) - .selected_window_implementation_id - .as_deref(), - Some("low-retention-layout") - ); - } - - #[test] - fn global_selection_compares_streaming_summary_and_raw_over_one_horizon() { - let target = streaming_sum_query(); - let space = crate::replacement::search_workload(vec![("q", Rc::clone(&target))]).unwrap(); - let workload = streaming_workload(); - let mut model = streaming_model(); - for group in space.target_subdag_candidates() { - for candidate in &group.candidates { - if let Replacement::SubDAG(root) = &candidate.replacement { - if !root.contains_asap() { - continue; - } - bind_aggregations( - &mut model, - &group.target, - root, - streaming_inputs(), - streaming_cpu(), - ); - } - } - } - let selection = global_selection_with_summary_maintenance_lifecycles( - &space, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - let plan = assemble_selected_dag_with_summary_maintenance_lifecycles( - &selection, - &space.roots[0].1, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap() - .unwrap(); - assert!(!plan.selected_raw_recompute); - assert_eq!(plan.raw_recompute_total_cost, Some(Cost(5_264.0))); - - let mut missing_baseline = model.clone(); - missing_baseline - .target_comparisons - .get_mut(&Rc::as_ptr(&space.roots[0].1)) - .unwrap() - .raw - .physical_dag - .evidence - .clear(); - let unavailable = global_selection_with_summary_maintenance_lifecycles( - &space, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &missing_baseline, - ) - .unwrap(); - assert!(unavailable - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .is_none()); - - let mut raw_cheaper = model; - for evidence in raw_cheaper.node_evidence.aggregations.values_mut() { - evidence.insert_cpu_ops = 10_000.0; - } - let cheap_selection = global_selection_with_summary_maintenance_lifecycles( - &space, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &raw_cheaper, - ) - .unwrap(); - assert!(cheap_selection - .for_target(&space.roots[0].1) - .unwrap() - .chosen - .is_none()); - let cheap_plan = assemble_selected_dag_with_summary_maintenance_lifecycles( - &cheap_selection, - &space.roots[0].1, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &raw_cheaper, - ) - .unwrap() - .unwrap(); - assert!(cheap_plan.selected_raw_recompute); - assert_eq!(cheap_plan.raw_recompute_total_cost, Some(Cost(5_264.0))); - } - - #[test] - fn raw_evolution_is_bound_to_the_requested_target() { - let target_a = streaming_sum_query(); - let target_b = streaming_sum_query(); - let root_a = summary_with_operations(false, false, false); - let root_b = summary_with_operations(false, false, false); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target_a, - &root_a, - streaming_inputs(), - streaming_cpu(), - ); - let mut faster = streaming_inputs(); - // Candidate-local intermediate cardinality is not the raw target's - // planning-time cardinality and must not constrain its baseline. - faster.initial_input_rows = 7; - faster.initial_input_bytes = 448; - faster.initial_source_scan_bytes = 448; - faster.ingestion_rate_per_second = 4.0; - bind_aggregations(&mut model, &target_b, &root_b, faster, streaming_cpu()); - model - .target_comparisons - .get_mut(&Rc::as_ptr(&target_b)) - .unwrap() - .raw = { - let mut raw = streaming_raw(); - raw.ingestion_rate_per_second = 4.0; - let statistics = &mut raw - .physical_dag - .evidence - .get_mut("raw-scan") - .unwrap() - .statistics; - let OperatorStatistics::Scan { - edges, - source_read_bytes, - } = statistics - else { - unreachable!() - }; - *source_read_bytes = 7_040; - edges.input = EdgeStatistics { - rows: 110, - bytes: 7_040, - }; - edges.output = edges.input; - raw - }; - - let a = model.raw_query_recompute_total_cost(&target_a, 5.0); - let b = model.raw_query_recompute_total_cost(&target_b, 5.0); - assert_eq!(a, Some(Cost(5_264.0))); - assert!(b.unwrap().0 > a.unwrap().0); - assert_eq!(model.raw_query_recompute_total_cost(&target_a, 5.0), a); - } - - #[test] - fn raw_validation_uses_reachable_nodes_and_allows_repeated_source_scans() { - let target = streaming_sum_query(); - let root = summary_with_operations(false, false, false); - let scope = streaming_scope(); - let mut raw = streaming_raw(); - let first_scan = raw.physical_dag.nodes[0].clone(); - let mut second_scan = first_scan.clone(); - second_scan.id = "raw-scan-2".into(); - let mut unreachable = first_scan.clone(); - unreachable.id = "unreachable-per-evaluation".into(); - unreachable.execution = ExecutionMultiplicity::PerEvaluation; - raw.physical_dag.nodes = vec![ - first_scan, - second_scan, - unreachable, - PhysicalDAGNode { - id: "raw-concat".into(), - operator: PhysicalOperator::Concat, - children: vec!["raw-scan".into(), "raw-scan-2".into()], - scan_selection: None, - output_buffer_bytes: 0, - retained_bytes: 0, - execution: ExecutionMultiplicity::Once, - }, - ]; - raw.physical_dag.root = "raw-concat".into(); - let scan_evidence = raw.physical_dag.evidence["raw-scan"].clone(); - let mut second_scan_evidence = scan_evidence.clone(); - second_scan_evidence.physical_id = "raw-scan-2".into(); - raw.physical_dag - .evidence - .insert("raw-scan-2".into(), second_scan_evidence); - let mut unreachable_evidence = scan_evidence; - unreachable_evidence.physical_id = "unreachable-per-evaluation".into(); - raw.physical_dag - .evidence - .insert("unreachable-per-evaluation".into(), unreachable_evidence); - raw.physical_dag.evidence.insert( - "raw-concat".into(), - PhysicalNodeEvidence { - physical_id: "raw-concat".into(), - statistics: OperatorStatistics::Concat { - inputs: vec![ - EdgeStatistics { - rows: 80, - bytes: 5_120, - }, - EdgeStatistics { - rows: 80, - bytes: 5_120, - }, - ], - output: EdgeStatistics { - rows: 160, - bytes: 10_240, - }, - promql: None, - }, - output_buffer_bytes: 0, - }, - ); - let mut model = streaming_model(); - assert!(model - .bind_candidate_comparison(&target, &root, scope, raw) - .is_ok()); - } - - #[test] - fn comparison_binding_is_transactional_and_shared_nodes_allow_two_targets() { - let target_a = streaming_sum_query(); - let target_b = streaming_sum_query(); - let root = summary_with_operations(false, false, false); - let mut model = streaming_model(); - let mut wrong_scope = streaming_scope(); - wrong_scope.sources[0].source = Source::TimeSeries { - metric: "wrong".into(), - }; - assert_eq!( - model.bind_candidate_comparison(&target_a, &root, wrong_scope, streaming_raw(),), - Err(AnalyticalCostError::ComparisonScopeMismatch( - "raw target source lineage" - )) - ); - assert!(model.target_comparisons.is_empty()); - assert!(model.candidate_comparisons.is_empty()); - - model - .bind_candidate_comparison(&target_a, &root, streaming_scope(), streaming_raw()) - .unwrap(); - model - .bind_candidate_comparison(&target_b, &root, streaming_scope(), streaming_raw()) - .unwrap(); - assert_eq!(model.candidate_comparisons.len(), 2); - } - - #[test] - fn target_scope_rejects_extra_sources_and_tracks_info_matchers() { - let target = streaming_sum_query(); - let mut extra = streaming_scope(); - extra - .sources - .push(crate::physical_operator_statistics::ScanSelection { - source: Source::TimeSeries { - metric: "unused".into(), - }, - source_snapshot_id: "stream-start".into(), - predicates: vec![], - info_matchers: vec![], - }); - assert_eq!( - validate_query_scope(&target, &extra), - Err(AnalyticalCostError::ComparisonScopeMismatch( - "raw target source lineage" - )) - ); - - let selector = vec![InfoMatcher { - label: "job".into(), - op: CompareOpKind::Eq, - value: "api".into(), - }]; - let info_target = OperatorNode::new_shared(asap_types::ir::Operator::NonASAP( - NonASAPOp::PromqlInfoEnrich { - selector: selector.clone(), - child: target, - }, - )) - .unwrap(); - let mut info_scope = streaming_scope(); - info_scope - .sources - .push(crate::physical_operator_statistics::ScanSelection { - source: Source::TimeSeries { - metric: "target_info".into(), - }, - source_snapshot_id: "info-start".into(), - predicates: vec![], - info_matchers: selector, - }); - validate_query_scope(&info_target, &info_scope).unwrap(); - info_scope.sources[1].info_matchers[0].value = "worker".into(); - assert_eq!( - validate_query_scope(&info_target, &info_scope), - Err(AnalyticalCostError::ComparisonScopeMismatch( - "raw target source lineage" - )) - ); - } - - #[test] - fn summary_delete_dag_fails_closed_before_costing() { - // SummaryDelete is reserved: planning rejects the DAG even with full - // delete evidence, instead of costing (or owner-checking) the delete. - let target = streaming_sum_query(); - let root = summary_with_operations(false, false, true); - let mut cpu = streaming_cpu(); - cpu.delete_cpu_ops = Some(1.0); - cpu.delete_events_per_second = Some(1.0); - cpu.delete_routing_fanout = Some(1); - let mut model = streaming_model(); - model.capabilities.delete = true; - bind_aggregations(&mut model, &target, &root, streaming_inputs(), cpu); - - // Under a evaluation, the reserved delete surfaces as an illegal child. - assert!(matches!( - streaming_planning_error(Rc::clone(&root), &model), - asap_types::post_asap::ExecutionDataStateError::IllegalChildDataState { - edge: "FinalizeExactAccumulator.child", - child: asap_types::post_asap::ExecutionDataState::QUERY_ROWS, - } - )); - // As the root, it is reported as the unimplemented operator itself. - assert!(matches!( - streaming_planning_error(evaluation_state(&root), &model), - asap_types::post_asap::ExecutionDataStateError::UnimplementedOperator { - operator: "SummaryDelete" - } - )); - } - - #[test] - fn summary_edge_and_io_evidence_fail_closed() { - // Over two independent summaries combined by a BinaryOp: a parent - // input edge that disagrees with its child's output, or missing I/O - // evidence on the root, leaves the whole-DAG cost unset. - let workload = streaming_workload(); - let target = streaming_sum_query(); - let root = add_independent_summary_results(); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - model.node_evidence.insert_operation( - &root, - SummaryOperatorEvidence::Binary(test_resource( - "binary-edge", - vec![test_edge(), EdgeStatistics { rows: 2, bytes: 16 }], - 1.0, - 1, - 0, - )), - ); - let bad_edge = plan_summary_maintenance_lifecycles( - Rc::clone(&root), - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(bad_edge.summary_total_cost, None); - - let root_evidence = model - .node_evidence - .operations - .get_mut(&Rc::as_ptr(&root)) - .unwrap() - .resource_mut(); - root_evidence.inputs = vec![test_edge(), test_edge()]; - root_evidence.io_bytes_per_execution = None; - let missing_io = plan_summary_maintenance_lifecycles( - Rc::clone(&root), - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(missing_io.summary_total_cost, None); - - // Control: the same evidence with I/O restored is costable. - model - .node_evidence - .operations - .get_mut(&Rc::as_ptr(&root)) - .unwrap() - .resource_mut() - .io_bytes_per_execution = Some(0); - let complete = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert!(complete.summary_total_cost.is_some()); - } - - #[test] - fn summary_edges_io_and_physical_identity_fail_closed() { - // Evidence bound to a structurally equal clone of the BinaryOp does not - // count for the real node; a bad input edge or missing I/O on the real - // node still fails closed. - let workload = streaming_workload(); - let target = streaming_sum_query(); - let root = add_independent_summary_results(); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - model.node_evidence.insert_operation( - &Rc::new((*root).clone()), - SummaryOperatorEvidence::Binary(test_resource( - "unused", - vec![test_edge(), test_edge()], - 1.0, - 1, - 0, - )), - ); - // Bind the actual BinaryOp, then make one parent input disagree with - // its child's output. - model.node_evidence.insert_operation( - &root, - SummaryOperatorEvidence::Binary(test_resource( - "binary-edge", - vec![test_edge(), EdgeStatistics { rows: 2, bytes: 16 }], - 1.0, - 1, - 0, - )), - ); - let bad_edge = plan_summary_maintenance_lifecycles( - Rc::clone(&root), - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(bad_edge.summary_total_cost, None); - - let root_evidence = model - .node_evidence - .operations - .get_mut(&Rc::as_ptr(&root)) - .unwrap() - .resource_mut(); - root_evidence.inputs = vec![test_edge(), test_edge()]; - root_evidence.io_bytes_per_execution = None; - let missing_io = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(missing_io.summary_total_cost, None); - } - - #[test] - fn liveness_does_not_add_disjoint_execution_workspaces() { - let target = streaming_sum_query(); - let root = summary_join(); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - let join = evidence_nodes(&root).1[0]; - model.node_evidence.joins.insert( - join as *const _, - SummaryJoinEvidence { - physical_id: "huge-join".into(), - inputs: vec![test_edge(), test_edge()], - output: test_edge(), - cpu_ops_per_execution: 1.0, - working_memory_bytes: u64::MAX, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - }, - ); - model - .node_evidence - .operations - .get_mut(&Rc::as_ptr(&root)) - .unwrap() - .resource_mut() - .working_memory_bytes = u64::MAX; - assert_eq!( - estimate_transient_liveness(&root, &model.node_evidence), - Ok(u64::MAX) - ); - } - - #[test] - fn conflicting_evidence_cannot_alias_one_provider_physical_identity() { - // Two independent summary states (combined by a BinaryOp) that claim - // one physical id but carry different evidence leave the cost unset. - let workload = streaming_workload(); - let target = streaming_sum_query(); - let root = add_independent_summary_results(); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - let aggregations = evidence_nodes(&root).0; - assert_eq!(aggregations.len(), 2); - let first = aggregations[0] as *const _; - let second = aggregations[1] as *const _; - model - .node_evidence - .aggregations - .get_mut(&first) - .unwrap() - .physical_id = "aliased-state".into(); - let second_evidence = model.node_evidence.aggregations.get_mut(&second).unwrap(); - second_evidence.physical_id = "aliased-state".into(); - second_evidence.insert_cpu_ops = 99.0; - - let plan = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(plan.summary_total_cost, None); - } - - #[test] - fn summary_join_root_fails_closed_even_with_join_evidence() { - // SummaryJoin is reserved: planning rejects the DAG whether or not - // join evidence is bound, so no partial or join cost is produced. - let root = summary_join(); - let target = streaming_sum_query(); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - assert!(matches!( - streaming_planning_error(Rc::clone(&root), &model), - asap_types::post_asap::ExecutionDataStateError::UnimplementedOperator { - operator: "SummaryJoin" - } - )); - - let join_node = evidence_nodes(&root).1[0]; - model.node_evidence.joins.insert( - join_node as *const _, - SummaryJoinEvidence { - physical_id: "costed-join".into(), - inputs: vec![test_edge(), test_edge()], - output: test_edge(), - cpu_ops_per_execution: 6.0, - working_memory_bytes: 64, - output_buffer_bytes: 64, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - }, - ); - assert!(matches!( - streaming_planning_error(root, &model), - asap_types::post_asap::ExecutionDataStateError::UnimplementedOperator { - operator: "SummaryJoin" - } - )); - } - - #[test] - fn whole_dag_cost_requires_and_uses_each_rc_bound_state_evidence() { - // Over two independent summaries combined by a BinaryOp: the cost needs - // evidence for each Rc-bound state, charges peak transient memory, and - // de-duplicates bootstrap scans only on a shared provider read id. - let workload = streaming_workload(); - let root = add_independent_summary_results(); - let (left, right) = binary_operands(&root); - let target = streaming_sum_query(); - let aggregations = [evaluation_state(&left), evaluation_state(&right)]; - let mut model = streaming_model(); - bind_comparison(&mut model, &target, &root); - model.node_evidence.insert_aggregation( - &aggregations[0], - SummaryAggregateEvidence { - physical_id: "left-state".into(), - input: test_edge(), - output: test_edge(), - scan_selection_index: Some(0), - bootstrap_read_identity: "left-bootstrap".into(), - inputs: streaming_inputs(), - insert_cpu_ops: streaming_cpu().insert_cpu_ops.unwrap(), - }, - ); - let incomplete = plan_summary_maintenance_lifecycles( - Rc::clone(&root), - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(incomplete.summary_total_cost, None); - - let mut second_inputs = streaming_inputs(); - second_inputs.state_bytes_per_summary = 250; - let mut second_cpu = streaming_cpu(); - second_cpu.insert_cpu_ops = Some(5.0); - model.node_evidence.insert_aggregation( - &aggregations[1], - SummaryAggregateEvidence { - physical_id: "right-state".into(), - input: test_edge(), - output: test_edge(), - scan_selection_index: Some(0), - bootstrap_read_identity: "right-bootstrap".into(), - inputs: second_inputs, - insert_cpu_ops: second_cpu.insert_cpu_ops.unwrap(), - }, - ); - // The left evaluation plays the old join's role: a 64-byte workspace and a - // 64-byte output that stays live until the root BinaryOp consumes it. - model.node_evidence.insert_operation( - &left, - SummaryOperatorEvidence::ValueOperation(test_resource( - "left-evaluation", - vec![test_edge()], - 6.0, - 64, - 64, - )), - ); - model.node_evidence.insert_operation( - &right, - SummaryOperatorEvidence::ValueOperation(test_resource( - "right-evaluation", - vec![test_edge()], - 3.0, - 0, - 0, - )), - ); - model.node_evidence.insert_operation( - &root, - SummaryOperatorEvidence::Binary(test_resource( - "root-binary", - vec![test_edge(), test_edge()], - 3.0, - 0, - 0, - )), - ); - let complete = plan_summary_maintenance_lifecycles( - Rc::clone(&root), - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert!(complete.summary_total_cost.is_some()); - - model - .node_evidence - .operations - .get_mut(&Rc::as_ptr(&root)) - .unwrap() - .resource_mut() - .working_memory_bytes = 128; - let larger_workspace = plan_summary_maintenance_lifecycles( - Rc::clone(&root), - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - // The left evaluation's 64-byte output remains live while the binary's - // workspace is active (64 + 128 = 192); the evaluation's own workspace is - // released first, so the old peak was 64 + 64 = 128. - assert_eq!( - larger_workspace.summary_total_cost.unwrap().0 - complete.summary_total_cost.unwrap().0, - 64.0 - ); - - // Equal ScanSelection does not imply that two independent state - // builds share one physical read. Only a provider-owned read identity - // permits scan de-duplication. - let mut shared_read = model; - for aggregate in shared_read.node_evidence.aggregations.values_mut() { - aggregate.bootstrap_read_identity = "one-physical-read".into(); - } - let shared = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &shared_read, - ) - .unwrap(); - assert_eq!( - larger_workspace.summary_total_cost.unwrap().0 - shared.summary_total_cost.unwrap().0, - 640.0 - ); - } - - #[test] - fn planner_selects_an_abstract_window_framework_from_downstream_evidence() { - let mut workload = streaming_workload(); - workload.repeating_queries.as_mut().unwrap()[0] - .requirements - .accuracy = - asap_types::workload::AccuracyRequirement::Explicit(AccuracyTarget::EpsilonDelta { - epsilon: 0.10, - delta: 0.01, - }); - let target = streaming_sum_query(); - let root = summary_with_operations(false, false, false); - let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { - child: summary_input, - }) = &root.operator - else { - unreachable!(); - }; - let windowed_summary = Rc::clone(summary_input); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - - let mut tumbling = model.node_evidence.clone(); - for aggregate in tumbling.aggregations.values_mut() { - aggregate.inputs.active_window_count = 1; - aggregate.inputs.retained_window_count = 20; - } - let mut sliding = model.node_evidence.clone(); - for aggregate in sliding.aggregations.values_mut() { - aggregate.inputs.active_window_count = 10; - aggregate.inputs.retained_window_count = 10; - } - let mut exponential_histogram = model.node_evidence.clone(); - for aggregate in exponential_histogram.aggregations.values_mut() { - aggregate.inputs.active_window_count = 2; - aggregate.inputs.retained_window_count = 2; - } - for candidate in [ - SummaryWindowFrameworkCandidate { - physical_plan_id: "tumbling-v1".into(), - assignments: vec![SummaryWindowFrameworkAssignment { - summary: Rc::clone(&windowed_summary), - framework: Some(SummaryWindowFramework::Tumbling), - }], - accuracy: SummaryWindowAccuracyEvidence::Exact, - node_evidence: tumbling, - }, - SummaryWindowFrameworkCandidate { - physical_plan_id: "sliding-v1".into(), - assignments: vec![SummaryWindowFrameworkAssignment { - summary: Rc::clone(&windowed_summary), - framework: Some(SummaryWindowFramework::Sliding), - }], - accuracy: SummaryWindowAccuracyEvidence::Exact, - node_evidence: sliding, - }, - SummaryWindowFrameworkCandidate { - physical_plan_id: "eh-v1".into(), - assignments: vec![SummaryWindowFrameworkAssignment { - summary: Rc::clone(&windowed_summary), - framework: Some(SummaryWindowFramework::ExponentialHistogram), - }], - accuracy: SummaryWindowAccuracyEvidence::ExponentialHistogram( - ExponentialHistogramAccuracyEvidence::UniversalGsum { - epsilon: 0.05, - failure_probability: 0.01, - range: ExponentialHistogramQueryRange::MostRecentWindow, - }, - ), - node_evidence: exponential_histogram, - }, - ] { - model - .bind_window_framework_candidate(&target, &root, candidate) - .unwrap(); - } - // Framework candidates are authoritative. Selection must not depend - // on duplicating one arbitrary implementation into the legacy global - // evidence map. - model.node_evidence = SummaryNodeEvidence::default(); - - let plan = plan_summary_maintenance_lifecycles( - Rc::clone(&root), - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities { - supports_ephemeral: false, - supports_prepared: false, - supports_shared: false, - supports_continuously_maintained: true, - }, - &model, - ) - .unwrap(); - - assert_eq!( - plan.deployments[0].selected_window_framework, - Some(SummaryWindowFramework::ExponentialHistogram) - ); - assert_eq!( - plan.selected_window_implementation_id.as_deref(), - Some("eh-v1") - ); - let guarantee = plan.window_accuracy_guarantee.as_ref().unwrap(); - assert_eq!(guarantee.metric, ErrorMetric::RelativeValue); - assert!((guarantee.bound.evaluate().unwrap() - 0.05).abs() < f64::EPSILON); - let exported = - crate::summary_maintenance_dag_export::export_summary_maintenance_plan(&plan); - assert_eq!( - exported.deployments[0].selected_window_framework, - Some(SummaryWindowFramework::ExponentialHistogram) - ); - assert_eq!( - exported.selected_window_implementation_id.as_deref(), - Some("eh-v1") - ); - assert_eq!( - exported.window_accuracy_guarantee.unwrap().metric, - ErrorMetric::RelativeValue - ); - - workload.repeating_queries.as_mut().unwrap()[0] - .requirements - .accuracy = - asap_types::workload::AccuracyRequirement::Explicit(AccuracyTarget::Epsilon(0.01)); - let stricter = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities { - supports_ephemeral: false, - supports_prepared: false, - supports_shared: false, - supports_continuously_maintained: true, - }, - &model, - ) - .unwrap(); - assert_ne!( - stricter.deployments[0].selected_window_framework, - Some(SummaryWindowFramework::ExponentialHistogram) - ); - assert!(stricter.window_accuracy_guarantee.unwrap().is_exact()); - } - - #[test] - fn window_framework_candidates_require_unique_nonempty_planner_primitives() { - let target = streaming_sum_query(); - let root = summary_with_operations(false, false, false); - let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { - child: summary_input, - }) = &root.operator - else { - unreachable!(); - }; - let windowed_summary = Rc::clone(summary_input); - let mut model = streaming_model(); - bind_comparison(&mut model, &target, &root); - - let empty = model.bind_window_framework_candidate( - &target, - &root, - SummaryWindowFrameworkCandidate { - physical_plan_id: "empty-assignments".into(), - assignments: vec![], - accuracy: SummaryWindowAccuracyEvidence::Exact, - node_evidence: model.node_evidence.clone(), - }, - ); - assert!(matches!(empty, Err(AnalyticalCostError::MissingOrZero(_)))); - - let candidate = SummaryWindowFrameworkCandidate { - physical_plan_id: "tumbling-v1".into(), - assignments: vec![SummaryWindowFrameworkAssignment { - summary: windowed_summary, - framework: Some(SummaryWindowFramework::Tumbling), - }], - accuracy: SummaryWindowAccuracyEvidence::Exact, - node_evidence: model.node_evidence.clone(), - }; - model - .bind_window_framework_candidate(&target, &root, candidate.clone()) - .unwrap(); - assert!(matches!( - model.bind_window_framework_candidate(&target, &root, candidate), - Err(AnalyticalCostError::ComparisonScopeMismatch(_)) - )); - } - - #[test] - fn one_physical_identity_cannot_alias_different_window_frameworks() { - // Two independent summaries (combined by a BinaryOp) sharing one - // physical state id but assigned different window frameworks leave - // the cost unset. - let workload = streaming_workload(); - let target = streaming_sum_query(); - let root = add_independent_summary_results(); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - let (left, right) = binary_operands(&root); - let aggregation_nodes = [evaluation_state(&left), evaluation_state(&right)]; - - let mut shared_aggregation = - model.node_evidence.aggregations[&Rc::as_ptr(&aggregation_nodes[0])].clone(); - shared_aggregation.physical_id = "shared-window-state".into(); - for aggregate in &aggregation_nodes { - model - .node_evidence - .insert_aggregation(aggregate, shared_aggregation.clone()); - } - - let retained_children: Vec<_> = aggregation_nodes - .iter() - .map(|aggregate| match &aggregate.operator { - Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) => Rc::clone(child), - _ => unreachable!(), - }) - .collect(); - let mut shared_retained = - model.node_evidence.retained_queries[&Rc::as_ptr(&retained_children[0])].clone(); - shared_retained.physical_id = "shared-retained-input".into(); - for child in &retained_children { - model - .node_evidence - .retained_queries - .insert(Rc::as_ptr(child), shared_retained.clone()); - } - - let candidate = SummaryWindowFrameworkCandidate { - physical_plan_id: "mixed-framework-binary".into(), - assignments: vec![ - SummaryWindowFrameworkAssignment { - summary: Rc::clone(&aggregation_nodes[0]), - framework: Some(SummaryWindowFramework::Tumbling), - }, - SummaryWindowFrameworkAssignment { - summary: Rc::clone(&aggregation_nodes[1]), - framework: Some(SummaryWindowFramework::Sliding), - }, - ], - accuracy: SummaryWindowAccuracyEvidence::Exact, - node_evidence: model.node_evidence.clone(), - }; - model - .bind_window_framework_candidate(&target, &root, candidate) - .unwrap(); - - let plan = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(plan.summary_total_cost, None); - } - - #[test] - fn promsketch_eh_accuracy_composes_registered_full_and_subwindow_bounds() { - let full = SummaryWindowAccuracyEvidence::ExponentialHistogram( - ExponentialHistogramAccuracyEvidence::KllRank { - eh_epsilon: 0.01, - kll_epsilon: 0.02, - failure_probability: 0.01, - range: ExponentialHistogramQueryRange::MostRecentWindow, - }, - ) - .guarantee(true) - .unwrap(); - assert_eq!(full.metric, ErrorMetric::Rank); - assert!((full.bound.evaluate().unwrap() - 0.04).abs() < f64::EPSILON); - - let subwindow = SummaryWindowAccuracyEvidence::ExponentialHistogram( - ExponentialHistogramAccuracyEvidence::KllRank { - eh_epsilon: 0.01, - kll_epsilon: 0.02, - failure_probability: 0.01, - range: ExponentialHistogramQueryRange::SubWindow { - suffix_rows: 100, - query_rows: 25, - }, - }, - ) - .guarantee(true) - .unwrap(); - assert!((subwindow.bound.evaluate().unwrap() - 0.10).abs() < f64::EPSILON); - - let gsum = SummaryWindowAccuracyEvidence::ExponentialHistogram( - ExponentialHistogramAccuracyEvidence::UniversalGsum { - epsilon: 0.05, - failure_probability: 0.30, - range: ExponentialHistogramQueryRange::SubWindow { - suffix_rows: 100, - query_rows: 25, - }, - }, - ) - .guarantee(true) - .unwrap(); - assert_eq!(gsum.metric, ErrorMetric::RelativeValue); - assert!((gsum.bound.evaluate().unwrap() - 0.20).abs() < f64::EPSILON); - } - - #[test] - fn eh_accuracy_rejects_negative_components_and_mismatched_summary_guarantees() { - let evidence = SummaryWindowAccuracyEvidence::ExponentialHistogram( - ExponentialHistogramAccuracyEvidence::KllRank { - eh_epsilon: -0.01, - kll_epsilon: 0.03, - failure_probability: 0.01, - range: ExponentialHistogramQueryRange::MostRecentWindow, - }, - ); - assert!(evidence.guarantee(true).is_none()); - - let evidence = SummaryWindowAccuracyEvidence::ExponentialHistogram( - ExponentialHistogramAccuracyEvidence::KllRank { - eh_epsilon: 0.01, - kll_epsilon: 0.02, - failure_probability: 0.01, - range: ExponentialHistogramQueryRange::MostRecentWindow, - }, - ); - let actual_summary = ResultGuarantee { - metric: ErrorMetric::Rank, - bound: BoundExpr::Constant { value: 0.03 }, - failure_probability: ProbabilityExpr::Constant { value: 0.01 }, - provenance: vec![], - }; - assert!(evidence - .end_to_end_guarantee(true, Some(&actual_summary)) - .is_none()); - } - - #[test] - fn exponential_histogram_without_registered_accuracy_composition_fails_closed() { - let mut workload = streaming_workload(); - workload.repeating_queries.as_mut().unwrap()[0] - .requirements - .accuracy = - asap_types::workload::AccuracyRequirement::Explicit(AccuracyTarget::Epsilon(1.0)); - let target = streaming_sum_query(); - let root = summary_with_operations(false, false, false); - let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { - child: summary_input, - }) = &root.operator - else { - unreachable!(); - }; - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - model - .bind_window_framework_candidate( - &target, - &root, - SummaryWindowFrameworkCandidate { - physical_plan_id: "invalid-eh".into(), - assignments: vec![SummaryWindowFrameworkAssignment { - summary: Rc::clone(summary_input), - framework: Some(SummaryWindowFramework::ExponentialHistogram), - }], - accuracy: SummaryWindowAccuracyEvidence::Exact, - node_evidence: model.node_evidence.clone(), - }, - ) - .unwrap(); - - let plan = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(plan.summary_total_cost, None); - } - - /// Bulk relational evidence cannot hide summary operators below an ordinary root. - #[test] - fn retained_subdag_evidence_cannot_hide_summary_work() { - let workload = streaming_workload(); - let target = streaming_sum_query(); - let root = add_shared_summary_result(); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - model.node_evidence.insert_retained_query( - &root, - RetainedSubDAGEvidence { - physical_id: "false-retained-root".into(), - output: test_edge(), - preprocessing_cpu_ops_over_horizon: 0.0, - working_memory_bytes: 0, - output_buffer_bytes: 0, - }, - ); - let plan = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(plan.summary_total_cost, None); - } - - #[test] - fn whole_dag_fails_closed_for_missing_retained_work_or_false_source_lineage() { - let workload = streaming_workload(); - let target = streaming_sum_query(); - let root = summary_with_operations(false, false, false); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - model.node_evidence.retained_queries.clear(); - let missing_retained = plan_summary_maintenance_lifecycles( - Rc::clone(&root), - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(missing_retained.summary_total_cost, None); - - bind_comparison(&mut model, &target, &root); - model - .target_comparisons - .get_mut(&Rc::as_ptr(&target)) - .unwrap() - .scope - .sources[0] - .source = Source::TimeSeries { - metric: "other_metric".into(), - }; - let false_lineage = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &model, - ) - .unwrap(); - assert_eq!(false_lineage.summary_total_cost, None); - } - - #[test] - fn state_only_needs_no_evaluation_and_summary_merge_child_fails_closed() { - // A state-only root is costable without evaluation evidence; a - // SummaryAgg over a reserved SummaryMerge is rejected at planning. - let workload = streaming_workload(); - let target = streaming_sum_query(); - let estimated = summary_with_operations(false, false, false); - let state_only = evaluation_state(&estimated); - let mut no_evaluation_cpu = streaming_cpu(); - no_evaluation_cpu.evaluation_cpu_ops = None; - let mut state_model = streaming_model(); - bind_aggregations( - &mut state_model, - &target, - &state_only, - streaming_inputs(), - no_evaluation_cpu, - ); - let state_plan = plan_summary_maintenance_lifecycles( - state_only, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &state_model, - ) - .unwrap(); - assert!(state_plan.summary_total_cost.is_some()); - - let nested = std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { - child: evaluation_state(&summary_with_operations(true, false, false)), - family: FieldDataType::ExactAggregate(ExactKind::Count, ExactParams::Count), - input: SummaryUpdate { - item: None, - weight: asap_types::post_asap::SummaryInputExpr::Constant(1.0), - weight_domain: Default::default(), - }, - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::PerSubpopulationInstance, - filter: None, - }), - count_state_schema(), - ) - .with_guarantee(None), - ); - let mut nested_cpu = streaming_cpu(); - nested_cpu.merge_cpu_ops = Some(1.0); - let mut nested_model = streaming_model(); - bind_aggregations( - &mut nested_model, - &target, - &nested, - streaming_inputs(), - nested_cpu, - ); - assert!(matches!( - streaming_planning_error(nested, &nested_model), - asap_types::post_asap::ExecutionDataStateError::UnimplementedOperator { - operator: "SummaryMerge" - } - )); - } - - #[test] - fn mixed_arrival_fails_closed_until_backlog_and_stream_are_separate() { - let data = DataWorkload { - arrival: DataArrival::Mixed, - ..DataWorkload::default() - }; - let mut scope = streaming_scope(); - scope.data_arrival = DataArrival::Mixed; - assert_eq!( - SummaryMaintenanceInputs::from_workload(physical(), &data, &scope), - Err(AnalyticalCostError::UnsupportedDataArrival( - DataArrival::Mixed - )) - ); - } - - #[test] - fn direct_read_costs_build_updates_windows_and_recurrence() { - let estimate = estimate_test( - &summary_with_operations(false, false, false), - &continuous_guarantee(), - SummaryMaintenanceInputs { - initial_input_rows: 10, - initial_input_bytes: 640, - initial_source_scan_bytes: 640, - ingestion_rate_per_second: 2.0, - active_window_count: 2, - bootstrap_window_count: 1, - retained_window_count: 3, - physical_summary_count: 2, - state_bytes_per_summary: 100, - }, - SummaryOperationCpuEvidence { - insert_cpu_ops: Some(2.0), - evaluation_cpu_ops: Some(3.0), - ..SummaryOperationCpuEvidence::default() - }, - ) - .unwrap(); - // 10 bootstrap + 10 arrivals into two active windows; two states read 5 times. - assert_eq!(estimate.cpu_ops(), 90.0); - assert_eq!(estimate.peak_memory_bytes(), 1_000); - assert_eq!(estimate.scan_bytes(), 640); - } - - #[test] - fn operations_use_update_or_read_multiplicity_and_shared_state_once() { - let estimate = estimate_test( - &summary_with_operations(true, true, true), - &continuous_guarantee(), - SummaryMaintenanceInputs { - initial_input_rows: 1, - initial_input_bytes: 8, - initial_source_scan_bytes: 8, - ingestion_rate_per_second: 4.0, - active_window_count: 1, - bootstrap_window_count: 1, - retained_window_count: 2, - physical_summary_count: 2, - state_bytes_per_summary: 10, - }, - SummaryOperationCpuEvidence { - insert_cpu_ops: Some(1.0), - merge_cpu_ops: Some(2.0), - subtract_cpu_ops: Some(3.0), - delete_cpu_ops: Some(5.0), - delete_events_per_second: Some(4.0), - delete_routing_fanout: Some(2), - evaluation_cpu_ops: Some(7.0), - }, - ) - .unwrap(); - assert_eq!(estimate.cpu_ops(), 21.0 + 20.0 + 30.0 + 200.0 + 70.0); - // Three persistent windows plus one transient result, for two instances. - assert_eq!(estimate.peak_memory_bytes(), 80); - } - - #[test] - fn lifecycle_mode_and_schedule_must_match_existing_planner_semantics() { - let mut guarantee = continuous_guarantee(); - guarantee.evaluation_schedule = EvaluationSchedule::OnRead; - assert_eq!( - estimate_test( - &summary_with_operations(false, false, false), - &guarantee, - SummaryMaintenanceInputs { - initial_input_rows: 1, - initial_input_bytes: 8, - initial_source_scan_bytes: 8, - ingestion_rate_per_second: 1.0, - active_window_count: 1, - bootstrap_window_count: 1, - retained_window_count: 1, - physical_summary_count: 1, - state_bytes_per_summary: 8, - }, - SummaryOperationCpuEvidence { - insert_cpu_ops: Some(1.0), - evaluation_cpu_ops: Some(1.0), - ..SummaryOperationCpuEvidence::default() - }, - ), - Err(AnalyticalCostError::IncompatibleLifecycleGuarantee) - ); - } - - #[test] - fn missing_cost_for_an_operation_in_the_dag_fails_closed() { - assert_eq!( - estimate_test( - &summary_with_operations(true, false, false), - &continuous_guarantee(), - SummaryMaintenanceInputs { - initial_input_rows: 1, - initial_input_bytes: 8, - initial_source_scan_bytes: 8, - ingestion_rate_per_second: 1.0, - active_window_count: 1, - bootstrap_window_count: 1, - retained_window_count: 1, - physical_summary_count: 1, - state_bytes_per_summary: 8, - }, - SummaryOperationCpuEvidence { - insert_cpu_ops: Some(1.0), - evaluation_cpu_ops: Some(1.0), - ..SummaryOperationCpuEvidence::default() - }, - ), - Err(AnalyticalCostError::MissingOrStale("merge_cpu_ops")) - ); - } - - #[test] - fn direct_build_mode_is_not_mispriced_as_incremental_maintenance() { - let mut guarantee = continuous_guarantee(); - guarantee.summary_maintenance_lifecycle = SummaryMaintenanceLifecycle::Ephemeral; - guarantee.summary_maintenance_mode = SummaryMaintenanceMode::DirectBuild; - guarantee.evaluation_schedule = EvaluationSchedule::OneShot; - assert_eq!( - estimate_test( - &summary_with_operations(false, false, false), - &guarantee, - SummaryMaintenanceInputs { - initial_input_rows: 1, - initial_input_bytes: 8, - initial_source_scan_bytes: 8, - ingestion_rate_per_second: 1.0, - active_window_count: 1, - bootstrap_window_count: 1, - retained_window_count: 1, - physical_summary_count: 1, - state_bytes_per_summary: 8, - }, - SummaryOperationCpuEvidence { - insert_cpu_ops: Some(1.0), - evaluation_cpu_ops: Some(1.0), - ..SummaryOperationCpuEvidence::default() - }, - ), - Err(AnalyticalCostError::IncompatibleLifecycleGuarantee) - ); - } - - #[test] - fn prepared_maintenance_charges_only_its_active_interval() { - let guarantee = SummaryMaintenanceLifecycleGuarantee { - summary_maintenance_lifecycle: SummaryMaintenanceLifecycle::Prepared { - activate_at: asap_types::workload::TimestampMs(1_000), - retire_at: asap_types::workload::TimestampMs(6_000), - }, - summary_maintenance_mode: SummaryMaintenanceMode::Incremental, - evaluation_schedule: EvaluationSchedule::PerUpdate, - output_representation: OutputRepresentation::SummaryState, - }; - let estimate = estimate_test( - &summary_with_operations(false, false, false), - &guarantee, - SummaryMaintenanceInputs { - initial_input_rows: 10, - initial_input_bytes: 80, - initial_source_scan_bytes: 80, - ingestion_rate_per_second: 2.0, - active_window_count: 1, - bootstrap_window_count: 1, - retained_window_count: 1, - physical_summary_count: 1, - state_bytes_per_summary: 8, - }, - SummaryOperationCpuEvidence { - insert_cpu_ops: Some(1.0), - evaluation_cpu_ops: Some(1.0), - ..SummaryOperationCpuEvidence::default() - }, - ) - .unwrap(); - // Two pre-activation arrivals join the bootstrap; eight more are - // maintained through the horizon; five reads are served. - assert_eq!(estimate.cpu_ops(), 25.0); - } - - #[test] - fn shared_retention_is_not_the_comparison_horizon() { - let guarantee = SummaryMaintenanceLifecycleGuarantee { - summary_maintenance_lifecycle: SummaryMaintenanceLifecycle::Shared { - retention: asap_types::workload::DurationMs(999), - }, - summary_maintenance_mode: SummaryMaintenanceMode::Incremental, - evaluation_schedule: EvaluationSchedule::PerUpdate, - output_representation: OutputRepresentation::SummaryState, - }; - assert!(estimate_test( - &summary_with_operations(false, false, false), - &guarantee, - SummaryMaintenanceInputs { - initial_input_rows: 1, - initial_input_bytes: 8, - initial_source_scan_bytes: 8, - ingestion_rate_per_second: 1.0, - active_window_count: 1, - bootstrap_window_count: 1, - retained_window_count: 1, - physical_summary_count: 1, - state_bytes_per_summary: 8, - }, - SummaryOperationCpuEvidence { - insert_cpu_ops: Some(1.0), - evaluation_cpu_ops: Some(1.0), - ..SummaryOperationCpuEvidence::default() - }, - ) - .is_ok()); - } - - #[test] - fn lifecycle_retention_rate_integrates_to_one_peak_capacity_charge() { - let target = streaming_sum_query(); - let root = summary_with_operations(false, false, false); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - let aggregation = evidence_nodes(&root).0[0]; - let inputs = model - .lifecycle_inputs(aggregation, Some(Horizon(5.0))) - .unwrap(); - let integrated = inputs.retention_cost_rate.unwrap().0 * 5.0; - // (2 active + 3 retained) * 2 states * 100 bytes, calibrated once. - assert_eq!(integrated, 1_000.0); - } - - #[test] - fn summary_join_requires_cardinality_and_working_memory_evidence() { - let joined = summary_join(); - let inputs = SummaryMaintenanceInputs { - initial_input_rows: 1, - initial_input_bytes: 8, - initial_source_scan_bytes: 8, - ingestion_rate_per_second: 1.0, - active_window_count: 1, - bootstrap_window_count: 1, - retained_window_count: 1, - physical_summary_count: 1, - state_bytes_per_summary: 8, - }; - let cpu = SummaryOperationCpuEvidence { - insert_cpu_ops: Some(1.0), - evaluation_cpu_ops: Some(1.0), - ..SummaryOperationCpuEvidence::default() - }; - assert_eq!( - estimate_join_test(&joined, &continuous_guarantee(), inputs, cpu, None,), - Err(AnalyticalCostError::MissingOrStale("summary_join")) - ); - let estimate = estimate_join_test( - &joined, - &continuous_guarantee(), - inputs, - cpu, - Some(SummaryJoinEvidence { - physical_id: "diagnostic-join".into(), - inputs: vec![test_edge(), test_edge()], - output: test_edge(), - cpu_ops_per_execution: 12.0, - working_memory_bytes: 32, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - }), - ) - .unwrap(); - assert_eq!(estimate.cpu_ops(), 77.0); - assert_eq!(estimate.peak_memory_bytes(), 64); // 4 persistent states + join memory. - } - - fn count_state_schema() -> Schema { - Schema::lifted( - vec![Field::new( - "count", - FieldDataType::ExactAggregate(ExactKind::Count, ExactParams::Count), - false, - )], - None, - ) - } - - fn count_evaluation_schema() -> Schema { - Schema::lifted(vec![Field::plain("count", DataType::Int64, false)], None) - } - - /// The retained relational input of every test summary: a bare scan of - /// the `metrics` series. - fn metrics_scan() -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source: Source::TimeSeries { - metric: "metrics".into(), - }, - predicates: vec![], - schema: Schema::with_time_index( - vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("value", DataType::Float64, false), - ], - 0, - vec![], - ), - })) - .unwrap() - } - - fn summary_with_operations(merge: bool, subtract: bool, delete: bool) -> Rc { - let state_type = FieldDataType::ExactAggregate(ExactKind::Count, ExactParams::Count); - let schema = count_state_schema(); - let child = metrics_scan(); - let agg = OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { - child, - family: state_type, - input: SummaryUpdate { - item: None, - weight: asap_types::post_asap::SummaryInputExpr::Constant(1.0), - weight_domain: Default::default(), - }, - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::PerSubpopulationInstance, - filter: None, - }), - schema.clone(), - ) - .with_guarantee(None); - let agg = std::rc::Rc::new(agg); - let mut root = Rc::clone(&agg); - if merge { - root = std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryMerge { - children: vec![Rc::clone(&agg), Rc::clone(&agg)], - }), - schema.clone(), - ) - .with_guarantee(None), - ); - } - if subtract { - root = std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummarySubtract { - left: Rc::clone(&root), - right: Rc::clone(&agg), - }), - schema.clone(), - ) - .with_guarantee(None), - ); - } - if delete { - root = std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryDelete { - summary_input: root, - key: 0, - }), - schema.clone(), - ) - .with_guarantee(None), - ); - } - std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: root }), - count_evaluation_schema(), - ) - .with_guarantee(Some(ResultGuarantee::exact("exact count evaluation"))), - ) - } - - fn summary_join() -> Rc { - let left = summary_with_operations(false, false, false); - let right = summary_with_operations(false, false, false); - let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: left }) = &left.operator - else { - unreachable!() - }; - let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: right }) = &right.operator - else { - unreachable!() - }; - let join = std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryJoin { - outer: Rc::clone(left), - inner: Rc::clone(right), - key: 0, - family: FieldDataType::ExactAggregate(ExactKind::Count, ExactParams::Count), - }), - left.schema.clone(), - ) - .with_guarantee(None), - ); - std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child: join }), - count_evaluation_schema(), - ) - .with_guarantee(None), - ) - } - - fn add_shared_summary_result() -> Rc { - let operand = summary_with_operations(false, false, false); - Rc::new( - OperatorNode::new(Operator::NonASAP(NonASAPOp::BinaryOp { - operator: BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), - vector_match: None, - }, - return_bool: false, - lhs: Rc::clone(&operand), - rhs: operand, - })) - .unwrap() - .with_guarantee(Some(ResultGuarantee::exact("test binary"))), - ) - } - - /// Two independent summary states, each read out, combined by an ordinary - /// `BinaryOp`: the non-reserved replacement for a `SummaryJoin` fixture. - #[test] - fn independent_summary_results_form_a_valid_dag() { - add_independent_summary_results() - .validate_structure() - .unwrap(); - } - - fn add_independent_summary_results() -> Rc { - Rc::new( - OperatorNode::new(Operator::NonASAP(NonASAPOp::BinaryOp { - operator: BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), - vector_match: None, - }, - return_bool: false, - lhs: summary_with_operations(false, false, false), - rhs: summary_with_operations(false, false, false), - })) - .unwrap() - .with_guarantee(Some(ResultGuarantee::exact("test binary"))), - ) - } - - /// The `(lhs, rhs)` evaluations of [`add_independent_summary_results`]. - fn binary_operands(root: &OperatorNode) -> (Rc, Rc) { - let Operator::NonASAP(NonASAPOp::BinaryOp { lhs, rhs, .. }) = &root.operator else { - unreachable!(); - }; - (Rc::clone(lhs), Rc::clone(rhs)) - } - - /// The `SummaryAgg` under one `SummaryEstimate` evaluation. - fn evaluation_state(evaluation: &OperatorNode) -> Rc { - let Operator::ASAP(ASAPOp::FinalizeExactAccumulator { - child: summary_input, - }) = &evaluation.operator - else { - unreachable!(); - }; - Rc::clone(summary_input) - } - - /// The DAG-validation error that streaming lifecycle planning of `root` - /// fails closed with. - fn streaming_planning_error( - root: Rc, - model: &SummaryMaintenanceCostModel, - ) -> asap_types::post_asap::ExecutionDataStateError { - let workload = streaming_workload(); - match plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - model, - ) { - Err( - crate::summary_maintenance_lifecycle::SummaryMaintenanceLifecyclePlanError::InvalidPostAsapDAG( - error, - ), - ) => error, - Err(other) => panic!("unexpected planning error: {other}"), - Ok(_) => panic!("planning must fail closed"), - } - } - - fn test_resource( - physical_id: &str, - inputs: Vec, - cpu_ops: f64, - working_memory_bytes: u64, - output_buffer_bytes: u64, - ) -> SummaryOperatorResourceEvidence { - SummaryOperatorResourceEvidence { - physical_id: physical_id.into(), - inputs, - output: test_edge(), - cpu_ops, - working_memory_bytes, - output_buffer_bytes, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - } - } - - #[test] - fn exact_binary_is_costable_with_explicit_physical_evidence() { - let workload = streaming_workload(); - let target = streaming_sum_query(); - let root = add_shared_summary_result(); - let mut model = streaming_model(); - bind_aggregations( - &mut model, - &target, - &root, - streaming_inputs(), - streaming_cpu(), - ); - - let plan = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &streaming_data_workload(), &[0]), - 0, - Some(Horizon(5.0)), - SummaryMaintenanceLifecycleCapabilities { - supports_ephemeral: true, - supports_prepared: false, - supports_shared: false, - supports_continuously_maintained: false, - }, - &model, - ) - .expect("binary physical evidence should produce a complete cost"); - assert!(plan.summary_total_cost.is_some()); - } - - fn streaming_sum_query() -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(vec![]), - measures: vec![AggIntent::Sum { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: metrics_scan(), - })) - .unwrap() - } - - fn streaming_workload() -> QueryWorkload { - QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: None, - repeating_queries: Some(vec![RepeatingEntry { - query: Query("sum(metrics)".into()), - demand: RepeatedDemand::FixedInterval(RepetitionInterval(1_000)), - requirements: QueryRequirements::default(), - predictability: Predictability::Predictable { known_at: None }, - time_selection: TimeSelection::default(), - }]), - } - } - - fn streaming_data_workload() -> DataWorkload { - DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - data_ingestion_interval: Evidence { - value: Some(asap_types::workload::DurationMs(1_000)), - ..Default::default() - }, - ingestion_rate: Evidence { - value: Some(Rate(2.0)), - source: EvidenceSource::Declared, - observed_at_ms: None, - valid_for_ms: None, - }, - ..Default::default() - } - } - - fn streaming_model() -> SummaryMaintenanceCostModel { - SummaryMaintenanceCostModel::new( - ResourceCalibration { - cost_per_cpu_op: 1.0, - cost_per_scan_byte: 1.0, - cost_per_retained_byte: 1.0, - version: "test".into(), - }, - SummaryMaintenanceCapabilities { - incremental_update: true, - merge: false, - delete: false, - }, - ) - } - - fn streaming_raw() -> RawInputEvidence { - let scope = streaming_scope(); - let node = PhysicalDAGNode { - id: "raw-scan".into(), - operator: PhysicalOperator::Scan, - children: vec![], - scan_selection: Some(scope.sources[0].clone()), - output_buffer_bytes: 0, - retained_bytes: 0, - execution: ExecutionMultiplicity::Once, - }; - let edge = EdgeStatistics { - rows: 80, - bytes: 5_120, - }; - let statistics = OperatorStatistics::Scan { - source_read_bytes: 5_120, - edges: UnaryEdgeStatistics { - input: edge, - output: edge, - promql: None, - }, - }; - RawInputEvidence { - planning_time_input_rows: 10, - planning_time_input_bytes: 640, - planning_time_source_scan_bytes: 640, - arriving_logical_row_bytes: 64, - arriving_source_row_bytes: 64, - ingestion_rate_per_second: 2.0, - physical_dag: EvidenceBackedPhysicalDAG { - nodes: vec![node], - root: "raw-scan".into(), - evidence: HashMap::from([( - "raw-scan".into(), - PhysicalNodeEvidence { - physical_id: "raw-scan".into(), - statistics, - output_buffer_bytes: 0, - }, - )]), - }, - } - } - - /// A retained relational sub-DAG: a non-ASAP node with no summary below - /// it, costed as one unit through retained-query evidence. - fn is_retained(node: &OperatorNode) -> bool { - !node.contains_asap() - } - - fn bind_comparison( - model: &mut SummaryMaintenanceCostModel, - target: &Rc, - root: &Rc, - ) { - model - .bind_candidate_comparison(target, root, streaming_scope(), streaming_raw()) - .unwrap(); - fn retained( - model: &mut SummaryMaintenanceCostModel, - node: &Rc, - seen: &mut HashSet<*const OperatorNode>, - ) { - if !seen.insert(Rc::as_ptr(node)) { - return; - } - if is_retained(node) { - model.node_evidence.insert_retained_query( - node, - RetainedSubDAGEvidence { - physical_id: format!("retained-{node:p}"), - output: test_edge(), - preprocessing_cpu_ops_over_horizon: 1.0, - working_memory_bytes: 8, - output_buffer_bytes: 0, - }, - ); - return; - } - for child in node.children() { - retained(model, child, seen); - } - } - retained(model, root, &mut HashSet::new()); - } - - fn streaming_inputs() -> SummaryMaintenanceInputs { - SummaryMaintenanceInputs { - initial_input_rows: 10, - initial_input_bytes: 640, - initial_source_scan_bytes: 640, - ingestion_rate_per_second: 2.0, - active_window_count: 2, - bootstrap_window_count: 1, - retained_window_count: 3, - physical_summary_count: 2, - state_bytes_per_summary: 100, - } - } - - fn test_edge() -> EdgeStatistics { - EdgeStatistics { rows: 1, bytes: 8 } - } - - fn streaming_cpu() -> SummaryOperationCpuEvidence { - SummaryOperationCpuEvidence { - insert_cpu_ops: Some(2.0), - evaluation_cpu_ops: Some(3.0), - ..SummaryOperationCpuEvidence::default() - } - } - - fn bind_aggregations( - model: &mut SummaryMaintenanceCostModel, - target: &Rc, - root: &Rc, - inputs: SummaryMaintenanceInputs, - cpu: SummaryOperationCpuEvidence, - ) { - bind_comparison(model, target, root); - for node in evidence_nodes(root).0 { - let source_root = matches!( - &node.operator, - Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) if is_retained(child) - ); - let mut node_inputs = inputs; - if !source_root { - node_inputs.initial_input_rows = test_edge().rows; - node_inputs.initial_input_bytes = test_edge().bytes; - node_inputs.initial_source_scan_bytes = 0; - } - model.node_evidence.aggregations.insert( - node as *const _, - SummaryAggregateEvidence { - physical_id: format!("agg-{node:p}"), - input: test_edge(), - output: test_edge(), - scan_selection_index: source_root.then_some(0), - bootstrap_read_identity: if source_root { - "shared-bootstrap".into() - } else { - String::new() - }, - inputs: node_inputs, - insert_cpu_ops: cpu.insert_cpu_ops.unwrap(), - }, - ); - } - fn resource( - physical_id: String, - inputs: Vec, - cpu_ops: f64, - working_memory_bytes: u64, - ) -> SummaryOperatorResourceEvidence { - SummaryOperatorResourceEvidence { - physical_id, - inputs, - output: test_edge(), - cpu_ops, - working_memory_bytes, - output_buffer_bytes: 0, - executions_per_evaluation: 1, - io_bytes_per_execution: Some(0), - } - } - fn bind_ops( - model: &mut SummaryMaintenanceCostModel, - node: &OperatorNode, - seen: &mut HashSet<*const OperatorNode>, - inputs: SummaryMaintenanceInputs, - cpu: SummaryOperationCpuEvidence, - ) { - if !seen.insert(node as *const _) { - return; - } - if is_retained(node) { - return; - } - let operation = match &node.operator { - Operator::NonASAP(NonASAPOp::BinaryOp { .. }) => { - cpu.evaluation_cpu_ops.map(|cpu_ops| { - SummaryOperatorEvidence::Binary(resource( - format!("binary-{node:p}"), - vec![test_edge(), test_edge()], - cpu_ops, - 0, - )) - }) - } - Operator::NonASAP(NonASAPOp::Join { .. }) => None, - Operator::NonASAP(_) - | Operator::ASAP( - ASAPOp::FinalizeExactAccumulator { .. } - | ASAPOp::MaintainPopulation { .. } - | ASAPOp::EvaluatePopulation { .. }, - ) => cpu.evaluation_cpu_ops.map(|cpu_ops| { - SummaryOperatorEvidence::ValueOperation(resource( - format!("value-operation-{node:p}"), - vec![test_edge()], - cpu_ops, - 0, - )) - }), - Operator::ASAP(ASAPOp::SummaryMerge { children }) => { - cpu.merge_cpu_ops.map(|cpu_ops| { - SummaryOperatorEvidence::Merge(resource( - format!("merge-{node:p}"), - vec![test_edge(); children.len()], - cpu_ops, - inputs.state_bytes_per_summary, - )) - }) - } - Operator::ASAP(ASAPOp::SummarySubtract { .. }) => { - cpu.subtract_cpu_ops.map(|cpu_ops| { - SummaryOperatorEvidence::Subtract(resource( - format!("subtract-{node:p}"), - vec![test_edge(), test_edge()], - cpu_ops, - inputs.state_bytes_per_summary, - )) - }) - } - Operator::ASAP(ASAPOp::SummaryDelete { .. }) => { - cpu.delete_cpu_ops.and_then(|cpu_ops| { - Some(SummaryOperatorEvidence::Delete { - resource: resource( - format!("delete-{node:p}"), - vec![test_edge()], - cpu_ops, - 0, - ), - events_per_second: cpu.delete_events_per_second?, - routing_fanout: cpu.delete_routing_fanout?, - }) - }) - } - Operator::ASAP(ASAPOp::SummaryEstimate { .. }) => { - cpu.evaluation_cpu_ops.map(|cpu_ops| { - SummaryOperatorEvidence::Evaluation(resource( - format!("evaluation-{node:p}"), - vec![test_edge()], - cpu_ops, - 0, - )) - }) - } - Operator::ASAP( - ASAPOp::SummaryAgg { .. } - | ASAPOp::SummaryJoin { .. } - | ASAPOp::Extension { .. }, - ) => None, - }; - if let Some(operation) = operation { - model - .node_evidence - .operations - .insert(node as *const _, operation); - if let Operator::ASAP(ASAPOp::SummaryDelete { summary_input, .. }) = &node.operator - { - fn owning_aggs( - node: &OperatorNode, - seen: &mut HashSet<*const OperatorNode>, - owners: &mut Vec<*const OperatorNode>, - ) { - if !seen.insert(node as *const _) { - return; - } - if matches!(node.operator, Operator::ASAP(ASAPOp::SummaryAgg { .. })) { - owners.push(node as *const _); - } - for child in node.children() { - owning_aggs(child, seen, owners); - } - } - let mut owners = Vec::new(); - owning_aggs(summary_input, &mut HashSet::new(), &mut owners); - owners.sort_unstable(); - owners.dedup(); - if let [owner] = owners.as_slice() { - model - .node_evidence - .operation_state_owners - .insert(node as *const _, *owner); - } - } - } - for child in node.children() { - bind_ops(model, child, seen, inputs, cpu); - } - } - bind_ops(model, root, &mut HashSet::new(), inputs, cpu); - } - - fn streaming_scope() -> ComparisonScope { - let workload = streaming_workload(); - let entry = workload.entries().next().unwrap(); - ComparisonScope::from_workload( - &DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - data_ingestion_interval: Evidence { - value: Some(asap_types::workload::DurationMs(1_000)), - ..Default::default() - }, - ingestion_rate: Evidence { - value: Some(Rate(2.0)), - source: EvidenceSource::Declared, - observed_at_ms: None, - valid_for_ms: None, - }, - ..Default::default() - }, - &entry, - asap_types::workload::TimestampMs(0), - asap_types::workload::DurationMs(5_000), - vec![crate::physical_operator_statistics::ScanSelection { - source: Source::TimeSeries { - metric: "metrics".into(), - }, - source_snapshot_id: "stream-start".into(), - predicates: vec![], - info_matchers: vec![], - }], - ) - .unwrap() - } -} diff --git a/crates/asap-aware-mapping/src/summary_maintenance_cost/window.rs b/crates/asap-aware-mapping/src/summary_maintenance_cost/window.rs deleted file mode 100644 index fb80d5ee0..000000000 --- a/crates/asap-aware-mapping/src/summary_maintenance_cost/window.rs +++ /dev/null @@ -1,252 +0,0 @@ -use super::*; - -/// One per-state window choice within a complete Planner candidate. -#[derive(Debug, Clone)] -pub struct SummaryWindowFrameworkAssignment { - pub summary: Rc, - /// `None` explicitly means that this state is not window-organized. - pub framework: Option, -} - -/// Cost evidence for one complete abstract window-framework assignment across -/// a summary DAG in Planner search. -/// -/// The provider derives this evidence from a concrete downstream -/// implementation under the current data workload. The stable identity is -/// provenance for the chosen implementation, while deployment placement and -/// runtime configuration remain downstream concerns. -#[derive(Debug, Clone)] -pub struct SummaryWindowFrameworkCandidate { - /// Stable identity of the complete provider implementation whose evidence - /// is bound to this planner-visible framework assignment. - pub physical_plan_id: String, - /// Exactly one assignment for every summary deployment in the DAG. - pub assignments: Vec, - /// Registered end-to-end accuracy composition for this complete window - /// assignment. EH combinations must use one of the specialized proofs; - /// unknown combinations fail closed. - pub accuracy: SummaryWindowAccuracyEvidence, - pub node_evidence: SummaryNodeEvidence, -} - -pub(super) fn summary_aggregation_identities(root: &OperatorNode) -> HashSet<*const OperatorNode> { - fn visit( - node: &OperatorNode, - seen: &mut HashSet<*const OperatorNode>, - out: &mut HashSet<*const OperatorNode>, - ) { - if !seen.insert(node as *const _) { - return; - } - if matches!(node.operator, Operator::ASAP(ASAPOp::SummaryAgg { .. })) { - out.insert(node as *const _); - } - for child in node.children() { - visit(child, seen, out); - } - } - - let mut out = HashSet::new(); - visit(root, &mut HashSet::new(), &mut out); - out -} - -/// Cardinality normalization used by the PromSketch EH bounds. The paper's -/// sub-window error is stated relative to the suffix beginning at the query's -/// left endpoint, so a query-relative bound needs `suffix_rows/query_rows`. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum ExponentialHistogramQueryRange { - MostRecentWindow, - SubWindow { suffix_rows: u64, query_rows: u64 }, -} - -/// Registered accuracy compositions for Exponential Histogram realizations. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum ExponentialHistogramAccuracyEvidence { - /// PromSketch EHKLL normalized rank error. - KllRank { - eh_epsilon: f64, - kll_epsilon: f64, - failure_probability: f64, - range: ExponentialHistogramQueryRange, - }, - /// PromSketch EHUniv/GSum relative error. - UniversalGsum { - epsilon: f64, - failure_probability: f64, - range: ExponentialHistogramQueryRange, - }, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum SummaryWindowAccuracyEvidence { - /// The window implementation preserves exact query-time coverage and adds - /// no error. Used for exact tumbling/sliding realizations. - Exact, - ExponentialHistogram(ExponentialHistogramAccuracyEvidence), -} - -impl ExponentialHistogramQueryRange { - fn suffix_to_query_ratio(self) -> Option { - match self { - Self::MostRecentWindow => Some(1.0), - Self::SubWindow { - suffix_rows, - query_rows, - } if query_rows > 0 && suffix_rows >= query_rows => { - Some(suffix_rows as f64 / query_rows as f64) - } - Self::SubWindow { .. } => None, - } - } -} - -impl SummaryWindowAccuracyEvidence { - pub(super) fn matches_assignments( - &self, - assignments: &[SummaryWindowFrameworkAssignment], - ) -> bool { - let eh_summaries: Vec<_> = assignments - .iter() - .filter(|assignment| { - assignment.framework == Some(SummaryWindowFramework::ExponentialHistogram) - }) - .collect(); - match self { - Self::Exact => eh_summaries.is_empty(), - Self::ExponentialHistogram(ExponentialHistogramAccuracyEvidence::KllRank { - .. - }) => { - eh_summaries.len() == 1 - && eh_summaries.iter().all(|assignment| { - matches!( - &assignment.summary.operator, - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - }) if kind.algorithm() == &SketchAlgorithm::Kll - ) - }) - } - Self::ExponentialHistogram(ExponentialHistogramAccuracyEvidence::UniversalGsum { - .. - }) => { - eh_summaries.len() == 1 - && eh_summaries.iter().all(|assignment| { - matches!( - &assignment.summary.operator, - Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate( - ExactKind::Count | ExactKind::Sum, - _ - ), - .. - }) - ) - }) - } - } - } - - /// Compose the two EH combinations proved by PromSketch - /// (doi:10.14778/3742728.3742732). Unknown EH combinations deliberately - /// have no catch-all arm. - pub(super) fn guarantee(&self, uses_exponential_histogram: bool) -> Option { - match self { - Self::Exact if !uses_exponential_histogram => { - Some(ResultGuarantee::exact("exact window coverage")) - } - Self::Exact => None, - Self::ExponentialHistogram(evidence) if uses_exponential_histogram => { - let (metric, bound, failure_probability, rule) = match *evidence { - ExponentialHistogramAccuracyEvidence::KllRank { - eh_epsilon, - kll_epsilon, - failure_probability, - range, - } => { - if !eh_epsilon.is_finite() - || eh_epsilon < 0.0 - || !kll_epsilon.is_finite() - || kll_epsilon < 0.0 - { - return None; - } - ( - ErrorMetric::Rank, - 2.0 * eh_epsilon * range.suffix_to_query_ratio()? + kll_epsilon, - failure_probability, - "promsketch_eh_kll_rank", - ) - } - ExponentialHistogramAccuracyEvidence::UniversalGsum { - epsilon, - failure_probability, - range, - } => { - if !epsilon.is_finite() || epsilon < 0.0 { - return None; - } - ( - ErrorMetric::RelativeValue, - epsilon * range.suffix_to_query_ratio()?, - failure_probability, - "promsketch_eh_universal_gsum", - ) - } - }; - if !bound.is_finite() - || bound < 0.0 - || !failure_probability.is_finite() - || !(0.0..=1.0).contains(&failure_probability) - { - return None; - } - Some(ResultGuarantee { - metric, - bound: BoundExpr::Constant { value: bound }, - failure_probability: ProbabilityExpr::Constant { - value: failure_probability, - }, - provenance: vec![GuaranteeSource::RuntimeObservation { - source: rule.into(), - detail: serde_json::json!({ - "reference": "doi:10.14778/3742728.3742732" - }), - }], - }) - } - Self::ExponentialHistogram(_) => None, - } - } - - pub(super) fn end_to_end_guarantee( - &self, - uses_exponential_histogram: bool, - summary_guarantee: Option<&ResultGuarantee>, - ) -> Option { - let summary = summary_guarantee?; - match self { - Self::Exact if !uses_exponential_histogram => Some(summary.clone()), - Self::ExponentialHistogram(ExponentialHistogramAccuracyEvidence::KllRank { - kll_epsilon, - failure_probability, - .. - }) if uses_exponential_histogram => { - let summary_bound = summary.bound.evaluate()?; - let summary_failure = summary.failure_probability.evaluate()?; - if summary.metric != ErrorMetric::Rank - || summary_bound != *kll_epsilon - || summary_failure != *failure_probability - { - return None; - } - self.guarantee(true) - } - Self::ExponentialHistogram(ExponentialHistogramAccuracyEvidence::UniversalGsum { - .. - }) if uses_exponential_histogram && summary.is_exact() => self.guarantee(true), - _ => None, - } - } -} diff --git a/crates/asap-aware-mapping/src/summary_maintenance_dag_export.rs b/crates/asap-aware-mapping/src/summary_maintenance_dag_export.rs deleted file mode 100644 index a3a7bb8f8..000000000 --- a/crates/asap-aware-mapping/src/summary_maintenance_dag_export.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! Serializable DAG export for a materialized summary-maintenance plan. -//! -//! `asap-types::dag_export` owns the crate-neutral post-ASAP DAG shape. This -//! adapter lives in the mapping layer, where summary-maintenance lifecycle -//! alternatives and their typed rejection reasons are available, and emits -//! both views together. - -use std::collections::HashMap; -use std::rc::Rc; - -use asap_types::ir::OperatorNode; -use serde::Serialize; - -use asap_types::dag_export::{self, SummaryDAG}; -use asap_types::ir::physical_export::PhysicalASAPNodeId; -use asap_types::post_asap::{ - ResultGuarantee, SummaryMaintenanceLifecycle, SummaryMaintenanceLifecycleGuarantee, - SummaryWindowFramework, -}; - -use crate::summary_maintenance_lifecycle::{ - SummaryMaintenanceLifecyclePlan, SummaryMaintenanceLifecycleRejection, -}; - -#[derive(Debug, Clone, Serialize)] -pub struct SummaryMaintenanceDAGExport { - pub dag: SummaryDAG, - pub deployments: Vec, - pub horizon_seconds: Option, - pub evaluation_rate_per_second: Option, - pub update_rate_per_second: Option, - pub expected_reads: Option, - pub selected_raw_recompute: bool, - #[serde(skip_serializing_if = "Option::is_none")] - /// Provider implementation key. The legacy JSON field name is retained - /// until the surrounding export receives its own schema-version bump. - #[serde(rename = "selected_physical_plan_id")] - pub selected_window_implementation_id: Option, - pub summary_total_cost: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub window_accuracy_guarantee: Option, - pub raw_recompute_total_cost: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SummaryMaintenanceDeploymentExport { - pub post_asap_node_id: PhysicalASAPNodeId, - #[serde(skip_serializing_if = "Option::is_none")] - pub selected_window_framework: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub selected: Option, - pub alternatives: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SummaryMaintenanceLifecycleAlternativeExport { - pub lifecycle: SummaryMaintenanceLifecycle, - pub total_cost: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub rejection: Option, - pub assumptions: Vec, -} - -pub type SummaryMaintenanceLifecycleGuaranteeExport = SummaryMaintenanceLifecycleGuarantee; - -pub fn export_summary_maintenance_plan( - plan: &SummaryMaintenanceLifecyclePlan, -) -> SummaryMaintenanceDAGExport { - let deployments: Vec<_> = plan - .deployments - .iter() - .map(|deployment| SummaryMaintenanceDeploymentExport { - post_asap_node_id: deployment.post_asap_node_id, - selected_window_framework: deployment.selected_window_framework.clone(), - selected: deployment - .summary_maintenance_lifecycle_guarantee - .as_ref() - .cloned(), - alternatives: deployment - .alternatives - .iter() - .map(|alternative| SummaryMaintenanceLifecycleAlternativeExport { - lifecycle: alternative.summary_maintenance_lifecycle.clone(), - total_cost: alternative.total_cost.map(|cost| cost.0), - rejection: alternative.rejection.clone(), - assumptions: alternative.assumptions.clone(), - }) - .collect(), - }) - .collect(); - let mut dag = dag_export::export_summary(&plan.root); - let deployment_by_summary: HashMap<_, _> = plan - .deployments - .iter() - .zip(&deployments) - .map(|(deployment, export)| (Rc::as_ptr(&deployment.summary), export)) - .collect(); - annotate_lifecycle_deployments(&mut dag, &deployment_by_summary); - - SummaryMaintenanceDAGExport { - dag, - deployments, - horizon_seconds: plan.horizon.map(|horizon| horizon.0), - evaluation_rate_per_second: plan.evaluation_rate.map(|rate| rate.0), - update_rate_per_second: plan.update_rate.map(|rate| rate.0), - expected_reads: plan.expected_reads, - selected_raw_recompute: plan.selected_raw_recompute, - selected_window_implementation_id: plan.selected_window_implementation_id.clone(), - summary_total_cost: plan.summary_total_cost.map(|cost| cost.0), - window_accuracy_guarantee: plan.window_accuracy_guarantee.clone(), - raw_recompute_total_cost: plan.raw_recompute_total_cost.map(|cost| cost.0), - } -} - -/// Attach a deployment directly to the exported node of its `SummaryAgg`, -/// matched by the `Rc` identity every exported node carries. This makes the -/// decision visible to dag consumers without asking them to reconstruct -/// pointer identity from dag position. -fn annotate_lifecycle_deployments( - dag: &mut SummaryDAG, - deployments: &HashMap<*const OperatorNode, &SummaryMaintenanceDeploymentExport>, -) { - for dag_node in &mut dag.nodes { - let Some(source) = &dag_node.source_node else { - continue; - }; - if let Some(deployment) = deployments.get(&Rc::as_ptr(source)) { - dag_node.detail["summary_maintenance"] = - serde_json::to_value(deployment).expect("lifecycle export is serializable"); - } - } -} diff --git a/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs b/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs deleted file mode 100644 index 3123ba558..000000000 --- a/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs +++ /dev/null @@ -1,3925 +0,0 @@ -//! Workload-aware summary-maintenance lifecycle planning. -//! -//! A **summary-maintenance lifecycle** is the planner policy for when one -//! materialized summary state is created, retained or shared, updated as data -//! arrives, and retired. It is deliberately narrower than the end-to-end data -//! lifecycle and independent of query recurrence. Recurrence says when and how -//! often queries will read the result. The planner converts that demand into -//! expected reads and an evaluation rate, then uses those quantities to compare -//! rebuilding per query with retaining or continuously maintaining state. -//! Recurrence does not itself prescribe a state-maintenance policy. -//! -//! This module enumerates and costs `Ephemeral`, `Prepared`, `Shared`, and -//! `ContinuouslyMaintained` alternatives for every unique `SummaryAgg` in a -//! materialized plan, and for every maintained population (`MaintainPopulation`) -//! that is not an input of a `SummaryAgg`. [`SummaryMaintenanceMode`] is an orthogonal detail of -//! the selected deployment: state is either built directly or updated -//! incrementally. Unknown evidence stays unknown and therefore cannot make a -//! long-lived alternative win. - -use asap_types::ir::cse::share_common_sub_dags; -use std::collections::{HashMap, HashSet}; -use std::rc::Rc; - -use asap_types::ir::physical_export::{ - compile_physical_asap_dag_with_node_ids, compile_physical_asap_workload_with_node_ids, - PhysicalASAPDAG, PhysicalASAPDAGValidationError, PhysicalASAPNodeId, -}; -use asap_types::ir::timing::{apply_lifecycle_timings, LifecycleAssignment, TimingMemo}; -use asap_types::ir::{ASAPOp, Operator, OperatorNode, QueryRoot}; -use asap_types::post_asap::{ - EvaluationSchedule, ExecutionDataStateError, ExecutionTiming, OutputRepresentation, - ResultGuarantee, SummaryMaintenanceLifecycle, SummaryMaintenanceLifecycleGuarantee, - SummaryMaintenanceMode, SummaryWindowFramework, -}; -use asap_types::types::AccuracyTarget; -use asap_types::workload::{ - DataArrival, DataWorkload, Predictability, QueryRecurrence, QueryWorkload, RepeatedDemand, - TimestampMs, WorkloadError, -}; - -use crate::analytical_cost::AnalyticalCostError; -use crate::cost_model::{ - CompleteSummaryCandidateEstimate, Cost, CostModel, CostedSummaryDeployment, -}; -use crate::physical_operator_statistics::evaluations_in_horizon; -use crate::recurrence::{ - CostRate, EvaluationRate, Horizon, RecurrenceError, RecurrenceProfile, UpdateRate, -}; -use crate::replacement::{ - CandidateCostOverrides, CandidateLogicalASAPDAGs, GlobalSelection, RealizationError, - Replacement, ReplacementProvenance, -}; - -/// Summary-maintenance lifecycle shapes supported by the target runtime. -/// -/// These independent flags describe the set of lifecycle alternatives the -/// runtime implements, not simultaneous states of one deployment. Multiple -/// flags may be `true` (a runtime can support both ephemeral and prepared -/// state, for example); the planner still selects exactly one mutually -/// exclusive [`SummaryMaintenanceLifecycle`] for each deployment. A supported -/// alternative may still be rejected because workload evidence is missing or -/// its cost is unknown. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SummaryMaintenanceLifecycleCapabilities { - /// The runtime can build a fresh state for each invocation and retire it - /// after that invocation finishes. - pub supports_ephemeral: bool, - /// The runtime can build state before a predictable execution and retain - /// it until that scheduled execution window ends. - pub supports_prepared: bool, - /// The runtime can retain one state and reuse it across multiple reads. - pub supports_shared: bool, - /// The runtime can keep state current by applying arriving data updates. - pub supports_continuously_maintained: bool, -} - -/// State operations supported by one concrete summary family and -/// representation. -/// -/// This differs from [`SummaryMaintenanceLifecycleCapabilities`]: these flags -/// describe what the summary algorithm itself can do, while lifecycle -/// capabilities describe what deployment policies the target runtime can -/// orchestrate. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct SummaryMaintenanceCapabilities { - /// Existing state can incorporate arriving input without a full rebuild. - pub incremental_update: bool, - /// Two independently built states can be combined into one equivalent - /// state. - pub merge: bool, - /// Expired or retracted input can be removed from existing state. - pub delete: bool, -} - -impl SummaryMaintenanceLifecycleCapabilities { - pub const ALL: Self = Self { - supports_ephemeral: true, - supports_prepared: true, - supports_shared: true, - supports_continuously_maintained: true, - }; -} - -impl Default for SummaryMaintenanceLifecycleCapabilities { - fn default() -> Self { - Self::ALL - } -} - -/// Primitive costs for one concrete summary state. Every field is optional: -/// missing statistics produce an uncosted alternative, never a zero. -/// -/// The lifecycle planner combines these state-specific inputs with workload -/// rates, invocation counts, and the optimization horizon. All `Cost` fields -/// are one-time costs unless their name explicitly says otherwise. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct SummaryMaintenanceLifecycleCostInputs { - /// One-time cost to construct the state from its input. - pub build_cost: Option, - /// Cost to incorporate one arriving input update into existing state. - pub maintenance_cost_per_update: Option, - /// Cost of one read or finalization from already-built summary state. - pub summary_read_cost: Option, - /// Cost per second for retaining the state over a lifecycle window. - pub retention_cost_rate: Option, - /// One-time cost to release or retire the state. - pub retirement_cost: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -pub enum SummaryMaintenanceLifecycleRejection { - UnsupportedByRuntime, - RequiresPredictableOneTimeQuery, - RequiresMultipleReads, - RequiresHorizon, - RequiresContinuousData, - MissingOrStaleIngestionRate, - SummaryDoesNotSupportIncrementalUpdates, - SummaryDoesNotSupportDeletion, - MissingCostEvidence, -} - -/// One candidate lifecycle policy for a particular summary deployment. -/// -/// `total_cost: None` never means zero: it means the planner lacks enough -/// evidence to cost the candidate. Such a candidate is not selectable and its -/// `rejection` explains why. -#[derive(Debug, Clone, PartialEq)] -pub struct SummaryMaintenanceLifecycleAlternative { - /// State creation, retention, sharing, update, and retirement policy. - pub summary_maintenance_lifecycle: SummaryMaintenanceLifecycle, - /// Complete cost over the requested horizon, when every input is known. - pub total_cost: Option, - /// Why this alternative cannot be selected; `None` means it is legal and - /// fully costed. - pub rejection: Option, - /// Human-readable premises used when deriving and costing the alternative. - pub assumptions: Vec, -} - -impl SummaryMaintenanceLifecycleAlternative { - fn selectable(&self) -> bool { - self.rejection.is_none() && self.total_cost.is_some() - } -} - -/// One unique retained-state deployment. Shared `Rc` nodes are emitted once. -#[derive(Debug, Clone)] -pub struct SummaryMaintenanceDeployment { - /// Identity of this summary in the exported post-ASAP semantic DAG. - /// It is scoped to one plan version and is not a summary definition or - /// summary instance identity. - pub post_asap_node_id: PhysicalASAPNodeId, - /// The unique materialized `SummaryAgg` represented by this deployment. - pub summary: Rc, - /// Lifecycle, evaluation, and representation commitment selected for this - /// state, or `None` when no alternative is selectable. - pub summary_maintenance_lifecycle_guarantee: Option, - /// Abstract window primitive selected for this state. Concrete runtime - /// implementation, placement, and identity remain downstream decisions. - pub selected_window_framework: Option, - /// Every lifecycle shape considered, including rejected and uncosted ones. - pub alternatives: Vec, -} - -/// Workload-aware lifecycle and window-framework decisions for every unique -/// summary state reachable from one materialized post-ASAP root. -#[derive(Debug, Clone)] -pub struct SummaryMaintenanceLifecyclePlan { - /// Root of the materialized post-ASAP DAG being deployed. - pub root: Rc, - /// One entry per unique reachable `SummaryAgg`; shared `Rc` nodes appear - /// only once. - pub deployments: Vec, - /// Caller-supplied optimization horizon used to turn rates into total - /// costs. `None` keeps horizon-dependent alternatives unselectable. - pub horizon: Option, - /// Aggregate recurring query-evaluation rate derived from the workload. - pub evaluation_rate: Option, - /// Fresh source-data ingestion rate, when supplied by the workload. - pub update_rate: Option, - /// Total demand inside the horizon, when every recurrence is known. - pub expected_reads: Option, - /// Whether global costing preferred rebuilding the raw expression over all - /// summary deployments. - pub selected_raw_recompute: bool, - /// Provider-owned identity of the selected complete physical deployment - /// (for example a tumbling, sliding, or exponential-histogram plan). - pub selected_window_implementation_id: Option, - /// Cost of the selected set of summary deployments, when fully known. - pub summary_total_cost: Option, - /// Composed accuracy guarantee supplied by the selected physical window - /// evidence, when the window framework introduces approximation. - pub window_accuracy_guarantee: Option, - /// Cost of evaluating the original expression for the same demand, when - /// fully known. - pub raw_recompute_total_cost: Option, -} - -/// Why a lifecycle plan cannot assign execution timing to its DAG. -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum SummaryMaintenanceTimingError { - #[error(transparent)] - InvalidPostAsapDAG(#[from] ExecutionDataStateError), - #[error("summary {0:?} has no selected lifecycle")] - UnselectedLifecycle(PhysicalASAPNodeId), - /// A maintained population outside any `SummaryAgg`'s inputs has no - /// deployment, so its timing would be guessed. Enumeration always emits - /// one; this arises only for a plan whose root or deployments were edited. - #[error("node {0:?} maintains state that has no summary-maintenance lifecycle")] - UnplannedMaintainedState(PhysicalASAPNodeId), - #[error(transparent)] - InvalidPhases(#[from] PhysicalASAPDAGValidationError), -} - -impl SummaryMaintenanceLifecyclePlan { - /// The post-ASAP DAG of [`Self::root`] with every node's timing derived - /// from the selected lifecycles, so physical compilation places it. - /// - /// A retained (non-`Ephemeral`) state outlives one query, so it and every - /// input it consumes run at ingestion time. Every other node runs at query - /// time: evaluations and consumers of retained state, and each `Ephemeral` - /// state not consumed by retained state together with its inputs, whose - /// raw data the deployment must supply as a query source. This applies to - /// maintained populations as to `SummaryAgg` states; a population feeding - /// a `SummaryAgg` is one of its inputs. Timings already on the root are - /// ignored. - pub fn execution_timed_dag(&self) -> Result { - execution_timed_workload_dag(&[QueryRoot::Operator(Rc::clone(&self.root))], &[self]) - } -} - -/// One physical ASAP DAG for a workload: `roots` are the queries in -/// workload order, operator roots becoming `roots` and standalone scalars -/// `scalar_roots` of the export, with sub-DAGs shared between queries -/// exported once. `plans` are the lifecycle plans of every planned sub-DAG -/// `roots` reach; timing follows the selected lifecycles of their -/// deployments, as in [`SummaryMaintenanceLifecyclePlan::execution_timed_dag`]. -pub(crate) fn execution_timed_workload_dag( - roots: &[QueryRoot], - plans: &[&SummaryMaintenanceLifecyclePlan], -) -> Result { - // One memo, so a node shared by several roots is timed and exported once. - let mut memo = TimingMemo::new(); - let assignment = LifecycleAssignment::default_maintained(); - let mut timed = Vec::with_capacity(roots.len()); - for root in roots { - timed.push(match root { - QueryRoot::Operator(node) => { - QueryRoot::Operator(apply_lifecycle_timings(node, &assignment, &mut memo)?) - } - QueryRoot::Scalar(expr) => { - for node in expr.operator_refs() { - apply_lifecycle_timings(node, &assignment, &mut memo)?; - } - QueryRoot::Scalar(expr.map_operator_refs(&mut |node| { - Rc::clone(memo.timed(node).expect("scalar reference was timed")) - })) - } - }); - } - let compiled = compile_physical_asap_workload_with_node_ids(&timed)?; - let id_of = |node: &Rc| { - compiled - .node_ids - .node_id(memo.timed(node).expect("plan node was timed")) - .expect("timed plan node belongs to the compiled DAG") - }; - let deployments: Vec<_> = plans - .iter() - .flat_map(|plan| &plan.deployments) - .map(|deployment| (id_of(&deployment.summary), deployment)) - .collect(); - for plan in plans { - for population in &standalone_populations(&plan.root) { - let id = id_of(population); - if !deployments.iter().any(|(deployed, _)| *deployed == id) { - return Err(SummaryMaintenanceTimingError::UnplannedMaintainedState(id)); - } - } - } - let dag = compiled.dag; - let mut pending = Vec::new(); - for (id, deployment) in &deployments { - let guarantee = deployment - .summary_maintenance_lifecycle_guarantee - .as_ref() - .ok_or(SummaryMaintenanceTimingError::UnselectedLifecycle(*id))?; - if guarantee.summary_maintenance_lifecycle != SummaryMaintenanceLifecycle::Ephemeral { - pending.push(*id); - } - } - let mut ingestion = HashSet::new(); - while let Some(id) = pending.pop() { - if ingestion.insert(id) { - pending.extend( - dag.edges - .iter() - .filter(|edge| edge.consumer == id) - .map(|edge| edge.producer), - ); - } - } - let phases = dag - .nodes - .iter() - .map(|node| { - let timing = if ingestion.contains(&node.id) { - ExecutionTiming::IngestionTime - } else { - ExecutionTiming::QueryTime - }; - (node.id, timing) - }) - .collect(); - Ok(dag.with_execution_phases(&phases)?) -} - -/// Explicit association between a materialized target and the normalized -/// workload entries whose demand consumes it. -/// -/// [`QueryWorkload`] remains the source of query demand, while source-data -/// evidence is supplied independently. Indices avoid copying normalized entry -/// definitions while ensuring unrelated entries do not influence a target's -/// lifecycle decision. -#[derive(Debug, Clone, Copy)] -pub struct WorkloadDemand<'a> { - /// Original normalized query workload. - pub workload: &'a QueryWorkload, - /// Independent source-data evidence, when the caller has it. - pub data_workload: Option<&'a DataWorkload>, - /// Indices from [`QueryWorkload::entries`] that consume this target. - pub entry_indices: &'a [usize], -} - -impl<'a> WorkloadDemand<'a> { - /// Bind query demand without source-data evidence. Callers that have a - /// [`DataWorkload`] should use [`Self::new_with_data`] so ingestion facts - /// are not silently discarded. - pub const fn new_without_data(workload: &'a QueryWorkload, entry_indices: &'a [usize]) -> Self { - Self { - workload, - data_workload: None, - entry_indices, - } - } - - pub const fn new_with_data( - workload: &'a QueryWorkload, - data_workload: &'a DataWorkload, - entry_indices: &'a [usize], - ) -> Self { - Self { - workload, - data_workload: Some(data_workload), - entry_indices, - } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum SummaryMaintenanceLifecyclePlanError { - #[error(transparent)] - InvalidWorkload(#[from] WorkloadError), - #[error("optimization horizon must be finite and strictly positive")] - InvalidHorizon, - #[error("workload entry index {index} is out of bounds for {entry_count} entries")] - InvalidWorkloadEntry { index: usize, entry_count: usize }, - #[error("a workload-demand binding must contain at least one entry")] - EmptyWorkloadDemand, - #[error("workload entry index {index} appears more than once in one demand binding")] - DuplicateWorkloadEntry { index: usize }, - #[error(transparent)] - InvalidPostAsapDAG(#[from] ExecutionDataStateError), -} - -#[derive(Debug, thiserror::Error)] -pub enum SummaryMaintenanceLifecycleAssemblyError { - #[error(transparent)] - AssembleDAG(#[from] RealizationError), - #[error(transparent)] - SummaryMaintenance(#[from] SummaryMaintenanceLifecyclePlanError), -} - -/// Failure while deriving workload-aware candidate costs before global -/// selection. -#[derive(Debug, thiserror::Error)] -pub enum SummaryMaintenanceLifecycleSelectionError { - #[error(transparent)] - Recurrence(#[from] RecurrenceError), - #[error(transparent)] - SummaryMaintenance(#[from] SummaryMaintenanceLifecyclePlanError), -} - -/// Every lifecycle alternative for each unique retained state of one fixed -/// root, before any lifecycle is chosen. -/// -/// Planner selection ([`plan_summary_maintenance_lifecycles`]) and a -/// deployment's explicit choice ([`Self::select`]) both finish from this value, -/// so they produce the same [`SummaryMaintenanceLifecyclePlan`] shape. -pub struct SummaryMaintenanceLifecycleCandidates<'a> { - /// Unselected plan: deployments carry alternatives but no guarantee or - /// window framework. - plan: SummaryMaintenanceLifecyclePlan, - components: Vec, - arrival: DataArrival, - required_accuracy: Vec, - cost_model: &'a dyn CostModel, - comparison_target: Option<&'a OperatorNode>, -} - -/// Why an explicit per-state lifecycle choice cannot be bound. -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum SummaryMaintenanceLifecycleChoiceError { - #[error("summary {0:?} is not a deployment of this root")] - UnknownSummary(PhysicalASAPNodeId), - #[error("summary {0:?} is chosen more than once")] - DuplicateChoice(PhysicalASAPNodeId), - #[error("summary {0:?} has no chosen lifecycle")] - MissingChoice(PhysicalASAPNodeId), - #[error("chosen lifecycle is not an enumerated alternative of summary {0:?}")] - NotAnAlternative(PhysicalASAPNodeId), - #[error("chosen lifecycle of summary {post_asap_node_id:?} is rejected: {rejection:?}")] - Rejected { - post_asap_node_id: PhysicalASAPNodeId, - rejection: Option, - }, - #[error("summary states on one maintenance path have different evaluation schedules")] - IncompatibleEvaluationSchedules, - #[error("the cost model supplied no complete estimate for the chosen combination")] - NoCompleteEstimate, -} - -impl SummaryMaintenanceLifecycleCandidates<'_> { - /// One entry per unique retained state (see - /// [`SummaryMaintenanceLifecyclePlan::deployments`]), with every - /// alternative and its rejection; no lifecycle or window framework is - /// selected. - pub fn deployments(&self) -> &[SummaryMaintenanceDeployment] { - &self.plan.deployments - } - - /// Guarantee that binding `lifecycle` would attach under this workload's - /// data arrival, so a caller can price an alternative before choosing it. - pub fn guarantee( - &self, - lifecycle: &SummaryMaintenanceLifecycle, - ) -> SummaryMaintenanceLifecycleGuarantee { - lifecycle_guarantee(lifecycle, self.arrival) - } - - fn context(&self) -> CompleteCostContext<'_> { - CompleteCostContext { - root: &self.plan.root, - components: &self.components, - cost_model: self.cost_model, - comparison_target: self.comparison_target, - horizon: self.plan.horizon, - expected_reads: self.plan.expected_reads, - required_accuracy: &self.required_accuracy, - } - } - - fn finish( - mut self, - estimate: Option, - ) -> SummaryMaintenanceLifecyclePlan { - if let Some(estimate) = estimate { - self.plan.summary_total_cost = Some(estimate.cost); - self.plan.selected_window_implementation_id = estimate.physical_plan_id; - self.plan.window_accuracy_guarantee = estimate.window_accuracy_guarantee; - } - self.plan - } - - /// Planner's choice: the cheapest complete combination of eligible - /// alternatives. - fn select_cheapest(mut self) -> SummaryMaintenanceLifecyclePlan { - let estimate = select_complete_lifecycle_combination( - &self.plan.root, - &mut self.plan.deployments, - &self.components, - self.arrival, - self.cost_model, - self.comparison_target, - self.plan.horizon, - self.plan.expected_reads, - &self.required_accuracy, - ); - self.finish(estimate) - } - - /// Bind one caller-chosen lifecycle per summary state. Each choice must be - /// an alternative Planner itself could select; the complete estimate is - /// then obtained exactly as for Planner selection, so window framework and - /// cost are the model's and unknown cost is never replaced by zero. - pub fn select( - mut self, - choices: &[(PhysicalASAPNodeId, SummaryMaintenanceLifecycle)], - ) -> Result { - use SummaryMaintenanceLifecycleChoiceError as E; - let deployments = &self.plan.deployments; - let mut chosen: Vec> = - vec![None; deployments.len()]; - let context = self.context(); - for (id, lifecycle) in choices { - let index = deployments - .iter() - .position(|deployment| deployment.post_asap_node_id == *id) - .ok_or(E::UnknownSummary(*id))?; - if chosen[index].is_some() { - return Err(E::DuplicateChoice(*id)); - } - let alternative = deployments[index] - .alternatives - .iter() - .find(|alternative| alternative.summary_maintenance_lifecycle == *lifecycle) - .ok_or(E::NotAnAlternative(*id))?; - if !context.eligible(alternative) { - return Err(E::Rejected { - post_asap_node_id: *id, - rejection: alternative.rejection.clone(), - }); - } - chosen[index] = Some(alternative); - } - let selected = chosen - .into_iter() - .enumerate() - .map(|(index, alternative)| { - let alternative = - alternative.ok_or(E::MissingChoice(deployments[index].post_asap_node_id))?; - Ok(( - index, - lifecycle_guarantee(&alternative.summary_maintenance_lifecycle, self.arrival), - // Reached only for costed alternatives or when the - // complete hook is authoritative, matching Planner search. - alternative.total_cost.unwrap_or(Cost::ZERO), - )) - }) - .collect::, E>>()?; - if selected.is_empty() { - return Ok(self.finish(None)); - } - if !context.schedules_compatible(&selected) { - return Err(E::IncompatibleEvaluationSchedules); - } - let estimate = context - .estimate(deployments, &selected) - .ok_or(E::NoCompleteEstimate)?; - let guarantees = selected - .into_iter() - .map(|(index, guarantee, _)| (index, guarantee)) - .collect(); - apply_selection(&mut self.plan.deployments, guarantees, &estimate); - Ok(self.finish(Some(estimate))) - } -} - -/// Workload-wide evidence derived specifically for summary-maintenance -/// lifecycle enumeration and costing. -/// -/// This is not another workload input model. [`QueryWorkload`] and its -/// normalized entries remain the source of truth. Unlike one -/// [`asap_types::workload::QueryWorkloadEntry`], these values aggregate all -/// entries at a particular planning time and optional horizon. It also cannot -/// reuse [`crate::recurrence::RecurrenceProfile`], which describes recurrence -/// for one candidate target and counts consumers rather than invocations. -#[derive(Debug)] -struct SummaryMaintenanceWorkloadFacts { - required_accuracy: Vec, - /// Total one-time and recurring reads inside the horizon. `None` means a - /// recurrence or horizon was unknown, not zero reads. - reads: Option, - /// Sum of declared invocations across all one-time workload entries. - one_time_invocations: u64, - /// Sum of usable recurring query rates in evaluations per second. - evaluation_rate: Option, - /// Fresh workload-level ingestion rate in updates per second. - update_rate: Option, - /// Whether the workload's source data is static, arriving, mixed, or - /// unknown. - arrival: DataArrival, - /// Earliest known activation and latest scheduled execution across - /// predictable one-time entries. `None` means no valid preparation window. - prepared_window: Option<(TimestampMs, TimestampMs)>, - /// Whether every bound consumer is a predictable one-time query suitable - /// for prepared state. - prepared_eligible: bool, - /// Whether maintaining the selected moving time scope requires deleting - /// expired input from summary state. - requires_deletion: bool, -} - -/// Validate a materialized plan, enumerate lifecycle alternatives for each -/// unique summary state, and select the cheapest legal alternative whose cost -/// is fully known. -pub fn plan_summary_maintenance_lifecycles( - root: Rc, - demand: WorkloadDemand<'_>, - now_ms: u64, - horizon: Option, - capabilities: SummaryMaintenanceLifecycleCapabilities, - cost_model: &dyn CostModel, -) -> Result { - Ok(enumerate_summary_maintenance_lifecycles( - root, - demand, - now_ms, - horizon, - capabilities, - cost_model, - )? - .select_cheapest()) -} - -/// Validate a materialized plan and enumerate lifecycle alternatives for each -/// unique summary state without choosing one. A deployment that prices the -/// alternatives itself binds its choice with -/// [`SummaryMaintenanceLifecycleCandidates::select`]. -pub fn enumerate_summary_maintenance_lifecycles<'a>( - root: Rc, - demand: WorkloadDemand<'_>, - now_ms: u64, - horizon: Option, - capabilities: SummaryMaintenanceLifecycleCapabilities, - cost_model: &'a dyn CostModel, -) -> Result, SummaryMaintenanceLifecyclePlanError> { - enumerate_with_profile( - root, - demand, - now_ms, - horizon, - capabilities, - cost_model, - None, - None, - ) -} - -/// Internal candidate-costing form. The workload binding supplies temporal -/// eligibility and data-arrival facts; `profile` supplies effective uses after -/// DAG path multiplicity has been propagated by `CandidateLogicalASAPDAGs`. -#[expect(clippy::too_many_arguments, reason = "internal bound planning context")] -fn enumerate_with_profile<'a>( - root: Rc, - demand: WorkloadDemand<'_>, - now_ms: u64, - horizon: Option, - capabilities: SummaryMaintenanceLifecycleCapabilities, - cost_model: &'a dyn CostModel, - profile: Option, - comparison_target: Option<&'a OperatorNode>, -) -> Result, SummaryMaintenanceLifecyclePlanError> { - demand.workload.validate()?; - if let Some(data) = demand.data_workload { - data.validate()?; - } - if horizon.is_some_and(|h| !h.0.is_finite() || h.0 <= 0.0) { - return Err(SummaryMaintenanceLifecyclePlanError::InvalidHorizon); - } - let mut facts = workload_facts( - demand.workload, - demand.data_workload, - demand.entry_indices, - now_ms, - horizon, - )?; - if let Some(profile) = profile { - facts.one_time_invocations = u64::try_from(profile.one_shot_consumers).unwrap_or(u64::MAX); - facts.evaluation_rate = profile.evaluation_rate; - facts.update_rate = profile.update_rate; - facts.reads = match (profile.evaluation_rate, horizon) { - (Some(rate), Some(horizon)) => { - Some(profile.one_shot_consumers as f64 + rate.0 * horizon.0) - } - (Some(_), None) => None, - (None, _) if profile.one_shot_consumers > 0 => Some(profile.one_shot_consumers as f64), - // Preserve unknown recurrence from the normalized workload. An - // empty profile does not prove that the target is never read. - (None, _) => facts.reads, - }; - } - let mut summaries = Vec::new(); - collect_states( - &root, - &mut HashSet::new(), - &mut summaries, - StateKind::SummaryAgg, - ); - summaries.extend(standalone_populations(&root)); - let mut timing_memo = TimingMemo::new(); - let timed_root = apply_lifecycle_timings( - &root, - &LifecycleAssignment::default_maintained(), - &mut timing_memo, - )?; - let node_ids = compile_physical_asap_dag_with_node_ids(&timed_root)?.node_ids; - let components = summary_state_components(&summaries); - let deployments: Vec = summaries - .into_iter() - .map(|summary| { - let capabilities = if OperatorNode::reachable(&summary).iter().any(|node| { - matches!( - node.non_asap(), - Some(asap_types::ir::NonASAPOp::BinaryOp { .. }) - ) && asap_types::ir::timing::validate_default(node, ExecutionTiming::IngestionTime) - .is_err() - }) { - SummaryMaintenanceLifecycleCapabilities { - supports_ephemeral: capabilities.supports_ephemeral, - supports_prepared: false, - supports_shared: false, - supports_continuously_maintained: false, - } - } else { - capabilities - }; - let alternatives = alternatives_for( - &facts, - horizon, - capabilities, - cost_model.summary_maintenance_capabilities(&summary), - cost_model.summary_maintenance_lifecycle_cost_inputs_for_horizon(&summary, horizon), - ); - SummaryMaintenanceDeployment { - post_asap_node_id: timing_memo - .timed(&summary) - .and_then(|timed| node_ids.node_id(timed)) - .expect("collected summary belongs to the compiled DAG"), - summary, - summary_maintenance_lifecycle_guarantee: None, - selected_window_framework: None, - alternatives, - } - }) - .collect(); - let selected_raw_recompute = !root.contains_asap(); - Ok(SummaryMaintenanceLifecycleCandidates { - plan: SummaryMaintenanceLifecyclePlan { - root, - deployments, - horizon, - evaluation_rate: facts.evaluation_rate, - update_rate: facts.update_rate, - expected_reads: facts.reads, - selected_raw_recompute, - selected_window_implementation_id: None, - summary_total_cost: None, - window_accuracy_guarantee: None, - raw_recompute_total_cost: None, - }, - components, - arrival: facts.arrival, - required_accuracy: facts.required_accuracy, - cost_model, - comparison_target, - }) -} - -/// Rank semantic summary siblings using the cheapest legal -/// summary-maintenance lifecycle for each candidate before final global -/// selection. The candidate space stays compact; only cost overrides are -/// attached, so shared `Rc` identity and exact-composition commitments remain -/// the responsibility of `GlobalSelection`. -/// -/// Summary candidates of different targets whose outermost `SummaryAgg` is -/// structurally identical (for example p50 and p99 over one KLL) form a class. -/// When [`shared_state_cost`] can cost that state once against the union of -/// the targets' entries, each member is offered an equal split of it instead -/// of its independent cost. If selection then leaves any member of a class on -/// another choice, that class reverts to independent costs and selection runs -/// once more. -pub fn global_selection_with_summary_maintenance_lifecycles<'a, Id>( - space: &'a CandidateLogicalASAPDAGs, - demand: WorkloadDemand<'_>, - now_ms: u64, - horizon: Option, - capabilities: SummaryMaintenanceLifecycleCapabilities, - cost_model: &dyn CostModel, -) -> Result, SummaryMaintenanceLifecycleSelectionError> { - let WorkloadDemand { - workload, - data_workload, - entry_indices: root_workload_entries, - } = demand; - let profiles = space.recurrence_profiles_from_workload( - workload, - data_workload, - root_workload_entries, - now_ms, - horizon, - )?; - let bindings = space.workload_entries_by_target(workload, root_workload_entries)?; - let mut costs = CandidateCostOverrides::default(); - // Finalized summary candidates, as sharing-class members. - let mut members = Vec::new(); - for group in space.target_subdag_candidates() { - let Some(entry_indices) = bindings.get(&Rc::as_ptr(&group.target)) else { - continue; - }; - for candidate in &group.candidates { - // Only summary realizations carry a maintenance lifecycle; a - // logical rewrite or CSE share/recompute candidate does not. - let Replacement::SubDAG(summary) = &candidate.replacement else { - continue; - }; - if candidate.provenance != ReplacementProvenance::SummaryRealization { - continue; - } - costs.finalize_target(&group.target); - let plan = enumerate_with_profile( - Rc::clone(summary), - WorkloadDemand { - workload, - data_workload, - entry_indices, - }, - now_ms, - horizon, - capabilities, - cost_model, - Some(profiles.for_target(&group.target)), - Some(&group.target), - )? - .select_cheapest(); - let raw = plan - .expected_reads - .and_then(|reads| cost_model.raw_query_recompute_total_cost(&group.target, reads)); - // Final comparison is atomic: without the raw side, no summary - // override is published even when that summary alone is costed. - if let Some(raw) = raw { - costs.insert_raw(&group.target, raw); - if !plan.deployments.is_empty() { - if let Some(total) = plan.summary_total_cost { - costs.insert(&group.target, candidate, total); - } - members.push((group, candidate, Rc::clone(summary))); - } - } - } - } - - // Intern every member once; members whose outermost state (the - // `SummaryAgg` every other state of the candidate feeds) interns to the - // same node share it. Classes are kept in first-member order. - let interned = share_common_sub_dags( - members - .iter() - .enumerate() - .map(|(index, (_, _, summary))| (index, Rc::clone(summary))) - .collect(), - ); - let mut classes: Vec<(Rc, Vec)> = Vec::new(); - for (index, root) in interned { - let states = summary_states(&root); - let Some(state) = states - .iter() - .find(|state| summary_states(state).len() == states.len()) - else { - continue; - }; - if !standalone_populations(&root).is_empty() { - continue; - } - match classes.iter_mut().find(|(s, _)| Rc::ptr_eq(s, state)) { - Some((_, class)) => class.push(index), - None => classes.push((Rc::clone(state), vec![index])), - } - } - let mut shared = Vec::new(); - for (state, class) in classes { - let mut targets: Vec<&Rc> = Vec::new(); - for &index in &class { - let target = &members[index].0.target; - if !targets.iter().any(|t| Rc::ptr_eq(t, target)) { - targets.push(target); - } - } - if targets.len() < 2 { - continue; - } - let mut entries: Vec = targets - .iter() - .flat_map(|target| bindings[&Rc::as_ptr(target)].iter().copied()) - .collect(); - entries.sort_unstable(); - entries.dedup(); - let Some(cost) = shared_state_cost( - &state, - WorkloadDemand { - workload, - data_workload, - entry_indices: &entries, - }, - now_ms, - horizon, - capabilities, - cost_model, - )? - else { - continue; - }; - shared.push((class, Cost(cost.0 / targets.len() as f64))); - } - - let with_shared = |kept: &[(Vec, Cost)]| { - let mut costs = costs.clone(); - for (class, split) in kept { - for &index in class { - let (group, candidate, _) = &members[index]; - costs.insert(&group.target, candidate, *split); - } - } - costs - }; - let selection = space.global_selection_with_candidate_costs( - cost_model, - &profiles, - horizon, - &with_shared(&shared), - )?; - let before = shared.len(); - shared.retain(|(class, _)| { - class.iter().all(|&index| { - let target = &members[index].0.target; - let chosen = selection.for_target(target).and_then(|s| s.chosen); - class.iter().any(|&other| { - Rc::ptr_eq(&members[other].0.target, target) - && chosen.is_some_and(|chosen| std::ptr::eq(chosen, members[other].1)) - }) - }) - }); - if shared.len() == before { - return Ok(selection); - } - Ok(space.global_selection_with_candidate_costs( - cost_model, - &profiles, - horizon, - &with_shared(&shared), - )?) -} - -/// Cost of one `SummaryAgg` state maintained once for every entry in -/// `demand`, or `None` when no lifecycle alternative is selectable for it. -/// No comparison target is supplied: the state serves several queries. -pub(crate) fn shared_state_cost( - state: &Rc, - demand: WorkloadDemand<'_>, - now_ms: u64, - horizon: Option, - capabilities: SummaryMaintenanceLifecycleCapabilities, - cost_model: &dyn CostModel, -) -> Result, SummaryMaintenanceLifecyclePlanError> { - Ok(enumerate_with_profile( - Rc::clone(state), - demand, - now_ms, - horizon, - capabilities, - cost_model, - None, - None, - )? - .select_cheapest() - .summary_total_cost) -} - -/// Every unique `SummaryAgg` reachable from `root`. -pub(crate) fn summary_states(root: &Rc) -> Vec> { - let mut states = Vec::new(); - collect_states( - root, - &mut HashSet::new(), - &mut states, - StateKind::SummaryAgg, - ); - states -} - -/// Assemble a globally selected phase-valid DAG and attach workload-aware -/// summary maintenance decisions. This does not create or maintain runtime state. -pub fn assemble_selected_dag_with_summary_maintenance_lifecycles( - selection: &GlobalSelection<'_>, - target: &Rc, - demand: WorkloadDemand<'_>, - now_ms: u64, - horizon: Option, - capabilities: SummaryMaintenanceLifecycleCapabilities, - cost_model: &dyn CostModel, -) -> Result, SummaryMaintenanceLifecycleAssemblyError> { - selection - .assemble_selected_dag(target)? - .map(|root| { - plan_assembled_dag( - root, - target, - demand, - now_ms, - horizon, - capabilities, - cost_model, - ) - }) - .transpose() -} - -/// The lifecycle half of -/// [`assemble_selected_dag_with_summary_maintenance_lifecycles`], for a root -/// the caller already assembled (and possibly interned across queries). -pub(crate) fn plan_assembled_dag( - root: Rc, - target: &Rc, - demand: WorkloadDemand<'_>, - now_ms: u64, - horizon: Option, - capabilities: SummaryMaintenanceLifecycleCapabilities, - cost_model: &dyn CostModel, -) -> Result { - let mut plan = enumerate_with_profile( - root, - demand, - now_ms, - horizon, - capabilities, - cost_model, - None, - Some(target), - )? - .select_cheapest(); - plan.raw_recompute_total_cost = plan - .expected_reads - .and_then(|reads| cost_model.raw_query_recompute_total_cost(target, reads)); - if !plan.selected_raw_recompute - && plan.raw_recompute_total_cost.is_none_or(|raw| { - plan.summary_total_cost - .is_none_or(|summary| raw.0 <= summary.0) - }) - { - plan.root = crate::replacement::retain_exact(target)?; - plan.deployments.clear(); - plan.selected_raw_recompute = true; - plan.selected_window_implementation_id = None; - plan.summary_total_cost = None; - plan.window_accuracy_guarantee = None; - } - // The plan is a query result: an exact state is read through its - // finalize. Costing above saw the bare state, which is what the cost - // model's evidence is bound to; the finalize wraps the same `Rc`. - plan.root = crate::replacement::finalize_query_candidate(plan.root, target)?; - Ok(plan) -} - -fn workload_facts( - workload: &QueryWorkload, - data_workload: Option<&DataWorkload>, - workload_entry_indices: &[usize], - now_ms: u64, - horizon: Option, -) -> Result { - let mut one_time_invocations = 0u64; - let mut recurring_reads = 0.0; - let mut recurring_known = true; - let mut evaluation_rate = 0.0; - let mut has_evaluation_rate = false; - let mut prepared_start: Option = None; - let mut prepared_end: Option = None; - let mut prepared_eligible = true; - let mut requires_deletion = false; - let mut required_accuracy = Vec::new(); - - let entries: Vec<_> = workload.entries().collect(); - if workload_entry_indices.is_empty() { - return Err(SummaryMaintenanceLifecyclePlanError::EmptyWorkloadDemand); - } - let mut seen_indices = HashSet::new(); - for &index in workload_entry_indices { - if !seen_indices.insert(index) { - return Err(SummaryMaintenanceLifecyclePlanError::DuplicateWorkloadEntry { index }); - } - let entry = entries.get(index).ok_or( - SummaryMaintenanceLifecyclePlanError::InvalidWorkloadEntry { - index, - entry_count: entries.len(), - }, - )?; - required_accuracy.push(entry.requirements.accuracy.target()); - requires_deletion |= entry.time_selection.lookback.is_some() - && entry.time_selection.as_of.is_none() - && matches!( - entry.time_selection.scope, - asap_types::workload::QueryTimeScope::RealTime - | asap_types::workload::QueryTimeScope::Mixed - ); - match &entry.recurrence { - QueryRecurrence::OneTime { - invocations, - execute_at, - } => { - one_time_invocations = one_time_invocations.saturating_add(*invocations); - let covered = if let ( - Predictability::Predictable { - known_at: Some(known), - }, - Some(execute), - ) = (&entry.predictability, execute_at) - { - if known < execute && now_ms < execute.0 { - let activate = TimestampMs(known.0.max(now_ms)); - prepared_start = - Some(prepared_start.map_or(activate, |old| old.min(activate))); - prepared_end = Some(prepared_end.map_or(*execute, |old| old.max(*execute))); - true - } else { - false - } - } else { - false - }; - prepared_eligible &= covered; - } - QueryRecurrence::Repeated(RepeatedDemand::FixedInterval(interval)) - | QueryRecurrence::Repeated(RepeatedDemand::FixedIntervalAt { interval, .. }) => { - prepared_eligible = false; - let rate = 1000.0 / f64::from(interval.0); - evaluation_rate += rate; - has_evaluation_rate = true; - if let Some(h) = horizon { - recurring_reads += h.0 * rate; - } else { - recurring_known = false; - } - } - QueryRecurrence::Repeated(RepeatedDemand::Scheduled(schedule)) => { - prepared_eligible = false; - if let Some(h) = horizon { - let end_ms = now_ms.saturating_add((h.0 * 1000.0) as u64); - let reads_in_horizon = schedule - .iter() - .filter(|at| at.0 >= now_ms && at.0 <= end_ms) - .count() as f64; - recurring_reads += reads_in_horizon; - evaluation_rate += reads_in_horizon / h.0; - has_evaluation_rate = true; - } else { - recurring_known = false; - } - } - QueryRecurrence::Repeated(RepeatedDemand::EstimatedRate(estimate)) => { - prepared_eligible = false; - if !estimate.is_fresh_at(now_ms) { - recurring_known = false; - continue; - } - let rate = estimate.expected_rate.0; - evaluation_rate += rate; - has_evaluation_rate = true; - if let Some(h) = horizon { - recurring_reads += h.0 * rate; - } else { - recurring_known = false; - } - } - QueryRecurrence::Unknown => { - prepared_eligible = false; - recurring_known = false; - } - } - } - - let data = data_workload; - let arrival = data.map_or(DataArrival::Unknown, |data| data.arrival); - let update_rate = data - .and_then(|data| data.ingestion_rate.value_at(now_ms)) - .map(|rate| UpdateRate(rate.0)); - let reads = if let Some(horizon) = horizon { - let horizon_ms = horizon.0 * 1_000.0; - if !horizon_ms.is_finite() - || horizon_ms <= 0.0 - || horizon_ms > u64::MAX as f64 - || horizon_ms.fract() != 0.0 - { - None - } else { - workload_entry_indices - .iter() - .try_fold(0_u64, |total, index| { - let entry = entries.get(*index)?; - match evaluations_in_horizon(&entry.recurrence, now_ms, horizon_ms as u64) { - Ok(count) => total.checked_add(count), - Err(AnalyticalCostError::NoEvaluationsInHorizon) => Some(total), - Err(_) => None, - } - }) - .map(|count| count as f64) - } - } else { - recurring_known.then_some(one_time_invocations as f64 + recurring_reads) - }; - Ok(SummaryMaintenanceWorkloadFacts { - required_accuracy, - reads, - one_time_invocations, - evaluation_rate: has_evaluation_rate.then_some(EvaluationRate(evaluation_rate)), - update_rate, - arrival, - prepared_window: prepared_start.zip(prepared_end), - prepared_eligible, - requires_deletion, - }) -} - -fn alternatives_for( - facts: &SummaryMaintenanceWorkloadFacts, - horizon: Option, - capabilities: SummaryMaintenanceLifecycleCapabilities, - summary_capabilities: SummaryMaintenanceCapabilities, - costs: SummaryMaintenanceLifecycleCostInputs, -) -> Vec { - let alternatives = vec![ - ephemeral(facts, capabilities, &costs), - prepared(facts, capabilities, summary_capabilities, &costs), - shared(facts, horizon, capabilities, summary_capabilities, &costs), - continuous(facts, horizon, capabilities, summary_capabilities, &costs), - ]; - alternatives -} - -fn ephemeral( - facts: &SummaryMaintenanceWorkloadFacts, - capabilities: SummaryMaintenanceLifecycleCapabilities, - costs: &SummaryMaintenanceLifecycleCostInputs, -) -> SummaryMaintenanceLifecycleAlternative { - let lifecycle = SummaryMaintenanceLifecycle::Ephemeral; - if !capabilities.supports_ephemeral { - return rejected( - lifecycle, - SummaryMaintenanceLifecycleRejection::UnsupportedByRuntime, - ); - } - let total_cost = zip_costs(&[ - costs.build_cost, - costs.summary_read_cost, - costs.retirement_cost, - ]) - .zip(facts.reads) - .map(|(per_read, reads)| Cost(per_read * reads)); - costed_or_unknown( - lifecycle, - total_cost, - vec!["state is rebuilt per invocation".into()], - ) -} - -fn prepared( - facts: &SummaryMaintenanceWorkloadFacts, - capabilities: SummaryMaintenanceLifecycleCapabilities, - summary_capabilities: SummaryMaintenanceCapabilities, - costs: &SummaryMaintenanceLifecycleCostInputs, -) -> SummaryMaintenanceLifecycleAlternative { - if !facts.prepared_eligible { - return rejected( - SummaryMaintenanceLifecycle::Prepared { - activate_at: TimestampMs(0), - retire_at: TimestampMs(0), - }, - SummaryMaintenanceLifecycleRejection::RequiresPredictableOneTimeQuery, - ); - } - let Some((activate_at, retire_at)) = facts.prepared_window else { - return rejected( - SummaryMaintenanceLifecycle::Prepared { - activate_at: TimestampMs(0), - retire_at: TimestampMs(0), - }, - SummaryMaintenanceLifecycleRejection::RequiresPredictableOneTimeQuery, - ); - }; - let lifecycle = SummaryMaintenanceLifecycle::Prepared { - activate_at, - retire_at, - }; - if !capabilities.supports_prepared { - return rejected( - lifecycle, - SummaryMaintenanceLifecycleRejection::UnsupportedByRuntime, - ); - } - if let Some(rejection) = maintenance_capability_rejection(facts, summary_capabilities) { - return rejected(lifecycle, rejection); - } - let seconds = retire_at.0.saturating_sub(activate_at.0) as f64 / 1000.0; - let maintenance = maintenance_cost(facts, costs, seconds); - let total_cost = match ( - costs.build_cost, - costs.summary_read_cost, - costs.retention_cost_rate, - costs.retirement_cost, - maintenance, - ) { - (Some(build), Some(read), Some(retention), Some(retire), Some(maintenance)) => Some(Cost( - build.0 - + read.0 * facts.one_time_invocations as f64 - + retention.0 * seconds - + retire.0 - + maintenance, - )), - _ => None, - }; - costed_or_unknown( - lifecycle, - total_cost, - vec!["activation and retirement come from the declared schedule".into()], - ) -} - -fn shared( - facts: &SummaryMaintenanceWorkloadFacts, - horizon: Option, - capabilities: SummaryMaintenanceLifecycleCapabilities, - summary_capabilities: SummaryMaintenanceCapabilities, - costs: &SummaryMaintenanceLifecycleCostInputs, -) -> SummaryMaintenanceLifecycleAlternative { - let lifecycle = SummaryMaintenanceLifecycle::Shared { - retention: asap_types::workload::DurationMs(horizon.map_or(0, |h| (h.0 * 1000.0) as u64)), - }; - if !capabilities.supports_shared { - return rejected( - lifecycle, - SummaryMaintenanceLifecycleRejection::UnsupportedByRuntime, - ); - } - if let Some(rejection) = maintenance_capability_rejection(facts, summary_capabilities) { - return rejected(lifecycle, rejection); - } - if facts.reads.is_none_or(|reads| reads <= 1.0) { - return rejected( - lifecycle, - SummaryMaintenanceLifecycleRejection::RequiresMultipleReads, - ); - } - let Some(horizon) = horizon else { - return rejected( - lifecycle, - SummaryMaintenanceLifecycleRejection::RequiresHorizon, - ); - }; - let total_cost = retained_cost(facts, costs, horizon.0); - costed_or_unknown( - lifecycle, - total_cost, - vec!["one state is shared across reads".into()], - ) -} - -fn continuous( - facts: &SummaryMaintenanceWorkloadFacts, - horizon: Option, - capabilities: SummaryMaintenanceLifecycleCapabilities, - summary_capabilities: SummaryMaintenanceCapabilities, - costs: &SummaryMaintenanceLifecycleCostInputs, -) -> SummaryMaintenanceLifecycleAlternative { - let lifecycle = SummaryMaintenanceLifecycle::ContinuouslyMaintained; - if !capabilities.supports_continuously_maintained { - return rejected( - lifecycle, - SummaryMaintenanceLifecycleRejection::UnsupportedByRuntime, - ); - } - if !matches!( - facts.arrival, - DataArrival::ContinuouslyIngesting | DataArrival::Mixed - ) { - return rejected( - lifecycle, - SummaryMaintenanceLifecycleRejection::RequiresContinuousData, - ); - } - if facts.update_rate.is_none() { - return rejected( - lifecycle, - SummaryMaintenanceLifecycleRejection::MissingOrStaleIngestionRate, - ); - } - if let Some(rejection) = maintenance_capability_rejection(facts, summary_capabilities) { - return rejected(lifecycle, rejection); - } - let Some(horizon) = horizon else { - return rejected( - lifecycle, - SummaryMaintenanceLifecycleRejection::RequiresHorizon, - ); - }; - let total_cost = retained_cost(facts, costs, horizon.0); - costed_or_unknown( - lifecycle, - total_cost, - vec!["updates are applied for the optimization horizon".into()], - ) -} - -fn maintenance_capability_rejection( - facts: &SummaryMaintenanceWorkloadFacts, - capabilities: SummaryMaintenanceCapabilities, -) -> Option { - if matches!( - facts.arrival, - DataArrival::ContinuouslyIngesting | DataArrival::Mixed - ) && !capabilities.incremental_update - { - Some(SummaryMaintenanceLifecycleRejection::SummaryDoesNotSupportIncrementalUpdates) - } else if matches!( - facts.arrival, - DataArrival::ContinuouslyIngesting | DataArrival::Mixed - ) && facts.requires_deletion - && !capabilities.delete - { - Some(SummaryMaintenanceLifecycleRejection::SummaryDoesNotSupportDeletion) - } else { - None - } -} - -fn retained_cost( - facts: &SummaryMaintenanceWorkloadFacts, - costs: &SummaryMaintenanceLifecycleCostInputs, - seconds: f64, -) -> Option { - let reads = facts.reads?; - let maintenance = maintenance_cost(facts, costs, seconds)?; - Some(Cost( - costs.build_cost?.0 - + maintenance - + reads * costs.summary_read_cost?.0 - + seconds * costs.retention_cost_rate?.0 - + costs.retirement_cost?.0, - )) -} - -fn maintenance_cost( - facts: &SummaryMaintenanceWorkloadFacts, - costs: &SummaryMaintenanceLifecycleCostInputs, - seconds: f64, -) -> Option { - match facts.arrival { - DataArrival::AtRest => Some(0.0), - DataArrival::ContinuouslyIngesting | DataArrival::Mixed => { - Some(seconds * facts.update_rate?.0 * costs.maintenance_cost_per_update?.0) - } - DataArrival::Unknown => None, - } -} - -fn zip_costs(costs: &[Option]) -> Option { - costs - .iter() - .try_fold(0.0, |sum, cost| Some(sum + cost.as_ref()?.0)) -} - -fn costed_or_unknown( - summary_maintenance_lifecycle: SummaryMaintenanceLifecycle, - total_cost: Option, - assumptions: Vec, -) -> SummaryMaintenanceLifecycleAlternative { - SummaryMaintenanceLifecycleAlternative { - summary_maintenance_lifecycle, - total_cost, - rejection: total_cost - .is_none() - .then_some(SummaryMaintenanceLifecycleRejection::MissingCostEvidence), - assumptions, - } -} - -fn rejected( - summary_maintenance_lifecycle: SummaryMaintenanceLifecycle, - rejection: SummaryMaintenanceLifecycleRejection, -) -> SummaryMaintenanceLifecycleAlternative { - SummaryMaintenanceLifecycleAlternative { - summary_maintenance_lifecycle, - total_cost: None, - rejection: Some(rejection), - assumptions: Vec::new(), - } -} - -#[derive(Clone, Copy, PartialEq)] -enum StateKind { - SummaryAgg, - Population, -} - -/// Collect every unique node of `kind` reachable from `node`. -fn collect_states( - node: &Rc, - seen: &mut HashSet<*const OperatorNode>, - output: &mut Vec>, - kind: StateKind, -) { - if !seen.insert(Rc::as_ptr(node)) { - return; - } - if matches!( - (&node.operator, kind), - ( - Operator::ASAP(ASAPOp::SummaryAgg { .. }), - StateKind::SummaryAgg - ) | ( - Operator::ASAP(ASAPOp::MaintainPopulation { .. }), - StateKind::Population - ) - ) { - output.push(Rc::clone(node)); - } - for child in node.children() { - collect_states(child, seen, output, kind); - } -} - -/// Maintained populations that are not an input of any `SummaryAgg`. A -/// population feeding summary state is on that state's maintenance path, so -/// that state's lifecycle times it, even when a evaluation also reads it directly. -fn standalone_populations(root: &Rc) -> Vec> { - let mut summaries = Vec::new(); - collect_states( - root, - &mut HashSet::new(), - &mut summaries, - StateKind::SummaryAgg, - ); - let mut nested = Vec::new(); - let mut seen = HashSet::new(); - for summary in &summaries { - collect_states(summary, &mut seen, &mut nested, StateKind::Population); - } - let nested: HashSet<_> = nested.iter().map(Rc::as_ptr).collect(); - let mut populations = Vec::new(); - collect_states( - root, - &mut HashSet::new(), - &mut populations, - StateKind::Population, - ); - populations.retain(|population| !nested.contains(&Rc::as_ptr(population))); - populations -} - -pub(crate) fn evaluation_schedule( - lifecycle: &SummaryMaintenanceLifecycle, - arrival: DataArrival, -) -> EvaluationSchedule { - match lifecycle { - SummaryMaintenanceLifecycle::Ephemeral => EvaluationSchedule::OneShot, - SummaryMaintenanceLifecycle::Prepared { .. } - | SummaryMaintenanceLifecycle::Shared { .. } - if matches!( - arrival, - DataArrival::ContinuouslyIngesting | DataArrival::Mixed - ) => - { - EvaluationSchedule::PerUpdate - } - SummaryMaintenanceLifecycle::Prepared { .. } => EvaluationSchedule::OneShot, - SummaryMaintenanceLifecycle::Shared { .. } => EvaluationSchedule::OnRead, - SummaryMaintenanceLifecycle::ContinuouslyMaintained => EvaluationSchedule::PerUpdate, - } -} - -/// Summary states composed on one maintenance path must be produced on the -/// same schedule. Return a component id for each collected state. -fn summary_state_components(summaries: &[Rc]) -> Vec { - let indices: HashMap<_, _> = summaries - .iter() - .enumerate() - .map(|(index, summary)| (Rc::as_ptr(summary), index)) - .collect(); - let mut parents: Vec<_> = (0..summaries.len()).collect(); - - fn find(parents: &mut [usize], index: usize) -> usize { - if parents[index] != index { - parents[index] = find(parents, parents[index]); - } - parents[index] - } - - for (parent_index, summary) in summaries.iter().enumerate() { - let Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) = &summary.operator else { - continue; - }; - if !matches!( - child.operator, - Operator::ASAP( - ASAPOp::SummaryAgg { .. } - | ASAPOp::SummaryJoin { .. } - | ASAPOp::SummarySubtract { .. } - | ASAPOp::SummaryDelete { .. } - | ASAPOp::SummaryMerge { .. } - ) - ) { - continue; - } - let mut descendants = Vec::new(); - collect_states( - child, - &mut HashSet::new(), - &mut descendants, - StateKind::SummaryAgg, - ); - for descendant in descendants { - let child_index = indices[&Rc::as_ptr(&descendant)]; - let parent_root = find(&mut parents, parent_index); - let child_root = find(&mut parents, child_index); - parents[child_root] = parent_root; - } - } - (0..parents.len()) - .map(|index| find(&mut parents, index)) - .collect() -} - -/// Inputs shared by every complete lifecycle-combination evaluation of one -/// root, whether Planner searches combinations or a caller supplies one. -struct CompleteCostContext<'a> { - root: &'a OperatorNode, - components: &'a [usize], - cost_model: &'a dyn CostModel, - comparison_target: Option<&'a OperatorNode>, - horizon: Option, - expected_reads: Option, - required_accuracy: &'a [AccuracyTarget], -} - -impl CompleteCostContext<'_> { - /// Planner's own admission rule for one alternative. Uncosted alternatives - /// are admitted only when the complete-candidate hook is authoritative. - fn eligible(&self, alternative: &SummaryMaintenanceLifecycleAlternative) -> bool { - alternative.selectable() - || (self - .cost_model - .complete_summary_candidate_estimate_covers_lifecycle_costs() - && alternative.rejection - == Some(SummaryMaintenanceLifecycleRejection::MissingCostEvidence)) - } - - /// `selected` holds one entry per deployment, in deployment order. - fn schedules_compatible( - &self, - selected: &[(usize, SummaryMaintenanceLifecycleGuarantee, Cost)], - ) -> bool { - !selected.iter().enumerate().any(|(left, (_, a, _))| { - selected.iter().enumerate().any(|(right, (_, b, _))| { - self.components[left] == self.components[right] - && a.evaluation_schedule != b.evaluation_schedule - }) - }) - } - - fn estimate( - &self, - deployments: &[SummaryMaintenanceDeployment], - selected: &[(usize, SummaryMaintenanceLifecycleGuarantee, Cost)], - ) -> Option { - if !self.schedules_compatible(selected) { - return None; - } - let costed: Vec<_> = selected - .iter() - .map(|(index, guarantee, cost)| CostedSummaryDeployment { - summary: &deployments[*index].summary, - guarantee, - selected_cost: *cost, - }) - .collect(); - let estimate = self.cost_model.complete_summary_candidate_estimate( - self.root, - self.comparison_target, - &costed, - self.horizon, - self.expected_reads, - self.required_accuracy, - )?; - (estimate.window_frameworks.len() == deployments.len()).then_some(estimate) - } -} - -fn lifecycle_guarantee( - lifecycle: &SummaryMaintenanceLifecycle, - arrival: DataArrival, -) -> SummaryMaintenanceLifecycleGuarantee { - SummaryMaintenanceLifecycleGuarantee { - summary_maintenance_mode: maintenance_mode(lifecycle, arrival), - evaluation_schedule: evaluation_schedule(lifecycle, arrival), - summary_maintenance_lifecycle: lifecycle.clone(), - output_representation: OutputRepresentation::SummaryState, - } -} - -fn apply_selection( - deployments: &mut [SummaryMaintenanceDeployment], - guarantees: Vec<(usize, SummaryMaintenanceLifecycleGuarantee)>, - estimate: &CompleteSummaryCandidateEstimate, -) { - for (index, guarantee) in guarantees { - deployments[index].summary_maintenance_lifecycle_guarantee = Some(guarantee); - } - for (deployment, framework) in deployments - .iter_mut() - .zip(estimate.window_frameworks.iter().cloned()) - { - deployment.selected_window_framework = framework; - } -} - -#[expect(clippy::too_many_arguments, reason = "complete combination context")] -fn select_complete_lifecycle_combination( - root: &OperatorNode, - deployments: &mut [SummaryMaintenanceDeployment], - components: &[usize], - arrival: DataArrival, - cost_model: &dyn CostModel, - comparison_target: Option<&OperatorNode>, - horizon: Option, - expected_reads: Option, - required_accuracy: &[AccuracyTarget], -) -> Option { - const MAX_COMPLETE_LIFECYCLE_COMBINATIONS: usize = 4_096; - if deployments.is_empty() { - return None; - } - let context = CompleteCostContext { - root, - components, - cost_model, - comparison_target, - horizon, - expected_reads, - required_accuracy, - }; - // The whole-candidate hook is intentionally arbitrary, so partial costs - // cannot soundly prune the search. Bound exhaustive enumeration and fail - // closed instead of allowing an adversarial DAG to consume exponential - // planner time. - let combinations = deployments - .iter() - .try_fold(1_usize, |product, deployment| { - let selectable = deployment - .alternatives - .iter() - .filter(|alternative| context.eligible(alternative)) - .count(); - product.checked_mul(selectable) - })?; - if combinations == 0 || combinations > MAX_COMPLETE_LIFECYCLE_COMBINATIONS { - return None; - } - type Best = Option<( - CompleteSummaryCandidateEstimate, - Vec<(usize, SummaryMaintenanceLifecycleGuarantee)>, - )>; - fn visit( - index: usize, - context: &CompleteCostContext<'_>, - deployments: &[SummaryMaintenanceDeployment], - arrival: DataArrival, - selected: &mut Vec<(usize, SummaryMaintenanceLifecycleGuarantee, Cost)>, - best: &mut Best, - ) { - if index == deployments.len() { - let Some(estimate) = context.estimate(deployments, selected) else { - return; - }; - if best - .as_ref() - .is_none_or(|(best_estimate, _)| estimate.cost.0 < best_estimate.cost.0) - { - *best = Some(( - estimate, - selected - .iter() - .map(|(index, guarantee, _)| (*index, guarantee.clone())) - .collect(), - )); - } - return; - } - for alternative in deployments[index] - .alternatives - .iter() - .filter(|alternative| context.eligible(alternative)) - { - selected.push(( - index, - lifecycle_guarantee(&alternative.summary_maintenance_lifecycle, arrival), - alternative.total_cost.unwrap_or(Cost::ZERO), - )); - visit(index + 1, context, deployments, arrival, selected, best); - selected.pop(); - } - } - - let mut best = None; - visit( - 0, - &context, - deployments, - arrival, - &mut Vec::new(), - &mut best, - ); - let (estimate, guarantees) = best?; - apply_selection(deployments, guarantees, &estimate); - Some(estimate) -} - -pub(crate) fn maintenance_mode( - lifecycle: &SummaryMaintenanceLifecycle, - arrival: DataArrival, -) -> SummaryMaintenanceMode { - match lifecycle { - SummaryMaintenanceLifecycle::Ephemeral => SummaryMaintenanceMode::DirectBuild, - SummaryMaintenanceLifecycle::ContinuouslyMaintained => SummaryMaintenanceMode::Incremental, - SummaryMaintenanceLifecycle::Prepared { .. } - | SummaryMaintenanceLifecycle::Shared { .. } => match arrival { - DataArrival::ContinuouslyIngesting | DataArrival::Mixed => { - SummaryMaintenanceMode::Incremental - } - DataArrival::AtRest | DataArrival::Unknown => SummaryMaintenanceMode::DirectBuild, - }, - } -} - -#[cfg(test)] -mod tests { - // Independent data evidence must be validated at both planning boundaries. - #[test] - fn rejects_invalid_parallel_data_evidence() { - let query = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); - let space = crate::replacement::search_workload(vec![("q", quantile_query())]).unwrap(); - for rate in [1.0, -1.0, f64::NAN, f64::INFINITY] { - let mut data = at_rest(); - data.ingestion_rate.value = Some(Rate(rate)); - assert!(space - .recurrence_profiles_from_workload(&query, Some(&data), &[0], 0, None) - .is_err()); - assert!(plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_with_data(&query, &data, &[0]), - 0, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &crate::cost_model::DefaultCostModel, - ) - .is_err()); - } - } - use super::*; - use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; - use asap_types::ir::{BinaryOperator, NonASAPOp}; - use asap_types::post_asap::{ - ExactKind, ExactParams, Field, FieldDataType, GroupingStrategy, ResultGuarantee, Schema, - SketchAlgorithm, - }; - use asap_types::pre_asap::AggIntent; - use asap_types::pre_asap::{ - ArithmeticOpKind, BinaryOpKind, ColumnRef, DataType, Reduction, Source, - }; - use asap_types::types::AccuracyTarget; - use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, EvidenceSource, Predictability, Query, - QueryLanguage, QueryRequirements, Rate, RepeatingEntry, RepetitionInterval, TimeSelection, - }; - - struct UnitCosts; - - impl CostModel for UnitCosts { - fn rank_candidates( - &self, - _intent: &asap_types::pre_asap::AggIntent, - candidates: &[asap_types::post_asap::SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn summary_maintenance_lifecycle_cost_inputs( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - SummaryMaintenanceLifecycleCostInputs { - build_cost: Some(Cost(10.0)), - maintenance_cost_per_update: Some(Cost(1.0)), - summary_read_cost: Some(Cost(1.0)), - retention_cost_rate: Some(CostRate(0.1)), - retirement_cost: Some(Cost(1.0)), - } - } - - fn summary_maintenance_capabilities( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceCapabilities { - SummaryMaintenanceCapabilities { - incremental_update: true, - merge: true, - delete: true, - } - } - } - - struct RawCheaper; - - impl CostModel for RawCheaper { - fn rank_candidates( - &self, - _intent: &asap_types::pre_asap::AggIntent, - candidates: &[asap_types::post_asap::SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn summary_maintenance_lifecycle_cost_inputs( - &self, - summary: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - UnitCosts.summary_maintenance_lifecycle_cost_inputs(summary) - } - - fn summary_maintenance_capabilities( - &self, - summary: &OperatorNode, - ) -> SummaryMaintenanceCapabilities { - UnitCosts.summary_maintenance_capabilities(summary) - } - - fn raw_query_recompute_cost(&self, _target: &OperatorNode) -> Option { - Some(Cost(1.0)) - } - } - - struct NoDelete; - - impl CostModel for NoDelete { - fn rank_candidates( - &self, - _intent: &asap_types::pre_asap::AggIntent, - candidates: &[asap_types::post_asap::SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn summary_maintenance_lifecycle_cost_inputs( - &self, - summary: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - UnitCosts.summary_maintenance_lifecycle_cost_inputs(summary) - } - - fn summary_maintenance_capabilities( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceCapabilities { - SummaryMaintenanceCapabilities { - incremental_update: true, - merge: true, - delete: false, - } - } - } - - struct SummaryMaintenancePrefersDdSketch; - - impl CostModel for SummaryMaintenancePrefersDdSketch { - fn raw_query_recompute_total_cost( - &self, - _target: &OperatorNode, - _expected_reads: f64, - ) -> Option { - Some(Cost(1_000.0)) - } - - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - // Preserve semantic mapping's KLL-first order. The lifecycle - // total below must be what changes the final choice. - candidates.to_vec() - } - - fn summary_maintenance_lifecycle_cost_inputs( - &self, - summary: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - let build = match sketch_algorithm(summary) { - Some(SketchAlgorithm::Kll) => 100.0, - Some(SketchAlgorithm::DDSketch) => 1.0, - _ => 10.0, - }; - SummaryMaintenanceLifecycleCostInputs { - build_cost: Some(Cost(build)), - maintenance_cost_per_update: Some(Cost(1.0)), - summary_read_cost: Some(Cost(1.0)), - retention_cost_rate: Some(CostRate(0.1)), - retirement_cost: Some(Cost(1.0)), - } - } - } - - struct IncompatibleNestedCosts; - - struct WholeCandidatePrefersContinuous; - - impl CostModel for WholeCandidatePrefersContinuous { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn complete_summary_candidate_cost( - &self, - _root: &OperatorNode, - _target: Option<&OperatorNode>, - deployments: &[CostedSummaryDeployment<'_>], - _horizon: Option, - _expected_reads: Option, - _required_accuracy: &[AccuracyTarget], - ) -> Option { - Some( - if deployments.iter().all(|deployment| { - matches!( - deployment.guarantee.summary_maintenance_lifecycle, - SummaryMaintenanceLifecycle::ContinuouslyMaintained - ) - }) { - Cost(1.0) - } else { - Cost(100.0) - }, - ) - } - } - - impl CostModel for IncompatibleNestedCosts { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn summary_maintenance_lifecycle_cost_inputs( - &self, - summary: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - // A leaf summary is one built directly over kept pre-ASAP rows - // (its child is not an ASAP node); a nested one reads state. - let is_leaf = matches!( - &summary.operator, - Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) if !child.is_asap() - ); - SummaryMaintenanceLifecycleCostInputs { - build_cost: Some(Cost(if is_leaf { 1.0 } else { 100.0 })), - maintenance_cost_per_update: Some(Cost(if is_leaf { 100.0 } else { 0.0 })), - summary_read_cost: Some(Cost::ZERO), - retention_cost_rate: Some(CostRate(0.0)), - retirement_cost: Some(Cost::ZERO), - } - } - - fn summary_maintenance_capabilities( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceCapabilities { - SummaryMaintenanceCapabilities { - incremental_update: true, - merge: true, - delete: true, - } - } - } - - /// The sketch algorithm of the first `SummaryAgg` reachable from `node` - /// (through a evaluation or any relational operator kept above it). - fn sketch_algorithm(node: &OperatorNode) -> Option { - if let Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::Sketch(kind, _), - .. - }) = &node.operator - { - return Some(kind.algorithm().clone()); - } - node.children() - .into_iter() - .find_map(|child| sketch_algorithm(child)) - } - - fn query_root() -> Rc { - query_root_for("m") - } - - fn query_root_for(metric: &str) -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Scan { - source: Source::TimeSeries { - metric: metric.into(), - }, - predicates: vec![], - schema: Schema::with_time_index( - vec![ - Field::plain("ts", DataType::Timestamp, false), - Field::plain("value", DataType::Float64, false), - ], - 0, - vec![], - ), - })) - .unwrap() - } - - fn sum_query() -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(vec![]), - measures: vec![AggIntent::Sum { col: None }], - output_names: vec![], - filters: vec![], - having: None, - child: query_root(), - })) - .unwrap() - } - - fn quantile_query() -> Rc { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(vec![]), - measures: vec![AggIntent::Quantile { - col: None, - q: 0.99, - accuracy: AccuracyTarget::Epsilon(0.1), - }], - output_names: vec![], - filters: vec![], - having: None, - child: query_root(), - })) - .unwrap() - } - - /// An exact sum accumulator over the kept pre-ASAP scan. - fn summary() -> Rc { - let child = Rc::new( - query_root() - .as_ref() - .clone() - .with_guarantee(Some(ResultGuarantee::exact("raw"))), - ); - let family = FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); - std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { - child, - family: family.clone(), - input: asap_types::post_asap::SummaryUpdate::column(ColumnRef::Named( - "value".into(), - )), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - filter: None, - }), - Schema::lifted(vec![Field::new("state", family, false)], None), - ) - .with_guarantee(Some(ResultGuarantee::exact("sum"))), - ) - } - - fn nested_summary() -> Rc { - let child = summary(); - let family = FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); - std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { - child, - family: family.clone(), - input: asap_types::post_asap::SummaryUpdate::column(ColumnRef::Named( - "state".into(), - )), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - filter: None, - }), - Schema::lifted(vec![Field::new("state", family, false)], None), - ) - .with_guarantee(Some(ResultGuarantee::exact("nested sum"))), - ) - } - - fn batch(predictability: Predictability) -> BatchEntry { - BatchEntry { - query: Query("sum(m)".into()), - requirements: QueryRequirements::default(), - predictability, - invocations: 1, - execute_at: None, - time_selection: TimeSelection::default(), - } - } - - fn workload( - batches: Vec, - repeating: Vec, - _data: DataWorkload, - ) -> QueryWorkload { - QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: (!batches.is_empty()).then_some(batches), - repeating_queries: (!repeating.is_empty()).then_some(repeating), - } - } - - fn at_rest() -> DataWorkload { - DataWorkload { - arrival: DataArrival::AtRest, - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ..Default::default() - } - } - - fn continuous(observed_at_ms: u64, valid_for_ms: u64) -> DataWorkload { - DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ingestion_rate: Evidence { - value: Some(Rate(1.0)), - source: EvidenceSource::Observed, - observed_at_ms: Some(observed_at_ms), - valid_for_ms: Some(valid_for_ms), - }, - ..Default::default() - } - } - - fn repeating() -> RepeatingEntry { - RepeatingEntry { - query: Query("sum(m)".into()), - demand: RepeatedDemand::FixedInterval(RepetitionInterval(1_000)), - requirements: QueryRequirements::default(), - predictability: Predictability::Predictable { known_at: None }, - time_selection: TimeSelection::default(), - } - } - - fn selected_summary_maintenance_lifecycle( - deployment: &SummaryMaintenanceDeployment, - ) -> Option<&SummaryMaintenanceLifecycle> { - deployment - .summary_maintenance_lifecycle_guarantee - .as_ref() - .map(|guarantee| &guarantee.summary_maintenance_lifecycle) - } - - #[test] - fn fixed_interval_reads_use_the_physical_horizon_multiplicity() { - let mut query = repeating(); - query.demand = RepeatedDemand::FixedInterval(RepetitionInterval(600)); - let workload = workload(vec![], vec![query], at_rest()); - - let facts = - workload_facts(&workload, Some(&at_rest()), &[0], 0, Some(Horizon(1.0))).unwrap(); - - assert_eq!(facts.reads, Some(1.0)); - } - - #[test] - fn unpredictable_one_time_at_rest_selects_ephemeral() { - let plan = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_without_data( - &workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()), - &[0], - ), - 1_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - assert_eq!(plan.deployments.len(), 1); - assert_eq!( - selected_summary_maintenance_lifecycle(&plan.deployments[0]), - Some(&SummaryMaintenanceLifecycle::Ephemeral) - ); - let guarantee = plan.deployments[0] - .summary_maintenance_lifecycle_guarantee - .as_ref() - .unwrap(); - assert_eq!(guarantee.evaluation_schedule, EvaluationSchedule::OneShot); - assert_eq!( - guarantee.summary_maintenance_mode, - SummaryMaintenanceMode::DirectBuild - ); - assert_eq!( - guarantee.output_representation, - OutputRepresentation::SummaryState - ); - assert_eq!( - plan.deployments[0].alternatives[0].total_cost, - Some(Cost(12.0)) - ); - } - - #[test] - fn predictable_scheduled_one_time_offers_prepared_state() { - let mut entry = batch(Predictability::Predictable { - known_at: Some(TimestampMs(1_000)), - }); - entry.execute_at = Some(TimestampMs(11_000)); - let plan = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_with_data( - &workload(vec![entry], vec![], at_rest()), - &at_rest(), - &[0], - ), - 1_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - let prepared = &plan.deployments[0].alternatives[1]; - assert!(prepared.rejection.is_none()); - assert_eq!(prepared.total_cost, Some(Cost(13.0))); - } - - #[test] - fn prepared_state_starts_no_earlier_than_planning_time() { - let mut entry = batch(Predictability::Predictable { - known_at: Some(TimestampMs(1_000)), - }); - entry.execute_at = Some(TimestampMs(11_000)); - let plan = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_with_data( - &workload(vec![entry], vec![], at_rest()), - &at_rest(), - &[0], - ), - 6_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - let prepared = &plan.deployments[0].alternatives[1]; - assert_eq!( - prepared.summary_maintenance_lifecycle, - SummaryMaintenanceLifecycle::Prepared { - activate_at: TimestampMs(6_000), - retire_at: TimestampMs(11_000), - } - ); - assert_eq!(prepared.total_cost, Some(Cost(12.5))); - } - - #[test] - fn expired_one_time_execution_cannot_select_prepared_state() { - let mut entry = batch(Predictability::Predictable { - known_at: Some(TimestampMs(1_000)), - }); - entry.execute_at = Some(TimestampMs(2_000)); - let plan = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_without_data(&workload(vec![entry], vec![], at_rest()), &[0]), - 3_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - assert_eq!( - plan.deployments[0].alternatives[1].rejection, - Some(SummaryMaintenanceLifecycleRejection::RequiresPredictableOneTimeQuery) - ); - } - - #[test] - fn nested_summary_lifecycles_have_compatible_evaluation_schedules() { - let workload = workload(vec![], vec![repeating()], continuous(1_000, 20_000)); - let plan = plan_summary_maintenance_lifecycles( - nested_summary(), - WorkloadDemand::new_without_data(&workload, &[0]), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &IncompatibleNestedCosts, - ) - .unwrap(); - - assert_eq!(plan.deployments.len(), 2); - let schedules: HashSet<_> = plan - .deployments - .iter() - .map(|deployment| { - deployment - .summary_maintenance_lifecycle_guarantee - .as_ref() - .unwrap() - .evaluation_schedule - }) - .collect(); - assert_eq!(schedules.len(), 1); - } - - #[test] - fn repeated_at_rest_selects_shared_without_inventing_updates() { - let plan = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_with_data( - &workload(vec![], vec![repeating()], at_rest()), - &at_rest(), - &[0], - ), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - assert_eq!( - selected_summary_maintenance_lifecycle(&plan.deployments[0]), - Some(&SummaryMaintenanceLifecycle::Shared { - retention: DurationMs(10_000) - }) - ); - assert_eq!( - plan.deployments[0] - .summary_maintenance_lifecycle_guarantee - .as_ref() - .unwrap() - .summary_maintenance_mode, - SummaryMaintenanceMode::DirectBuild - ); - assert_eq!( - plan.deployments[0].alternatives[3].rejection, - Some(SummaryMaintenanceLifecycleRejection::RequiresContinuousData) - ); - assert_eq!(plan.update_rate, None); - } - - #[test] - fn repeated_continuous_workload_can_select_continuous_maintenance() { - let capabilities = SummaryMaintenanceLifecycleCapabilities { - supports_shared: false, - ..SummaryMaintenanceLifecycleCapabilities::ALL - }; - let plan = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_with_data( - &workload(vec![], vec![repeating()], continuous(1_000, 60_000)), - &continuous(1_000, 60_000), - &[0], - ), - 1_000, - Some(Horizon(10.0)), - capabilities, - &UnitCosts, - ) - .unwrap(); - assert_eq!( - selected_summary_maintenance_lifecycle(&plan.deployments[0]), - Some(&SummaryMaintenanceLifecycle::ContinuouslyMaintained) - ); - assert_eq!( - plan.deployments[0] - .summary_maintenance_lifecycle_guarantee - .as_ref() - .unwrap() - .summary_maintenance_mode, - SummaryMaintenanceMode::Incremental - ); - assert_eq!(plan.evaluation_rate, Some(EvaluationRate(1.0))); - assert_eq!(plan.update_rate, Some(UpdateRate(1.0))); - } - - #[test] - fn stale_ingestion_evidence_cannot_enable_continuous_maintenance() { - let plan = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_with_data( - &workload(vec![], vec![repeating()], continuous(1_000, 1_000)), - &continuous(1_000, 1_000), - &[0], - ), - 3_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - assert_eq!( - plan.deployments[0].alternatives[3].rejection, - Some(SummaryMaintenanceLifecycleRejection::MissingOrStaleIngestionRate) - ); - assert_eq!(plan.update_rate, None); - } - - #[test] - fn unknown_costs_do_not_make_a_long_lived_lifecycle_win() { - let plan = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_without_data( - &workload(vec![], vec![repeating()], continuous(1_000, 60_000)), - &[0], - ), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &crate::cost_model::DefaultCostModel, - ) - .unwrap(); - assert_eq!( - selected_summary_maintenance_lifecycle(&plan.deployments[0]), - None - ); - assert!(plan.deployments[0] - .alternatives - .iter() - .all(|alternative| alternative.rejection.is_some())); - } - - #[test] - fn unrelated_workload_entries_do_not_create_reuse_for_a_target() { - let plan = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_without_data( - &workload( - vec![batch(Predictability::AdHoc), batch(Predictability::AdHoc)], - vec![], - at_rest(), - ), - &[0], - ), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - assert_eq!( - selected_summary_maintenance_lifecycle(&plan.deployments[0]), - Some(&SummaryMaintenanceLifecycle::Ephemeral) - ); - assert_eq!( - plan.deployments[0].alternatives[2].rejection, - Some(SummaryMaintenanceLifecycleRejection::RequiresMultipleReads) - ); - } - - #[test] - fn scheduled_rate_counts_only_executions_inside_the_horizon() { - let mut entry = repeating(); - entry.demand = RepeatedDemand::Scheduled(vec![ - TimestampMs(999), - TimestampMs(5_000), - TimestampMs(20_000), - ]); - let plan = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_without_data(&workload(vec![], vec![entry], at_rest()), &[0]), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - assert_eq!(plan.evaluation_rate, Some(EvaluationRate(0.1))); - } - - #[test] - fn demand_binding_rejects_empty_and_duplicate_entries() { - let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); - assert!(matches!( - plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_without_data(&workload, &[]), - 1_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ), - Err(SummaryMaintenanceLifecyclePlanError::EmptyWorkloadDemand) - )); - assert!(matches!( - plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_without_data(&workload, &[0, 0]), - 1_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ), - Err(SummaryMaintenanceLifecyclePlanError::DuplicateWorkloadEntry { index: 0 }) - )); - } - - #[test] - fn prepared_requires_every_bound_consumer_to_be_scheduled_and_predictable() { - let mut predictable = batch(Predictability::Predictable { - known_at: Some(TimestampMs(1_000)), - }); - predictable.execute_at = Some(TimestampMs(2_000)); - let workload = workload( - vec![predictable, batch(Predictability::AdHoc)], - vec![], - at_rest(), - ); - let plan = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_without_data(&workload, &[0, 1]), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - assert_eq!( - plan.deployments[0].alternatives[1].rejection, - Some(SummaryMaintenanceLifecycleRejection::RequiresPredictableOneTimeQuery) - ); - } - - #[test] - fn moving_realtime_maintenance_requires_summary_deletion_support() { - let mut entry = repeating(); - entry.time_selection = TimeSelection { - scope: asap_types::workload::QueryTimeScope::RealTime, - lookback: Some(DurationMs(60_000)), - as_of: None, - }; - let plan = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_with_data( - &workload(vec![], vec![entry], continuous(1_000, 60_000)), - &continuous(1_000, 60_000), - &[0], - ), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &NoDelete, - ) - .unwrap(); - assert_eq!( - plan.deployments[0].alternatives[3].rejection, - Some(SummaryMaintenanceLifecycleRejection::SummaryDoesNotSupportDeletion) - ); - } - - #[test] - fn lifecycle_cost_can_fall_back_to_raw_recomputation() { - let target = sum_query(); - let space = crate::replacement::search_workload(vec![("q", Rc::clone(&target))]).unwrap(); - let selection = space.global_selection(&RawCheaper); - let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); - let plan = assemble_selected_dag_with_summary_maintenance_lifecycles( - &selection, - &space.roots[0].1, - WorkloadDemand::new_without_data(&workload, &[0]), - 1_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &RawCheaper, - ) - .unwrap() - .unwrap(); - assert!(plan.selected_raw_recompute); - assert_eq!(plan.raw_recompute_total_cost, Some(Cost(1.0))); - assert_eq!(plan.summary_total_cost, None); - assert!(plan.deployments.is_empty()); - // The logical query stays exact; deployment assigns execution timing. - assert!(!plan.root.contains_asap()); - assert!(matches!( - plan.root.non_asap(), - Some(NonASAPOp::Aggregate { .. }) - )); - assert!(plan.root.timing.is_none()); - - let exported = - crate::summary_maintenance_dag_export::export_summary_maintenance_plan(&plan); - assert!(exported.selected_raw_recompute); - assert_eq!(exported.raw_recompute_total_cost, Some(1.0)); - assert_eq!(exported.summary_total_cost, None); - assert!(exported.deployments.is_empty()); - } - - #[test] - fn whole_candidate_cost_is_evaluated_before_selecting_a_lifecycle() { - let root = summary(); - let mut deployments = vec![SummaryMaintenanceDeployment { - post_asap_node_id: 0, - summary: Rc::clone(&root), - summary_maintenance_lifecycle_guarantee: None, - selected_window_framework: None, - alternatives: vec![ - SummaryMaintenanceLifecycleAlternative { - summary_maintenance_lifecycle: SummaryMaintenanceLifecycle::Ephemeral, - total_cost: Some(Cost(1.0)), - rejection: None, - assumptions: vec![], - }, - SummaryMaintenanceLifecycleAlternative { - summary_maintenance_lifecycle: - SummaryMaintenanceLifecycle::ContinuouslyMaintained, - total_cost: Some(Cost(10.0)), - rejection: None, - assumptions: vec![], - }, - ], - }]; - - let total = select_complete_lifecycle_combination( - &root, - &mut deployments, - &[0], - DataArrival::ContinuouslyIngesting, - &WholeCandidatePrefersContinuous, - None, - Some(Horizon(10.0)), - Some(2.0), - &[], - ); - - assert_eq!(total.map(|estimate| estimate.cost), Some(Cost(1.0))); - assert!(matches!( - deployments[0] - .summary_maintenance_lifecycle_guarantee - .as_ref() - .unwrap() - .summary_maintenance_lifecycle, - SummaryMaintenanceLifecycle::ContinuouslyMaintained - )); - } - - #[test] - fn complete_lifecycle_enumeration_fails_closed_above_safe_bound() { - let root = summary(); - let alternatives = vec![ - SummaryMaintenanceLifecycleAlternative { - summary_maintenance_lifecycle: SummaryMaintenanceLifecycle::Ephemeral, - total_cost: Some(Cost(1.0)), - rejection: None, - assumptions: vec![], - }, - SummaryMaintenanceLifecycleAlternative { - summary_maintenance_lifecycle: SummaryMaintenanceLifecycle::ContinuouslyMaintained, - total_cost: Some(Cost(2.0)), - rejection: None, - assumptions: vec![], - }, - ]; - let mut deployments: Vec<_> = (0..13) - .map(|summary_index| SummaryMaintenanceDeployment { - post_asap_node_id: (summary_index as usize), - summary: Rc::clone(&root), - summary_maintenance_lifecycle_guarantee: None, - selected_window_framework: None, - alternatives: alternatives.clone(), - }) - .collect(); - assert_eq!( - select_complete_lifecycle_combination( - &root, - &mut deployments, - &(0..13).collect::>(), - DataArrival::ContinuouslyIngesting, - &WholeCandidatePrefersContinuous, - None, - Some(Horizon(10.0)), - Some(2.0), - &[], - ), - None - ); - assert!(deployments - .iter() - .all(|deployment| deployment.summary_maintenance_lifecycle_guarantee.is_none())); - } - - #[test] - fn materialization_falls_back_to_raw_when_raw_cost_is_unavailable() { - let target = quantile_query(); - let space = crate::replacement::search_workload(vec![("q", target)]).unwrap(); - let selection = space.global_selection(&UnitCosts); - let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); - let plan = assemble_selected_dag_with_summary_maintenance_lifecycles( - &selection, - &space.roots[0].1, - WorkloadDemand::new_without_data(&workload, &[0]), - 1_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap() - .unwrap(); - assert!(plan.selected_raw_recompute); - assert!(plan.raw_recompute_total_cost.is_none()); - assert!(!plan.root.contains_asap()); - assert!(matches!( - plan.root.non_asap(), - Some(NonASAPOp::Aggregate { .. }) - )); - } - - #[test] - fn unmatched_target_is_reported_as_raw_recomputation() { - let target = query_root(); - let space = crate::replacement::search_workload(vec![("q", Rc::clone(&target))]).unwrap(); - let selection = space.global_selection(&RawCheaper); - let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); - let plan = assemble_selected_dag_with_summary_maintenance_lifecycles( - &selection, - &space.roots[0].1, - WorkloadDemand::new_without_data(&workload, &[0]), - 1_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &RawCheaper, - ) - .unwrap() - .unwrap(); - - assert!(plan.selected_raw_recompute); - assert_eq!(plan.raw_recompute_total_cost, Some(Cost(1.0))); - assert_eq!(plan.summary_total_cost, None); - assert!(plan.deployments.is_empty()); - assert!(!plan.root.contains_asap()); - assert!(matches!(plan.root.non_asap(), Some(NonASAPOp::Scan { .. }))); - } - - #[test] - fn lifecycle_cost_reorders_semantic_summary_candidates_before_materialization() { - let target = quantile_query(); - let space = crate::replacement::search_workload(vec![("q", target)]).unwrap(); - let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); - - let selection = global_selection_with_summary_maintenance_lifecycles( - &space, - WorkloadDemand::new_with_data(&workload, &at_rest(), &[0]), - 1_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &SummaryMaintenancePrefersDdSketch, - ) - .unwrap(); - let materialized = selection - .assemble_selected_query(&space.roots[0].1) - .unwrap() - .unwrap(); - - assert_eq!( - sketch_algorithm(&materialized), - Some(SketchAlgorithm::DDSketch) - ); - } - - #[test] - fn lifecycle_cost_counts_one_shared_summary_node_once() { - // One shared exact accumulator read twice by the same root: a - // query-time `sum + sum` over one finalized state. (`SummaryMerge` - // is reserved in the unified IR, so the sharing is expressed through - // a relational consumer instead.) - let shared = summary(); - let finalized = Rc::new( - OperatorNode::new(Operator::ASAP(ASAPOp::FinalizeExactAccumulator { - child: Rc::clone(&shared), - })) - .unwrap(), - ); - let root = - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::BinaryOp { - operator: BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), - vector_match: None, - }, - return_bool: false, - lhs: Rc::clone(&finalized), - rhs: finalized, - })) - .unwrap(); - let workload = workload( - vec![batch(Predictability::AdHoc), batch(Predictability::AdHoc)], - vec![], - at_rest(), - ); - let horizon = Some(Horizon(10.0)); - let plan = plan_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &at_rest(), &[0, 1]), - 1_000, - horizon, - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - assert_eq!(plan.deployments.len(), 1); - assert!(matches!( - selected_summary_maintenance_lifecycle(&plan.deployments[0]), - Some(SummaryMaintenanceLifecycle::Shared { .. }) - )); - } - - /// A state costs 10 however often it is read. Recomputing p50 raw costs - /// 1 and p99 costs 8. - struct P50PrefersRaw; - - impl CostModel for P50PrefersRaw { - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn summary_maintenance_lifecycle_cost_inputs( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - SummaryMaintenanceLifecycleCostInputs { - build_cost: Some(Cost(10.0)), - maintenance_cost_per_update: Some(Cost::ZERO), - summary_read_cost: Some(Cost::ZERO), - retention_cost_rate: Some(CostRate(0.0)), - retirement_cost: Some(Cost::ZERO), - } - } - - fn summary_maintenance_capabilities( - &self, - summary: &OperatorNode, - ) -> SummaryMaintenanceCapabilities { - UnitCosts.summary_maintenance_capabilities(summary) - } - - fn raw_query_recompute_total_cost( - &self, - target: &OperatorNode, - _expected_reads: f64, - ) -> Option { - match target.non_asap() { - Some(NonASAPOp::Aggregate { measures, .. }) => match measures[..] { - [AggIntent::Quantile { q: 0.5, .. }] => Some(Cost(1.0)), - _ => Some(Cost(8.0)), - }, - _ => None, - } - } - } - - /// p50 and p99 form a sharing class over one state (5 each), but p50's - /// raw recompute (1) still wins. The class reverts, so p99 is reselected - /// at its independent cost (10) and recomputes raw (8), as it does alone. - /// Checked at selection: the assembled plan's own raw comparison would - /// recompute p99 raw either way. - #[test] - fn sharing_class_reverts_when_a_member_selects_elsewhere() { - let quantile = |q| { - OperatorNode::new_shared(asap_types::ir::Operator::NonASAP(NonASAPOp::Aggregate { - reduction: Reduction::by(vec![]), - measures: vec![AggIntent::Quantile { - col: None, - q, - accuracy: AccuracyTarget::Epsilon(0.1), - }], - // A shared output name keeps p50 and p99 on one state. - output_names: vec!["value".into()], - filters: vec![], - having: None, - child: query_root(), - })) - .unwrap() - }; - let workload = workload(vec![], vec![repeating(), repeating()], at_rest()); - // Whether each root selected a summary rather than raw recompute. - let summaries = |space: &CandidateLogicalASAPDAGs<&str>, entries: &[usize]| { - let selection = global_selection_with_summary_maintenance_lifecycles( - space, - WorkloadDemand::new_with_data(&workload, &at_rest(), entries), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &P50PrefersRaw, - ) - .unwrap(); - space - .roots - .iter() - .map(|(_, target)| selection.for_target(target).unwrap().chosen.is_some()) - .collect::>() - }; - - let space = crate::replacement::search_workload(vec![ - ("p50", quantile(0.5)), - ("p99", quantile(0.99)), - ]) - .unwrap(); - let alone = crate::replacement::search_workload(vec![("p99", quantile(0.99))]).unwrap(); - assert_eq!(summaries(&space, &[0, 1]), vec![false, false]); - assert_eq!(summaries(&alone, &[1]), vec![false]); - } - - #[test] - fn normalized_workload_drives_candidate_logical_asap_dags_recurrence_profiles() { - let root = query_root(); - let space = - crate::replacement::search_workload(vec![("dashboard", Rc::clone(&root))]).unwrap(); - let workload = workload(vec![], vec![repeating()], continuous(1_000, 60_000)); - let profiles = space - .recurrence_profiles_from_workload( - &workload, - Some(&continuous(1_000, 60_000)), - &[0], - 1_000, - Some(Horizon(10.0)), - ) - .unwrap(); - // `search_workload` canonicalizes roots through CSE; recurrence - // profiles are keyed by that canonical post-CSE node. - let profile = profiles.for_target(&space.roots[0].1); - assert_eq!(profile.evaluation_rate, Some(EvaluationRate(1.0))); - assert_eq!(profile.update_rate, Some(UpdateRate(1.0))); - assert_eq!(profile.one_shot_consumers, 0); - } - - #[test] - fn recurrence_binding_is_explicit_when_root_order_differs_from_workload_order() { - let repeating_root = query_root_for("dashboard"); - let batch_root = query_root_for("batch"); - let space = crate::replacement::search_workload(vec![ - ("dashboard", repeating_root), - ("batch", batch_root), - ]) - .unwrap(); - let workload = workload( - vec![batch(Predictability::AdHoc)], - vec![repeating()], - at_rest(), - ); - let profiles = space - .recurrence_profiles_from_workload(&workload, None, &[1, 0], 1_000, Some(Horizon(10.0))) - .unwrap(); - let dashboard = profiles.for_target(&space.roots[0].1); - let batch = profiles.for_target(&space.roots[1].1); - assert_eq!(dashboard.evaluation_rate, Some(EvaluationRate(1.0))); - assert_eq!(dashboard.one_shot_consumers, 0); - assert_eq!(batch.evaluation_rate, None); - assert_eq!(batch.one_shot_consumers, 1); - } - - fn continuous_candidates<'a>( - workload: &QueryWorkload, - data: &DataWorkload, - model: &'a dyn CostModel, - ) -> SummaryMaintenanceLifecycleCandidates<'a> { - enumerate_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_with_data(workload, data, &[0]), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities { - supports_shared: false, - ..SummaryMaintenanceLifecycleCapabilities::ALL - }, - model, - ) - .unwrap() - } - - fn choose( - candidates: &SummaryMaintenanceLifecycleCandidates<'_>, - lifecycle: SummaryMaintenanceLifecycle, - ) -> Vec<(PhysicalASAPNodeId, SummaryMaintenanceLifecycle)> { - candidates - .deployments() - .iter() - .map(|deployment| (deployment.post_asap_node_id, lifecycle.clone())) - .collect() - } - - // Enumeration reports all four lifecycle kinds with their rejections and - // selects nothing. - #[test] - fn enumeration_exposes_every_lifecycle_without_selecting() { - let data = continuous(1_000, 60_000); - let workload = workload(vec![], vec![repeating()], data.clone()); - let candidates = continuous_candidates(&workload, &data, &UnitCosts); - let [deployment] = candidates.deployments() else { - panic!("one summary state"); - }; - assert_eq!(deployment.summary_maintenance_lifecycle_guarantee, None); - assert_eq!(deployment.selected_window_framework, None); - let outcome: Vec<_> = deployment - .alternatives - .iter() - .map(|alternative| { - ( - &alternative.summary_maintenance_lifecycle, - alternative.rejection.clone(), - alternative.total_cost.is_some(), - ) - }) - .collect(); - assert!(matches!( - outcome.as_slice(), - [ - (SummaryMaintenanceLifecycle::Ephemeral, None, true), - ( - SummaryMaintenanceLifecycle::Prepared { .. }, - Some(SummaryMaintenanceLifecycleRejection::RequiresPredictableOneTimeQuery), - false - ), - ( - SummaryMaintenanceLifecycle::Shared { .. }, - Some(SummaryMaintenanceLifecycleRejection::UnsupportedByRuntime), - false - ), - ( - SummaryMaintenanceLifecycle::ContinuouslyMaintained, - None, - true - ), - ] - )); - let guarantee = candidates.guarantee(&SummaryMaintenanceLifecycle::ContinuouslyMaintained); - assert_eq!( - guarantee.summary_maintenance_mode, - SummaryMaintenanceMode::Incremental - ); - assert_eq!(guarantee.evaluation_schedule, EvaluationSchedule::PerUpdate); - } - - // Explicitly choosing Planner's own selection reproduces Planner's plan. - #[test] - fn explicit_choice_of_planner_selection_reproduces_planner_plan() { - let data = continuous(1_000, 60_000); - let workload = workload(vec![], vec![repeating()], data.clone()); - let planned = plan_summary_maintenance_lifecycles( - summary(), - WorkloadDemand::new_with_data(&workload, &data, &[0]), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities { - supports_shared: false, - ..SummaryMaintenanceLifecycleCapabilities::ALL - }, - &UnitCosts, - ) - .unwrap(); - let candidates = continuous_candidates(&workload, &data, &UnitCosts); - let choice = choose( - &candidates, - SummaryMaintenanceLifecycle::ContinuouslyMaintained, - ); - let chosen = candidates.select(&choice).unwrap(); - assert_eq!(format!("{chosen:?}"), format!("{planned:?}")); - } - - // A deployment may bind a legal alternative Planner's estimate does not - // prefer; the plan carries that alternative's guarantee and cost. - #[test] - fn explicit_choice_may_bind_a_costlier_legal_alternative() { - let data = continuous(1_000, 60_000); - let workload = workload(vec![], vec![repeating()], data.clone()); - let candidates = continuous_candidates(&workload, &data, &UnitCosts); - let ephemeral_cost = candidates.deployments()[0].alternatives[0].total_cost; - let choice = choose(&candidates, SummaryMaintenanceLifecycle::Ephemeral); - let plan = candidates.select(&choice).unwrap(); - assert_eq!( - selected_summary_maintenance_lifecycle(&plan.deployments[0]), - Some(&SummaryMaintenanceLifecycle::Ephemeral) - ); - assert_eq!(plan.summary_total_cost, ephemeral_cost); - } - - // Choices that Planner could not select, or that do not cover exactly the - // enumerated states, are refused rather than bound. - #[test] - fn explicit_choice_rejects_illegal_or_incomplete_choices() { - use SummaryMaintenanceLifecycleChoiceError as E; - let data = continuous(1_000, 60_000); - let workload = workload(vec![], vec![repeating()], data.clone()); - let select = |model: &dyn CostModel, choice: &dyn Fn(PhysicalASAPNodeId) -> Vec<_>| { - let candidates = continuous_candidates(&workload, &data, model); - let id = candidates.deployments()[0].post_asap_node_id; - (id, candidates.select(&choice(id)).unwrap_err()) - }; - let shared = SummaryMaintenanceLifecycle::Shared { - retention: DurationMs(10_000), - }; - let (id, error) = select(&UnitCosts, &|id| vec![(id, shared.clone())]); - assert_eq!( - error, - E::Rejected { - post_asap_node_id: id, - rejection: Some(SummaryMaintenanceLifecycleRejection::UnsupportedByRuntime), - } - ); - let continuous = SummaryMaintenanceLifecycle::ContinuouslyMaintained; - let (id, error) = select(&crate::cost_model::DefaultCostModel, &|id| { - vec![(id, SummaryMaintenanceLifecycle::Ephemeral)] - }); - assert_eq!( - error, - E::Rejected { - post_asap_node_id: id, - rejection: Some(SummaryMaintenanceLifecycleRejection::MissingCostEvidence), - } - ); - let (id, error) = select(&UnitCosts, &|id| { - vec![( - id, - SummaryMaintenanceLifecycle::Shared { - retention: DurationMs(1), - }, - )] - }); - assert_eq!(error, E::NotAnAlternative(id)); - let (id, error) = select(&UnitCosts, &|_| vec![]); - assert_eq!(error, E::MissingChoice(id)); - let (id, error) = select(&UnitCosts, &|id| { - vec![(id, continuous.clone()), (id, continuous.clone())] - }); - assert_eq!(error, E::DuplicateChoice(id)); - let (_, error) = select(&UnitCosts, &|_| vec![(usize::MAX, continuous.clone())]); - assert_eq!(error, E::UnknownSummary(usize::MAX)); - } - - // Nested states on one maintenance path must share an evaluation schedule. - #[test] - fn explicit_choice_rejects_incompatible_nested_schedules() { - let workload = workload(vec![], vec![repeating()], continuous(1_000, 20_000)); - let candidates = enumerate_summary_maintenance_lifecycles( - nested_summary(), - WorkloadDemand::new_with_data(&workload, &continuous(1_000, 20_000), &[0]), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &IncompatibleNestedCosts, - ) - .unwrap(); - let [outer, inner] = candidates.deployments() else { - panic!("two summary states"); - }; - let choice = vec![ - ( - outer.post_asap_node_id, - SummaryMaintenanceLifecycle::Ephemeral, - ), - ( - inner.post_asap_node_id, - SummaryMaintenanceLifecycle::ContinuouslyMaintained, - ), - ]; - assert_eq!( - candidates.select(&choice).unwrap_err(), - SummaryMaintenanceLifecycleChoiceError::IncompatibleEvaluationSchedules - ); - } - - // A multi-summary root yields one candidate entry per unique state, with - // a shared `Rc` state listed once. - #[test] - fn enumeration_lists_each_unique_summary_state_once() { - let shared = summary(); - let root = test_binary( - test_binary(evaluation(&shared), evaluation(&shared)), - evaluation(&summary()), - ); - let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); - let candidates = enumerate_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(&workload, &at_rest(), &[0]), - 1_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - let ids: HashSet<_> = candidates - .deployments() - .iter() - .map(|deployment| deployment.post_asap_node_id) - .collect(); - assert_eq!(candidates.deployments().len(), 2); - assert_eq!(ids.len(), 2); - assert!(candidates - .deployments() - .iter() - .any(|deployment| Rc::ptr_eq(&deployment.summary, &shared))); - } - - fn evaluation(state: &Rc) -> Rc { - std::rc::Rc::new( - OperatorNode::with_schema( - asap_types::ir::Operator::ASAP(ASAPOp::FinalizeExactAccumulator { - child: Rc::clone(state), - }), - Schema::lifted( - vec![Field::new( - "value", - FieldDataType::Plain(DataType::Float64), - false, - )], - None, - ), - ) - .with_guarantee(Some(ResultGuarantee::exact("sum"))), - ) - } - - fn test_binary(lhs: Rc, rhs: Rc) -> Rc { - let schema = lhs.schema.clone(); - Rc::new(OperatorNode::with_schema( - Operator::NonASAP(NonASAPOp::BinaryOp { - lhs, - rhs, - return_bool: false, - operator: BinaryOperator { - kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), - vector_match: None, - checked_relative_division: false, - checked_finite_division: false, - }, - }), - schema, - )) - } - - fn lifecycle_matching( - alternatives: &[SummaryMaintenanceLifecycleAlternative], - kind: fn(&SummaryMaintenanceLifecycle) -> bool, - ) -> SummaryMaintenanceLifecycle { - alternatives - .iter() - .map(|alternative| &alternative.summary_maintenance_lifecycle) - .find(|lifecycle| kind(lifecycle)) - .expect("lifecycle kind is an alternative") - .clone() - } - - /// Bind the lifecycle `choose` picks for every state of `root`, then - /// derive the timed DAG. - fn timed_dag( - root: Rc, - workload: &QueryWorkload, - data: &DataWorkload, - horizon: Option, - choose: impl Fn(&SummaryMaintenanceDeployment) -> SummaryMaintenanceLifecycle, - ) -> PhysicalASAPDAG { - let candidates = enumerate_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data(workload, data, &[0]), - 1_000, - horizon, - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - let choice: Vec<_> = candidates - .deployments() - .iter() - .map(|deployment| (deployment.post_asap_node_id, choose(deployment))) - .collect(); - let dag = candidates - .select(&choice) - .unwrap() - .execution_timed_dag() - .unwrap(); - dag.validate().unwrap(); - dag - } - - /// Operator kinds in node-id order, each paired with its timing. - fn timings(dag: &PhysicalASAPDAG) -> Vec<(&'static str, ExecutionTiming)> { - dag.nodes - .iter() - .map(|node| { - let kind = match node.payload { - PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::BinaryOp { .. }) => "binary", - PhysicalASAPOperatorPayload::NonASAP(_) => "raw", - PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { .. }) => "state", - PhysicalASAPOperatorPayload::ASAP(ASAPOp::FinalizeExactAccumulator { - .. - }) - | PhysicalASAPOperatorPayload::ASAP(ASAPOp::EvaluatePopulation { .. }) => { - "evaluation" - } - - _ => "other", - }; - (kind, node.output_state.timing) - }) - .collect() - } - - const INGEST: ExecutionTiming = ExecutionTiming::IngestionTime; - const QUERY: ExecutionTiming = ExecutionTiming::QueryTime; - - // Every retained lifecycle kind runs its state and inputs at ingestion - // time and its evaluation at query time. - #[test] - fn retained_lifecycles_time_state_and_inputs_at_ingestion() { - let mut scheduled = batch(Predictability::Predictable { - known_at: Some(TimestampMs(1_000)), - }); - scheduled.execute_at = Some(TimestampMs(11_000)); - type Case = ( - QueryWorkload, - DataWorkload, - Option, - fn(&SummaryMaintenanceLifecycle) -> bool, - ); - let cases: [Case; 3] = [ - ( - workload(vec![], vec![repeating()], continuous(1_000, 60_000)), - continuous(1_000, 60_000), - Some(Horizon(10.0)), - |lifecycle| { - matches!( - lifecycle, - SummaryMaintenanceLifecycle::ContinuouslyMaintained - ) - }, - ), - ( - workload(vec![], vec![repeating()], at_rest()), - at_rest(), - Some(Horizon(10.0)), - |lifecycle| matches!(lifecycle, SummaryMaintenanceLifecycle::Shared { .. }), - ), - ( - workload(vec![scheduled], vec![], at_rest()), - at_rest(), - None, - |lifecycle| matches!(lifecycle, SummaryMaintenanceLifecycle::Prepared { .. }), - ), - ]; - for (workload, data, horizon, kind) in cases { - let dag = timed_dag( - evaluation(&summary()), - &workload, - &data, - horizon, - |deployment| lifecycle_matching(&deployment.alternatives, kind), - ); - assert_eq!( - timings(&dag), - [("raw", INGEST), ("state", INGEST), ("evaluation", QUERY)] - ); - } - } - - // An Ephemeral state, its raw input, and its evaluation all run at query time. - #[test] - fn ephemeral_lifecycle_times_state_and_downstream_at_query() { - let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); - let dag = timed_dag(evaluation(&summary()), &workload, &at_rest(), None, |_| { - SummaryMaintenanceLifecycle::Ephemeral - }); - assert_eq!( - timings(&dag), - [("raw", QUERY), ("state", QUERY), ("evaluation", QUERY)] - ); - } - - // One state read by two consumers is one deployment; its timing follows - // that single choice while both consumers run at query time. - #[test] - fn shared_state_is_timed_once_for_all_consumers() { - let state = summary(); - let lhs = evaluation(&state); - let rhs = Rc::new(lhs.as_ref().clone()); - let root = test_binary(lhs, rhs); - let data = continuous(1_000, 60_000); - let workload = workload(vec![], vec![repeating()], data.clone()); - let dag = timed_dag(root, &workload, &data, Some(Horizon(10.0)), |deployment| { - assert!(Rc::ptr_eq(&deployment.summary, &state)); - SummaryMaintenanceLifecycle::ContinuouslyMaintained - }); - assert_eq!( - timings(&dag), - [ - ("raw", INGEST), - ("state", INGEST), - ("evaluation", QUERY), - ("evaluation", QUERY), - ("binary", QUERY), - ] - ); - } - - // An Ephemeral state consumed by retained state is built on the retained - // state's ingestion path; it is not retained, but cannot run at query time. - #[test] - fn ephemeral_state_feeding_retained_state_runs_at_ingestion() { - let mut scheduled = batch(Predictability::Predictable { - known_at: Some(TimestampMs(1_000)), - }); - scheduled.execute_at = Some(TimestampMs(11_000)); - let root = nested_summary(); - let workload = workload(vec![scheduled], vec![], at_rest()); - let dag = timed_dag( - Rc::clone(&root), - &workload, - &at_rest(), - None, - |deployment| { - if Rc::ptr_eq(&deployment.summary, &root) { - lifecycle_matching(&deployment.alternatives, |lifecycle| { - matches!(lifecycle, SummaryMaintenanceLifecycle::Prepared { .. }) - }) - } else { - SummaryMaintenanceLifecycle::Ephemeral - } - }, - ); - assert_eq!( - timings(&dag), - [("raw", INGEST), ("state", INGEST), ("state", INGEST)] - ); - } - - // Timing is not derived for a state without a selected lifecycle, and a - // raw-recompute plan runs entirely at query time. - #[test] - fn timing_requires_a_selected_lifecycle_for_every_state() { - let workload = workload(vec![batch(Predictability::AdHoc)], vec![], at_rest()); - let data = at_rest(); - let demand = WorkloadDemand::new_with_data(&workload, &data, &[0]); - let plan = plan_summary_maintenance_lifecycles( - evaluation(&summary()), - demand, - 1_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &crate::cost_model::DefaultCostModel, - ) - .unwrap(); - assert_eq!( - plan.execution_timed_dag().unwrap_err(), - SummaryMaintenanceTimingError::UnselectedLifecycle( - plan.deployments[0].post_asap_node_id - ) - ); - let raw = plan_summary_maintenance_lifecycles( - crate::replacement::retain_exact(&sum_query()).unwrap(), - demand, - 1_000, - None, - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - assert_eq!( - timings(&raw.execution_timed_dag().unwrap()), - [("raw", QUERY), ("raw", QUERY)] - ); - } - - /// A strategy-built `sum(a)` over one maintained current-series population. - fn population_evaluation() -> Rc { - let target = crate::test_support::lower_promql("sum(a)", AccuracyTarget::Exact); - crate::maintained_population::MaintainedPopulationStrategy::new(std::slice::from_ref( - &target, - )) - .candidate(&target) - .unwrap() - } - - fn is_population(node: &OperatorNode) -> bool { - matches!( - node.operator, - Operator::ASAP(ASAPOp::MaintainPopulation { .. }) - ) - } - - fn population_timings(dag: &PhysicalASAPDAG) -> Vec<(&'static str, ExecutionTiming)> { - dag.nodes - .iter() - .zip(timings(dag)) - .map(|(node, (kind, timing))| match node.payload { - PhysicalASAPOperatorPayload::ASAP(ASAPOp::MaintainPopulation { .. }) => { - ("population", timing) - } - _ => (kind, timing), - }) - .collect() - } - - // A maintained population is enumerated as retained state, with costs - // from the caller's model for both the maintained and the rebuilt choice. - #[test] - fn enumeration_includes_maintained_population() { - let data = continuous(1_000, 60_000); - let workload = workload(vec![], vec![repeating()], data.clone()); - let candidates = enumerate_summary_maintenance_lifecycles( - population_evaluation(), - WorkloadDemand::new_with_data(&workload, &data, &[0]), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - let [deployment] = candidates.deployments() else { - panic!("one population state"); - }; - assert!(is_population(&deployment.summary)); - let cost = |lifecycle: SummaryMaintenanceLifecycle| { - deployment - .alternatives - .iter() - .find(|alternative| alternative.summary_maintenance_lifecycle == lifecycle) - .and_then(|alternative| alternative.total_cost) - }; - // Ephemeral: (build 10 + read 1 + retire 1) x 10 reads. Maintained over - // 10 s at 1 update/s: build 10 + updates 10 + reads 10 + retention 1 + retire 1. - assert_eq!( - cost(SummaryMaintenanceLifecycle::Ephemeral), - Some(Cost(120.0)) - ); - assert_eq!( - cost(SummaryMaintenanceLifecycle::ContinuouslyMaintained), - Some(Cost(32.0)) - ); - } - - // Without cost evidence a population's alternatives stay unknown: Planner - // selects none and timing is refused rather than guessed. - #[test] - fn population_without_cost_evidence_stays_unselected() { - let data = continuous(1_000, 60_000); - let workload = workload(vec![], vec![repeating()], data.clone()); - let plan = plan_summary_maintenance_lifecycles( - population_evaluation(), - WorkloadDemand::new_with_data(&workload, &data, &[0]), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &crate::cost_model::DefaultCostModel, - ) - .unwrap(); - let [deployment] = plan.deployments.as_slice() else { - panic!("one population state"); - }; - assert!(deployment - .alternatives - .iter() - .all(|alternative| alternative.total_cost.is_none())); - assert!(deployment.summary_maintenance_lifecycle_guarantee.is_none()); - assert_eq!( - plan.execution_timed_dag().unwrap_err(), - SummaryMaintenanceTimingError::UnselectedLifecycle(deployment.post_asap_node_id) - ); - } - - // A retained population and its raw input run at ingestion time; an - // Ephemeral population is rebuilt from raw input at query time. - #[test] - fn population_lifecycle_choice_decides_its_timing() { - let data = continuous(1_000, 60_000); - let workload = workload(vec![], vec![repeating()], data.clone()); - let timed = |lifecycle: SummaryMaintenanceLifecycle| { - population_timings(&timed_dag( - population_evaluation(), - &workload, - &data, - Some(Horizon(10.0)), - |_| lifecycle.clone(), - )) - }; - assert_eq!( - timed(SummaryMaintenanceLifecycle::ContinuouslyMaintained), - [ - ("raw", INGEST), - ("raw", INGEST), - ("population", INGEST), - ("evaluation", QUERY) - ] - ); - assert_eq!( - timed(SummaryMaintenanceLifecycle::Ephemeral), - [ - ("raw", QUERY), - ("raw", QUERY), - ("population", QUERY), - ("evaluation", QUERY) - ] - ); - } - - // When retaining is cheaper, Planner's own selection keeps the population - // maintained at ingestion time, as realization strategies placed it before - // population timing became a lifecycle decision. - #[test] - fn planner_selection_retains_population_at_ingestion() { - let data = continuous(1_000, 60_000); - let workload = workload(vec![], vec![repeating()], data.clone()); - let plan = plan_summary_maintenance_lifecycles( - population_evaluation(), - WorkloadDemand::new_with_data(&workload, &data, &[0]), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - // Shared and ContinuouslyMaintained tie at 32; the first wins. - assert!(matches!( - selected_summary_maintenance_lifecycle(&plan.deployments[0]), - Some(SummaryMaintenanceLifecycle::Shared { .. }) - )); - assert_eq!( - population_timings(&plan.execution_timed_dag().unwrap()), - [ - ("raw", INGEST), - ("raw", INGEST), - ("population", INGEST), - ("evaluation", QUERY) - ] - ); - } - - // A population feeding summary state is that state's input, not a separate - // deployment: the state's lifecycle times it. - #[test] - fn population_feeding_summary_state_follows_that_state() { - let Operator::ASAP(ASAPOp::EvaluatePopulation { - child: population, .. - }) = &population_evaluation().operator - else { - unreachable!() - }; - let state = summary(); - let Operator::ASAP(ASAPOp::SummaryAgg { - family, - input, - reduction, - grouping, - .. - }) = &state.operator - else { - unreachable!() - }; - let mut copy = state.as_ref().clone(); - copy.operator = Operator::ASAP(ASAPOp::SummaryAgg { - child: Rc::clone(population), - family: family.clone(), - input: input.clone(), - reduction: reduction.clone(), - grouping: grouping.clone(), - filter: None, - }); - let state = Rc::new(copy); - let data = continuous(1_000, 60_000); - let workload = workload(vec![], vec![repeating()], data.clone()); - let timed = |lifecycle: SummaryMaintenanceLifecycle| { - population_timings(&timed_dag( - evaluation(&state), - &workload, - &data, - Some(Horizon(10.0)), - |deployment| { - assert!(Rc::ptr_eq(&deployment.summary, &state)); - lifecycle.clone() - }, - )) - }; - assert_eq!( - timed(SummaryMaintenanceLifecycle::ContinuouslyMaintained), - [ - ("raw", INGEST), - ("raw", INGEST), - ("population", INGEST), - ("state", INGEST), - ("evaluation", QUERY) - ] - ); - assert_eq!( - timed(SummaryMaintenanceLifecycle::Ephemeral), - [ - ("raw", QUERY), - ("raw", QUERY), - ("population", QUERY), - ("state", QUERY), - ("evaluation", QUERY) - ] - ); - } - - // A population both read directly and consumed by summary state is that - // state's input in either traversal order: not a separate deployment, and - // timed by the state's lifecycle. - #[test] - fn shared_population_follows_its_summary_consumer() { - let direct = population_evaluation(); - let Operator::ASAP(ASAPOp::EvaluatePopulation { - child: population, .. - }) = &direct.operator - else { - unreachable!() - }; - let state = summary(); - let Operator::ASAP(ASAPOp::SummaryAgg { - family, - input, - reduction, - grouping, - .. - }) = &state.operator - else { - unreachable!() - }; - let mut copy = state.as_ref().clone(); - copy.operator = Operator::ASAP(ASAPOp::SummaryAgg { - child: Rc::clone(population), - family: family.clone(), - input: input.clone(), - reduction: reduction.clone(), - grouping: grouping.clone(), - filter: None, - }); - let state = Rc::new(copy); - let binary = test_binary; - let data = continuous(1_000, 60_000); - let workload = workload(vec![], vec![repeating()], data.clone()); - for root in [ - binary(Rc::clone(&direct), evaluation(&state)), - binary(evaluation(&state), Rc::clone(&direct)), - ] { - for lifecycle in [ - SummaryMaintenanceLifecycle::ContinuouslyMaintained, - SummaryMaintenanceLifecycle::Ephemeral, - ] { - let dag = timed_dag( - Rc::clone(&root), - &workload, - &data, - Some(Horizon(10.0)), - |deployment| { - assert!(Rc::ptr_eq(&deployment.summary, &state)); - lifecycle.clone() - }, - ); - let expected = if lifecycle == SummaryMaintenanceLifecycle::Ephemeral { - QUERY - } else { - INGEST - }; - for (kind, timing) in population_timings(&dag) { - if matches!(kind, "raw" | "population" | "state") { - assert_eq!(timing, expected, "{kind}"); - } else { - assert_eq!(timing, QUERY, "{kind}"); - } - } - } - } - } - - // A plan whose population deployment was removed after enumeration is - // refused rather than timed by a guess. - #[test] - fn timing_refuses_population_without_deployment() { - let data = continuous(1_000, 60_000); - let workload = workload(vec![], vec![repeating()], data.clone()); - let mut plan = plan_summary_maintenance_lifecycles( - population_evaluation(), - WorkloadDemand::new_with_data(&workload, &data, &[0]), - 1_000, - Some(Horizon(10.0)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &UnitCosts, - ) - .unwrap(); - let id = plan.deployments.remove(0).post_asap_node_id; - assert_eq!( - plan.execution_timed_dag(), - Err(SummaryMaintenanceTimingError::UnplannedMaintainedState(id)) - ); - } -} diff --git a/crates/asap-physical-operators/tests/weighted_topk_binding.rs b/crates/asap-physical-operators/tests/weighted_topk_binding.rs index faa9ab6e4..7a5bdbe00 100644 --- a/crates/asap-physical-operators/tests/weighted_topk_binding.rs +++ b/crates/asap-physical-operators/tests/weighted_topk_binding.rs @@ -813,100 +813,59 @@ fn spatial_topk_requires_the_limit_and_sort_partitions_to_agree() { assert!(candidates(&with_limit_partition(&global, by_job.clone())).is_empty()); } -/// Deployment-side lifecycle choice: every summary state of `candidate` is -/// continuously maintained, and the chosen lifecycles set execution timing. +/// Every summary state of `candidate` maintained at ingestion time, the +/// materialization a deployment would assign for a continuously served query: +/// each `SummaryAgg` and every input it consumes run at ingestion time, the +/// rest at query time. The phases are assigned on the exported DAG because +/// the candidate pins its finalize boundary to query time. fn continuously_maintained_dag(candidate: &Rc) -> PhysicalASAPDAG { - use asap_aware_mapping::{ - cost_model::{Cost, CostModel}, - enumerate_summary_maintenance_lifecycles, CostRate, Horizon, - SummaryMaintenanceCapabilities, SummaryMaintenanceLifecycleCapabilities, - SummaryMaintenanceLifecycleCostInputs, WorkloadDemand, - }; - use planner_types::workload::{ - DataArrival, Rate, RepeatedDemand, RepeatingEntry, RepetitionInterval, - }; - struct Costed; - impl CostModel for Costed { - fn rank_candidates( - &self, - _: &planner_types::pre_asap::agg_intent::AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - fn summary_maintenance_lifecycle_cost_inputs( - &self, - _: &planner_types::ir::OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - SummaryMaintenanceLifecycleCostInputs { - build_cost: Some(Cost(10.)), - maintenance_cost_per_update: Some(Cost(1.)), - summary_read_cost: Some(Cost(1.)), - retention_cost_rate: Some(CostRate(0.1)), - retirement_cost: Some(Cost(1.)), - } - } - fn summary_maintenance_capabilities( - &self, - _: &planner_types::ir::OperatorNode, - ) -> SummaryMaintenanceCapabilities { - SummaryMaintenanceCapabilities { - incremental_update: true, - merge: true, - delete: true, - } - } - } - const NOW_MS: u64 = 1_000_000; - let queries = QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: None, - repeating_queries: Some(vec![RepeatingEntry { - query: Query("topk by(job)(2, rate(m[1m]))".into()), - demand: RepeatedDemand::FixedInterval(RepetitionInterval(60_000)), - requirements: QueryRequirements::default(), - predictability: Predictability::Predictable { known_at: None }, - time_selection: TimeSelection::default(), - }]), - }; - let data = DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - ingestion_rate: WorkloadEvidence { - value: Some(Rate(1.)), - source: planner_types::workload::EvidenceSource::Observed, - observed_at_ms: Some(NOW_MS), - valid_for_ms: Some(60_000), - }, - ..Default::default() - }; - let lifecycles = enumerate_summary_maintenance_lifecycles( - Rc::clone(candidate), - WorkloadDemand::new_with_data(&queries, &data, &[0]), - NOW_MS, - Some(Horizon(100.)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &Costed, + use planner_types::ir::{apply_lifecycle_timings, LifecycleAssignment, TimingMemo}; + let timed = apply_lifecycle_timings( + candidate, + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), ) .unwrap(); - let choices = lifecycles - .deployments() + let dag = planner_types::ir::physical_export::compile_physical_asap_dag(&timed).unwrap(); + let mut pending: Vec<_> = dag + .nodes .iter() - .map(|deployment| { - ( - deployment.post_asap_node_id, - SummaryMaintenanceLifecycle::ContinuouslyMaintained, + .filter(|node| { + matches!( + node.payload, + PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { .. }) ) }) - .collect::>(); - lifecycles - .select(&choices) - .unwrap() - .execution_timed_dag() - .unwrap() + .map(|node| node.id) + .collect(); + let mut ingestion = std::collections::HashSet::new(); + while let Some(id) = pending.pop() { + if ingestion.insert(id) { + pending.extend( + dag.edges + .iter() + .filter(|edge| edge.consumer == id) + .map(|edge| edge.producer), + ); + } + } + let phases = dag + .nodes + .iter() + .map(|node| { + let timing = if ingestion.contains(&node.id) { + ExecutionTiming::IngestionTime + } else { + ExecutionTiming::QueryTime + }; + (node.id, timing) + }) + .collect(); + dag.with_execution_phases(&phases).unwrap() } // A maintained heap over finalized per-series Rate is the fixed-window -// placement: lifecycle timing, not a separate candidate, puts it in precompute. +// placement: materialization timing, not a separate candidate, puts it in precompute. #[test] fn maintained_rate_heap_lifecycle_compiles_fixed_window_precompute() { use asap_physical_operators::physical_planner::{ diff --git a/crates/integration-tests/tests/operator_design_examples.rs b/crates/integration-tests/tests/operator_design_examples.rs index c5f0c66ee..dcd51088d 100644 --- a/crates/integration-tests/tests/operator_design_examples.rs +++ b/crates/integration-tests/tests/operator_design_examples.rs @@ -258,46 +258,15 @@ async fn sql_window_and_filtered_aggregate_types() { /// and executes both selected plans. No replacement dag is constructed by the test. #[tokio::test] async fn batch_planning_replaces_and_shares_summary_operators() { - use asap_aware_mapping::cost_model::{Cost, DefaultCostModel}; use asap_aware_mapping::pass::PlanningModels; - use asap_aware_mapping::{ - CostModel, CostRate, LifecycleInput, SummaryMaintenanceLifecycleCapabilities, - SummaryMaintenanceLifecycleCostInputs, - }; use asap_physical_operators::{ physical_planner::{compile, InputContract}, runtime::Scope, values::{Batch, Value}, }; use asap_planner::{e2e_plan, FrontendInput, UserInput}; - use asap_types::post_asap::SketchAlgorithm; use asap_types::workload::*; use std::{collections::BTreeMap, sync::Arc}; - struct Costs; - impl CostModel for Costs { - fn rank_candidates( - &self, - intent: &AggIntent, - candidates: &[SketchAlgorithm], - ) -> Vec { - DefaultCostModel.rank_candidates(intent, candidates) - } - fn summary_maintenance_lifecycle_cost_inputs( - &self, - _: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - SummaryMaintenanceLifecycleCostInputs { - build_cost: Some(Cost(1.0)), - maintenance_cost_per_update: Some(Cost::ZERO), - summary_read_cost: Some(Cost::ZERO), - retention_cost_rate: Some(CostRate(0.0)), - retirement_cost: Some(Cost::ZERO), - } - } - fn raw_query_recompute_cost(&self, _: &OperatorNode) -> Option { - Some(Cost(1000.0)) - } - } let queries = [ "SELECT SUM(bytes) + 1 AS result FROM requests", "SELECT SUM(bytes) * 2 AS result FROM requests", @@ -335,8 +304,7 @@ async fn batch_planning_replaces_and_shares_summary_operators() { let output = e2e_plan(UserInput::new( &workload, FrontendInput::Sql { catalog: &catalog }, - PlanningModels::builtin().with_cost(&Costs), - LifecycleInput::new(0, SummaryMaintenanceLifecycleCapabilities::default()), + PlanningModels::builtin(), )) .await .unwrap(); @@ -349,8 +317,7 @@ async fn batch_planning_replaces_and_shares_summary_operators() { .collect(); assert_eq!(states.len(), 1, "the batch owns one shared SUM state"); for (plan, expected) in output.plans.iter().zip([31.0, 60.0]) { - assert!(!plan.plan.selected_raw_recompute); - let root = &plan.plan.root; + let root = &plan.root; root.validate_structure().unwrap(); assert!(OperatorNode::reachable(root) .iter() diff --git a/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs b/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs deleted file mode 100644 index b297ed518..000000000 --- a/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs +++ /dev/null @@ -1,1155 +0,0 @@ -//! End-to-end coverage for workload-aware summary-maintenance planning: -//! source workload -> PromQL lowering -> candidate search -> -//! summary-maintenance lifecycle selection -> materialized deployment guarantees. - -use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; -use asap_types::ir::ASAPOp; -use physical_common::compile_physical_asap_dag; -use std::rc::Rc; - -use asap_aware_mapping::cost_model::Cost; -use asap_aware_mapping::CostRate; -use asap_aware_mapping::{ - assemble_selected_dag_with_summary_maintenance_lifecycles, export_summary_maintenance_plan, - global_selection_with_summary_maintenance_lifecycles, search_workload_with, CostModel, Horizon, - SummaryMaintenanceCapabilities, SummaryMaintenanceLifecycleCapabilities, - SummaryMaintenanceLifecycleCostInputs, SummaryMaintenanceLifecycleRejection, WorkloadDemand, -}; -use asap_frontend_promql::lower_promql_workload; -use asap_types::ir::OperatorNode; -use asap_types::post_asap::{ - EvaluationSchedule, SummaryMaintenanceLifecycle, SummaryMaintenanceMode, -}; -use asap_types::pre_asap::agg_intent::AggIntent; -use asap_types::types::AccuracyTarget; -use asap_types::workload::{ - AccuracyRequirement, BatchEntry, DataArrival, DataWorkload, DurationMs, Evidence, - EvidenceSource, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, - QueryTimeScope, QueryWorkload, Rate, RepeatedDemand, RepeatingEntry, RepetitionInterval, - TimeSelection, -}; - -const NOW_MS: u64 = 1_000_000; - -struct FullyCostedRuntime; - -impl CostModel for FullyCostedRuntime { - fn raw_query_recompute_total_cost( - &self, - _target: &OperatorNode, - _expected_reads: f64, - ) -> Option { - Some(Cost(1_000.0)) - } - - fn rank_candidates( - &self, - _intent: &AggIntent, - candidates: &[asap_types::post_asap::SketchAlgorithm], - ) -> Vec { - candidates.to_vec() - } - - fn summary_maintenance_lifecycle_cost_inputs( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - SummaryMaintenanceLifecycleCostInputs { - build_cost: Some(Cost(10.0)), - maintenance_cost_per_update: Some(Cost(1.0)), - summary_read_cost: Some(Cost(1.0)), - retention_cost_rate: Some(CostRate(0.1)), - retirement_cost: Some(Cost(1.0)), - } - } - - fn summary_maintenance_capabilities( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceCapabilities { - SummaryMaintenanceCapabilities { - incremental_update: true, - merge: true, - delete: true, - } - } -} - -fn dashboard_workload() -> PlanningWorkload { - let query = Query("quantile_over_time(0.99, latency[5m])".into()); - let requirements = QueryRequirements { - accuracy: AccuracyRequirement::Explicit(AccuracyTarget::Epsilon(0.01)), - ..QueryRequirements::default() - }; - PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: Some(vec![BatchEntry { - query: query.clone(), - requirements: requirements.clone(), - predictability: Predictability::AdHoc, - invocations: 1, - execute_at: None, - time_selection: TimeSelection::default(), - }]), - repeating_queries: Some(vec![RepeatingEntry { - query, - demand: RepeatedDemand::FixedInterval(RepetitionInterval(1_000)), - requirements, - predictability: Predictability::Predictable { known_at: None }, - time_selection: TimeSelection { - scope: QueryTimeScope::RealTime, - ..TimeSelection::default() - }, - }]), - }, - data_workload: Some(DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - ingestion_rate: Evidence { - value: Some(Rate(1.0)), - source: EvidenceSource::Observed, - observed_at_ms: Some(NOW_MS), - valid_for_ms: Some(60_000), - }, - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ..DataWorkload::default() - }), - } -} - -#[test] -fn promql_dashboard_materializes_continuous_summary_with_explained_rejections() { - let workload = dashboard_workload(); - let plan = selected_plan(&workload); - - assert!(!plan.selected_raw_recompute); - assert_eq!(plan.expected_reads, Some(100.0)); - assert_eq!(plan.deployments.len(), 1); - - let deployment = &plan.deployments[0]; - let guarantee = deployment - .summary_maintenance_lifecycle_guarantee - .as_ref() - .expect("selected lifecycle guarantee"); - assert_eq!( - guarantee.summary_maintenance_lifecycle, - SummaryMaintenanceLifecycle::ContinuouslyMaintained - ); - assert_eq!(guarantee.evaluation_schedule, EvaluationSchedule::PerUpdate); - assert_eq!( - guarantee.summary_maintenance_mode, - SummaryMaintenanceMode::Incremental - ); - assert!(deployment.alternatives.iter().any(|alternative| { - matches!( - alternative.summary_maintenance_lifecycle, - SummaryMaintenanceLifecycle::Prepared { .. } - ) && alternative.rejection - == Some(SummaryMaintenanceLifecycleRejection::RequiresPredictableOneTimeQuery) - })); - assert!(deployment.alternatives.iter().any(|alternative| { - matches!( - alternative.summary_maintenance_lifecycle, - SummaryMaintenanceLifecycle::Shared { .. } - ) && alternative.rejection - == Some(SummaryMaintenanceLifecycleRejection::UnsupportedByRuntime) - })); - - let exported = serde_json::to_value(export_summary_maintenance_plan(&plan)).unwrap(); - assert_eq!( - exported["deployments"][0]["selected"]["lifecycle"]["kind"], - "continuously_maintained" - ); - assert_eq!( - exported["deployments"][0]["selected"]["maintenance_mode"], - "incremental" - ); - let alternatives = exported["deployments"][0]["alternatives"] - .as_array() - .expect("exported lifecycle alternatives"); - assert!(alternatives.iter().any(|alternative| { - alternative["lifecycle"]["kind"] == "prepared" - && alternative["rejection"] == "requires_predictable_one_time_query" - })); - assert!(alternatives.iter().any(|alternative| { - alternative["lifecycle"]["kind"] == "shared" - && alternative["rejection"] == "unsupported_by_runtime" - })); - assert!(exported["dag"]["nodes"].as_array().is_some()); - let summary_node = exported["dag"]["nodes"] - .as_array() - .unwrap() - .iter() - .find(|node| node["kind"] == "summary_agg") - .expect("exported summary_agg node"); - assert_eq!( - summary_node["detail"]["summary_maintenance"]["selected"]["lifecycle"]["kind"], - "continuously_maintained" - ); -} - -fn selected_plan( - workload: &PlanningWorkload, -) -> asap_aware_mapping::SummaryMaintenanceLifecyclePlan { - selected_plan_with_model(workload, &FullyCostedRuntime) -} - -fn selected_plan_with_model( - workload: &PlanningWorkload, - model: &dyn CostModel, -) -> asap_aware_mapping::SummaryMaintenanceLifecyclePlan { - selected_plan_with_horizon(workload, model, Horizon(100.)) -} - -fn selected_plan_with_horizon( - workload: &PlanningWorkload, - model: &dyn CostModel, - horizon: Horizon, -) -> asap_aware_mapping::SummaryMaintenanceLifecyclePlan { - workload.validate().unwrap(); - - let lowered = lower_promql_workload(workload, 0) - .expect("valid PromQL workload") - .into_iter() - .next() - .expect("one normalized workload entry"); - selected_plan_for_lowered(workload, lowered, model, horizon) -} - -fn selected_plan_for_lowered( - workload: &PlanningWorkload, - lowered: Rc, - model: &dyn CostModel, - horizon: Horizon, -) -> asap_aware_mapping::SummaryMaintenanceLifecyclePlan { - let root = lowered; - let strategies = asap_aware_mapping::default_strategies_with(model); - let space = search_workload_with(vec![("dashboard", Rc::clone(&root))], &strategies).unwrap(); - let target = Rc::clone(&space.roots[0].1); - let capabilities = SummaryMaintenanceLifecycleCapabilities { - supports_ephemeral: true, - supports_prepared: false, - supports_shared: false, - supports_continuously_maintained: true, - }; - - let selection = global_selection_with_summary_maintenance_lifecycles( - &space, - WorkloadDemand { - workload: &workload.query_workload, - data_workload: workload.data_workload.as_ref(), - entry_indices: &[1], - }, - NOW_MS, - Some(horizon), - capabilities, - model, - ) - .unwrap(); - assemble_selected_dag_with_summary_maintenance_lifecycles( - &selection, - &target, - WorkloadDemand::new_with_data( - &workload.query_workload, - workload.data_workload.as_ref().unwrap(), - &[1], - ), - NOW_MS, - Some(horizon), - capabilities, - model, - ) - .unwrap() - .expect("selected summary plan") -} - -mod physical_common; - -/// A selected continuous lifecycle supplies a materialization boundary; its -/// maintenance and query DAGs execute the selected KLL computation in fresh runs. -#[test] -fn continuous_lifecycle_compiles_and_executes_spatial_kll() { - use asap_physical_operators::{ - physical_planner::{compile_candidate, InputContract}, - runtime::Scope, - values::{Batch, Value}, - }; - use asap_types::{post_asap::FieldDataType, pre_asap::DataType}; - use std::{collections::BTreeMap, sync::Arc}; - let mut workload = dashboard_workload(); - workload.query_workload.query_batch.as_mut().unwrap()[0].query = - Query("quantile(0.99, latency)".into()); - workload.query_workload.repeating_queries.as_mut().unwrap()[0].query = - Query("quantile(0.99, latency)".into()); - let selected = selected_plan(&workload); - assert_eq!( - selected.deployments[0] - .summary_maintenance_lifecycle_guarantee - .as_ref() - .unwrap() - .summary_maintenance_lifecycle, - SummaryMaintenanceLifecycle::ContinuouslyMaintained - ); - let dag = compile_physical_asap_dag(&selected.root).unwrap(); - let build = dag - .nodes - .iter() - .find(|node| { - matches!( - node.payload, - PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { .. }) - ) - }) - .unwrap(); - let input = dag - .edges - .iter() - .find(|edge| edge.consumer == build.id) - .unwrap() - .producer; - let raw = dag.nodes.iter().find(|node| node.id == input).unwrap(); - let schema = Arc::new(raw.output_schema.clone()); - let candidate = compile_candidate( - &dag, - BTreeMap::from([(input as u64, InputContract::bounded(schema.clone()))]), - &[dag.roots[0] as u64], - &[build.id as u64], - ) - .unwrap(); - - // A continuous input without a finite pane boundary cannot implement this - // blocking builder. Retain lifecycle ownership in the candidate payload; - // only the legal bounded request candidate reaches workload pricing. - let mut unbounded = InputContract::bounded(schema.clone()); - unbounded.properties.boundedness = asap_physical_operators::plan::Boundedness::Unbounded; - let rejected = compile_candidate( - &dag, - BTreeMap::from([(input as u64, unbounded)]), - &[dag.roots[0] as u64], - &[build.id as u64], - ); - assert!(rejected.is_err()); - let request = compile_candidate( - &dag, - BTreeMap::from([(input as u64, InputContract::bounded(schema.clone()))]), - &[dag.roots[0] as u64], - &[], - ) - .unwrap(); - let mut priced = 0; - let feedback = asap_physical_operators::physical_planner::select_candidate( - vec![ - rejected.map(|candidate| { - ( - SummaryMaintenanceLifecycle::ContinuouslyMaintained, - candidate, - ) - }), - Ok((SummaryMaintenanceLifecycle::Ephemeral, request)), - ], - |_| { - priced += 1; - Ok(Some( - asap_physical_operators::physical_planner::CandidateCost { - workload_scope: "dashboard".into(), - horizon_seconds: 100., - total_cost: 1000., - }, - )) - }, - ) - .unwrap(); - assert_eq!(priced, 1); - assert_eq!(feedback.candidate.0, SummaryMaintenanceLifecycle::Ephemeral); - for revision in [1, 2] { - let rows = (1..=100) - .map(|value| { - schema - .fields - .iter() - .map(|field| match field.dtype { - FieldDataType::Plain(DataType::Float64) => Value::Float64(f64::from(value)), - FieldDataType::Plain(DataType::Timestamp) => Value::Timestamp(300_000), - _ => panic!("unexpected field {field:?}"), - }) - .collect() - }) - .collect(); - let raw_batch = Batch::try_new(schema.clone(), rows).unwrap(); - let direct = physical_common::execute( - &feedback.candidate.1.query, - BTreeMap::from([(input as u64, raw_batch.clone())]), - Scope::Query { - evaluation_time_ms: 300_000, - revision, - }, - ); - let state = physical_common::execute( - candidate.precompute.as_ref().unwrap(), - BTreeMap::from([(input as u64, raw_batch)]), - Scope::Ingestion { - window_start_ms: 0, - window_end_ms: 300_000, - revision, - }, - ); - let result = physical_common::execute( - &candidate.query, - BTreeMap::from([(build.id as u64, state[0][0].clone())]), - Scope::Query { - evaluation_time_ms: 300_000, - revision, - }, - ); - let values: Vec<_> = result[0] - .iter() - .flat_map(|batch| batch.rows()) - .flat_map(|row| row.iter()) - .filter_map(|value| { - if let Value::Float64(value) = value { - Some(*value) - } else { - None - } - }) - .collect(); - let direct_values: Vec<_> = direct[0] - .iter() - .flat_map(|batch| batch.rows()) - .flat_map(|row| row.iter()) - .filter_map(|value| { - if let Value::Float64(value) = value { - Some(*value) - } else { - None - } - }) - .collect(); - assert_eq!( - values, direct_values, - "maintenance and request candidates preserve the same population" - ); - assert_eq!(values.len(), 1); - assert!( - (98. ..=100.).contains(&values[0]), - "p99 rank must reflect the supplied population" - ); - } -} - -fn quantile_workload(query: &str) -> PlanningWorkload { - let mut workload = dashboard_workload(); - workload.query_workload.query_batch.as_mut().unwrap()[0].query = Query(query.into()); - workload.query_workload.repeating_queries.as_mut().unwrap()[0].query = Query(query.into()); - workload -} - -/// Timed DAG for `query` after binding every summary state to `lifecycle`. -/// Grouped queries carry a physical series identity, as per-entity state needs. -fn lifecycle_timed_dag( - query: &str, - lifecycle: &SummaryMaintenanceLifecycle, -) -> (asap_types::ir::physical_export::PhysicalASAPDAG, Vec) { - use asap_aware_mapping::enumerate_summary_maintenance_lifecycles; - let workload = quantile_workload(query); - let mut lowered = lower_promql_workload(&workload, 0).unwrap().remove(0); - if query.contains(" by(") { - lowered = - asap_physical_operators::physical_planner::promql_rows::with_series_identity(&lowered) - .unwrap(); - } - let root = - selected_plan_for_lowered(&workload, lowered, &FullyCostedRuntime, Horizon(100.)).root; - let candidates = enumerate_summary_maintenance_lifecycles( - root, - WorkloadDemand::new_with_data( - &workload.query_workload, - workload.data_workload.as_ref().unwrap(), - &[1], - ), - NOW_MS, - Some(Horizon(100.)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &FullyCostedRuntime, - ) - .unwrap(); - let choices: Vec<_> = candidates - .deployments() - .iter() - .map(|deployment| (deployment.post_asap_node_id, lifecycle.clone())) - .collect(); - let mut states: Vec<_> = choices.iter().map(|(id, _)| *id as u64).collect(); - states.sort_unstable(); - let dag = candidates - .select(&choices) - .unwrap() - .execution_timed_dag() - .unwrap(); - (dag, states) -} - -/// Compile inputs for a timed DAG: its raw source, available at either phase. -fn raw_inputs( - dag: &asap_types::ir::physical_export::PhysicalASAPDAG, -) -> std::collections::BTreeMap { - let raw = dag - .nodes - .iter() - .find(|node| { - matches!( - node.payload, - asap_types::ir::physical_export::PhysicalASAPOperatorPayload::NonASAP( - asap_types::ir::NonASAPOp::TimeRange { .. } - ) - ) - }) - .unwrap(); - std::collections::BTreeMap::from([( - raw.id as u64, - asap_physical_operators::physical_planner::InputContract::bounded(std::sync::Arc::new( - raw.output_schema.clone(), - )), - )]) -} - -/// For existing PromQL fixtures, Planner's own retained lifecycle selection -/// reproduces the timing that realization strategies assign today. -#[test] -fn planner_lifecycle_selection_reproduces_strategy_timing() { - for query in [ - "quantile_over_time(0.99, latency[5m])", - "quantile(0.99, latency)", - "sum by(job)(rate(m[1m]))", - ] { - let plan = selected_plan(&quantile_workload(query)); - assert!(!plan.selected_raw_recompute, "{query}"); - assert!(plan.deployments.iter().all(|deployment| { - deployment - .summary_maintenance_lifecycle_guarantee - .as_ref() - .is_some_and(|guarantee| { - guarantee.summary_maintenance_lifecycle - != SummaryMaintenanceLifecycle::Ephemeral - }) - })); - let strategy = compile_physical_asap_dag(&plan.root).unwrap(); - assert_eq!(plan.execution_timed_dag().unwrap(), strategy, "{query}"); - } -} - -/// An explicitly chosen lifecycle reaches physical compilation through timing: -/// ContinuouslyMaintained puts the state in precompute, Ephemeral leaves -/// precompute empty and reads the raw source at query time; both answer alike. -#[test] -fn chosen_lifecycle_timing_decides_precompute_contents() { - use asap_physical_operators::{ - physical_planner::{compile_candidate, frontier_from_timing}, - runtime::Scope, - values::{Batch, Value}, - }; - use asap_types::{post_asap::FieldDataType, pre_asap::DataType}; - use std::collections::BTreeMap; - - let mut answers = Vec::new(); - for lifecycle in [ - SummaryMaintenanceLifecycle::ContinuouslyMaintained, - SummaryMaintenanceLifecycle::Ephemeral, - ] { - let (dag, states) = lifecycle_timed_dag("quantile(0.99, latency)", &lifecycle); - let [state] = states[..] else { - panic!("one summary state"); - }; - let inputs = raw_inputs(&dag); - let (&raw_id, contract) = inputs.iter().next().unwrap(); - let schema = contract.schema.clone(); - let frontier = frontier_from_timing(&dag).unwrap(); - let candidate = compile_candidate(&dag, inputs, &[dag.roots[0] as u64], &frontier).unwrap(); - let rows = (1..=100) - .map(|value| { - schema - .fields - .iter() - .map(|field| match field.dtype { - FieldDataType::Plain(DataType::Float64) => Value::Float64(f64::from(value)), - FieldDataType::Plain(DataType::Timestamp) => Value::Timestamp(300_000), - _ => panic!("unexpected field {field:?}"), - }) - .collect() - }) - .collect(); - let raw_batch = Batch::try_new(schema.clone(), rows).unwrap(); - let query_scope = Scope::Query { - evaluation_time_ms: 300_000, - revision: 1, - }; - let result = if lifecycle == SummaryMaintenanceLifecycle::Ephemeral { - assert!(frontier.is_empty()); - assert!(candidate.precompute.is_none()); - physical_common::execute( - &candidate.query, - BTreeMap::from([(raw_id, raw_batch)]), - query_scope, - ) - } else { - assert_eq!(frontier, [state]); - assert_eq!( - candidate - .materialized_outputs - .keys() - .copied() - .collect::>(), - [state] - ); - let stored = physical_common::execute( - candidate.precompute.as_ref().unwrap(), - BTreeMap::from([(raw_id, raw_batch)]), - Scope::Ingestion { - window_start_ms: 0, - window_end_ms: 300_000, - revision: 1, - }, - ); - physical_common::execute( - &candidate.query, - BTreeMap::from([(state, stored[0][0].clone())]), - query_scope, - ) - }; - answers.push( - result[0] - .iter() - .flat_map(|batch| batch.rows()) - .flat_map(|row| row.iter()) - .filter_map(|value| match value { - Value::Float64(value) => Some(*value), - _ => None, - }) - .collect::>(), - ); - } - assert_eq!(answers[0], answers[1]); - assert_eq!(answers[0].len(), 1); -} - -/// One compilation, cut by each lifecycle assignment's timing, yields exactly -/// the candidate `compile_candidate` builds for that timed DAG: the retained -/// state is the frontier under ContinuouslyMaintained, and nothing under -/// Ephemeral. Covers the KLL quantile fixture and grouped Rate→Sum. -#[test] -fn lifecycle_timing_cuts_one_compilation() { - use asap_physical_operators::physical_planner::{ - compile, compile_candidate, cut_candidate, frontier_from_timing, - }; - for query in ["quantile(0.99, latency)", "sum by(job)(rate(m[1m]))"] { - let ephemeral = SummaryMaintenanceLifecycle::Ephemeral; - let (compiled_dag, _) = lifecycle_timed_dag(query, &ephemeral); - let inputs = raw_inputs(&compiled_dag); - let roots = [compiled_dag.roots[0] as u64]; - let compiled = compile(&compiled_dag, inputs.clone(), &roots).unwrap(); - for lifecycle in [ - SummaryMaintenanceLifecycle::ContinuouslyMaintained, - ephemeral, - ] { - let (dag, states) = lifecycle_timed_dag(query, &lifecycle); - let frontier = frontier_from_timing(&dag).unwrap(); - // Retained states read by a query-time consumer, or the root itself. - let query_time = |id: u64| { - dag.nodes.iter().any(|node| { - node.id as u64 == id - && node.output_state.timing - == asap_types::post_asap::ExecutionTiming::QueryTime - }) - }; - let expected_frontier = if lifecycle == SummaryMaintenanceLifecycle::Ephemeral { - vec![] - } else { - states - .iter() - .copied() - .filter(|state| { - *state == dag.roots[0] as u64 - || dag.edges.iter().any(|edge| { - edge.producer as u64 == *state && query_time(edge.consumer as u64) - }) - }) - .collect() - }; - assert_eq!(frontier, expected_frontier, "{query} {lifecycle:?}"); - let cut = cut_candidate(&compiled, &frontier).unwrap(); - let expected = compile_candidate(&dag, inputs.clone(), &roots, &frontier).unwrap(); - assert_eq!( - serde_json::to_vec(&cut).unwrap(), - serde_json::to_vec(&expected).unwrap(), - "{query} {lifecycle:?}" - ); - } - } -} - -/// A maintained current-series population is placed by its lifecycle choice: -/// ContinuouslyMaintained stores the population in precompute, Ephemeral -/// rebuilds it from the raw source at query time; both rank alike. -#[test] -fn chosen_population_lifecycle_decides_precompute_contents() { - use asap_aware_mapping::{ - enumerate_summary_maintenance_lifecycles, - maintained_population::MaintainedPopulationStrategy, - }; - use asap_physical_operators::{ - physical_planner::{ - compile_candidate, - promql_rows::{series_row, with_series_identity}, - InputContract, - }, - runtime::Scope, - values::{Batch, Value}, - }; - use asap_types::post_asap::maintained_population::PopulationInput; - use std::{collections::BTreeMap, sync::Arc}; - - let workload = quantile_workload("topk by(job)(1, m)"); - let root = - with_series_identity(&lower_promql_workload(&workload, 0).unwrap().remove(0)).unwrap(); - let root = MaintainedPopulationStrategy::new(std::slice::from_ref(&root)) - .candidate(&root) - .unwrap(); - let mut answers = Vec::new(); - for lifecycle in [ - SummaryMaintenanceLifecycle::ContinuouslyMaintained, - SummaryMaintenanceLifecycle::Ephemeral, - ] { - let candidates = enumerate_summary_maintenance_lifecycles( - Rc::clone(&root), - WorkloadDemand::new_with_data( - &workload.query_workload, - workload.data_workload.as_ref().unwrap(), - &[1], - ), - NOW_MS, - Some(Horizon(100.)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &FullyCostedRuntime, - ) - .unwrap(); - let [deployment] = candidates.deployments() else { - panic!("one population state"); - }; - let id = deployment.post_asap_node_id; - let dag = candidates - .select(&[(id, lifecycle.clone())]) - .unwrap() - .execution_timed_dag() - .unwrap(); - let population = dag.nodes.iter().find(|node| node.id == id).unwrap(); - let PhysicalASAPOperatorPayload::ASAP(ASAPOp::MaintainPopulation { population, .. }) = - &population.payload - else { - panic!("the deployment is the maintained population"); - }; - let PopulationInput::CurrentSeries(spec) = &population.input else { - panic!("current-series population"); - }; - let lookback = i64::try_from(spec.lookback_ms).unwrap(); - let raw = dag - .nodes - .iter() - .find(|node| { - matches!( - node.payload, - PhysicalASAPOperatorPayload::NonASAP( - asap_types::ir::NonASAPOp::TimeRange { .. } - ) - ) - }) - .unwrap(); - let (raw_id, schema) = (raw.id as u64, Arc::new(raw.output_schema.clone())); - let frontier = - asap_physical_operators::physical_planner::frontier_from_timing(&dag).unwrap(); - let candidate = compile_candidate( - &dag, - BTreeMap::from([(raw_id, InputContract::bounded(schema.clone()))]), - &[dag.roots[0] as u64], - &frontier, - ) - .unwrap(); - let end = 60_000; - let rows = [("a", end - 1, 100.), ("a", end, 1.), ("b", end, 20.)] - .into_iter() - .map(|(instance, at, value)| { - series_row( - &schema, - &BTreeMap::from([ - ("job".into(), "api".into()), - ("instance".into(), instance.into()), - ]), - at, - value, - ) - .unwrap() - }) - .collect(); - let raw_batch = Batch::try_new(schema.clone(), rows).unwrap(); - let query_scope = Scope::Query { - evaluation_time_ms: end, - revision: 1, - }; - let result = if lifecycle == SummaryMaintenanceLifecycle::Ephemeral { - assert!(frontier.is_empty()); - assert!(candidate.precompute.is_none()); - physical_common::execute( - &candidate.query, - BTreeMap::from([(raw_id, raw_batch)]), - query_scope, - ) - } else { - let state = id as u64; - assert_eq!(frontier, [state]); - let stored = physical_common::execute( - candidate.precompute.as_ref().unwrap(), - BTreeMap::from([(raw_id, raw_batch)]), - Scope::Ingestion { - window_start_ms: end - lookback, - window_end_ms: end, - revision: 1, - }, - ); - physical_common::execute( - &candidate.query, - BTreeMap::from([(state, stored[0][0].clone())]), - query_scope, - ) - }; - answers.push( - result[0] - .iter() - .flat_map(|batch| batch.rows()) - .flat_map(|row| row.iter()) - .filter_map(|value| match value { - Value::Float64(value) => Some(*value), - _ => None, - }) - .collect::>(), - ); - } - assert_eq!(answers[0], answers[1]); - assert_eq!(answers[0], [20.]); -} - -/// Grouped Rate→Sum is one inventory candidate: retaining the Sum state puts -/// Rate and Sum in precompute, while an `Ephemeral` Sum over a retained Rate -/// state leaves Sum in the query DAG. -#[test] -fn grouped_rate_sum_placement_is_a_lifecycle_choice() { - use asap_aware_mapping::enumerate_summary_maintenance_lifecycles; - use asap_physical_operators::physical_planner::{compile_candidate, InputContract}; - use asap_types::post_asap::{ExactKind, FieldDataType}; - use std::{collections::BTreeMap, sync::Arc}; - - let workload = quantile_workload("sum by(job)(rate(m[1m]))"); - let root = asap_physical_operators::physical_planner::promql_rows::with_series_identity( - &lower_promql_workload(&workload, 0).unwrap().remove(0), - ) - .unwrap(); - let is_exact = |node: &OperatorNode, kind: ExactKind| { - matches!(&node.operator, asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(k, _), .. - }) if *k == kind) - }; - let inventory = asap_aware_mapping::search_workload(vec![("q", root)]) - .unwrap() - .enumerate_candidate_dags(4096) - .unwrap(); - let candidates = inventory - .candidates - .into_iter() - .map(|mut forest| forest.remove(0).1) - .filter(|candidate| { - matches!(&candidate.operator, asap_types::ir::Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) - if is_exact(child, ExactKind::Sum)) - }) - .collect::>(); - let [candidate] = candidates.as_slice() else { - panic!("one grouped Sum candidate, got {}", candidates.len()); - }; - let mut placements = Vec::new(); - for sum_lifecycle in [ - SummaryMaintenanceLifecycle::ContinuouslyMaintained, - SummaryMaintenanceLifecycle::Ephemeral, - ] { - let lifecycles = enumerate_summary_maintenance_lifecycles( - Rc::clone(candidate), - WorkloadDemand::new_with_data( - &workload.query_workload, - workload.data_workload.as_ref().unwrap(), - &[1], - ), - NOW_MS, - Some(Horizon(100.)), - SummaryMaintenanceLifecycleCapabilities::ALL, - &FullyCostedRuntime, - ) - .unwrap(); - let choices = lifecycles - .deployments() - .iter() - .map(|deployment| { - let lifecycle = if is_exact(&deployment.summary, ExactKind::Sum) { - sum_lifecycle.clone() - } else { - SummaryMaintenanceLifecycle::ContinuouslyMaintained - }; - (deployment.post_asap_node_id, lifecycle) - }) - .collect::>(); - assert_eq!(choices.len(), 2, "Rate and Sum states"); - let dag = lifecycles - .select(&choices) - .unwrap() - .execution_timed_dag() - .unwrap(); - let raw = dag - .nodes - .iter() - .find(|node| { - matches!( - node.payload, - PhysicalASAPOperatorPayload::NonASAP( - asap_types::ir::NonASAPOp::TimeRange { .. } - ) - ) - }) - .unwrap(); - let frontier = - asap_physical_operators::physical_planner::frontier_from_timing(&dag).unwrap(); - let [boundary] = frontier.as_slice() else { - panic!("one precompute output, got {frontier:?}"); - }; - let boundary = dag - .nodes - .iter() - .find(|node| node.id as u64 == *boundary) - .unwrap(); - let physical = compile_candidate( - &dag, - BTreeMap::from([( - raw.id as u64, - InputContract::bounded(Arc::new(raw.output_schema.clone())), - )]), - &[dag.roots[0] as u64], - &frontier, - ) - .unwrap(); - let json = |value| String::from_utf8(serde_json::to_vec(value).unwrap()).unwrap(); - placements.push(( - boundary.payload.clone(), - json(physical.precompute.as_ref().unwrap()), - json(&physical.query), - )); - } - let builds = |json: &str, kind: &str| { - json.contains(&format!( - r#"{{"SummaryBuild":{{"family":{{"ExactAggregate":["{kind}","{kind}"]}}"# - )) - }; - let [(retained, retained_pre, retained_query), (ephemeral, ephemeral_pre, ephemeral_query)] = - placements.as_slice() - else { - unreachable!() - }; - let state = |payload: &PhysicalASAPOperatorPayload, kind: ExactKind| { - matches!(payload, PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { - family: FieldDataType::ExactAggregate(k, _), .. - }) if *k == kind) - }; - assert!(state(retained, ExactKind::Sum)); - assert!(builds(retained_pre, "Rate") && builds(retained_pre, "Sum")); - assert!(!retained_query.contains("SummaryBuild")); - assert!(state(ephemeral, ExactKind::Rate)); - assert!(builds(ephemeral_pre, "Rate") && !builds(ephemeral_pre, "Sum")); - assert!(builds(ephemeral_query, "Sum")); -} - -/// The lifecycle-timed DAG Planner selects for `query` with upfront series -/// typing, and whether it keeps an ingestion-time Binary. -fn typed_selection(query: &str) -> (asap_types::ir::physical_export::PhysicalASAPDAG, bool) { - use asap_types::post_asap::ExecutionTiming; - let workload = quantile_workload(query); - let lowered = asap_types::ir::schema_support::with_promql_series_identity( - &lower_promql_workload(&workload, 0).unwrap().remove(0), - ) - .unwrap(); - let dag = selected_plan_for_lowered(&workload, lowered, &FullyCostedRuntime, Horizon(100.)) - .execution_timed_dag() - .unwrap(); - let ingestion_binary = dag.nodes.iter().any(|node| { - matches!( - node.payload, - PhysicalASAPOperatorPayload::NonASAP(asap_types::ir::NonASAPOp::BinaryOp { .. }) - ) && node.output_state.timing == ExecutionTiming::IngestionTime - }); - (dag, ingestion_binary) -} - -/// Execute a timed DAG's precompute and query DAGs over `samples` -/// (`(metric, job, seconds, value)`) at 300s; returns the root's values. -fn execute_timed( - dag: &asap_types::ir::physical_export::PhysicalASAPDAG, - samples: &[(&str, &str, i64, f64)], -) -> Vec { - use asap_physical_operators::{ - physical_planner::{compile_candidate, frontier_from_timing, promql_rows, InputContract}, - runtime::Scope, - values::{Batch, Value}, - }; - use asap_types::{ir::physical_export::PhysicalASAPOperatorPayload, pre_asap::Source}; - use std::{collections::BTreeMap, sync::Arc}; - // Raw inputs: a selector Fallback is itself the input; a retained - // expression reads each of its selectors through its raw-series slots. - let mut raw = BTreeMap::new(); - for node in &dag.nodes { - if !matches!( - node.payload, - PhysicalASAPOperatorPayload::NonASAP( - asap_types::ir::NonASAPOp::TimeRange { .. } - | asap_types::ir::NonASAPOp::Scan { .. } - ) - ) { - continue; - } - let mut id = node.id; - loop { - let n = dag.nodes.iter().find(|n| n.id == id).unwrap(); - if let PhysicalASAPOperatorPayload::NonASAP(asap_types::ir::NonASAPOp::Scan { - source: Source::TimeSeries { metric }, - .. - }) = &n.payload - { - raw.insert( - node.id as u64, - (Arc::new(node.output_schema.clone()), metric.clone()), - ); - break; - } - id = dag - .edges - .iter() - .find(|e| e.consumer == id) - .unwrap() - .producer; - } - } - let batch = |schema: &asap_physical_operators::values::SchemaRef, name: &str| { - let rows = samples - .iter() - .filter(|sample| sample.0 == name) - .map(|(metric, job, seconds, value)| { - let labels = BTreeMap::from([ - ("__name__".to_string(), metric.to_string()), - ("job".to_string(), job.to_string()), - ]); - promql_rows::series_row(schema, &labels, seconds * 1000, *value).unwrap() - }) - .collect(); - Batch::try_new(schema.clone(), rows).unwrap() - }; - let frontier = frontier_from_timing(dag).unwrap(); - let candidate = compile_candidate( - dag, - raw.iter() - .map(|(id, (schema, _))| (*id, InputContract::bounded(schema.clone()))) - .collect(), - &[dag.roots[0] as u64], - &frontier, - ) - .unwrap(); - let raw_sources = |plan: &asap_physical_operators::physical_planner::CompiledPhysicalDAG| { - plan.input_contracts() - .filter_map(|(id, _)| raw.get(&id).map(|(schema, name)| (id, batch(schema, name)))) - .collect::>() - }; - let mut query_sources = raw_sources(&candidate.query); - if let Some(precompute) = &candidate.precompute { - let stored = physical_common::execute( - precompute, - raw_sources(precompute), - Scope::Ingestion { - window_start_ms: 240_000, - window_end_ms: 300_000, - revision: 1, - }, - ); - for (root, batches) in precompute.roots().iter().zip(stored) { - query_sources.insert(*root, batches[0].clone()); - } - } - let result = physical_common::execute( - &candidate.query, - query_sources, - Scope::Query { - evaluation_time_ms: 300_000, - revision: 1, - }, - ); - result[0] - .iter() - .flat_map(|batch| batch.rows()) - .flat_map(|row| row.iter()) - .filter_map(|value| match value { - Value::Float64(value) => Some(*value), - _ => None, - }) - .collect() -} - -/// Prometheus drops series without a match: arithmetic over different -/// selectors keeps only label sets present on both sides (none when disjoint), -/// and such arithmetic never becomes aligned maintenance. -#[test] -fn maintained_arithmetic_over_different_selectors_matches_prometheus() { - let query = "sum(sum_over_time(m[1m]) + sum_over_time(n[1m]))"; - let (dag, ingestion_binary) = typed_selection(query); - let disjoint = [ - ("m", "a", 250, 1.0), - ("m", "a", 290, 2.0), - ("n", "b", 250, 5.0), - ]; - let values = execute_timed(&dag, &disjoint); - assert!(values.is_empty(), "{values:?}"); - // Only job a is on both sides: m_a + n_a = (1 + 2) + 7; m{job="b"} is dropped. - let overlapping = [ - ("m", "a", 250, 1.0), - ("m", "a", 290, 2.0), - ("m", "b", 250, 5.0), - ("n", "a", 250, 7.0), - ]; - assert_eq!(execute_timed(&dag, &overlapping), [10.0]); - assert!(!ingestion_binary); - // The quantile's exact fallback runs outside Planner; it must not be maintained either. - let (_, ingestion_binary) = - typed_selection("quantile(0.9, sum_over_time(m[1m]) + sum_over_time(n[1m]))"); - assert!(!ingestion_binary); -} - -/// Arithmetic over one selector keeps its maintained layout and adds each -/// series' two evaluations before the quantile. -#[test] -fn maintained_arithmetic_over_one_selector_executes() { - let (dag, ingestion_binary) = - typed_selection("quantile(0.9, sum_over_time(m[1m]) + sum_over_time(m[1m]))"); - assert!(ingestion_binary, "one selector shares its key set"); - let values = execute_timed( - &dag, - &[ - ("m", "a", 250, 1.0), - ("m", "a", 290, 2.0), - ("m", "b", 250, 5.0), - ], - ); - // job a: 3 + 3 = 6; job b: 5 + 5 = 10 (mispairing a with b gives 8 and 8). - // KLL at epsilon 0.01 returns an input value within 0.01 of rank 0.9; of - // two values only the larger is. - assert_eq!(values, [10.0]); -} diff --git a/crates/planner/src/lib.rs b/crates/planner/src/lib.rs index 63ed6c645..48c4dcef0 100644 --- a/crates/planner/src/lib.rs +++ b/crates/planner/src/lib.rs @@ -25,8 +25,8 @@ use asap_frontend_sql::{lower_sql_dialect, SqlCatalog, SqlError}; // configures the same models and reads the same output whether it goes through // `e2e_plan` or straight to `optimize`. pub use asap_aware_mapping::pass::{ - optimize, LifecycleInput, MajorPass, OptimizationInput, OptimizationPass, OptimizeError, - PassRegistry, PlanOutput, PlanningModels, QueryLifecyclePlan, + optimize, MajorPass, OptimizationInput, OptimizationPass, OptimizeError, PassRegistry, + PlanOutput, PlanningModels, QueryPlan, }; // ── Input ──────────────────────────────────────────────────────────────── @@ -57,9 +57,6 @@ pub struct UserInput<'a> { pub workload: &'a PlanningWorkload, pub frontend_specific: FrontendInput<'a>, pub models: PlanningModels<'a>, - /// Planning clock and runtime capabilities for the - /// maintenance-versus-recomputation decision every plan carries. - pub lifecycle: LifecycleInput, /// `None` uses [`MajorPass`]. A black-box caller never sets this. pub pass: Option<&'a dyn OptimizationPass>, } @@ -69,13 +66,11 @@ impl<'a> UserInput<'a> { workload: &'a PlanningWorkload, frontend_specific: FrontendInput<'a>, models: PlanningModels<'a>, - lifecycle: LifecycleInput, ) -> Self { Self { workload, frontend_specific, models, - lifecycle, pass: None, } } @@ -106,21 +101,6 @@ impl<'a> UserInput<'a> { }); } - if let Some(horizon) = self.lifecycle.horizon { - if !horizon.0.is_finite() || horizon.0 <= 0.0 { - return Err(UserInputError::InvalidHorizon(horizon.0)); - } - } - // Two clocks would let the DAG be built for one instant and priced - // for another, with neither stage able to notice. - if let FrontendInput::Promql { now_ms, .. } = &self.frontend_specific { - if *now_ms != self.lifecycle.now_ms { - return Err(UserInputError::PlanningTimeMismatch { - frontend: *now_ms, - lifecycle: self.lifecycle.now_ms, - }); - } - } Ok(()) } } @@ -147,10 +127,6 @@ pub enum UserInputError { language: String, frontend: &'static str, }, - #[error("planning horizon must be finite and positive, got {0}")] - InvalidHorizon(f64), - #[error("frontend planning time {frontend} ms disagrees with lifecycle planning time {lifecycle} ms")] - PlanningTimeMismatch { frontend: u64, lifecycle: u64 }, } #[derive(Debug, thiserror::Error)] @@ -199,7 +175,7 @@ pub async fn e2e_plan(input: UserInput<'_>) -> Result { let fallback = MajorPass; let pass: &dyn OptimizationPass = input.pass.unwrap_or(&fallback); - let optimization = OptimizationInput::new(&parsed, input.models, input.lifecycle); + let optimization = OptimizationInput::new(&parsed, input.models); optimize(pass, optimization).map_err(PlanError::Optimize) } @@ -207,8 +183,7 @@ pub async fn e2e_plan(input: UserInput<'_>) -> Result { /// /// The SQL frontend is driven one entry at a time rather than /// through `lower_sql_batch`, which walks `query_batch` alone and would drop -/// every repeating query — exactly the entries whose recurrence the lifecycle -/// stage needs. +/// every repeating query, and the output must cover every entry. async fn lower(input: &UserInput<'_>) -> Result, PlanError> { let entries = || input.workload.query_workload.entries(); diff --git a/crates/planner/tests/e2e_plan.rs b/crates/planner/tests/e2e_plan.rs index a1cf15d05..9beb0b4b9 100644 --- a/crates/planner/tests/e2e_plan.rs +++ b/crates/planner/tests/e2e_plan.rs @@ -7,9 +7,7 @@ use asap_aware_mapping::pass::{ OptimizationInput, OptimizationPass, OptimizeError, PlanOutput, PlanningModels, }; use asap_aware_mapping::replacement::default_strategies_with_evidence; -use asap_aware_mapping::{ - search_workload_with_targets, Horizon, LifecycleInput, SummaryMaintenanceLifecycleCapabilities, -}; +use asap_aware_mapping::search_workload_with_targets; use asap_frontend_sql::{lower_sql_dialect, SqlCatalog}; use asap_planner::{e2e_plan, FrontendInput, PlanError, UserInput, UserInputError}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; @@ -40,12 +38,6 @@ fn batch(sql: &str) -> BatchEntry { } } -/// The planning clock and default capabilities, no horizon: the least a -/// caller can supply. -fn lifecycle() -> LifecycleInput { - LifecycleInput::new(NOW_MS, SummaryMaintenanceLifecycleCapabilities::default()) -} - fn lineitem_catalog() -> SqlCatalog { SqlCatalog::new().with_table( "lineitem", @@ -89,7 +81,6 @@ async fn plans_every_query_in_entry_order() { &workload, FrontendInput::Sql { catalog: &catalog }, PlanningModels::builtin(), - lifecycle(), ); let output = e2e_plan(input).await.expect("workload plans"); @@ -97,13 +88,10 @@ async fn plans_every_query_in_entry_order() { assert_eq!(output.entry_indices(), vec![0, 1]); } -/// With the built-in cost model no lifecycle cost is ever known, and -/// lifecycle-aware selection then finalizes every summary target as raw -/// recompute: the cost-only selection picks a sketch for the same workload. -/// This pins that behavior so the facade's output is not mistaken for a -/// decision; it is a defect of `DefaultCostModel`, not addressed here. +/// The facade selects what workload-wide cost selection selects over the +/// same search space: here a summary for both approximate queries. #[tokio::test] -async fn builtin_cost_model_cannot_price_lifecycles_and_falls_back_to_raw_recompute() { +async fn facade_plans_match_cost_only_selection() { let workload = sql_workload( vec![ batch("SELECT COUNT(DISTINCT l_orderkey) FROM lineitem"), @@ -118,7 +106,6 @@ async fn builtin_cost_model_cannot_price_lifecycles_and_falls_back_to_raw_recomp &workload, FrontendInput::Sql { catalog: &catalog }, models, - lifecycle(), )) .await .expect("workload plans"); @@ -153,21 +140,17 @@ async fn builtin_cost_model_cannot_price_lifecycles_and_falls_back_to_raw_recomp "entry {}: cost-only selection was expected to pick a summary", plan.entry_index ); - assert!( - !plan.plan.root.contains_asap() - && plan.plan.selected_raw_recompute - && plan.plan.deployments.is_empty() - && plan.plan.summary_total_cost.is_none() - && plan.plan.raw_recompute_total_cost.is_none(), - "entry {}: the built-in model priced a lifecycle", + assert_eq!( + plan.root, cost_only, + "entry {}: the facade selected a different DAG", plan.entry_index ); } } /// A repeating SQL query reaches the optimizer. `lower_sql_batch` walks -/// `query_batch` alone, so driving the frontend through it would drop exactly -/// the entries whose recurrence the lifecycle stage reads. +/// `query_batch` alone, so driving the frontend through it would drop the +/// repeating entries. #[tokio::test] async fn lowers_repeating_sql_entries_too() { let workload = sql_workload( @@ -187,7 +170,6 @@ async fn lowers_repeating_sql_entries_too() { &workload, FrontendInput::Sql { catalog: &catalog }, PlanningModels::builtin(), - lifecycle(), ); let output = e2e_plan(input).await.expect("workload plans"); @@ -229,7 +211,6 @@ async fn runs_a_caller_supplied_pass_instead_of_the_shipped_one() { &workload, FrontendInput::Sql { catalog: &catalog }, PlanningModels::builtin(), - lifecycle(), ) .with_pass(&pass); @@ -270,7 +251,6 @@ async fn harness_rejects_a_pass_that_mislabels_entry_indices() { &workload, FrontendInput::Sql { catalog: &catalog }, PlanningModels::builtin(), - lifecycle(), ) .with_pass(&pass); @@ -302,7 +282,6 @@ async fn rejects_a_frontend_that_does_not_match_the_workload_language() { histograms: None, }, PlanningModels::builtin(), - lifecycle(), ); let err = e2e_plan(input).await.unwrap_err(); @@ -312,48 +291,9 @@ async fn rejects_a_frontend_that_does_not_match_the_workload_language() { )); } -/// Two planning clocks would let the DAG be built for one instant and priced -/// for another; the input check refuses that before lowering. -#[test] -fn rejects_disagreeing_planning_clocks() { - let workload = PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: Some(vec![batch("up")]), - repeating_queries: None, - }, - data_workload: Some(DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - data_ingestion_interval: Evidence { - value: Some(DurationMs(15_000)), - ..Default::default() - }, - ..Default::default() - }), - }; - let input = UserInput::new( - &workload, - FrontendInput::Promql { - now_ms: NOW_MS, - histograms: None, - }, - PlanningModels::builtin(), - LifecycleInput::new( - NOW_MS + 1, - SummaryMaintenanceLifecycleCapabilities::default(), - ), - ); - - assert!(matches!( - input.validate(), - Err(UserInputError::PlanningTimeMismatch { .. }) - )); -} - -/// The maintenance decisions ride inside each plan, and the DAG is still -/// there — inside the plan's `root`, not alongside it. +/// A repeating PromQL query yields one plan carrying its selected DAG root. #[tokio::test] -async fn lifecycle_decisions_ride_inside_each_plan() { +async fn each_plan_carries_its_selected_root() { let workload = PlanningWorkload { query_workload: QueryWorkload { language: QueryLanguage::PromQL, @@ -382,61 +322,15 @@ async fn lifecycle_decisions_ride_inside_each_plan() { histograms: None, }, PlanningModels::builtin(), - lifecycle().with_horizon(Horizon(3_600.0)), ); let output = e2e_plan(input).await.expect("workload plans"); assert_eq!(output.plans.len(), 1); assert_eq!(output.plans[0].entry_index, 0); - let _: &Rc<_> = &output.plans[0].plan.root; + let _: &Rc<_> = &output.plans[0].root; assert_eq!(output.operator_roots().len(), 1); } -/// Each root's lifecycle is planned against the entries that read it: a -/// query polled every minute and an unrelated one polled every ten minutes -/// each see only their own reads over the hour, not the workload's 66. -#[tokio::test] -async fn each_plan_counts_only_its_own_entries_reads() { - let repeating = |query: &str, interval_ms: u32| RepeatingEntry { - query: Query(query.into()), - demand: RepeatedDemand::FixedInterval(RepetitionInterval(interval_ms)), - requirements: approximate(), - predictability: Predictability::Unknown, - time_selection: TimeSelection::default(), - }; - let workload = PlanningWorkload { - query_workload: QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: None, - repeating_queries: Some(vec![ - repeating("count_over_time(up[5m])", 60_000), - repeating("sum_over_time(latency[5m])", 600_000), - ]), - }, - data_workload: Some(DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - data_ingestion_interval: Evidence { - value: Some(DurationMs(15_000)), - ..Default::default() - }, - ..Default::default() - }), - }; - let input = UserInput::new( - &workload, - FrontendInput::Promql { - now_ms: NOW_MS, - histograms: None, - }, - PlanningModels::builtin(), - lifecycle().with_horizon(Horizon(3_600.0)), - ); - - let output = e2e_plan(input).await.expect("workload plans"); - let reads: Vec<_> = output.plans.iter().map(|p| p.plan.expected_reads).collect(); - assert_eq!(reads, vec![Some(60.0), Some(6.0)]); -} - /// Scalar-only and mixed workloads preserve entry bindings without wrapper nodes. #[tokio::test] async fn scalar_roots_survive_planning_in_workload_order() { @@ -465,7 +359,6 @@ async fn scalar_roots_survive_planning_in_workload_order() { histograms: None, }, PlanningModels::builtin(), - lifecycle(), )) .await .unwrap(); @@ -486,7 +379,7 @@ async fn scalar_roots_survive_planning_in_workload_order() { assert_eq!(expr.operator_refs().len(), 1); assert!(Rc::ptr_eq( expr.operator_refs()[0], - &output.scalar_plans[0].plan.root + &output.scalar_plans[0].root )); } } diff --git a/crates/planner/tests/summary_sharing.rs b/crates/planner/tests/summary_sharing.rs index cdb80f045..ce9e0c9c0 100644 --- a/crates/planner/tests/summary_sharing.rs +++ b/crates/planner/tests/summary_sharing.rs @@ -1,5 +1,5 @@ //! Structurally identical summary producers chosen by different queries are -//! shared after Pass 1: one `Rc` across their plans, costed once. +//! shared after Pass 1: one `Rc` across their plans. use asap_types::ir::cse::share_common_sub_dags; use asap_types::ir::{ASAPOp, OperatorNode}; @@ -8,16 +8,11 @@ use std::rc::Rc; use asap_aware_mapping::accuracy::{ AccuracyModel, DefaultAccuracyModel, EqualSplitAllocator, PropagationStats, }; -use asap_aware_mapping::cost_model::Cost; -use asap_aware_mapping::pass::{PlanOutput, PlanningModels}; +use asap_aware_mapping::pass::{PlanOutput, PlanningModels, QueryPlan}; use asap_aware_mapping::replacement::{default_size_params, DEFAULT_DELTA}; use asap_aware_mapping::{ - global_selection_with_summary_maintenance_lifecycles, search_workload_with_targets, - ASAPStrategies, ReplacementStrategy, WorkloadDemand, -}; -use asap_aware_mapping::{ - CostModel, CostRate, DefaultCostModel, Horizon, LifecycleInput, SummaryMaintenanceCapabilities, - SummaryMaintenanceLifecycleCapabilities, SummaryMaintenanceLifecycleCostInputs, + search_workload_with_targets, ASAPStrategies, CostModel, DefaultCostModel, Replacement, + ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, }; use asap_frontend_promql::lower_promql_workload; use asap_frontend_sql::SqlCatalog; @@ -26,7 +21,7 @@ use asap_types::post_asap::{ AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, ProbabilityExpr, ResultGuarantee, SketchStatistic, }; -use asap_types::post_asap::{FieldDataType, SketchAlgorithm, SketchParams}; +use asap_types::post_asap::{FieldDataType, SketchAlgorithm, SketchKind, SketchParams}; use asap_types::pre_asap::agg_intent::default_quantile; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::pre_asap::AggIntent; @@ -38,16 +33,18 @@ use asap_types::workload::{ }; const NOW_MS: u64 = 1_700_000_000_000; -const HORIZON_S: f64 = 3_600.0; -/// A state costs `build` once however often it is read; raw recomputation -/// costs `raw_per_read` per read. -struct FixedCosts { - build: f64, - raw_per_read: f64, -} +/// Stand-in for the workload-level amortization Stage 2 materialization will +/// price: a sketch candidate costs `preference(kind)` per sketch state, any +/// other candidate more than every sketch. Ranking is otherwise built-in. +struct PreferSketch(fn(&SketchKind) -> f64); + +impl CostModel for PreferSketch { + // Selection takes the cheapest candidate by `estimate_cost`. + fn candidate_cost_covers_complete_plan(&self) -> bool { + true + } -impl CostModel for FixedCosts { fn rank_candidates( &self, intent: &AggIntent, @@ -56,41 +53,42 @@ impl CostModel for FixedCosts { DefaultCostModel.rank_candidates(intent, candidates) } - fn summary_maintenance_lifecycle_cost_inputs( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceLifecycleCostInputs { - SummaryMaintenanceLifecycleCostInputs { - build_cost: Some(Cost(self.build)), - maintenance_cost_per_update: Some(Cost::ZERO), - summary_read_cost: Some(Cost::ZERO), - retention_cost_rate: Some(CostRate(0.0)), - retirement_cost: Some(Cost::ZERO), - } - } - - fn summary_maintenance_capabilities( - &self, - _summary: &OperatorNode, - ) -> SummaryMaintenanceCapabilities { - SummaryMaintenanceCapabilities { - incremental_update: true, - merge: true, - delete: true, + fn estimate_cost(&self, candidate: &ReplacementSubDAG, _: &TargetSubDAG<'_>) -> f64 { + let Replacement::SubDAG(root) = &candidate.replacement else { + return 1e9; + }; + let kinds: Vec<_> = OperatorNode::reachable(root) + .into_iter() + .filter_map(|node| match &node.operator { + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::Sketch(kind, _), + .. + }) => Some(kind.clone()), + _ => None, + }) + .collect(); + if kinds.is_empty() { + 1e9 + } else { + kinds.iter().map(self.0).sum() } } - - fn raw_query_recompute_cost(&self, _target: &OperatorNode) -> Option { - Some(Cost(self.raw_per_read)) - } } -/// Summaries are far cheaper than raw recomputation, so every query selects -/// one independently and only sharing is under test. -const CHEAP_SUMMARY: FixedCosts = FixedCosts { - build: 1.0, - raw_per_read: 1_000.0, -}; +/// Prefers the largest KLL, i.e. one sized for the strictest consumer. +const PREFER_LARGE_KLL: PreferSketch = PreferSketch(|kind| match kind.params() { + SketchParams::Kll { k } => 1.0 / f64::from(*k), + _ => 1.0, +}); + +/// Prefers UnivMon, which can serve every frequency moment from one state. +const PREFER_UNIVMON: PreferSketch = PreferSketch(|kind| { + if kind.algorithm() == &SketchAlgorithm::UnivMon { + 0.0 + } else { + 1.0 + } +}); fn requirements(epsilon: f64) -> QueryRequirements { QueryRequirements { @@ -110,11 +108,6 @@ fn repeating(query: &str, epsilon: f64) -> RepeatingEntry { } } -fn lifecycle() -> LifecycleInput { - LifecycleInput::new(NOW_MS, SummaryMaintenanceLifecycleCapabilities::default()) - .with_horizon(Horizon(HORIZON_S)) -} - fn promql_workload(queries: &[(&str, f64)]) -> PlanningWorkload { PlanningWorkload { query_workload: QueryWorkload { @@ -142,7 +135,11 @@ fn promql_workload(queries: &[(&str, f64)]) -> PlanningWorkload { } } -async fn plan_promql(queries: &[(&str, f64)], costs: &FixedCosts) -> PlanOutput { +async fn plan_promql(queries: &[(&str, f64)]) -> PlanOutput { + plan_promql_with(queries, &DefaultCostModel).await +} + +async fn plan_promql_with(queries: &[(&str, f64)], cost: &dyn CostModel) -> PlanOutput { let workload = promql_workload(queries); let input = UserInput::new( &workload, @@ -150,13 +147,12 @@ async fn plan_promql(queries: &[(&str, f64)], costs: &FixedCosts) -> PlanOutput now_ms: NOW_MS, histograms: None, }, - PlanningModels::builtin().with_cost(costs), - lifecycle(), + PlanningModels::builtin().with_cost(cost), ); e2e_plan(input).await.expect("workload plans") } -async fn plan_sql(queries: &[&str], costs: &FixedCosts) -> PlanOutput { +async fn plan_sql(queries: &[&str]) -> PlanOutput { let workload = PlanningWorkload { query_workload: QueryWorkload { language: QueryLanguage::SQL(SqlDialect::DataFusionSQL), @@ -178,25 +174,33 @@ async fn plan_sql(queries: &[&str], costs: &FixedCosts) -> PlanOutput { let input = UserInput::new( &workload, FrontendInput::Sql { catalog: &catalog }, - PlanningModels::builtin().with_cost(costs), - lifecycle(), + PlanningModels::builtin(), ); e2e_plan(input).await.expect("workload plans") } -/// Every summary state each plan deploys. +/// Every summary state (`SummaryAgg`) each plan reaches, in traversal order. +fn plan_states(plan: &QueryPlan) -> Vec> { + OperatorNode::reachable(&plan.root) + .into_iter() + .filter(|node| { + matches!( + node.operator, + asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { .. }) + ) + }) + .collect() +} + +/// Every summary state each plan reaches; each plan selects at least one. fn states(output: &PlanOutput) -> Vec>> { output .plans .iter() .map(|plan| { - assert!(!plan.plan.selected_raw_recompute, "{:?}", plan.plan.root); - assert!(!plan.plan.deployments.is_empty()); - plan.plan - .deployments - .iter() - .map(|deployment| Rc::clone(&deployment.summary)) - .collect() + let states = plan_states(plan); + assert!(!states.is_empty(), "{:?}", plan.root); + states }) .collect() } @@ -210,12 +214,12 @@ fn same_states(states: &[Vec>]) -> bool { .all(|(left, right)| Rc::ptr_eq(left, right)) } -/// The deployments a consumer would run, deduplicated by pointer. +/// The summary states a consumer would run, deduplicated by pointer. fn unique_deployments(output: &PlanOutput) -> usize { let mut seen: Vec<*const OperatorNode> = Vec::new(); for plan in &output.plans { - for deployment in &plan.plan.deployments { - let ptr = Rc::as_ptr(&deployment.summary); + for state in plan_states(plan) { + let ptr = Rc::as_ptr(&state); if !seen.contains(&ptr) { seen.push(ptr); } @@ -225,39 +229,18 @@ fn unique_deployments(output: &PlanOutput) -> usize { } /// p50 and p99 over the same window and accuracy read one KLL: the -/// equal-params subset of summary capability. Both plans hold the same `Rc` -/// with the same lifecycle, so a consumer maintains it once. +/// equal-params subset of summary capability. Both plans hold the same `Rc`, +/// so a consumer maintains it once. #[tokio::test] async fn quantiles_with_equal_params_share_one_producer() { - let output = plan_promql( - &[ - ("quantile_over_time(0.5, lat[5m])", 0.01), - ("quantile_over_time(0.99, lat[5m])", 0.01), - ], - &CHEAP_SUMMARY, - ) + let output = plan_promql(&[ + ("quantile_over_time(0.5, lat[5m])", 0.01), + ("quantile_over_time(0.99, lat[5m])", 0.01), + ]) .await; assert!(same_states(&states(&output))); - assert!(!Rc::ptr_eq( - &output.plans[0].plan.root, - &output.plans[1].plan.root - )); + assert!(!Rc::ptr_eq(&output.plans[0].root, &output.plans[1].root)); assert_eq!(unique_deployments(&output), 1); - let lifecycles: Vec<_> = output - .plans - .iter() - .map(|plan| { - plan.plan.deployments[0] - .summary_maintenance_lifecycle_guarantee - .clone() - }) - .collect(); - assert_eq!(lifecycles[0], lifecycles[1]); - assert!(lifecycles[0].is_some()); - // Each plan is planned against both queries' reads. - for plan in &output.plans { - assert_eq!(plan.plan.expected_reads, Some(12.0)); - } } /// A different window or label selector is a different producer, even when @@ -282,27 +265,27 @@ async fn different_producers_are_not_shared() { ("quantile_over_time(0.99, lat{job=\"b\"}[5m])", 0.01), ], ] { - let output = plan_promql(&queries, &CHEAP_SUMMARY).await; + let output = plan_promql(&queries).await; assert!(!same_states(&states(&output)), "{queries:?}"); assert_eq!(unique_deployments(&output), 2, "{queries:?}"); for (plan, (_, epsilon)) in output.plans.iter().zip(queries) { - assert_eq!(plan.plan.expected_reads, Some(6.0), "{queries:?}"); assert_eq!(kll_k(plan), kll_k_for(epsilon), "{queries:?}"); } } } /// The KLL `k` of the one state a plan deploys. -fn kll_k(plan: &asap_aware_mapping::pass::QueryLifecyclePlan) -> u32 { - let [deployment] = plan.plan.deployments.as_slice() else { - panic!("one state: {:?}", plan.plan.deployments.len()); +fn kll_k(plan: &QueryPlan) -> u32 { + let states = plan_states(plan); + let [deployment] = states.as_slice() else { + panic!("one state: {:?}", states.len()); }; let asap_types::ir::Operator::ASAP(ASAPOp::SummaryAgg { family: FieldDataType::Sketch(kind, _), .. - }) = &deployment.summary.operator + }) = &deployment.operator else { - panic!("sketch state: {:?}", deployment.summary.operator); + panic!("sketch state: {:?}", deployment.operator); }; let SketchParams::Kll { k } = kind.params() else { panic!("KLL state: {kind:?}"); @@ -324,19 +307,20 @@ fn kll_k_for(epsilon: f64) -> u32 { } /// p50 at ε=0.01 and p99 at ε=0.001 over the same input share one KLL sized -/// for the strictest consumer; each reader's guarantee meets its own target. +/// for the strictest consumer when the cost model prefers that candidate; each +/// reader's guarantee meets its own target. #[tokio::test] async fn quantiles_share_one_producer_sized_for_the_strictest_consumer() { let p50 = ("quantile_over_time(0.5, lat[5m])", 0.01); let p99 = ("quantile_over_time(0.99, lat[5m])", 0.001); assert!(kll_k_for(0.001) > kll_k_for(0.01)); - let output = plan_promql(&[p50, p99], &CHEAP_SUMMARY).await; + let output = plan_promql_with(&[p50, p99], &PREFER_LARGE_KLL).await; assert!(same_states(&states(&output))); assert_eq!(unique_deployments(&output), 1); for (plan, (_, epsilon)) in output.plans.iter().zip([p50, p99]) { assert_eq!(kll_k(plan), kll_k_for(0.001)); - let guarantee = plan.plan.root.guarantee.as_ref().expect("certified"); + let guarantee = plan.root.guarantee.as_ref().expect("certified"); assert!( guarantee.bound.evaluate().unwrap() <= epsilon, "{guarantee:?}" @@ -344,7 +328,7 @@ async fn quantiles_share_one_producer_sized_for_the_strictest_consumer() { } // Alone, the looser query keeps its own, smaller KLL. - let alone = plan_promql(&[p50], &CHEAP_SUMMARY).await; + let alone = plan_promql_with(&[p50], &PREFER_LARGE_KLL).await; assert_eq!(kll_k(&alone.plans[0]), kll_k_for(0.01)); } @@ -352,11 +336,7 @@ async fn quantiles_share_one_producer_sized_for_the_strictest_consumer() { /// quantile, so p50 and p99 over one selector share it. #[tokio::test] async fn cross_series_p50_and_p99_share_one_producer() { - let output = plan_promql( - &[("quantile(0.5, lat)", 0.01), ("quantile(0.99, lat)", 0.01)], - &CHEAP_SUMMARY, - ) - .await; + let output = plan_promql(&[("quantile(0.5, lat)", 0.01), ("quantile(0.99, lat)", 0.01)]).await; assert!(same_states(&states(&output))); assert_eq!(unique_deployments(&output), 1); } @@ -366,7 +346,7 @@ async fn cross_series_p50_and_p99_share_one_producer() { #[tokio::test] async fn identical_ungrouped_queries_share_their_producers() { let query = ("sum(rate(x[5m]))", 0.01); - let output = plan_promql(&[query, query], &CHEAP_SUMMARY).await; + let output = plan_promql(&[query, query]).await; assert!(same_states(&states(&output))); assert_eq!(unique_deployments(&output), 2); } @@ -377,7 +357,7 @@ async fn identical_ungrouped_queries_share_their_producers() { async fn identical_sql_percentiles_share_one_producer() { let query = "SELECT approx_percentile_cont(l_extendedprice, 0.5) FROM lineitem WHERE l_orderkey > 10"; - let output = plan_sql(&[query, query], &CHEAP_SUMMARY).await; + let output = plan_sql(&[query, query]).await; assert!(same_states(&states(&output))); assert_eq!(unique_deployments(&output), 1); } @@ -391,15 +371,14 @@ async fn sql_p50_and_p99_share_one_producer() { "SELECT approx_percentile_cont(l_extendedprice, 0.5) FROM lineitem WHERE l_orderkey > 10"; let p99 = "SELECT approx_percentile_cont(l_extendedprice, 0.99) FROM lineitem WHERE l_orderkey > 10"; - let output = plan_sql(&[p50, p99], &CHEAP_SUMMARY).await; + let output = plan_sql(&[p50, p99]).await; assert!(same_states(&states(&output))); assert_eq!(unique_deployments(&output), 1); let names: Vec<_> = output .plans .iter() .map(|plan| { - plan.plan - .root + plan.root .schema .fields .iter() @@ -425,32 +404,12 @@ async fn sql_p50_and_p99_share_one_producer() { "SELECT approx_percentile_cont(l_orderkey, 0.99) FROM lineitem WHERE l_orderkey > 10", ], ] { - let output = plan_sql(&queries, &CHEAP_SUMMARY).await; + let output = plan_sql(&queries).await; assert!(!same_states(&states(&output)), "{queries:?}"); assert_eq!(unique_deployments(&output), 2, "{queries:?}"); } } -/// A state costs 100 and recomputing a query costs 60 over its six reads: -/// alone, the query recomputes raw. Shared by p50 and p99, the state costs 50 -/// per query, so both keep it. -#[tokio::test] -async fn shared_amortization_alone_can_beat_raw_recompute() { - let costs = FixedCosts { - build: 100.0, - raw_per_read: 10.0, - }; - let p50 = ("quantile_over_time(0.5, lat[5m])", 0.01); - let p99 = ("quantile_over_time(0.99, lat[5m])", 0.01); - - let alone = plan_promql(&[p50], &costs).await; - assert!(alone.plans[0].plan.selected_raw_recompute); - - let output = plan_promql(&[p50, p99], &costs).await; - assert!(same_states(&states(&output))); - assert_eq!(unique_deployments(&output), 1); -} - /// Synthetic evidence certifying UnivMon evaluations; it exercises sharing, never /// runtime accuracy. struct UnivMonEvidence; @@ -489,7 +448,7 @@ impl AccuracyModel for UnivMonEvidence { } /// Distinct count, entropy and L2 over one input, certified by an accuracy -/// model, read one UnivMon state: #515 sharing is the summary-capability rule +/// model and selected by a cost model preferring UnivMon, read one UnivMon state: #515 sharing is the summary-capability rule /// when the states are identical. `MajorPass` builds candidates with the /// built-in accuracy model, so this runs its pipeline with the test model. #[test] @@ -509,25 +468,12 @@ fn certified_frequency_evaluations_share_one_univmon_state() { .collect(); let strategies: Vec> = vec![Box::new(ASAPStrategies::new_with_planning_inputs( - &CHEAP_SUMMARY, + &PREFER_UNIVMON, &UnivMonEvidence, &EqualSplitAllocator, ))]; let space = search_workload_with_targets(roots, &strategies, &UnivMonEvidence).unwrap(); - let entry_indices: Vec = (0..queries.len()).collect(); - let selection = global_selection_with_summary_maintenance_lifecycles( - &space, - WorkloadDemand { - workload: &workload.query_workload, - data_workload: workload.data_workload.as_ref(), - entry_indices: &entry_indices, - }, - NOW_MS, - Some(Horizon(HORIZON_S)), - SummaryMaintenanceLifecycleCapabilities::default(), - &CHEAP_SUMMARY, - ) - .expect("selects"); + let selection = space.global_selection(&PREFER_UNIVMON); let assembled = space .roots .iter() @@ -565,14 +511,11 @@ fn certified_frequency_evaluations_share_one_univmon_state() { /// state, and entries keep their order around a literal scalar query. #[tokio::test] async fn scalar_queries_read_planned_summaries() { - let output = plan_promql( - &[ - ("1", 0.01), - ("scalar(quantile(0.99, rate(x[5m])))", 0.01), - ("quantile(0.99, rate(x[5m]))", 0.01), - ], - &CHEAP_SUMMARY, - ) + let output = plan_promql(&[ + ("1", 0.01), + ("scalar(quantile(0.99, rate(x[5m])))", 0.01), + ("quantile(0.99, rate(x[5m]))", 0.01), + ]) .await; assert_eq!(output.entry_indices(), vec![0, 1, 2]); let scalar_entries: Vec<_> = output.scalar_roots.iter().map(|(i, _)| *i).collect(); @@ -583,18 +526,12 @@ async fn scalar_queries_read_planned_summaries() { assert_eq!(output.scalar_plans.len(), 1); let read = &output.scalar_plans[0]; assert_eq!(read.entry_index, 1); - assert!(!read.plan.selected_raw_recompute, "{:?}", read.plan.root); - let read_states: Vec<_> = read - .plan - .deployments - .iter() - .map(|deployment| Rc::clone(&deployment.summary)) - .collect(); - assert!(!read_states.is_empty()); + let read_states = plan_states(read); + assert!(!read_states.is_empty(), "{:?}", read.root); assert!(same_states(&[read_states, states(&output).remove(0)])); let refs = output.scalar_roots[1].1.operator_refs(); assert_eq!(refs.len(), 1); - assert!(Rc::ptr_eq(refs[0], &read.plan.root)); + assert!(Rc::ptr_eq(refs[0], &read.root)); // The export keys each scalar query by its entry and points the scalar // read at the node the operator query exports: one summary, read twice. @@ -616,17 +553,17 @@ async fn scalar_queries_read_planned_summaries() { /// under a cost model that prefers summaries both read a sketch. #[tokio::test] async fn combined_scalar_references_read_summaries() { - let output = plan_promql( + let output = plan_promql_with( &[( "scalar(quantile(0.99, rate(x[5m]))) - scalar(quantile(0.5, rate(x[5m])))", 0.01, )], - &CHEAP_SUMMARY, + &PREFER_LARGE_KLL, ) .await; assert_eq!(output.scalar_plans.len(), 2); for read in &output.scalar_plans { - let sketches = OperatorNode::reachable(&read.plan.root) + let sketches = OperatorNode::reachable(&read.root) .into_iter() .filter(|node| { matches!( @@ -638,7 +575,7 @@ async fn combined_scalar_references_read_summaries() { ) }) .count(); - assert!(sketches > 0, "{:#?}", read.plan.root); + assert!(sketches > 0, "{:#?}", read.root); } } @@ -654,7 +591,7 @@ async fn identical_scalar_references_plan_once() { 2, ), ] { - let output = plan_promql(&[(query, 0.01)], &CHEAP_SUMMARY).await; + let output = plan_promql(&[(query, 0.01)]).await; assert_eq!(output.scalar_plans.len(), distinct, "{query}"); let (_, expr) = &output.scalar_roots[0]; for node in expr.operator_refs() { @@ -662,7 +599,7 @@ async fn identical_scalar_references_plan_once() { output .scalar_plans .iter() - .any(|read| Rc::ptr_eq(&read.plan.root, node)), + .any(|read| Rc::ptr_eq(&read.root, node)), "{query}" ); } @@ -679,17 +616,13 @@ async fn exact_state_roots_are_finalized() { "sum(rate(x[5m]))", "sum(sum_over_time(m[1m]) + sum_over_time(n[1m]))", ] { - let output = plan_promql(&[(query, 0.01)], &CHEAP_SUMMARY).await; - let plan = &output.plans[0].plan; + let output = plan_promql(&[(query, 0.01)]).await; + let root = &output.plans[0].root; assert!( - matches!( - plan.root.asap(), - Some(ASAPOp::FinalizeExactAccumulator { .. }) - ), - "{query}: {:#?}", - plan.root + matches!(root.asap(), Some(ASAPOp::FinalizeExactAccumulator { .. })), + "{query}: {root:#?}" ); - let dag = plan.execution_timed_dag().unwrap(); + let dag = output.execution_timed_dag().unwrap(); let root = dag .nodes .iter() diff --git a/crates/types/src/post_asap/mod.rs b/crates/types/src/post_asap/mod.rs index e282fccad..04e3264c2 100644 --- a/crates/types/src/post_asap/mod.rs +++ b/crates/types/src/post_asap/mod.rs @@ -20,7 +20,7 @@ //! sketch-valued edge types. //! //! The rest: accuracy guarantees ([`guarantee`]), maintained populations, -//! summary windows and maintenance lifecycle, and the execution timing / +//! summary-window panes, and the execution timing / //! data-state vocabulary ([`execution_data_state`]). pub mod execution_data_state; @@ -28,8 +28,6 @@ pub mod guarantee; pub mod maintained_population; pub mod query_time; pub mod sketch; -pub mod summary_maintenance; -pub mod summary_maintenance_lifecycle; pub mod summary_window; pub use crate::pre_asap::schema::{Field, FieldDataType, Schema}; @@ -51,12 +49,6 @@ pub use sketch::{ SketchCategory, SketchKind, SketchParams, SketchStatistic, StatModelKind, StatModelParams, SummaryInputExpr, SummaryUpdate, WaveletKind, WaveletParams, WeightDomain, }; -pub use summary_maintenance::SummaryMaintenanceMode; -pub use summary_maintenance_lifecycle::{ - EvaluationSchedule, OutputRepresentation, SummaryMaintenanceLifecycle, - SummaryMaintenanceLifecycleGuarantee, -}; pub use summary_window::{ - plan_pane_phase, validate_pane_coverage, PaneCoverageError, PaneLayout, SummaryWindowFramework, - WindowEdgeCoverage, + plan_pane_phase, validate_pane_coverage, PaneCoverageError, PaneLayout, WindowEdgeCoverage, }; diff --git a/crates/types/src/post_asap/summary_maintenance.rs b/crates/types/src/post_asap/summary_maintenance.rs deleted file mode 100644 index 4e7eaf205..000000000 --- a/crates/types/src/post_asap/summary_maintenance.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Planner-level construction mode for a materialized summary. -//! -//! An ASAP [`crate::ir::OperatorNode`] is a logical summary expression and deliberately -//! does not carry this choice: the same candidate may be built directly for -//! one workload or maintained incrementally for another. Planner search -//! attaches the selected mode to its lifecycle guarantee; downstream physical -//! compilation chooses its concrete implementation. - -/// How a summary deployment obtains its state, independent of implementation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SummaryMaintenanceMode { - /// Rebuild the summary from its complete input when the deployment needs - /// a value. No update stream is required. - DirectBuild, - /// Create the state once and apply input changes as they arrive. - Incremental, -} - -impl SummaryMaintenanceMode { - pub const fn as_str(self) -> &'static str { - match self { - Self::DirectBuild => "direct_build", - Self::Incremental => "incremental", - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn maintenance_modes_have_stable_export_names() { - assert_eq!(SummaryMaintenanceMode::DirectBuild.as_str(), "direct_build"); - assert_eq!(SummaryMaintenanceMode::Incremental.as_str(), "incremental"); - } -} diff --git a/crates/types/src/post_asap/summary_maintenance_lifecycle.rs b/crates/types/src/post_asap/summary_maintenance_lifecycle.rs deleted file mode 100644 index 798d861f6..000000000 --- a/crates/types/src/post_asap/summary_maintenance_lifecycle.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Planner-level summary-maintenance lifecycle vocabulary. -//! -//! A **summary-maintenance lifecycle** describes when one materialized summary -//! state is created, retained or shared, updated, and retired. It does not -//! describe the broader data lifecycle (collection, transport, and storage), -//! and it is not implied by a logical `SummaryAgg`. Physical planning compares -//! alternatives using the expected number and timing of reads, the source-data -//! arrival/update rate, state-operation costs, and runtime capabilities. - -use super::SummaryMaintenanceMode; -use crate::workload::{DurationMs, TimestampMs}; - -/// When an operator is evaluated. This is independent of whether it owns -/// state and how long that state is retained. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EvaluationSchedule { - OneShot, - PerUpdate, - OnRead, -} - -/// The form in which this deployment exposes its result to its consumer. The -/// consumer is the next operator in the execution plan that reads the -/// summary's output; for example, `Estimate` is the consumer in -/// `SummaryAgg -> Estimate`. The exposed result is ordinary rows, reusable -/// summary state, or a finalized value. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum OutputRepresentation { - PlainRows, - SummaryState, - FinalizedValue, -} - -/// Abstract policy for when one materialized summary state is created, -/// retained or shared, updated as data arrives, and retired. -/// -/// This is not the lifecycle of the source data or query. Query recurrence -/// provides the expected number and timing of reads; data arrival provides the -/// expected state-update demand. The planner combines those quantities with -/// costs and runtime capabilities to compare these policies. -#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum SummaryMaintenanceLifecycle { - Ephemeral, - Prepared { - #[serde(rename = "activate_at_ms")] - activate_at: TimestampMs, - #[serde(rename = "retire_at_ms")] - retire_at: TimestampMs, - }, - Shared { - #[serde(rename = "retention_ms")] - retention: DurationMs, - }, - ContinuouslyMaintained, -} - -/// The lifecycle commitment emitted for one materialized summary deployment. -/// -/// This names the summary-maintenance promise explicitly so consumers do not -/// confuse it with guarantees about the broader data lifecycle. Accuracy is a -/// separate [`super::ResultGuarantee`]. -#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SummaryMaintenanceLifecycleGuarantee { - #[serde(rename = "lifecycle")] - pub summary_maintenance_lifecycle: SummaryMaintenanceLifecycle, - #[serde(rename = "maintenance_mode")] - pub summary_maintenance_mode: SummaryMaintenanceMode, - pub evaluation_schedule: EvaluationSchedule, - pub output_representation: OutputRepresentation, -} diff --git a/crates/types/src/post_asap/summary_window.rs b/crates/types/src/post_asap/summary_window.rs index 6d45649b4..724a8d595 100644 --- a/crates/types/src/post_asap/summary_window.rs +++ b/crates/types/src/post_asap/summary_window.rs @@ -1,29 +1,13 @@ -//! Planner-level summary-window primitives. +//! Planner-level summary-window pane primitives. //! -//! These values identify the abstract window framework selected during -//! candidate search. They do not identify a runtime library, process, -//! placement, shard layout, storage backend, or deployment instance; those -//! choices belong to downstream physical compilation. +//! These values describe pane layout and window-edge coverage. They do not +//! identify a runtime library, process, placement, shard layout, storage +//! backend, or deployment instance; those choices belong to downstream +//! physical compilation. use crate::workload::RepeatedDemand; use serde::{Deserialize, Serialize}; -/// Abstract framework used to organize incrementally maintained summary -/// state over time. -/// -/// The built-in variants name semantics that the planner can compare across -/// implementations with defined planning and accuracy behavior. -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SummaryWindowFramework { - /// Disjoint, fixed-width windows. - Tumbling, - /// Overlapping logical windows, commonly realized from reusable panes. - Sliding, - /// Hierarchical buckets with exponentially increasing coverage. - ExponentialHistogram, -} - /// Concrete pane phase recorded in a catalog layout or inventory snapshot. /// Milliseconds are canonical throughout the shared contract. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -122,30 +106,6 @@ pub fn plan_pane_phase( mod tests { use super::*; - #[test] - fn built_in_frameworks_round_trip() { - for framework in [ - SummaryWindowFramework::Tumbling, - SummaryWindowFramework::Sliding, - SummaryWindowFramework::ExponentialHistogram, - ] { - let encoded = serde_json::to_string(&framework).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - framework - ); - } - } - - /// Opaque names cannot enter planning without defined window semantics. - #[test] - fn unimplemented_window_extensions_are_rejected() { - assert!(serde_json::from_value::( - serde_json::json!({"extension": "learned_window"}) - ) - .is_err()); - } - #[test] fn pane_only_evaluation_rejects_source_and_query_phase_mismatch() { let layout = PaneLayout { diff --git a/docs/design_docs/proposals/asap-aware-mapping/workload-demand-and-summary-lifecycle.md b/docs/design_docs/proposals/asap-aware-mapping/workload-demand-and-summary-lifecycle.md deleted file mode 100644 index 483e04503..000000000 --- a/docs/design_docs/proposals/asap-aware-mapping/workload-demand-and-summary-lifecycle.md +++ /dev/null @@ -1,758 +0,0 @@ -# Design: Query Workloads, Data Workloads, and Summary Lifecycle Maintenance - -> Status: partially implemented design. Workload types and -> [lifecycle planning APIs](../../../../crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs) -> implement the bounded planning path described below. The current-support and -> future-work sections distinguish available behavior from broader search, -> forecast integration and runtime deployment work. - -## Audience and context - -This document is for ASAPPlanner designers, architects, researchers, and -developers working on workload-aware plan selection. It defines how the -planner should describe query workload, data workload, and the lifecycle of -summary state. It is a design contract, not a description of the current -public Rust API. - -The terminology follows the ProjectASAP -[glossary](https://github.com/ProjectASAP/internal-docs/blob/03e1c70f5af3ae9221471898541067eee7f86338/glossary.md). -That glossary is authoritative for the meanings of data workload, query -workload, ad-hoc and predictable queries, one-time and repeated queries, -real-time and longitudinal queries, output cardinality, and lookback window. -This document maps those concepts into planner responsibilities and records -where the current model is incomplete. - -This design is orthogonal to -[end-to-end accuracy guarantees](end-to-end-accuracy-guarantees.md). Accuracy -decides whether a candidate is correct enough. Workload demand and state -lifecycle decide whether building, maintaining, sharing, or recomputing that -candidate is worthwhile. Neither decision may override the other. - -## Problem and why now - -A summary operator does not imply one execution lifecycle. The same exact or -approximate summary can be: - -- built once from data at rest and discarded after one query; -- prepared before a known future query and retired afterward; -- shared across a bounded set of requests; or -- maintained incrementally as data continues to arrive. - -Likewise, an exact stateless operator may run once over a batch, once per -update in an incremental pipeline, or once per readout. Operator statefulness, -execution schedule, and output representation are separate properties. - -The query expression alone cannot determine those properties. The same query -may arrive unexpectedly during exploration, run once at a scheduled time, or -repeat every ten seconds on a dashboard. Planning summary state from syntax -alone either misses reuse or invents reuse that the workload does not justify. - -The current `PlanningWorkload` separates query demand from data arrival. -Query entries include predictability, recurrence or invocation count, accuracy, -and time selection; `DataWorkload` contains arrival and empirical facts. -These fields do not themselves select a summary-maintenance lifecycle. -The [input/output/workflow design](../../architecture/input-output-workflow.md) -is authoritative for current fields, defaults, and public call sequences. - -## Inputs, outputs, and end-to-end behavior - -For the broader lifecycle design, four categories of information matter -(these are not four current top-level Rust fields): - -1. logical queries, which define query semantics; -2. query workload, including per-query accuracy and latency requirements, - predictability, recurrence, and queried time scope; -3. data-workload characteristics, including arrival, volume, cardinality, and - distribution; -4. existing summaries and the lifecycle actions available to the deployment. - -Candidate search outputs `CandidateLogicalASAPDAGs`. The implemented lifecycle-aware workflow -then returns a `SummaryMaintenanceLifecyclePlan` per query root, containing the -Post-ASAP DAG and maintenance decisions. It can choose exact raw recomputation -when summary maintenance does not beat raw cost or comparable costs are missing. A -state deployment states whether a summary is ephemeral, prepared, shared for a -bounded period, or continuously maintained. It retains costs, assumptions, and -structured rejection reasons. Exporting full input provenance remains a later -integration. - -```text - logical queries ---+ - query workload -----+ - data workload ---+--> candidate plans - available summaries ---+ -> semantic and accuracy legality - -> lifecycle alternatives - -> horizon-normalized cost - -> selected plan + deployments -``` - -For an unpredictable one-time query, the planner may read an existing summary, -build an ephemeral summary, or recompute from raw data. It must not assume -future reuse. For a predictable one-time query, it may additionally compare -preparing state in advance with building or recomputing at execution time. For -repeated queries, it may amortize build and maintenance cost across reads over -an explicit horizon. - -### End-to-end decision order - -```text -normalize query and data workloads - -> derive recurrence, time-scope, and data evidence - -> enumerate semantic plan alternatives - -> enumerate legal execution contracts and state lifecycles - -> validate summary capabilities and phase constraints - -> derive and check accuracy guarantees - -> normalize one-time and rate costs over an explicit horizon - -> rank legal alternatives and compare the selected summary deployment - with raw recomputation - -> emit plan, deployments, assumptions, and rejected alternatives -``` - -## Goals and non-goals - -### Goals - -- Represent glossary-defined query-workload and data-workload concepts without - collapsing independent axes into one enum. -- Separate an operator's statefulness from its execution schedule and the - lifecycle of the state it produces. -- Make unknown demand explicit and fail closed rather than treating it as zero - or infinite reuse. -- Compare one-time and rate-valued costs only through an explicit horizon. -- Explain why a selected plan builds, reuses, maintains, or avoids summary - state. -- Preserve a minimal path from the current batch/repeating workload and - recurrence profile to the proposed model. - -### Non-goals - -- Scheduling jobs, assigning machines, admission control, or executing queries. -- Predicting future query text inside ASAPPlanner. -- Defining a sketch runtime or state-storage protocol. -- Choosing a concrete forecasting algorithm for uncertain demand. -- Changing accuracy targets or guarantee algebra. - -## Heilmeier questions - -- **What are we trying to do?** Choose whether summary state should be built, - maintained, shared, reused, or avoided for different query workloads - and data workload. -- **How is it done today, and what are the limits?** The planner distinguishes - one-shot counts, fixed repeating intervals, and an ingest-rate proxy. It - cannot distinguish an unexpected exploratory query from a scheduled one-time - report, or data at rest from continuous ingestion as an explicit mode. -- **What is new, and why will it succeed?** Orthogonal workload axes and an - explicit state lifecycle let the existing recurrence formulas compare the - same summary under different deployment choices without changing query - semantics. -- **Who cares?** Users need predictable latency and cost; operators need to - know what state will exist and for how long; planner developers need demand - assumptions to be auditable. -- **What are the risks and costs?** More inputs can make planning harder to - configure, forecasts may be stale, and a large lifecycle search space can - increase planning cost. -- **What are the checks for success?** The acceptance cases below must produce - different lifecycle alternatives and cost terms for identical query syntax - under different workload contracts. - -## Proposed design - -### Authoritative concepts and ownership - -| Concept | Authoritative layer | Reason | -| --- | --- | --- | -| Query meaning | Pre-ASAP query IR | Workload metadata must not change semantics | -| Accuracy requirement | Query workload (per-query) | The required result fidelity may be explicit or supplied by the normalization default | -| Response-latency requirement | Query workload (per-query) | The optional end-to-end response-time bound belongs to one query execution | -| Query workload | Workload input | Arrival and recurrence are not inferable from syntax | -| Data workload | Workload input | Ingestion and distribution describe the data, not query workload | -| Summary capability | Summary properties | Merge, delete, and update support constrain legal lifecycles | -| State lifecycle | Physical planning decision | Lifecycle is selected, not declared by `SummaryAgg` | -| Cost | Cost model and explanation | Cost consumes all inputs but does not define their meaning | - -### Query workload - -Accuracy and latency are separate per-query requirements within the query -workload. They constrain different planner decisions and must not be collapsed -into one SLA value: - -```rust -enum AccuracyRequirement { - /// The caller supplied the required result fidelity. - Explicit(AccuracyTarget), - /// The source omitted accuracy; normalization applies the exact default. - ImplicitExact, -} - -enum LatencyRequirement { - /// Maximum permitted end-to-end latency for one query execution. - ExplicitMax(Duration), - /// The caller supplied no latency bound. - Unspecified, -} - -struct QueryRequirements { - accuracy: AccuracyRequirement, - response_latency: LatencyRequirement, -} -``` - -An omitted accuracy field is not an unknown accuracy target and does not permit -arbitrary approximation: the current normalization policy makes it -`ImplicitExact`. Keeping that variant distinct from `Explicit(Exact)` preserves -whether the caller chose exactness or inherited the default. An unspecified -response-latency requirement imposes no response-time constraint; it is not a -zero-duration bound or evidence that every latency is acceptable. Accuracy is -checked as a legality constraint. The normalized model preserves response -latency, but the current planner does not yet reject plans against that bound. - -#### Classification axes - -The glossary classifications must be modeled independently. - -##### Predictability - -```rust -enum Predictability { - /// The query shape is not known before arrival. - AdHoc, - /// The query or parameterized template is known before execution. - Predictable { - known_at: Option, - }, - /// The caller supplied no reliable classification. - Unknown, -} -``` - -`AdHoc` does not mean repeated or one-time. It means the query shape was not -known in advance. The glossary currently places exploratory/ad-hoc queries in -the one-time category, so the MVP should accept `AdHoc + OneTime` and reserve -other combinations until a concrete use case establishes their semantics. - -##### Recurrence - -```rust -enum QueryRecurrence { - OneTime { - invocations: u64, - execute_at: Option, - }, - Repeated { - demand: RepeatedDemand, - }, - Unknown, -} - -enum RepeatedDemand { - FixedInterval(Duration), - Scheduled(Vec), - EstimatedRate(DemandEstimate), -} - -struct DemandEstimate { - /// Time range over which the demand was measured or forecast. - observation_window: ObservationWindow, - /// Expected demand, expressed in exactly one form. - expected: ExpectedDemand, - /// Highest expected invocation rate within the observation window. - peak_rate: Option, - /// Highest expected number of simultaneously executing invocations. - max_concurrency: Option, - /// Confidence in this estimate, in the inclusive range [0.0, 1.0]. - confidence: Confidence, - source: EvidenceSource, - observed_at: Option, - valid_for: Option, -} - -enum ExpectedDemand { - /// Expected total invocations over `observation_window`. - InvocationCount(u64), - /// Expected average invocations per second over `observation_window`. - AverageRate(Rate), -} - -struct ObservationWindow { - start: Timestamp, - end: Timestamp, -} - -struct Confidence(f64); -``` - -One-time means no recurrence is expected for that workload entry. Several -one-time consumers may still share a subplan within a submitted workload. -Repeated means the same query expression over its selected data is evaluated -over time, matching the glossary. Parameterized templates require an explicit -equivalence policy before their executions count as the same query. - -Query-workload volume is more than an average rate. Cost and latency can differ -for the same total request count when requests arrive in bursts or concurrently. -`ExpectedDemand` makes invocation count and average rate alternative -representations, preventing conflicting values in one estimate. The observation -window must be non-empty, rates must be finite and non-negative, and -`Confidence` must be between zero and one. Fixed intervals and explicit -schedules are declarations rather than estimates and do not need fabricated -confidence. The MVP may cost only invocation count and evaluation rate, but it -must preserve unsupported volume characteristics for explanation rather than -silently discarding them. - -##### Queried time scope - -```rust -enum QueryTimeScope { - RealTime, - Longitudinal, - Mixed, - Unknown, -} -``` - -`QueryTimeScope` is not a response-latency requirement. It classifies the event -time of the data selected by the query; `LatencyRequirement` constrains the -wall-clock time allowed to produce the result. They are independent: a -longitudinal query over archived data may require a 100 ms response, while a -real-time query over the latest data may permit a 30 second response. - -This classification is not derived only from a numeric lookback. A five-minute -lookback over recent data is real-time; the same duration over archived data is -not. Planning input should therefore carry the classification and the concrete -time selection separately: - -```rust -struct TimeSelection { - scope: QueryTimeScope, - lookback: Option, - as_of: Option, -} -``` - -For example, the same five-minute lookback has a different scope depending on -whether it is anchored at the current planning time or at a historical time: - -```rust -// The last five minutes: real-time. -TimeSelection { - scope: QueryTimeScope::RealTime, - lookback: Some(Duration::minutes(5)), - as_of: None, -} - -// A five-minute interval from archived data: longitudinal. -TimeSelection { - scope: QueryTimeScope::Longitudinal, - lookback: Some(Duration::minutes(5)), - as_of: Some(timestamp!("2024-01-01T12:05:00Z")), -} -``` - -`lookback` is a query property already represented by temporal query nodes in -some frontends. The normalized workload should reference or derive it rather -than introduce a second conflicting value. - -### Data workload is separate from query workload - -```rust -enum DataArrival { - AtRest, - ContinuouslyIngesting, - Mixed, - Unknown, -} - -/// Statistical distribution of keys in the input data. -enum DataDistribution { - /// A small number of keys account for most observations. - Zipf, - /// Keys are approximately equally likely. - Uniform, - /// Observations arrive in bursts with a temporarily concentrated key set. - Bursty, -} - -struct DataWorkload { - arrival: DataArrival, - ingestion_volume: Evidence, - ingestion_rate: Evidence, - input_cardinality: Evidence, - distribution: Evidence, -} -``` - -`DataDistribution` reuses the existing ASAPPlanner classification. It describes -the key-frequency shape used by summary accuracy and cost models, not whether -data arrives continuously. An unavailable or unsupported distribution is -represented by `Evidence.value = None` rather than by assuming the default -distribution. - -The former `DataCharacteristics` was a stale, continuous-ingestion-specific -case built around series count and samples per second. `DataWorkload` replaces -it as the normalized input rather than embedding that special case in the -general model. Data at rest may have row count and scan statistics without a -nonzero ingestion rate. Unknown arrival must not be interpreted as continuously -ingesting or at rest. - -Every empirical value uses an evidence wrapper conceptually containing: - -```rust -struct Evidence { - value: Option, - source: EvidenceSource, - observed_at: Option, - valid_for: Option, -} -``` - -This reuses the provenance and freshness principles from empirical summary -parameter configuration. Missing, stale, or future-dated evidence remains -unknown. - -### Output cardinality is a derived or evidenced cost input - -Output cardinality depends on input cardinality and grouping columns. The -planner may derive it analytically, accept a catalog estimate, or leave it -unknown. The source and freshness metadata must be preserved because output -cardinality affects summary size, read cost, post-processing cost, and network -cost. It is not a query correctness requirement. - -### Separate operator state, schedule, and output - -The physical design must not use `SummaryAgg` as shorthand for incremental -maintenance. - -```rust -enum OperatorState { - Stateless, - Stateful { - mergeable: bool, - deletable: bool, - }, -} - -enum EvaluationSchedule { - OneShot, - PerUpdate, - OnRead, -} - -enum OutputRepresentation { - PlainRows, - SummaryState, - FinalizedValue, -} -``` - -A one-shot sketch builder is stateful while it consumes its input, but it does -not imply long-lived incremental maintenance. A stateless transform can run -`PerUpdate` before a downstream maintained summary. These types describe an -execution contract; they do not replace semantic operators in the post-ASAP IR. - -### State lifecycle is a plan alternative - -```rust -enum StateLifecycle { - Ephemeral, - Prepared { - activate_at: Timestamp, - retire_at: Timestamp, - }, - Shared { - retention: Duration, - }, - ContinuouslyMaintained, -} -``` - -- `Ephemeral` builds state for one submitted workload and discards it afterward. -- `Prepared` builds or begins maintaining state before a predictable query and - retires it after the known need ends. -- `Shared` retains state for multiple consumers over a bounded lifetime. -- `ContinuouslyMaintained` applies data updates until an explicit later - deployment decision retires the state. - -The summary family and its properties constrain which lifecycles are legal. -For example, an append-only sketch may support continuous inserts but not a -sliding-window lifecycle requiring deletion. Lifecycle legality is checked -before cost ranking, like accuracy legality. Deployments provide these -per-summary properties through `summary_lifecycle_capabilities`; moving -real-time windows require deletion support as well as incremental updates. - -### Existing summaries are planning input - -An ad-hoc query cannot justify creating permanent state from unknown future -demand, but it may use compatible state that already exists. The planning -problem therefore needs a state catalog describing identity, parameters, -coverage, freshness, accuracy guarantee, lifecycle, and ownership. Catalog -integration is a separate implementation increment; this design only requires -that "reuse existing" and "create new" remain distinguishable alternatives. - -### Cost over a horizon - -`H` is the optimization horizon: the future wall-clock duration over which the -planner compares one-time and recurring costs. The existing cost model -represents it in seconds: - -```rust -/// A finite, strictly positive optimization duration, in seconds. -struct Horizon(f64); -``` - -The horizon is not the query lookback, the queried time scope, or the response -latency bound. It answers only "over how much future execution time should -these alternatives be costed?" All alternatives in one decision must use the -same `H`. `reads(H)` is the number of query evaluations expected or scheduled -within that horizon; for a fixed evaluation rate it is -`H * evaluation_rate`, plus any separately modeled one-time invocations. -Who supplies `H`, and whether a deployment may default it, remains an explicit -architecture decision below. If no horizon is available, the planner must not -compare a one-time cost with a rate-valued cost. - -For a stateful incremental alternative over horizon `H`: - -```text -total(H) = build_cost - + H * update_rate * maintenance_cost_per_update - + reads(H) * summary_read_cost - + H * retention_cost_rate - + retirement_cost -``` - -For repeated raw recomputation: - -```text -total(H) = reads(H) * raw_recompute_cost -``` - -The current lifecycle-aware materialization sums the selected summary -deployments and can replace that plan with raw recomputation when the raw cost -is lower or the summary lifecycle is uncostable. Jointly reconsidering every -sibling semantic candidate under lifecycle costs remains a later optimizer -integration; this document does not claim that broader search is implemented. - -For an ephemeral summary: - -```text -total = invocations * (build_cost + summary_read_cost + retirement_cost) -``` - -`retirement_cost` consistently means the one-time cost of ending a summary -state lifecycle, including deallocation or other cleanup. For ephemeral state, -retirement happens immediately after each invocation; for prepared, shared, or -continuously maintained state, it happens when that deployment is retired. - -For prepared state, update and retention terms apply only between activation -and retirement. Existing state does not pay a new build cost, but its catalog -provenance must establish that assumption. - -The existing `Cost`, `CostRate`, `EvaluationRate`, `UpdateRate`, `Horizon`, and -`total_cost` types are the minimum viable foundation. The implementation should -extend their explanations and lifecycle coverage instead of creating a second -recurrence cost system. - -### Unknown and uncertain demand - -Unknown demand is not zero demand and is not evidence of future reuse. The MVP -policy is: - -- do not select newly created long-lived state solely on unknown future reuse; -- allow raw recomputation, ephemeral build, and reuse of already available - compatible state; -- retain an explicit explanation of the missing demand evidence; and -- require an explicit planning objective before using an estimated demand - distribution. - -Future uncertain-demand support may add expected-cost, percentile-cost, -worst-case, or regret objectives. Those policies must consume a typed estimate -with confidence and provenance; they are not implicit behavior of -`Predictability::Unknown`. - -## Review against the ProjectASAP glossary - -The glossary review found the following required coverage and current gaps. - -| Glossary concept | Current ASAPPlanner representation | Missing design support | -| --- | --- | --- | -| Data at rest vs continuously ingesting | `DataArrival` is explicit | Runtime/catalog-specific arrival discovery remains external | -| Ingestion volume | `DataWorkload::ingestion_volume` carries evidence | A concrete time basis for volume remains deployment-specific | -| Ingestion rate | Evidenced independently from query evaluation rate | Preserve richer unit/provenance metadata when integrations require it | -| Input cardinality | Evidenced workload-level cardinality feeds accuracy | Per-dataset/metric/column scoping remains future work | -| Data distribution | Evidenced built-in enum | Permit deployment-specific distributions later | -| Ad-hoc vs predictable | `Predictability` is independent from recurrence | Parameterized-template equivalence remains open | -| One-time vs repeated | One-time, fixed, scheduled, estimated, and unknown recurrence | Forecast-policy integration remains future work | -| Query volume and characteristics | Estimates preserve average/count, peak, concurrency, confidence, and freshness | Peak and concurrency are not yet consumed by cost or latency models | -| Real-time vs longitudinal | `TimeSelection` carries scope, lookback, and `as_of` | Conflict policy with temporal IR remains open | -| Output cardinality | May be inferred locally; no common evidenced input | Add derived/evidenced value and provenance for costing | -| Lookback window | Represented in temporal query shapes/frontends | Establish query IR as authority and expose it to workload costing | -| CTSA pipeline | Not explicitly modeled | Keep as architectural context; planner consumes collect/store/analyze facts but does not model transmission topology in the MVP | -| CSP(F) | Cost and fidelity partly modeled | Treat scale/performance/fidelity as objectives and constraints; do not collapse fidelity into cost | - -Two terminology constraints apply: - -1. A repeated query is not inherently a streaming-data workload. It may - repeatedly query data at rest. -2. A one-time query is not inherently stateless. A predictable one-time query - may justify prepared state, while an ephemeral summary is stateful during - its one execution. - -## Minimal complexity - -The minimum input model is determined by the downstream applications selected -for integration, not by a context-free notion of the fewest possible fields. -Each supported use case must contribute the workload facts that can change -plan legality, accuracy, lifecycle, or cost: - -- Time-series metric queries require queried time scope and lookback. -- Repeated dashboard queries, including an ASAPQuery integration, require - recurrence and evaluation frequency so the planner can cost reuse and - maintenance across executions. -- Batch queries over data at rest require an explicit at-rest arrival mode and - must not be assigned a fabricated ingestion rate. -- Summary techniques whose accuracy depends on the input distribution require - evidenced distribution characteristics; omitting them must produce unknown - accuracy or a conservative fallback rather than a favorable assumption. - -The initial implementation should include the union of fields required by its -committed integrations. Additional workload dimensions should be added when a -new downstream use case demonstrates that they affect a planning decision. - -The simplest alternative is to extend `BatchEntry` with optional schedule and -classification fields and extend `RepeatingEntry` with time scope. That is a -reasonable serialization migration, but it is not a sufficient conceptual -model: it continues to make predictability and recurrence mutually exclusive -container choices, and it has no place for data arrival or state lifecycle. - -The minimum new conceptual layers are therefore: - -1. orthogonal query-demand metadata, required because glossary categories are - not one taxonomy; -2. data-workload metadata, required because ingestion does not describe query - recurrence; -3. state lifecycle as a physical alternative, required because one summary - operator can be deployed ephemerally or incrementally. - -No separate scheduler, forecasting framework, or replacement cost model is -introduced. Existing query IR, summary properties, accuracy model, and -recurrence cost types remain authoritative in their domains. - -## Alternatives and decisions - -### Encode workload class as one enum - -Rejected. Variants such as `AdHoc`, `OneShot`, and `Repeated` overlap: -predictability and recurrence are different facts, and time scope is a third. - -### Infer demand from query syntax or submitted root count - -Rejected. Syntax contains no evidence of future arrival, and several roots in -one request establish only current structural sharing. - -### Treat every summary as continuously maintained - -Rejected. It excludes ephemeral construction over data at rest and overcharges -one-time plans. It also hides deployment lifetime from explanations. - -### Treat every one-time query as raw recomputation - -Rejected. An ephemeral summary may reduce memory or network cost during one -execution, an existing summary may already answer the query, and a predictable -future query may justify preparation. - -### Fold fidelity into a scalar cost - -Rejected. Accuracy and semantic correctness are constraints checked before -ranking. A cheaper plan cannot purchase permission to violate fidelity. - -### Extend the existing recurrence profile only - -Partially accepted for implementation reuse, rejected as the whole model. -`RecurrenceProfile` is an aggregated cost context for a target. It should remain -the derived input to cost decisions, while normalized workload metadata retains -predictability, time scope, provenance, and lifecycle information needed before -and after aggregation. - -## Quality attributes and evidence - -- **Understandability:** explanations use glossary terms and show each axis - separately. Proxy: reviewers can distinguish repeated queries from continuous - ingestion in exported plan evidence. -- **Debuggability:** selected and rejected lifecycle alternatives record costs, - horizon-derived decisions, assumptions, and typed rejection reasons. Full - demand/data provenance in exported explanations remains future work. -- **Maintainability:** current recurrence types remain the cost authority; - normalized workload types remain the source authority. No duplicate formula - system is introduced. -- **Extensibility:** scheduled and estimated recurrence fit without changing - query semantics. Forecasting policies remain pluggable planning objectives. -- **Performance:** lifecycle enumeration expands the candidate space. The MVP - should generate only capability-compatible alternatives and deduplicate - equivalent deployments before ranking. -- **Operability:** every long-lived state has activation, retention or retirement - semantics and ownership in output. Concrete runtime APIs are future work. -- **Security and privacy:** query logs and empirical distributions may be - sensitive. Provenance must identify a source without requiring raw query-log - contents to be embedded in exported plans. - -## Acceptance and test design - -Realization acceptance is defined by identical logical queries producing -different legal lifecycle choices under different workload contracts: - -1. **Unpredictable one-time query:** offers raw recomputation, compatible - existing state, and ephemeral build; does not justify new continuous state. -2. **Predictable scheduled one-time query:** may offer prepared state with a - bounded activation and retirement period. -3. **Repeated query over continuously ingesting data:** compares incremental - maintenance and repeated recomputation using distinct update and evaluation - rates over an explicit horizon. -4. **Repeated query over data at rest:** uses evaluation rate without inventing - maintenance updates. -5. **Real-time and longitudinal queries with the same expression:** preserve - different time selections and may receive different scan, retention, and - summary alternatives. -6. **Unknown demand:** remains unknown in explanation and cannot make a newly - created long-lived state win through assumed reuse. -7. **Mixed one-time and repeated consumers:** requires an explicit horizon and - accounts for shared build cost once. -8. **Accuracy failure:** rejects a lifecycle regardless of favorable workload - cost. - -Focused unit tests should cover normalization, invalid combinations, evidence -freshness, lifecycle capability checks, and dimensional cost arithmetic. -End-to-end tests should cover cases 1–8 through candidate selection and exported -explanations. A reviewer who did not implement the workload types should design -or review at least the unknown-demand and mixed-consumer cases; that independent -review has not occurred for this design document. - -## Risks, rollout, and exit criteria - -The implementation should roll out additively: - -1. add normalized metadata and explanations while preserving current - batch/repeating behavior; -2. derive the existing `RecurrenceProfile` from the richer model; -3. add ephemeral and existing-state alternatives; -4. add prepared and continuously maintained lifecycle selection; -5. integrate empirical demand and state catalogs only when provenance and - freshness contracts are available. - -Compatibility requires old workloads to normalize without changing their -current decisions when no new metadata is supplied. Unknown new fields must -take the documented conservative path rather than acquire optimistic defaults. - -Open decisions requiring architecture or product input: - -- whether predictable parameterized query templates count as the same repeated - query and under which equivalence relation; -- who supplies the optimization horizon and whether a deployment may define a - default for purely repeated workloads; -- which planning objective governs uncertain demand; -- how state ownership, quota, and retirement requests cross the planner/runtime - boundary; -- whether real-time versus longitudinal is supplied by the caller, derived by a - policy using `as_of` and lookback, or both with conflict diagnostics; and -- the minimum evidence freshness required before empirical workload data may - affect selection. - -The design exits draft status when these decisions have owners, the normalized -input has a compatibility plan, and acceptance cases 1–8 can be expressed in -fixtures without runtime-specific assumptions. diff --git a/tools/dag-viewer/README.md b/tools/dag-viewer/README.md index 8c55aef56..182c0f94c 100644 --- a/tools/dag-viewer/README.md +++ b/tools/dag-viewer/README.md @@ -102,15 +102,6 @@ to calibrate against, and `--planner-cost-json` once there is. Without either flag, `--post-asap` exports the raw DAG only. -The viewer also accepts the JSON produced by -`export_summary_maintenance_plan`. It renders the materialized summary DAG as -a single lifecycle-plan lane. Selecting a `SummaryAgg` shows the chosen -lifecycle and maintenance mode together with every alternative's cost, -assumptions, and rejection reason. The selected-node panel also shows the -plan-level summary-versus-raw decision, costs, horizon, expected reads, and -evaluation/update rates. Raw-recomputation plans retain that decision summary -even though they have no deployed `SummaryAgg` to annotate. - ## Standalone HTML ```sh diff --git a/tools/dag-viewer/lifecycle-summary-maintenance.png b/tools/dag-viewer/lifecycle-summary-maintenance.png deleted file mode 100644 index e873ffd9fe759a3633010fbbc91eec82cee604b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 174559 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

9Mud@T`)O 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 diff --git a/tools/dag-viewer/render.py b/tools/dag-viewer/render.py index e9e101b0a..db1bc0cf6 100755 --- a/tools/dag-viewer/render.py +++ b/tools/dag-viewer/render.py @@ -261,23 +261,6 @@ def load_workload(paths: list[Path]) -> dict: for path in paths: data = json.loads(path.read_text()) incoming = data.get("queries", []) - if not incoming and isinstance(data.get("dag"), dict) and isinstance(data.get("deployments"), list): - incoming = [{ - "name": path.stem or "Summary maintenance plan", - "dag": data["dag"], - "post_dag": data["dag"], - "lifecycle_plan": True, - "lifecycle_summary": { - "selected_raw_recompute": data.get("selected_raw_recompute", False), - "summary_total_cost": data.get("summary_total_cost"), - "raw_recompute_total_cost": data.get("raw_recompute_total_cost"), - "horizon_seconds": data.get("horizon_seconds"), - "evaluation_rate_per_second": data.get("evaluation_rate_per_second"), - "update_rate_per_second": data.get("update_rate_per_second"), - "expected_reads": data.get("expected_reads"), - "deployment_count": len(data["deployments"]), - }, - }] for q in incoming: name = q["name"] if name in seen_names: diff --git a/tools/dag-viewer/test_render.py b/tools/dag-viewer/test_render.py index 20ccb64cd..e78e978f0 100644 --- a/tools/dag-viewer/test_render.py +++ b/tools/dag-viewer/test_render.py @@ -79,43 +79,6 @@ def test_boundary_terms_and_provenance_survive_standalone_export(self): self.assertIn(annotation["model_version"], html) self.assertIn(annotation["evidence_version"], html) - def test_loads_summary_maintenance_export_as_a_lifecycle_plan(self): - dag = named_dag("unused")["dag"] - summary = { - "selected_raw_recompute": True, - "summary_total_cost": None, - "raw_recompute_total_cost": 7.5, - "horizon_seconds": 60.0, - "evaluation_rate_per_second": 2.0, - "update_rate_per_second": 3.0, - "expected_reads": 120.0, - } - with tempfile.TemporaryDirectory() as d: - path = Path(d) / "lifecycle.json" - path.write_text(json.dumps({"dag": dag, "deployments": [], **summary})) - workload = load_workload([path]) - - query = workload["queries"][0] - self.assertEqual(query["name"], "lifecycle") - self.assertTrue(query["lifecycle_plan"]) - self.assertEqual(query["post_dag"], dag) - self.assertEqual( - query["lifecycle_summary"], - {**summary, "deployment_count": 0}, - ) - - def test_preserves_summary_plan_deployment_count(self): - dag = named_dag("unused")["dag"] - with tempfile.TemporaryDirectory() as d: - path = Path(d) / "lifecycle.json" - path.write_text(json.dumps({"dag": dag, "deployments": [{}, {}]})) - workload = load_workload([path]) - - self.assertEqual( - workload["queries"][0]["lifecycle_summary"]["deployment_count"], - 2, - ) - def test_merges_queries_across_files_in_order(self): with tempfile.TemporaryDirectory() as d: f1 = Path(d) / "a.json" diff --git a/tools/dag-viewer/viewer.js b/tools/dag-viewer/viewer.js index 36649c03a..d8191dc1a 100644 --- a/tools/dag-viewer/viewer.js +++ b/tools/dag-viewer/viewer.js @@ -122,13 +122,7 @@ function loadFiles(fileList) { reader.onload = () => { try { const parsed = JSON.parse(reader.result); - const incoming = parsed.queries || (parsed.dag && parsed.deployments ? [{ - name: file.name.replace(/\.json$/i, '') || 'Summary maintenance plan', - dag: parsed.dag, - post_dag: parsed.dag, - lifecycle_plan: true, - lifecycle_summary: lifecyclePlanSummary(parsed), - }] : []); + const incoming = parsed.queries || []; const existingNames = new Set(queries.map((q) => q.name)); // One batch id per *file* — every query this one dag_export // invocation produced shares its decision.id numbering. @@ -137,7 +131,7 @@ function loadFiles(fileList) { let name = q.name; if (existingNames.has(name)) name = `${q.name} (${file.name})`; existingNames.add(name); - queries.push({ name, dag: q.dag, source: q.source, replacements: q.replacements || [], post_dag: q.post_dag, workload_cost: q.workload_cost, lifecycle_plan: q.lifecycle_plan, lifecycle_summary: q.lifecycle_summary, sourceBatch }); + queries.push({ name, dag: q.dag, source: q.source, replacements: q.replacements || [], post_dag: q.post_dag, workload_cost: q.workload_cost, sourceBatch }); }); } catch (err) { alert(`Failed to parse ${file.name}: ${err.message}`); @@ -154,19 +148,6 @@ function loadFiles(fileList) { fileInput.value = ''; } -function lifecyclePlanSummary(plan) { - return { - selected_raw_recompute: Boolean(plan.selected_raw_recompute), - summary_total_cost: plan.summary_total_cost ?? null, - raw_recompute_total_cost: plan.raw_recompute_total_cost ?? null, - horizon_seconds: plan.horizon_seconds ?? null, - evaluation_rate_per_second: plan.evaluation_rate_per_second ?? null, - update_rate_per_second: plan.update_rate_per_second ?? null, - expected_reads: plan.expected_reads ?? null, - deployment_count: Array.isArray(plan.deployments) ? plan.deployments.length : 0, - }; -} - function getParticipants() { return Array.from(participants) .filter((i) => i >= 0 && i < queries.length) @@ -481,28 +462,6 @@ function renderPrePostAsap() { return; } hideModeHint(); - if (selected.length === 1 && selected[0].lifecycle_plan) { - viewTitleEl.textContent = `Summary maintenance: ${selected[0].name}`; - const elements = laneElements( - 'summary-maintenance', - `${selected[0].name} · lifecycle plan`, - selected[0].post_dag, - selected[0], - 'post', - ); - buildCy(elements); - finalizeDAGInteractions(); - applyHighlighting(); - const initial = cy.nodes().filter((node) => !node.data('isLane') && node.data('root')).first(); - if (initial && initial.length) { - initial.select(); - showPrePostDetail(initial.data()); - } else { - clearDetail(); - } - fitAndSyncZoom(); - return; - } viewTitleEl.textContent = selected.length === 1 ? `Pre/Post-ASAP: ${selected[0].name}` : `Pre/Post-ASAP workload union: ${selected.length} queries`; @@ -655,7 +614,6 @@ function laneElements(laneId, laneLabel, dag, query, stage, laneCost) { laneId, stage, queryName: query.name, - lifecycleSummary: query.lifecycle_summary, translations: translationsForNode(query, node, stage), }, classes: node.decision && typeof node.decision.benefit?.value === 'number' @@ -1009,33 +967,6 @@ function showPrePostDetail(data) { : ''; const decisions = data.translations || []; - const planSummary = data.lifecycleSummary; - let planSummaryHtml = ''; - if (planSummary) { - const value = (item) => item === null || item === undefined ? 'unknown' : String(item); - const selected = planSummary.selected_raw_recompute - ? 'Raw recomputation' - : 'Summary maintenance'; - planSummaryHtml = `

`; - } - const lifecycle = node.detail && node.detail.summary_maintenance; - let lifecycleHtml = ''; - if (lifecycle) { - const selected = lifecycle.selected; - const selectedText = selected - ? `${selected.lifecycle.kind} · ${selected.maintenance_mode} · ${selected.evaluation_schedule} · ${selected.output_representation}` - : 'No lifecycle selected'; - const alternatives = (lifecycle.alternatives || []).map((alternative) => { - const status = alternative.rejection ? `rejected: ${alternative.rejection}` : `cost: ${alternative.total_cost}`; - const assumptions = (alternative.assumptions || []).join('; ') || 'none'; - return `
${escapeHtml(alternative.lifecycle.kind)}
${escapeHtml(status)}
assumptions: ${escapeHtml(assumptions)}
`; - }).join(''); - lifecycleHtml = `

Summary maintenance lifecycle

Selected: ${escapeHtml(selectedText)}
${alternatives}
`; - } let translationHtml = ''; if (decisions.length > 0) { const cards = decisions.map((entry) => ` @@ -1057,9 +988,7 @@ function showPrePostDetail(data) { ${escapeHtml(chipLabel)}
${escapeHtml(node.label)}
${rootHtml} - ${planSummaryHtml} ${translationHtml} - ${lifecycleHtml}

IR node content

${escapeHtml(JSON.stringify(node.detail, null, 2))}
`; @@ -1181,7 +1110,7 @@ function loadWorkload(parsed) { const incoming = (parsed && parsed.queries) || []; // One batch id for this whole document — see `sourceBatch`'s own doc above. const sourceBatch = nextSourceBatch++; - incoming.forEach((q) => queries.push({ name: q.name, dag: q.dag, source: q.source, replacements: q.replacements || [], post_dag: q.post_dag, workload_cost: q.workload_cost, lifecycle_plan: q.lifecycle_plan, lifecycle_summary: q.lifecycle_summary, sourceBatch })); + incoming.forEach((q) => queries.push({ name: q.name, dag: q.dag, source: q.source, replacements: q.replacements || [], post_dag: q.post_dag, workload_cost: q.workload_cost, sourceBatch })); if (activeIndex === -1 && queries.length > 0) activeIndex = 0; if (participants.size === 0 && activeIndex >= 0) participants.add(activeIndex); } From 3063f46ec8b14ab4a78c3332bc0eb8df3f5fdbdc Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:16:24 +0000 Subject: [PATCH 2/8] refactor(ir): default to query-time materialization Rename LifecycleAssignment to MaterializationAssignment and apply_lifecycle_timings to apply_materialization_timings. The default assignment is now all_query_time(): nothing is materialized until Stage 2 materialization (#509) decides per sub-DAG. all_ingestion_time() and set() assign maintenance explicitly. - validate_default becomes validate_maintained: candidate legality is still checked with every summary maintained, so candidate generation is unchanged. planned_data_state and fixed_window_rate_candidates use the same maintained assumption, and the maintained precompute compilers in promql_rows assign ingestion time explicitly. - PlanOutput::execution_timed_dag, show_post_asap_ir and the default test helpers now emit query-time summaries. - forces_query_time (#541) treats a SummaryAgg as query time when the assignment, including its default, puts it there. - Tests that exercise maintenance assign it explicitly (new `maintained` helpers); a new unit test pins the query-time default. Co-Authored-By: Claude Opus 5.5 --- .../src/exact_composition.rs | 10 +- .../src/maintained_population.rs | 12 +- crates/asap-aware-mapping/src/pass/mod.rs | 12 +- crates/asap-aware-mapping/src/replacement.rs | 37 ++--- crates/asap-aware-mapping/src/test_support.rs | 28 +++- .../src/physical_planner/candidates.rs | 14 +- .../src/physical_planner/promql_rows.rs | 12 +- .../tests/common/mod.rs | 8 +- .../tests/weighted_topk_binding.rs | 8 +- crates/devtools/src/bin/show_post_asap_ir.rs | 4 +- crates/frontend-promql/tests/support.rs | 14 +- .../tests/maintained_population.rs | 10 +- crates/frontend-sql/tests/pearson_corr.rs | 8 +- crates/integration-tests/src/lib.rs | 35 +++-- .../tests/exact_composition.rs | 14 +- .../tests/physical_common/mod.rs | 27 +++- .../tests/precompute_raw_samples.rs | 6 +- .../tests/promql_to_post_asap.rs | 11 +- crates/types/src/ir/mod.rs | 6 +- crates/types/src/ir/node.rs | 2 +- crates/types/src/ir/physical_export.rs | 4 +- crates/types/src/ir/timing.rs | 144 ++++++++++++------ .../src/post_asap/execution_data_state.rs | 6 +- crates/types/tests/physical_export.rs | 30 ++-- 24 files changed, 287 insertions(+), 175 deletions(-) diff --git a/crates/asap-aware-mapping/src/exact_composition.rs b/crates/asap-aware-mapping/src/exact_composition.rs index 253006a93..bd46edbef 100644 --- a/crates/asap-aware-mapping/src/exact_composition.rs +++ b/crates/asap-aware-mapping/src/exact_composition.rs @@ -67,7 +67,7 @@ use std::rc::Rc; use asap_types::ir::aggregate_schema::aggregate_output_schema; use asap_types::ir::operator_properties::Reduction; -use asap_types::ir::timing::{planned_data_state, validate_default}; +use asap_types::ir::timing::{planned_data_state, validate_maintained}; use asap_types::ir::{NonASAPOp, Operator, OperatorNode, Predicate}; use asap_types::post_asap::execution_data_state::lift_plain; use asap_types::post_asap::{ @@ -202,7 +202,7 @@ impl ExactComposition { /// Build the composed, data_state-validated node over `child`. Every edge of /// the result (including everything beneath `child`) is checked by - /// `asap_types::ir::timing::validate_default`; an illegal + /// `asap_types::ir::timing::validate_maintained`; an illegal /// placement is a typed [`RealizationError::ExecutionDataState`], never deferred to a /// runtime. pub fn compose(&self, child: Rc) -> Result, RealizationError> { @@ -261,7 +261,7 @@ impl ExactComposition { OperatorNode::with_schema(Operator::NonASAP(self.op.clone().into_op(child)), schema) .with_guarantee(guarantee), ); - validate_default(&node, self.placement.data_state().timing)?; + validate_maintained(&node, self.placement.data_state().timing)?; Ok(node) } @@ -660,7 +660,7 @@ mod tests { Operator::NonASAP(NonASAPOp::Aggregate { .. }) )); // Timing is no longer stored by composition: under the default - // lifecycle assignment the composed read-time operation runs at + // materialization assignment the composed read-time operation runs at // query time. assert_eq!(timed(&composed).timing, Some(ExecutionTiming::QueryTime)); assert!(composed @@ -701,7 +701,7 @@ mod tests { composed.operator, Operator::NonASAP(NonASAPOp::Aggregate { .. }) )); - validate_default(&composed, ExecutionTiming::IngestionTime).unwrap(); + validate_maintained(&composed, ExecutionTiming::IngestionTime).unwrap(); assert_eq!( planned_data_state(&composed, ExecutionTiming::IngestionTime).timing, ExecutionTiming::IngestionTime diff --git a/crates/asap-aware-mapping/src/maintained_population.rs b/crates/asap-aware-mapping/src/maintained_population.rs index 7c22bba81..43ff7197b 100644 --- a/crates/asap-aware-mapping/src/maintained_population.rs +++ b/crates/asap-aware-mapping/src/maintained_population.rs @@ -336,15 +336,17 @@ mod tests { use crate::test_support::lower_promql; use asap_types::ir::cse::share_common_sub_dags; use asap_types::ir::physical_export::compile_physical_asap_dag as export_timed; - use asap_types::ir::timing::{apply_lifecycle_timings, LifecycleAssignment, TimingMemo}; + use asap_types::ir::timing::{ + apply_materialization_timings, MaterializationAssignment, TimingMemo, + }; - /// Time `root` under the default lifecycle assignment (which runs the + /// Time `root` under the default materialization assignment (which runs the /// data-state / population-contract validation) and export it. fn compile_physical_asap_dag(root: &Rc) -> Result<(), String> { root.validate_structure().map_err(|e| e.to_string())?; - let timed = apply_lifecycle_timings( + let timed = apply_materialization_timings( root, - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_query_time(), &mut TimingMemo::new(), ) .map_err(|e| format!("{e:?}"))?; @@ -517,7 +519,7 @@ mod tests { assert!(recognize(&per_job_limit_over_global_sort).is_none()); } - // Population timing is a lifecycle choice: a retained or rebuilt + // Population timing is a materialization choice: a retained or rebuilt // population both validate, while its evaluation must stay at query time. #[test] fn population_timing_is_not_structural() { diff --git a/crates/asap-aware-mapping/src/pass/mod.rs b/crates/asap-aware-mapping/src/pass/mod.rs index ef74fe5ec..4676bd04f 100644 --- a/crates/asap-aware-mapping/src/pass/mod.rs +++ b/crates/asap-aware-mapping/src/pass/mod.rs @@ -18,7 +18,9 @@ use std::collections::BTreeMap; use std::rc::Rc; use asap_types::ir::physical_export::compile_physical_asap_workload; -use asap_types::ir::timing::{apply_lifecycle_timings, LifecycleAssignment, TimingMemo}; +use asap_types::ir::timing::{ + apply_materialization_timings, MaterializationAssignment, TimingMemo, +}; use asap_types::ir::{OperatorNode, QueryRoot}; use asap_types::parsed_workload::ParsedWorkload; use asap_types::post_asap::ExecutionDataStateError; @@ -232,7 +234,7 @@ impl PlanOutput { ) -> Result { // One memo, so a node shared by several roots is timed and exported once. let mut memo = TimingMemo::new(); - let assignment = LifecycleAssignment::default_maintained(); + let assignment = MaterializationAssignment::all_query_time(); let timed = self .roots() .iter() @@ -254,16 +256,16 @@ impl PlanOutput { /// timing each operator it references and rebuilding it over the timed nodes. fn time_query_root( root: &QueryRoot, - assignment: &LifecycleAssignment, + assignment: &MaterializationAssignment, memo: &mut TimingMemo, ) -> Result { Ok(match root { QueryRoot::Operator(node) => { - QueryRoot::Operator(apply_lifecycle_timings(node, assignment, memo)?) + QueryRoot::Operator(apply_materialization_timings(node, assignment, memo)?) } QueryRoot::Scalar(expr) => { for node in expr.operator_refs() { - apply_lifecycle_timings(node, assignment, memo)?; + apply_materialization_timings(node, assignment, memo)?; } QueryRoot::Scalar(expr.map_operator_refs(&mut |node| { Rc::clone(memo.timed(node).expect("scalar reference was timed")) diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 32fe251d1..90640a2be 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -350,7 +350,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use asap_types::ir::cse::{share_common_sub_dags, structural_hash, HashCache}; use asap_types::ir::operator_properties::{BinaryOpKind, JoinKind, Reduction}; -use asap_types::ir::timing::validate_default; +use asap_types::ir::timing::validate_maintained; use asap_types::ir::SchemaDerivationError; use asap_types::ir::{ ASAPOp, BinaryOperator, NonASAPOp, Operator, OperatorNode, Predicate, ProjectItem, ScalarExpr, @@ -1419,10 +1419,11 @@ impl<'a> ASAPStrategies<'a> { fn place(node: &Rc) -> Option> { retime_rate_finalize(node, ExecutionTiming::IngestionTime, true) } + // Legal only if the candidate stays executable with its states maintained. let timed = |node: &Rc| { - asap_types::ir::timing::apply_lifecycle_timings( + asap_types::ir::timing::apply_materialization_timings( node, - &asap_types::ir::timing::LifecycleAssignment::default_maintained(), + &asap_types::ir::timing::MaterializationAssignment::all_ingestion_time(), &mut asap_types::ir::timing::TimingMemo::new(), ) .ok() @@ -1790,7 +1791,7 @@ impl ReplacementStrategy for ASAPStrategies<'_> { /// the logical root does not expose, so each is a finalized query result /// for the identity-carrying root. Placement variants (for example, /// fixed-window or query-time Rate aggregation) are not listed here: the - /// lifecycle assigns timing and the physical compiler reads it. + /// materialization assigns timing and the physical compiler reads it. fn propose_for_root(&self, root: &Rc, target: &AccuracyTarget) -> Proposals { let Ok(typed) = asap_types::ir::schema_support::with_promql_series_identity(root) else { return Proposals::default(); @@ -2000,7 +2001,7 @@ fn exact_topk_over_temporal_values( ) .with_guarantee(guarantee), ); - validate_default(&node, ExecutionTiming::QueryTime)?; + validate_maintained(&node, ExecutionTiming::QueryTime)?; Ok(Some(node)) } @@ -2019,7 +2020,7 @@ fn realize_temporal_average( return Ok(None); }; operator.checked_finite_division = true; - validate_default(&node, ExecutionTiming::QueryTime)?; + validate_maintained(&node, ExecutionTiming::QueryTime)?; Ok(Some(node)) } @@ -2343,7 +2344,7 @@ pub fn finalize_query_candidate( /// The read boundary's placement is fixed here, where the candidate's /// semantics decide it (a fresh query-time summary over this evaluation's -/// finalized values vs. finalized values feeding maintenance); the lifecycle +/// finalized values vs. finalized values feeding maintenance); the materialization /// timing pass honors it. fn finalize_exact_accumulator( node: Rc, @@ -2751,7 +2752,7 @@ fn finish_weighted_topk( ) .with_guarantee(guarantee), ); - validate_default(&result, ExecutionTiming::QueryTime)?; + validate_maintained(&result, ExecutionTiming::QueryTime)?; Ok(result) } @@ -3132,7 +3133,7 @@ fn construct_summary_agg( } else if snapshot_weighted { // Each evaluation's finalized rates feed a fresh summary; rate snapshots // must never accumulate across evaluations. Query time is only the - // initial layout; a retained summary's lifecycle moves it to ingestion. + // initial layout; a maintained summary's materialization moves it to ingestion. finalize_query_candidate(bound_child, &input.child)? } else { let child = @@ -4209,7 +4210,7 @@ impl CandidateLogicalASAPDAGs { } /// DAG candidates assembled from an unpriced search space. -/// This is an internal planning stage: callers must still validate lifecycle +/// This is an internal planning stage: callers must still validate materialization /// requirements and compile supported physical operators before deployment. /// The caller supplies a finite expansion budget; exceeding it is an error, /// never a silently truncated inventory presented as exhaustive. @@ -5226,7 +5227,7 @@ impl<'a> GlobalSelection<'a> { return Ok(Rc::clone(node)); } // A selected summary that realizes its inner aggregate, instead of - // hiding it in `KeepPreAsap`, is kept; lifecycle assignment decides + // hiding it in `KeepPreAsap`, is kept; materialization assignment decides // whether it runs in precompute or at query time. let selected_composed_summary = self .groups @@ -5370,7 +5371,7 @@ impl<'a> GlobalSelection<'a> { let node = Rc::new( OperatorNode::with_schema(operator, target.schema.clone()).with_guarantee(guarantee), ); - validate_default(&node, ExecutionTiming::QueryTime)?; + validate_maintained(&node, ExecutionTiming::QueryTime)?; Ok(node) } @@ -5489,7 +5490,7 @@ fn relink_agg_child(node: &Rc, new_child: &Rc) -> Rc ) .with_guarantee(node.guarantee.clone()), ); - match validate_default(&rebuilt, ExecutionTiming::IngestionTime) { + match validate_maintained(&rebuilt, ExecutionTiming::IngestionTime) { Ok(_) => rebuilt, Err(_) => Rc::clone(node), } @@ -6915,7 +6916,7 @@ mod tests { use super::*; use crate::accuracy::PropagationStats; use crate::cost_model::Cost; - use crate::test_support::{agg, agg_per_entity, lower_promql, metric_scan, timed}; + use crate::test_support::{agg, agg_per_entity, lower_promql, maintained, metric_scan, timed}; use asap_types::ir::operator_properties::{Reduction as ReductionTy, Source}; use asap_types::ir::TimeRangeKind; use asap_types::pre_asap::agg_intent::{ @@ -7110,9 +7111,9 @@ mod tests { } // Grouped Sum over Rate evaluations stays a summary state in the inventory, - // so lifecycle assignment can place it in precompute or at query time. + // so materialization assignment can place it in precompute or at query time. #[test] - fn grouped_rate_sum_inventory_keeps_sum_state_for_lifecycle_placement() { + fn grouped_rate_sum_inventory_keeps_sum_state_for_materialization_placement() { let root = lower_promql("sum by(job)(rate(m[1m]))", AccuracyTarget::Exact); let inventory = search_workload(vec![(0usize, root)]) .unwrap() @@ -10236,8 +10237,8 @@ mod tests { let inner = agg(vec![2], AggIntent::Sum { col: None }, metric_scan(&["job"])); let outer = agg(vec![], default_quantile(0.9), inner); // Timing is not stored during realization: time the candidate under - // the default lifecycle assignment to read the maintenance boundary. - let root = timed(&realize(&outer).unwrap()); + // a maintained materialization assignment to read the maintenance boundary. + let root = maintained(&realize(&outer).unwrap()); let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { panic!("expected estimate root, got {:?}", root.operator); diff --git a/crates/asap-aware-mapping/src/test_support.rs b/crates/asap-aware-mapping/src/test_support.rs index 4147d05eb..739a943f4 100644 --- a/crates/asap-aware-mapping/src/test_support.rs +++ b/crates/asap-aware-mapping/src/test_support.rs @@ -51,7 +51,9 @@ pub(crate) fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Rc) -> Rc) -> Rc { - apply_lifecycle_timings( + apply_materialization_timings( + root, + &MaterializationAssignment::all_query_time(), + &mut TimingMemo::new(), + ) + .expect("default materialization timings apply") +} + +/// `root` timed with every summary maintained at ingestion time. +pub(crate) fn maintained(root: &Rc) -> Rc { + apply_materialization_timings( root, - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_ingestion_time(), &mut TimingMemo::new(), ) - .expect("default lifecycle timings apply") + .expect("maintained materialization timings apply") } -/// Time `root` under the default lifecycle assignment (which runs every +/// Time `root` under the default materialization assignment (which runs every /// data-state / population-contract check) and export it as a physical ASAP DAG. pub(crate) fn time_and_export( root: &Rc, @@ -192,9 +204,9 @@ pub(crate) fn time_and_export( asap_types::ir::physical_export::PhysicalASAPDAG, asap_types::post_asap::execution_data_state::ExecutionDataStateError, > { - let timed = apply_lifecycle_timings( + let timed = apply_materialization_timings( root, - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_query_time(), &mut TimingMemo::new(), )?; asap_types::ir::physical_export::compile_physical_asap_dag(&timed) diff --git a/crates/asap-physical-operators/src/physical_planner/candidates.rs b/crates/asap-physical-operators/src/physical_planner/candidates.rs index 90f4c4132..f332db2c0 100644 --- a/crates/asap-physical-operators/src/physical_planner/candidates.rs +++ b/crates/asap-physical-operators/src/physical_planner/candidates.rs @@ -1,7 +1,7 @@ //! Compile maintenance-selected frontiers without deployment-specific DAG rewrites. use super::*; -/// One computation realization; lifecycle/window/revision requirements accompany +/// One computation realization; materialization/window/revision requirements accompany /// it during optimization and deployment. Stored outputs have no storage identity. /// Deserialization validates the producer/reader boundary. #[derive(Clone, serde::Serialize, serde::Deserialize)] @@ -86,7 +86,7 @@ pub fn cut_candidate( }) } -/// Materialization frontier implied by lifecycle-assigned timing: ingestion-time +/// Materialization frontier implied by materialization-assigned timing: ingestion-time /// nodes read by a query-time node, plus each root, and each node a scalar /// root reads, that is ingestion-timed. /// `cut_candidate` of one [`compile`] result with this frontier realizes the @@ -133,7 +133,7 @@ pub fn frontier_from_timing(dag: &PhysicalASAPDAG) -> Result, Error> /// Enumerate bounded, reachable materialization frontiers above explicit inputs. /// Each frontier is an antichain: storing an output and its ancestor together -/// would leave the ancestor unused by query execution. Lifecycle eligibility +/// would leave the ancestor unused by query execution. Materialization eligibility /// and deployment feasibility are evaluated separately before cost selection. /// Exceeding the search budget returns an error, never a partial inventory. pub fn enumerate_frontiers( @@ -378,9 +378,9 @@ mod tests { .assemble_selected_query(&space.roots[0].1) .unwrap() .unwrap(); - let selected = planner_types::ir::apply_lifecycle_timings( + let selected = planner_types::ir::apply_materialization_timings( &selected, - &Default::default(), + &planner_types::ir::MaterializationAssignment::all_ingestion_time(), &mut Default::default(), ) .unwrap(); @@ -446,8 +446,8 @@ mod tests { )]) } - /// Cutting one compilation by a retained-state timing and by the all - /// query-time timing (what ContinuouslyMaintained and Ephemeral assign) + /// Cutting one compilation by a maintained-state timing and by the all + /// query-time timing /// lowers each Planner node once and matches `compile_candidate`. #[test] fn timing_cuts_share_one_lowering() { diff --git a/crates/asap-physical-operators/src/physical_planner/promql_rows.rs b/crates/asap-physical-operators/src/physical_planner/promql_rows.rs index 81b790b62..c2da3811b 100644 --- a/crates/asap-physical-operators/src/physical_planner/promql_rows.rs +++ b/crates/asap-physical-operators/src/physical_planner/promql_rows.rs @@ -90,9 +90,10 @@ pub fn compile_current_series_evaluation( use planner_types::post_asap::{ maintained_population::PopulationStatistic, Field as SummaryField, }; - let selected = planner_types::ir::apply_lifecycle_timings( + // This compiler emits maintained precompute: every summary at ingestion time. + let selected = planner_types::ir::apply_materialization_timings( selected, - &planner_types::ir::LifecycleAssignment::default_maintained(), + &planner_types::ir::MaterializationAssignment::all_ingestion_time(), &mut planner_types::ir::TimingMemo::new(), ) .map_err(|e| invalid(e.to_string()))?; @@ -204,9 +205,10 @@ pub fn compile_rate_ranking( } node.children().into_iter().find_map(frontier) } - let selected = planner_types::ir::apply_lifecycle_timings( + // This compiler emits maintained precompute: every summary at ingestion time. + let selected = planner_types::ir::apply_materialization_timings( selected, - &planner_types::ir::LifecycleAssignment::default_maintained(), + &planner_types::ir::MaterializationAssignment::all_ingestion_time(), &mut planner_types::ir::TimingMemo::new(), ) .map_err(|e| invalid(e.to_string()))?; @@ -239,7 +241,7 @@ pub fn compile_rate_ranking( Ok((source, program)) } -/// Compile a lifecycle-timed DAG whose heap or grouped Sum over per-series +/// Compile a materialization-timed DAG whose heap or grouped Sum over per-series /// Rate evaluations runs at ingestion time: fresh aggregate state per closed /// window. The input is the complete collection of per-series counter states. pub fn compile_fixed_window_rate_aggregation( diff --git a/crates/asap-physical-operators/tests/common/mod.rs b/crates/asap-physical-operators/tests/common/mod.rs index 16f6b1f25..f4db3e6d8 100644 --- a/crates/asap-physical-operators/tests/common/mod.rs +++ b/crates/asap-physical-operators/tests/common/mod.rs @@ -1,14 +1,16 @@ #![allow(dead_code)] use planner_types::ir::physical_export::PhysicalASAPDAG; -use planner_types::ir::{apply_lifecycle_timings, LifecycleAssignment, OperatorNode, TimingMemo}; +use planner_types::ir::{ + apply_materialization_timings, MaterializationAssignment, OperatorNode, TimingMemo, +}; use std::rc::Rc; pub fn compile_physical_asap_dag( root: &Rc, ) -> Result> { - let root = apply_lifecycle_timings( + let root = apply_materialization_timings( root, - &LifecycleAssignment::default(), + &MaterializationAssignment::default(), &mut TimingMemo::default(), )?; Ok(planner_types::ir::physical_export::compile_physical_asap_dag(&root)?) diff --git a/crates/asap-physical-operators/tests/weighted_topk_binding.rs b/crates/asap-physical-operators/tests/weighted_topk_binding.rs index 7a5bdbe00..1bb55767e 100644 --- a/crates/asap-physical-operators/tests/weighted_topk_binding.rs +++ b/crates/asap-physical-operators/tests/weighted_topk_binding.rs @@ -819,10 +819,10 @@ fn spatial_topk_requires_the_limit_and_sort_partitions_to_agree() { /// rest at query time. The phases are assigned on the exported DAG because /// the candidate pins its finalize boundary to query time. fn continuously_maintained_dag(candidate: &Rc) -> PhysicalASAPDAG { - use planner_types::ir::{apply_lifecycle_timings, LifecycleAssignment, TimingMemo}; - let timed = apply_lifecycle_timings( + use planner_types::ir::{apply_materialization_timings, MaterializationAssignment, TimingMemo}; + let timed = apply_materialization_timings( candidate, - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_query_time(), &mut TimingMemo::new(), ) .unwrap(); @@ -867,7 +867,7 @@ fn continuously_maintained_dag(candidate: &Rc) // A maintained heap over finalized per-series Rate is the fixed-window // placement: materialization timing, not a separate candidate, puts it in precompute. #[test] -fn maintained_rate_heap_lifecycle_compiles_fixed_window_precompute() { +fn maintained_rate_heap_compiles_fixed_window_precompute() { use asap_physical_operators::physical_planner::{ compile_candidate, promql_rows::with_series_identity, }; diff --git a/crates/devtools/src/bin/show_post_asap_ir.rs b/crates/devtools/src/bin/show_post_asap_ir.rs index 722f3289a..51123cfdb 100644 --- a/crates/devtools/src/bin/show_post_asap_ir.rs +++ b/crates/devtools/src/bin/show_post_asap_ir.rs @@ -176,9 +176,9 @@ mod tests { candidates[0].guarantee.is_none(), "missing evidence must not claim a certified ratio bound" ); - let timed = asap_types::ir::timing::apply_lifecycle_timings( + let timed = asap_types::ir::timing::apply_materialization_timings( &candidates[0], - &asap_types::ir::timing::LifecycleAssignment::default_maintained(), + &asap_types::ir::timing::MaterializationAssignment::all_query_time(), &mut asap_types::ir::timing::TimingMemo::new(), ) .expect("the demo candidate has a legal default timing"); diff --git a/crates/frontend-promql/tests/support.rs b/crates/frontend-promql/tests/support.rs index 1bd247d04..6b5418f9f 100644 --- a/crates/frontend-promql/tests/support.rs +++ b/crates/frontend-promql/tests/support.rs @@ -69,18 +69,18 @@ pub fn promql_scalar(node: &ScalarExpr) -> Option { } } -/// Time `root` under the default (every summary maintained) lifecycle -/// assignment and export the post-ASAP DAG — the wire-6 export needs every -/// node timed first. +/// Time `root` under the default materialization assignment (every summary +/// at query time) and export the physical DAG — export needs every node +/// timed first. #[allow(dead_code)] pub fn post_asap_dag(root: &Rc) -> asap_types::ir::physical_export::PhysicalASAPDAG { - use asap_types::ir::{apply_lifecycle_timings, LifecycleAssignment, TimingMemo}; - let timed = apply_lifecycle_timings( + use asap_types::ir::{apply_materialization_timings, MaterializationAssignment, TimingMemo}; + let timed = apply_materialization_timings( root, - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_query_time(), &mut TimingMemo::new(), ) - .expect("default lifecycle timings"); + .expect("default materialization timings"); asap_types::ir::physical_export::compile_physical_asap_dag(&timed) .expect("post-ASAP DAG export") } diff --git a/crates/frontend-sql/tests/maintained_population.rs b/crates/frontend-sql/tests/maintained_population.rs index dc7d55c44..56d4520a5 100644 --- a/crates/frontend-sql/tests/maintained_population.rs +++ b/crates/frontend-sql/tests/maintained_population.rs @@ -3,8 +3,8 @@ use asap_aware_mapping::maintained_population::MaintainedPopulationStrategy; use asap_frontend_sql::{lower_sql, SqlCatalog}; use asap_types::{ ir::{ - apply_lifecycle_timings, cse::share_common_sub_dags, - physical_export::compile_physical_asap_dag, ASAPOp, LifecycleAssignment, NonASAPOp, + apply_materialization_timings, cse::share_common_sub_dags, + physical_export::compile_physical_asap_dag, ASAPOp, MaterializationAssignment, NonASAPOp, Operator, OperatorNode, TimingMemo, }, post_asap::maintained_population::{MaintainedPopulation, PopulationInput}, @@ -38,12 +38,12 @@ fn population(mut node: &OperatorNode) -> (&Rc, &MaintainedPopulat (child, population) } -/// Export `plan` the way the planner does: assign the default lifecycle +/// Export `plan` the way the planner does: assign the default materialization /// timings, then compile the timed DAG. fn compile(plan: &Rc) -> Result<(), String> { - let timed = apply_lifecycle_timings( + let timed = apply_materialization_timings( plan, - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_query_time(), &mut TimingMemo::new(), ) .map_err(|e| e.to_string())?; diff --git a/crates/frontend-sql/tests/pearson_corr.rs b/crates/frontend-sql/tests/pearson_corr.rs index d4930c98d..3604dce04 100644 --- a/crates/frontend-sql/tests/pearson_corr.rs +++ b/crates/frontend-sql/tests/pearson_corr.rs @@ -3,8 +3,8 @@ use std::rc::Rc; use asap_frontend_sql::{lower_sql, SqlCatalog}; use asap_types::ir::{ - apply_lifecycle_timings, physical_export::compile_physical_asap_dag, LifecycleAssignment, - NonASAPOp, OperatorNode, ScalarExpr, TimingMemo, + apply_materialization_timings, physical_export::compile_physical_asap_dag, + MaterializationAssignment, NonASAPOp, OperatorNode, ScalarExpr, TimingMemo, }; use asap_types::pre_asap::{AggIntent, DataType, Field, Schema}; use asap_types::types::AccuracyTarget; @@ -152,9 +152,9 @@ async fn corr_survives_exact_plan_compilation() { // The exact fallback is the query's own operator DAG, no ASAP node added. assert!(!plan.contains_asap(), "expected exact fallback"); assert_eq!(aggregate(&plan).0, aggregate(&query).0); - let timed = apply_lifecycle_timings( + let timed = apply_materialization_timings( &plan, - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_query_time(), &mut TimingMemo::new(), ) .unwrap(); diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs index 35822710d..6102ca0ff 100644 --- a/crates/integration-tests/src/lib.rs +++ b/crates/integration-tests/src/lib.rs @@ -100,22 +100,37 @@ pub mod fixtures { /// Timing and export helpers for post-ASAP plans. pub mod post_asap { use asap_types::ir::physical_export::{compile_physical_asap_dag, PhysicalASAPDAG}; - use asap_types::ir::{apply_lifecycle_timings, LifecycleAssignment, OperatorNode, TimingMemo}; + use asap_types::ir::{ + apply_materialization_timings, MaterializationAssignment, OperatorNode, TimingMemo, + }; use std::rc::Rc; - /// Time `root` under the default (every summary maintained) lifecycle - /// assignment. Returns the timed copy; read `node.timing` on it. + /// Time `root` under the default assignment (every summary computed at + /// query time). Returns the timed copy; read `node.timing` on it. pub fn timed(root: &Rc) -> Rc { - apply_lifecycle_timings( - root, - &LifecycleAssignment::default_maintained(), - &mut TimingMemo::new(), - ) - .expect("default lifecycle timing failed") + timed_with(root, &MaterializationAssignment::all_query_time()) + } + + /// Time `root` with every summary maintained at ingestion time. + pub fn maintained(root: &Rc) -> Rc { + timed_with(root, &MaterializationAssignment::all_ingestion_time()) } - /// Time `root` (default assignment), then export the wire-6 DAG. + fn timed_with( + root: &Rc, + assignment: &MaterializationAssignment, + ) -> Rc { + apply_materialization_timings(root, assignment, &mut TimingMemo::new()) + .expect("materialization timing failed") + } + + /// Time `root` (default assignment), then export the physical DAG. pub fn post_asap_dag(root: &Rc) -> PhysicalASAPDAG { compile_physical_asap_dag(&timed(root)).expect("post-ASAP DAG export failed") } + + /// Time `root` with every summary maintained, then export the physical DAG. + pub fn maintained_post_asap_dag(root: &Rc) -> PhysicalASAPDAG { + compile_physical_asap_dag(&maintained(root)).expect("post-ASAP DAG export failed") + } } diff --git a/crates/integration-tests/tests/exact_composition.rs b/crates/integration-tests/tests/exact_composition.rs index 05a0616ad..db6a9bd26 100644 --- a/crates/integration-tests/tests/exact_composition.rs +++ b/crates/integration-tests/tests/exact_composition.rs @@ -25,7 +25,7 @@ use asap_aware_mapping::{ CostModel, DefaultCostModel, EvaluationRate, ExplanationKind, OperationPlacement, }; use asap_integration_tests::fixtures::lower_promql; -use asap_integration_tests::post_asap::{post_asap_dag, timed}; +use asap_integration_tests::post_asap::{maintained, post_asap_dag, timed}; use asap_types::dag_export; use asap_types::ir::operator_properties::{Reduction, Source}; use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; @@ -387,9 +387,9 @@ fn every_exact_accumulator_is_finalized_before_an_outer_sketch() { let Replacement::SubDAG(root) = &candidates[0].replacement else { unreachable!() }; - // Timing is not stored on the plan: time it (default lifecycle, - // which also validates every edge) and inspect the timed copy. - let root = timed(root); + // Timing is not stored on the plan: time it with the outer summary + // maintained (which also validates every edge) and inspect the copy. + let root = maintained(root); let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { panic!("expected KLL evaluation, got {:?}", root.operator); }; @@ -670,8 +670,8 @@ fn outer_summary_over_an_exact_function_composes_at_ingestion_time() { assert!(decision.cost_rate < decision.baseline_rate); let composed = selection.assemble_selected_query(&root).unwrap().unwrap(); - // Walk the timed copy: timing is written by the lifecycle assignment. - let composed = timed(&composed); + // Walk the timed copy, with the outer summary maintained at ingestion time. + let composed = maintained(&composed); let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &composed.operator else { panic!("expected evaluation root, got {:?}", composed.operator); }; @@ -725,7 +725,7 @@ fn summary_construction_follows_its_value_input_phase() { // time, because a evaluation sits below it. let state = asap_types::ir::planned_data_state(&illegal, ExecutionTiming::IngestionTime); assert_eq!(state.timing, ExecutionTiming::QueryTime); - asap_types::ir::validate_default(&illegal, state.timing).unwrap(); + asap_types::ir::validate_maintained(&illegal, state.timing).unwrap(); } #[test] diff --git a/crates/integration-tests/tests/physical_common/mod.rs b/crates/integration-tests/tests/physical_common/mod.rs index 2f03f459c..11216b3e8 100644 --- a/crates/integration-tests/tests/physical_common/mod.rs +++ b/crates/integration-tests/tests/physical_common/mod.rs @@ -46,11 +46,30 @@ pub fn execute( pub fn compile_physical_asap_dag( root: &std::rc::Rc, ) -> Result> { - let root = asap_types::ir::apply_lifecycle_timings( + compile_with( root, - &Default::default(), - &mut Default::default(), - )?; + &asap_types::ir::MaterializationAssignment::all_query_time(), + ) +} + +/// As [`compile_physical_asap_dag`], with every summary maintained at +/// ingestion time, the placement precompute compilation requires. +#[allow(dead_code)] // Not every test binary sharing this module compiles precompute. +pub fn compile_maintained_physical_asap_dag( + root: &std::rc::Rc, +) -> Result> { + compile_with( + root, + &asap_types::ir::MaterializationAssignment::all_ingestion_time(), + ) +} + +fn compile_with( + root: &std::rc::Rc, + assignment: &asap_types::ir::MaterializationAssignment, +) -> Result> { + let root = + asap_types::ir::apply_materialization_timings(root, assignment, &mut Default::default())?; Ok(asap_types::ir::physical_export::compile_physical_asap_dag( &root, )?) diff --git a/crates/integration-tests/tests/precompute_raw_samples.rs b/crates/integration-tests/tests/precompute_raw_samples.rs index 5a0b93e16..8af10787d 100644 --- a/crates/integration-tests/tests/precompute_raw_samples.rs +++ b/crates/integration-tests/tests/precompute_raw_samples.rs @@ -4,7 +4,7 @@ mod physical_common; use asap_types::ir::physical_export::{PhysicalASAPDAG, PhysicalASAPOperatorPayload}; use asap_types::ir::ASAPOp; use asap_types::ir::OperatorNode; -use physical_common::compile_physical_asap_dag; +use physical_common::compile_maintained_physical_asap_dag; use std::{collections::BTreeMap, collections::BTreeSet, rc::Rc, sync::Arc}; use asap_aware_mapping::cost_model::DefaultCostModel; @@ -417,7 +417,7 @@ fn raw_sample_summaries_compile_and_match_their_kernels() { let mut checked = BTreeMap::new(); for (query, accuracy) in queries { for candidate in candidates(query, accuracy.clone()) { - let dag = compile_physical_asap_dag(&candidate).unwrap(); + let dag = compile_maintained_physical_asap_dag(&candidate).unwrap(); for (source, root) in raw_summaries(&dag) { match check(query, &dag, source, root, &rows) { Ok(family) => { @@ -470,7 +470,7 @@ fn grouped_raw_summary(family: FieldDataType, input: SummaryUpdate) -> (Physical ) .pop() .unwrap(); - let mut dag = compile_physical_asap_dag(&candidate).unwrap(); + let mut dag = compile_maintained_physical_asap_dag(&candidate).unwrap(); let (source, root) = raw_summaries(&dag)[0]; let node = dag.nodes.iter_mut().find(|n| n.id as u64 == root).unwrap(); let PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { diff --git a/crates/integration-tests/tests/promql_to_post_asap.rs b/crates/integration-tests/tests/promql_to_post_asap.rs index 50f8da056..8d793a1b5 100644 --- a/crates/integration-tests/tests/promql_to_post_asap.rs +++ b/crates/integration-tests/tests/promql_to_post_asap.rs @@ -19,7 +19,9 @@ use asap_aware_mapping::{ ReplacementStrategy, ReplacementSubDAG, TargetSubDAG, }; use asap_integration_tests::fixtures::lower_promql; -use asap_integration_tests::post_asap::{post_asap_dag, timed}; +use asap_integration_tests::post_asap::{ + maintained, maintained_post_asap_dag, post_asap_dag, timed, +}; use asap_types::ir::operator_properties::Reduction; use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, ScalarExpr}; @@ -1063,8 +1065,9 @@ fn nested_summary_explicitly_finalizes_exact_child_at_ingestion_time() { .assemble_selected_query(&space.roots[0].1) .unwrap() .unwrap(); - // Stored timings are gone: time the plan and read the timed copy. - let timed_plan = timed(&plan); + // Stored timings are gone: time the plan with its outer summary + // maintained and read the timed copy. + let timed_plan = maintained(&plan); let Some(ASAPOp::SummaryEstimate { summary_input, .. }) = timed_plan.asap() else { panic!("expected selected quantile summary"); }; @@ -1123,7 +1126,7 @@ fn physical_node_owns_phase_independently_of_binary_payload() { .assemble_selected_query(&search.roots[0].1) .unwrap() .unwrap(); - let dag = post_asap_dag(&plan); + let dag = maintained_post_asap_dag(&plan); let node = dag .nodes .iter() diff --git a/crates/types/src/ir/mod.rs b/crates/types/src/ir/mod.rs index c7ba61f18..6996fafdd 100644 --- a/crates/types/src/ir/mod.rs +++ b/crates/types/src/ir/mod.rs @@ -19,11 +19,11 @@ pub mod cse; pub mod flat; /// Physical ASAP DAG: the flattened operators plus execution timing. pub mod physical_export; -/// Execution timing for physical plans: a lifecycle assignment expanded onto every node. +/// Execution timing for physical plans: a materialization assignment expanded onto every node. pub mod timing; pub use timing::{ - apply_lifecycle_timings, data_state, planned_data_state, split_shared_by_phase, - validate_default, LifecycleAssignment, TimingMemo, + apply_materialization_timings, data_state, planned_data_state, split_shared_by_phase, + validate_maintained, MaterializationAssignment, TimingMemo, }; pub mod schema_support; /// Semantic observation coverage, separate from field layout and physical timing. diff --git a/crates/types/src/ir/node.rs b/crates/types/src/ir/node.rs index b8df8af1d..d0a581c1c 100644 --- a/crates/types/src/ir/node.rs +++ b/crates/types/src/ir/node.rs @@ -91,7 +91,7 @@ impl Operator { /// `schema` and `result_kind` are derived from `operator` and its children /// at construction and retained. `guarantee` is `None` until accuracy /// assessment establishes one (`None` never means exact). `timing` is `None` -/// until a lifecycle assignment is applied; export rejects an executable +/// until a materialization assignment is applied; export rejects an executable /// node without one. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct OperatorNode { diff --git a/crates/types/src/ir/physical_export.rs b/crates/types/src/ir/physical_export.rs index ba322e0a7..e4b237b17 100644 --- a/crates/types/src/ir/physical_export.rs +++ b/crates/types/src/ir/physical_export.rs @@ -2,7 +2,7 @@ //! //! The flattened operators of [`super::flat`], plus the execution timing //! (data state) of every node and edge. The input must already be timed -//! ([`super::timing::apply_lifecycle_timings`]); export reads each node's +//! ([`super::timing::apply_materialization_timings`]); export reads each node's //! timing and does not re-run data-state validation. use std::collections::{BTreeMap, HashMap, HashSet}; @@ -407,7 +407,7 @@ pub fn compile_physical_asap_dag( } /// Export the timed DAG below `root`. Every reachable node must carry a -/// timing (see [`super::timing::apply_lifecycle_timings`]); the data-state +/// timing (see [`super::timing::apply_materialization_timings`]); the data-state /// rules were checked by that pass and are not re-run here. pub fn compile_physical_asap_dag_with_node_ids( root: &Rc, diff --git a/crates/types/src/ir/timing.rs b/crates/types/src/ir/timing.rs index e4ff6a538..d7aef6768 100644 --- a/crates/types/src/ir/timing.rs +++ b/crates/types/src/ir/timing.rs @@ -1,16 +1,18 @@ -//! Execution timing: written into every node from a lifecycle assignment, -//! then validated against each operator's kind and its consuming edges. +//! Execution timing: written into every node from a materialization +//! assignment, then validated against each operator's kind and its consuming +//! edges. //! -//! The logical DAG carries no timing. Summary materialization chooses a -//! lifecycle per summary state; [`LifecycleAssignment`] records that choice -//! (ingestion-time maintenance or query-time recomputation per `SummaryAgg`) -//! and [`apply_lifecycle_timings`] expands it into a timing on every node: +//! The logical DAG carries no timing. Materialization decides per summary +//! state whether it is maintained at ingestion time or computed at query +//! time; [`MaterializationAssignment`] records that choice per `SummaryAgg` +//! and [`apply_materialization_timings`] expands it into a timing on every node: //! //! - a node of fixed kind takes its kind's timing (`SummaryEstimate` and //! `EvaluatePopulation` run at query time, `MaintainPopulation` at ingestion //! time); -//! - a `SummaryAgg` takes the assignment's timing (default: ingestion time), -//! unless something below it can only exist at query time; +//! - a `SummaryAgg` takes the assignment's timing (default: query time, until +//! Stage 2 materialization (#509) chooses otherwise), unless something below +//! it can only exist at query time; //! - a PromQL `BinaryOp` over per-series inputs that are not the same series //! rows runs at query time, since maintenance cannot align them; //! - every other node runs when its consumer runs: everything that feeds a @@ -45,23 +47,31 @@ use crate::post_asap::execution_data_state::{ }; use crate::pre_asap::schema::{DataType, FieldDataType, Schema}; -/// The per-state lifecycle choice summary materialization made: for each -/// `SummaryAgg` node (by identity), whether its state is maintained at -/// ingestion time or recomputed at query time. A state absent from the map -/// takes the default, ingestion-time maintenance. +/// The per-state materialization choice: for each `SummaryAgg` node (by +/// identity), whether its state is maintained at ingestion time or computed +/// at query time. A state absent from the map takes the assignment's default. +/// `Default` is [`Self::all_query_time`]: nothing is materialized until Stage 2 +/// materialization (#509) decides otherwise. #[derive(Debug, Clone, Default)] -pub struct LifecycleAssignment { +pub struct MaterializationAssignment { summary_timings: HashMap<*const OperatorNode, ExecutionTiming>, + default_timing: ExecutionTiming, } -impl LifecycleAssignment { - /// The assignment under which every summary state is maintained at - /// ingestion time — the timings every plan carried before lifecycles - /// became a planning choice. - pub fn default_maintained() -> Self { +impl MaterializationAssignment { + /// Every summary state computed at query time. + pub fn all_query_time() -> Self { Self::default() } + /// Every summary state maintained at ingestion time. + pub fn all_ingestion_time() -> Self { + Self { + summary_timings: HashMap::new(), + default_timing: ExecutionTiming::IngestionTime, + } + } + pub fn set(&mut self, summary: &Rc, timing: ExecutionTiming) { self.summary_timings.insert(Rc::as_ptr(summary), timing); } @@ -70,11 +80,11 @@ impl LifecycleAssignment { self.summary_timings .get(&Rc::as_ptr(summary)) .copied() - .unwrap_or(ExecutionTiming::IngestionTime) + .unwrap_or(self.default_timing) } } -/// Memo of one [`apply_lifecycle_timings`] pass: `input node → timed node`, +/// Memo of one [`apply_materialization_timings`] pass: `input node → timed node`, /// shared by every root of a workload so a node shared by two roots stays /// one `Rc`. Re-reaching a node with a different timing is a conflict. #[derive(Default)] @@ -112,7 +122,7 @@ pub fn data_state(node: &OperatorNode) -> Option { /// at query time. fn forces_query_time( node: &OperatorNode, - assignment: &LifecycleAssignment, + assignment: &MaterializationAssignment, seen: &mut HashMap<*const OperatorNode, bool>, ) -> bool { let key = node as *const OperatorNode; @@ -124,7 +134,12 @@ fn forces_query_time( Operator::ASAP(ASAPOp::SummaryEstimate { .. }) | Operator::ASAP(ASAPOp::EvaluatePopulation { .. }) => true, Operator::ASAP(ASAPOp::SummaryAgg { .. }) - if assignment.summary_timings.get(&key) == Some(&ExecutionTiming::QueryTime) => + if assignment + .summary_timings + .get(&key) + .copied() + .unwrap_or(assignment.default_timing) + == ExecutionTiming::QueryTime => { true } @@ -150,9 +165,9 @@ fn forces_query_time( /// Write the timings of `assignment` into every node reachable from `root`, /// top-down, then validate every edge. Returns the timed copy of `root`; /// `memo` carries the sharing across the roots of one workload. -pub fn apply_lifecycle_timings( +pub fn apply_materialization_timings( root: &Rc, - assignment: &LifecycleAssignment, + assignment: &MaterializationAssignment, memo: &mut TimingMemo, ) -> Result, ExecutionDataStateError> { let mut forced = HashMap::new(); @@ -172,24 +187,26 @@ pub fn apply_lifecycle_timings( Ok(timed) } -/// Validate the sub-DAG below `root` under the default (every summary -/// maintained) assignment, with `root` consumed at `root_timing`. For -/// planning-time legality checks of a candidate before it is assembled into -/// a workload DAG; nothing is kept. -pub fn validate_default( +/// Validate the sub-DAG below `root` with every summary maintained at +/// ingestion time ([`MaterializationAssignment::all_ingestion_time`]) and +/// `root` consumed at `root_timing`. For planning-time legality checks of a +/// candidate before it is assembled into a workload DAG: a candidate must stay +/// executable if materialization later maintains its states. Nothing is kept. +pub fn validate_maintained( root: &Rc, root_timing: ExecutionTiming, ) -> Result<(), ExecutionDataStateError> { - let assignment = LifecycleAssignment::default_maintained(); + let assignment = MaterializationAssignment::all_ingestion_time(); let mut memo = TimingMemo::new(); let mut forced = HashMap::new(); let timed = write(root, root_timing, &assignment, &mut memo, &mut forced)?; validate(&timed, &mut HashMap::new()) } -/// The data state `node` produces under the default assignment when its -/// consumer runs at `consumer` — the planning-time answer to "what does this -/// candidate's output look like" before any assignment is applied. +/// The data state `node` produces with every summary maintained at ingestion +/// time when its consumer runs at `consumer` — the planning-time answer to +/// "what does this candidate's output look like", consistent with +/// [`validate_maintained`]. pub fn planned_data_state( node: &Rc, consumer: ExecutionTiming, @@ -198,7 +215,7 @@ pub fn planned_data_state( let timing = own_timing( node, consumer, - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_ingestion_time(), &mut forced, ); ExecutionDataState { @@ -215,7 +232,7 @@ pub fn planned_data_state( fn own_timing( node: &Rc, consumer: ExecutionTiming, - assignment: &LifecycleAssignment, + assignment: &MaterializationAssignment, forced: &mut HashMap<*const OperatorNode, bool>, ) -> ExecutionTiming { // A placement fixed when the candidate was built (an exact-state read @@ -242,7 +259,7 @@ fn own_timing( fn write( node: &Rc, consumer: ExecutionTiming, - assignment: &LifecycleAssignment, + assignment: &MaterializationAssignment, memo: &mut TimingMemo, forced: &mut HashMap<*const OperatorNode, bool>, ) -> Result, ExecutionDataStateError> { @@ -573,7 +590,7 @@ fn validate_non_asap( /// one timing stays one `Rc`. Returns the (possibly rewritten) root. pub fn split_shared_by_phase( root: &Rc, - assignment: &LifecycleAssignment, + assignment: &MaterializationAssignment, ) -> Rc { // First pass: the set of timings each node is reached with. let mut reached: HashMap<*const OperatorNode, Vec> = HashMap::new(); @@ -581,7 +598,7 @@ pub fn split_shared_by_phase( fn collect( node: &Rc, consumer: ExecutionTiming, - assignment: &LifecycleAssignment, + assignment: &MaterializationAssignment, reached: &mut HashMap<*const OperatorNode, Vec>, forced: &mut HashMap<*const OperatorNode, bool>, ) { @@ -611,7 +628,7 @@ pub fn split_shared_by_phase( fn rebuild( node: &Rc, consumer: ExecutionTiming, - assignment: &LifecycleAssignment, + assignment: &MaterializationAssignment, reached: &HashMap<*const OperatorNode, Vec>, copies: &mut HashMap<(*const OperatorNode, ExecutionTiming), Rc>, forced: &mut HashMap<*const OperatorNode, bool>, @@ -774,10 +791,12 @@ mod tests { ) } + /// Apply with every summary maintained, the placement whose edge rules + /// these tests exercise. fn apply(root: &Rc) -> Result, ExecutionDataStateError> { - apply_lifecycle_timings( + apply_materialization_timings( root, - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_ingestion_time(), &mut TimingMemo::new(), ) } @@ -786,6 +805,38 @@ mod tests { Rc::clone(node.children()[0]) } + /// Without a materialization decision, a summary and its input run at + /// query time; an explicit per-state choice overrides the default. + #[test] + fn default_assignment_materializes_nothing() { + let summary = agg(scan(), kll()); + let root = apply_materialization_timings( + &summary, + &MaterializationAssignment::default(), + &mut TimingMemo::new(), + ) + .unwrap(); + assert_eq!( + data_state(&root), + Some(ExecutionDataState { + timing: ExecutionTiming::QueryTime, + primitive: DataPrimitive::SummaryState, + }) + ); + assert_eq!( + data_state(&child(&root)), + Some(ExecutionDataState::QUERY_ROWS) + ); + let mut assignment = MaterializationAssignment::all_query_time(); + assignment.set(&summary, ExecutionTiming::IngestionTime); + let root = + apply_materialization_timings(&summary, &assignment, &mut TimingMemo::new()).unwrap(); + assert_eq!( + data_state(&root), + Some(ExecutionDataState::INGESTION_SUMMARY) + ); + } + #[test] fn summary_agg_input_runs_at_ingestion_time() { let root = apply(&agg(scan(), kll())).unwrap(); @@ -811,10 +862,11 @@ mod tests { fn query_time_state_moves_its_consuming_summary_to_query_time() { let inner = agg(scan(), exact_sum()); let outer = agg(Rc::clone(&inner), kll()); - let mut assignment = LifecycleAssignment::default_maintained(); + let mut assignment = MaterializationAssignment::all_ingestion_time(); assignment.set(&inner, ExecutionTiming::QueryTime); let root = - apply_lifecycle_timings(&estimate(outer), &assignment, &mut TimingMemo::new()).unwrap(); + apply_materialization_timings(&estimate(outer), &assignment, &mut TimingMemo::new()) + .unwrap(); let outer = child(&root); assert_eq!(outer.timing, Some(ExecutionTiming::QueryTime)); assert_eq!(child(&outer).timing, Some(ExecutionTiming::QueryTime)); @@ -898,7 +950,7 @@ mod tests { second: ExecutionDataState::QUERY_ROWS, }) ); - let split = split_shared_by_phase(&root, &LifecycleAssignment::default_maintained()); + let split = split_shared_by_phase(&root, &MaterializationAssignment::all_ingestion_time()); assert!(apply(&split).is_ok()); } @@ -921,11 +973,11 @@ mod tests { }; for operand in [1, 2] { assert!(matches!( - validate_default(&corr_over(operand), ExecutionTiming::QueryTime), + validate_maintained(&corr_over(operand), ExecutionTiming::QueryTime), Err(ExecutionDataStateError::NonPlainOperand { .. }) )); } - validate_default(&corr_over(3), ExecutionTiming::QueryTime).unwrap(); + validate_maintained(&corr_over(3), ExecutionTiming::QueryTime).unwrap(); } // Typed derived updates retain one opaque series identity only when both // operands cover the same series; arbitrary labels are never admitted. diff --git a/crates/types/src/post_asap/execution_data_state.rs b/crates/types/src/post_asap/execution_data_state.rs index 8b1b197de..a8c177baa 100644 --- a/crates/types/src/post_asap/execution_data_state.rs +++ b/crates/types/src/post_asap/execution_data_state.rs @@ -137,9 +137,11 @@ pub enum ExecutionDataStateError { /// and `Extension`. #[error("{operator} has no execution timing contract yet")] UnimplementedOperator { operator: &'static str }, - /// A node reached by export without a timing: the lifecycle timing pass + /// A node reached by export without a timing: the materialization timing pass /// was not applied to the DAG first. - #[error("{operator} node has no execution timing; apply lifecycle timings before export")] + #[error( + "{operator} node has no execution timing; apply materialization timings before export" + )] UntimedNode { operator: &'static str }, /// The exported physical DAG failed its own validation, e.g. an empty /// batch or a hand-timed plan the timing pass would have rejected. diff --git a/crates/types/tests/physical_export.rs b/crates/types/tests/physical_export.rs index 485182384..2148a230e 100644 --- a/crates/types/tests/physical_export.rs +++ b/crates/types/tests/physical_export.rs @@ -4,8 +4,8 @@ use asap_types::ir::physical_export::{ PhysicalASAPDAGValidationError, }; use asap_types::ir::{ - apply_lifecycle_timings, ASAPOp, LifecycleAssignment, NonASAPOp, Operator, OperatorNode, - QueryRoot, TimingMemo, + apply_materialization_timings, ASAPOp, MaterializationAssignment, NonASAPOp, Operator, + OperatorNode, QueryRoot, TimingMemo, }; use asap_types::post_asap::{ ExactKind, ExactParams, ExecutionDataStateError, ExecutionTiming, SummaryUpdate, @@ -41,12 +41,12 @@ fn plan() -> Rc { .unwrap() } -/// Default lifecycle: the summary is maintained at ingestion time and read at query time. +/// A summary maintained at ingestion time is read at query time. #[test] fn timed_plan_exports_with_timing() { - let timed = apply_lifecycle_timings( + let timed = apply_materialization_timings( &plan(), - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_ingestion_time(), &mut TimingMemo::new(), ) .unwrap(); @@ -74,9 +74,9 @@ fn timed_plan_exports_with_timing() { /// A query-time producer cannot feed an ingestion-time consumer. #[test] fn query_time_input_to_ingestion_is_rejected() { - let timed = apply_lifecycle_timings( + let timed = apply_materialization_timings( &plan(), - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_ingestion_time(), &mut TimingMemo::new(), ) .unwrap(); @@ -105,11 +105,11 @@ fn batch_shares_the_summary_and_keeps_one_root_per_query() { child: state, })) .unwrap(); - let assignment = LifecycleAssignment::default_maintained(); + let assignment = MaterializationAssignment::all_query_time(); let mut memo = TimingMemo::new(); let timed: Vec<_> = [first, second] .iter() - .map(|root| apply_lifecycle_timings(root, &assignment, &mut memo).unwrap()) + .map(|root| apply_materialization_timings(root, &assignment, &mut memo).unwrap()) .collect(); let roots: Vec = timed.into_iter().map(QueryRoot::Operator).collect(); let dag = compile_physical_asap_workload(&roots).unwrap(); @@ -124,9 +124,9 @@ fn batch_shares_the_summary_and_keeps_one_root_per_query() { fn scalar_roots_export_in_batch_order_and_reference_nodes() { use asap_types::ir::{ExprSemantics, ScalarExpr}; use asap_types::pre_asap::ArithmeticOpKind; - let timed = apply_lifecycle_timings( + let timed = apply_materialization_timings( &plan(), - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_query_time(), &mut TimingMemo::new(), ) .unwrap(); @@ -221,9 +221,9 @@ fn a_reference_read_twice_is_one_scalar_ref_edge() { child: series("a"), })) .unwrap(); - let timed = apply_lifecycle_timings( + let timed = apply_materialization_timings( &project, - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::default(), &mut TimingMemo::new(), ) .unwrap(); @@ -262,9 +262,9 @@ fn swapped_edge_roles_are_rejected() { right: scan("b"), })) .unwrap(); - let timed = apply_lifecycle_timings( + let timed = apply_materialization_timings( &join, - &LifecycleAssignment::default_maintained(), + &MaterializationAssignment::all_query_time(), &mut TimingMemo::new(), ) .unwrap(); From d564c7c09df588dfaa8561f53460bebe2265ad94 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:23:55 +0000 Subject: [PATCH 3/8] docs: drop the summary maintenance lifecycle and document the query-time default Remove the lifecycle APIs, recipes and viewer section from the docs, delete the workload-demand-and-summary-lifecycle proposal, and point materialization questions to Stage 2 (#509). Rename LifecycleAssignment/apply_lifecycle_timings/ validate_default to their new names and describe the all-query-time default. Co-Authored-By: Claude Opus 5.5 --- docs/README.md | 3 +- docs/design_docs/architecture/README.md | 34 +-- .../architecture/asap-aware-mapping.md | 6 +- .../architecture/asap-aware-plan-search.md | 8 +- .../evidence-dependent-candidates.md | 6 +- .../architecture/input-output-workflow.md | 114 ++------- .../architecture/physical-plan-integration.md | 61 +---- .../architecture/planner-runtime-contract.md | 47 ++-- ...d_interface_with_pluggable_optimization.md | 83 +++--- docs/design_docs/concepts/planner-pipeline.md | 9 +- docs/design_docs/concepts/post-asap-ir.md | 32 +-- .../physical-planning-and-deployment.md | 142 +++++------ .../proposals/asap-aware-mapping/README.md | 1 - .../analytical-resource-cost.md | 237 ++---------------- .../maintained-populations.md | 5 +- .../proposals/asapquery-rule-coverage.md | 8 +- .../asap-aware-mapping-architecture.md | 4 +- docs/develop_docs/library-api.md | 218 ++-------------- docs/develop_docs/offline-sketch-evidence.md | 13 +- .../develop_docs/physical-compile-coverage.md | 5 +- docs/develop_docs/physical-handoff-costs.md | 4 +- docs/develop_docs/pre-asap-ir.md | 4 +- docs/develop_docs/storage-operation-costs.md | 3 +- .../target-candidate-api-migration.md | 6 +- docs/user_guide_docs/run-a-query.md | 4 +- 25 files changed, 248 insertions(+), 809 deletions(-) diff --git a/docs/README.md b/docs/README.md index ec15798f5..c4fc3ea7f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,8 +19,7 @@ Start with [ASAPPlanner input, output, and workflows](design_docs/architecture/i for the integration boundary, nested inputs, and choice of planning workflow. Use [Public library functions and examples](develop_docs/library-api.md) for -frontend lowering, workload search, ranking, optional selection and lifecycle -integration. +frontend lowering, workload search, ranking, selection and DAG assembly. ## Extend the planner diff --git a/docs/design_docs/architecture/README.md b/docs/design_docs/architecture/README.md index e5f8ff2e7..d0cc61026 100644 --- a/docs/design_docs/architecture/README.md +++ b/docs/design_docs/architecture/README.md @@ -8,7 +8,7 @@ deployment-level decision, and run the selected contract. For the integration workflow, start with [ASAPPlanner input, output, and workflows](input-output-workflow.md). It defines inputs, `CandidateLogicalASAPDAGs`, selection -and summary-maintenance lifecycle workflows, and future replanning support. +and assembly workflows, and future replanning support. ## Planner component flow @@ -23,10 +23,6 @@ flowchart TD RANK["Optional cost_sorted: ranked inspection view"] SELECT["Optional global_selection + assemble_selected_dag"] DAG["Selected logical Post-ASAP DAG"] - LINPUT["Optional lifecycle inputs: horizon, rates, capabilities, costs"] - LIFE["global_selection_with_summary_maintenance_lifecycles"] - LMAT["assemble_selected_dag_with_summary_maintenance_lifecycles"] - LPLAN["SummaryMaintenanceLifecyclePlan: DAG root + lifecycle decisions"] BACKEND["Downstream: bind physical alternatives, decide deployment, compile and execute"] W --> PRE F --> PRE @@ -35,19 +31,15 @@ flowchart TD SEARCH --> SPACE SPACE --> RANK --> BACKEND SPACE --> SELECT --> DAG --> BACKEND - SPACE --> LIFE - LINPUT --> LIFE --> LMAT --> LPLAN --> BACKEND ``` `CandidateLogicalASAPDAGs` is the output of logical candidate search. Each target's candidate set holds -alternatives and rejection reasons, but no selected maintenance lifecycle. -Choose among the three branches: inspect candidates (optionally ranked), select -and assemble logical DAGs, or select and assemble with summary-maintenance -lifecycle decisions. Use the last branch when Planner owns the maintenance -decision; otherwise the backend owns it. Its first -call returns a `GlobalSelection`; the second returns a -`SummaryMaintenanceLifecyclePlan` with an assembled DAG root and lifecycle -decisions. No branch by itself deploys or executes a physical plan. +alternatives and rejection reasons, but no materialization decision. +Choose between two branches: inspect candidates (optionally ranked), or select +and assemble logical DAGs. Stage 2 materialization (#509) will decide per +sub-DAG whether to materialize and whether at ingestion or query time; until +then every summary runs at query time. No branch by itself deploys or executes +a physical plan. Known-invalid evidence rejects a logical candidate. Missing accuracy evidence leaves a constructible candidate visible in `CandidateLogicalASAPDAGs` but uncertified; default selection does not commit it without the required guarantee. Cost evidence can @@ -61,7 +53,7 @@ an unsupported physical alternative into a deployable plan. | Shared IR | `asap-types` | The unified operator IR (`ir`: one `OperatorNode` before and after ASAP optimization), schemas, workloads, guarantees, and exported plan data | | Front-end common | `frontend-common` | Name-based `UnresolvedOp` tree shared by the front ends, and `resolve_root` into the operator IR | | Query frontends | `frontend-sql`, `frontend-promql`, `frontend-metricsql` | Parse source languages and produce canonical Pre-ASAP queries | -| ASAP-aware mapping | `asap-aware-mapping` | Candidate generation, CSE, legality, accuracy propagation, lifecycle expansion, costing, and ranking | +| ASAP-aware mapping | `asap-aware-mapping` | Candidate generation, CSE, legality, accuracy propagation, costing, and ranking | | Developer inspection | `devtools` | Expose planner DAGs, alternatives, decisions, and explanations for inspection | | End-to-end validation | `integration-tests` | Verify behavior across frontends, mapping, and output IR | @@ -77,8 +69,7 @@ The primary output is `CandidateLogicalASAPDAGs`; `cost_sorted` derives an optio view with index-aligned costs. Downstream may inspect compatible choices across targets rather than assuming the first candidate is a feasible physical workload plan. Candidates carry logical summary algorithms, -parameters, and guarantees; selected maintenance lifecycle decisions appear -only after a summary-maintenance-lifecycle-aware helper runs. Rejection reasons +parameters, and guarantees, but no materialization decision. Rejection reasons are retained in the candidate space. ASAPQuery-backend and other downstream applications translate the candidates @@ -90,10 +81,8 @@ must not silently change Planner-owned semantics. `CandidateLogicalASAPDAGs::global_selection` optionally coordinates structural choices across targets; `GlobalSelection::assemble_selected_dag` constructs a selected semantic DAG. -Those plain APIs do not establish physical feasibility or a -maintenance-versus-recompute decision. The lifecycle-aware selection call uses -additional workload and evidence inputs; its DAG assembly call returns a -plan with both a root and lifecycle decisions. See the [library guide](../../develop_docs/library-api.md#optional-whole-plan-selection-and-dag-assembly) +Those APIs do not establish physical feasibility or a +materialization decision. See the [library guide](../../develop_docs/library-api.md#optional-whole-plan-selection-and-dag-assembly) for the distinction. Downstream may consume candidates directly and retains responsibility for physical commitment. @@ -104,7 +93,6 @@ responsibility for physical commitment. - [Post-ASAP IR](../concepts/post-asap-ir.md) - [ASAP-aware mapping](asap-aware-mapping.md) - [Accuracy guarantees](../proposals/asap-aware-mapping/end-to-end-accuracy-guarantees.md) -- [Workload demand and summary lifecycle](../proposals/asap-aware-mapping/workload-demand-and-summary-lifecycle.md) - [Physical-plan integration](physical-plan-integration.md) - [Analytical resource cost](../proposals/asap-aware-mapping/analytical-resource-cost.md) - [Searching over plans](asap-aware-plan-search.md) diff --git a/docs/design_docs/architecture/asap-aware-mapping.md b/docs/design_docs/architecture/asap-aware-mapping.md index 78d3e0f9d..c04fa530a 100644 --- a/docs/design_docs/architecture/asap-aware-mapping.md +++ b/docs/design_docs/architecture/asap-aware-mapping.md @@ -8,7 +8,7 @@ Given a logical query plan, the mapping layer explores alternative plans that ma Candidate search takes canonical **Pre-ASAP query roots** and produces `CandidateLogicalASAPDAGs`, a compact set of **candidate Post-ASAP DAGs**. Ranking, selection, -and summary-maintenance lifecycle decisions are subsequent operations over it; +and DAG assembly are subsequent operations over it; see [input, output, and workflows](input-output-workflow.md). For example, a percentile query might be answered by: @@ -75,7 +75,6 @@ CandidateLogicalASAPDAGs: compact candidate Post-ASAP DAGs | +--> inspect / rank +--> select and assemble logical DAGs - +--> select and assemble with summary-maintenance lifecycle decisions ``` --- @@ -104,9 +103,6 @@ The design is split into focused documents: - [ASAPPlanner planner-runtime contract](planner-runtime-contract.md) separates planner-owned search and selection from downstream physical implementation, deployment, and execution. -- [Query workloads, data workloads, and summary lifecycle maintenance](../proposals/asap-aware-mapping/workload-demand-and-summary-lifecycle.md) separates - query-workload properties from data-workload properties and defines ephemeral, prepared, - shared, and continuously maintained summary-state alternatives. - [Explainability](../../develop_docs/replacement-explanations.md) describes how the planner reports available replacements using the same candidate space it optimizes. diff --git a/docs/design_docs/architecture/asap-aware-plan-search.md b/docs/design_docs/architecture/asap-aware-plan-search.md index fbc5982bc..e612740b6 100644 --- a/docs/design_docs/architecture/asap-aware-plan-search.md +++ b/docs/design_docs/architecture/asap-aware-plan-search.md @@ -84,10 +84,10 @@ sets. This avoids copying every full plan when most structure is shared. Cartesian product. `cost_sorted` returns a `RankedTargetSubDAGCandidates` view for each target. `global_selection` coordinates supported sharing and composition choices; `assemble_selected_dag(root)` assembles one selected DAG -per query root. This does not prove global physical optimality or select a -summary-maintenance lifecycle. The -[workflow design](input-output-workflow.md#workflows) explains when to use the -ordinary or summary-maintenance-lifecycle-aware path. +per query root. This does not prove global physical optimality or decide +which summaries are materialized; Stage 2 materialization (#509) will own that. +The [workflow design](input-output-workflow.md#workflows) describes the call +order. The [code architecture](../../develop_docs/asap-aware-mapping-architecture.md) describes current discovery and registry behavior; the diff --git a/docs/design_docs/architecture/evidence-dependent-candidates.md b/docs/design_docs/architecture/evidence-dependent-candidates.md index 9dcbba3fd..437cfe4b0 100644 --- a/docs/design_docs/architecture/evidence-dependent-candidates.md +++ b/docs/design_docs/architecture/evidence-dependent-candidates.md @@ -58,12 +58,10 @@ not emit a `RejectedCandidate` for that case. | HLL confidence | Symbolic failure probability | Reject a fully known unmet root target. | | Relative-value composition | Symbolic bound when input sign is unknown | Reject known signed input for this rule. | | Exact sum/average/extremum | Symbolic row-count probability term | Reject unsupported metric combinations. | -| Cost/rate/physical evidence | `None` cost or missing workload rate; candidate remains in `CandidateLogicalASAPDAGs` | Physical/lifecycle evaluation reports unavailable or rejected evidence. | +| Cost/rate/physical evidence | `None` cost or missing workload rate; candidate remains in `CandidateLogicalASAPDAGs` | Physical evaluation reports unavailable or rejected evidence. | | Mixed exact/summary operator | Unknown runtime support; candidate remains in `CandidateLogicalASAPDAGs` | `Some(false)` prevents construction. | -Lifecycle deployment choices are a separate output from `CandidateLogicalASAPDAGs`; their -capability/cost rejections do not erase the logical summary candidate. The -backend must still check ordinary summary family, window, and state-operation +The backend must still check summary family, window, and state-operation capabilities before deployment. - Accuracy/domain: `AccuracyEvidenceProvider` supplies quantile domains and diff --git a/docs/design_docs/architecture/input-output-workflow.md b/docs/design_docs/architecture/input-output-workflow.md index 4120811ca..18221035e 100644 --- a/docs/design_docs/architecture/input-output-workflow.md +++ b/docs/design_docs/architecture/input-output-workflow.md @@ -18,7 +18,7 @@ Post-ASAP alternatives for the workload. | `PlanningWorkload.data_workload` | Data arrival and optional evidence about ingestion, cardinality, and distribution | No implicit default. Set `None` when unavailable for non-PromQL workloads; PromQL requires `Some(DataWorkload)` with a nonzero ingestion interval. | | Frontend-specific dependencies (outside `PlanningWorkload`) | `SqlCatalog` for SQL; `now_ms` and, when needed, `HistogramCatalog` for PromQL | `SqlCatalog` is required for SQL lowering; `now_ms` is required for PromQL lowering | | Planning models | Candidate cost/ranking and accuracy composition/checking | Used by the relevant APIs; built-in `DefaultCostModel` and `DefaultAccuracyModel` are available | -| External evidence and capabilities | Domain facts, measured costs, workload statistics, and runtime support | Supply when available and when the chosen optimization or lifecycle decision depends on them; absence is not proof | +| External evidence and capabilities | Domain facts, measured costs, workload statistics, and runtime support | Supply when available and when the chosen optimization depends on them; absence is not proof | Frontend lowering and candidate search are stages within this workflow, not additional end-to-end inputs. See [Inputs](#inputs) for the nested workload @@ -30,13 +30,14 @@ fields and [frontend dependencies](#frontend-specific-dependencies). |---|---|---| | `CandidateLogicalASAPDAGs` | The legal candidate Post-ASAP DAGs for the workload, represented compactly as canonical roots, one candidate set per target sub-DAG, and cross-target composition information | The ASAPPlanner output | -[Ranking](#ranked-view), [selection and -DAG assembly](#selection-and-dag-assembly), and -[summary-maintenance lifecycle](#summary-maintenance-lifecycle-aware-helper) APIs operate on this `CandidateLogicalASAPDAGs`. +[Ranking](#ranked-view) and [selection and +DAG assembly](#selection-and-dag-assembly) APIs operate on this `CandidateLogicalASAPDAGs`. These are alternative uses of the candidate space, not mandatory sequential -stages. `CandidateLogicalASAPDAGs` itself has no selected summary-maintenance lifecycle, and -its candidates do not choose precompute versus query-time placement: a chosen -lifecycle assignment sets each node's execution timing. +stages. Its candidates do not choose ingestion-time versus query-time +placement: a `MaterializationAssignment` sets each node's execution timing. +Stage 2 materialization (#509) will decide per sub-DAG whether to materialize +and whether at ingestion or query time; until then every summary runs at query +time. The candidate DAGs are logical planning artifacts. ASAPPlanner does **not** produce a deployed executable plan; downstream systems bind physical operators, @@ -58,7 +59,7 @@ PlanningWorkload + frontend dependencies + planning models/evidence Suppose a dashboard evaluates `count_over_time(up[5m])` once a minute, and `up` receives a sample every 15 seconds. This diagram traces the concrete -inputs and the three possible uses of the same candidate space: +inputs and the two possible uses of the same candidate space: ```mermaid flowchart TD @@ -72,10 +73,6 @@ flowchart TD I["cost_sorted: inspect choices"] G["global_selection + assemble_selected_dag(root)"] L["One selected Post-ASAP DAG; the exact pre-ASAP sub-DAG if no optimization is selected"] - X["Extra lifecycle inputs: horizon; update rate; capabilities; comparable summary/raw costs"] - H["Summary-maintenance-lifecycle-aware selection"] - HM["Assemble one selected DAG and decide summary maintenance"] - O["SummaryMaintenanceLifecyclePlan: assembled DAG root + maintenance/recompute decision"] B["Backend: bind physical operators, deploy, and execute"] Q --> F D --> F @@ -83,16 +80,13 @@ flowchart TD F --> R --> S --> P P --> I P --> G --> L --> B - P --> H - X --> H --> HM --> O --> B ``` “Predictable” says the query is known in advance; it is independent of its one-minute recurrence. The `CandidateLogicalASAPDAGs` may contain an exact count-summary -realization, but it is not a deployed query. Without the extra lifecycle -inputs, the caller can still inspect candidates or obtain a logical DAG; it -cannot conclude that maintaining a summary is cheaper than recomputing raw -results. +realization, but it is not a deployed query. The caller can inspect candidates +or obtain a logical DAG; deciding whether maintaining a summary is cheaper than +recomputing raw results belongs to Stage 2 materialization (#509). For contrast, a one-time SQL query needs a catalog but need not supply data arrival evidence merely to inspect logical alternatives: @@ -110,8 +104,7 @@ flowchart LR ``` In this SQL example, `data_workload` can be `None` if the chosen lowering and -search rules do not consume it. The lifecycle helper is not needed merely to -inspect the `CandidateLogicalASAPDAGs`. +search rules do not consume it. --- @@ -175,9 +168,9 @@ struct BatchEntry { |---|---:|---|---| | `query` | Yes | Raw query text in `QueryWorkload.language`. | `count(up)` determines the expression to lower and plan. | | `requirements` | Yes | Accuracy and response-latency requirements. Defaults mean exact accuracy and unspecified latency. | An explicit ε target permits approximate candidates; the exact default does not. | -| `predictability` | Yes as a field; `Unknown` is allowed | Whether the query is ad hoc, known in advance, or unknown. `known_at` records when a predictable query became known. | A report known at 10:00 and scheduled for 11:00 may use a `Prepared` summary before execution. `AdHoc` or `Unknown` does not establish that eligibility. | +| `predictability` | Yes as a field; `Unknown` is allowed | Whether the query is ad hoc, known in advance, or unknown. `known_at` records when a predictable query became known. | A report known at 10:00 and scheduled for 11:00 could have its summary prepared before execution. `AdHoc` or `Unknown` does not establish that eligibility. | | `invocations` | Yes, nonzero | Number of executions in this finite batch. | Ten executions can amortize one summary build differently from one execution. | -| `execute_at` | Optional | Known execution time. | The `Prepared` case above also needs an execution time; without it Planner cannot establish a preparation window. | +| `execute_at` | Optional | Known execution time. | The prepared case above also needs an execution time; without it no preparation window can be established. | | `time_selection` | Yes | Whether the query follows current data or a historical interval, its lookback, and any fixed upper bound. | A moving five-minute window can require deletion/window support that a fixed historical interval does not. | ##### `repeating_queries: Option>` @@ -197,7 +190,7 @@ struct RepeatingEntry { | `query` | Yes | Raw query text in `QueryWorkload.language`. | `rate(up[5m])` determines the expression to lower and plan. | | `demand` | Yes | A nonzero fixed interval, fixed interval with evaluation phase, nonempty explicit schedule, or evidence-backed estimated rate. | A query every minute produces more expected reads over a horizon than one every hour. | | `requirements` | Yes | Accuracy and response-latency requirements. | An exact dashboard query cannot use an approximate summary solely because it is cheaper. | -| `predictability` | Yes as a field; `Unknown` is allowed | Records whether future executions are known in advance; independent of recurrence. | Current lifecycle code does not use this field for repeating entries; set `Unknown` if no predictability claim is available. | +| `predictability` | Yes as a field; `Unknown` is allowed | Records whether future executions are known in advance; independent of recurrence. | Current planner code does not use this field for repeating entries; set `Unknown` if no predictability claim is available. | | `time_selection` | Yes | Event-time scope, optional lookback, and optional fixed `as_of` time. | A live five-minute lookback differs from a fixed historical range when checking maintenance capabilities. | ##### Shared entry fields @@ -216,7 +209,7 @@ fields expand as follows: Frontend lowering produces one Pre-ASAP `Rc` root for each normalized query entry. The caller must retain each root's association with its workload -entry for later recurrence and lifecycle planning. +entry for later recurrence and materialization planning. #### `data_workload: Option` @@ -285,8 +278,8 @@ latter cannot be fabricated by one. | Accuracy model | Target-aware search takes an `AccuracyModel`; `DefaultAccuracyModel` is available. Default strategies also use it for candidate construction. | Composes candidate guarantees and checks them against requested accuracy. The model does not itself provide missing data-domain facts. | | Cost model | Candidate strategies and `cost_sorted`/`global_selection` use a `CostModel`; `DefaultCostModel` is available. | Ranks or selects candidates. The built-in model is not a measured deployment cost for every physical implementation. | | Accuracy/domain evidence | `AccuracyEvidenceProvider`; default strategies use `NoAccuracyEvidence` when no provider is supplied. | Input ranges, nonempty populations, Top-K intervals, and similar facts can certify or rule out particular approximations. Missing facts remain unknown. | -| Measured cost evidence | Supplied through a deployment-specific cost model or physical-evidence provider when cost-based physical/lifecycle comparison is needed. | CPU, memory, and I/O estimates must be comparable before claiming a summary beats raw recomputation. | -| Runtime/lifecycle capabilities | Passed to lifecycle APIs or checked by deployment-specific providers; `SummaryMaintenanceLifecycleCapabilities::default()` enables all four lifecycle shapes, so it is not proof of actual backend support. | Prevents choosing a maintenance/window operation the intended executor cannot implement. | +| Measured cost evidence | Supplied through a deployment-specific cost model or physical-evidence provider when cost-based physical comparison is needed. | CPU, memory, and I/O estimates must be comparable before claiming a summary beats raw recomputation. | +| Runtime capabilities | Checked by deployment-specific providers. | Prevents choosing a maintenance/window operation the intended executor cannot implement. | For example, the query `quantile_over_time(0.9, data[5m]) / quantile_over_time(0.5, data[5m])` does not tell Planner whether the windows @@ -305,9 +298,6 @@ domain evidence is missing; automatic `global_selection` does not choose it. See the [candidate-search reference](../../develop_docs/library-api.md#generate-and-rank-candidates) for this backend-selection path. -Additional inputs for a Planner-owned maintenance decision are listed with the -[summary-maintenance-lifecycle-aware helper](#summary-maintenance-lifecycle-aware-helper). - --- ## Output @@ -375,8 +365,7 @@ Then choose the operation matching the caller's responsibility: | Purpose | Operation | Result | |---|---|---| | Inspect candidates or let the backend choose | [Ranked view](#ranked-view), if ranking is useful | Per-target candidate lists and costs | -| Ask Planner to choose logical computations; backend owns summary maintenance | [Selection and DAG assembly](#selection-and-dag-assembly) | One selected Post-ASAP DAG root per query | -| Ask Planner to also decide summary maintenance versus raw recomputation | [Summary-maintenance-lifecycle-aware helper](#summary-maintenance-lifecycle-aware-helper) | One plan containing a DAG root and maintenance decisions per query | +| Ask Planner to choose logical computations | [Selection and DAG assembly](#selection-and-dag-assembly) | One selected Post-ASAP DAG root per query | ### Ranked view @@ -435,67 +424,8 @@ with some nodes now ASAP operators) and carries no execution timing yet; the [API reference](../../develop_docs/library-api.md#api-definition-and-example) describes the function signatures and return handling. -This path selects how to compute the query, not how to maintain summary state. - -### Summary-maintenance-lifecycle-aware helper - -This workflow performs both candidate selection and DAG assembly, incorporating -summary-maintenance lifecycle costs. Use it when ASAPPlanner owns the decision -to maintain summaries versus recompute raw data. It is not needed for candidate -inspection or when the downstream backend owns that decision. - -Starting from an existing `CandidateLogicalASAPDAGs`, call these two public helpers in order; -there is no need to run the ordinary selection/assembly workflow first: - -1. `global_selection_with_summary_maintenance_lifecycles` uses the workload - binding, lifecycle capabilities, and comparable costs to choose compatible - candidates across target sub-DAGs. It returns `GlobalSelection`, not a DAG or a - deployment plan. -2. For each wanted query root, `assemble_selected_dag_with_summary_maintenance_lifecycles` - takes that selection and root, constructs a Post-ASAP DAG, compares the - selected summary's maintenance cost with raw recomputation, and returns - `Result, SummaryMaintenanceLifecycleAssemblyError>`. - When a summary does not beat a - known raw cost, or a required comparable cost is unavailable, the result - retains the exact pre-ASAP root (`retain_exact`) and no summary deployments. - -As in ordinary selection, one selection call serves the workload and assembly -is per root. The second helper calls `assemble_selected_dag` internally; callers -do not need a separate assembly call. Neither helper creates a materialized view -or deploys runtime state. -The output is a selected logical DAG with lifecycle decisions, not an executable -deployment plan. Any claim of optimization is relative to the supplied cost -model, evidence, and available candidates. -See the [library guide's lifecycle and capabilities section](../../develop_docs/library-api.md#lifecycle-and-capabilities) -for an API example and the capability contract. - -Across the two calls, the caller supplies these parameters: - -| Helper parameter | Source | Required | -|---|---|---:| -| `CandidateLogicalASAPDAGs` | Canonical ASAPPlanner output; passed to selection | Yes | -| `GlobalSelection` and one root | Selection result and a root in that `CandidateLogicalASAPDAGs`; passed to DAG assembly | Yes for each assembled root | -| Workload binding | `QueryWorkload` plus the workload-entry indices associated with each root | Yes | -| Planning time (`now_ms`) | Caller clock in Unix milliseconds | Yes | -| Planning horizon | Caller policy | Conditional: required for finite totals over recurring demand | -| Data arrival and update rate | `DataWorkload` evidence | Conditional: required to cost continuous maintenance | -| Lifecycle capabilities | Deployment/runtime provider | Yes for checking deployable lifecycle alternatives | -| Summary and raw cost information | Cost model and physical-evidence provider | Yes for a cost-based maintenance-versus-recompute decision | - -Recurrence and time selection are already fields of the bound `QueryWorkload`; -they are not duplicated as separate top-level inputs. Similarly, data arrival -and update rate are read from the optional `DataWorkload`. Missing required -facts remain unknown rather than being treated as zero. - -The per-query output, `SummaryMaintenanceLifecyclePlan`, **contains** the -Post-ASAP DAG rather than being a parallel representation. It records: - -* the assembled Post-ASAP DAG root (`Rc`, with execution timing written); -* lifecycle choices for summary state; -* planning horizon and expected reads/updates; -* selected window implementation and guarantees; -* comparable summary and raw-recomputation costs; and -* whether raw recomputation was selected. +This path selects how to compute the query, not whether summary state is +materialized. --- diff --git a/docs/design_docs/architecture/physical-plan-integration.md b/docs/design_docs/architecture/physical-plan-integration.md index ffbf62239..df908440b 100644 --- a/docs/design_docs/architecture/physical-plan-integration.md +++ b/docs/design_docs/architecture/physical-plan-integration.md @@ -122,26 +122,10 @@ already exists. Until lowering introduces an explicit physical operator, statistics contract, validation rule, and resource formula for an operation, a candidate containing it is unavailable. -The streaming integration can consume a complete binding through -`SummaryNodeEvidence`. That binding is keyed to exact `OperatorNode` -identities and uses structured evidence for aggregate state, join, merge, -subtract, delete, evaluation, and retained pre-ASAP work. It is a physical -evidence boundary, not automatic physical lowering: a deployment must still -select each concrete implementation and provide all edges, resource facts, -multiplicities, source ownership, and stable physical identities. The planner -fails closed when any reachable ASAP node lacks that binding. - -The raw/query portion of a streaming comparison remains a `PhysicalDAG` using -the canonical `PhysicalOperator` and `OperatorStatistics` pairing. Summary -evidence is kept separate only where lifecycle-driven update, retention, and -expiration multiplicities require facts beyond the query-DAG -`Once`/`PerEvaluation` schedule. It must not redefine workload, lifecycle, or -summary-family semantics. - -Lifecycle choice affects the physical DAG but does not replace it. Ephemeral, -prepared, shared, and continuously maintained alternatives determine when -build, update, evaluation, merge, subtract, or delete nodes execute. The physical -operators still determine how each execution consumes CPU, memory, and I/O. +Materialization choice (Stage 2, #509) affects the physical DAG but does not +replace it: it determines when build, update, evaluation, merge, subtract, or +delete nodes execute. The physical operators still determine how each execution +consumes CPU, memory, and I/O. ## Statistics contract @@ -470,39 +454,8 @@ a numeric entity key is not a score. Exact accumulator inputs are explicitly finalized before row operators consume them. None of these operations proves candidate completeness; that evidence belongs to the semi-join's pruning step. -The logical DAG carries no execution layout: `apply_lifecycle_timings` writes -each node's timing from the lifecycle assignment before export. Uniform phase +The logical DAG carries no execution layout: `apply_materialization_timings` +writes each node's timing from a `MaterializationAssignment` before export (all +query time by default). Uniform phase assignment applies to the exported post-ASAP DAG; it is not a claim that every deployment has implemented every placement. - - -## Summary cost evidence across data-arrival modes - -`SummaryMaintenanceCostModel` binds `SummaryNodeEvidence` and -`SummaryOperatorEvidence` independently of data-arrival mode. `ComparisonScope` -and the canonical `DataWorkload` determine arrival semantics; individual operator -resource records do not define another workload model. - -`SummaryMaintenanceInputs::from_workload` requires fresh snapshot cardinality. -For `AtRest`, it derives zero arrivals without requiring ingestion-rate evidence; -a fresh nonzero or invalid rate contradicts that declaration and is rejected. -For `ContinuouslyIngesting`, fresh, finite, nonnegative rate evidence remains -mandatory. Missing continuous rate evidence is never treated as zero. -Raw and summary evidence supplied directly by a provider obey the same arrival -invariant. Their source lineage, horizon, evaluation count, and snapshot dimensions -must still match. The existing lifecycle planner selects direct builds for a fixed -snapshot and charges bootstrap work, result evaluation, and retention; it charges -no arrival updates. This does not add computation-placement policy. - -`Mixed` and `Unknown` remain unsupported for analytical comparisons: the current -workload schema cannot identify separate backlog and arrival populations. The -adapter fails explicitly rather than guessing a split. The estimator version is -`summary-maintenance-resource-v2`; evidence type names drop the `Streaming` prefix -(`SummaryMaintenanceInputs`, `SummaryPhysicalInputEvidence`, `SummaryAggregateEvidence`, -`RetainedSubDAGEvidence`, `RawInputEvidence`, and the summary window/alternative -types). Update source imports; no legacy-name aliases are provided. - -Regressions cover a fixed snapshot with no rate evidence, contradictory arrival -rates, scope mismatches, missing continuous-rate/cardinality evidence, and actual -lifecycle selection of a completely costed at-rest summary against its raw scan. -The existing continuous-ingestion and mixed-arrival rejection tests remain. diff --git a/docs/design_docs/architecture/planner-runtime-contract.md b/docs/design_docs/architecture/planner-runtime-contract.md index 5dfe5f760..0077252ca 100644 --- a/docs/design_docs/architecture/planner-runtime-contract.md +++ b/docs/design_docs/architecture/planner-runtime-contract.md @@ -4,33 +4,27 @@ ASAPPlanner produces `CandidateLogicalASAPDAGs`, a compact logical candidate space. Integrators may select candidates downstream or ask Planner's helpers to select and assemble -DAGs. Summary-maintenance lifecycle decisions belong to Planner only when the -integration uses its lifecycle-aware workflow; physical deployment and execution -remain downstream. The [input/output/workflow design](input-output-workflow.md) -defines this boundary. - -A downstream provider can report implementation alternatives and their cost and -accuracy evidence for a Planner-owned comparison. The resulting -`SummaryMaintenanceLifecyclePlan` contains a Post-ASAP DAG root and maintenance -decisions; it is not an executable plan. Repeated provider calls do not constitute -an implemented end-to-end replanning or deployment-transition protocol. +DAGs. Stage 2 materialization (#509) will decide per sub-DAG whether to +materialize and whether at ingestion or query time; until then every summary +runs at query time. Physical deployment and execution remain downstream. The +[input/output/workflow design](input-output-workflow.md) defines this boundary. ## Three decision layers | Layer | Owner | Examples | |---|---|---| | Logical candidate semantics | ASAPPlanner | Query rewrite; summary family and parameters; grouping; accuracy guarantees when established. | -| Summary maintenance and realization selection | Planner helpers when delegated to Planner; otherwise downstream | `Ephemeral`, `Prepared`, `Shared`, `ContinuouslyMaintained`; `DirectBuild` or `Incremental`; window implementations compared using provider evidence. | +| Summary materialization and realization selection | Stage 2 materialization (#509); downstream until then | Ingestion-time maintenance or query-time computation per summary state; direct build or incremental update; window implementations compared using provider evidence. | | Concrete implementation and deployment | ASAPQuery-backend and its workload optimizer | Library and data-structure implementation, exact pane layout, placement, sharding, storage, transmission, materialization IDs, executor configuration, and workload-wide assignment. | ASAPCollector and the ASAPQuery data plane execute the compiled downstream plans. They validate capabilities and plan identities, maintain or read the specified state, and report runtime observations. They do not silently choose -a different summary, lifecycle, or realization framework. +a different summary, materialization, or realization framework. ## Incremental-maintenance example -When the integration delegates summary-maintenance decisions to Planner, +Once Stage 2 materialization (#509) owns summary-maintenance decisions, ASAPPlanner may decide that a logical summary should be incrementally maintained: new data updates existing summary state. It may also select the planner-visible window realization—such as tumbling, sliding/panes, or an @@ -43,7 +37,7 @@ pane representation, runtime operator implementation, placement, sharding, watermark behavior, and materialization identifiers. ASAPCollector maintains the compiled panes and summary state. -Thus `Incremental` describes the state-update lifecycle, while tumbling, +Thus incremental update describes how state is maintained, while tumbling, sliding, and exponential-histogram describe realization algorithms. They are distinct axes, but both can participate in ASAPPlanner's candidate space. The backend still owns how the selected algorithms are physically realized. @@ -64,11 +58,10 @@ selected algorithm's semantics or guarantees. ## Iterative planning protocol (future integration) The sequence below is an intended integration design, not one shipped public -API or a required path for every caller. Current provider and lifecycle helpers -support a bounded planning decision; cross-run identity, migration, activation, -and rollback are not an end-to-end Planner protocol. +API or a required path for every caller. Cross-run identity, migration, +activation, and rollback are not an end-to-end Planner protocol. -1. ASAPPlanner enumerates semantically valid logical summaries, lifecycle +1. ASAPPlanner enumerates semantically valid logical summaries, materialization alternatives, and registered realization strategies. 2. A physical-plan provider maps those candidates to executor-feasible complete alternatives. Unsupported candidates are omitted or explicitly rejected. @@ -76,12 +69,12 @@ and rollback are not an end-to-end Planner protocol. scan selection, input/output edges, operation counts, update and bootstrap fanout, retained state, CPU, memory, I/O, and accuracy facts. 4. ASAPPlanner keeps constructible candidates with missing evidence visible - in `CandidateLogicalASAPDAGs` but does not certify unknown accuracy. The - summary-maintenance-lifecycle-aware workflow compares supported alternatives - over the same workload horizon. Missing or incomparable costs do not establish + in `CandidateLogicalASAPDAGs` but does not certify unknown accuracy. + Materialization compares supported alternatives over the same workload + horizon. Missing or incomparable costs do not establish that maintaining a summary beats raw recomputation; structural scores and optimistic zeroes are not substitutes. -5. ASAPPlanner outputs the selected Post-ASAP semantics, lifecycle guarantees, +5. ASAPPlanner outputs the selected Post-ASAP semantics, materialization choices, realization contract, and chosen provider identity. 6. ASAPQuery-backend compiles that result into consistent `CollectorPlan`, `BackendPlan`, and `QueryPlan` projections and performs deployment-level and @@ -106,11 +99,8 @@ such as cache behavior, serialization overhead, compression, spill I/O, or data-distribution-dependent sketch error. Provenance and version information must accompany those facts so stale observations fail closed. -`SummaryPhysicalPlanAlternative` is the current integration point for a -complete provider-enumerated implementation. Its identity is returned with the -winning lifecycle combination. More structured planner-owned realization -contracts can refine the candidate space without moving executor -implementation into ASAPPlanner. +More structured planner-owned realization contracts can refine the candidate +space without moving executor implementation into ASAPPlanner. ## Workload-wide optimization @@ -146,7 +136,7 @@ in one cost formula. automatically selected. - Shared logical nodes remain shared across the planner-runtime contract; physical sharing additionally requires compatible filters, grouping, windows, parameters, - lifecycle, and guarantees. + materialization, and guarantees. - Collector, backend, and query plans are projections of one compiled decision and cannot be optimized independently into inconsistent semantics. @@ -155,7 +145,6 @@ in one cost formula. - [Post-ASAP IR](../concepts/post-asap-ir.md) - [Physical plan integration](physical-plan-integration.md) - [Analytical resource cost](../proposals/asap-aware-mapping/analytical-resource-cost.md) -- [Workload demand and summary lifecycle](../proposals/asap-aware-mapping/workload-demand-and-summary-lifecycle.md) - [ASAPCollector physical compilation](https://github.com/ProjectASAP/ASAPCollector/blob/87684f4b61514382d8b087724694f93187bfc19c/docs/design_docs/control-plane/post-asap-physical-compilation.md) - [ASAPQuery configuration formulation](https://github.com/ProjectASAP/ASAPQuery/blob/main/.design_docs/sketch-config-optimization-formulation.md) - [ASAPQuery optimizer MIP formulation](https://github.com/ProjectASAP/ASAPQuery/blob/main/.design_docs/optimizer-mip-formulation.md) diff --git a/docs/design_docs/architecture/updated_interface_with_pluggable_optimization.md b/docs/design_docs/architecture/updated_interface_with_pluggable_optimization.md index 4820b6226..682055b0a 100644 --- a/docs/design_docs/architecture/updated_interface_with_pluggable_optimization.md +++ b/docs/design_docs/architecture/updated_interface_with_pluggable_optimization.md @@ -9,8 +9,8 @@ What that buys: * One call in place of six across three stages. `CandidateLogicalASAPDAGs` and `GlobalSelection` no longer appear in user code. -* The root-to-entry bindings a caller used to build by hand are derived, and - their ordering contract is checked rather than assumed. +* The root-to-entry binding a caller used to build by hand is derived, and + its ordering contract is checked rather than assumed. * A new optimization algorithm can be freely implemented as a trait implementation, rather than a rule disguised to fit a two-phase pipeline it does not share. @@ -20,7 +20,6 @@ Unchanged: `CandidateLogicalASAPDAGs`, `cost_sorted`, `global_selection`, and th ```text PlanningWorkload ──lowering──▶ ParsedWorkload ──OptimizationPass──▶ PlanOutput + frontend deps + models - + lifecycle input ``` --- @@ -64,7 +63,6 @@ Details of these types are provided below. | `workload` | `&PlanningWorkload` | | `frontend_specific` | `Sql { catalog }` / `Promql { now_ms, histograms }` / `Metricsql`; fixed by `query_workload.language` | | `models` | Cost model, accuracy model, evidence provider; `PlanningModels::builtin()` for the defaults | -| `lifecycle` | Planning clock and runtime capabilities for the maintenance-versus-recompute decision every plan carries | | `pass` | `None` uses `MajorPass` | ### `OptimizationInput` @@ -73,7 +71,6 @@ Details of these types are provided below. pub struct OptimizationInput<'a> { pub workload: &'a ParsedWorkload, pub models: PlanningModels<'a>, // same type UserInput uses - pub lifecycle: LifecycleInput, // same type UserInput uses } ``` @@ -83,19 +80,18 @@ pub struct OptimizationInput<'a> { ```rust pub struct PlanOutput { - pub plans: Vec, // one per workload entry, in entries() order + pub plans: Vec, // one per workload entry, in entries() order } -pub struct QueryLifecyclePlan { - pub entry_index: usize, // index into QueryWorkload::entries() - pub plan: SummaryMaintenanceLifecyclePlan, // its `root` is the DAG +pub struct QueryPlan { + pub entry_index: usize, // index into QueryWorkload::entries() + pub root: Rc, // selected post-ASAP DAG; shared nodes are the same Rc } ``` -Every plan carries the maintenance decisions, so the pass always runs -lifecycle-aware selection. A cost model that cannot price lifecycles -(`DefaultCostModel` today) makes that selection fall back to raw recompute for -every summary target; supply a model with the lifecycle cost hooks. +Plans carry no materialization decision. `PlanOutput::execution_timed_dag()` +times every summary at query time until Stage 2 materialization (#509) decides +per sub-DAG whether to materialize and whether at ingestion or query time. --- @@ -112,8 +108,9 @@ The `MajorPass` described below will be used by default, which corresponds to th |---|---| | Build roots | `Id` is the entry's position in `entries()`; the accuracy target comes from its `requirements` | | Candidate search | `search_workload_with_targets` with `default_strategies_with_evidence` | -| Select | `global_selection`, or `global_selection_with_summary_maintenance_lifecycles` with a `WorkloadDemand` derived from the `ParsedWorkload` | -| Assemble, per root | `assemble_selected_dag`, or its lifecycle-aware counterpart | +| Select | `CandidateLogicalASAPDAGs::global_selection` | +| Assemble, per root | `GlobalSelection::assemble_selected_dag` | +| Share | `asap_types::ir::cse::share_common_sub_dags` across the assembled roots | Moving it behind the trait changes one thing for existing developers: **`ReplacementStrategy` is now a concept of `MajorPass`, not of the optimization @@ -159,70 +156,60 @@ for name in registry.names() { `PassRegistry` is caller-owned, not a link-time global, so two tests in one binary cannot see each other's registrations. -### 3.3 The three existing workflows, in this shape +### 3.3 The existing workflows, in this shape -[Input, output, and workflows](input-output-workflow.md) describes three ways to -use the candidate space. Only the last is what a pass produces; the other two -stay on the old interfaces. +[Input, output, and workflows](input-output-workflow.md) describes two ways to +use the candidate space. The second is what a pass produces; the first stays +on the old interfaces. | Workflow there | Here | |---|---| | Ranked view (`cost_sorted`) | Not covered by this design, you should handle it with old interfaces | -| Selection and DAG assembly | Not covered either: `search_workload_with_targets` + `global_selection` + `assemble_selected_dag` | -| Summary-maintenance-lifecycle-aware helper | `PlanOutput` | +| Selection and DAG assembly | `PlanOutput` | -The third is no longer a call sequence the caller drives. +Selection and assembly are no longer a call sequence the caller drives. Following is an example of how the old workflow maps to the new interface. ```rust -// Before — from a PlanningWorkload and a catalog, with lifecycle decisions. +// Before — from a PlanningWorkload and a catalog. // 1. Lower every normalized entry, and record which entry each root came from. // Not lower_sql_batch: it walks query_batch alone and drops repeating entries. let mut roots = Vec::new(); -let mut entry_indices = Vec::new(); for (index, entry) in workload.query_workload.entries().enumerate() { let accuracy = entry.requirements.accuracy.target(); let expr = lower_sql_dialect(&entry.query.0, &catalog, dialect.clone(), accuracy.clone()) .await?; roots.push((index, expr, Some(accuracy))); - entry_indices.push(index); } // 2. Search for candidates. let strategies = default_strategies_with_evidence(&cost_model, &evidence); let space = search_workload_with_targets(roots, &strategies, &accuracy_model); -// 3. Select once for the whole workload, re-binding roots to workload entries. -let demand = WorkloadDemand { - workload: &workload.query_workload, - data_workload: workload.data_workload.as_ref(), - entry_indices: &entry_indices, -}; -let selection = global_selection_with_summary_maintenance_lifecycles( - &space, demand, now_ms, horizon, capabilities, &cost_model)?; - -// 4. Assemble once per root. -let mut plans = Vec::new(); +// 3. Select once for the whole workload. +let selection = space.global_selection(&cost_model); + +// 4. Assemble once per root, then share common sub-DAGs across roots. +let mut assembled = Vec::new(); for (index, root) in &space.roots { - let plan = assemble_selected_dag_with_summary_maintenance_lifecycles( - &selection, root, demand, now_ms, horizon, capabilities, &cost_model)?; - plans.push((*index, plan)); + if let Some(dag) = selection.assemble_selected_dag(root)? { + assembled.push((*index, dag)); + } } +let plans = share_common_sub_dags(assembled); ``` ```rust // After. let output = e2e_plan( UserInput::new(&workload, FrontendInput::Sql { catalog: &catalog }, - PlanningModels::builtin(), - LifecycleInput::new(now_ms, capabilities).with_horizon(horizon)) + PlanningModels::builtin()) ).await?; ``` -Steps 1 and 3 are where the two bindings lived: the `Id` carried through the -roots tuple, and the `&[usize]` rebuilt for `WorkloadDemand`. Both had to agree -with `entries()` order, and nothing checked that they did. `MajorPass` still +Step 1 is where the binding lived: the `Id` carried through the roots tuple had +to agree with `entries()` order, and nothing checked that it did. `MajorPass` still runs all four steps; another pass need not run any of them. ## 4. Code layout @@ -230,7 +217,7 @@ runs all four steps; another pass need not run any of them. | Crate | What it holds | |---|---| | `asap-types` | `ParsedWorkload` | -| `asap-aware-mapping` | `OptimizationPass`, `OptimizationInput`, `PlanOutput`, `PlanningModels`, `LifecycleInput`, `optimize`, `PassRegistry`, `MajorPass` | +| `asap-aware-mapping` | `OptimizationPass`, `OptimizationInput`, `PlanOutput`, `PlanningModels`, `optimize`, `PassRegistry`, `MajorPass` | | `asap-planner` *(new)* | `e2e_plan`, `UserInput`, `FrontendInput`, lowering dispatch | ```text @@ -242,9 +229,9 @@ asap-planner ──┬──> asap-frontend-{sql, promql, metricsql} `asap-planner` is separate because it is the only crate depending on every frontend; before it, the sole facade re-exporting more than one was -`asap-devtools`, a developer-tools crate. `PlanningModels` and `LifecycleInput` -live in `asap-aware-mapping` because both inputs use them, and `asap-planner` -re-exports them. +`asap-devtools`, a developer-tools crate. `PlanningModels` lives in +`asap-aware-mapping` because both inputs use it, and `asap-planner` re-exports +it. --- diff --git a/docs/design_docs/concepts/planner-pipeline.md b/docs/design_docs/concepts/planner-pipeline.md index 7401ae0da..c07d17775 100644 --- a/docs/design_docs/concepts/planner-pipeline.md +++ b/docs/design_docs/concepts/planner-pipeline.md @@ -14,12 +14,11 @@ over that output, not mandatory stages of candidate search. | +--> inspect candidates, optionally using cost_sorted +--> select and assemble logical DAGs - +--> select and assemble with summary-maintenance lifecycle decisions -The last two branches are alternatives: use the summary-maintenance-lifecycle-aware -workflow when Planner owns maintenance-versus-recomputation decisions; otherwise -the backend owns them. All physical binding, deployment, and execution remain -downstream responsibilities. +Stage 2 materialization (#509) will decide per sub-DAG whether to materialize +and whether at ingestion or query time; until then every summary runs at query +time. All physical binding, deployment, and execution remain downstream +responsibilities. The [input, output, and workflows](../architecture/input-output-workflow.md) document defines the public boundary and helper call order. diff --git a/docs/design_docs/concepts/post-asap-ir.md b/docs/design_docs/concepts/post-asap-ir.md index f7592f3d6..95e48f350 100644 --- a/docs/design_docs/concepts/post-asap-ir.md +++ b/docs/design_docs/concepts/post-asap-ir.md @@ -62,11 +62,9 @@ Reserved (migrated but unimplemented; schema derivation rejects them with The earlier draft listed `SummaryCreate` and `SummaryInsert`. These are not separate variants. `SummaryAgg` describes the state-producing -computation and its update input. The -[summary-maintenance lifecycle](../proposals/asap-aware-mapping/workload-demand-and-summary-lifecycle.md) -separately describes when state is created, retained, shared, updated and retired. -Physical binding and runtime execution implement the actual build and update -operations. Not every summary family supports incremental maintenance. +computation and its update input. Stage 2 materialization (#509) will decide +whether and when that state is maintained. Physical binding and runtime +execution implement the actual build and update operations. Not every summary family supports incremental maintenance. ## Exact work and composition @@ -79,8 +77,8 @@ Exact work is represented by the ordinary operators, unchanged: assessed yet (`is_logical_rewrite`). - `BinaryOp` combines independently planned operands. Summary planning may set its typed division guards (`checked_finite_division`, - `checked_relative_division`); the operator's timing comes from the lifecycle - assignment, not from the operator. + `checked_relative_division`); the operator's timing comes from the + materialization assignment, not from the operator. - Aggregate, projection, filter, sort and limit over an evaluation are the ordinary `Aggregate`, `Project`, `Filter`, `Sort` and `Limit` operators reading an ASAP node. Exact-accumulator state may pass through the projection-like @@ -109,11 +107,12 @@ not definitions of the operator. The logical DAG carries no timing: `OperatorNode::timing` is `None` on every front-end node and every candidate, and `map_children` clears it. Summary -materialization chooses a lifecycle per summary state and records it in a -[`LifecycleAssignment`](../../../crates/types/src/ir/timing.rs) (ingestion-time -maintenance or query-time recomputation per `SummaryAgg`; a state absent from -the assignment defaults to ingestion-time maintenance). -`apply_lifecycle_timings(root, &assignment, &mut TimingMemo)` then writes a +materialization chooses a timing per summary state and records it in a +[`MaterializationAssignment`](../../../crates/types/src/ir/timing.rs) (ingestion-time +maintenance or query-time computation per `SummaryAgg`). The default is +`all_query_time()`; until Stage 2 materialization (#509) decides otherwise, the +planner times every `SummaryAgg` at query time. +`apply_materialization_timings(root, &assignment, &mut TimingMemo)` then writes a timing into every node, top-down: - a node of fixed kind takes its kind's timing — `SummaryEstimate` and @@ -129,8 +128,9 @@ The pass then validates every edge (rows or exact-accumulator state into a an `EvaluatePopulation`, no ingestion work reading a query-time value) and rejects a node reached from two consumers that need different timings; `split_shared_by_phase` copies such a sub-DAG for one side before the -assignment is applied. `validate_default` and `planned_data_state` answer the -same questions for a candidate at planning time without keeping anything. +assignment is applied. `validate_maintained` and `planned_data_state` answer the +same questions for a candidate at planning time, assuming every summary is +maintained at ingestion time, without keeping anything. ## Exported DAG @@ -157,7 +157,7 @@ children as edges and no embedded sub-DAGs: producer's data state and grouping/window compatibility. - Each node records `output_state` (timing plus `Raw` or `SummaryState`), `output_schema` and `guarantee`. Export reads the timing written by - `apply_lifecycle_timings` and rejects an untimed node + `apply_materialization_timings` and rejects an untimed node (`ExecutionDataStateError::UntimedNode`); it does not re-run data-state validation. @@ -196,7 +196,7 @@ membership guarantee's child provenance. An exact request does not accept this approximate output path merely because its selected identities are certified. Deployment chooses ingestion time or query time for these operators. The -lifecycle assignment writes the placement; `with_execution_phases` can +materialization assignment writes the placement; `with_execution_phases` can reassign it on the exported DAG. Either deployment must give each evaluation a complete rate window and an isolated summary state, or maintain an equivalent replacement strategy. Appending successive rate snapshots to one cumulative state is invalid. diff --git a/docs/design_docs/physical-planning-and-deployment.md b/docs/design_docs/physical-planning-and-deployment.md index 7bb8a93f6..549ead9de 100644 --- a/docs/design_docs/physical-planning-and-deployment.md +++ b/docs/design_docs/physical-planning-and-deployment.md @@ -7,11 +7,11 @@ A Post-ASAP computation is progressively realized through four layers: ```mermaid flowchart LR L["Logical Post-ASAP DAG
What computation?"] - M["Summary Maintenance Lifecycle
How is state maintained?"] + M["Materialization
How is state maintained?"] P["Physical DAG(s)
How is it executed?"] D["Deployment Plan / DAG
How is it instantiated?"] - L -->|"Summary Maintenance
Candidate Generation"| M + L -->|"Stage 2
Materialization (#509)"| M M -->|"Physical Plan
Compiler"| P P -->|"Deployment Plan
Compiler"| D ``` @@ -19,46 +19,43 @@ flowchart LR | Layer | Defines | | --- | --- | | **Logical Post-ASAP DAG** | Computation semantics | -| **Summary Maintenance Lifecycle** | Build, retention, reuse, and window strategy | +| **Materialization** | Build, retention, reuse, and window strategy | | **Physical DAG(s)** | Supported physical candidates, executable operators and typed input boundaries | | **Deployment Plan / DAG** | Selected candidate, concrete data/state bindings and operational lifecycle | ASAPPlanner owns the first three layers and the shared physical operator implementation library. Deployment systems such as ASAPQuery and asap-fusion -own deployment compilation and operation. The lifecycle is a planning contract +own deployment compilation and operation. Materialization is a planning contract associated with the logical DAG, not a separate computation IR. +> **Status:** Stage 2 materialization (#509) will decide per sub-DAG whether to +> materialize and whether at ingestion or query time. It is not implemented yet; +> until then the planner times every summary at query time. Sections 2 and 3 +> describe the intended contract. + The Logical Post-ASAP DAG is preceded by the Pre-ASAP DAG, the language-independent query semantics before summary selection. Both are logical `OperatorNode` DAGs. `compile_physical_asap_dag` exports the selected DAG as a `PhysicalASAPDAG`, which is the Physical Plan Compiler's input. Its per-node execution phase (ingestion or query time) is -decided by the selected summary maintenance lifecycle, as the layer contract -below states. +decided by a `MaterializationAssignment`, as the layer contract below states. ### Layer contract 1. **Logical Post-ASAP** (`CandidateLogicalASAPDAGs`) decides what to compute: summary families, readouts and sharing. It does not decide placement; timing that a realization strategy writes while building a candidate is provisional. -2. **Summary maintenance lifecycle** (Planner) lists the lifecycle choices for - each unique retained state: every summary state (`SummaryAgg`) and every - maintained population that does not feed a summary state. - A chosen assignment determines every node's - `ExecutionTiming`, plus window framework and retention. - `SummaryMaintenanceLifecyclePlan::execution_timed_dag` applies it: a retained - (non-`Ephemeral`) state and all of its inputs run at ingestion time; - readouts, other consumers, and `Ephemeral` states not consumed by retained - state run at query time. A population that feeds a summary state is one of - that state's inputs and follows its timing. +2. **Materialization** (Stage 2, #509) chooses ingestion or query time for each + summary state (`SummaryAgg`) and records it in a `MaterializationAssignment`. + `apply_materialization_timings` writes every node's `ExecutionTiming`: an + ingestion-time state and all of its inputs run at ingestion time; readouts, + other consumers, and query-time states run at query time. + `MaintainPopulation` always runs at ingestion time. The default assignment + is all query time, which `PlanOutput::execution_timed_dag` applies. 3. **Physical compile** (Planner) reads timing: ingestion-time nodes form the precompute DAG and the rest form the query DAG, joined by typed outputs. It does not see raw ingestion, panes, storage or stored-state readout. -4. **Backend** chooses the lifecycle assignment with its own `CostModel`: - precompute CPU (`maintenance_cost_per_update`), sketch/summary store cost - (`retention_cost_rate`), query reads (`summary_read_cost`) and per-query - builds (`build_cost`, for `Ephemeral`), counting shared state once. - `Ephemeral` requires the deployment to supply the state's raw input as a - query-time source. +4. **Backend** binds and executes the timed DAG. A query-time summary requires + the deployment to supply the state's raw input as a query-time source. ### Candidate generation and deployment selection @@ -68,7 +65,7 @@ because a deployment-independent cost estimate prefers another candidate. Logical candidates are an internal search stage, not the deployment handoff. ```text -Query semantics + accuracy and lifecycle requirements +Query semantics + accuracy and freshness requirements ↓ Planner Supported Physical DAG candidates + typed inputs/outputs + requirements ↓ backend @@ -92,9 +89,9 @@ and a feasible candidate that loses on cost. Absence is not a cost comparison. For `sum by(job)(rate(m[1m]))`, Rate remains per series before grouped Sum. `CandidateLogicalASAPDAGs` offers one such candidate, with a per-series Rate state and a grouped -Sum state. Its lifecycle assignment places it: a retained Sum state finalizes -Rate and builds Sum within a bounded precompute run; an `Ephemeral` Sum over a -retained Rate state leaves the Rate readout and Sum in the query DAG. Storing a +Sum state. Its materialization assignment places it: an ingestion-time Sum state +finalizes Rate and builds Sum within a bounded precompute run; a query-time Sum +over an ingestion-time Rate state leaves the Rate readout and Sum in the query DAG. Storing a value requires its exact evaluation window, revision, readiness and serving cadence to match the query contract. @@ -108,7 +105,7 @@ Planner's candidate space decides what to compute, not placement. For an instant-vector PromQL TopK, Planner resolves rows that carry the complete series identity and lists the current-series heap realizations per root with the other candidates, unranked. Precompute or query-time placement of Rate and grouped Sum -is not a separate Planner candidate: the summary maintenance lifecycle assigns +is not a separate Planner candidate: the materialization assignment sets each node's timing, and the physical compiler reads it. This is the target ownership contract. A backend path that still reconstructs @@ -169,10 +166,10 @@ KLLMerge p50 p99 │ - │ Summary Maintenance Candidate Generation + │ Stage 2 Materialization (#509) ▼ -2. Summary Maintenance Lifecycle +2. Materialization KLLBuild(k=200) strategy = continuously maintain @@ -226,7 +223,7 @@ Each stage adds a different class of decision while preserving the preceding contracts. Here, continuous maintenance means recurring production of pane state; the bounded build DAG does not itself implement an unbounded streaming window. -## 2. Logical Post-ASAP DAG → Summary Maintenance Lifecycle +## 2. Logical Post-ASAP DAG → Materialization The **Logical Post-ASAP DAG** defines computation semantics: @@ -244,33 +241,13 @@ Quantile(.5) Quantile(.99) It establishes that KLL with `k=200` is used and that the merge is shared by the two readouts. It does not determine when KLL states are built or retained. -**Summary Maintenance Candidate Generation** enumerates legal lifecycle choices -using workload demand, window/freshness requirements and supported physical -implementations. Backend selection uses runtime feasibility and cost after -physical compilation. The following example follows one candidate. - -Candidate generation and selection are separate steps. For every unique retained -state, enumeration reports each lifecycle (ephemeral, prepared, shared, -continuously maintained) as legal, with a Planner cost or explicitly unknown -cost, or as rejected with a reason. Planner does not remove a legal alternative -because its own estimate prefers another. A deployment prices the legal -alternatives over the whole workload, counting shared state once, and binds one -lifecycle per state. Binding checks that the choice is legal and that states on -one maintenance path share an evaluation schedule. An alternative whose cost is -unknown can be bound only when the deployment's cost model is authoritative for -complete-candidate cost; unknown cost is never treated as zero. It then yields the same -lifecycle guarantee and window framework the physical compiler consumes when -Planner selects. Planner's own cheapest-alternative selection remains available -for callers without deployment pricing. The window framework is decided for the -complete combination, not for one alternative in isolation. - -A maintained population (for example, the current series of `topk by(job)(1, m)`) -is retained state like a summary. Retaining it maintains the latest sample per -series at ingestion and leaves only the readout at query time. Choosing -`Ephemeral` rebuilds that snapshot from raw samples for each query, so the -deployment must supply the raw source at query time. The caller's `CostModel` -prices both through the same lifecycle hooks; a model without population -evidence leaves them unknown, and they are not selected. +**Stage 2 Materialization (#509)** will choose when states are built and how +long they are retained, using workload demand, window/freshness requirements +and supported physical implementations. Backend selection uses runtime +feasibility and cost after physical compilation. Retained state includes +maintained populations (for example, the current series of +`topk by(job)(1, m)`), which keep the latest sample per series at ingestion and +leave only the readout at query time. For the running example, assume it selects: @@ -290,24 +267,24 @@ reuse: one merged state serves p50 and p99 ``` -This produces the **Summary Maintenance Lifecycle**. +This is the running example's **Materialization**. -The lifecycle specifies how the selected logical summary should be maintained, +It specifies how the selected logical summary should be maintained, but not its concrete operator implementation or storage location. Physical feasibility may feed back into selection. For example, if the required -pane-based maintenance cannot be implemented, this lifecycle candidate cannot be +pane-based maintenance cannot be implemented, this materialization cannot be selected. One-minute panes alone also cannot cover an arbitrarily phased query window; that requires supported boundary handling or a different candidate. -## 3. Summary Maintenance Lifecycle → Physical DAG +## 3. Materialization → Physical DAG The **Physical Plan Compiler** consumes both computation semantics and maintenance requirements: ```text Timed Post-ASAP DAG (PhysicalASAPDAG) -+ Summary Maintenance Lifecycle ++ Materialization + physical capabilities ↓ Physical Plan Compiler @@ -315,14 +292,13 @@ Physical Plan Compiler Physical DAG(s) ``` -For the running example, the lifecycle creates two execution boundaries. +For the running example, materialization creates two execution boundaries. These two halves are named as `PhysicalASAPDAG` names them, `precompute` -and `query`. *Maintenance* stays the lifecycle's word (section 2): it covers +and `query`. *Maintenance* stays materialization's word (section 2): it covers how state is built, retained, reused and scheduled. A precompute DAG is the -physical object that a maintenance lifecycle compiles to, so reusing -*maintenance* for it collapses two layers that the crates keep apart: -`asap-aware-mapping::summary_maintenance_*` owns the lifecycle, and +physical object that maintenance compiles to, so reusing *maintenance* for it +collapses two layers: Stage 2 materialization (#509) owns maintenance, and `asap-physical-operators::physical_planner` owns the DAGs. ### Precompute Physical DAG @@ -384,17 +360,17 @@ DAG. If the required behavior cannot be realized, physical compilation fails. Materialization frontiers are Planner decisions. A candidate records both the precompute Physical DAG and the query Physical DAG, with typed outputs connecting them. The deployment compiler binds those outputs; it does not move operators. -Lifecycle timing gives the frontier: ingestion-time nodes read by query-time -nodes. For `sum by(job)(rate(m[1m]))`, the two lifecycle choices of the single +Execution timing gives the frontier: ingestion-time nodes read by query-time +nodes. For `sum by(job)(rate(m[1m]))`, two materialization choices for the single logical candidate give: ```text -Candidate A (Rate state retained, Sum Ephemeral): +Candidate A (Rate state at ingestion time, Sum at query time): precompute: counter samples → per-series Rate state materialized output: per-series Rate states for window/evaluation/revision query: stored Rate states → Rate readout → grouped Sum → result -Candidate B (Rate and Sum states retained): +Candidate B (Rate and Sum states at ingestion time): precompute: counter samples → per-series Rate → grouped Sum state materialized output: grouped Sum states for window/evaluation/revision query: stored grouped Sum states → Sum readout → result @@ -417,9 +393,9 @@ feasibility is rejected before pricing. The optimizer supplies candidate frontiers and cost evidence, including updates, retention, recurrence and sharing. `enumerate_frontiers` constructs bounded, reachable antichain frontiers above explicit input boundaries, including query-only and fully precomputed results. It fails explicitly when the candidate budget is exceeded. Maintenance selection must still reject frontiers that violate window, freshness, or reuse requirements; deployment feasibility is checked before pricing. -The lifecycle layer decides timing; physical compilation reads it. Lowering a +Materialization decides timing; physical compilation reads it. Lowering a node does not depend on the frontier, so each query DAG is lowered once and -different lifecycle assignments are different cuts of that lowering. +different materialization assignments are different cuts of that lowering. `compile(dag, inputs, roots)` yields the complete `CompiledPhysicalDAG`. `frontier_from_timing(&timed_dag)` reads an assignment's timed DAG (from `execution_timed_dag`) and returns its frontier: ingestion-time nodes read by @@ -435,7 +411,7 @@ pane candidates remain a separate lowering. Physical compilation opens no readers. Bounded precompute outputs become typed query inputs. Their source, filters, grouping, build window, evaluation time, readiness and -revision contracts must accompany the selected lifecycle and be checked during +revision contracts must accompany the selected materialization and be checked during deployment binding. Type compatibility alone does not establish reuse legality. The Planner integration test executes both candidates through the shared runtime @@ -450,7 +426,7 @@ The **Deployment Plan Compiler** binds the Physical DAGs to the concrete deploym ```text Physical DAGs -+ Summary Maintenance Lifecycle ++ Materialization + deployment catalog/state + sources/materializations + operational policy @@ -494,7 +470,7 @@ InputSlot[5 panes] ``` The Deployment Plan Compiler establishes bindings and checks that their contracts -satisfy the physical inputs and selected lifecycle, including KLL parameters, +satisfy the physical inputs and selected materialization, including KLL parameters, source, filters, grouping, window coverage and revision scope. The deployment engine resolves request-specific states and checks their actual coverage, revisions and readiness at execution time. A compiled plan cannot establish future readiness. @@ -510,8 +486,8 @@ The complete example makes the ownership boundary explicit: | Stage | KLL example decision | | --- | --- | | **Logical Post-ASAP DAG** | Use `KLL(k=200)` with shared merge for p50/p99 | -| **Summary Maintenance Candidate Generation** | Maintain 1-minute panes and reuse them for aligned five-minute queries | -| **Summary Maintenance Lifecycle** | Record pane/window/freshness/reuse requirements and each node's execution timing | +| **Stage 2 Materialization (#509)** | Maintain 1-minute panes and reuse them for aligned five-minute queries | +| **Materialization** | Record pane/window/freshness/reuse requirements and each node's execution timing | | **Physical Plan Compiler** | Lower to native KLL build, merge, and readout operators | | **Physical DAG** | Define precompute and query DAGs with typed input/output boundaries | | **Deployment Plan Compiler** | Bind raw input and KLL state slots to concrete sources/materializations | @@ -521,7 +497,7 @@ The complete example makes the ownership boundary explicit: Logical: "Use KLL for p50/p99." -Lifecycle: +Materialization: "Maintain reusable 1-minute KLL panes." Physical: @@ -551,17 +527,11 @@ snapshots as separate inputs. ## 6. Executable acceptance coverage -The tests cover optimizer-selected lifecycle execution alongside independent +The tests cover physical candidate execution alongside independent operator/runtime fixtures: | Test | Contract exercised | | --- | --- | -| `summary_maintenance_lifecycle_e2e::continuous_lifecycle_compiles_and_executes_spatial_kll` | PromQL workload → selected continuous lifecycle → logical DAG → compiled precompute/query candidate → results in independent revisions; an unbounded candidate fails before pricing, and a bounded request candidate summarizes the same input samples | -| `summary_maintenance_lifecycle_e2e::chosen_lifecycle_timing_decides_precompute_contents` | PromQL workload → enumerated lifecycles → explicit choice → timed DAG → compiled candidate; ContinuouslyMaintained stores the state in precompute, Ephemeral leaves precompute empty and reads the raw source at query time; both return the same p99 | -| `summary_maintenance_lifecycle_e2e::lifecycle_timing_cuts_one_compilation` | KLL quantile and grouped Rate→Sum: one compilation cut by the ContinuouslyMaintained and Ephemeral timed DAGs equals `compile_candidate` for each; the frontier is the retained state or empty | - -| `summary_maintenance_lifecycle_e2e::chosen_population_lifecycle_decides_precompute_contents` | PromQL `topk by(job)` over a maintained population → explicit choice → timed DAG → compiled candidate; ContinuouslyMaintained stores the population in precompute, Ephemeral rebuilds it from raw samples at query time; both rank alike | -| `summary_maintenance_lifecycle_e2e::planner_lifecycle_selection_reproduces_strategy_timing` | For PromQL summary fixtures, the timed DAG from Planner's retained selection equals the DAG realization strategies produce | | `kll_pane_execution::five_panes_roundtrip_and_shared_merge_runs_once` | Explicit one-minute precompute DAGs → real MessagePack state bytes → five required query inputs → shared native merge → p50/p99; counts every sample once, checks adjacent aligned windows and instruments one merge start per run | | `kll_pane_execution::restored_panes_reject_corruption_parameters_schema_and_missing_binding` | Corrupt bytes, parameter relabelling, incompatible schemas and absent bindings fail explicitly | | `precompute_candidates::grouped_rate_can_be_materialized_before_or_after_grouped_sum` | Cost changes select different legal precompute frontiers; both selected candidates execute with the same reset-sensitive result; uncompilable candidates are not priced | diff --git a/docs/design_docs/proposals/asap-aware-mapping/README.md b/docs/design_docs/proposals/asap-aware-mapping/README.md index a58d3b5dd..814d50134 100644 --- a/docs/design_docs/proposals/asap-aware-mapping/README.md +++ b/docs/design_docs/proposals/asap-aware-mapping/README.md @@ -9,4 +9,3 @@ extensions. Each status note identifies the implemented scope and remaining work - [Shared maintained populations](maintained-populations.md) - [Optimization dimensions](optimizations.md) - [Summary properties](summary-properties.md) -- [Workload demand and summary lifecycle](workload-demand-and-summary-lifecycle.md) diff --git a/docs/design_docs/proposals/asap-aware-mapping/analytical-resource-cost.md b/docs/design_docs/proposals/asap-aware-mapping/analytical-resource-cost.md index b742a8970..6e270159e 100644 --- a/docs/design_docs/proposals/asap-aware-mapping/analytical-resource-cost.md +++ b/docs/design_docs/proposals/asap-aware-mapping/analytical-resource-cost.md @@ -2,7 +2,7 @@ > Status: implemented model with explicit support limits. The > [analytical estimator](../../../../crates/asap-aware-mapping/src/analytical_cost.rs) -> and physical/streaming adapters implement supported evidenced comparisons. +> and physical-plan adapter implement supported evidenced comparisons. > Unsupported operators, arrival modes and missing evidence remain unavailable; > proposed extensions are not implied by the implemented formulas. @@ -17,23 +17,19 @@ plans. These are separate concerns: evidence to estimate CPU work, peak memory, and source/disk I/O. The physical-resource estimator itself is independent of the arrival mode. -Two planner adapters currently lower work into it. `PhysicalPlanCostModel` -compares complete at-rest plans. `SummaryMaintenanceCostModel` resolves -`DataArrival::ContinuouslyIngesting` over a finite horizon, including -bootstrap, arriving updates, retained state, and query readout. Evidence from -one arrival mode must not be reused for the other. `Mixed` and `Unknown` -remain unavailable until their distinct data regions are modeled. - -Both entry points replace dimensionless plan-node counts with estimates +One planner adapter currently lowers work into it: `PhysicalPlanCostModel` +compares complete at-rest plans. Continuously-ingesting, `Mixed` and `Unknown` +comparisons are unavailable; costing summary maintenance over arriving data +belongs to Stage 2 materialization (#509). Evidence from one arrival mode must +not be reused for another. + +The adapter replaces dimensionless plan-node counts with estimates derived from operator complexity, cardinality, row width, and concrete summary parameters. The estimates are predictions; they are not measurements reported by a physical executor. The model does not decide semantic legality. Ordinary summary guarantees are -composed before costing. A window framework that itself introduces error must, -however, carry a typed composed guarantee in the same complete evidence bundle; -the streaming adapter checks that guarantee against every bound workload -accuracy target before the candidate can be ranked. Missing evidence produces +composed before costing. Missing evidence produces an unavailable estimate, never an assumed zero or a structural-cost fallback. The implementation keeps five layers distinct: @@ -64,21 +60,12 @@ physical_operator_statistics.rs ──────┤ physical evidence contract ▼ analytical_cost.rs ─────────── operator formulas and CPU/memory/I/O composition │ - ├──────────────► physical_plan_cost_model.rs - │ at-rest raw/rewrite/summary comparison adapter - │ - └──────────────► summary_maintenance_cost/ - evidence.rs authoritative summary evidence - estimator.rs complete maintenance-DAG resources - window.rs window assignment and accuracy - model.rs lifecycle/alternative ranking adapter + └──────────────► physical_plan_cost_model.rs + at-rest raw/rewrite/summary comparison adapter ``` -Raw query plans and incrementally maintained summary plans share -`EvidenceBackedPhysicalDAG`; there is no streaming-only duplicate of the -physical DAG or operator-statistics contract. Summary-maintenance modules add -only the evidence and scheduling semantics that do not exist for an ordinary -query plan. +Raw query plans and summary plans share `EvidenceBackedPhysicalDAG`; there is +no separate physical DAG or operator-statistics contract for summaries. An estimate has physical dimensions: @@ -236,14 +223,14 @@ normalized workload, lowered query IR, and freshness-aware statistics: `DataWorkload` does define whether input is streaming: its `arrival` field is `AtRest`, `ContinuouslyIngesting`, `Mixed`, or `Unknown`, and a continuous arrival rate comes from fresh `ingestion_rate` evidence. These facts describe -how source data arrives. They do not choose a lifecycle or a window framework: +how source data arrives. They do not choose materialization or a window framework: `Incremental` describes how a selected summary state is updated, while tumbling, sliding, and exponential histogram describe how that state is organized over time. Evidence is read through `Evidence::value_at(planning_time)`. Stale, future, or improperly time-bounded evidence remains unknown. Costing follows -the same freshness rule as accuracy and lifecycle planning. +the same freshness rule as accuracy checking. The current workload schema does not yet contain every physical statistic. The missing facts have explicit ownership: @@ -306,7 +293,7 @@ physical operators and matching statistics variants. Until then, a candidate containing such an unlowered operation is unavailable rather than partially costed. -## Workload horizon and lifecycle +## Workload horizon Every alternative must cover the same source data and query horizon. The `DataArrival::AtRest` physical-DAG comparison is build-once, read-many: @@ -327,9 +314,7 @@ incremental updates are a one-time snapshot build. The at-rest summary alternative scans the selected source snapshot once and retains state. Its raw alternative recomputes from that snapshot for every -query read. The continuously-ingesting entry point separately charges -bootstrap, updates, summary operations, retained state, and raw evaluations; -its lifecycle rules are defined below. +query read. ### Comparable source and workload scope @@ -733,157 +718,14 @@ input. ## Summary operator formulas -### Incremental single-summary foundation - -For `DataArrival::ContinuouslyIngesting`, the incremental estimator accepts -one selected lifecycle and one unique logical `SummaryAgg`. This deliberately -narrow contract prevents one flat evidence record from being reused across -several summary nodes with different input cardinalities, algorithms, or state -sizes. Complete multi-node streaming alternatives require per-node physical -evidence. - -The canonical workload supplies fresh bootstrap cardinality, ingestion rate, -query recurrence, planning time, and a finite horizon. Physical evidence adds -logical/bootstrap bytes, physical bootstrap scan bytes, active and retained -window counts, the number of concrete summary-state instances per window, and -bytes per state instance. Names use `summary`, not `sketch`, because an exact -aggregate or another non-sketch state is equally valid. - -For bootstrap rows `B`, arrivals `U`, simultaneously updated windows `A`, -query evaluations `Q`, physical summary instances `P`, and state bytes `S`: - -```text -insert invocations = (B + U) × A -retained memory = (A + retained_windows) × P × S -``` - -Each input row is routed to its matching summary instance; it is not inserted -into every group. Merge, subtract, and readout work may operate over all `P` -instances. Delete work follows the same routed window updates rather than -multiplying every update by every possible group. - -An empty bootstrap is valid and has zero logical bytes and zero source reads. -A non-empty bootstrap requires positive logical and physical source bytes. -Active window count, summary-instance count, state width, horizon, and query -evaluation count must be positive; retained-window count may be zero for a new -stream. Required per-operation CPU evidence must be finite and positive. - -Lifecycle retention and the planning horizon are different quantities. A -short retained window may be maintained throughout a much longer planning -horizon, so the estimator does not require `retention >= horizon`. Lifecycle -legality and query time-coverage checks establish whether the retained window -can answer the query. - -### Comparing single-summary lifecycle alternatives - -For one logical `SummaryAgg`, the analytical lifecycle adapter converts the -same physical evidence into the existing lifecycle planner's five cost terms: - -| Lifecycle term | Resource basis | -|---|---| -| Initial build | Bootstrap rows routed to every bootstrap-active window, plus the bootstrap source read. | -| Maintenance per update | One arriving row routed to every currently active window. | -| Summary read | Readout of every physical summary instance needed by one query evaluation. | -| Retention rate | All active and retained state bytes calibrated over the finite comparison horizon. | -| Retirement | Zero only for releasing modeled memory; an actual delete, expiration, or rebuild requires explicit operation evidence. | - -The existing lifecycle model—not this adapter—enumerates `Ephemeral`, -`Prepared`, `Shared`, and `ContinuouslyMaintained`, checks workload and runtime -legality, and multiplies per-update and per-read terms by the normalized -workload rates. Missing any required term leaves that alternative unavailable. - -`Ephemeral` is a direct build, not incremental maintenance. For every query -evaluation, it rebuilds from the snapshot visible at that evaluation, charges -that evaluation's complete source read, and releases its state afterward. -Its state contributes to peak transient memory but not persistent retention. - -The raw side is supplied as a complete `ResourceEstimate` for one execution of -the raw physical DAG. The lifecycle planner applies the same recurrence and -horizon. This deliberately avoids reconstructing raw work with a special-case -`input_rows × cpu_per_row` formula that would omit joins, windows, sorts, or -other operators. - -Flat single-summary evidence is bound to the exact `SummaryNode` and raw -`QueryExpr` identities for which it was produced. It cannot be reused for a -structurally similar node or for multiple summary states. A complete -multi-summary `SummaryExpr` DAG requires per-node physical evidence and -physical-identity deduplication. - -### Complete bound streaming summary DAGs - -The multi-node streaming path accepts a complete, already-bound -`SummaryExpr` DAG. It does not guess physical implementations. The provider must -provide evidence for every reachable node: - -| Logical node | Required physical evidence | -|---|---| -| `KeepPreAsap` | One retained preprocessing operator with output edge, horizon CPU, workspace, and output buffer. | -| `SummaryAgg` | Input/output edges, insert CPU, concrete state count and width, bootstrap/update window fanout, and explicit source-read ownership. | -| `SummaryMerge` | Typed merge evidence with total CPU, workspace, output buffer, I/O, and execution multiplicity. | -| `SummarySubtract` | Typed subtract evidence with the same resource dimensions. | -| `SummaryDelete` | Typed delete evidence plus expiration/retraction rate, routing fanout, and the exact state owner. | -| `SummaryEstimate` | Typed readout evidence with total resource use per execution. | -| `SummaryJoin` | Ordered input/output edges and total physical join CPU, workspace, output buffer, I/O, and multiplicity. | - -The merge/subtract/delete/readout evidence is an enum structured by operation -kind. Delete-only rate and routing fields therefore cannot be attached to a -merge or readout. Join CPU is the total build, probe, match-production, and -output work of the selected algorithm; matched output pairs alone are not a -valid join cost. - -Every parent input edge must equal the corresponding child output edge. -Provider-owned `physical_id` values deduplicate a shared operator only when -its complete evidence and physical child identities also agree. The cost model -holds owning `Rc` references for bound target and summary roots, so pointer -keys cannot become stale and alias a later allocation. - -A `SummaryAgg` that reads storage declares `scan_selection_index = Some(i)`, -a non-empty bootstrap-read identity, and positive physical source bytes. An -aggregate over an already-materialized summary edge declares `None`, an empty -read identity, and zero source bytes. Its logical input rows and bytes remain -positive when the intermediate is non-empty. This prevents nested aggregates -from charging the original source scan repeatedly. - -For streaming raw recomputation, `planning_time_input_rows`, -`planning_time_input_bytes`, and `planning_time_source_scan_bytes` describe the -initial snapshot. Logical bytes per arriving row and physical source bytes per -arriving row are separate. The recurrence determines every evaluation offset; -the provider supplies one once-counted physical DAG whose statistics aggregate -those evolving evaluations over the complete horizon. Marking its nodes -`PerEvaluation` would multiply the already-aggregated evidence again and is -rejected. Validation follows only nodes reachable from the physical root. If -the raw algorithm intentionally reads the same semantic source more than once, -each reachable scan carries the same evolved source statistics and is charged -separately; equal scan selection does not deduplicate physical I/O. - -This raw-evolution contract currently supports exactly one distinct source -coverage. A multi-source streaming target is unavailable until per-source -arrival rates and widths are supplied. Target lineage includes ordinary -predicates and PromQL info selectors; extra, missing, or mismatched source -coverage makes both sides incomparable. - -Lifecycle enumeration considers only alternatives legal for the canonical -workload and runtime. A `Prepared` state must cover every scheduled evaluation -it serves. `Shared.retention` describes data/window coverage, not the planning -horizon, so a shorter retention value is not rejected merely because the -optimizer horizon is longer. Missing node evidence, zero required CPU, -unknown I/O, inconsistent edges, or an unsupported lifecycle combination -makes the complete candidate unavailable; partial per-state costs are never -used as a fallback. +Costing incremental maintenance of continuously-ingested summaries was removed +with the summary maintenance lifecycle; Stage 2 materialization (#509) will +define it. The formulas below give per-operation work and state size. ### Ranking complete physical implementations A logical summary candidate can be bound to more than one complete physical -implementation. Each alternative has a non-empty, provider-owned identity and -a complete `StreamingNodeEvidence` bundle. The planner evaluates every legal -lifecycle combination against every bound physical implementation over the -same `ComparisonScope`, excludes alternatives whose evidence is incomplete or -invalid, and returns both the least calibrated cost and its physical-plan -identity. Duplicate identities are rejected because they would make the -selection result ambiguous. If no explicit alternatives are registered, the -candidate's single canonical evidence bundle is used. - -Physical evidence is alternative-specific: window fanout, retained state, +implementation. Physical evidence is alternative-specific: window fanout, retained state, operation costs, and source reads must describe that implementation as a whole. The planner does not mix individual nodes from different alternatives. @@ -903,7 +745,7 @@ cpu_ops = bootstrap_rows × bootstrap_window_count × insert_ops(params) scan_bytes = source_read_bytes for the build ``` -Merge, subtract, and delete add their own invocation counts described above; +Merge, subtract, and delete add their own invocation counts; they are never folded into the simple formula implicitly. Concrete accuracy-sized parameters determine state and work: @@ -927,7 +769,7 @@ from logical group count alone. Summary merge, subtract, delete, and readout are separate physical operators. Their CPU and memory use the concrete summary state size and number of input states. A plan using one of these operations is unavailable until the -corresponding formula and required lifecycle evidence are present. +corresponding formula and required evidence are present. Summary construction uses physical-input realization rules before it emits a `SummaryAgg`. The default rule consumes the logical aggregate's immediate @@ -988,9 +830,7 @@ mismatches and arithmetic overflow also fail closed. ### Downstream physical-planning boundary This cost model consumes resource evidence for a physical implementation, but -ASAPPlanner does not own or select that implementation. It does select the -abstract per-summary `SummaryWindowFramework` assignment by comparing complete -`StreamingWindowFrameworkCandidate` evidence bundles. Component ownership, +ASAPPlanner does not own or select that implementation. Component ownership, including the distinction between a window primitive and its concrete runtime implementation, is defined in [ASAPPlanner planner-runtime contract](../../architecture/planner-runtime-contract.md). @@ -1064,7 +904,7 @@ candidate. The intended end-to-end selection pipeline is: 1. enumerates semantically valid alternatives; -2. checks end-to-end accuracy and lifecycle legality; +2. checks end-to-end accuracy; 3. derives fresh workload and operator statistics; 4. sizes physical summary parameters; 5. estimates the complete candidate DAG; @@ -1075,31 +915,8 @@ listed above. `PhysicalPlanCostModel` executes this pipeline for every candidate supplied to `CandidateLogicalASAPDAGs::global_selection`. Logical rewrites are lowered recursively. Summary candidates participate only after the deployment has bound their complete `SummaryExpr` DAG; there is no optimistic generic -summary fallback. The streaming adapter connects raw recomputation and -primitive summary lifecycle costs to the existing global lifecycle-selection -hooks. -The lifecycle planner enumerates compatible lifecycle combinations for the -unique `SummaryAgg` deployments and invokes -`complete_summary_candidate_estimate` -for each combination before selecting the minimum. The hook receives explicit -node-to-guarantee bindings plus the horizon and expected reads. Each logical -occurrence is looked up by exact `Rc` identity, while every -evidence record also carries a provider-owned physical identity. Equal physical -identities deduplicate work and retained state only when their logical summary, -selected window framework, operator facts, edge statistics, lifecycle -guarantee, and physical child identities agree; -conflicts make the candidate unavailable. Thus heterogeneous states are costed -independently and genuinely shared deployments once. Merge, subtract, delete, -readout, and join participate in automatic -candidate ranking. Exhaustive whole-root scoring is capped at 4,096 lifecycle -combinations because an arbitrary whole-candidate hook cannot be soundly -pruned by primitive costs; a larger space is unavailable rather than consuming -exponential planner time. If the root needs unavailable operation evidence, the -hook returns unavailable. Global selection then excludes that summary and -materialization retains the raw expression. A missing raw estimate also forces -raw fallback, because no public selection/materialization path may publish an -uncompared summary. The planner never falls back to the partial `SummaryAgg` -sum. +summary fallback. Choosing between a maintained summary and raw recomputation +belongs to Stage 2 materialization (#509). Before applying the following arithmetic, callers validate exact equality of the raw and selected alternative's `ComparisonScope`, and use the same diff --git a/docs/design_docs/proposals/asap-aware-mapping/maintained-populations.md b/docs/design_docs/proposals/asap-aware-mapping/maintained-populations.md index 005670db8..c2b05588e 100644 --- a/docs/design_docs/proposals/asap-aware-mapping/maintained-populations.md +++ b/docs/design_docs/proposals/asap-aware-mapping/maintained-populations.md @@ -70,7 +70,7 @@ columns and multi-measure aggregates need additional rules. ```text KeepPreAsap(input) - -> MaintainPopulation { input, max_k, quantiles } [lifecycle-timed] + -> MaintainPopulation { input, max_k, quantiles } [ingestion time] -> ReadPopulation { Quantile(q1) } [read] -> ReadPopulation { Quantile(q2) } [read] -> ReadPopulation { TopK(k1) } [read] @@ -116,8 +116,7 @@ because their source names or numeric values happen to agree. ## Validation, selection and execution responsibilities Planner validates the declared input, the query-time readout and readout -compatibility; the population's lifecycle decides whether it is maintained at -ingestion or rebuilt per query. Its intended guarantee is exact membership and exact readout; +compatibility; `MaintainPopulation` always runs at ingestion time. Its intended guarantee is exact membership and exact readout; a physical implementation still must preserve the language's numeric and empty-input semantics. In particular, SQL global COUNT over an empty population returns a row with zero, while PromQL COUNT over an empty vector returns an empty vector. diff --git a/docs/design_docs/proposals/asapquery-rule-coverage.md b/docs/design_docs/proposals/asapquery-rule-coverage.md index 634878696..9ba999292 100644 --- a/docs/design_docs/proposals/asapquery-rule-coverage.md +++ b/docs/design_docs/proposals/asapquery-rule-coverage.md @@ -25,7 +25,7 @@ cost, and selection rules under `optimizer/`. The reviewed source is | Subpopulation label placement | Covered more generally | `HydraGroupingStrategy` and `GroupingStrategy` express per-subpopulation and shared multi-subpopulation realizations. | | Shared computation | Covered more generally | workload-wide CSE and `SharedSubDAGStrategy` operate on physical DAG identity rather than AQE names. | | Average decomposition | Semantic-equivalent rewriting | The same rewrite strategy exposes independently optimizable sum/count accumulators when null semantics and schema permit it. | -| Merge/delete legality | Covered | Summary-family capabilities and lifecycle validation determine which maintenance operations are legal. | +| Merge/delete legality | Covered | Summary-family capabilities determine which maintenance operations are legal. | | Window-framework selection | Separate physical-planning work | Window selection must compare an extensible set of implementations, including tumbling, sliding, PromSketch-style exponential-histogram windows, and other window frameworks. This audit does not introduce a closed window enum or choose among them. | | Retention/cleanup scheduling | Outside planner scope | The audit deliberately does not import ASAPQuery's Arroyo-specific cleanup thresholds, timers, or failure workarounds. The planner may declare a selected summary's required retention horizon and cost it, but the runtime/storage layer owns when and how expired physical state is reclaimed. | | Empirical per-sketch atomic costs | Covered through evidence | Analytical statistics and deployment profiles provide cost evidence; benchmark tables should be ingested as calibrated evidence rather than compiled into matching rules. | @@ -45,7 +45,7 @@ does not create a new strategy category. | Whether a finer grouping can answer a coarser grouping | `RollupStrategy` | | Whether tighter accuracy can answer a looser request | `AccuracyReconciliationStrategy` | | Whether a larger Top-K result can answer a smaller limit | `TopKLimitReuseStrategy` | -| Which maintenance lifecycle is legal | the summary-maintenance lifecycle planner | +| Whether and when a summary is materialized | Stage 2 materialization (#509) | | Which window framework implements a range | the physical deployment/window-selection planner | | How expired physical state is cleaned up | runtime/storage lifecycle management, not a planner strategy | @@ -53,7 +53,7 @@ Accordingly, ASAPQuery's four collapsible temporal/spatial patterns extend the existing semantic-rewrite owner. Temporal and spatial function recognition is already front-end lowering into `AggIntent`; sketch compatibility remains in `ASAPStrategies`; labels remain in `HydraGroupingStrategy`; and -maintenance lifecycle legality remains in the lifecycle planner. Window +materialization decisions belong to Stage 2 materialization (#509). Window framework selection is separate physical-planning work. None of these become a parallel syntax-oriented `PatternStrategy`. @@ -69,7 +69,7 @@ planner. Rules match typed operators and declared capabilities, never parser spellings. A rule that composes operators states the algebraic law it relies on and preserves the original output schema. Unknown pairs, missing statistics, or -unsupported lifecycle operations produce no candidate; they never silently +unsupported maintenance operations produce no candidate; they never silently fall back to an optimistic estimate. Window choices should follow the same principle without assuming that every diff --git a/docs/develop_docs/asap-aware-mapping-architecture.md b/docs/develop_docs/asap-aware-mapping-architecture.md index 154c131ff..eaf3d879a 100644 --- a/docs/develop_docs/asap-aware-mapping-architecture.md +++ b/docs/develop_docs/asap-aware-mapping-architecture.md @@ -257,8 +257,8 @@ contract consistent and preserves the full choice set for other callers. `CandidateLogicalASAPDAGs::global_selection` optionally coordinates cross-target sharing and composition choices. `GlobalSelection::assemble_selected_dag` constructs the selected -semantic DAG. These plain APIs do not establish lifecycle or physical deployment -feasibility. Recurrence and lifecycle-aware variants require the corresponding +semantic DAG. These APIs do not decide materialization or establish physical +deployment feasibility. Recurrence-aware variants require the corresponding workload and evidence inputs; downstream owns physical commitment and execution. See the [library workflow](library-api.md#optional-whole-plan-selection-and-dag-assembly). diff --git a/docs/develop_docs/library-api.md b/docs/develop_docs/library-api.md index a6eaaea70..0ace620b6 100644 --- a/docs/develop_docs/library-api.md +++ b/docs/develop_docs/library-api.md @@ -15,7 +15,6 @@ do not deploy a plan, and a serializable DAG is not evidence of runtime readines | Pre-ASAP IR | Frontend `lower_*` | [Lower a query](#lower-a-query-into-pre-asap-ir) | | All ranked candidates | `search_workload_with_targets` -> `cost_sorted` | [Generate and rank](#generate-and-rank-candidates) | | Custom optimization set | Construct `Vec>`, then search | [Strategies and models](#choose-strategies-and-models) | -| Summary-maintenance lifecycle comparison | Lifecycle-aware selection -> DAG assembly with maintenance decisions | [Lifecycle recipe](#lifecycle-and-capabilities) | | Selected semantic DAG / export | `global_selection` -> `assemble_selected_dag` -> export | [Selection example](#optional-whole-plan-selection-and-dag-assembly) | Each recipe ends at a different artifact. Use only the stages needed for that @@ -65,7 +64,7 @@ lower_promql_workload(workload: &PlanningWorkload, now_ms: u64) `DataWorkload.data_ingestion_interval` must contain a nonzero `Evidence`. Pass the actual planning time as `now_ms` (Unix milliseconds), consistently with -downstream lifecycle planning. Expired or future cadence evidence is rejected, +downstream planning. Expired or future cadence evidence is rejected, as is expiring evidence without an observation timestamp. The histogram variant takes the same timestamp after its histogram catalog argument. The examples use `0` only because their explicitly supplied cadence is timeless. @@ -288,7 +287,8 @@ Callers do not apply `with_series_identity` themselves. Compile each with `promql_rows::compile_current_series_evaluation`; other queries keep their previous inventory. `global_selection` never commits these candidates; the backend compiles and prices them. CandidateLogicalASAPDAGs lists no placement variants: node timing -comes from the summary maintenance lifecycle. +comes from a `MaterializationAssignment` (all query time until Stage 2 +materialization, #509, decides otherwise). ## Choose strategies and models @@ -409,7 +409,7 @@ Module-qualified paths below are relative to `asap_aware_mapping`. | Parameter | Available value / constructor | Meaning | | --- | --- | --- | | `&dyn CostModel` | `DefaultCostModel` | Built-in ordering/sizing and structural estimates; no measured deployment guarantee | -| `&dyn CostModel` | `empirical_cost::EmpiricalCostModel::new(provider)` | Offline sketch-benchmark model: ranks algorithms using matching offline measurements and supplies partial lifecycle costs | +| `&dyn CostModel` | `empirical_cost::EmpiricalCostModel::new(provider)` | Offline sketch-benchmark model: ranks algorithms using matching offline measurements | | `&dyn CostModel` | `physical_plan_cost_model::PhysicalPlanCostModel::new(&provider, calibration)?` | Deployment-specific physical-plan model: compares complete physical alternatives using provider evidence and resource calibration; evidence may be offline or online | | `&dyn AccuracyModel` | `DefaultAccuracyModel` | Built-in guarantee rules and satisfaction checks | | `&dyn AccuracyBudgetAllocator` | `EqualSplitAllocator` | Built-in allocation of composition accuracy budgets | @@ -423,7 +423,7 @@ These models differ in scope, not simply in whether they are offline or online. | Model | Evidence and comparison | Missing evidence / limits | | --- | --- | --- | -| `EmpiricalCostModel` | Offline sketch benchmarks matched to exact parameters, distribution, environment and validity interval; current algorithm ranking uses measured update CPU nanoseconds | If the measurements required for ranking are incomplete, preserves the incoming algorithm order. Supplies partial build/update lifecycle costs; `estimate_cost()` still uses `DefaultCostModel` structural scores | +| `EmpiricalCostModel` | Offline sketch benchmarks matched to exact parameters, distribution, environment and validity interval; current algorithm ranking uses measured update CPU nanoseconds | If the measurements required for ranking are incomplete, preserves the incoming algorithm order. `estimate_cost()` still uses `DefaultCostModel` structural scores | | `PhysicalPlanCostModel` | A downstream provider supplies a consistent evidence snapshot and complete physical alternatives; calibration converts modeled resource quantities into comparable costs | A candidate with incomplete evidence is unavailable, without structural-cost fallback. Current candidate admission also requires it to cost less than the raw alternative | `PhysicalPlanCostModel` does not collect online telemetry itself. Its provider @@ -478,7 +478,7 @@ constructor using default accuracy/allocation and no extra evidence. | Extension point | What it controls | What it cannot establish alone | | --- | --- | --- | | `ReplacementStrategy` | Proposed semantic alternatives | Permission to violate query semantics or downstream support | -| `CostModel` | Candidate ordering/sizing hooks, recurrence/lifecycle and complete-cost evidence hooks | Correctness, measured costs without evidence, or installed runtime support | +| `CostModel` | Candidate ordering/sizing hooks and recurrence cost hooks | Correctness, measured costs without evidence, or installed runtime support | | `AccuracyModel` | Derivation, propagation and satisfaction of guarantees | A meaningful guarantee without its required assumptions/evidence | | `AccuracyBudgetAllocator` | Local accuracy requirements proposed within composition | End-to-end correctness without subsequent validation | | `AccuracyEvidenceProvider` | Planning-time statistics used by supported strategies | Authority to change query requirements | @@ -497,12 +497,9 @@ Keep each provider's evidence scope and freshness valid for the query population inputs. `QueryWorkload` contains the language and optional batch/repeating entries. Entries carry requirements, predictability, recurrence and time selection. These facts are separate: repeated queries can read data at rest. -`WorkloadDemand` associates a target with the relevant workload entry indices -and explicitly includes or omits the parallel data evidence. -Both recurrence and lifecycle planning validate this independent data evidence: -ingestion rates must be finite and nonnegative, and data at rest cannot have a -positive ingestion rate. `DataWorkload::validate()` shares these checks with -`PlanningWorkload::validate()`. +`DataWorkload::validate()` checks the independent data evidence: ingestion +rates must be finite and nonnegative, and data at rest cannot have a positive +ingestion rate. `PlanningWorkload::validate()` shares these checks. | Type/input | Current behavior | Caller responsibility | | --- | --- | --- | @@ -510,188 +507,11 @@ positive ingestion rate. `DataWorkload::validate()` shares these checks with | `DataWorkload::default()` | Unknown arrival, unknown evidence | Supply facts needed for the requested comparisons | | `Evidence::default()` | No value, unknown source | Unknown/stale evidence is not zero; provide scoped valid observations | | `DefaultCostModel` | Built-in ordering/sizing and structural cost hooks | Supply deployment evidence for calibrated comparisons | -| `SummaryMaintenanceLifecycleCostInputs::default()` | All primitive costs unknown | Implement the required lifecycle cost hooks; structural defaults are insufficient | -| `horizon: None` in lifecycle planning | Horizon-dependent alternatives are unselectable | Supply a positive horizon when comparing rates/amortized reuse | -| Lifecycle capabilities default | All four modes enabled | Override with the actual runtime support | -| Per-summary maintenance capabilities default | Incremental update, merge, delete all false | Advertise supported operations for the concrete state representation | `Default` is a Rust constructor contract, not a general serde omission rule. Several workload fields require explicit serialized values. A struct field being optional also does not guarantee every planning operation can succeed without it. -## Lifecycle and capabilities - -Use this workflow when Planner owns summary-maintenance lifecycle decisions; -otherwise the backend may make them from logical candidates. It includes both -selection and DAG assembly, so callers do not first run the ordinary workflow. -The first helper returns one `GlobalSelection`; the second is called per root -and returns a plan containing `root: Rc` (already timed) plus maintenance decisions. -See the [workflow design](../design_docs/architecture/input-output-workflow.md#summary-maintenance-lifecycle-aware-helper). - -Two capabilities are distinct: the runtime can orchestrate a lifecycle, and the -chosen summary representation supports the required state operations. Both must -hold. Workload legality and known cost evidence can further restrict alternatives. - -### API definition and options - -```text -global_selection_with_summary_maintenance_lifecycles<'a, Id>( - space: &'a CandidateLogicalASAPDAGs, demand: WorkloadDemand<'_>, - now_ms: u64, horizon: Option, - capabilities: SummaryMaintenanceLifecycleCapabilities, cost_model: &dyn CostModel, -) -> Result, SummaryMaintenanceLifecycleSelectionError> - -assemble_selected_dag_with_summary_maintenance_lifecycles( - selection: &GlobalSelection<'_>, target: &Rc, - demand: WorkloadDemand<'_>, now_ms: u64, horizon: Option, - capabilities: SummaryMaintenanceLifecycleCapabilities, cost_model: &dyn CostModel, -) -> Result, SummaryMaintenanceLifecycleAssemblyError> -``` - -| Argument | Values / requirements | -| --- | --- | -| `space`, `demand` | Actual candidate space plus query demand, optional data evidence, and one normalized workload entry index for each `space.roots` entry | -| `target` | A root from `space.roots`, after canonical sharing | -| `demand` | `WorkloadDemand::new_with_data(...)` when data evidence is available; use `new_without_data(...)` only when its absence is intentional | -| `now_ms` | Actual planning time in Unix milliseconds for evidence freshness | -| `horizon` | `Some(Horizon(seconds))` with positive finite seconds, or `None` when horizon-dependent comparisons are unavailable | -| `capabilities` | Explicit Boolean fields below; several may be true | -| `cost_model` | A model supplying required lifecycle and raw-comparison evidence; default structural estimates are not enough | - -| Capability field | `true` permits consideration of… | `false` means… | -| --- | --- | --- | -| `supports_ephemeral` | Fresh build per invocation, retired afterward | Exclude that lifecycle | -| `supports_prepared` | Build before a predictable execution and retain until it | Exclude that lifecycle | -| `supports_shared` | Retain state for multiple reads | Exclude that lifecycle | -| `supports_continuously_maintained` | Keep state current as updates arrive | Exclude that lifecycle | - -All flags default to true; integrations should pass real support. Enabling a -flag does not override workload, algorithm-operation or evidence checks. - -### Example: lifecycle-aware planning for a batch-only runtime - -This helper takes the real workload and cost provider from your application. -It supports one searched root mapped to one workload entry, and returns a typed -plan/error rather than making up costs. For a shared root consumed by several -entries, construct demand using all applicable indices. - -```rust -use asap_aware_mapping::{ - global_selection_with_summary_maintenance_lifecycles, - assemble_selected_dag_with_summary_maintenance_lifecycles, CostModel, Horizon, CandidateLogicalASAPDAGs, - SummaryMaintenanceLifecycleCapabilities, SummaryMaintenanceLifecyclePlan, - WorkloadDemand, -}; -use asap_types::workload::PlanningWorkload; - -fn plan_batch_root( - space: &CandidateLogicalASAPDAGs<&str>, - workload: &PlanningWorkload, - entry_index: usize, - now_ms: u64, - horizon: Option, - model: &dyn CostModel, -) -> Result, Box> { - if space.roots.len() != 1 { - return Err("this example requires exactly one root".into()); - } - let capabilities = SummaryMaintenanceLifecycleCapabilities { - supports_ephemeral: true, - supports_prepared: false, - supports_shared: false, - supports_continuously_maintained: false, - }; - let indices = [entry_index]; - let demand = WorkloadDemand { - workload: &workload.query_workload, - data_workload: workload.data_workload.as_ref(), - entry_indices: &indices, - }; - let selection = global_selection_with_summary_maintenance_lifecycles( - space, demand, now_ms, horizon, capabilities, model, - )?; - let plan = assemble_selected_dag_with_summary_maintenance_lifecycles( - &selection, &space.roots[0].1, demand, - now_ms, horizon, capabilities, model, - )?; - if let Some(plan) = &plan { - println!("raw_recompute={}, deployments={:#?}", - plan.selected_raw_recompute, plan.deployments); - } - Ok(plan) -} -``` - -Use this helper with the `space` built by the search example and the corresponding -workload/provider. No incremental lifecycle is permitted, but unknown evidence -can still prevent choosing summary state. If only one legal alternative remains, -recording it is a complete lifecycle decision. Data-at-rest alone does not imply -that prepared or retained shared state is supported. - -| Function | Inputs | Output / promise | -| --- | --- | --- | -| `plan_summary_maintenance_lifecycles` | Assembled logical DAG root, `WorkloadDemand`, `now_ms`, optional horizon, runtime capabilities, cost model | `Result` for that fixed root; does not revisit all semantic candidates | -| `global_selection_with_summary_maintenance_lifecycles` | `CandidateLogicalASAPDAGs`, workload/root-entry associations, time, horizon, capabilities, cost model | Lifecycle-aware compatible selection/error, using eligible cost evidence | -| `assemble_selected_dag_with_summary_maintenance_lifecycles` | Selection, target root and lifecycle context | Optional lifecycle plan/error; attaches state deployment decisions | -| `enumerate_summary_maintenance_lifecycles` | Same inputs as `plan_summary_maintenance_lifecycles` | `SummaryMaintenanceLifecycleCandidates`: per unique retained state, every alternative with its cost or rejection; nothing selected. `guarantee(&lifecycle)` gives the mode/schedule that alternative would carry | -| `SummaryMaintenanceLifecycleCandidates::select(choices)` | One `(PostAsapNodeId, SummaryMaintenanceLifecycle)` per state, copied from `deployments()` | The same `SummaryMaintenanceLifecyclePlan` Planner selection would produce for that combination, or `SummaryMaintenanceLifecycleChoiceError` when a choice is unknown, missing, duplicated, rejected, schedule-incompatible, or not completely estimable | - -Inspect `deployments`, their selected lifecycle/alternatives/rejections, -`selected_raw_recompute`, and optional summary/raw costs. Success of a function -call alone is not a certificate that every desired summary was selected or fully -costed. A raw alternative remains a downstream execution obligation. - -Lifecycle feasibility and costs must affect final deployment comparison. Running -lifecycle analysis after structural selection can evaluate the selected root, -but does not make the earlier selection lifecycle-optimal. An application may -consume ranked candidates and perform this comparison downstream instead. - -A deployment that prices lifecycles itself calls -`enumerate_summary_maintenance_lifecycles`, prices the alternatives, and binds -its choice with `select`. A choice is accepted only if Planner could select it: -an alternative with `MissingCostEvidence` is accepted only when the cost model's -complete-candidate hook covers lifecycle costs. Window frameworks and totals come -from that hook, as in Planner selection. - -A lifecycle choice then fixes each physical placement through timing: a -continuously maintained state and its inputs run at ingestion time, while an -ephemeral one stays at query time. Compile each query's `PhysicalASAPDAG` once and -cut every chosen assignment from that result: - -```rust -use asap_physical_operators::physical_planner::{ - compile, cut_candidate, frontier_from_timing, -}; - -let compiled = compile(&dag, inputs, &roots)?; // each node lowered once -for plan in lifecycle_plans { - let frontier = frontier_from_timing(&plan.execution_timed_dag()?)?; - // Precompute/query DAGs split at `frontier`; no logical lowering. - let candidate = cut_candidate(&compiled, &frontier)?; - // Check feasibility and price `candidate`; bind the selected one as is. -} -``` - -The frontier is the set of ingestion-time nodes read by query-time nodes (or an -ingestion-time root). `frontier_from_timing` rejects a query-time node feeding -an ingestion-time node. `cut_candidate` returns exactly what -`compile_candidate(&dag, inputs, &roots, &frontier)` returns and rejects the -same invalid frontiers. If the DAG has an ingestion-time `Binary`, compile with -the same timing for that node, because it lowers differently. Temporal pane -candidates are a different lowering and still use -`compile_temporal_pane_candidate`. - -Retained states are `SummaryAgg` nodes and `MaintainPopulation` nodes that do -not feed a `SummaryAgg`; a population that does feed one is part of that -state's input. The lifecycle cost hooks (`summary_maintenance_capabilities`, -`summary_maintenance_lifecycle_cost_inputs_for_horizon`) and the complete-candidate -hook therefore also receive `MaintainPopulation` nodes. A model that does not -recognize one should return unknown costs, which keep its alternatives -unselected; a model that prices every node uniformly now also prices -populations, so population candidates can win lifecycle-aware selection. `SummaryMaintenanceLifecyclePlan::execution_timed_dag` times a -population as it times a summary state: retained at ingestion, `Ephemeral` at -query time from the raw source. - ## Optional whole-plan selection and DAG assembly ### What does global selection mean? @@ -727,14 +547,14 @@ constructs the selected semantic DAG while preserving shared nodes. | `cost_sorted()` | How are the alternatives ranked for each subexpression? | Ranked alternatives per target | | `global_selection()` | Which compatible choices should be used together, accounting for sharing and dependencies? | A coordinated selection across targets under the supplied model | -Plain `global_selection()` does not automatically perform lifecycle planning or -establish physical deployment feasibility. Use the corresponding evidence-aware -workflow for those decisions. Downstream still owns physical commitment. +Plain `global_selection()` does not decide materialization or establish +physical deployment feasibility. Stage 2 materialization (#509) will own +materialization; downstream still owns physical commitment. | Method on `CandidateLogicalASAPDAGs` / `GlobalSelection` | Behavior | | --- | --- | -| `CandidateLogicalASAPDAGs::global_selection(&model)` | Compatible structural selection across targets; no recurrence or lifecycle planning implied | -| `CandidateLogicalASAPDAGs::global_selection_with_recurrence(...)` | Compatible selection using supplied recurrence profiles/horizon; no lifecycle commitments implied | +| `CandidateLogicalASAPDAGs::global_selection(&model)` | Compatible structural selection across targets; no recurrence or materialization planning implied | +| `CandidateLogicalASAPDAGs::global_selection_with_recurrence(...)` | Compatible selection using supplied recurrence profiles/horizon; no materialization commitments implied | | `GlobalSelection::assemble_selected_dag(&target)` | `Result>, RealizationError>`; constructs untimed semantic IR, not stored summary data | Use a target associated with the searched space; DAG assembly can return `None` @@ -752,8 +572,8 @@ GlobalSelection::assemble_selected_dag(&self, target: &Rc) ``` For structural inspection only, this complete example selects a semantic root -and exports its inspection DAG. It performs no lifecycle or deployment planning. -Use lifecycle-aware selection above when the comparison needs those decisions. +and exports its inspection DAG. It performs no materialization or deployment +planning. ```rust use asap_frontend_promql::lower_promql_workload; @@ -807,14 +627,13 @@ fn main() -> Result<(), Box> { | --- | --- | | `asap_types::dag_export::export(&query)` | Pre-ASAP inspection dag | | `asap_types::dag_export::export_summary(&summary)` | Post-ASAP inspection dag | -| `asap_types::ir::apply_lifecycle_timings(&root, &assignment, &mut TimingMemo::new())` | Write execution timing into every node from a `LifecycleAssignment` and validate the data-state edges; a lifecycle plan's `root` is already timed | +| `asap_types::ir::apply_materialization_timings(&root, &assignment, &mut TimingMemo::new())` | Write execution timing into every node from a `MaterializationAssignment` (default: all query time) and validate the data-state edges; `PlanOutput::execution_timed_dag()` applies the default to a planned workload | | `asap_types::ir::physical_export::compile_physical_asap_dag(&timed_root)` | Export a timed DAG as a `PhysicalASAPDAG` (`PHYSICAL_ASAP_DAG_WIRE_VERSION`); rejects an untimed node; `compile_physical_asap_workload(&roots)` exports a batch with one root per query | | `PhysicalASAPDAGDocument::new(dag)` and `.validate()` | Versioned semantic envelope and explicit validation; constructing it alone does not validate | -| `asap_aware_mapping::export_summary_maintenance_plan(&plan)` | Graph plus lifecycle deployments, alternatives and available cost/guarantee information | | `explain_replacements` / `explain_replacements_with` | Findings from default/custom-strategy search; not a complete physical feasibility report | Choose the export matching your intended handoff: an inspection DAG is not -interchangeable with a versioned execution contract. Preserve lifecycle and +interchangeable with a versioned execution contract. Preserve cost/guarantee evidence needed downstream instead of exporting only a bare DAG. For public symbol details, build local API documentation with: @@ -827,6 +646,5 @@ cargo doc -p asap-aware-mapping -p asap-types --no-deps - [Frontend PromQL](../../crates/frontend-promql/src/lib.rs), [SQL](../../crates/frontend-sql/src/lib.rs), [MetricsQL](../../crates/frontend-metricsql/src/lib.rs) - [Search, ranking and selection](../../crates/asap-aware-mapping/src/replacement.rs) - [Cost models](../../crates/asap-aware-mapping/src/cost_model.rs) -- [Lifecycle APIs](../../crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs) - [Workload types](../../crates/types/src/workload.rs) - [Planner-runtime contract](../design_docs/architecture/planner-runtime-contract.md) diff --git a/docs/develop_docs/offline-sketch-evidence.md b/docs/develop_docs/offline-sketch-evidence.md index 56c507de1..4a6d0c2ca 100644 --- a/docs/develop_docs/offline-sketch-evidence.md +++ b/docs/develop_docs/offline-sketch-evidence.md @@ -60,8 +60,7 @@ distribution or machine; the provider does not interpolate between datasets. Each measured resource is an optional `Measurement` with `value`, optional `stddev`, `samples`, and optional `method`. CPU fields are process CPU nanoseconds per operation; `build_cpu_ns` measures empty construction. Building an ingested -snapshot additionally requires `sample_count × update_cpu_ns`; the lifecycle -helper returns that sum only when both measurements exist. Memory and disk +snapshot additionally requires `sample_count × update_cpu_ns`. Memory and disk fields are bytes; `scan_bytes` records bytes read by scans, not storage occupancy. Producer methods must state what was measured and how normalization was performed. `retained_bytes` is distinct @@ -87,12 +86,10 @@ scores as CPU or measured savings. Deployment cost models can own the provider and call `lookup` with their own parameter sizing. This preserves the deployment's other cost and capability -hooks. The provider's lifecycle helper returns available build/update CPU costs -for a single independently instantiated state. It deliberately leaves retention, -retirement and read costs unknown. In particular, a point-frequency benchmark -read does not price a total-count read, even when both use CMS. A deployment must -match evaluation semantics and supply the missing lifecycle and raw-query evidence -before selecting and pricing a complete physical plan. Never combine these +hooks. A point-frequency benchmark read does not price a total-count read, even +when both use CMS. A deployment must match evaluation semantics and supply +retention, retirement, read and raw-query evidence before selecting and pricing +a complete physical plan. Never combine these nanosecond costs with CPU operation counts without explicit calibration. `error` contains offline observed statistics and a query descriptor. Its metric diff --git a/docs/develop_docs/physical-compile-coverage.md b/docs/develop_docs/physical-compile-coverage.md index 920626e9f..8db8cc63e 100644 --- a/docs/develop_docs/physical-compile-coverage.md +++ b/docs/develop_docs/physical-compile-coverage.md @@ -5,8 +5,9 @@ Audience: developers moving computation from ASAPQuery-backend into ## Contract -Logical selection decides what to compute. The maintenance lifecycle sets node -timing. `physical_planner::compile` turns a timed `PhysicalASAPDAG` into physical +Logical selection decides what to compute. A `MaterializationAssignment` sets +node timing (all query time until Stage 2 materialization, #509, decides +otherwise). `physical_planner::compile` turns a timed `PhysicalASAPDAG` into physical operator DAGs. The backend owns ingestion, panes, storage, stored-state evaluation, external exact engines, pricing/selection, and execution scheduling. diff --git a/docs/develop_docs/physical-handoff-costs.md b/docs/develop_docs/physical-handoff-costs.md index 29525f2c5..dcd95e747 100644 --- a/docs/develop_docs/physical-handoff-costs.md +++ b/docs/develop_docs/physical-handoff-costs.md @@ -88,8 +88,8 @@ traffic is inferred from logical edges, operator buffers, or scan bytes. Unknown endpoints, mismatched payloads, absent node evidence, duplicate IDs, stale evidence, invalid coefficients, and integer overflow return typed errors; ranking/export report the comparison as unavailable. This extends the physical -plan adapter; lifecycle-specific summary-maintenance costing and caching are -separate follow-up integration points. +plan adapter; summary-maintenance costing for Stage 2 materialization (#509) is +a separate follow-up integration point. Verification: diff --git a/docs/develop_docs/pre-asap-ir.md b/docs/develop_docs/pre-asap-ir.md index cae40d0c3..f73e28a30 100644 --- a/docs/develop_docs/pre-asap-ir.md +++ b/docs/develop_docs/pre-asap-ir.md @@ -33,7 +33,7 @@ pub struct OperatorNode { pub result_kind: OperatorResultKind, // Relation | InstantVector | RangeVector | State | Scalar pub schema: Schema, // output schema, derived at construction pub guarantee: Option, // None until accuracy assessment establishes one - pub timing: Option, // None until a lifecycle assignment is applied + pub timing: Option, // None until a materialization assignment is applied } ``` @@ -47,7 +47,7 @@ pub struct OperatorNode { retain a more specific schema through `OperatorNode::with_schema`. - `guarantee` is `None` until accuracy assessment establishes one; `None` never means exact. - `timing` is `None` in every front-end DAG and every candidate. It is written by - `ir::timing::apply_lifecycle_timings` (see the Post-ASAP IR document); export rejects an + `ir::timing::apply_materialization_timings` (see the Post-ASAP IR document); export rejects an untimed node. `OperatorNode::children()` returns the operator's inputs in field order followed by the diff --git a/docs/develop_docs/storage-operation-costs.md b/docs/develop_docs/storage-operation-costs.md index 1a8dacdbd..999b79103 100644 --- a/docs/develop_docs/storage-operation-costs.md +++ b/docs/develop_docs/storage-operation-costs.md @@ -71,8 +71,7 @@ estimate and storage request estimate remain independently inspectable. Missing entries, expired/future evidence, incompatible node snapshots, zero request sizes, invalid calibration, and overflow return typed analytical errors. When used by plan ranking/export they make that comparison unavailable. -This profile extends the physical-plan adapter; the separate summary-maintenance -lifecycle estimator retains its existing dimensions. Combined physical-plan +This profile extends the physical-plan adapter. Combined physical-plan ranking currently supports storage profiles only with an explicit `NoCache` profile. `CacheProfile::Evidence` together with storage evidence makes the comparison unavailable: aggregate cache hit ratios cannot identify which diff --git a/docs/develop_docs/target-candidate-api-migration.md b/docs/develop_docs/target-candidate-api-migration.md index 19e4a13b7..ea90fe328 100644 --- a/docs/develop_docs/target-candidate-api-migration.md +++ b/docs/develop_docs/target-candidate-api-migration.md @@ -24,8 +24,8 @@ The earlier #445 renames (`TargetSubDAGCandidates`, counterpart) are prerequisites, not additional changes here. The workflow remains one selection call per workload followed by one assembly -call per query root. `SummaryMaintenanceLifecyclePlan` contains the assembled -Post-ASAP DAG root plus maintenance decisions; it is not an executable plan. +call per query root. The summary-maintenance lifecycle API named above was later +removed; Stage 2 materialization (#509) will own maintenance decisions. ## Later: unified operator IR (operator flattening) @@ -41,7 +41,7 @@ or `Operator::ASAP(ASAPOp)`. Old public names are not kept as aliases. | `SummaryExpr::ValueOperation { .. }` over an evaluation | An ordinary `NonASAPOp` (`Project`, `Filter`, `Sort`, `Limit`, `Aggregate`) reading an ASAP node; `FinalizeExactAccumulator`, `MaintainPopulation`, `EvaluatePopulation` are `ASAPOp` variants | | `Replacement::Summary(..)` / `Replacement::Rewrite(..)` | `Replacement::SubDAG(Rc)`; `is_logical_rewrite` tells them apart | | `SummaryFamilyType` | `FieldDataType` (its non-`Plain` variants) | -| Timing stored on post-ASAP nodes | `OperatorNode::timing`, `None` until `ir::timing::apply_lifecycle_timings` writes it from a `LifecycleAssignment` | +| Timing stored on post-ASAP nodes | `OperatorNode::timing`, `None` until `ir::timing::apply_materialization_timings` writes it from a `MaterializationAssignment` (default: all query time) | | `UnresolvedQueryExpr` + `asap_types::pre_asap::resolve_root` | `UnresolvedOp` / `UnresolvedScalar` + `asap_frontend_common::resolve_root` | | `pre_asap::canonicalize`, `pre_asap::cse::share_common_sub_dags` | `ir::canonicalize::canonicalize`, `ir::cse::share_common_sub_dags` | | `asap_types::post_asap::compile_post_asap_dag` (wire version 5, `Fallback`/`Binary`/`Value` payloads) | `asap_types::ir::physical_export::compile_physical_asap_dag` (`PhysicalASAPDAG`, wire version `PHYSICAL_ASAP_DAG_WIRE_VERSION`: one node per operator, `Operator` payloads, `ScalarRef` edges); input must be timed | diff --git a/docs/user_guide_docs/run-a-query.md b/docs/user_guide_docs/run-a-query.md index 2f7325cc3..c6f52aa61 100644 --- a/docs/user_guide_docs/run-a-query.md +++ b/docs/user_guide_docs/run-a-query.md @@ -6,7 +6,7 @@ corpus coverage. These commands do not deploy or execute a physical plan. To develop an application using the Rust library, start with [Library API: definitions, options, and examples](../develop_docs/library-api.md). That guide explains how to choose strategies and models, rank candidates, and -work with lifecycle capabilities. +assemble selected DAGs. ## Choose a command @@ -96,7 +96,7 @@ cargo run -p asap-devtools --bin show_post_asap_ir -- --data-ingestion-interval- available binding from the sketch strategy for each query, numbered in cost-model order. If no candidate is available, it prints the pre-ASAP fallback as candidate 1. It does not show the complete ranked workload candidate set or choose a -deployment lifecycle. Its SQL examples use a fixed demonstration catalog, not +deployment. Its SQL examples use a fixed demonstration catalog, not your database schema. Use the [library workflow](../develop_docs/library-api.md) to retain workload alternatives and provide your own models. From 7d8dcd9545087b1ef7149e7558e50aa5af0a019a Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:35:08 +0000 Subject: [PATCH 4/8] refactor: finish the Operator port after the legacy cleanup Follow #537: the remaining tests read Operator payloads from the physical DAG, and docs name ir::flat / ir::physical_export instead of the removed ir::export. Co-Authored-By: Claude Opus 5.5 --- crates/types/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 8c1eeb921..c48fd9df0 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -2,7 +2,8 @@ //! //! - [`ir`] — the unified operator IR: one operator language before and //! after ASAP optimization ([`ir::OperatorNode`]), plus its passes -//! (canonicalize, CSE, timing) and the wire export ([`ir::physical_export`]). +//! (canonicalize, CSE, timing), the flat DAG form ([`ir::flat`]) and the +//! physical ASAP DAG ([`ir::physical_export`]). //! - [`pre_asap`] — the shared field vocabulary the IR's operators are //! built from (grouping keys, reductions, sources, aggregation intents, //! scalar literal / operator kinds, [`pre_asap::Schema`]). From 892c9920c80001fa99e17e9a42b8e8d92928ee9b Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:15:00 +0000 Subject: [PATCH 5/8] refactor(ir): materialization per #509: NotMaterialized, MatAtIngestion, MatAtQuery options. Replace the two-way timing assignment with them: - New Materialization { NotMaterialized, MatAtIngestion(Medium), MatAtQuery(Medium) } and Medium { Memory, Disk }; only MatAtIngestion runs at ingestion time. - MaterializationAssignment maps a SummaryAgg or MaintainPopulation to its option (set); Default is NotMaterialized everywhere, and with_default gives unassigned sub-DAGs an option where the DAG allows it (validate_maintained, planned_data_state and the maintained precompute compilers use MatAtIngestion). all_query_time, all_ingestion_time and summary_timing are gone. - MaintainPopulation follows its assignment like SummaryAgg instead of always running at ingestion time. - An explicit MatAtIngestion over something that only exists at query time fails with UpstreamNotAtIngestion ("upstream of ingestion-time node not at ingestion time") instead of silently moving to query time. - OperatorNode and PhysicalASAPDAGNode carry their Materialization; PhysicalASAPDAG validation rejects a materialization inconsistent with the node's timing. - Remove PhysicalASAPDAG::with_execution_phases (#509 Assumption 8: the deployment executes the plan as given). Tests place summaries through the assignment instead. Retention and choosing the option per sub-DAG stay with Stage 2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../src/maintained_population.rs | 55 ++-- crates/asap-aware-mapping/src/pass/mod.rs | 2 +- crates/asap-aware-mapping/src/replacement.rs | 4 +- crates/asap-aware-mapping/src/test_support.rs | 12 +- .../src/physical_planner/candidates.rs | 18 +- .../src/physical_planner/logical.rs | 1 + .../src/physical_planner/promql_rows.rs | 12 +- .../tests/physical_dag.rs | 17 +- .../tests/physical_semantics.rs | 1 + .../tests/precompute_population.rs | 15 + .../tests/promql_binary.rs | 2 + .../asap-physical-operators/tests/raw_scan.rs | 1 + .../tests/summary_projection.rs | 6 + .../tests/weighted_topk_binding.rs | 102 +++--- crates/devtools/src/bin/show_post_asap_ir.rs | 2 +- crates/frontend-promql/tests/support.rs | 2 +- .../tests/maintained_population.rs | 2 +- crates/frontend-sql/tests/pearson_corr.rs | 2 +- crates/integration-tests/src/lib.rs | 21 +- .../tests/physical_common/mod.rs | 13 +- .../tests/promql_to_post_asap.rs | 42 ++- crates/types/src/ir/cse.rs | 10 +- crates/types/src/ir/flat.rs | 4 +- crates/types/src/ir/mod.rs | 1 + crates/types/src/ir/node.rs | 16 +- crates/types/src/ir/physical_export.rs | 70 +++-- crates/types/src/ir/schema_support.rs | 1 + crates/types/src/ir/timing.rs | 290 ++++++++++++------ .../src/post_asap/execution_data_state.rs | 41 ++- crates/types/src/post_asap/mod.rs | 2 +- crates/types/tests/physical_export.rs | 92 ++++-- .../architecture/physical-plan-integration.md | 9 +- .../architecture/planner-runtime-contract.md | 2 +- docs/design_docs/concepts/post-asap-ir.md | 72 +++-- .../physical-planning-and-deployment.md | 16 +- .../maintained-populations.md | 23 +- tools/dag-viewer/dag.example.json | 200 ++++++------ 37 files changed, 746 insertions(+), 435 deletions(-) diff --git a/crates/asap-aware-mapping/src/maintained_population.rs b/crates/asap-aware-mapping/src/maintained_population.rs index 43ff7197b..403fe0d41 100644 --- a/crates/asap-aware-mapping/src/maintained_population.rs +++ b/crates/asap-aware-mapping/src/maintained_population.rs @@ -346,7 +346,7 @@ mod tests { root.validate_structure().map_err(|e| e.to_string())?; let timed = apply_materialization_timings( root, - &MaterializationAssignment::all_query_time(), + &MaterializationAssignment::default(), &mut TimingMemo::new(), ) .map_err(|e| format!("{e:?}"))?; @@ -519,31 +519,50 @@ mod tests { assert!(recognize(&per_job_limit_over_global_sort).is_none()); } - // Population timing is a materialization choice: a retained or rebuilt - // population both validate, while its evaluation must stay at query time. + // Population timing is a materialization choice: the population follows + // its assignment (`NotMaterialized` by default), while its evaluation + // always runs at query time. #[test] - fn population_timing_is_not_structural() { + fn population_timing_follows_its_materialization() { + use asap_types::ir::{Materialization, Medium}; + use asap_types::post_asap::ExecutionTiming::{IngestionTime, QueryTime}; let root = lower("topk(5,a)"); let candidate = MaintainedPopulationStrategy::new(std::slice::from_ref(&root)) .candidate(&root) .unwrap(); - use asap_types::post_asap::ExecutionTiming; - let with_timings = |population: ExecutionTiming, evaluation: ExecutionTiming| { - let mut node = (*candidate).clone(); - node.timing = Some(evaluation); - let Operator::ASAP(ASAPOp::EvaluatePopulation { child, .. }) = &mut node.operator - else { + let Some(ASAPOp::EvaluatePopulation { + child: population, .. + }) = candidate.asap() + else { + unreachable!() + }; + let on_disk = Materialization::MatAtIngestion(Medium::Disk); + let mut at_ingestion = MaterializationAssignment::default(); + at_ingestion.set(population, on_disk); + for (assignment, timing, materialization) in [ + ( + MaterializationAssignment::default(), + QueryTime, + Materialization::NotMaterialized, + ), + (at_ingestion, IngestionTime, on_disk), + ] { + let timed = + apply_materialization_timings(&candidate, &assignment, &mut TimingMemo::new()) + .unwrap(); + let dag = export_timed(&timed).unwrap(); + let [population, evaluation] = &dag.nodes[dag.nodes.len() - 2..] else { unreachable!() }; - Rc::make_mut(child).timing = Some(population); - compile_physical_asap_dag(&Rc::new(node)) - }; - use ExecutionTiming::{IngestionTime, QueryTime}; - assert!(with_timings(IngestionTime, QueryTime).is_ok()); - assert!(with_timings(QueryTime, QueryTime).is_ok()); - assert!(with_timings(IngestionTime, IngestionTime).is_err()); + assert_eq!(population.output_state.timing, timing); + assert_eq!(population.materialization, materialization); + assert_eq!(evaluation.output_state.timing, QueryTime); + } + let mut evaluation_at_ingestion = (*candidate).clone(); + evaluation_at_ingestion.timing = Some(IngestionTime); + assert!(compile_physical_asap_dag(&Rc::new(evaluation_at_ingestion)).is_err()); } - // A evaluation cannot reinterpret arbitrary rows as maintained state or exceed its producer's contract. + // An evaluation cannot reinterpret arbitrary rows as maintained state or exceed its producer's contract. #[test] fn malformed_population_dags_fail_closed() { let root = lower("topk(5,a)"); diff --git a/crates/asap-aware-mapping/src/pass/mod.rs b/crates/asap-aware-mapping/src/pass/mod.rs index 4676bd04f..6da588e4f 100644 --- a/crates/asap-aware-mapping/src/pass/mod.rs +++ b/crates/asap-aware-mapping/src/pass/mod.rs @@ -234,7 +234,7 @@ impl PlanOutput { ) -> Result { // One memo, so a node shared by several roots is timed and exported once. let mut memo = TimingMemo::new(); - let assignment = MaterializationAssignment::all_query_time(); + let assignment = MaterializationAssignment::default(); let timed = self .roots() .iter() diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 90640a2be..7300e5c7f 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -1423,7 +1423,9 @@ impl<'a> ASAPStrategies<'a> { let timed = |node: &Rc| { asap_types::ir::timing::apply_materialization_timings( node, - &asap_types::ir::timing::MaterializationAssignment::all_ingestion_time(), + &asap_types::ir::timing::MaterializationAssignment::with_default( + asap_types::ir::Materialization::MatAtIngestion(asap_types::ir::Medium::Memory), + ), &mut asap_types::ir::timing::TimingMemo::new(), ) .ok() diff --git a/crates/asap-aware-mapping/src/test_support.rs b/crates/asap-aware-mapping/src/test_support.rs index 739a943f4..8c5cd8d1d 100644 --- a/crates/asap-aware-mapping/src/test_support.rs +++ b/crates/asap-aware-mapping/src/test_support.rs @@ -54,7 +54,7 @@ use asap_types::ir::operator_properties::{GroupKeys, Reduction, Source}; use asap_types::ir::timing::{ apply_materialization_timings, MaterializationAssignment, TimingMemo, }; -use asap_types::ir::{NonASAPOp, Predicate, ScalarExpr, TimeRangeKind}; +use asap_types::ir::{Materialization, Medium, NonASAPOp, Predicate, ScalarExpr, TimeRangeKind}; use asap_types::pre_asap::agg_intent::AggIntent; use asap_types::pre_asap::schema::{ColumnId, DataType, Field, Schema}; @@ -175,22 +175,22 @@ pub(crate) fn time_range(range: Duration, child: Rc) -> Rc) -> Rc { apply_materialization_timings( root, - &MaterializationAssignment::all_query_time(), + &MaterializationAssignment::default(), &mut TimingMemo::new(), ) .expect("default materialization timings apply") } -/// `root` timed with every summary maintained at ingestion time. +/// `root` timed with every sub-DAG `MatAtIngestion` where the DAG allows it. pub(crate) fn maintained(root: &Rc) -> Rc { apply_materialization_timings( root, - &MaterializationAssignment::all_ingestion_time(), + &MaterializationAssignment::with_default(Materialization::MatAtIngestion(Medium::Memory)), &mut TimingMemo::new(), ) .expect("maintained materialization timings apply") @@ -206,7 +206,7 @@ pub(crate) fn time_and_export( > { let timed = apply_materialization_timings( root, - &MaterializationAssignment::all_query_time(), + &MaterializationAssignment::default(), &mut TimingMemo::new(), )?; asap_types::ir::physical_export::compile_physical_asap_dag(&timed) diff --git a/crates/asap-physical-operators/src/physical_planner/candidates.rs b/crates/asap-physical-operators/src/physical_planner/candidates.rs index f332db2c0..a59083ec1 100644 --- a/crates/asap-physical-operators/src/physical_planner/candidates.rs +++ b/crates/asap-physical-operators/src/physical_planner/candidates.rs @@ -380,7 +380,11 @@ mod tests { .unwrap(); let selected = planner_types::ir::apply_materialization_timings( &selected, - &planner_types::ir::MaterializationAssignment::all_ingestion_time(), + &planner_types::ir::MaterializationAssignment::with_default( + planner_types::ir::Materialization::MatAtIngestion( + planner_types::ir::Medium::Memory, + ), + ), &mut Default::default(), ) .unwrap(); @@ -418,9 +422,21 @@ mod tests { dag: &PhysicalASAPDAG, timing: impl Fn(&PhysicalASAPDAGNode) -> planner_types::post_asap::ExecutionTiming, ) -> PhysicalASAPDAG { + use planner_types::post_asap::{ExecutionTiming, Materialization, Medium}; let mut timed = dag.clone(); for node in &mut timed.nodes { node.output_state.timing = timing(node); + // A stored sub-DAG output runs when its materialization says. + let stored = matches!( + node.payload, + Payload::ASAP(ASAPOp::SummaryAgg { .. } | ASAPOp::MaintainPopulation { .. }) + ); + node.materialization = match node.output_state.timing { + ExecutionTiming::IngestionTime if stored => { + Materialization::MatAtIngestion(Medium::Memory) + } + _ => Materialization::NotMaterialized, + }; } for edge in &mut timed.edges { let producer = timed.nodes.iter().find(|node| node.id == edge.producer); diff --git a/crates/asap-physical-operators/src/physical_planner/logical.rs b/crates/asap-physical-operators/src/physical_planner/logical.rs index e503827dd..fc4f78c91 100644 --- a/crates/asap-physical-operators/src/physical_planner/logical.rs +++ b/crates/asap-physical-operators/src/physical_planner/logical.rs @@ -37,6 +37,7 @@ pub(super) fn restore(dag: &PhysicalASAPDAG) -> Result> = BTreeMap::from([ ( 0, @@ -1196,6 +1204,7 @@ fn grouped_temporal_schema_compiles_and_executes_topk() { id, payload: PhysicalASAPOperatorPayload::NonASAP(operation), output_state: ExecutionDataState::QUERY_ROWS, + materialization: Default::default(), output_schema: (*input).clone(), guarantee: None, }; @@ -1283,6 +1292,7 @@ fn certified_pruning_rejects_missing_authoritative_values_after_recovery() { id: 2, output_schema: (*schema).clone(), output_state: ExecutionDataState::QUERY_ROWS, + materialization: Default::default(), guarantee: None, payload: PhysicalASAPOperatorPayload::NonASAP(planner_types::ir::NonASAPOp::Join { kind: JoinKind::Semi, @@ -1398,6 +1408,7 @@ fn compiled_ingestion_binary_preserves_alignment_and_rejects_missing_updates() { id: 2, output_schema: (*input).clone(), output_state: ExecutionDataState::INGESTION_ROWS, + materialization: Default::default(), guarantee: None, payload: PhysicalASAPOperatorPayload::NonASAP(planner_types::ir::NonASAPOp::BinaryOp { operator: BinaryOperator { diff --git a/crates/asap-physical-operators/tests/physical_semantics.rs b/crates/asap-physical-operators/tests/physical_semantics.rs index 20f02f76e..042487f1c 100644 --- a/crates/asap-physical-operators/tests/physical_semantics.rs +++ b/crates/asap-physical-operators/tests/physical_semantics.rs @@ -387,6 +387,7 @@ fn global_extrema_bind_with_planner_derived_schema() { child: 0, }), output_state: ExecutionDataState::QUERY_ROWS, + materialization: Default::default(), output_schema: (*output).clone(), guarantee: None, }; diff --git a/crates/asap-physical-operators/tests/precompute_population.rs b/crates/asap-physical-operators/tests/precompute_population.rs index bc25adba5..dc69f45b8 100644 --- a/crates/asap-physical-operators/tests/precompute_population.rs +++ b/crates/asap-physical-operators/tests/precompute_population.rs @@ -61,6 +61,9 @@ fn finalized_shared_panes_rebuild_one_global_summary_after_recovery() { children: vec![], }), output_state: ExecutionDataState::INGESTION_SUMMARY, + materialization: planner_types::post_asap::Materialization::MatAtIngestion( + planner_types::post_asap::Medium::Memory, + ), output_schema: state_schema.clone(), guarantee: None, }, @@ -70,6 +73,7 @@ fn finalized_shared_panes_rebuild_one_global_summary_after_recovery() { child: 0, }), output_state: ExecutionDataState::INGESTION_ROWS, + materialization: Default::default(), output_schema: value_schema.clone(), guarantee: None, }, @@ -89,6 +93,7 @@ fn finalized_shared_panes_rebuild_one_global_summary_after_recovery() { }, ), output_state: ExecutionDataState::INGESTION_ROWS, + materialization: Default::default(), output_schema: value_schema.clone(), guarantee: None, }, @@ -106,6 +111,9 @@ fn finalized_shared_panes_rebuild_one_global_summary_after_recovery() { child: 2, }), output_state: ExecutionDataState::INGESTION_SUMMARY, + materialization: planner_types::post_asap::Materialization::MatAtIngestion( + planner_types::post_asap::Medium::Memory, + ), output_schema: state_schema.clone(), guarantee: None, }, @@ -256,6 +264,9 @@ fn state_dag( id: 0, payload: PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryMerge { children: vec![] }), output_state: ExecutionDataState::INGESTION_SUMMARY, + materialization: planner_types::post_asap::Materialization::MatAtIngestion( + planner_types::post_asap::Medium::Memory, + ), output_schema: logical_schema(family.clone()), guarantee: None, }]; @@ -273,6 +284,7 @@ fn state_dag( child: read_id - 1, }), output_state: ExecutionDataState::INGESTION_ROWS, + materialization: Default::default(), output_schema: logical_schema(FieldDataType::Plain(DataType::Float64)), guarantee: None, }); @@ -288,6 +300,9 @@ fn state_dag( child: read_id, }), output_state: ExecutionDataState::INGESTION_SUMMARY, + materialization: planner_types::post_asap::Materialization::MatAtIngestion( + planner_types::post_asap::Medium::Memory, + ), output_schema: logical_schema(target), guarantee: None, }); diff --git a/crates/asap-physical-operators/tests/promql_binary.rs b/crates/asap-physical-operators/tests/promql_binary.rs index 882ab35b6..9d83df7e7 100644 --- a/crates/asap-physical-operators/tests/promql_binary.rs +++ b/crates/asap-physical-operators/tests/promql_binary.rs @@ -77,6 +77,7 @@ fn program_for_bool(operator: BinaryOperator, return_bool: bool) -> CompiledPhys rhs: 1, }), output_state: ExecutionDataState::QUERY_ROWS, + materialization: Default::default(), output_schema: (*schema).clone(), guarantee: None, }; @@ -398,6 +399,7 @@ fn stored_series_evaluations_support_filters_and_sets() { } else { ExecutionDataState::QUERY_ROWS }, + materialization: Default::default(), output_schema: if id < 2 { (*state_schema).clone() } else { diff --git a/crates/asap-physical-operators/tests/raw_scan.rs b/crates/asap-physical-operators/tests/raw_scan.rs index 27921320c..e8b77f5c1 100644 --- a/crates/asap-physical-operators/tests/raw_scan.rs +++ b/crates/asap-physical-operators/tests/raw_scan.rs @@ -73,6 +73,7 @@ fn plan( id, payload, output_state: state, + materialization: Default::default(), output_schema: (**schema).clone(), guarantee: None, }; diff --git a/crates/asap-physical-operators/tests/summary_projection.rs b/crates/asap-physical-operators/tests/summary_projection.rs index 304bcf8d6..32f5b1d46 100644 --- a/crates/asap-physical-operators/tests/summary_projection.rs +++ b/crates/asap-physical-operators/tests/summary_projection.rs @@ -65,6 +65,9 @@ fn post_asap_summary_projection_survives_recovery() { }), output_schema: (*schema).clone(), output_state: ExecutionDataState::INGESTION_SUMMARY, + materialization: planner_types::post_asap::Materialization::MatAtIngestion( + planner_types::post_asap::Medium::Memory, + ), guarantee: None, }, PhysicalASAPDAGNode { @@ -82,6 +85,9 @@ fn post_asap_summary_projection_survives_recovery() { }), output_schema: output.clone(), output_state: ExecutionDataState::INGESTION_SUMMARY, + materialization: planner_types::post_asap::Materialization::MatAtIngestion( + planner_types::post_asap::Medium::Memory, + ), guarantee: None, }, ], diff --git a/crates/asap-physical-operators/tests/weighted_topk_binding.rs b/crates/asap-physical-operators/tests/weighted_topk_binding.rs index 1bb55767e..62024b2c5 100644 --- a/crates/asap-physical-operators/tests/weighted_topk_binding.rs +++ b/crates/asap-physical-operators/tests/weighted_topk_binding.rs @@ -87,13 +87,15 @@ fn assert_weighted_binding(evidence: &dyn AccuracyEvidenceProvider, algorithm: S }) .unwrap(); let dag = compile_physical_asap_dag(&plan).unwrap(); - let build=dag.nodes.iter().find(|node|matches!(&node.payload,PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg {family:FieldDataType::Sketch(kind,_),..})if kind.algorithm()==&algorithm)).unwrap(); - let rate_id = dag - .edges - .iter() - .find(|edge| edge.consumer == build.id) - .unwrap() - .producer; + let rate_id_of = |dag: &PhysicalASAPDAG| { + let build=dag.nodes.iter().find(|node|matches!(&node.payload,PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg {family:FieldDataType::Sketch(kind,_),..})if kind.algorithm()==&algorithm)).unwrap(); + dag.edges + .iter() + .find(|edge| edge.consumer == build.id) + .unwrap() + .producer + }; + let rate_id = rate_id_of(&dag); let rates = Arc::new( dag.nodes .iter() @@ -129,9 +131,11 @@ fn assert_weighted_binding(evidence: &dyn AccuracyEvidenceProvider, algorithm: S }) .collect(); let batch = Batch::try_new(rates.clone(), rows).unwrap(); - for (phase, scope) in [ + // The summaries `MatAtIngestion` run as data arrives; `NotMaterialized` + // (the default) runs everything when the query does. + for (placed, scope) in [ ( - ExecutionTiming::IngestionTime, + continuously_maintained_dag(&plan), Scope::Ingestion { window_start_ms: 0, window_end_ms: 60_000, @@ -139,16 +143,14 @@ fn assert_weighted_binding(evidence: &dyn AccuracyEvidenceProvider, algorithm: S }, ), ( - ExecutionTiming::QueryTime, + dag.clone(), Scope::Query { evaluation_time_ms: 60_000, revision: 1, }, ), ] { - let placed = dag - .with_execution_phases(&dag.nodes.iter().map(|node| (node.id, phase)).collect()) - .unwrap(); + let rate_id = rate_id_of(&placed); let source = Box::new(Operator::source(rates.clone(), vec![batch.clone()]).unwrap()) as Source<'static>; let compiled = compile( @@ -813,55 +815,37 @@ fn spatial_topk_requires_the_limit_and_sort_partitions_to_agree() { assert!(candidates(&with_limit_partition(&global, by_job.clone())).is_empty()); } -/// Every summary state of `candidate` maintained at ingestion time, the -/// materialization a deployment would assign for a continuously served query: -/// each `SummaryAgg` and every input it consumes run at ingestion time, the -/// rest at query time. The phases are assigned on the exported DAG because -/// the candidate pins its finalize boundary to query time. +/// `candidate` with every `SummaryAgg` `MatAtIngestion`, the materialization +/// a continuously served query would get: each summary and every input it +/// consumes run at ingestion time, the rest at query time. The candidate pins +/// its finalize boundary to query time, which would put the summaries above +/// it upstream of a query-time node, so the pin is lifted first. fn continuously_maintained_dag(candidate: &Rc) -> PhysicalASAPDAG { - use planner_types::ir::{apply_materialization_timings, MaterializationAssignment, TimingMemo}; - let timed = apply_materialization_timings( - candidate, - &MaterializationAssignment::all_query_time(), - &mut TimingMemo::new(), - ) - .unwrap(); - let dag = planner_types::ir::physical_export::compile_physical_asap_dag(&timed).unwrap(); - let mut pending: Vec<_> = dag - .nodes - .iter() - .filter(|node| { - matches!( - node.payload, - PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { .. }) - ) - }) - .map(|node| node.id) - .collect(); - let mut ingestion = std::collections::HashSet::new(); - while let Some(id) = pending.pop() { - if ingestion.insert(id) { - pending.extend( - dag.edges - .iter() - .filter(|edge| edge.consumer == id) - .map(|edge| edge.producer), - ); + use planner_types::ir::{ + apply_materialization_timings, Materialization, MaterializationAssignment, Medium, + Operator as IrOperator, OperatorNode, TimingMemo, + }; + fn unpinned(node: &Rc) -> Rc { + let mut copy = (**node).clone(); + copy.operator = node.operator.map_children(unpinned); + if matches!( + copy.operator, + IrOperator::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) + ) { + copy.timing = None; } + Rc::new(copy) } - let phases = dag - .nodes - .iter() - .map(|node| { - let timing = if ingestion.contains(&node.id) { - ExecutionTiming::IngestionTime - } else { - ExecutionTiming::QueryTime - }; - (node.id, timing) - }) - .collect(); - dag.with_execution_phases(&phases).unwrap() + let candidate = unpinned(candidate); + let mut assignment = MaterializationAssignment::default(); + for node in OperatorNode::reachable(&candidate) { + if matches!(node.operator, IrOperator::ASAP(ASAPOp::SummaryAgg { .. })) { + assignment.set(&node, Materialization::MatAtIngestion(Medium::Memory)); + } + } + let timed = + apply_materialization_timings(&candidate, &assignment, &mut TimingMemo::new()).unwrap(); + planner_types::ir::physical_export::compile_physical_asap_dag(&timed).unwrap() } // A maintained heap over finalized per-series Rate is the fixed-window diff --git a/crates/devtools/src/bin/show_post_asap_ir.rs b/crates/devtools/src/bin/show_post_asap_ir.rs index 51123cfdb..bc5d5092a 100644 --- a/crates/devtools/src/bin/show_post_asap_ir.rs +++ b/crates/devtools/src/bin/show_post_asap_ir.rs @@ -178,7 +178,7 @@ mod tests { ); let timed = asap_types::ir::timing::apply_materialization_timings( &candidates[0], - &asap_types::ir::timing::MaterializationAssignment::all_query_time(), + &asap_types::ir::timing::MaterializationAssignment::default(), &mut asap_types::ir::timing::TimingMemo::new(), ) .expect("the demo candidate has a legal default timing"); diff --git a/crates/frontend-promql/tests/support.rs b/crates/frontend-promql/tests/support.rs index 6b5418f9f..afa6f28f7 100644 --- a/crates/frontend-promql/tests/support.rs +++ b/crates/frontend-promql/tests/support.rs @@ -77,7 +77,7 @@ pub fn post_asap_dag(root: &Rc) -> asap_types::ir::physical_export use asap_types::ir::{apply_materialization_timings, MaterializationAssignment, TimingMemo}; let timed = apply_materialization_timings( root, - &MaterializationAssignment::all_query_time(), + &MaterializationAssignment::default(), &mut TimingMemo::new(), ) .expect("default materialization timings"); diff --git a/crates/frontend-sql/tests/maintained_population.rs b/crates/frontend-sql/tests/maintained_population.rs index 56d4520a5..a1563223f 100644 --- a/crates/frontend-sql/tests/maintained_population.rs +++ b/crates/frontend-sql/tests/maintained_population.rs @@ -43,7 +43,7 @@ fn population(mut node: &OperatorNode) -> (&Rc, &MaintainedPopulat fn compile(plan: &Rc) -> Result<(), String> { let timed = apply_materialization_timings( plan, - &MaterializationAssignment::all_query_time(), + &MaterializationAssignment::default(), &mut TimingMemo::new(), ) .map_err(|e| e.to_string())?; diff --git a/crates/frontend-sql/tests/pearson_corr.rs b/crates/frontend-sql/tests/pearson_corr.rs index 3604dce04..309236132 100644 --- a/crates/frontend-sql/tests/pearson_corr.rs +++ b/crates/frontend-sql/tests/pearson_corr.rs @@ -154,7 +154,7 @@ async fn corr_survives_exact_plan_compilation() { assert_eq!(aggregate(&plan).0, aggregate(&query).0); let timed = apply_materialization_timings( &plan, - &MaterializationAssignment::all_query_time(), + &MaterializationAssignment::default(), &mut TimingMemo::new(), ) .unwrap(); diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs index 6102ca0ff..317bc4712 100644 --- a/crates/integration-tests/src/lib.rs +++ b/crates/integration-tests/src/lib.rs @@ -101,19 +101,25 @@ pub mod fixtures { pub mod post_asap { use asap_types::ir::physical_export::{compile_physical_asap_dag, PhysicalASAPDAG}; use asap_types::ir::{ - apply_materialization_timings, MaterializationAssignment, OperatorNode, TimingMemo, + apply_materialization_timings, Materialization, MaterializationAssignment, Medium, + OperatorNode, TimingMemo, }; use std::rc::Rc; - /// Time `root` under the default assignment (every summary computed at - /// query time). Returns the timed copy; read `node.timing` on it. + /// Time `root` under the default assignment (every sub-DAG + /// `NotMaterialized`, so every summary computed at query time). Returns the timed copy; read `node.timing` on it. pub fn timed(root: &Rc) -> Rc { - timed_with(root, &MaterializationAssignment::all_query_time()) + timed_with(root, &MaterializationAssignment::default()) } - /// Time `root` with every summary maintained at ingestion time. + /// Time `root` with every sub-DAG `MatAtIngestion` where the DAG allows it. pub fn maintained(root: &Rc) -> Rc { - timed_with(root, &MaterializationAssignment::all_ingestion_time()) + timed_with( + root, + &MaterializationAssignment::with_default(Materialization::MatAtIngestion( + Medium::Memory, + )), + ) } fn timed_with( @@ -129,7 +135,8 @@ pub mod post_asap { compile_physical_asap_dag(&timed(root)).expect("post-ASAP DAG export failed") } - /// Time `root` with every summary maintained, then export the physical DAG. + /// Time `root` with every sub-DAG `MatAtIngestion` where the DAG allows + /// it, then export the physical DAG. pub fn maintained_post_asap_dag(root: &Rc) -> PhysicalASAPDAG { compile_physical_asap_dag(&maintained(root)).expect("post-ASAP DAG export failed") } diff --git a/crates/integration-tests/tests/physical_common/mod.rs b/crates/integration-tests/tests/physical_common/mod.rs index 11216b3e8..b971bb647 100644 --- a/crates/integration-tests/tests/physical_common/mod.rs +++ b/crates/integration-tests/tests/physical_common/mod.rs @@ -46,21 +46,20 @@ pub fn execute( pub fn compile_physical_asap_dag( root: &std::rc::Rc, ) -> Result> { - compile_with( - root, - &asap_types::ir::MaterializationAssignment::all_query_time(), - ) + compile_with(root, &asap_types::ir::MaterializationAssignment::default()) } -/// As [`compile_physical_asap_dag`], with every summary maintained at -/// ingestion time, the placement precompute compilation requires. +/// As [`compile_physical_asap_dag`], with every sub-DAG `MatAtIngestion` +/// where the DAG allows it, the placement precompute compilation requires. #[allow(dead_code)] // Not every test binary sharing this module compiles precompute. pub fn compile_maintained_physical_asap_dag( root: &std::rc::Rc, ) -> Result> { compile_with( root, - &asap_types::ir::MaterializationAssignment::all_ingestion_time(), + &asap_types::ir::MaterializationAssignment::with_default( + asap_types::ir::Materialization::MatAtIngestion(asap_types::ir::Medium::Memory), + ), ) } diff --git a/crates/integration-tests/tests/promql_to_post_asap.rs b/crates/integration-tests/tests/promql_to_post_asap.rs index 8d793a1b5..d92f32992 100644 --- a/crates/integration-tests/tests/promql_to_post_asap.rs +++ b/crates/integration-tests/tests/promql_to_post_asap.rs @@ -26,9 +26,9 @@ use asap_types::ir::operator_properties::Reduction; use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; use asap_types::ir::{ASAPOp, NonASAPOp, Operator, OperatorNode, ScalarExpr}; use asap_types::post_asap::{ - CompositionOperator, EntityIdentity, ExactKind, ExactParams, FieldDataType, GroupingStrategy, - Schema, SketchAlgorithm, SketchKind, SketchParams, SketchStatistic, SummaryInputExpr, - SummaryUpdate, + CompositionOperator, EntityIdentity, ExactKind, ExactParams, ExecutionDataStateError, + FieldDataType, GroupingStrategy, Schema, SketchAlgorithm, SketchKind, SketchParams, + SketchStatistic, SummaryInputExpr, SummaryUpdate, }; use asap_types::pre_asap::expr_ir::ColumnRef; use asap_types::pre_asap::schema::DataType; @@ -477,18 +477,32 @@ fn rate_and_increase_topk_use_summary_scores_and_grouped_limits() { rates.asap(), Some(ASAPOp::FinalizeExactAccumulator { .. }) )); + // Not materialized by default: everything runs at query time. let dag = post_asap_dag(&plan); - for phase in [ - asap_types::post_asap::ExecutionTiming::IngestionTime, - asap_types::post_asap::ExecutionTiming::QueryTime, - ] { - let phases = dag.nodes.iter().map(|node| (node.id, phase)).collect(); - let placed = dag.with_execution_phases(&phases).unwrap(); - assert!(placed - .nodes - .iter() - .all(|node| node.output_state.timing == phase)); - } + assert!(dag.nodes.iter().all(|node| { + node.output_state.timing == asap_types::post_asap::ExecutionTiming::QueryTime + && node.materialization == asap_types::post_asap::Materialization::NotMaterialized + })); + // The candidate pins the finalize boundary below the weighted summary + // to query time, so that summary cannot be materialized at ingestion. + let mut assignment = asap_types::ir::MaterializationAssignment::default(); + assignment.set( + summary_input, + asap_types::post_asap::Materialization::MatAtIngestion( + asap_types::post_asap::Medium::Memory, + ), + ); + assert_eq!( + asap_types::ir::apply_materialization_timings( + &plan, + &assignment, + &mut asap_types::ir::TimingMemo::new() + ) + .err(), + Some(ExecutionDataStateError::UpstreamNotAtIngestion { + operator: "SummaryAgg" + }) + ); let guarantee = plan.guarantee.as_ref().unwrap(); assert!(guarantee.failure_probability.evaluate().unwrap() <= 0.01); assert!(guarantee.provenance.iter().any(|source| matches!(source, diff --git a/crates/types/src/ir/cse.rs b/crates/types/src/ir/cse.rs index c229772f7..af3876254 100644 --- a/crates/types/src/ir/cse.rs +++ b/crates/types/src/ir/cse.rs @@ -80,7 +80,7 @@ fn own_fields(node: &OperatorNode) -> Operator<()> { /// hash is SipHash over two parts: /// /// 1. the canonical JSON of [`own_fields`] plus `result_kind`, `schema`, -/// `guarantee` and `timing` — every field `PartialEq` compares except the +/// `guarantee`, `timing` and `materialization` — every field `PartialEq` compares except the /// children. A scalar expression is serialized as data with each operator /// node it reads replaced by `()`, so a reference to an /// interned sub-DAG contributes nothing of its own here; @@ -112,6 +112,7 @@ pub fn structural_hash(node: &OperatorNode, cache: &mut HashCache) -> u64 { &node.schema, &node.guarantee, node.timing, + node.materialization, ); serde_json::to_string(&own) .unwrap_or_default() @@ -167,6 +168,7 @@ fn same_node(left: &OperatorNode, right: &OperatorNode, memo: &mut EqMemo) -> bo && left.result_kind == right.result_kind && left.schema == right.schema && left.timing == right.timing + && left.materialization == right.materialization && same_value(&left.guarantee, &right.guarantee) && same_value(&own_fields(left), &own_fields(right)) } @@ -234,7 +236,8 @@ pub fn dag_node_count(root: &Rc) -> usize { type Visited = HashMap<*const OperatorNode, (Rc, Rc)>; /// Intern `node`'s children (recursively), then `node` itself. The rebuilt -/// node keeps `node`'s retained schema, result kind, guarantee and timing: +/// node keeps `node`'s retained schema, result kind, guarantee, timing and +/// materialization: /// every child is replaced by an equal node, so each derived property stays /// valid, and the result is `PartialEq`-equal to the input. fn intern_bottom_up( @@ -250,7 +253,8 @@ fn intern_bottom_up( .map_children(|child| intern_bottom_up(table, visited, child)); let rebuilt = OperatorNode::with_schema(operator, node.schema.clone()) .with_guarantee(node.guarantee.clone()) - .with_timing(node.timing); + .with_timing(node.timing) + .with_materialization(node.materialization); let interned = table.intern(rebuilt); visited.insert(Rc::as_ptr(node), (Rc::clone(node), Rc::clone(&interned))); interned diff --git a/crates/types/src/ir/flat.rs b/crates/types/src/ir/flat.rs index 434a3406f..fc504d95f 100644 --- a/crates/types/src/ir/flat.rs +++ b/crates/types/src/ir/flat.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use super::node::{Operator, OperatorNode, OperatorResultKind}; use super::query::QueryRoot; -use crate::post_asap::execution_data_state::ExecutionTiming; +use crate::post_asap::execution_data_state::{ExecutionTiming, Materialization}; use crate::post_asap::guarantee::ResultGuarantee; use crate::pre_asap::schema::Schema; @@ -30,6 +30,7 @@ pub struct FlatNode { pub schema: Schema, pub guarantee: Option, pub timing: Option, + pub materialization: Materialization, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -77,6 +78,7 @@ fn visit( schema: node.schema.clone(), guarantee: node.guarantee.clone(), timing: node.timing, + materialization: node.materialization, }); originals.push(Rc::clone(node)); ids.insert(Rc::as_ptr(node), id); diff --git a/crates/types/src/ir/mod.rs b/crates/types/src/ir/mod.rs index 6996fafdd..0b6756693 100644 --- a/crates/types/src/ir/mod.rs +++ b/crates/types/src/ir/mod.rs @@ -21,6 +21,7 @@ pub mod flat; pub mod physical_export; /// Execution timing for physical plans: a materialization assignment expanded onto every node. pub mod timing; +pub use crate::post_asap::execution_data_state::{Materialization, Medium}; pub use timing::{ apply_materialization_timings, data_state, planned_data_state, split_shared_by_phase, validate_maintained, MaterializationAssignment, TimingMemo, diff --git a/crates/types/src/ir/node.rs b/crates/types/src/ir/node.rs index d0a581c1c..1599da398 100644 --- a/crates/types/src/ir/node.rs +++ b/crates/types/src/ir/node.rs @@ -12,7 +12,7 @@ use super::asap::ASAPOp; use super::non_asap::NonASAPOp; use super::summary_coverage::SummaryCoverage; use crate::ir::SchemaDerivationError; -use crate::post_asap::execution_data_state::ExecutionTiming; +use crate::post_asap::execution_data_state::{ExecutionTiming, Materialization}; use crate::post_asap::guarantee::ResultGuarantee; use crate::pre_asap::schema::Schema; @@ -92,7 +92,9 @@ impl Operator { /// at construction and retained. `guarantee` is `None` until accuracy /// assessment establishes one (`None` never means exact). `timing` is `None` /// until a materialization assignment is applied; export rejects an executable -/// node without one. +/// node without one. `materialization` is the option that assignment chose +/// for this node (`NotMaterialized` until then, and for every node whose +/// output is not stored). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct OperatorNode { pub operator: Operator, @@ -100,6 +102,7 @@ pub struct OperatorNode { pub schema: Schema, pub guarantee: Option, pub timing: Option, + pub materialization: Materialization, } impl OperatorNode { @@ -124,6 +127,7 @@ impl OperatorNode { schema, guarantee: None, timing: None, + materialization: Materialization::NotMaterialized, } } @@ -144,6 +148,11 @@ impl OperatorNode { self } + pub fn with_materialization(mut self, materialization: Materialization) -> Self { + self.materialization = materialization; + self + } + /// What this summary state covers; `None` for a node that is not a /// `SummaryAgg` or a valid `SummaryMerge`. Derived on each call, since /// the public fields can be edited in place. @@ -201,7 +210,8 @@ impl OperatorNode { /// the old derived schema are retained, for either operator category. A /// change in output arity with such overrides needs an explicit new /// naming assignment. - /// `guarantee` and `timing` depend on the inputs and are cleared. + /// `guarantee`, `timing` and `materialization` depend on the inputs and + /// are cleared. pub fn with_new_children( &self, f: impl FnMut(&Rc) -> Rc, diff --git a/crates/types/src/ir/physical_export.rs b/crates/types/src/ir/physical_export.rs index e4b237b17..a5cc9054b 100644 --- a/crates/types/src/ir/physical_export.rs +++ b/crates/types/src/ir/physical_export.rs @@ -1,11 +1,13 @@ //! Physical ASAP DAG (planner-layering stage 2 output). //! -//! The flattened operators of [`super::flat`], plus the execution timing -//! (data state) of every node and edge. The input must already be timed -//! ([`super::timing::apply_materialization_timings`]); export reads each node's -//! timing and does not re-run data-state validation. +//! The flattened operators of [`super::flat`], plus the materialization and +//! execution timing (data state) of every node and edge. The input must +//! already be timed ([`super::timing::apply_materialization_timings`]); export +//! reads each node's timing and materialization and does not re-run +//! data-state validation. The deployment executes the plan as given +//! (#509 Assumption 8): it does not change materialization or timing. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use serde::{Deserialize, Serialize}; @@ -18,9 +20,9 @@ use super::non_asap::NonASAPOp; use super::operator_properties::Reduction; use super::query::QueryRoot; use super::scalar::ScalarExpr; -use super::timing::data_state; +use super::timing::{data_state, is_materialization_point}; use crate::post_asap::execution_data_state::{ - ExecutionDataState, ExecutionDataStateError, ExecutionTiming, + ExecutionDataState, ExecutionDataStateError, ExecutionTiming, Materialization, }; use crate::post_asap::guarantee::ResultGuarantee; use crate::pre_asap::schema::{FieldDataType, Schema}; @@ -66,6 +68,10 @@ pub struct PhysicalASAPDAGNode { pub payload: PhysicalASAPOperatorPayload, /// Phase is a placement choice for every operator, independent of payload kind. pub output_state: ExecutionDataState, + /// Whether and where the deployment stores this node's output. Not + /// `NotMaterialized` only on a `SummaryAgg` or `MaintainPopulation` + /// whose sub-DAG the materialization assignment chose to store. + pub materialization: Materialization, pub output_schema: Schema, pub guarantee: Option, } @@ -111,8 +117,14 @@ pub struct PhysicalASAPDAGDocument { #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum PhysicalASAPDAGValidationError { - #[error("phase assignment must name every DAG node exactly once")] - IncompletePhaseAssignment, + #[error( + "node {node:?} runs at {timing:?}, which its materialization {materialization:?} does not" + )] + MaterializationTimingMismatch { + node: PhysicalASAPNodeId, + timing: ExecutionTiming, + materialization: Materialization, + }, #[error("ingestion node {consumer:?} depends on query node {producer:?}")] QueryDependencyInIngestion { producer: PhysicalASAPNodeId, @@ -173,37 +185,26 @@ impl PhysicalASAPDAGDocument { } impl PhysicalASAPDAG { - /// Assign execution phases without changing operator semantics. Phase choices - /// do not prove deployment support: callers must bind concrete implementations - /// and storage boundaries before installing this plan. - pub fn with_execution_phases( - &self, - phases: &BTreeMap, - ) -> Result { - self.validate()?; - if phases.len() != self.nodes.len() - || self.nodes.iter().any(|node| !phases.contains_key(&node.id)) - { - return Err(PhysicalASAPDAGValidationError::IncompletePhaseAssignment); - } - let mut dag = self.clone(); - for node in &mut dag.nodes { - node.output_state.timing = phases[&node.id]; - } - let states: HashMap<_, _> = dag.nodes.iter().map(|n| (n.id, n.output_state)).collect(); - for edge in &mut dag.edges { - edge.data_state = states[&edge.producer]; - } - dag.validate()?; - Ok(dag) - } - pub fn validate(&self) -> Result<(), PhysicalASAPDAGValidationError> { let mut nodes = HashMap::new(); for node in &self.nodes { if nodes.insert(node.id, node).is_some() { return Err(PhysicalASAPDAGValidationError::DuplicateNodeId(node.id)); } + // A sub-DAG output runs when its materialization says; any other + // node is not stored and runs when its consumer runs. + if (is_materialization_point(&node.payload) + || node.materialization != Materialization::NotMaterialized) + && node.materialization.timing() != node.output_state.timing + { + return Err( + PhysicalASAPDAGValidationError::MaterializationTimingMismatch { + node: node.id, + timing: node.output_state.timing, + materialization: node.materialization, + }, + ); + } if let Operator::ASAP(ASAPOp::SummaryAgg { family, grouping, .. }) = &node.payload @@ -471,6 +472,7 @@ pub fn compile_physical_asap_workload_with_node_ids( id, payload: flat_node.operator, output_state, + materialization: flat_node.materialization, output_schema: flat_node.schema, guarantee: flat_node.guarantee, }); diff --git a/crates/types/src/ir/schema_support.rs b/crates/types/src/ir/schema_support.rs index 95c42070d..6b86cfae1 100644 --- a/crates/types/src/ir/schema_support.rs +++ b/crates/types/src/ir/schema_support.rs @@ -86,6 +86,7 @@ pub fn with_promql_series_identity( let mut rebuilt = OperatorNode::new(operator).map_err(|e| e.to_string())?; rebuilt.guarantee = node.guarantee.clone(); rebuilt.timing = node.timing; + rebuilt.materialization = node.materialization; let rebuilt = Rc::new(rebuilt); memo.insert(Rc::as_ptr(node), Rc::clone(&rebuilt)); Ok(rebuilt) diff --git a/crates/types/src/ir/timing.rs b/crates/types/src/ir/timing.rs index d7aef6768..7f947a800 100644 --- a/crates/types/src/ir/timing.rs +++ b/crates/types/src/ir/timing.rs @@ -2,22 +2,26 @@ //! assignment, then validated against each operator's kind and its consuming //! edges. //! -//! The logical DAG carries no timing. Materialization decides per summary -//! state whether it is maintained at ingestion time or computed at query -//! time; [`MaterializationAssignment`] records that choice per `SummaryAgg` -//! and [`apply_materialization_timings`] expands it into a timing on every node: +//! The logical DAG carries no timing. Materialization (#509 +//! §Materialization) gives each sub-DAG one of three options: +//! `NotMaterialized`, `MatAtIngestion(medium)` or `MatAtQuery(medium)`. +//! [`MaterializationAssignment`] records that option per sub-DAG, named by +//! the node whose output would be stored (a `SummaryAgg` or a +//! `MaintainPopulation`), and [`apply_materialization_timings`] expands it +//! into a timing on every node: //! -//! - a node of fixed kind takes its kind's timing (`SummaryEstimate` and -//! `EvaluatePopulation` run at query time, `MaintainPopulation` at ingestion -//! time); -//! - a `SummaryAgg` takes the assignment's timing (default: query time, until -//! Stage 2 materialization (#509) chooses otherwise), unless something below -//! it can only exist at query time; +//! - `SummaryEstimate` and `EvaluatePopulation` run at query time; +//! - a `SummaryAgg` or `MaintainPopulation` runs at ingestion time if it is +//! `MatAtIngestion`, and at query time otherwise (the default is +//! `NotMaterialized`). Every node upstream of an ingestion-time node also +//! runs at ingestion time, so an explicit `MatAtIngestion` over something +//! that can only exist at query time is an error +//! ([`ExecutionDataStateError::UpstreamNotAtIngestion`]); //! - a PromQL `BinaryOp` over per-series inputs that are not the same series //! rows runs at query time, since maintenance cannot align them; -//! - every other node runs when its consumer runs: everything that feeds a -//! maintained state runs at ingestion time, everything above a evaluation at -//! query time. +//! - every other node runs when its consumer runs: everything that feeds an +//! ingestion-time state runs at ingestion time, everything above an +//! evaluation at query time. //! //! A node reached from two consumers that need different timings cannot be //! executed once for both; [`split_shared_by_phase`] copies such a sub-DAG @@ -31,8 +35,8 @@ //! | `SummaryAgg.child` | Rows, or exact-accumulator state, never a query-time value when the state is maintained | //! | `SummaryEstimate.summary_input` | Summary state at either phase | //! | `FinalizeExactAccumulator.child` | Exact-accumulator state | -//! | `EvaluatePopulation.child` | A `MaintainPopulation` at ingestion time | -//! | `MaintainPopulation.child` | Ingestion-time rows matching the population's input | +//! | `EvaluatePopulation.child` | A timed `MaintainPopulation` | +//! | `MaintainPopulation.child` | Rows at its own timing matching the population's input | //! | any `NonASAP` consumer | Rows (or exact-accumulator state for a projection-like operator) at the consumer's own timing; ingestion work never reads a query-time value | use std::collections::HashMap; @@ -43,47 +47,75 @@ use super::node::{Operator, OperatorNode}; use super::non_asap::NonASAPOp; use crate::ir::operator_properties::BinaryOpKind; use crate::post_asap::execution_data_state::{ - DataPrimitive, ExecutionDataState, ExecutionDataStateError, ExecutionTiming, + DataPrimitive, ExecutionDataState, ExecutionDataStateError, ExecutionTiming, Materialization, + Medium, }; use crate::pre_asap::schema::{DataType, FieldDataType, Schema}; -/// The per-state materialization choice: for each `SummaryAgg` node (by -/// identity), whether its state is maintained at ingestion time or computed -/// at query time. A state absent from the map takes the assignment's default. -/// `Default` is [`Self::all_query_time`]: nothing is materialized until Stage 2 -/// materialization (#509) decides otherwise. +/// The materialization option of each sub-DAG (#509 §Materialization). A +/// sub-DAG is named by the node whose output would be stored, a `SummaryAgg` +/// or a `MaintainPopulation` (by identity); every other node runs when its +/// consumer runs. `Default` assigns `NotMaterialized` to every sub-DAG. #[derive(Debug, Clone, Default)] pub struct MaterializationAssignment { - summary_timings: HashMap<*const OperatorNode, ExecutionTiming>, - default_timing: ExecutionTiming, + assigned: HashMap<*const OperatorNode, Materialization>, + unassigned: Materialization, } impl MaterializationAssignment { - /// Every summary state computed at query time. - pub fn all_query_time() -> Self { - Self::default() - } - - /// Every summary state maintained at ingestion time. - pub fn all_ingestion_time() -> Self { + /// Every sub-DAG not [`set`](Self::set) explicitly takes `materialization` + /// where the DAG allows it: a `MatAtIngestion` default over a sub-DAG + /// that can only run at query time leaves that sub-DAG `NotMaterialized`. + pub fn with_default(materialization: Materialization) -> Self { Self { - summary_timings: HashMap::new(), - default_timing: ExecutionTiming::IngestionTime, + assigned: HashMap::new(), + unassigned: materialization, } } - pub fn set(&mut self, summary: &Rc, timing: ExecutionTiming) { - self.summary_timings.insert(Rc::as_ptr(summary), timing); + /// Assign `materialization` to the sub-DAG whose output `node` produces. + /// Panics unless `node` is a `SummaryAgg` or a `MaintainPopulation`. + pub fn set(&mut self, node: &Rc, materialization: Materialization) { + assert!( + is_materialization_point(&node.operator), + "materialization is assigned to a SummaryAgg or MaintainPopulation, not {}", + node.operator.kind_name() + ); + self.assigned.insert(Rc::as_ptr(node), materialization); } - pub fn summary_timing(&self, summary: &Rc) -> ExecutionTiming { - self.summary_timings - .get(&Rc::as_ptr(summary)) - .copied() - .unwrap_or(self.default_timing) + /// The option `node` gets when `upstream_at_query_time` says whether + /// something below it can only run at query time. + fn resolve( + &self, + node: &OperatorNode, + upstream_at_query_time: bool, + ) -> Result { + let at_ingestion = |m: Materialization| m.timing() == ExecutionTiming::IngestionTime; + match self.assigned.get(&(node as *const OperatorNode)) { + Some(&m) if at_ingestion(m) && upstream_at_query_time => { + Err(ExecutionDataStateError::UpstreamNotAtIngestion { + operator: node.operator.kind_name(), + }) + } + Some(&m) => Ok(m), + None if at_ingestion(self.unassigned) && upstream_at_query_time => { + Ok(Materialization::NotMaterialized) + } + None => Ok(self.unassigned), + } } } +/// Whether `operator`'s output is a sub-DAG output that materialization may +/// store: a `SummaryAgg` or a `MaintainPopulation`. +pub(crate) fn is_materialization_point(operator: &Operator) -> bool { + matches!( + operator, + Operator::ASAP(ASAPOp::SummaryAgg { .. } | ASAPOp::MaintainPopulation { .. }) + ) +} + /// Memo of one [`apply_materialization_timings`] pass: `input node → timed node`, /// shared by every root of a workload so a node shared by two roots stays /// one `Rc`. Re-reaching a node with a different timing is a conflict. @@ -116,8 +148,8 @@ pub fn data_state(node: &OperatorNode) -> Option { } /// Whether the sub-DAG below `node` contains a node that can only run at -/// query time (an evaluation, or a summary state `assignment` recomputes at -/// query time), which forces every consumer above it to query time as well. +/// query time (an evaluation, or a sub-DAG `assignment` does not put at +/// ingestion time), which forces every consumer above it to query time as well. /// A PromQL `BinaryOp` whose inputs are not the same per-series rows also runs /// at query time. fn forces_query_time( @@ -133,15 +165,13 @@ fn forces_query_time( _ if node.timing == Some(ExecutionTiming::QueryTime) => true, Operator::ASAP(ASAPOp::SummaryEstimate { .. }) | Operator::ASAP(ASAPOp::EvaluatePopulation { .. }) => true, - Operator::ASAP(ASAPOp::SummaryAgg { .. }) - if assignment - .summary_timings - .get(&key) - .copied() - .unwrap_or(assignment.default_timing) - == ExecutionTiming::QueryTime => - { - true + Operator::ASAP( + ASAPOp::SummaryAgg { child, .. } | ASAPOp::MaintainPopulation { child, .. }, + ) => { + let upstream = forces_query_time(child, assignment, seen); + assignment + .resolve(node, upstream) + .map_or(true, |m| m.timing() == ExecutionTiming::QueryTime) } Operator::NonASAP(NonASAPOp::BinaryOp { lhs, rhs, .. }) if node @@ -187,24 +217,25 @@ pub fn apply_materialization_timings( Ok(timed) } -/// Validate the sub-DAG below `root` with every summary maintained at -/// ingestion time ([`MaterializationAssignment::all_ingestion_time`]) and +/// Validate the sub-DAG below `root` with every sub-DAG `MatAtIngestion` +/// where the DAG allows it ([`MaterializationAssignment::with_default`]) and /// `root` consumed at `root_timing`. For planning-time legality checks of a /// candidate before it is assembled into a workload DAG: a candidate must stay -/// executable if materialization later maintains its states. Nothing is kept. +/// executable if materialization later puts its states at ingestion time. +/// Nothing is kept. pub fn validate_maintained( root: &Rc, root_timing: ExecutionTiming, ) -> Result<(), ExecutionDataStateError> { - let assignment = MaterializationAssignment::all_ingestion_time(); + let assignment = all_at_ingestion(); let mut memo = TimingMemo::new(); let mut forced = HashMap::new(); let timed = write(root, root_timing, &assignment, &mut memo, &mut forced)?; validate(&timed, &mut HashMap::new()) } -/// The data state `node` produces with every summary maintained at ingestion -/// time when its consumer runs at `consumer` — the planning-time answer to +/// The data state `node` produces with every sub-DAG `MatAtIngestion` where +/// the DAG allows it, when its consumer runs at `consumer` — the planning-time answer to /// "what does this candidate's output look like", consistent with /// [`validate_maintained`]. pub fn planned_data_state( @@ -212,12 +243,8 @@ pub fn planned_data_state( consumer: ExecutionTiming, ) -> ExecutionDataState { let mut forced = HashMap::new(); - let timing = own_timing( - node, - consumer, - &MaterializationAssignment::all_ingestion_time(), - &mut forced, - ); + let (timing, _) = placement(node, consumer, &all_at_ingestion(), &mut forced) + .expect("a default assignment never fails"); ExecutionDataState { timing, primitive: match &node.operator { @@ -228,32 +255,52 @@ pub fn planned_data_state( } } -/// The timing `node` takes when its consumer runs at `consumer`. -fn own_timing( +/// The assignment of [`validate_maintained`] and [`planned_data_state`]. +fn all_at_ingestion() -> MaterializationAssignment { + MaterializationAssignment::with_default(Materialization::MatAtIngestion(Medium::Memory)) +} + +/// The timing and materialization `node` takes when its consumer runs at +/// `consumer`. +fn placement( node: &Rc, consumer: ExecutionTiming, assignment: &MaterializationAssignment, forced: &mut HashMap<*const OperatorNode, bool>, -) -> ExecutionTiming { +) -> Result<(ExecutionTiming, Materialization), ExecutionDataStateError> { // A placement fixed when the candidate was built (an exact-state read // boundary that must run at query time, or one that feeds maintenance) // is honored; a conflicting consumer is rejected by validation. if let Some(placed) = node.timing { - return placed; + return Ok((placed, node.materialization)); } - match &node.operator { + Ok(match &node.operator { Operator::ASAP(ASAPOp::SummaryEstimate { .. }) - | Operator::ASAP(ASAPOp::EvaluatePopulation { .. }) => ExecutionTiming::QueryTime, - Operator::ASAP(ASAPOp::MaintainPopulation { .. }) => ExecutionTiming::IngestionTime, - Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) => { - if forces_query_time(child, assignment, forced) { - ExecutionTiming::QueryTime - } else { - assignment.summary_timing(node) - } + | Operator::ASAP(ASAPOp::EvaluatePopulation { .. }) => { + (ExecutionTiming::QueryTime, Materialization::NotMaterialized) } - _ => consumer, - } + Operator::ASAP( + ASAPOp::SummaryAgg { child, .. } | ASAPOp::MaintainPopulation { child, .. }, + ) => { + let materialization = + assignment.resolve(node, forces_query_time(child, assignment, forced))?; + (materialization.timing(), materialization) + } + _ => (consumer, Materialization::NotMaterialized), + }) +} + +/// The timing `node` takes when its consumer runs at `consumer`, for +/// [`split_shared_by_phase`]: an assignment that cannot be honored is +/// reported later by [`apply_materialization_timings`]. +fn own_timing( + node: &Rc, + consumer: ExecutionTiming, + assignment: &MaterializationAssignment, + forced: &mut HashMap<*const OperatorNode, bool>, +) -> ExecutionTiming { + placement(node, consumer, assignment, forced) + .map_or(ExecutionTiming::QueryTime, |(timing, _)| timing) } fn write( @@ -263,7 +310,7 @@ fn write( memo: &mut TimingMemo, forced: &mut HashMap<*const OperatorNode, bool>, ) -> Result, ExecutionDataStateError> { - let timing = own_timing(node, consumer, assignment, forced); + let (timing, materialization) = placement(node, consumer, assignment, forced)?; if let Some(done) = memo.done.get(&Rc::as_ptr(node)) { let previous = done.timing.expect("memoized node is timed"); if previous != timing { @@ -297,7 +344,8 @@ fn write( let timed = Rc::new( OperatorNode::with_schema(operator, node.schema.clone()) .with_guarantee(node.guarantee.clone()) - .with_timing(Some(timing)), + .with_timing(Some(timing)) + .with_materialization(materialization), ); memo.done.insert(Rc::as_ptr(node), Rc::clone(&timed)); Ok(timed) @@ -651,7 +699,8 @@ pub fn split_shared_by_phase( Rc::new( OperatorNode::with_schema(operator, node.schema.clone()) .with_guarantee(node.guarantee.clone()) - .with_timing(node.timing), + .with_timing(node.timing) + .with_materialization(node.materialization), ) } else { Rc::clone(node) @@ -791,22 +840,21 @@ mod tests { ) } - /// Apply with every summary maintained, the placement whose edge rules - /// these tests exercise. + /// Apply with every sub-DAG `MatAtIngestion` where the DAG allows it, the + /// placement whose edge rules these tests exercise. fn apply(root: &Rc) -> Result, ExecutionDataStateError> { - apply_materialization_timings( - root, - &MaterializationAssignment::all_ingestion_time(), - &mut TimingMemo::new(), - ) + apply_materialization_timings(root, &all_at_ingestion(), &mut TimingMemo::new()) } + const AT_INGESTION: Materialization = Materialization::MatAtIngestion(Medium::Memory); + fn child(node: &Rc) -> Rc { Rc::clone(node.children()[0]) } - /// Without a materialization decision, a summary and its input run at - /// query time; an explicit per-state choice overrides the default. + /// Without a materialization decision, a summary is `NotMaterialized` and + /// it and its input run at query time; an explicit choice overrides the + /// default and is recorded on the timed node. #[test] fn default_assignment_materializes_nothing() { let summary = agg(scan(), kll()); @@ -823,18 +871,56 @@ mod tests { primitive: DataPrimitive::SummaryState, }) ); + assert_eq!(root.materialization, Materialization::NotMaterialized); assert_eq!( data_state(&child(&root)), Some(ExecutionDataState::QUERY_ROWS) ); - let mut assignment = MaterializationAssignment::all_query_time(); - assignment.set(&summary, ExecutionTiming::IngestionTime); - let root = - apply_materialization_timings(&summary, &assignment, &mut TimingMemo::new()).unwrap(); + for (materialization, timing) in [ + (AT_INGESTION, ExecutionTiming::IngestionTime), + ( + Materialization::MatAtQuery(Medium::Disk), + ExecutionTiming::QueryTime, + ), + ] { + let mut assignment = MaterializationAssignment::default(); + assignment.set(&summary, materialization); + let root = apply_materialization_timings(&summary, &assignment, &mut TimingMemo::new()) + .unwrap(); + assert_eq!(root.timing, Some(timing)); + assert_eq!(root.materialization, materialization); + assert_eq!(child(&root).timing, Some(timing)); + assert_eq!( + child(&root).materialization, + Materialization::NotMaterialized + ); + } + } + + /// #509 Algorithm 3, line 13: an explicit `MatAtIngestion` over a state + /// that only exists at query time is an error, not a silent move to + /// query time. + #[test] + fn ingestion_time_node_over_query_time_upstream_is_rejected() { + let inner = agg(scan(), exact_sum()); + let outer = agg(Rc::clone(&inner), kll()); + let mut assignment = MaterializationAssignment::default(); + assignment.set(&outer, AT_INGESTION); assert_eq!( - data_state(&root), - Some(ExecutionDataState::INGESTION_SUMMARY) + apply_materialization_timings(&estimate(outer), &assignment, &mut TimingMemo::new()) + .err(), + Some(ExecutionDataStateError::UpstreamNotAtIngestion { + operator: "SummaryAgg" + }) ); + assignment.set(&inner, AT_INGESTION); + let outer = agg(Rc::clone(&inner), kll()); + assignment.set(&outer, AT_INGESTION); + let root = + apply_materialization_timings(&estimate(outer), &assignment, &mut TimingMemo::new()) + .unwrap(); + assert_eq!(child(&root).materialization, AT_INGESTION); + assert_eq!(child(&child(&root)).materialization, AT_INGESTION); } #[test] @@ -856,19 +942,21 @@ mod tests { assert!(apply(&estimate(agg(inner, kll()))).is_ok()); } - /// A state recomputed at query time moves the summary that reads it to - /// query time too, as an evaluation below it would. + /// A state not at ingestion time leaves the summary that reads it, when + /// that summary has no explicit assignment, `NotMaterialized` at query + /// time, as an evaluation below it would. #[test] fn query_time_state_moves_its_consuming_summary_to_query_time() { let inner = agg(scan(), exact_sum()); let outer = agg(Rc::clone(&inner), kll()); - let mut assignment = MaterializationAssignment::all_ingestion_time(); - assignment.set(&inner, ExecutionTiming::QueryTime); + let mut assignment = all_at_ingestion(); + assignment.set(&inner, Materialization::NotMaterialized); let root = apply_materialization_timings(&estimate(outer), &assignment, &mut TimingMemo::new()) .unwrap(); let outer = child(&root); assert_eq!(outer.timing, Some(ExecutionTiming::QueryTime)); + assert_eq!(outer.materialization, Materialization::NotMaterialized); assert_eq!(child(&outer).timing, Some(ExecutionTiming::QueryTime)); } @@ -879,7 +967,7 @@ mod tests { assert_eq!(child(&root).timing, Some(ExecutionTiming::QueryTime)); } - /// Any non-ASAP operator over a evaluation runs at query time. + /// Any non-ASAP operator over an evaluation runs at query time. #[test] fn query_time_operation_over_evaluation_is_legal_and_root_is_evaluation() { let evaluation = || estimate(agg(scan(), kll())); @@ -950,7 +1038,7 @@ mod tests { second: ExecutionDataState::QUERY_ROWS, }) ); - let split = split_shared_by_phase(&root, &MaterializationAssignment::all_ingestion_time()); + let split = split_shared_by_phase(&root, &all_at_ingestion()); assert!(apply(&split).is_ok()); } diff --git a/crates/types/src/post_asap/execution_data_state.rs b/crates/types/src/post_asap/execution_data_state.rs index a8c177baa..d91523823 100644 --- a/crates/types/src/post_asap/execution_data_state.rs +++ b/crates/types/src/post_asap/execution_data_state.rs @@ -2,7 +2,8 @@ //! //! [`ExecutionTiming`] says when a node's value is produced (ingestion vs. //! query time); [`ExecutionDataState`] pairs it with the [`DataPrimitive`] -//! the edge carries (raw values vs. summary state). The rules that assign +//! the edge carries (raw values vs. summary state). [`Materialization`] is +//! the per-sub-DAG choice a node's timing is derived from. The rules that assign //! and check them over a DAG live in [`crate::ir::timing`], which reports //! violations as [`ExecutionDataStateError`]. @@ -31,6 +32,40 @@ impl ExecutionTiming { } } +/// Where a materialized output is stored (#509 §Materialization). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum Medium { + Memory, + Disk, +} + +/// The materialization option of one sub-DAG (#509 §Materialization, +/// Algorithm 3): whether its output is stored across query executions, and +/// whether it is computed at ingestion time or at query time. +#[derive( + Default, Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, +)] +pub enum Materialization { + /// Runs at query time for each execution; its output is discarded. + #[default] + NotMaterialized, + /// Runs as data arrives; its output is stored until a query reads it. + MatAtIngestion(Medium), + /// Runs when a query first needs it; its output is stored for later + /// executions and other queries of the batch. + MatAtQuery(Medium), +} + +impl Materialization { + /// When the sub-DAG runs: only `MatAtIngestion` runs at ingestion time. + pub fn timing(self) -> ExecutionTiming { + match self { + Self::MatAtIngestion(_) => ExecutionTiming::IngestionTime, + Self::NotMaterialized | Self::MatAtQuery(_) => ExecutionTiming::QueryTime, + } + } +} + /// The primitive representation carried by a post-ASAP edge. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum DataPrimitive { @@ -137,6 +172,10 @@ pub enum ExecutionDataStateError { /// and `Extension`. #[error("{operator} has no execution timing contract yet")] UnimplementedOperator { operator: &'static str }, + /// A node assigned `MatAtIngestion` over a sub-DAG that can only run at + /// query time (#509 Algorithm 3, line 13). + #[error("{operator}: upstream of ingestion-time node not at ingestion time")] + UpstreamNotAtIngestion { operator: &'static str }, /// A node reached by export without a timing: the materialization timing pass /// was not applied to the DAG first. #[error( diff --git a/crates/types/src/post_asap/mod.rs b/crates/types/src/post_asap/mod.rs index 04e3264c2..8e6342770 100644 --- a/crates/types/src/post_asap/mod.rs +++ b/crates/types/src/post_asap/mod.rs @@ -33,7 +33,7 @@ pub mod summary_window; pub use crate::pre_asap::schema::{Field, FieldDataType, Schema}; pub use execution_data_state::{ lift_plain, DataPrimitive, ExactOperationSchemaError, ExecutionDataState, - ExecutionDataStateError, ExecutionTiming, + ExecutionDataStateError, ExecutionTiming, Materialization, Medium, }; pub use guarantee::{ AccuracyError, BoundExpr, CompositionOperator, ErrorMetric, GuaranteeSource, ProbabilityExpr, diff --git a/crates/types/tests/physical_export.rs b/crates/types/tests/physical_export.rs index 2148a230e..3b18c61e1 100644 --- a/crates/types/tests/physical_export.rs +++ b/crates/types/tests/physical_export.rs @@ -4,8 +4,8 @@ use asap_types::ir::physical_export::{ PhysicalASAPDAGValidationError, }; use asap_types::ir::{ - apply_materialization_timings, ASAPOp, MaterializationAssignment, NonASAPOp, Operator, - OperatorNode, QueryRoot, TimingMemo, + apply_materialization_timings, ASAPOp, Materialization, MaterializationAssignment, Medium, + NonASAPOp, Operator, OperatorNode, QueryRoot, TimingMemo, }; use asap_types::post_asap::{ ExactKind, ExactParams, ExecutionDataStateError, ExecutionTiming, SummaryUpdate, @@ -41,15 +41,21 @@ fn plan() -> Rc { .unwrap() } -/// A summary maintained at ingestion time is read at query time. +const ON_DISK: Materialization = Materialization::MatAtIngestion(Medium::Disk); + +/// `plan()` timed with its summary `MatAtIngestion` on disk. +fn timed_at_ingestion() -> Rc { + let root = plan(); + let mut assignment = MaterializationAssignment::default(); + assignment.set(root.children()[0], ON_DISK); + apply_materialization_timings(&root, &assignment, &mut TimingMemo::new()).unwrap() +} + +/// A summary materialized at ingestion time is read at query time, and the +/// exported DAG says where to store it. #[test] fn timed_plan_exports_with_timing() { - let timed = apply_materialization_timings( - &plan(), - &MaterializationAssignment::all_ingestion_time(), - &mut TimingMemo::new(), - ) - .unwrap(); + let timed = timed_at_ingestion(); let dag = compile_physical_asap_dag(&timed).unwrap(); let document = PhysicalASAPDAGDocument::new(dag.clone()); document.validate().unwrap(); @@ -63,6 +69,15 @@ fn timed_plan_exports_with_timing() { ExecutionTiming::QueryTime ] ); + let materializations: Vec<_> = dag.nodes.iter().map(|n| n.materialization).collect(); + assert_eq!( + materializations, + vec![ + Materialization::NotMaterialized, + ON_DISK, + Materialization::NotMaterialized + ] + ); // Timing keeps what the summary covers. assert!(timed.children()[0].coverage().is_some()); @@ -74,13 +89,7 @@ fn timed_plan_exports_with_timing() { /// A query-time producer cannot feed an ingestion-time consumer. #[test] fn query_time_input_to_ingestion_is_rejected() { - let timed = apply_materialization_timings( - &plan(), - &MaterializationAssignment::all_ingestion_time(), - &mut TimingMemo::new(), - ) - .unwrap(); - let mut dag = compile_physical_asap_dag(&timed).unwrap(); + let mut dag = compile_physical_asap_dag(&timed_at_ingestion()).unwrap(); dag.nodes[0].output_state.timing = ExecutionTiming::QueryTime; dag.edges[0].data_state = dag.nodes[0].output_state; assert!(matches!( @@ -89,6 +98,27 @@ fn query_time_input_to_ingestion_is_rejected() { )); } +/// A summary's timing must be the one its materialization implies: the +/// deployment cannot run a `MatAtIngestion` state at query time. +#[test] +fn materialization_inconsistent_with_timing_is_rejected() { + let mut dag = compile_physical_asap_dag(&timed_at_ingestion()).unwrap(); + dag.nodes[1].output_state.timing = ExecutionTiming::QueryTime; + dag.edges[1].data_state = dag.nodes[1].output_state; + assert_eq!( + dag.validate(), + Err( + PhysicalASAPDAGValidationError::MaterializationTimingMismatch { + node: 1, + timing: ExecutionTiming::QueryTime, + materialization: ON_DISK, + } + ) + ); + dag.nodes[1].materialization = Materialization::MatAtQuery(Medium::Disk); + dag.validate().unwrap(); +} + /// Untimed plans cannot be exported as physical plans. #[test] fn untimed_plan_is_rejected() { @@ -105,7 +135,7 @@ fn batch_shares_the_summary_and_keeps_one_root_per_query() { child: state, })) .unwrap(); - let assignment = MaterializationAssignment::all_query_time(); + let assignment = MaterializationAssignment::default(); let mut memo = TimingMemo::new(); let timed: Vec<_> = [first, second] .iter() @@ -126,7 +156,7 @@ fn scalar_roots_export_in_batch_order_and_reference_nodes() { use asap_types::pre_asap::ArithmeticOpKind; let timed = apply_materialization_timings( &plan(), - &MaterializationAssignment::all_query_time(), + &MaterializationAssignment::default(), &mut TimingMemo::new(), ) .unwrap(); @@ -156,6 +186,30 @@ fn scalar_roots_export_in_batch_order_and_reference_nodes() { let mut scalar_only = dag.clone(); scalar_only.roots.clear(); scalar_only.validate().unwrap(); + // Materialization is checked on the nodes a scalar reads, too. + let state = scalar_only + .nodes + .iter() + .position(|node| { + matches!( + node.payload, + asap_types::ir::physical_export::PhysicalASAPOperatorPayload::ASAP( + ASAPOp::SummaryAgg { .. } + ) + ) + }) + .unwrap(); + scalar_only.nodes[state].materialization = ON_DISK; + assert_eq!( + scalar_only.validate(), + Err( + PhysicalASAPDAGValidationError::MaterializationTimingMismatch { + node: scalar_only.nodes[state].id, + timing: ExecutionTiming::QueryTime, + materialization: ON_DISK, + } + ) + ); let mut missing = dag.clone(); missing.scalar_roots[0].1 = ScalarExpr::PromqlScalarFromVector(99); @@ -264,7 +318,7 @@ fn swapped_edge_roles_are_rejected() { .unwrap(); let timed = apply_materialization_timings( &join, - &MaterializationAssignment::all_query_time(), + &MaterializationAssignment::default(), &mut TimingMemo::new(), ) .unwrap(); diff --git a/docs/design_docs/architecture/physical-plan-integration.md b/docs/design_docs/architecture/physical-plan-integration.md index df908440b..6ce4e12fb 100644 --- a/docs/design_docs/architecture/physical-plan-integration.md +++ b/docs/design_docs/architecture/physical-plan-integration.md @@ -455,7 +455,8 @@ finalized before row operators consume them. None of these operations proves candidate completeness; that evidence belongs to the semi-join's pruning step. The logical DAG carries no execution layout: `apply_materialization_timings` -writes each node's timing from a `MaterializationAssignment` before export (all -query time by default). Uniform phase -assignment applies to the exported post-ASAP DAG; -it is not a claim that every deployment has implemented every placement. +writes each node's timing and materialization from a +`MaterializationAssignment` before export (every sub-DAG `NotMaterialized` by +default, so all query time). The exported DAG carries both, and the deployment +executes it as given; this is not a claim that every deployment has +implemented every placement. diff --git a/docs/design_docs/architecture/planner-runtime-contract.md b/docs/design_docs/architecture/planner-runtime-contract.md index 0077252ca..4ac46c4be 100644 --- a/docs/design_docs/architecture/planner-runtime-contract.md +++ b/docs/design_docs/architecture/planner-runtime-contract.md @@ -14,7 +14,7 @@ runs at query time. Physical deployment and execution remain downstream. The | Layer | Owner | Examples | |---|---|---| | Logical candidate semantics | ASAPPlanner | Query rewrite; summary family and parameters; grouping; accuracy guarantees when established. | -| Summary materialization and realization selection | Stage 2 materialization (#509); downstream until then | Ingestion-time maintenance or query-time computation per summary state; direct build or incremental update; window implementations compared using provider evidence. | +| Summary materialization and realization selection | ASAPPlanner Stage 2 materialization (#509) | `NotMaterialized`, `MatAtIngestion(medium)` or `MatAtQuery(medium)` per sub-DAG, and how long a stored output is kept; direct build or incremental update; window implementations compared using provider evidence. | | Concrete implementation and deployment | ASAPQuery-backend and its workload optimizer | Library and data-structure implementation, exact pane layout, placement, sharding, storage, transmission, materialization IDs, executor configuration, and workload-wide assignment. | ASAPCollector and the ASAPQuery data plane execute the compiled downstream diff --git a/docs/design_docs/concepts/post-asap-ir.md b/docs/design_docs/concepts/post-asap-ir.md index 95e48f350..d3de9dec5 100644 --- a/docs/design_docs/concepts/post-asap-ir.md +++ b/docs/design_docs/concepts/post-asap-ir.md @@ -106,31 +106,45 @@ one of these phases. Backend capability restrictions are implementation gaps, not definitions of the operator. The logical DAG carries no timing: `OperatorNode::timing` is `None` on every -front-end node and every candidate, and `map_children` clears it. Summary -materialization chooses a timing per summary state and records it in a -[`MaterializationAssignment`](../../../crates/types/src/ir/timing.rs) (ingestion-time -maintenance or query-time computation per `SummaryAgg`). The default is -`all_query_time()`; until Stage 2 materialization (#509) decides otherwise, the -planner times every `SummaryAgg` at query time. +front-end node and every candidate, and `map_children` clears it. +Materialization ([#509 §Materialization](../proposals/planner-layering.md#materialization)) +gives each sub-DAG one of three options: `NotMaterialized`, +`MatAtIngestion(medium)` or `MatAtQuery(medium)`, with `Medium::Memory` or +`Medium::Disk`. A +[`MaterializationAssignment`](../../../crates/types/src/ir/timing.rs) records +the option per sub-DAG, named by the node whose output would be stored (a +`SummaryAgg` or a `MaintainPopulation`); `set(node, materialization)` assigns +one, and every other sub-DAG is `NotMaterialized`. Choosing the options is +Stage 2's job (#604); until then the planner materializes nothing. `apply_materialization_timings(root, &assignment, &mut TimingMemo)` then writes a -timing into every node, top-down: - -- a node of fixed kind takes its kind's timing — `SummaryEstimate` and - `EvaluatePopulation` run at query time, `MaintainPopulation` at ingestion time; -- a `SummaryAgg` takes the assignment's timing, unless something below it can - only exist at query time (an evaluation); -- every other node runs when its consumer runs: everything that feeds a - maintained state runs at ingestion time, everything above an evaluation at - query time. +timing and a materialization into every node, top-down: + +- `SummaryEstimate` and `EvaluatePopulation` run at query time; +- a `SummaryAgg` or `MaintainPopulation` runs at ingestion time if it is + `MatAtIngestion`, and at query time if it is `NotMaterialized` or + `MatAtQuery`. Every node upstream of an ingestion-time node also runs at + ingestion time, so `MatAtIngestion` over something that can only exist at + query time (an evaluation, or a state that is not `MatAtIngestion`) fails + with `ExecutionDataStateError::UpstreamNotAtIngestion` ("upstream of + ingestion-time node not at ingestion time"); +- every other node is not stored and runs when its consumer runs: everything + that feeds an ingestion-time state runs at ingestion time, everything above + an evaluation at query time. The pass then validates every edge (rows or exact-accumulator state into a -`SummaryAgg`, state into an evaluation, an ingestion-time `MaintainPopulation` under +`SummaryAgg`, state into an evaluation, a timed `MaintainPopulation` under an `EvaluatePopulation`, no ingestion work reading a query-time value) and rejects a node reached from two consumers that need different timings; `split_shared_by_phase` copies such a sub-DAG for one side before the -assignment is applied. `validate_maintained` and `planned_data_state` answer the -same questions for a candidate at planning time, assuming every summary is -maintained at ingestion time, without keeping anything. +assignment is applied. How long a materialized output is kept (retention) is +also Stage 2's decision and is not in the assignment. + +`MaterializationAssignment::with_default(materialization)` gives every sub-DAG +that is not set explicitly `materialization` where the DAG allows it; a +`MatAtIngestion` default over a sub-DAG that can only run at query time leaves +that sub-DAG `NotMaterialized` instead of failing. `validate_maintained` and +`planned_data_state` use it to check, at planning time and without keeping +anything, that a candidate stays valid when every sub-DAG is `MatAtIngestion`. ## Exported DAG @@ -161,11 +175,15 @@ children as edges and no embedded sub-DAGs: (`ExecutionDataStateError::UntimedNode`); it does not re-run data-state validation. -Phase is stored on the `PhysicalASAPDAG` node, independently of its payload. -`PhysicalASAPDAG::with_execution_phases` reassigns a phase to every node and -updates its edges; ingestion work cannot depend on a future query result. -Deployments must separately check that they have an implementation and a -valid data source for the chosen placement. +Each node also records its `materialization`, including the medium, so the +deployment knows which outputs to store and where; validation rejects a +`SummaryAgg` or `MaintainPopulation` whose timing differs from the one its +materialization implies, and any `MatAtIngestion` or `MatAtQuery` node at the +other timing. Ingestion work cannot depend on a future query result. The +deployment executes the plan as given and does not change its materialization +or timing ([#509 Assumption 8](../proposals/planner-layering.md#assumptions)); it must +separately check that it has an implementation and a valid data source for +each placement. ## Weighted grouped TopK @@ -195,9 +213,9 @@ failure probabilities are combined, and the score guarantee remains in the membership guarantee's child provenance. An exact request does not accept this approximate output path merely because its selected identities are certified. -Deployment chooses ingestion time or query time for these operators. The -materialization assignment writes the placement; `with_execution_phases` can -reassign it on the exported DAG. Either deployment must give each evaluation a complete +The materialization assignment decides whether these summaries run at +ingestion time or at query time, and the exported DAG carries that placement. +Either placement must give each evaluation a complete rate window and an isolated summary state, or maintain an equivalent replacement strategy. Appending successive rate snapshots to one cumulative state is invalid. An ingestion execution can compute a window before the query and store its state; diff --git a/docs/design_docs/physical-planning-and-deployment.md b/docs/design_docs/physical-planning-and-deployment.md index 549ead9de..e839b796e 100644 --- a/docs/design_docs/physical-planning-and-deployment.md +++ b/docs/design_docs/physical-planning-and-deployment.md @@ -44,13 +44,15 @@ decided by a `MaterializationAssignment`, as the layer contract below states. 1. **Logical Post-ASAP** (`CandidateLogicalASAPDAGs`) decides what to compute: summary families, readouts and sharing. It does not decide placement; timing that a realization strategy writes while building a candidate is provisional. -2. **Materialization** (Stage 2, #509) chooses ingestion or query time for each - summary state (`SummaryAgg`) and records it in a `MaterializationAssignment`. - `apply_materialization_timings` writes every node's `ExecutionTiming`: an - ingestion-time state and all of its inputs run at ingestion time; readouts, - other consumers, and query-time states run at query time. - `MaintainPopulation` always runs at ingestion time. The default assignment - is all query time, which `PlanOutput::execution_timed_dag` applies. +2. **Materialization** (Stage 2, #509) chooses `NotMaterialized`, + `MatAtIngestion(medium)` or `MatAtQuery(medium)` for each sub-DAG whose + output may be stored (a `SummaryAgg` or a `MaintainPopulation`) and records + it in a `MaterializationAssignment`. `apply_materialization_timings` writes + every node's `ExecutionTiming` and `Materialization`: a `MatAtIngestion` + state and all of its inputs run at ingestion time; readouts, other + consumers, and the other states run at query time. The default assignment + is `NotMaterialized` everywhere, which `PlanOutput::execution_timed_dag` + applies. 3. **Physical compile** (Planner) reads timing: ingestion-time nodes form the precompute DAG and the rest form the query DAG, joined by typed outputs. It does not see raw ingestion, panes, storage or stored-state readout. diff --git a/docs/design_docs/proposals/asap-aware-mapping/maintained-populations.md b/docs/design_docs/proposals/asap-aware-mapping/maintained-populations.md index c2b05588e..d12f833ff 100644 --- a/docs/design_docs/proposals/asap-aware-mapping/maintained-populations.md +++ b/docs/design_docs/proposals/asap-aware-mapping/maintained-populations.md @@ -69,15 +69,21 @@ columns and multi-measure aggregates need additional rules. **Replacement sub-DAG:** ```text -KeepPreAsap(input) - -> MaintainPopulation { input, max_k, quantiles } [ingestion time] - -> ReadPopulation { Quantile(q1) } [read] - -> ReadPopulation { Quantile(q2) } [read] - -> ReadPopulation { TopK(k1) } [read] - -> ReadPopulation { TopK(k2) } [read] - -> ReadPopulation { Sum | Count | Average } [read] +input + -> MaintainPopulation { input, max_k, quantiles } + -> EvaluatePopulation { Quantile(q1) } + -> EvaluatePopulation { Quantile(q2) } + -> EvaluatePopulation { TopK(k1) } + -> EvaluatePopulation { TopK(k2) } + -> EvaluatePopulation { Sum | Count | Average } ``` +`EvaluatePopulation` runs at query time. `MaintainPopulation` has no fixed +timing: like a `SummaryAgg`, it follows its materialization +(`NotMaterialized`, `MatAtIngestion(medium)` or `MatAtQuery(medium)`, #509 +§Materialization), which Stage 2 chooses; it runs at ingestion time only when +it is `MatAtIngestion`. + The rule examines compatible workload roots, sets `max_k` to the largest requested k and enables quantile readout if any consumer needs it. It emits a candidate for each root; canonical summary CSE interns their identical maintenance producers. @@ -116,7 +122,8 @@ because their source names or numeric values happen to agree. ## Validation, selection and execution responsibilities Planner validates the declared input, the query-time readout and readout -compatibility; `MaintainPopulation` always runs at ingestion time. Its intended guarantee is exact membership and exact readout; +compatibility; `MaintainPopulation` runs at the timing its materialization +implies. Its intended guarantee is exact membership and exact readout; a physical implementation still must preserve the language's numeric and empty-input semantics. In particular, SQL global COUNT over an empty population returns a row with zero, while PromQL COUNT over an empty vector returns an empty vector. diff --git a/tools/dag-viewer/dag.example.json b/tools/dag-viewer/dag.example.json index c489f7b78..fdcedefed 100644 --- a/tools/dag-viewer/dag.example.json +++ b/tools/dag-viewer/dag.example.json @@ -113,7 +113,7 @@ }, "children": [], "workload_node_id": 0, - "hash": 5992903821157128693 + "hash": 6908994005896438407 }, { "id": 1, @@ -169,7 +169,7 @@ 0 ], "workload_node_id": 1, - "hash": 2322418053818133469, + "hash": 6455684992122519902, "notes": [ { "kind": "SketchApproximation", @@ -229,7 +229,7 @@ 1 ], "workload_node_id": 2, - "hash": 13155751879062808849 + "hash": 9357712178312893499 } ], "root": 2 @@ -351,7 +351,7 @@ "unique_keys": [] }, "children": [], - "hash": 5992903821157128693 + "hash": 6908994005896438407 }, { "id": 1, @@ -406,7 +406,7 @@ "children": [ 0 ], - "hash": 2322418053818133469 + "hash": 6455684992122519902 } ], "root": 1 @@ -537,7 +537,7 @@ "unique_keys": [] }, "children": [], - "hash": 6315306962080483567 + "hash": 384058483260984926 }, { "id": 1, @@ -606,7 +606,7 @@ "children": [ 0 ], - "hash": 7319371269782491912 + "hash": 15814889988858536838 }, { "id": 2, @@ -702,7 +702,7 @@ "children": [ 1 ], - "hash": 11635781505417422576 + "hash": 15241615172005928158 } ], "root": 2 @@ -850,7 +850,7 @@ }, "children": [], "workload_node_id": 0, - "hash": 6315306962080483567, + "hash": 384058483260984926, "decision": { "id": 0, "strategy": "ASAPStrategies", @@ -943,7 +943,7 @@ 0 ], "workload_node_id": 1, - "hash": 7319371269782491912, + "hash": 15814889988858536838, "decision": { "id": 0, "strategy": "ASAPStrategies", @@ -1063,7 +1063,7 @@ 1 ], "workload_node_id": 2, - "hash": 11635781505417422576, + "hash": 15241615172005928158, "decision": { "id": 0, "strategy": "ASAPStrategies", @@ -1140,7 +1140,7 @@ 2 ], "workload_node_id": 3, - "hash": 13155751879062808849 + "hash": 9357712178312893499 } ], "root": 3 @@ -1276,7 +1276,7 @@ }, "children": [], "workload_node_id": 0, - "hash": 5992903821157128693 + "hash": 6908994005896438407 }, { "id": 1, @@ -1330,7 +1330,7 @@ 0 ], "workload_node_id": 3, - "hash": 15432500422583384542 + "hash": 8656625452313770894 }, { "id": 2, @@ -1384,7 +1384,7 @@ 1 ], "workload_node_id": 4, - "hash": 17963877225883903375 + "hash": 4102274562539239511 } ], "root": 2 @@ -1506,7 +1506,7 @@ "unique_keys": [] }, "children": [], - "hash": 5992903821157128693 + "hash": 6908994005896438407 }, { "id": 1, @@ -1559,7 +1559,7 @@ "children": [ 0 ], - "hash": 15432500422583384542 + "hash": 8656625452313770894 } ], "root": 1 @@ -1675,7 +1675,7 @@ "unique_keys": [] }, "children": [], - "hash": 5992903821157128693 + "hash": 6908994005896438407 }, { "id": 1, @@ -1726,7 +1726,7 @@ "children": [ 0 ], - "hash": 310390602443712772 + "hash": 10179062530039673039 }, { "id": 2, @@ -1785,7 +1785,7 @@ "children": [ 1 ], - "hash": 18059109450340713504 + "hash": 5063523577177717082 }, { "id": 3, @@ -1836,7 +1836,7 @@ "children": [ 0 ], - "hash": 10926873078186554083 + "hash": 12295148967695211280 }, { "id": 4, @@ -1880,7 +1880,7 @@ 2, 3 ], - "hash": 1089002885905118593 + "hash": 5207996595762795408 } ], "root": 4 @@ -2028,7 +2028,7 @@ }, "children": [], "workload_node_id": 0, - "hash": 6315306962080483567, + "hash": 384058483260984926, "decision": { "id": 7, "strategy": "ASAPStrategies", @@ -2155,7 +2155,7 @@ 0 ], "workload_node_id": 4, - "hash": 2701552184495960072, + "hash": 799178486167313977, "decision": { "id": 7, "strategy": "ASAPStrategies", @@ -2238,7 +2238,7 @@ 1 ], "workload_node_id": 5, - "hash": 18059109450340713504, + "hash": 5063523577177717082, "decision": { "id": 1, "strategy": "SemanticEquivalentRewriteStrategy", @@ -2334,7 +2334,7 @@ 0 ], "workload_node_id": 6, - "hash": 14727181282387726522, + "hash": 7050639208991097747, "decision": { "id": 8, "strategy": "ASAPStrategies", @@ -2402,7 +2402,7 @@ 3 ], "workload_node_id": 7, - "hash": 1089002885905118593, + "hash": 5207996595762795408, "decision": { "id": 1, "strategy": "SemanticEquivalentRewriteStrategy", @@ -2479,7 +2479,7 @@ 4 ], "workload_node_id": 8, - "hash": 17963877225883903375 + "hash": 4102274562539239511 } ], "root": 5 @@ -2567,7 +2567,7 @@ }, "children": [], "workload_node_id": 5, - "hash": 15884523639471678680 + "hash": 16928591705169832851 }, { "id": 1, @@ -2607,7 +2607,7 @@ 0 ], "workload_node_id": 6, - "hash": 5927480761285910816 + "hash": 5944440839623837288 }, { "id": 2, @@ -2652,7 +2652,7 @@ 1 ], "workload_node_id": 7, - "hash": 977274539382465184 + "hash": 8785024119722619363 }, { "id": 3, @@ -2693,7 +2693,7 @@ 2 ], "workload_node_id": 8, - "hash": 13389420092441124844 + "hash": 6186561604017396907 } ], "root": 3 @@ -2767,7 +2767,7 @@ "unique_keys": [] }, "children": [], - "hash": 15884523639471678680 + "hash": 16928591705169832851 }, { "id": 1, @@ -2806,7 +2806,7 @@ "children": [ 0 ], - "hash": 5927480761285910816 + "hash": 5944440839623837288 }, { "id": 2, @@ -2850,7 +2850,7 @@ "children": [ 1 ], - "hash": 977274539382465184 + "hash": 8785024119722619363 } ], "root": 2 @@ -2918,7 +2918,7 @@ "unique_keys": [] }, "children": [], - "hash": 15884523639471678680 + "hash": 16928591705169832851 }, { "id": 1, @@ -2972,7 +2972,7 @@ "children": [ 0 ], - "hash": 4252745894123182058 + "hash": 2947655142598403385 }, { "id": 2, @@ -3062,7 +3062,7 @@ "children": [ 1 ], - "hash": 1949434180762498567 + "hash": 45212369069935119 } ], "root": 2 @@ -3152,7 +3152,7 @@ "unique_keys": [] }, "children": [], - "hash": 15884523639471678680 + "hash": 16928591705169832851 }, { "id": 1, @@ -3191,7 +3191,7 @@ "children": [ 0 ], - "hash": 5927480761285910816 + "hash": 5944440839623837288 }, { "id": 2, @@ -3235,7 +3235,7 @@ "children": [ 1 ], - "hash": 977274539382465184 + "hash": 8785024119722619363 }, { "id": 3, @@ -3275,7 +3275,7 @@ "children": [ 2 ], - "hash": 13389420092441124844 + "hash": 6186561604017396907 } ], "root": 3 @@ -3343,7 +3343,7 @@ "unique_keys": [] }, "children": [], - "hash": 15884523639471678680 + "hash": 16928591705169832851 }, { "id": 1, @@ -3397,7 +3397,7 @@ "children": [ 0 ], - "hash": 4252745894123182058 + "hash": 2947655142598403385 }, { "id": 2, @@ -3487,7 +3487,7 @@ "children": [ 1 ], - "hash": 1949434180762498567 + "hash": 45212369069935119 }, { "id": 3, @@ -3563,7 +3563,7 @@ "children": [ 2 ], - "hash": 4765736981108370595 + "hash": 2647395323701112382 }, { "id": 4, @@ -3648,7 +3648,7 @@ "children": [ 3 ], - "hash": 1968578139481232113 + "hash": 6807625048810781615 }, { "id": 5, @@ -3726,7 +3726,7 @@ "children": [ 4 ], - "hash": 18045468768442785975 + "hash": 4927452090234187032 } ], "root": 5 @@ -3811,7 +3811,7 @@ }, "children": [], "workload_node_id": 9, - "hash": 15884523639471678680, + "hash": 16928591705169832851, "decision": { "id": 2, "strategy": "ASAPStrategies", @@ -3889,7 +3889,7 @@ 0 ], "workload_node_id": 10, - "hash": 4252745894123182058, + "hash": 2947655142598403385, "decision": { "id": 2, "strategy": "ASAPStrategies", @@ -4003,7 +4003,7 @@ 1 ], "workload_node_id": 11, - "hash": 1949434180762498567, + "hash": 45212369069935119, "decision": { "id": 2, "strategy": "ASAPStrategies", @@ -4103,7 +4103,7 @@ 2 ], "workload_node_id": 12, - "hash": 4765736981108370595, + "hash": 2647395323701112382, "decision": { "id": 2, "strategy": "ASAPStrategies", @@ -4212,7 +4212,7 @@ 3 ], "workload_node_id": 13, - "hash": 1968578139481232113, + "hash": 6807625048810781615, "decision": { "id": 2, "strategy": "ASAPStrategies", @@ -4314,7 +4314,7 @@ 4 ], "workload_node_id": 14, - "hash": 18045468768442785975, + "hash": 4927452090234187032, "decision": { "id": 2, "strategy": "ASAPStrategies", @@ -4425,7 +4425,7 @@ }, "children": [], "workload_node_id": 5, - "hash": 15884523639471678680 + "hash": 16928591705169832851 }, { "id": 1, @@ -4465,7 +4465,7 @@ 0 ], "workload_node_id": 6, - "hash": 5927480761285910816 + "hash": 5944440839623837288 }, { "id": 2, @@ -4510,7 +4510,7 @@ 1 ], "workload_node_id": 7, - "hash": 977274539382465184 + "hash": 8785024119722619363 }, { "id": 3, @@ -4551,7 +4551,7 @@ 2 ], "workload_node_id": 9, - "hash": 10517606883559557536 + "hash": 17276237346142150766 } ], "root": 3 @@ -4625,7 +4625,7 @@ "unique_keys": [] }, "children": [], - "hash": 15884523639471678680 + "hash": 16928591705169832851 }, { "id": 1, @@ -4664,7 +4664,7 @@ "children": [ 0 ], - "hash": 5927480761285910816 + "hash": 5944440839623837288 }, { "id": 2, @@ -4708,7 +4708,7 @@ "children": [ 1 ], - "hash": 977274539382465184 + "hash": 8785024119722619363 } ], "root": 2 @@ -4776,7 +4776,7 @@ "unique_keys": [] }, "children": [], - "hash": 15884523639471678680 + "hash": 16928591705169832851 }, { "id": 1, @@ -4830,7 +4830,7 @@ "children": [ 0 ], - "hash": 4252745894123182058 + "hash": 2947655142598403385 }, { "id": 2, @@ -4920,7 +4920,7 @@ "children": [ 1 ], - "hash": 1949434180762498567 + "hash": 45212369069935119 } ], "root": 2 @@ -5010,7 +5010,7 @@ "unique_keys": [] }, "children": [], - "hash": 15884523639471678680 + "hash": 16928591705169832851 }, { "id": 1, @@ -5049,7 +5049,7 @@ "children": [ 0 ], - "hash": 5927480761285910816 + "hash": 5944440839623837288 }, { "id": 2, @@ -5093,7 +5093,7 @@ "children": [ 1 ], - "hash": 977274539382465184 + "hash": 8785024119722619363 }, { "id": 3, @@ -5133,7 +5133,7 @@ "children": [ 2 ], - "hash": 10517606883559557536 + "hash": 17276237346142150766 } ], "root": 3 @@ -5201,7 +5201,7 @@ "unique_keys": [] }, "children": [], - "hash": 15884523639471678680 + "hash": 16928591705169832851 }, { "id": 1, @@ -5255,7 +5255,7 @@ "children": [ 0 ], - "hash": 4252745894123182058 + "hash": 2947655142598403385 }, { "id": 2, @@ -5345,7 +5345,7 @@ "children": [ 1 ], - "hash": 1949434180762498567 + "hash": 45212369069935119 }, { "id": 3, @@ -5421,7 +5421,7 @@ "children": [ 2 ], - "hash": 4765736981108370595 + "hash": 2647395323701112382 }, { "id": 4, @@ -5506,7 +5506,7 @@ "children": [ 3 ], - "hash": 1968578139481232113 + "hash": 6807625048810781615 }, { "id": 5, @@ -5584,7 +5584,7 @@ "children": [ 4 ], - "hash": 9012020501007212672 + "hash": 3322862873989621581 } ], "root": 5 @@ -5669,7 +5669,7 @@ }, "children": [], "workload_node_id": 9, - "hash": 15884523639471678680, + "hash": 16928591705169832851, "decision": { "id": 4, "strategy": "ASAPStrategies", @@ -5747,7 +5747,7 @@ 0 ], "workload_node_id": 10, - "hash": 4252745894123182058, + "hash": 2947655142598403385, "decision": { "id": 4, "strategy": "ASAPStrategies", @@ -5861,7 +5861,7 @@ 1 ], "workload_node_id": 11, - "hash": 1949434180762498567, + "hash": 45212369069935119, "decision": { "id": 4, "strategy": "ASAPStrategies", @@ -5961,7 +5961,7 @@ 2 ], "workload_node_id": 12, - "hash": 4765736981108370595, + "hash": 2647395323701112382, "decision": { "id": 4, "strategy": "ASAPStrategies", @@ -6070,7 +6070,7 @@ 3 ], "workload_node_id": 13, - "hash": 1968578139481232113, + "hash": 6807625048810781615, "decision": { "id": 4, "strategy": "ASAPStrategies", @@ -6172,7 +6172,7 @@ 4 ], "workload_node_id": 15, - "hash": 9012020501007212672, + "hash": 3322862873989621581, "decision": { "id": 4, "strategy": "ASAPStrategies", @@ -6331,7 +6331,7 @@ }, "children": [], "workload_node_id": 0, - "hash": 5992903821157128693 + "hash": 6908994005896438407 }, { "id": 1, @@ -6393,7 +6393,7 @@ }, "children": [], "workload_node_id": 10, - "hash": 14183779228549732123 + "hash": 749177952914250128 }, { "id": 2, @@ -6482,7 +6482,7 @@ 1 ], "workload_node_id": 11, - "hash": 530354748869277082 + "hash": 3592026688202666165 }, { "id": 3, @@ -6538,7 +6538,7 @@ 2 ], "workload_node_id": 12, - "hash": 7814897957356983635, + "hash": 16087426938996590503, "notes": [ { "kind": "SketchApproximation", @@ -6598,7 +6598,7 @@ 3 ], "workload_node_id": 13, - "hash": 1112846222258997472 + "hash": 7086496332485428501 } ], "root": 4 @@ -6720,7 +6720,7 @@ "unique_keys": [] }, "children": [], - "hash": 5992903821157128693 + "hash": 6908994005896438407 }, { "id": 1, @@ -6781,7 +6781,7 @@ "unique_keys": [] }, "children": [], - "hash": 14183779228549732123 + "hash": 749177952914250128 }, { "id": 2, @@ -6869,7 +6869,7 @@ 0, 1 ], - "hash": 530354748869277082 + "hash": 3592026688202666165 }, { "id": 3, @@ -6924,7 +6924,7 @@ "children": [ 2 ], - "hash": 7814897957356983635 + "hash": 16087426938996590503 } ], "root": 3 @@ -7040,7 +7040,7 @@ "unique_keys": [] }, "children": [], - "hash": 5992903821157128693 + "hash": 6908994005896438407 }, { "id": 1, @@ -7101,7 +7101,7 @@ "unique_keys": [] }, "children": [], - "hash": 14183779228549732123 + "hash": 749177952914250128 }, { "id": 2, @@ -7204,7 +7204,7 @@ 0, 1 ], - "hash": 5789239540888802011 + "hash": 3667654325373069547 }, { "id": 3, @@ -7273,7 +7273,7 @@ "children": [ 2 ], - "hash": 10642821447658622813 + "hash": 1063284308144682453 }, { "id": 4, @@ -7369,7 +7369,7 @@ "children": [ 3 ], - "hash": 3897106577570430774 + "hash": 18298277158829120767 } ], "root": 4 @@ -7502,7 +7502,7 @@ }, "children": [], "workload_node_id": 16, - "hash": 5992903821157128693, + "hash": 6908994005896438407, "decision": { "id": 6, "strategy": "ASAPStrategies", @@ -7587,7 +7587,7 @@ }, "children": [], "workload_node_id": 17, - "hash": 14183779228549732123, + "hash": 749177952914250128, "decision": { "id": 6, "strategy": "ASAPStrategies", @@ -7714,7 +7714,7 @@ 1 ], "workload_node_id": 18, - "hash": 5789239540888802011, + "hash": 3667654325373069547, "decision": { "id": 6, "strategy": "ASAPStrategies", @@ -7807,7 +7807,7 @@ 2 ], "workload_node_id": 19, - "hash": 10642821447658622813, + "hash": 1063284308144682453, "decision": { "id": 6, "strategy": "ASAPStrategies", @@ -7927,7 +7927,7 @@ 3 ], "workload_node_id": 20, - "hash": 3897106577570430774, + "hash": 18298277158829120767, "decision": { "id": 6, "strategy": "ASAPStrategies", @@ -8004,7 +8004,7 @@ 4 ], "workload_node_id": 21, - "hash": 1112846222258997472 + "hash": 7086496332485428501 } ], "root": 5 From e941cbdd764e6843a2bd59034be2d8d496ef5c52 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:58:45 +0000 Subject: [PATCH 6/8] fix(ir): materialize any node; keep explicit assignments across a split Per #509 ("Materialization decides, for each sub-DAG, whether its output is stored"; Example 4 stores panes), `MaterializationAssignment::set` accepts any node. `MatAtIngestion` puts it at ingestion time, or fails with `UpstreamNotAtIngestion` when something below can only run at query time, and a query-time consumer reads its stored output. An ingestion-pinned node (e.g. a fixed-window rate finalize) over a query-time state now fails the same way instead of with `IllegalChildDataState`. `split_shared_by_phase` returns the assignment remapped onto the rebuilt nodes, so an explicit `MatAtIngestion` is no longer silently lost, and the assignment holds `Rc` clones of its keys so a reused address cannot match a stale entry. Renames the remaining "maintained" names to the docs' vocabulary (`validate_at_ingestion`, `at_ingestion` test helpers), notes that `frontier_from_timing` does not store `MatAtQuery` outputs yet (Stage 2), and bumps PHYSICAL_ASAP_DAG_WIRE_VERSION to 10. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../src/exact_composition.rs | 8 +- crates/asap-aware-mapping/src/replacement.rs | 18 +- crates/asap-aware-mapping/src/test_support.rs | 4 +- .../src/physical_planner/candidates.rs | 16 +- crates/integration-tests/src/lib.rs | 6 +- .../tests/exact_composition.rs | 8 +- .../tests/physical_common/mod.rs | 2 +- .../tests/precompute_raw_samples.rs | 6 +- .../tests/promql_to_post_asap.rs | 6 +- crates/types/src/ir/mod.rs | 2 +- crates/types/src/ir/physical_export.rs | 9 +- crates/types/src/ir/timing.rs | 274 +++++++++++++----- crates/types/tests/physical_export.rs | 11 +- docs/design_docs/concepts/post-asap-ir.md | 26 +- 14 files changed, 269 insertions(+), 127 deletions(-) diff --git a/crates/asap-aware-mapping/src/exact_composition.rs b/crates/asap-aware-mapping/src/exact_composition.rs index bd46edbef..8c5fdd268 100644 --- a/crates/asap-aware-mapping/src/exact_composition.rs +++ b/crates/asap-aware-mapping/src/exact_composition.rs @@ -67,7 +67,7 @@ use std::rc::Rc; use asap_types::ir::aggregate_schema::aggregate_output_schema; use asap_types::ir::operator_properties::Reduction; -use asap_types::ir::timing::{planned_data_state, validate_maintained}; +use asap_types::ir::timing::{planned_data_state, validate_at_ingestion}; use asap_types::ir::{NonASAPOp, Operator, OperatorNode, Predicate}; use asap_types::post_asap::execution_data_state::lift_plain; use asap_types::post_asap::{ @@ -202,7 +202,7 @@ impl ExactComposition { /// Build the composed, data_state-validated node over `child`. Every edge of /// the result (including everything beneath `child`) is checked by - /// `asap_types::ir::timing::validate_maintained`; an illegal + /// `asap_types::ir::timing::validate_at_ingestion`; an illegal /// placement is a typed [`RealizationError::ExecutionDataState`], never deferred to a /// runtime. pub fn compose(&self, child: Rc) -> Result, RealizationError> { @@ -261,7 +261,7 @@ impl ExactComposition { OperatorNode::with_schema(Operator::NonASAP(self.op.clone().into_op(child)), schema) .with_guarantee(guarantee), ); - validate_maintained(&node, self.placement.data_state().timing)?; + validate_at_ingestion(&node, self.placement.data_state().timing)?; Ok(node) } @@ -701,7 +701,7 @@ mod tests { composed.operator, Operator::NonASAP(NonASAPOp::Aggregate { .. }) )); - validate_maintained(&composed, ExecutionTiming::IngestionTime).unwrap(); + validate_at_ingestion(&composed, ExecutionTiming::IngestionTime).unwrap(); assert_eq!( planned_data_state(&composed, ExecutionTiming::IngestionTime).timing, ExecutionTiming::IngestionTime diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index 7300e5c7f..394a1d079 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -350,7 +350,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use asap_types::ir::cse::{share_common_sub_dags, structural_hash, HashCache}; use asap_types::ir::operator_properties::{BinaryOpKind, JoinKind, Reduction}; -use asap_types::ir::timing::validate_maintained; +use asap_types::ir::timing::validate_at_ingestion; use asap_types::ir::SchemaDerivationError; use asap_types::ir::{ ASAPOp, BinaryOperator, NonASAPOp, Operator, OperatorNode, Predicate, ProjectItem, ScalarExpr, @@ -2003,7 +2003,7 @@ fn exact_topk_over_temporal_values( ) .with_guarantee(guarantee), ); - validate_maintained(&node, ExecutionTiming::QueryTime)?; + validate_at_ingestion(&node, ExecutionTiming::QueryTime)?; Ok(Some(node)) } @@ -2022,7 +2022,7 @@ fn realize_temporal_average( return Ok(None); }; operator.checked_finite_division = true; - validate_maintained(&node, ExecutionTiming::QueryTime)?; + validate_at_ingestion(&node, ExecutionTiming::QueryTime)?; Ok(Some(node)) } @@ -2754,7 +2754,7 @@ fn finish_weighted_topk( ) .with_guarantee(guarantee), ); - validate_maintained(&result, ExecutionTiming::QueryTime)?; + validate_at_ingestion(&result, ExecutionTiming::QueryTime)?; Ok(result) } @@ -5373,7 +5373,7 @@ impl<'a> GlobalSelection<'a> { let node = Rc::new( OperatorNode::with_schema(operator, target.schema.clone()).with_guarantee(guarantee), ); - validate_maintained(&node, ExecutionTiming::QueryTime)?; + validate_at_ingestion(&node, ExecutionTiming::QueryTime)?; Ok(node) } @@ -5492,7 +5492,7 @@ fn relink_agg_child(node: &Rc, new_child: &Rc) -> Rc ) .with_guarantee(node.guarantee.clone()), ); - match validate_maintained(&rebuilt, ExecutionTiming::IngestionTime) { + match validate_at_ingestion(&rebuilt, ExecutionTiming::IngestionTime) { Ok(_) => rebuilt, Err(_) => Rc::clone(node), } @@ -6918,7 +6918,9 @@ mod tests { use super::*; use crate::accuracy::PropagationStats; use crate::cost_model::Cost; - use crate::test_support::{agg, agg_per_entity, lower_promql, maintained, metric_scan, timed}; + use crate::test_support::{ + agg, agg_per_entity, at_ingestion, lower_promql, metric_scan, timed, + }; use asap_types::ir::operator_properties::{Reduction as ReductionTy, Source}; use asap_types::ir::TimeRangeKind; use asap_types::pre_asap::agg_intent::{ @@ -10240,7 +10242,7 @@ mod tests { let outer = agg(vec![], default_quantile(0.9), inner); // Timing is not stored during realization: time the candidate under // a maintained materialization assignment to read the maintenance boundary. - let root = maintained(&realize(&outer).unwrap()); + let root = at_ingestion(&realize(&outer).unwrap()); let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { panic!("expected estimate root, got {:?}", root.operator); diff --git a/crates/asap-aware-mapping/src/test_support.rs b/crates/asap-aware-mapping/src/test_support.rs index 8c5cd8d1d..96348f10a 100644 --- a/crates/asap-aware-mapping/src/test_support.rs +++ b/crates/asap-aware-mapping/src/test_support.rs @@ -187,13 +187,13 @@ pub(crate) fn timed(root: &Rc) -> Rc { } /// `root` timed with every sub-DAG `MatAtIngestion` where the DAG allows it. -pub(crate) fn maintained(root: &Rc) -> Rc { +pub(crate) fn at_ingestion(root: &Rc) -> Rc { apply_materialization_timings( root, &MaterializationAssignment::with_default(Materialization::MatAtIngestion(Medium::Memory)), &mut TimingMemo::new(), ) - .expect("maintained materialization timings apply") + .expect("at-ingestion materialization timings apply") } /// Time `root` under the default materialization assignment (which runs every diff --git a/crates/asap-physical-operators/src/physical_planner/candidates.rs b/crates/asap-physical-operators/src/physical_planner/candidates.rs index a59083ec1..d2f10ac7e 100644 --- a/crates/asap-physical-operators/src/physical_planner/candidates.rs +++ b/crates/asap-physical-operators/src/physical_planner/candidates.rs @@ -94,6 +94,8 @@ pub fn cut_candidate( /// That holds while timing-dependent lowering (an ingestion-time `Binary` /// aligns by value column) has the same timing at compile time as here. /// A query-time node feeding an ingestion-time node has no valid placement. +/// Only ingestion timing is read: a `MatAtQuery` output is not stored by +/// this frontier yet (choosing and storing it is Stage 2's job). pub fn frontier_from_timing(dag: &PhysicalASAPDAG) -> Result, Error> { use planner_types::post_asap::ExecutionTiming::IngestionTime; let timing = dag @@ -462,20 +464,20 @@ mod tests { )]) } - /// Cutting one compilation by a maintained-state timing and by the all - /// query-time timing + /// Cutting one compilation by a `MatAtIngestion` timing and by the + /// `NotMaterialized` (all query-time) timing /// lowers each Planner node once and matches `compile_candidate`. #[test] fn timing_cuts_share_one_lowering() { use planner_types::post_asap::ExecutionTiming::QueryTime; - let (retained, _, root) = grouped_rate(); - let ephemeral = with_timing(&retained, |_| QueryTime); - let inputs = raw_input(&retained); + let (mat_at_ingestion, _, root) = grouped_rate(); + let not_materialized = with_timing(&mat_at_ingestion, |_| QueryTime); + let inputs = raw_input(&mat_at_ingestion); let lowered = || crate::physical_planner::LOWERED_NODES.with(|count| count.get()); let before = lowered(); - let compiled = compile(&ephemeral, inputs.clone(), &[root]).unwrap(); + let compiled = compile(¬_materialized, inputs.clone(), &[root]).unwrap(); let once = lowered() - before; - let cuts = [&retained, &ephemeral].map(|timed| { + let cuts = [&mat_at_ingestion, ¬_materialized].map(|timed| { let frontier = frontier_from_timing(timed).unwrap(); let cut = cut_candidate(&compiled, &frontier).unwrap(); (timed, frontier, cut) diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs index 317bc4712..e0b0d2efd 100644 --- a/crates/integration-tests/src/lib.rs +++ b/crates/integration-tests/src/lib.rs @@ -113,7 +113,7 @@ pub mod post_asap { } /// Time `root` with every sub-DAG `MatAtIngestion` where the DAG allows it. - pub fn maintained(root: &Rc) -> Rc { + pub fn at_ingestion(root: &Rc) -> Rc { timed_with( root, &MaterializationAssignment::with_default(Materialization::MatAtIngestion( @@ -137,7 +137,7 @@ pub mod post_asap { /// Time `root` with every sub-DAG `MatAtIngestion` where the DAG allows /// it, then export the physical DAG. - pub fn maintained_post_asap_dag(root: &Rc) -> PhysicalASAPDAG { - compile_physical_asap_dag(&maintained(root)).expect("post-ASAP DAG export failed") + pub fn at_ingestion_post_asap_dag(root: &Rc) -> PhysicalASAPDAG { + compile_physical_asap_dag(&at_ingestion(root)).expect("post-ASAP DAG export failed") } } diff --git a/crates/integration-tests/tests/exact_composition.rs b/crates/integration-tests/tests/exact_composition.rs index db6a9bd26..e9cbf9ad6 100644 --- a/crates/integration-tests/tests/exact_composition.rs +++ b/crates/integration-tests/tests/exact_composition.rs @@ -25,7 +25,7 @@ use asap_aware_mapping::{ CostModel, DefaultCostModel, EvaluationRate, ExplanationKind, OperationPlacement, }; use asap_integration_tests::fixtures::lower_promql; -use asap_integration_tests::post_asap::{maintained, post_asap_dag, timed}; +use asap_integration_tests::post_asap::{at_ingestion, post_asap_dag, timed}; use asap_types::dag_export; use asap_types::ir::operator_properties::{Reduction, Source}; use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; @@ -389,7 +389,7 @@ fn every_exact_accumulator_is_finalized_before_an_outer_sketch() { }; // Timing is not stored on the plan: time it with the outer summary // maintained (which also validates every edge) and inspect the copy. - let root = maintained(root); + let root = at_ingestion(root); let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &root.operator else { panic!("expected KLL evaluation, got {:?}", root.operator); }; @@ -671,7 +671,7 @@ fn outer_summary_over_an_exact_function_composes_at_ingestion_time() { let composed = selection.assemble_selected_query(&root).unwrap().unwrap(); // Walk the timed copy, with the outer summary maintained at ingestion time. - let composed = maintained(&composed); + let composed = at_ingestion(&composed); let Operator::ASAP(ASAPOp::SummaryEstimate { summary_input, .. }) = &composed.operator else { panic!("expected evaluation root, got {:?}", composed.operator); }; @@ -725,7 +725,7 @@ fn summary_construction_follows_its_value_input_phase() { // time, because a evaluation sits below it. let state = asap_types::ir::planned_data_state(&illegal, ExecutionTiming::IngestionTime); assert_eq!(state.timing, ExecutionTiming::QueryTime); - asap_types::ir::validate_maintained(&illegal, state.timing).unwrap(); + asap_types::ir::validate_at_ingestion(&illegal, state.timing).unwrap(); } #[test] diff --git a/crates/integration-tests/tests/physical_common/mod.rs b/crates/integration-tests/tests/physical_common/mod.rs index b971bb647..7bd2b1157 100644 --- a/crates/integration-tests/tests/physical_common/mod.rs +++ b/crates/integration-tests/tests/physical_common/mod.rs @@ -52,7 +52,7 @@ pub fn compile_physical_asap_dag( /// As [`compile_physical_asap_dag`], with every sub-DAG `MatAtIngestion` /// where the DAG allows it, the placement precompute compilation requires. #[allow(dead_code)] // Not every test binary sharing this module compiles precompute. -pub fn compile_maintained_physical_asap_dag( +pub fn compile_at_ingestion_physical_asap_dag( root: &std::rc::Rc, ) -> Result> { compile_with( diff --git a/crates/integration-tests/tests/precompute_raw_samples.rs b/crates/integration-tests/tests/precompute_raw_samples.rs index 8af10787d..286d860b1 100644 --- a/crates/integration-tests/tests/precompute_raw_samples.rs +++ b/crates/integration-tests/tests/precompute_raw_samples.rs @@ -4,7 +4,7 @@ mod physical_common; use asap_types::ir::physical_export::{PhysicalASAPDAG, PhysicalASAPOperatorPayload}; use asap_types::ir::ASAPOp; use asap_types::ir::OperatorNode; -use physical_common::compile_maintained_physical_asap_dag; +use physical_common::compile_at_ingestion_physical_asap_dag; use std::{collections::BTreeMap, collections::BTreeSet, rc::Rc, sync::Arc}; use asap_aware_mapping::cost_model::DefaultCostModel; @@ -417,7 +417,7 @@ fn raw_sample_summaries_compile_and_match_their_kernels() { let mut checked = BTreeMap::new(); for (query, accuracy) in queries { for candidate in candidates(query, accuracy.clone()) { - let dag = compile_maintained_physical_asap_dag(&candidate).unwrap(); + let dag = compile_at_ingestion_physical_asap_dag(&candidate).unwrap(); for (source, root) in raw_summaries(&dag) { match check(query, &dag, source, root, &rows) { Ok(family) => { @@ -470,7 +470,7 @@ fn grouped_raw_summary(family: FieldDataType, input: SummaryUpdate) -> (Physical ) .pop() .unwrap(); - let mut dag = compile_maintained_physical_asap_dag(&candidate).unwrap(); + let mut dag = compile_at_ingestion_physical_asap_dag(&candidate).unwrap(); let (source, root) = raw_summaries(&dag)[0]; let node = dag.nodes.iter_mut().find(|n| n.id as u64 == root).unwrap(); let PhysicalASAPOperatorPayload::ASAP(ASAPOp::SummaryAgg { diff --git a/crates/integration-tests/tests/promql_to_post_asap.rs b/crates/integration-tests/tests/promql_to_post_asap.rs index d92f32992..3c01987f2 100644 --- a/crates/integration-tests/tests/promql_to_post_asap.rs +++ b/crates/integration-tests/tests/promql_to_post_asap.rs @@ -20,7 +20,7 @@ use asap_aware_mapping::{ }; use asap_integration_tests::fixtures::lower_promql; use asap_integration_tests::post_asap::{ - maintained, maintained_post_asap_dag, post_asap_dag, timed, + at_ingestion, at_ingestion_post_asap_dag, post_asap_dag, timed, }; use asap_types::ir::operator_properties::Reduction; use asap_types::ir::physical_export::PhysicalASAPOperatorPayload; @@ -1081,7 +1081,7 @@ fn nested_summary_explicitly_finalizes_exact_child_at_ingestion_time() { .unwrap(); // Stored timings are gone: time the plan with its outer summary // maintained and read the timed copy. - let timed_plan = maintained(&plan); + let timed_plan = at_ingestion(&plan); let Some(ASAPOp::SummaryEstimate { summary_input, .. }) = timed_plan.asap() else { panic!("expected selected quantile summary"); }; @@ -1140,7 +1140,7 @@ fn physical_node_owns_phase_independently_of_binary_payload() { .assemble_selected_query(&search.roots[0].1) .unwrap() .unwrap(); - let dag = maintained_post_asap_dag(&plan); + let dag = at_ingestion_post_asap_dag(&plan); let node = dag .nodes .iter() diff --git a/crates/types/src/ir/mod.rs b/crates/types/src/ir/mod.rs index 0b6756693..85c97d20a 100644 --- a/crates/types/src/ir/mod.rs +++ b/crates/types/src/ir/mod.rs @@ -24,7 +24,7 @@ pub mod timing; pub use crate::post_asap::execution_data_state::{Materialization, Medium}; pub use timing::{ apply_materialization_timings, data_state, planned_data_state, split_shared_by_phase, - validate_maintained, MaterializationAssignment, TimingMemo, + validate_at_ingestion, MaterializationAssignment, TimingMemo, }; pub mod schema_support; /// Semantic observation coverage, separate from field layout and physical timing. diff --git a/crates/types/src/ir/physical_export.rs b/crates/types/src/ir/physical_export.rs index a5cc9054b..8a64e4e51 100644 --- a/crates/types/src/ir/physical_export.rs +++ b/crates/types/src/ir/physical_export.rs @@ -27,7 +27,7 @@ use crate::post_asap::execution_data_state::{ use crate::post_asap::guarantee::ResultGuarantee; use crate::pre_asap::schema::{FieldDataType, Schema}; -pub const PHYSICAL_ASAP_DAG_WIRE_VERSION: u32 = 9; +pub const PHYSICAL_ASAP_DAG_WIRE_VERSION: u32 = 10; /// A node's operator, with each child replaced by its node id. pub type PhysicalASAPOperatorPayload = Operator; @@ -68,9 +68,10 @@ pub struct PhysicalASAPDAGNode { pub payload: PhysicalASAPOperatorPayload, /// Phase is a placement choice for every operator, independent of payload kind. pub output_state: ExecutionDataState, - /// Whether and where the deployment stores this node's output. Not - /// `NotMaterialized` only on a `SummaryAgg` or `MaintainPopulation` - /// whose sub-DAG the materialization assignment chose to store. + /// Whether and where the deployment stores this node's output: the + /// option the materialization assignment chose for the sub-DAG this node + /// produces. Any node may be stored (#509 §Materialization); a node with + /// no assignment is `NotMaterialized`. pub materialization: Materialization, pub output_schema: Schema, pub guarantee: Option, diff --git a/crates/types/src/ir/timing.rs b/crates/types/src/ir/timing.rs index 7f947a800..fd0bb4acf 100644 --- a/crates/types/src/ir/timing.rs +++ b/crates/types/src/ir/timing.rs @@ -6,22 +6,23 @@ //! §Materialization) gives each sub-DAG one of three options: //! `NotMaterialized`, `MatAtIngestion(medium)` or `MatAtQuery(medium)`. //! [`MaterializationAssignment`] records that option per sub-DAG, named by -//! the node whose output would be stored (a `SummaryAgg` or a -//! `MaintainPopulation`), and [`apply_materialization_timings`] expands it -//! into a timing on every node: +//! the node whose output would be stored (any node: a summary state, a +//! population, or e.g. the tumbling-window panes of #509 Example 4), and +//! [`apply_materialization_timings`] expands it into a timing on every node: //! //! - `SummaryEstimate` and `EvaluatePopulation` run at query time; -//! - a `SummaryAgg` or `MaintainPopulation` runs at ingestion time if it is -//! `MatAtIngestion`, and at query time otherwise (the default is -//! `NotMaterialized`). Every node upstream of an ingestion-time node also -//! runs at ingestion time, so an explicit `MatAtIngestion` over something -//! that can only exist at query time is an error -//! ([`ExecutionDataStateError::UpstreamNotAtIngestion`]); +//! - a node assigned `MatAtIngestion` runs at ingestion time, and one +//! assigned `MatAtQuery` at query time. A `SummaryAgg` or +//! `MaintainPopulation` without an assignment is `NotMaterialized` and runs +//! at query time. Every node upstream of an ingestion-time node also runs +//! at ingestion time, so `MatAtIngestion` (or an ingestion timing fixed by +//! a candidate builder) over something that can only exist at query time +//! is an error ([`ExecutionDataStateError::UpstreamNotAtIngestion`]); //! - a PromQL `BinaryOp` over per-series inputs that are not the same series //! rows runs at query time, since maintenance cannot align them; -//! - every other node runs when its consumer runs: everything that feeds an -//! ingestion-time state runs at ingestion time, everything above an -//! evaluation at query time. +//! - every other node is `NotMaterialized` and runs when its consumer runs: +//! everything that feeds an ingestion-time node runs at ingestion time, +//! everything above an evaluation at query time. //! //! A node reached from two consumers that need different timings cannot be //! executed once for both; [`split_shared_by_phase`] copies such a sub-DAG @@ -37,7 +38,7 @@ //! | `FinalizeExactAccumulator.child` | Exact-accumulator state | //! | `EvaluatePopulation.child` | A timed `MaintainPopulation` | //! | `MaintainPopulation.child` | Rows at its own timing matching the population's input | -//! | any `NonASAP` consumer | Rows (or exact-accumulator state for a projection-like operator) at the consumer's own timing; ingestion work never reads a query-time value | +//! | any `NonASAP` consumer | Rows (or exact-accumulator state for a projection-like operator) at the consumer's own timing, or a `MatAtIngestion` output read at query time; ingestion work never reads a query-time value | use std::collections::HashMap; use std::rc::Rc; @@ -52,20 +53,27 @@ use crate::post_asap::execution_data_state::{ }; use crate::pre_asap::schema::{DataType, FieldDataType, Schema}; -/// The materialization option of each sub-DAG (#509 §Materialization). A -/// sub-DAG is named by the node whose output would be stored, a `SummaryAgg` -/// or a `MaintainPopulation` (by identity); every other node runs when its -/// consumer runs. `Default` assigns `NotMaterialized` to every sub-DAG. +/// The materialization option of each sub-DAG (#509 §Materialization: +/// "Materialization decides, for each sub-DAG, whether its output is +/// stored"). A sub-DAG is named by the node whose output would be stored (by +/// identity); any node may be assigned. A node without an assignment is not +/// stored: a `SummaryAgg` or `MaintainPopulation` takes the default, every +/// other node runs when its consumer runs. `Default` assigns +/// `NotMaterialized` to every sub-DAG. +/// +/// The assignment keeps every assigned node alive, so a later allocation +/// cannot reuse its address and match a stale entry. #[derive(Debug, Clone, Default)] pub struct MaterializationAssignment { - assigned: HashMap<*const OperatorNode, Materialization>, + assigned: HashMap<*const OperatorNode, (Rc, Materialization)>, unassigned: Materialization, } impl MaterializationAssignment { - /// Every sub-DAG not [`set`](Self::set) explicitly takes `materialization` - /// where the DAG allows it: a `MatAtIngestion` default over a sub-DAG - /// that can only run at query time leaves that sub-DAG `NotMaterialized`. + /// Every `SummaryAgg` or `MaintainPopulation` not [`set`](Self::set) + /// explicitly takes `materialization` where the DAG allows it: a + /// `MatAtIngestion` default over a sub-DAG that can only run at query + /// time leaves that sub-DAG `NotMaterialized`. pub fn with_default(materialization: Materialization) -> Self { Self { assigned: HashMap::new(), @@ -74,14 +82,16 @@ impl MaterializationAssignment { } /// Assign `materialization` to the sub-DAG whose output `node` produces. - /// Panics unless `node` is a `SummaryAgg` or a `MaintainPopulation`. pub fn set(&mut self, node: &Rc, materialization: Materialization) { - assert!( - is_materialization_point(&node.operator), - "materialization is assigned to a SummaryAgg or MaintainPopulation, not {}", - node.operator.kind_name() - ); - self.assigned.insert(Rc::as_ptr(node), materialization); + self.assigned + .insert(Rc::as_ptr(node), (Rc::clone(node), materialization)); + } + + /// The explicit assignment of `node`, if any. + fn get(&self, node: &OperatorNode) -> Option { + self.assigned + .get(&(node as *const OperatorNode)) + .map(|(_, m)| *m) } /// The option `node` gets when `upstream_at_query_time` says whether @@ -92,13 +102,13 @@ impl MaterializationAssignment { upstream_at_query_time: bool, ) -> Result { let at_ingestion = |m: Materialization| m.timing() == ExecutionTiming::IngestionTime; - match self.assigned.get(&(node as *const OperatorNode)) { - Some(&m) if at_ingestion(m) && upstream_at_query_time => { + match self.get(node) { + Some(m) if at_ingestion(m) && upstream_at_query_time => { Err(ExecutionDataStateError::UpstreamNotAtIngestion { operator: node.operator.kind_name(), }) } - Some(&m) => Ok(m), + Some(m) => Ok(m), None if at_ingestion(self.unassigned) && upstream_at_query_time => { Ok(Materialization::NotMaterialized) } @@ -161,17 +171,28 @@ fn forces_query_time( if let Some(&cached) = seen.get(&key) { return cached; } + let upstream = |seen: &mut HashMap<*const OperatorNode, bool>| { + node.children() + .iter() + .any(|child| forces_query_time(child, assignment, seen)) + }; let forced = match &node.operator { _ if node.timing == Some(ExecutionTiming::QueryTime) => true, Operator::ASAP(ASAPOp::SummaryEstimate { .. }) | Operator::ASAP(ASAPOp::EvaluatePopulation { .. }) => true, - Operator::ASAP( - ASAPOp::SummaryAgg { child, .. } | ASAPOp::MaintainPopulation { child, .. }, - ) => { - let upstream = forces_query_time(child, assignment, seen); - assignment - .resolve(node, upstream) - .map_or(true, |m| m.timing() == ExecutionTiming::QueryTime) + _ if node.timing.is_none() + && (is_materialization_point(&node.operator) || assignment.get(node).is_some()) => + { + let upstream = upstream(seen); + match assignment.resolve(node, upstream) { + Ok(Materialization::NotMaterialized) + if !is_materialization_point(&node.operator) => + { + upstream + } + Ok(m) => m.timing() == ExecutionTiming::QueryTime, + Err(_) => true, + } } Operator::NonASAP(NonASAPOp::BinaryOp { lhs, rhs, .. }) if node @@ -183,10 +204,7 @@ fn forces_query_time( { true } - _ => node - .children() - .iter() - .any(|child| forces_query_time(child, assignment, seen)), + _ => upstream(seen), }; seen.insert(key, forced); forced @@ -223,7 +241,7 @@ pub fn apply_materialization_timings( /// candidate before it is assembled into a workload DAG: a candidate must stay /// executable if materialization later puts its states at ingestion time. /// Nothing is kept. -pub fn validate_maintained( +pub fn validate_at_ingestion( root: &Rc, root_timing: ExecutionTiming, ) -> Result<(), ExecutionDataStateError> { @@ -237,7 +255,7 @@ pub fn validate_maintained( /// The data state `node` produces with every sub-DAG `MatAtIngestion` where /// the DAG allows it, when its consumer runs at `consumer` — the planning-time answer to /// "what does this candidate's output look like", consistent with -/// [`validate_maintained`]. +/// [`validate_at_ingestion`]. pub fn planned_data_state( node: &Rc, consumer: ExecutionTiming, @@ -255,7 +273,7 @@ pub fn planned_data_state( } } -/// The assignment of [`validate_maintained`] and [`planned_data_state`]. +/// The assignment of [`validate_at_ingestion`] and [`planned_data_state`]. fn all_at_ingestion() -> MaterializationAssignment { MaterializationAssignment::with_default(Materialization::MatAtIngestion(Medium::Memory)) } @@ -271,20 +289,38 @@ fn placement( // A placement fixed when the candidate was built (an exact-state read // boundary that must run at query time, or one that feeds maintenance) // is honored; a conflicting consumer is rejected by validation. + let upstream_at_query_time = |forced: &mut HashMap<*const OperatorNode, bool>| { + node.children() + .iter() + .any(|child| forces_query_time(child, assignment, forced)) + }; if let Some(placed) = node.timing { + if placed == ExecutionTiming::IngestionTime && upstream_at_query_time(forced) { + return Err(ExecutionDataStateError::UpstreamNotAtIngestion { + operator: node.operator.kind_name(), + }); + } return Ok((placed, node.materialization)); } Ok(match &node.operator { Operator::ASAP(ASAPOp::SummaryEstimate { .. }) - | Operator::ASAP(ASAPOp::EvaluatePopulation { .. }) => { - (ExecutionTiming::QueryTime, Materialization::NotMaterialized) - } - Operator::ASAP( - ASAPOp::SummaryAgg { child, .. } | ASAPOp::MaintainPopulation { child, .. }, - ) => { - let materialization = - assignment.resolve(node, forces_query_time(child, assignment, forced))?; - (materialization.timing(), materialization) + | Operator::ASAP(ASAPOp::EvaluatePopulation { .. }) => match assignment.get(node) { + Some(m) if m.timing() == ExecutionTiming::IngestionTime => { + return Err(ExecutionDataStateError::UpstreamNotAtIngestion { + operator: node.operator.kind_name(), + }) + } + Some(m) => (ExecutionTiming::QueryTime, m), + None => (ExecutionTiming::QueryTime, Materialization::NotMaterialized), + }, + _ if is_materialization_point(&node.operator) || assignment.get(node).is_some() => { + let upstream = upstream_at_query_time(forced); + match assignment.resolve(node, upstream)? { + Materialization::NotMaterialized if !is_materialization_point(&node.operator) => { + (consumer, Materialization::NotMaterialized) + } + materialization => (materialization.timing(), materialization), + } } _ => (consumer, Materialization::NotMaterialized), }) @@ -527,7 +563,13 @@ fn validate_non_asap( | NonASAPOp::Limit { .. } ) && s.primitive == DataPrimitive::SummaryState && is_exact_accumulator_state(&child.schema).is_ok(); - if s.timing != timing || (s.primitive != DataPrimitive::Raw && !passes_state) { + // A query-time consumer may read the stored output of a sub-DAG + // materialized at ingestion time. + let reads_stored = timing == ExecutionTiming::QueryTime + && child.materialization.timing() == ExecutionTiming::IngestionTime; + if (s.timing != timing && !reads_stored) + || (s.primitive != DataPrimitive::Raw && !passes_state) + { return Err(ExecutionDataStateError::IllegalChildDataState { edge: op.kind_name(), child: s, @@ -635,11 +677,13 @@ fn validate_non_asap( /// two different timings, so that a workload whose CSE shared a `Scan` /// between an ingestion-time summary and a query-time computation can still /// be timed. Only the conflicting sub-DAGs are copied; a sub-DAG reached with -/// one timing stays one `Rc`. Returns the (possibly rewritten) root. +/// one timing stays one `Rc`. Returns the (possibly rewritten) root and +/// `assignment` remapped onto it: a rebuilt node keeps its original's +/// explicit materialization. pub fn split_shared_by_phase( root: &Rc, assignment: &MaterializationAssignment, -) -> Rc { +) -> (Rc, MaterializationAssignment) { // First pass: the set of timings each node is reached with. let mut reached: HashMap<*const OperatorNode, Vec> = HashMap::new(); let mut forced = HashMap::new(); @@ -668,7 +712,7 @@ pub fn split_shared_by_phase( &mut forced, ); if reached.values().all(|timings| timings.len() <= 1) { - return Rc::clone(root); + return (Rc::clone(root), assignment.clone()); } // Second pass: rebuild, giving each (node, timing) pair its own copy. let mut copies: HashMap<(*const OperatorNode, ExecutionTiming), Rc> = @@ -708,14 +752,21 @@ pub fn split_shared_by_phase( copies.insert(key, Rc::clone(&out)); out } - rebuild( + let root = rebuild( root, ExecutionTiming::QueryTime, assignment, &reached, &mut copies, &mut forced, - ) + ); + let mut remapped = assignment.clone(); + for ((original, _), copy) in &copies { + if let Some((_, materialization)) = assignment.assigned.get(original) { + remapped.set(copy, *materialization); + } + } + (root, remapped) } /// Maintenance arithmetic needs the same per-series population on both sides. @@ -1008,13 +1059,12 @@ mod tests { #[test] fn function_over_evaluation_is_rejected() { let operation = placed_at_ingestion(max(estimate(agg(scan(), kll())))); - assert!(matches!( - apply(&estimate(agg(operation, kll()))), - Err(ExecutionDataStateError::IllegalChildDataState { - child: ExecutionDataState::QUERY_ROWS, - .. + assert_eq!( + apply(&estimate(agg(operation, kll()))).err(), + Some(ExecutionDataStateError::UpstreamNotAtIngestion { + operator: "Aggregate" }) - )); + ); } /// One shared sub-DAG reached as maintenance input and as query-time @@ -1038,8 +1088,90 @@ mod tests { second: ExecutionDataState::QUERY_ROWS, }) ); - let split = split_shared_by_phase(&root, &all_at_ingestion()); - assert!(apply(&split).is_ok()); + let (split, assignment) = split_shared_by_phase(&root, &all_at_ingestion()); + assert!(apply_materialization_timings(&split, &assignment, &mut TimingMemo::new()).is_ok()); + } + + /// A finalize pinned to ingestion time (as fixed-window rate candidates + /// build it) over a state that is not `MatAtIngestion` names the missing + /// upstream, not an illegal edge. + #[test] + fn ingestion_pinned_finalize_over_query_time_state_is_upstream_not_at_ingestion() { + let finalize = placed_at_ingestion( + OperatorNode::new_shared(crate::ir::Operator::ASAP( + ASAPOp::FinalizeExactAccumulator { + child: agg(scan(), exact_sum()), + }, + )) + .unwrap(), + ); + assert_eq!( + apply_materialization_timings( + &estimate(agg(finalize, kll())), + &MaterializationAssignment::default(), + &mut TimingMemo::new() + ) + .err(), + Some(ExecutionDataStateError::UpstreamNotAtIngestion { + operator: "FinalizeExactAccumulator" + }) + ); + } + + /// Splitting rebuilds the ancestors of a copied sub-DAG; an explicit + /// assignment on one of them follows it onto the rebuilt node. + #[test] + fn split_keeps_explicit_assignments() { + let shared = scan(); + let summary = agg(Rc::clone(&shared), kll()); + let root = OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Concat { + children: vec![max(estimate(Rc::clone(&summary))), max(Rc::clone(&shared))], + discriminator_unique_key: None, + })) + .unwrap(); + let mut assignment = MaterializationAssignment::default(); + assignment.set(&summary, AT_INGESTION); + let (split, assignment) = split_shared_by_phase(&root, &assignment); + let timed = + apply_materialization_timings(&split, &assignment, &mut TimingMemo::new()).unwrap(); + let summary = child(&child(&child(&timed))); + assert!(matches!( + summary.operator, + Operator::ASAP(ASAPOp::SummaryAgg { .. }) + )); + assert_eq!(summary.materialization, AT_INGESTION); + assert_eq!(summary.timing, Some(ExecutionTiming::IngestionTime)); + } + + /// Any node may be materialized, not only a summary state: stored rows + /// at ingestion time are read by a query-time consumer, and a node that + /// can only run at query time cannot be stored at ingestion time. + #[test] + fn any_node_may_be_materialized() { + let panes = max(scan()); + let root = max(Rc::clone(&panes)); + let mut assignment = MaterializationAssignment::default(); + assignment.set(&panes, AT_INGESTION); + let timed = + apply_materialization_timings(&root, &assignment, &mut TimingMemo::new()).unwrap(); + assert_eq!(timed.timing, Some(ExecutionTiming::QueryTime)); + assert_eq!(child(&timed).materialization, AT_INGESTION); + assert_eq!(child(&timed).timing, Some(ExecutionTiming::IngestionTime)); + assert_eq!( + child(&child(&timed)).timing, + Some(ExecutionTiming::IngestionTime) + ); + + let over_evaluation = max(estimate(agg(scan(), kll()))); + let mut assignment = MaterializationAssignment::default(); + assignment.set(&over_evaluation, AT_INGESTION); + assert_eq!( + apply_materialization_timings(&over_evaluation, &assignment, &mut TimingMemo::new()) + .err(), + Some(ExecutionDataStateError::UpstreamNotAtIngestion { + operator: "Aggregate" + }) + ); } /// Both paired operands must be plain; an unrelated state column is not @@ -1061,11 +1193,11 @@ mod tests { }; for operand in [1, 2] { assert!(matches!( - validate_maintained(&corr_over(operand), ExecutionTiming::QueryTime), + validate_at_ingestion(&corr_over(operand), ExecutionTiming::QueryTime), Err(ExecutionDataStateError::NonPlainOperand { .. }) )); } - validate_maintained(&corr_over(3), ExecutionTiming::QueryTime).unwrap(); + validate_at_ingestion(&corr_over(3), ExecutionTiming::QueryTime).unwrap(); } // Typed derived updates retain one opaque series identity only when both // operands cover the same series; arbitrary labels are never admitted. @@ -1124,7 +1256,7 @@ mod tests { }), schema, )); - validate_default(&placed_at_ingestion(binary), ExecutionTiming::IngestionTime) + validate_at_ingestion(&placed_at_ingestion(binary), ExecutionTiming::IngestionTime) }; let mut schema = Schema::lifted( vec![ diff --git a/crates/types/tests/physical_export.rs b/crates/types/tests/physical_export.rs index 3b18c61e1..5d1f2aa42 100644 --- a/crates/types/tests/physical_export.rs +++ b/crates/types/tests/physical_export.rs @@ -186,6 +186,7 @@ fn scalar_roots_export_in_batch_order_and_reference_nodes() { let mut scalar_only = dag.clone(); scalar_only.roots.clear(); scalar_only.validate().unwrap(); + let valid_scalar_only = scalar_only.clone(); // Materialization is checked on the nodes a scalar reads, too. let state = scalar_only .nodes @@ -227,13 +228,13 @@ fn scalar_roots_export_in_batch_order_and_reference_nodes() { // Keys must strictly ascend: a duplicate or a descending key is rejected. let literal: ScalarExpr = ScalarExpr::Literal(asap_types::pre_asap::ScalarValue::Float64(2.0)); - let mut duplicate = scalar_only.clone(); + let mut duplicate = valid_scalar_only.clone(); duplicate.scalar_roots.push((0, literal.clone())); assert_eq!( duplicate.validate(), Err(PhysicalASAPDAGValidationError::ScalarRootPosition(0)) ); - let mut descending = scalar_only.clone(); + let mut descending = valid_scalar_only; descending.scalar_roots[0].0 = 1; descending.scalar_roots.push((0, literal)); assert_eq!( @@ -389,9 +390,9 @@ fn exports_summary_over_summary_as_typed_precompute_edges() { })) .unwrap(); let mut memo = TimingMemo::new(); - let timed = - apply_lifecycle_timings(&root, &LifecycleAssignment::default_maintained(), &mut memo) - .unwrap(); + let at_ingestion = + MaterializationAssignment::with_default(Materialization::MatAtIngestion(Medium::Memory)); + let timed = apply_materialization_timings(&root, &at_ingestion, &mut memo).unwrap(); let compiled = compile_physical_asap_dag_with_node_ids(&timed).unwrap(); assert_eq!(compiled.node_ids.node_id(&timed), Some(3)); assert_eq!( diff --git a/docs/design_docs/concepts/post-asap-ir.md b/docs/design_docs/concepts/post-asap-ir.md index d3de9dec5..636604405 100644 --- a/docs/design_docs/concepts/post-asap-ir.md +++ b/docs/design_docs/concepts/post-asap-ir.md @@ -112,24 +112,27 @@ gives each sub-DAG one of three options: `NotMaterialized`, `MatAtIngestion(medium)` or `MatAtQuery(medium)`, with `Medium::Memory` or `Medium::Disk`. A [`MaterializationAssignment`](../../../crates/types/src/ir/timing.rs) records -the option per sub-DAG, named by the node whose output would be stored (a -`SummaryAgg` or a `MaintainPopulation`); `set(node, materialization)` assigns +the option per sub-DAG, named by the node whose output would be stored. Any +node may be named: a summary state, a population, or, as in #509 Example 4, +the tumbling-window panes a query reads. `set(node, materialization)` assigns one, and every other sub-DAG is `NotMaterialized`. Choosing the options is Stage 2's job (#604); until then the planner materializes nothing. `apply_materialization_timings(root, &assignment, &mut TimingMemo)` then writes a timing and a materialization into every node, top-down: - `SummaryEstimate` and `EvaluatePopulation` run at query time; -- a `SummaryAgg` or `MaintainPopulation` runs at ingestion time if it is - `MatAtIngestion`, and at query time if it is `NotMaterialized` or - `MatAtQuery`. Every node upstream of an ingestion-time node also runs at - ingestion time, so `MatAtIngestion` over something that can only exist at - query time (an evaluation, or a state that is not `MatAtIngestion`) fails - with `ExecutionDataStateError::UpstreamNotAtIngestion` ("upstream of +- a node assigned `MatAtIngestion` runs at ingestion time, and one assigned + `MatAtQuery` at query time; a `SummaryAgg` or `MaintainPopulation` that is + `NotMaterialized` runs at query time. Every node upstream of an + ingestion-time node also runs at ingestion time, so `MatAtIngestion` over + something that can only exist at query time (an evaluation, or a state + that is not `MatAtIngestion`) fails with + `ExecutionDataStateError::UpstreamNotAtIngestion` ("upstream of ingestion-time node not at ingestion time"); - every other node is not stored and runs when its consumer runs: everything - that feeds an ingestion-time state runs at ingestion time, everything above - an evaluation at query time. + that feeds an ingestion-time node runs at ingestion time, everything above + an evaluation at query time. A query-time consumer reads a `MatAtIngestion` + node's stored output. The pass then validates every edge (rows or exact-accumulator state into a `SummaryAgg`, state into an evaluation, a timed `MaintainPopulation` under @@ -142,7 +145,8 @@ also Stage 2's decision and is not in the assignment. `MaterializationAssignment::with_default(materialization)` gives every sub-DAG that is not set explicitly `materialization` where the DAG allows it; a `MatAtIngestion` default over a sub-DAG that can only run at query time leaves -that sub-DAG `NotMaterialized` instead of failing. `validate_maintained` and +that sub-DAG `NotMaterialized` instead of failing (the default applies to +`SummaryAgg` and `MaintainPopulation` nodes). `validate_at_ingestion` and `planned_data_state` use it to check, at planning time and without keeping anything, that a candidate stays valid when every sub-DAG is `MatAtIngestion`. From be17198aa04fb496f1690131de116fd56d22da67 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:08:25 +0000 Subject: [PATCH 7/8] test(integration): same answers with and without materialization Ports the four execution tests removed with the summary-maintenance lifecycle to MaterializationAssignment: NotMaterialized and MatAtIngestion placements of the KLL quantile, the current-series population, and sum_over_time arithmetic over one and over different selectors execute to the same results. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- .../tests/materialization_e2e.rs | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 crates/integration-tests/tests/materialization_e2e.rs diff --git a/crates/integration-tests/tests/materialization_e2e.rs b/crates/integration-tests/tests/materialization_e2e.rs new file mode 100644 index 000000000..72a2112f2 --- /dev/null +++ b/crates/integration-tests/tests/materialization_e2e.rs @@ -0,0 +1,375 @@ +//! Materialization decides placement, not answers: the same selected DAG +//! timed with its states `NotMaterialized` (everything at query time) and +//! `MatAtIngestion` (states in precompute) executes to the same result. +//! Ported from the removed summary-maintenance-lifecycle end-to-end tests. + +use std::collections::BTreeMap; +use std::rc::Rc; +use std::sync::Arc; + +use asap_aware_mapping::maintained_population::MaintainedPopulationStrategy; +use asap_aware_mapping::{search_workload, DefaultCostModel}; +use asap_integration_tests::fixtures::lower_promql; +use asap_physical_operators::physical_planner::{ + compile_candidate, frontier_from_timing, promql_rows, CompiledPhysicalDAG, InputContract, +}; +use asap_physical_operators::runtime::Scope; +use asap_physical_operators::values::{Batch, SchemaRef, Value}; +use asap_types::ir::physical_export::{ + compile_physical_asap_dag, PhysicalASAPDAG, PhysicalASAPOperatorPayload, +}; +use asap_types::ir::{ + apply_materialization_timings, ASAPOp, Materialization, MaterializationAssignment, Medium, + NonASAPOp, OperatorNode, TimingMemo, +}; +use asap_types::post_asap::maintained_population::PopulationInput; +use asap_types::post_asap::ExecutionTiming; +use asap_types::pre_asap::Source; +use asap_types::types::AccuracyTarget; + +mod physical_common; + +const AT_INGESTION: Materialization = Materialization::MatAtIngestion(Medium::Memory); + +/// The selected logical DAG for `query`, optionally with a PromQL series +/// identity (per-entity state needs one). +fn selected(query: &str, series_identity: bool) -> Rc { + let mut root = lower_promql(query, AccuracyTarget::Epsilon(0.01)).unwrap(); + if series_identity { + root = promql_rows::with_series_identity(&root).unwrap(); + } + let space = search_workload(vec![("query", root)]).unwrap(); + space + .global_selection(&DefaultCostModel) + .assemble_selected_query(&space.roots[0].1) + .unwrap() + .unwrap() +} + +/// The two placements under test: nothing stored, and every state stored at +/// ingestion time where the DAG allows it. +fn placements() -> [MaterializationAssignment; 2] { + [ + MaterializationAssignment::default(), + MaterializationAssignment::with_default(AT_INGESTION), + ] +} + +fn timed_dag(root: &Rc, assignment: &MaterializationAssignment) -> PhysicalASAPDAG { + let timed = apply_materialization_timings(root, assignment, &mut TimingMemo::new()).unwrap(); + compile_physical_asap_dag(&timed).unwrap() +} + +fn states(dag: &PhysicalASAPDAG) -> Vec { + dag.nodes + .iter() + .filter(|node| node.materialization == AT_INGESTION) + .map(|node| node.id as u64) + .collect() +} + +fn floats(result: &[Vec]) -> Vec { + result[0] + .iter() + .flat_map(|batch| batch.rows()) + .flat_map(|row| row.iter()) + .filter_map(|value| match value { + Value::Float64(value) => Some(*value), + _ => None, + }) + .collect() +} + +/// Execute a timed DAG over one raw-source batch per `TimeRange`/`Scan` +/// input (`batch(schema, metric)`): precompute (when there is one) over +/// `ingestion`, then the query at `evaluation_time_ms`. Returns the root's +/// values and the frontier. +fn execute( + dag: &PhysicalASAPDAG, + batch: &dyn Fn(&SchemaRef, &str) -> Batch, + ingestion: Scope, + evaluation_time_ms: i64, +) -> (Vec, Vec) { + let mut raw = BTreeMap::new(); + for node in &dag.nodes { + if !matches!( + node.payload, + PhysicalASAPOperatorPayload::NonASAP( + NonASAPOp::TimeRange { .. } | NonASAPOp::Scan { .. } + ) + ) { + continue; + } + let mut id = node.id; + loop { + let n = dag.nodes.iter().find(|n| n.id == id).unwrap(); + if let PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { metric }, + .. + }) = &n.payload + { + raw.insert( + node.id as u64, + (Arc::new(node.output_schema.clone()), metric.clone()), + ); + break; + } + id = dag + .edges + .iter() + .find(|e| e.consumer == id) + .unwrap() + .producer; + } + } + let frontier = frontier_from_timing(dag).unwrap(); + let candidate = compile_candidate( + dag, + raw.iter() + .map(|(id, (schema, _))| (*id, InputContract::bounded(schema.clone()))) + .collect(), + &[dag.roots[0] as u64], + &frontier, + ) + .unwrap(); + let raw_sources = |plan: &CompiledPhysicalDAG| { + plan.input_contracts() + .filter_map(|(id, _)| raw.get(&id).map(|(schema, name)| (id, batch(schema, name)))) + .collect::>() + }; + let mut query_sources = raw_sources(&candidate.query); + if frontier.is_empty() { + assert!(candidate.precompute.is_none()); + } + if let Some(precompute) = &candidate.precompute { + let stored = physical_common::execute(precompute, raw_sources(precompute), ingestion); + for (root, batches) in precompute.roots().iter().zip(stored) { + query_sources.insert(*root, batches[0].clone()); + } + } + let result = physical_common::execute( + &candidate.query, + query_sources, + Scope::Query { + evaluation_time_ms, + revision: 1, + }, + ); + (floats(&result), frontier) +} + +/// `(metric, job, seconds, value)` samples as one batch per metric. +fn job_samples<'a>( + samples: &'a [(&'a str, &'a str, i64, f64)], +) -> impl Fn(&SchemaRef, &str) -> Batch + 'a { + move |schema, name| { + let rows = samples + .iter() + .filter(|sample| sample.0 == name) + .map(|(metric, job, seconds, value)| { + let labels = BTreeMap::from([ + ("__name__".to_string(), metric.to_string()), + ("job".to_string(), job.to_string()), + ]); + promql_rows::series_row(schema, &labels, seconds * 1000, *value).unwrap() + }) + .collect(); + Batch::try_new(schema.clone(), rows).unwrap() + } +} + +/// `MatAtIngestion` puts the quantile's state in precompute; +/// `NotMaterialized` leaves precompute empty and reads the raw source at +/// query time; both answer alike. +#[test] +fn materialization_decides_precompute_contents() { + let root = selected("quantile(0.99, latency)", false); + let mut answers = Vec::new(); + for assignment in placements() { + let dag = timed_dag(&root, &assignment); + let batch = |schema: &SchemaRef, _: &str| { + let rows = (1..=100) + .map(|value| { + schema + .fields + .iter() + .map(|field| match field.dtype { + asap_types::post_asap::FieldDataType::Plain( + asap_types::pre_asap::DataType::Float64, + ) => Value::Float64(f64::from(value)), + asap_types::post_asap::FieldDataType::Plain( + asap_types::pre_asap::DataType::Timestamp, + ) => Value::Timestamp(300_000), + _ => panic!("unexpected field {field:?}"), + }) + .collect() + }) + .collect(); + Batch::try_new(schema.clone(), rows).unwrap() + }; + let ingestion = Scope::Ingestion { + window_start_ms: 0, + window_end_ms: 300_000, + revision: 1, + }; + let (values, frontier) = execute(&dag, &batch, ingestion, 300_000); + assert_eq!(frontier, states(&dag)); + answers.push(values); + } + assert_eq!(answers[1].len(), 1); + assert!(!states(&timed_dag(&root, &placements()[1])).is_empty()); + assert_eq!(answers[0], answers[1]); +} + +/// A maintained current-series population: `MatAtIngestion` stores it in +/// precompute, `NotMaterialized` rebuilds it from the raw source at query +/// time; both rank alike. +#[test] +fn population_materialization_decides_precompute_contents() { + let root = promql_rows::with_series_identity( + &lower_promql("topk by(job)(1, m)", AccuracyTarget::Epsilon(0.01)).unwrap(), + ) + .unwrap(); + let root = MaintainedPopulationStrategy::new(std::slice::from_ref(&root)) + .candidate(&root) + .unwrap(); + let population = OperatorNode::reachable(&root) + .into_iter() + .find(|node| matches!(node.asap(), Some(ASAPOp::MaintainPopulation { .. }))) + .expect("a maintained population"); + let mut stored = MaterializationAssignment::default(); + stored.set(&population, AT_INGESTION); + let mut answers = Vec::new(); + for assignment in [MaterializationAssignment::default(), stored] { + let dag = timed_dag(&root, &assignment); + let lookback = dag + .nodes + .iter() + .find_map(|node| match &node.payload { + PhysicalASAPOperatorPayload::ASAP(ASAPOp::MaintainPopulation { + population, + .. + }) => match &population.input { + PopulationInput::CurrentSeries(spec) => { + Some(i64::try_from(spec.lookback_ms).unwrap()) + } + _ => None, + }, + _ => None, + }) + .expect("current-series population"); + let end = 60_000; + let batch = |schema: &SchemaRef, _: &str| { + let rows = [("a", end - 1, 100.), ("a", end, 1.), ("b", end, 20.)] + .into_iter() + .map(|(instance, at, value)| { + promql_rows::series_row( + schema, + &BTreeMap::from([ + ("job".into(), "api".into()), + ("instance".into(), instance.into()), + ]), + at, + value, + ) + .unwrap() + }) + .collect(); + Batch::try_new(schema.clone(), rows).unwrap() + }; + let ingestion = Scope::Ingestion { + window_start_ms: end - lookback, + window_end_ms: end, + revision: 1, + }; + let (values, frontier) = execute(&dag, &batch, ingestion, end); + assert_eq!(frontier, states(&dag)); + answers.push(values); + } + assert_eq!(answers[0], answers[1]); + assert_eq!(answers[0], [20.]); +} + +/// Whether the DAG has an ingestion-time `BinaryOp`. +fn ingestion_binary(dag: &PhysicalASAPDAG) -> bool { + dag.nodes.iter().any(|node| { + matches!( + node.payload, + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::BinaryOp { .. }) + ) && node.output_state.timing == ExecutionTiming::IngestionTime + }) +} + +const INGESTION_WINDOW: Scope = Scope::Ingestion { + window_start_ms: 240_000, + window_end_ms: 300_000, + revision: 1, +}; + +/// Prometheus drops series without a match: arithmetic over different +/// selectors keeps only label sets present on both sides (none when +/// disjoint), at either placement, and never becomes aligned maintenance. +#[test] +fn arithmetic_over_different_selectors_matches_prometheus() { + // The selected root reads the outer `sum`'s exact state through its + // finalize. + let root = selected("sum(sum_over_time(m[1m]) + sum_over_time(n[1m]))", true); + assert!(matches!( + root.asap(), + Some(ASAPOp::FinalizeExactAccumulator { .. }) + )); + let disjoint = [ + ("m", "a", 250, 1.0), + ("m", "a", 290, 2.0), + ("n", "b", 250, 5.0), + ]; + // Only job a is on both sides: m_a + n_a = (1 + 2) + 7; m{job="b"} is dropped. + let overlapping = [ + ("m", "a", 250, 1.0), + ("m", "a", 290, 2.0), + ("m", "b", 250, 5.0), + ("n", "a", 250, 7.0), + ]; + for assignment in placements() { + let dag = timed_dag(&root, &assignment); + assert!(!ingestion_binary(&dag)); + let (values, _) = execute(&dag, &job_samples(&disjoint), INGESTION_WINDOW, 300_000); + assert!(values.is_empty(), "{values:?}"); + let (values, _) = execute(&dag, &job_samples(&overlapping), INGESTION_WINDOW, 300_000); + assert_eq!(values, [10.0]); + } + let root = selected( + "quantile(0.9, sum_over_time(m[1m]) + sum_over_time(n[1m]))", + true, + ); + assert!(!ingestion_binary(&timed_dag(&root, &placements()[1]))); +} + +/// Arithmetic over one selector is aligned maintenance when stored at +/// ingestion time, and adds each series' two evaluations before the +/// quantile at either placement. +#[test] +fn arithmetic_over_one_selector_executes() { + let root = selected( + "quantile(0.9, sum_over_time(m[1m]) + sum_over_time(m[1m]))", + true, + ); + let samples = [ + ("m", "a", 250, 1.0), + ("m", "a", 290, 2.0), + ("m", "b", 250, 5.0), + ]; + let [not_materialized, at_ingestion] = placements().map(|a| timed_dag(&root, &a)); + assert!(!ingestion_binary(¬_materialized)); + assert!( + ingestion_binary(&at_ingestion), + "one selector shares its key set" + ); + for dag in [not_materialized, at_ingestion] { + let (values, _) = execute(&dag, &job_samples(&samples), INGESTION_WINDOW, 300_000); + // job a: 3 + 3 = 6; job b: 5 + 5 = 10 (mispairing a with b gives 8 and 8). + // KLL at epsilon 0.01 returns an input value within 0.01 of rank 0.9; + // of two values only the larger is. + assert_eq!(values, [10.0]); + } +} From 0b4173dd49765d51b7961b44cc95cbed846341d9 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:05:06 +0000 Subject: [PATCH 8/8] fix(ir): honor explicit materialization on fixed-timing nodes; allow stored roots A node whose timing its candidate fixed keeps an agreeing explicit assignment and rejects a conflicting one (MaterializationConflictsWithFixedTiming) instead of dropping it. A root assigned MatAtIngestion is valid; only an ingestion-time root of rows that is not stored there errors (UnstoredIngestionRoot, was MaintenanceRowsAtRoot). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW --- crates/types/src/ir/timing.rs | 122 +++++++++++++++++- .../src/post_asap/execution_data_state.rs | 26 +++- docs/design_docs/concepts/post-asap-ir.md | 10 ++ 3 files changed, 151 insertions(+), 7 deletions(-) diff --git a/crates/types/src/ir/timing.rs b/crates/types/src/ir/timing.rs index fd0bb4acf..9d09c172c 100644 --- a/crates/types/src/ir/timing.rs +++ b/crates/types/src/ir/timing.rs @@ -24,6 +24,15 @@ //! everything that feeds an ingestion-time node runs at ingestion time, //! everything above an evaluation at query time. //! +//! A node whose timing a candidate builder fixed (e.g. the query-time +//! `FinalizeExactAccumulator` of `finalize_query_candidate`) keeps that +//! timing. An explicit assignment on it that agrees is kept; one that needs +//! the other timing is an error +//! ([`ExecutionDataStateError::MaterializationConflictsWithFixedTiming`]). +//! A root may be `MatAtIngestion`: the query reads its stored output. A root +//! of rows that runs at ingestion time without being stored there is an +//! error ([`ExecutionDataStateError::UnstoredIngestionRoot`]). +//! //! A node reached from two consumers that need different timings cannot be //! executed once for both; [`split_shared_by_phase`] copies such a sub-DAG //! for one side before the assignment is applied, and the pass itself @@ -226,10 +235,15 @@ pub fn apply_materialization_timings( memo, &mut forced, )?; + // A root stored at ingestion time is read by the query; one that runs at + // ingestion time without being stored there produces rows nothing reads. if timed.timing == Some(ExecutionTiming::IngestionTime) + && !matches!(timed.materialization, Materialization::MatAtIngestion(_)) && data_state(&timed).map(|s| s.primitive) == Some(DataPrimitive::Raw) { - return Err(ExecutionDataStateError::MaintenanceRowsAtRoot); + return Err(ExecutionDataStateError::UnstoredIngestionRoot { + operator: timed.operator.kind_name(), + }); } validate(&timed, &mut HashMap::new())?; Ok(timed) @@ -300,7 +314,20 @@ fn placement( operator: node.operator.kind_name(), }); } - return Ok((placed, node.materialization)); + // An explicit assignment is never silently dropped: one that agrees + // with the fixed timing is kept, one that needs the other timing is + // an error. + return match assignment.get(node) { + Some(m) if m != Materialization::NotMaterialized && m.timing() != placed => Err( + ExecutionDataStateError::MaterializationConflictsWithFixedTiming { + operator: node.operator.kind_name(), + fixed: placed, + materialization: m, + }, + ), + Some(m) => Ok((placed, m)), + None => Ok((placed, node.materialization)), + }; } Ok(match &node.operator { Operator::ASAP(ASAPOp::SummaryEstimate { .. }) @@ -1046,7 +1073,9 @@ mod tests { let operation = placed_at_ingestion(max(scan())); assert_eq!( apply(&operation).err(), - Some(ExecutionDataStateError::MaintenanceRowsAtRoot) + Some(ExecutionDataStateError::UnstoredIngestionRoot { + operator: "Aggregate" + }) ); let root = apply(&estimate(agg(operation, kll()))).unwrap(); let timed_operation = child(&child(&root)); @@ -1174,6 +1203,93 @@ mod tests { ); } + fn finalize(child: Rc) -> Rc { + OperatorNode::new_shared(crate::ir::Operator::ASAP( + ASAPOp::FinalizeExactAccumulator { child }, + )) + .unwrap() + } + + /// The materialization the exported DAG records for `node`'s operator kind. + fn exported(root: &Rc, kind: &str) -> Materialization { + let dag = crate::ir::physical_export::compile_physical_asap_dag(root).unwrap(); + dag.validate().unwrap(); + dag.nodes + .iter() + .find(|n| n.payload.kind_name() == kind) + .unwrap() + .materialization + } + + /// A node whose timing its candidate fixed (the query-time read boundary + /// of `finalize_query_candidate`) keeps an explicit assignment that agrees + /// with that timing and rejects one that needs the other timing. + #[test] + fn fixed_timing_keeps_an_agreeing_assignment_and_rejects_a_conflict() { + let state = agg(scan(), exact_sum()); + let read = Rc::new( + (*finalize(Rc::clone(&state))) + .clone() + .with_timing(Some(ExecutionTiming::QueryTime)), + ); + let stored = Materialization::MatAtQuery(Medium::Disk); + let mut assignment = MaterializationAssignment::default(); + assignment.set(&read, stored); + let timed = + apply_materialization_timings(&read, &assignment, &mut TimingMemo::new()).unwrap(); + assert_eq!(timed.timing, Some(ExecutionTiming::QueryTime)); + assert_eq!(timed.materialization, stored); + assert_eq!(exported(&timed, "FinalizeExactAccumulator"), stored); + + let mut assignment = MaterializationAssignment::default(); + assignment.set(&state, AT_INGESTION); + assignment.set(&read, AT_INGESTION); + assert_eq!( + apply_materialization_timings(&read, &assignment, &mut TimingMemo::new()).err(), + Some( + ExecutionDataStateError::MaterializationConflictsWithFixedTiming { + operator: "FinalizeExactAccumulator", + fixed: ExecutionTiming::QueryTime, + materialization: AT_INGESTION, + } + ) + ); + } + + /// A root stored at ingestion time is valid: the query reads the stored + /// output. A root that runs at ingestion time without being stored there + /// is not. + #[test] + fn a_root_may_be_stored_at_ingestion_time() { + let root = max(scan()); + let mut assignment = MaterializationAssignment::default(); + assignment.set(&root, AT_INGESTION); + let timed = + apply_materialization_timings(&root, &assignment, &mut TimingMemo::new()).unwrap(); + assert_eq!(timed.timing, Some(ExecutionTiming::IngestionTime)); + assert_eq!(exported(&timed, "Aggregate"), AT_INGESTION); + + let state = agg(scan(), exact_sum()); + let root = finalize(Rc::clone(&state)); + let mut assignment = MaterializationAssignment::default(); + assignment.set(&state, AT_INGESTION); + assignment.set(&root, AT_INGESTION); + let timed = + apply_materialization_timings(&root, &assignment, &mut TimingMemo::new()).unwrap(); + assert_eq!(timed.timing, Some(ExecutionTiming::IngestionTime)); + assert_eq!(exported(&timed, "FinalizeExactAccumulator"), AT_INGESTION); + + let mut assignment = MaterializationAssignment::default(); + let root = placed_at_ingestion(max(scan())); + assignment.set(&root, Materialization::NotMaterialized); + assert_eq!( + apply_materialization_timings(&root, &assignment, &mut TimingMemo::new()).err(), + Some(ExecutionDataStateError::UnstoredIngestionRoot { + operator: "Aggregate" + }) + ); + } + /// Both paired operands must be plain; an unrelated state column is not /// an input. #[test] diff --git a/crates/types/src/post_asap/execution_data_state.rs b/crates/types/src/post_asap/execution_data_state.rs index d91523823..7e73c9752 100644 --- a/crates/types/src/post_asap/execution_data_state.rs +++ b/crates/types/src/post_asap/execution_data_state.rs @@ -154,10 +154,28 @@ pub enum ExecutionDataStateError { first: ExecutionDataState, second: ExecutionDataState, }, - /// A maintenance-time value operation at the root of a plan: - /// nothing maintains state above it, so its output is never read. - #[error("A maintenance-time value operation cannot be a plan root: its update-path output feeds nothing")] - MaintenanceRowsAtRoot, + /// A plan root that runs at ingestion time without being stored there + /// (a timing fixed by a candidate builder): no query reads its rows. A + /// root assigned `MatAtIngestion` is valid; the query reads the stored + /// output. + #[error( + "{operator} root runs at ingestion time but is not MatAtIngestion: no query reads its \ + output" + )] + UnstoredIngestionRoot { operator: &'static str }, + /// An explicit materialization on a node whose timing a candidate builder + /// fixed (e.g. a query-time `FinalizeExactAccumulator` read boundary) + /// that needs the other timing. + #[error( + "{operator} is fixed at {} by its candidate; materialization {materialization:?} needs \ + the other timing", + fixed.as_str() + )] + MaterializationConflictsWithFixedTiming { + operator: &'static str, + fixed: ExecutionTiming, + materialization: Materialization, + }, #[error("unsupported maintenance binary schema or operator")] InvalidMaintenanceBinary, #[error("checked division requires one valid guard on a read-time division operator")] diff --git a/docs/design_docs/concepts/post-asap-ir.md b/docs/design_docs/concepts/post-asap-ir.md index 636604405..d073a1499 100644 --- a/docs/design_docs/concepts/post-asap-ir.md +++ b/docs/design_docs/concepts/post-asap-ir.md @@ -134,6 +134,16 @@ timing and a materialization into every node, top-down: an evaluation at query time. A query-time consumer reads a `MatAtIngestion` node's stored output. +A node whose timing a candidate builder fixed (the query-time +`FinalizeExactAccumulator` read boundary of `finalize_query_candidate`) keeps +it: an explicit assignment that agrees with that timing is kept, and one that +needs the other timing fails with +`ExecutionDataStateError::MaterializationConflictsWithFixedTiming`; an +explicit `MatAtIngestion` is never silently moved or dropped. A root may be +`MatAtIngestion`, and the query reads its stored output; a root of rows that +runs at ingestion time without being stored there fails with +`ExecutionDataStateError::UnstoredIngestionRoot`. + The pass then validates every edge (rows or exact-accumulator state into a `SummaryAgg`, state into an evaluation, a timed `MaintainPopulation` under an `EvaluatePopulation`, no ingestion work reading a query-time value) and rejects a