Skip to content

feat(plan-selection): one ingestion pipeline per refresh window in the selection MILP (#1095 §2.5 O3(b)) - #1157

Merged
zzylol merged 7 commits into
feat/1095-group-milpfrom
feat/1095-ingestion-per-refresh-window
Oct 31, 2026
Merged

zzylol merged 7 commits into
feat/1095-group-milpfrom
feat/1095-ingestion-per-refresh-window

Conversation

@zzylol

@zzylol zzylol commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1095

Why

#1151 (merged into feat/1095-stage3 as #1152–#1154) changed the cost rule for stateless ingestion work. The deployment refreshes all ingestion-time views of one refresh window in one run. So asap_cost::execution_demand::computations now charges a NotMaterialized ingestion-time node once per distinct refresh window of its MatAtIngestion consumers. A consumer with an unbounded window counts alone. #1095 §2.5 O3(b) was rewritten to match: one ingestion pipeline I_r per refresh window r.

The prototype (#1144) still had one ingestion context I. To keep its objective equal to plan_cost, it also had a hook in computations that charged such a node (1, 1.0), whoever read it. That hook under-charges a scan read by chains of different pane widths (§8 G5). This PR adopts I_r and removes the hook.

What

How

r(m) must be computed exactly as asap-cost computes it on the realized DAG. So groups::refresh_windows builds a nodes-only DAG of the candidates below each ingestion-time shareable candidate, reading each input from its first producer candidate, as catalog_node does. It then calls the same function, PhysicalASAPDAG::refresh_windows. For that to work on a DAG without edges, PhysicalASAPDAG::view_windows now walks each operator's inputs (payload.children()) instead of the edge list. validate requires these to be identical in a valid DAG, so its results on real DAGs do not change.

execution_contexts puts a candidate of I_r into the seeds of I_{r(m)} only when the context of an ingestion-time shareable m reads it. Stage 2's compatible_timing lets only ingestion-time candidates read a NotMaterialized ingestion-time candidate. Each I_r is then expanded through the candidates it computes, as I was. Contexts are ordered: queries, then the I_r (by IngestionPipeline), then the shareable contexts. MilpInputs::ingestion_context_of_shareable records m → I_{r(m)}.

Key code interfaces added / modified

crates/plan-selection/src/groups.rs

/// Which execution context (#1095 §2.0). Stays.
pub(crate) enum ContextKind {
    /// A query evaluation. // unchanged
    Query(usize),
    /// An ingestion pipeline `I_r`. // was `Ingestion` (one `I`)
    Ingestion(IngestionPipeline),
    /// A shareable candidate's own inputs. // unchanged
    Shared(PhysicalCandidateId),
}

/// Which ingestion pipeline `I_r` (§2.5 O3(b)). New.
pub(crate) enum IngestionPipeline {
    /// The ingestion-time views of this refresh window. // new
    Window(RefreshWindow),
    /// An ingestion-time shareable candidate whose window is unbounded or empty: refreshed alone. // new
    Alone(PhysicalCandidateId),
}

/// The pipeline of ingestion-time shareable `shareable`, from its refresh window. // new
pub(crate) fn ingestion_pipeline(
    shareable: PhysicalCandidateId,
    refresh_windows: &HashMap<PhysicalCandidateId, Option<RefreshWindow>>,
) -> IngestionPipeline;

/// What the MILP is built from. Stays; fields not listed are unchanged.
pub(crate) struct MilpInputs {
    /// Queries, then the `I_r` some context reads, then the shareable contexts. // was: queries, `I` at index Q, shareable
    pub(crate) contexts: Vec<ExecutionContext>,
    /// Per shareable candidate with a context, its index in `contexts`. // unchanged
    pub(crate) context_of_shareable: BTreeMap<PhysicalCandidateId, usize>,
    /// Per ingestion-time shareable `m` whose context reads a candidate of an `I_r`: the index of `I_{r(m)}`. // new
    pub(crate) ingestion_context_of_shareable: BTreeMap<PhysicalCandidateId, usize>,
    /* every other field unchanged and not shown */
}

impl MilpInputs {
    /// Where `context` reads a candidate of an `I_r`: itself for an `I_r`, `I_{r(m)}` for ingestion-time `m`'s context, else `None`. // new
    pub(crate) fn ingestion_context_read_by(&self, context: usize) -> Option<usize>;
}

/// `execution_contexts`' result. New.
pub(crate) struct ExecutionContexts {
    /// See `MilpInputs::contexts`. // new
    pub(crate) contexts: Vec<ExecutionContext>,
    /// See `MilpInputs::context_of_shareable`. // new
    pub(crate) context_of_shareable: BTreeMap<PhysicalCandidateId, usize>,
    /// See `MilpInputs::ingestion_context_of_shareable`. // new
    pub(crate) ingestion_context_of_shareable: BTreeMap<PhysicalCandidateId, usize>,
}

/// The execution contexts and their options. // was: returned `(Vec<ExecutionContext>, BTreeMap<..>)`, no `refresh_windows`
pub(crate) fn execution_contexts(
    physical: &PhysicalASAPMemoCandidates,
    kind: &[RunKind],
    producer_candidates_per_input: &[Vec<Vec<PhysicalCandidateId>>],
    output_candidates: &[Vec<PhysicalCandidateId>],
    refresh_windows: &HashMap<PhysicalCandidateId, Option<RefreshWindow>>, // new
    usable: impl Fn(PhysicalCandidateId) -> bool,
) -> ExecutionContexts;

crates/plan-selection/src/milp/model.rs

impl SelectionMilp<'_> {
    /// The index of `I` among the contexts. // removed (there is no single `I`)
    pub(crate) fn ingestion_context(&self) -> usize;
    /// `MilpInputs::ingestion_context_read_by`. // new
    pub(crate) fn ingestion_context_read_by(&self, context: usize) -> Option<usize>;
}

crates/types/src/ir/physical_export.rs

impl PhysicalASAPDAG {
    /// Each node's view window. It now walks each operator's inputs (a valid DAG's edges), so it also answers for a DAG of nodes alone. // signature unchanged
    pub fn view_windows(
        &self,
        nodes: impl IntoIterator<Item = PhysicalASAPNodeId>,
    ) -> HashMap<PhysicalASAPNodeId, ScanWindow>;
}

crates/cost/src/execution_demand.rs: no signature change. The hook None if n.output_state.timing == ExecutionTiming::IngestionTime && count.contains_key(&n.id) => (1, 1.0) is removed (merge resolution).

Usage (end to end, as the test ingestion_scans_under_chains_of_two_refresh_windows_are_charged_twice runs it):

// Two approximate quantile queries over metric `m` (100 series, 100 rows/s arriving):
// one every 10 s over 10 s, one every 60 s over 5 m.
let queries = [
    approximate("quantile_over_time(0.5, m[10s])", 10_000, 0.01),
    approximate("quantile_over_time(0.9, m[5m])", 60_000, 0.05),
];
// Stages 1 and 2 as the facade runs them; Stage 2 offers ingestion-time pane chains of many widths.
let fixture = through_stages_with(&queries, true, true);
// Stage 3: prepare_milp_inputs computes r(m) per ingestion-time chain and one I_r per window;
// the test keeps only the 10 s and 60 s chains and forbids query-time scans.
// solve_and_check solves, decodes, realizes through Stage 2 and asserts objective == plan_cost.
let (checked, scans) = ingestion_scans_under_refresh_windows(&queries, &[10_000, 60_000]);
// One ingestion scan per refresh window, each read only by chains of that window:
assert_eq!(scans, vec![BTreeSet::from([10_000]), BTreeSet::from([60_000])]);

With ASAP_STAGE3_SEARCH=milp, stage_pipeline reaches the same code. Its ASAP_STAGE3_REPORT lines report the model size; the Ingestion scope now holds every I_r.

Major algorithm changes

Ingestion contexts per refresh window (§2.5 O3(b), §3 rows 2–4, §3.5)

  • Problem. With one I, a scan read by a 1 s chain and a 30 s chain ran once in I, and the MILP charged it once. Under feat: charge a shared ingestion node once per refresh window (#1151) #1154, plan_cost charges it once per refresh window. Before this PR, the hook in computations hid the difference by under-charging the plan. On random-mixed-seed-862 workload-01, the old selected plan read one ingestion scan from chains of 1 s, 20 s and 30 s panes (node 51, 8.32e-9 per second). It was charged once instead of three times.
  • Before. 1. execution_contexts seeds one I with every ingestion-time candidate read across a boundary. 2. Row 3: p[ctx,c] ≤ p[I,c]. 3. Row 4 for I: read by any other context. 4. computations charged each such node (1, 1.0).
  • After. 1. refresh_windows computes r(m) per ingestion-time shareable candidate with PhysicalASAPDAG::refresh_windows. 2. execution_contexts seeds I_{r(m)} from m's context only (Alone(m) for an unbounded window). 3. Row 3: p[m,c] ≤ p[I_{r(m)},c]. 4. Row 4: a candidate of I_r is read by the contexts of the m with r(m) = r. 5. Each I_r run is charged at rate 1. computations applies feat: charge a shared ingestion node once per refresh window (#1151) #1154's rule unchanged.
  • Correctness. A node of I_r in the decoded DAG is read only by the m with r(m) = r, so computations counts it once. The same candidate selected in two I_r is two nodes, each counted once. r(m) comes from the same function computations calls. This holds as long as the candidates of one memo group read one window (see limits). The per-solve check (objective = plan_cost) passes on every test and on all seven §6.1 workloads.
  • Complexity. Contexts grow from Q + 1 + |M| to Q + |distinct r| + |M|. A candidate read by chains of k windows gets up to k p columns instead of 1. Measured: +0 to +4 % variables (workload-01's largest round: 39,907 → 40,251). refresh_windows is linear in the candidates below the ingestion-time states, built once per prepare_milp_inputs_with.
  • Assumptions and limits. r(m) is computed with each input read from its first producer candidate. That is exact when every producer candidate of an input reads one view window. refresh_windows debug-asserts this (producer_window_disagreements), because the per-solve equality check alone would miss a mismatch in a model with inexact terms (it reports InexactObjective). Tests check it on hand-built and real Stage 1–2 fixtures; no counterexample is known (a widened time range would be one). Only ingestion-time shareable contexts read a candidate of an I_r. If some other context read one, it would have no source and row 3 would force the read to 0 (Stage 2 never links one).

Before this PR

The prototype modeled one ingestion pipeline. It relied on a hook in asap-cost that charged stateless ingestion work once whoever read it, so plans sharing a scan across chains of different pane widths were under-priced (§8 G5).

After this PR

The MILP and plan_cost both use #1151's rule (one run per refresh window), and the hook is gone. A scan shared by chains of one refresh window is charged once; chains of k windows pay it k times. The solver now sees that cost and chooses between sharing and separate scans with it.

Verification

  • New tests in milp/model/tests.rs, each with the per-solve objective = plan_cost check:
    • one_ingestion_scan_under_chains_of_one_refresh_window_is_charged_once: two 10 s chains → one scan;
    • ingestion_scans_under_chains_of_two_refresh_windows_are_charged_twice: 10 s + 60 s chains → two scans, one per window.
    • Checked by mutation: with every window mapped to one pipeline, the second test fails with MILP objective 1.1670e-1 != plan_cost 1.1696e-1.
  • Review follow-ups (222fbd3):
    • execution_contexts_have_one_ingestion_pipeline_per_refresh_window (hand-built): two views of (10 s, 0) share one I_r; (60 s, 0) and (10 s, 5 s) get their own (the shifted one goes through a NotMaterialized shift and range); an unbounded view gets Alone.
    • producers_of_an_input_read_one_view_window (hand-built) and producers_of_an_input_read_one_view_window_through_stages (three Stage 1–2 fixtures).
    • asap-types a_view_has_its_refresh_window_on_a_dag_without_edges: a non-chain KLL over TimeRange(1 min) shifted 15 s has window (60 s, 15 s) with and without edges. It fails when the view_windows change is reverted.
    • Behavior is unchanged: row 4 now uses an inverse map I_r → contexts, the debug-assert is debug-only, and the label/wording changes are cosmetic. So no re-measurement.
  • cargo test -p asap-plan-selection --lib: 322 passed. cargo test -p asap-cost, cargo test -p asap-types: pass. cargo test -p asap-devtools --test devtools stage_pipeline: 51 passed.
  • cargo fmt --all; cargo clippy -p asap-types -p asap-cost -p asap-plan-selection --all-targets -- -D warnings and cargo clippy -p asap-plan-selection --all-targets --features lp-diagnosis -- -D warnings: clean.
  • §6.1 re-measure (stage_pipeline, ASAP_STAGE3_SEARCH=milp, aws-fargate cost model, accuracy enforced, 300 s limit). Before = feat(plan-selection): selection MILP solved per independent part, LP gap diagnosis, tighter big-M bounds (#1095 §5 3a, §6.1) #1155 head; after = this PR:
Workload Vars / binaries (before) Build Solve Status, gap Cost per second plan_cost = objective Wall Peak RSS
example1 525 / 347 (501) 0.07 → 0.07 s 0.03 → 0.03 s optimal → optimal 4.3770e+00 → 4.3770e+00 yes 2 → 2 s 54 → 53 MB
rangeheavy-t5-q05-q09 7,041 / 3,967 (6,982) 0.15 → 0.15 s 1.63 → 1.61 s optimal → optimal 3.9901e-06 → 3.9901e-06 yes 38 → 38 s 119 → 116 MB
rangeheavy-t7-t4 4,667 / 2,804 (4,490) 0.03 → 0.03 s 2.49 → 2.05 s optimal → optimal 2.1493e-07 → 2.1493e-07 yes 29 → 29 s 105 → 106 MB
rangeheavy-t8-t5 4,013 / 1,749 0.03 → 0.03 s 1.46 → 1.47 s optimal → optimal 6.4822e-06 → 6.4822e-06 yes 330 → 323 s 301 → 298 MB
  largest round (5×) 114,284 / 50,660 44.3 → 43.0 s 0.54 → 0.52 s deadline, no dual bound (both) incumbent 2.0661e-05 → 2.0661e-05
ratio-of-quantiles 52 / 29 0.00 → 0.00 s 0.05 → 0.05 s optimal → optimal 2.4071e-06 → 2.4071e-06 yes 1 → 1 s 35 → 35 MB
rollupheavy-t11 8,847 / 5,049 (8,788) 0.17 → 0.17 s 3.69 → 3.64 s optimal → optimal 1.4159e-06 → 1.4159e-06 yes 62 → 63 s 145 → 148 MB
workload-01 (selected round) 3,866 / 1,291 (39,907) 0.25 → 0.03 s 191.51 → 4.79 s optimal → deadline, 5.8 % 1.4171e-06 → 1.4402e-06 yes 292 → 300 s 896 → 892 MB
  largest round 40,251 / 13,491 (39,907) 0.2 → 0.3 s 191.51 → 200.44 s optimal → deadline, 41.2 % incumbent 1.4171e-06 → 2.2492e-06

Six workloads select the same plan at the same cost. Four of them have small variable increases from the extra I_r contexts. Example1, ratio-of-quantiles and rollupheavy-t11 have no ingestion-time node in the plan; t5-q05-q09, t7-t4 and t8-t5 have one chain over its own scan.

workload-01 (random-mixed-seed-862) changes by +2.31e-8 per second (+1.6 %). Two causes:

  1. The old plan was under-charged by 1.66e-8. Its ingestion scan (node 51, 8.32e-9 per second) was read by four chains of three refresh windows: Sum at 1 s panes, Increase at 20 s, and Sum and Rate at 30 s. The hook charged it once; feat: charge a shared ingestion node once per refresh window (#1151) #1154 charges it three times (+2 × 8.32e-9). So under the current rule, the old plan costs ≈ 1.4337e-06.
  2. The largest round no longer solves in time. Before, it was optimal just under the deadline (191.5 s). After, it has more variables and stops at a 41.2 % gap, so the selected plan is round 1's incumbent (5.8 % gap). That plan uses one scan per chain: four scans for the 1 s, 20 s and two 30 s chains (the 30 s Rate chain reads a cheaper scan of its own). It is 6.4e-9 above the old plan's corrected cost. With a 600 s limit, the largest round still ends at a 38.9 % gap, and the selection is the same 1.4402e-06.

The selected plan's objective equals plan_cost (checked per solve). Closing workload-01's gap belongs with the §5 steps (3b/3c).

🤖 Generated with Claude Code

…un (#1151) (#1152)

The deployment contract refreshes the ingestion-time views of one refresh window in one run per window end; the run's roots are the group's views whose state for that window is not yet stored. Part of #1151.
…un (#1151) (#1153)

RefreshWindow, PhysicalASAPDAG::refresh_window and ingestion_refresh_groups derive which ingestion-time views share one run; the install compile check and the stand-in runtimes refresh per group, and a counting-store test shows one raw read per window per group. No cost change. Part of #1151.
…1154)

asap_cost::execution_demand::computations counts a NotMaterialized ingestion-time node once per refresh window of its MatAtIngestion consumers instead of once per consumer, and the old Stage 3 engine (general_schedule, DirectCostProfile::selected_cost, direct_cost::encode) charges it the same way. Snapshots unchanged. Closes #1151 except the backend follow-up.
…resh window)

Conflict in crates/cost/src/execution_demand.rs: keep #1154's rule (a
NotMaterialized ingestion-time node counts once per refresh window of its
MatAtIngestion consumers) and drop the prototype's hook that charged such a
node (1, 1.0), which under-charged chains of different refresh windows.
…e selection MILP (#1095 §2.5 O3(b))

The ingestion context I becomes one I_r per refresh window r of the
ingestion-time shareable candidates (ContextKind::Ingestion(IngestionPipeline));
an ingestion-time candidate whose window is unbounded gets its own pipeline.
r(m) is PhysicalASAPDAG::refresh_windows on the DAG of the candidates below m,
the function asap-cost groups ingestion work by, so the objective keeps
equalling plan_cost. Row 3: p[m,g,c] <= p[I_r(m),g,c]; row 4: a candidate of
I_r is read by the contexts of the m with r(m) = r; the stateless ingestion
work is charged once per I_r.

view_windows walks each operator's inputs (a valid DAG's edges) so it also
answers for a DAG of nodes alone.
@zzylol zzylol added cost-model Cost formulas, statistics, ranking, and selection enhancement New feature or request optimizer Workload optimization and plan selection labels Oct 31, 2026
…fresh window (#1095)

- Debug-assert that every producer candidate of an input reads one view
  window when computing refresh windows (the per-solve equality check
  does not catch a mismatch when the model is inexact); probe test on
  hand-built and Stage 1-2 fixtures.
- asap-types test: a non-chain view's refresh window on a DAG without edges.
- Test execution_contexts with two windows, a shifted window through
  NotMaterialized nodes, and an unbounded (Alone) view.
- Row 4 reads an inverse map I_r -> contexts of its views, built once.
- I_r wording in inexact reasons and test comments; compact LP-diagnosis
  labels (I[10000ms@0], I[alone m42]).
@zzylol
zzylol merged commit b6eb71d into feat/1095-group-milp Oct 31, 2026
2 checks passed
zzylol added a commit that referenced this pull request Nov 2, 2026
… selection MILP (#1095) (#1157)

Brings feat/1095-stage3 (#1151 refresh groups and cost rule) into the prototype and drops its once-per-second ingestion hook. The MILP has one ingestion context I_r per refresh window (unbounded views alone), so a shared stateless ingestion node is charged once per refresh window, as asap-cost charges it. workload-01 costs 1.6% more: its old plan under-charged a scan read by chains of three refresh windows. Part of #1095.
zzylol added a commit that referenced this pull request Nov 2, 2026
… selection MILP (#1095) (#1157)

Brings feat/1095-stage3 (#1151 refresh groups and cost rule) into the prototype and drops its once-per-second ingestion hook. The MILP has one ingestion context I_r per refresh window (unbounded views alone), so a shared stateless ingestion node is charged once per refresh window, as asap-cost charges it. workload-01 costs 1.6% more: its old plan under-charged a scan read by chains of three refresh windows. Part of #1095.
zzylol added a commit that referenced this pull request Nov 2, 2026
… Stage 3's default search (#1095) (#1144)

* feat(plan-selection): group selection MILP over execution contexts behind ASAP_STAGE3_SEARCH=milp (#1095)

Step 1 of #1095's solving strategy: one MILP with a warm start. The
owner-model pieces from tag stage3-owner-milp-redesign are restored and
the owner/instance model is replaced by execution contexts: each query
evaluation, the ingestion pipeline I (NotMaterialized ingestion-time
candidates) and each shareable (materialized) candidate's own inputs.

- groups.rs (was owners.rs): RunKind, ResultSlot (group roots split by
  self-read depth; private nodes their own slot), execution contexts and
  their options; dominance, per-candidate usage and accuracy weights kept.
  Scan windows are costed at every union of the window kinds read within
  one context, as plan_cost prices them.
- milp/model.rs: p[ctx, c], x[m], a[q, m], rows 1-9, slot links for
  inputs over several result slots, order ranks within and across
  contexts, latency in scaled units.
- milp/cost_terms.rs: run terms (plain, producer-dependent, exact scan
  window sets), schedule-group rates, held memory, raw retention and
  pane retention levels.
- milp/warm_start.rs: each query's cheapest plan, then greedy sharing,
  costed exactly by the completion.
- milp/decode.rs: one node per (context, computed candidate) and per
  selected shareable candidate.
- asap-cost: a NotMaterialized ingestion-time node runs once per second
  for every consumer; a NotMaterialized node a materialized node computes
  is held until that node's slowest reader finishes.

The old engine stays the default.

* fix(plan-selection): a shareable candidate reads a scan at its own window; window-independent scans keep their usage per window (#1095)

* fix(plan-selection): adopt main's Stage 1 exports and cost_inputs name after rebasing onto the Stages 0–2 milestone (#1095)

* feat(plan-selection): selection MILP solved per independent part, LP gap diagnosis, tighter big-M bounds (#1095 §5 3a, §6.1) (#1155)

Each connected component of the selection MILP is solved alone and joined before the single decode, realization and plan_cost check. An lp-diagnosis feature (devtools only) attributes the LP gap by row and variable family. Two big-M bounds are tightened. No selected plan or cost changes.

* perf(plan-selection): keep accuracy builds across pair checks and queries (#1095 §8) (#1156)

Measured that candidates of one operator node never share an input context on the measured workloads (no same-timing pairs), so the §8 shared-input-context step has nothing to share. The 44 s build of rangeheavy-t8-t5 largest round was 96% accuracy checks; a candidate built outputs are now kept across pair checks and queries (except outputs built across a cycle of links), and duplicate exact stand-ins are skipped. Weights, excluded pairs, plans and costs unchanged; that round builds in 7.6 s.

* feat(plan-selection): one ingestion context per refresh window in the selection MILP (#1095) (#1157)

Brings feat/1095-stage3 (#1151 refresh groups and cost rule) into the prototype and drops its once-per-second ingestion hook. The MILP has one ingestion context I_r per refresh window (unbounded views alone), so a shared stateless ingestion node is charged once per refresh window, as asap-cost charges it. workload-01 costs 1.6% more: its old plan under-charged a scan read by chains of three refresh windows. Part of #1095.

* fix(plan-selection): use the tumbling-window names (#1163) in the group MILP after rebasing onto main

The group MILP commits were written before #1163 renamed PaneChain to
SummaryTumblingWindowChain and pane_width_ms / pane_count / newest_panes /
pane_retention_ms to their tumbling-window names; rename their uses, and
rustfmt the lines the rebase resolved.

* feat(plan-selection): the group MILP is Stage 3's default search (#1095 C6)

select_plan runs the group MILP unless ASAP_STAGE3_SEARCH=old. Inputs the
MILP does not price yet (cost models 2 and 3, or a measured operation cost
table without a frozen calibration factor) are an UnsupportedCostModel
error; there is no fallback to the old engine.

* fix(plan-selection): use the remaining tumbling-window names (#1163 part 2) in the group MILP

#1176 renamed the remaining pane helpers (pane_candidates, kept_pane_chains,
panes_of, retained_panes, the pane test helper, ...); rename their uses in
the group MILP's code and tests.
@zzylol
zzylol deleted the feat/1095-ingestion-per-refresh-window branch November 3, 2026 02:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cost-model Cost formulas, statistics, ranking, and selection enhancement New feature or request optimizer Workload optimization and plan selection

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant