Skip to content

feat(asap-tools): exact materialized-view arm for sketch-bench e2e queries - #845

Merged
milindsrivastava1997 merged 21 commits into
mainfrom
feat/e2e-sql-mv
Oct 14, 2026
Merged

milindsrivastava1997 merged 21 commits into
mainfrom
feat/e2e-sql-mv

Conversation

@zzylol

@zzylol zzylol commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Stacked on the sketch-bench SQL PR (base branch feat/e2e-sql-from-table).

Summary

Adds the baseline_mv arm: ClickHouse with materialized views and no sketches, only exact, mergeable states. Each view's bucket width is the query's repeat interval T.

  • One view per (table, value column, statistic, T), an AggregatingMergeTree keyed by bucket and the union of the groupings that read it, plus their child_of parents. Per-series and instant queries keep every label. Coarser groupings GROUP BY a subset and merge (roll-up). For example, template 16's whole CUBE reads one (region, service, endpoint) view.

  • States:

    Statistic State
    sum, count sumState, countState
    quantile quantilesExactInclusiveState over the view's levels
    distinct count uniqExactState
    instant vector argMaxState(value, ts)
    rate/increase per series: first/last value, min/max ts and count, fed into the same extrapolatedRate
    top-k exact per-key sums, then ORDER BY … LIMIT k
  • Windows: bucket b holds (b−T, b], and an edge flag keeps samples at exactly b apart. Classic's closed [E−W, E] merges the buckets in (E−W, E] plus bucket E−W's edge rows. Multigroup's left-open (E−W, E] merges just the buckets. A window that isn't a multiple of T raises. Instant queries need T = 1 s.

  • Per-arm init SQL, so the baseline arm never pays for the views: init.sql (tables only) and init_mv.sql (tables, then views; the views must exist before data arrives). Both drop old views first.

    • experiment_run_clickhouse.py now uses the run's first mode for ingest monitoring when baseline isn't in the run, so a baseline_mv-only run works.

Testing

  • On the same datasets as the SQL PR, every MV query matches its raw SQL in clickhouse-local over 392 eval points, classic and multigroup. Distinct counts, quantiles, per-series stats and top-k keys match bit-exactly; sums match within 1e-9 (the summation order differs).
  • Mutation checks: ignoring the edge rows, or using left-closed toStartOfInterval buckets, makes 84–132 subtests fail.
  • Real tables: every MV statement and init_mv.sql execute for classic (4 views), multigroup (3 views: http_user_id_cardinality_60s, http_latency_quantile_60s, flows_src_ip_cardinality_60s) and all-metrics8 (56 views).
  • New runner test in tests/test_experiment_run_clickhouse.py. black/flake8 clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae

milindsrivastava1997 and others added 15 commits October 12, 2026 13:34
…nerated2 workloads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e SQL configs

Adds a client_options.simulated_start_time so templated SQL can be evaluated
against bulk-loaded data with fixed timestamps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… configs

sketch_bench_workloads.py reads a sketch-bench table's workloads[0].e2e block
and writes, per table, the metric tables' init SQL, one SQL file per query
interval and a baseline experiment_run_clickhouse.py config whose query
groups repeat every interval.

Queries keep generate_workloads.py's Prometheus 2.53 semantics (range
vectors read (T - d, T], extrapolatedRate for rate/increase,
quantileExactInclusive) and add the classic template shapes: topk over
sum by, sum/count/quantile by over instant vectors (latest sample per series
in (T - 1s, T]) and rates, and the ratio of two quantile_over_time. Eval
time is {{ end_time }} floored to the interval. Unsupported shapes and
structured (non-PromQL) entries raise.

generate_workloads.extrapolated_rate now builds on rate_from_samples, which
the new module reuses; its SQL output is unchanged.

The test compares every generated statement in clickhouse-local against a
Python implementation of the Prometheus semantics at several eval times,
covering empty windows and every extrapolation branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
… SQL

Experiments run Prometheus 2.53.2 (installation/prometheus/install.sh),
whose range selectors and instant lookback are closed, [T - d, T]; left-open
windows arrived in 3.0. Aligned eval times put a sample on the left
boundary, so the SQL now reads ts >= T - d. The Python reference selects
[T - d, T] as well.

generate_workloads.py's generated2 SQL is unchanged; only the shared
rate_from_samples docstring no longer names a window shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
Structured e2e entries {table, value, capability, grouping, quantile,
window_secs, interval_secs} become pooled queries over a records table.
Cardinality is uniqExact of the value field per group, and quantile is
quantileExactInclusive over all of a group's samples. Both read the same
closed [T - W, T] window, with T floored to the interval as the PromQL
queries are. init.sql now creates records tables, with one Float64 column
per value field. Each table's structured queries are also written in
ASAPQuery's SQL frontend dialect (COUNT(DISTINCT col), QUANTILE(q, col),
BETWEEN DATEADD(s, -W, NOW()) AND NOW()) to asapquery.sql.

The clickhouse-local test adds a skewed records dataset in which endpoint is
a child of service. It checks template 16's CUBE over (region, service,
endpoint) and several cardinality groupings against a Python reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
The sketch-bench ClickHouse configs now run baseline_mv beside baseline:
exact mergeable states in materialized views, no sketches. There is one
AggregatingMergeTree view per (metric, value, statistic, interval T). Its
key is the T-second bucket plus the union of the queries' groupings and their
child_of parents (every label for per-series statistics). The states are
sumState, countState, quantilesExactInclusiveState over the queries' levels,
argMaxState per series for instant vectors, and per-series first/last
sample, min/max ts and count for rate/increase. Those feed the same
extrapolatedRate SQL.

Bucket b holds (b - T, b], and an `edge` flag keeps samples at exactly b
apart. A closed window [E - W, E] therefore merges the buckets in
(E - W, E] plus the edge rows of bucket E - W. A query whose window is not a
multiple of T raises, so instant vectors are only served at T = 1 s. The
init SQL creates the views after their tables and before any data arrives.

The test runs every MV query next to its raw SQL in clickhouse-local. Results
match exactly, except sums, which agree within 1e-9 relative.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
Multigroup (structured) queries involve no Prometheus, so they now read
ASAPQuery's left-open window (E - W, E] instead of the closed [E - W, E]
that classic PromQL keeps to match Prometheus 2.53. ASAPQuery's SQL
frontend and precompute windows are (start, end], so ClickHouse and
ASAPQuery compute over the same samples. Query gets a `left_open` flag that
structured entries set; the raw SQL uses `ts > E - W`.

The ASAPQuery-dialect file ends its window at the floored eval time instead
of NOW(). The frontend cannot floor a literal, so the client's
{{ end_datetime }} is moved back by {{ end_time % T }} inside DATEADD:

  ts BETWEEN DATEADD(s, -{{ end_time % T + W }}, '{{ end_datetime }}')
         AND DATEADD(s, -{{ end_time % T }}, '{{ end_datetime }}')

which the frontend parses to (start, duration) = (E - W, W).

The Python reference for multigroup now pools records in (t - w, t].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
Each sketch-bench table now gets one config per arm. The baseline config
(init.sql) creates only the tables. The baseline_mv config (init_mv.sql)
creates the tables and their views. Both files drop the views first, so
none left by an earlier baseline_mv run keeps feeding on the recreated
tables. Both configs list both arms' SQL files, so saved results label every
query by its baseline text.

experiment_run_clickhouse.py monitored ingest under the baseline mode's
client config and output dir. A baseline_mv-only run has neither, so ingest
now uses baseline when the run has it, and otherwise the run's first mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
A multigroup query's window is now ASAPQuery's (E - W, E], so its
baseline_mv SQL merges just the buckets in (E - W, E] and skips the edge
rows that hold a closed window's left boundary. Classic PromQL queries keep
the closed window and its edge rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
…se configs

The SQL floors {{ end_time }} to T, but the current query client sleeps T
after each repetition's queries finish, so its eval times drift by the
query latency and the arms answer different windows. Each query group now
sets `align_to_interval: true` and `query_time_offset: 10` (PR #841's
default). With #841's client, repetition k is evaluated at
floor((start - O) / T) * T + k * T and sent O seconds later.

The current client ignores `align_to_interval` (unknown client options
become unused attributes) and shifts its eval time back by the offset. The
module docstring records the drift until #841's client lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
…a query

convert() planned views for every query, and plan_views raises when a
window is not a multiple of the interval (e.g. an instant vector, which
reads [T - 1s, T], at T > 1 s). So such a table failed to convert even for
the baseline arm, which needs no views.

Now a table with such a query gets only the baseline arm: init.sql, the
raw SQL and the baseline config, plus a note on stderr naming the queries.
Dropping just those queries from the MV arm would shift its query indices
against the baseline SQL that labels both arms' results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
…w SQL

ClickHouse 26.10 turns `ts > <integer>` on a DateTime64 key column into
`ts in [<integer> + 1, +Inf)` for index pruning, so the left-open multigroup
SQL dropped the samples in the window's first second from the result (A6:
raw vs MV relative differences of 1e-6 to 2.6e-3 on 49 of 52 queries).
The raw SQL now compares ts with toDateTime64(<bound>, 3) on both ends, for
classic and multigroup queries.

A regression test runs a left-open and a closed query in clickhouse-local
over a table with 8-row granules and samples every 7 ms around the window
start. Without the cast it reproduces the loss (8435 of 8571 rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
…MV SQL

The views' `bucket` column is a DateTime (whole seconds), so integer bounds
already filter it exactly. They are now toDateTime(<bound>) anyway, to match
the column type, as the raw SQL's ts bounds now are.

The key-pruning regression test, merged from feat/e2e-sql-from-table, now
passes the views to load_script (this branch's signature). It also runs each
query's MV SQL over views with 1-row granules and checks raw and MV against
the expected counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
zzylol and others added 3 commits October 13, 2026 18:33
Brings in #837 (this branch's base, squash-merged) and #828. The one
conflict, generate_workloads.py (add/add), keeps this branch's version:
main's copy is #837's original commit, and this branch later moved
extrapolatedRate into rate_from_samples and closed its windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
sketch-bench's exporter now writes each structured e2e entry's ASAPQuery
SQL (`sql`, e.g. `... WHERE ts BETWEEN DATEADD(s, -300, NOW()) AND NOW()
...`), so it is the single ASAPQuery SQL renderer. asapquery.sql now takes
that SQL as written and only replaces NOW() with the floored eval time; this
module's own dialect renderer is gone. The ClickHouse raw SQL still comes
from the structured fields.

The frontend's DATEADD takes only NOW(), a literal or a CAST as its base, so
NOW() becomes a datetime literal floored in the client's Jinja template:
{{ end_datetime }} (UTC) with its seconds floored for T dividing 60, or its
minutes for 60 | T | 3600. Other intervals raise.

All 936 distinct structured queries in sketch-bench cd51be9's tables
(T = 60 s and 300 s), rendered through the client's QueryTemplate at three
end times, parse in ASAPQuery's SQL frontend to (start, duration) =
(floor(E) - W, W) and pass its matcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
Brings in origin/main (#837, #828) and the switch to the table's ASAPQuery
SQL. Conflicts resolved by keeping both sides: the module docstring keeps
this branch's per-arm outputs with the new asapquery.sql line; the tests
keep PlanViewsTest and FlooredEndDatetimeTest; experiment_run_clickhouse
and its test import both _ingest_mode and #828's prebuilt-output helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnJ7LocWxguh3q4QWkCYae
@milindsrivastava1997

Copy link
Copy Markdown
Contributor

Code review

Stacks on #844. Core MV logic checks out (closed and left-open windows, -State/-Merge pairs, child_of rollups), and test_mv_sql_matches_raw_sql is good coverage.

  1. Medium — sketch_bench_workloads.py ~L535 (init_sql): only views from the current table file are dropped. If workload A's baseline_mv run created data_value_quantile_60s, workload B on the same data table (different stats/intervals) drops and recreates data but never drops A's view, which likely keeps firing on inserts (untested) and skews B's insert cost. Drop all views sourcing the metric tables (via system.tables dependencies) or use a per-stem prefix.
  2. Medium-low — ~L584 (convert): when the mv arm is skipped (unservable query), stale init_mv.sql, interval_<T>s_baseline_mv.sql, <stem>_clickhouse_mv.yaml from an earlier run remain; a sweep over *_clickhouse*.yaml runs them with misaligned query indices. Delete them on skip; test_unservable_query_skips_mv_arm uses a fresh dir so can't catch this.
  3. Low — ~L425 (plan_views): name = parent[name] raises a bare KeyError if child_of names a label not in the metric — inputs that converted fine before now crash with no context. Validate and raise a ValueError naming table and label.
  4. Low — ~L577 vs ~L413: "window must be a multiple of interval" is checked in both convert and plan_views; a new unservable shape added to only one makes convert crash instead of skip. Share one mv_servable(q) predicate.
  5. Low — ~L443-510: view_states, mv_agg, raw_agg each branch on the same stat names; a per-stat table (state cols, -State, -Merge) would keep them in sync.
  6. Nit — docstring ~L29-31: uses T where it means eval time E (E - W, [E - 1s, E]).

Tests: missing a regression test that baseline init.sql drops existing views, and one for re-converting into a dir that already has mv files.

milindsrivastava1997 and others added 2 commits October 14, 2026 13:33
# Conflicts:
#	asap-tools/experiments/datasets/fake_metric/sketch_bench_workloads.py
#	asap-tools/experiments/tests/test_sketch_bench_workloads.py
- Keep every view in one database that each init drops, so views another
  workload left on the metric tables stop firing on inserts.
- Clear the table's generated files before writing, so a skipped
  baseline_mv arm or a removed interval leaves no stale SQL or config.
- Render views with no label keys (empty grouping) as valid DDL.
- Raise ValueError when child_of names an unknown label.
- Share one mv_servable() check between convert and plan_views.
- Docstring: E for the eval time, T for the interval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@milindsrivastava1997
milindsrivastava1997 changed the base branch from feat/e2e-sql-from-table to main October 14, 2026 17:36
@milindsrivastava1997

Copy link
Copy Markdown
Contributor

Addressed in 18a35eb. The branch now has main merged in (#844 squash-merged), and the PR targets main.

  1. Views left by other workloads: fixed. I confirmed it in clickhouse-local: a view stays attached to a dropped and recreated table and keeps firing on inserts. A per-stem prefix wouldn't help, because A's views would still be attached to data. All views now live in one database, sketch_bench_views. Every init SQL, baseline and baseline_mv alike, starts with DROP DATABASE IF EXISTS sketch_bench_views, which removes every workload's views, and baseline_mv then recreates it. New StaleViewsTest creates another workload's view, runs this workload's init, inserts a row, and checks that no views remain. It fails with the old per-view drops.
  2. Stale view-arm files: fixed. convert clears <stem>/ and <stem>_clickhouse_mv.yaml before writing, which also drops interval_<T>s files for intervals the table no longer has. test_unservable_query_skips_mv_arm now converts a servable table with an extra 1 s group first, then the unservable one into the same directory, and checks that only the baseline files remain.
  3. Unknown child_of: plan_views raises a ValueError naming the table and the labels. Tested.
  4. Duplicate servability check: mv_servable(q) is shared by convert and plan_views.
  5. Per-stat table: deferred. view_states and mv_agg are paired per stat, and the ClickHouse test compares every stat's view result against the raw SQL, so drift would fail there. raw_agg works over raw rows, not states, so a shared table would only partly cover it.
  6. T vs E in the docstring: fixed. E is the eval time, T the interval.

Also, from #844's review: a view whose queries all have empty grouping (no label keys) rendered SELECT …, edge, , sumState(…). view_ddl now joins the select list as one tuple. KeyPruningTest runs a quantile query with empty grouping (the only query on its view) through both the raw table and the view.

Each fix was checked by reverting it and seeing its test fail. Results: 22 tests pass, plus test_experiment_run_clickhouse.py.

🤖 Generated with Claude Code

A re-run that failed validation or rendering had already deleted the
earlier SQL files, leaving its config pointing at missing files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@milindsrivastava1997
milindsrivastava1997 merged commit 05ae0be into main Oct 14, 2026
11 checks passed
@milindsrivastava1997
milindsrivastava1997 deleted the feat/e2e-sql-mv branch October 14, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants