Skip to content

feat(query-engine): add opt-in rule-based Spark pushdown optimizer, starting with resample - #129

Open
tombonfert wants to merge 17 commits into
mainfrom
feature/query_optimizer_v0
Open

tombonfert wants to merge 17 commits into
mainfrom
feature/query_optimizer_v0

Conversation

@tombonfert

Copy link
Copy Markdown
Collaborator

Summary

Adds a rule-based optimizer that computes supported expression subtrees natively in Spark before the pandas UDF, so fewer rows are shipped to it. Pushdown is off by default, and with enable_pushdown=False the solver works as before.

What's new

  • Opt-in flag: DefaultSolver(spark, enable_pushdown=True), or query_engine.enable_pushdown: true in a report config.
  • Optimizer: classifies expression nodes bottom-up against an ordered list of rules. A channel is pushed down only if every use of it in a solve is inside a pushed-down subtree. Otherwise its raw rows are shipped and the subtree is evaluated in pandas, as before.
  • First rule: channel.resample(sample_rate) on channel() and channel_with_alias() selections, with the same results as SampleSeries.resample.
  • Shared Spark pipeline: all pushed-down expressions share one chunked pass over the channel data, with about 100 000 samples per chunk and chunks processed in parallel. The cost does not grow with the number of pushed-down expressions. The channels table is read once in full and once narrow (for the chunk boundaries), and the samples are shuffled once. Channels that are also used outside a pushed-down subtree add one read for their raw rows.
  • Native calculated channels: when every calculated channel of a solve is a pushdown as a whole, its rows are produced in Spark and no pandas UDF runs.
  • Evaluation hook: TimeSeriesOp.build first asks SeriesCache.materialized(node) for a precomputed result, so user expressions are never rewritten.

Behaviour with pushdown off

  • Channel resolution is unchanged, except that ties in alias priority now go to the lowest channel_id instead of an arbitrary match.
  • solve_calculated_channels checks the channel_id type of the channels table before any Spark job runs, and raises unless it is int or long. A string channel_id already fails inside the UDF on main (see Support string channel_id for calculated channels #127).

Docs

  • New page "How a Query Is Solved".
  • Spark pushdown section in Query Solvers.
  • API reference for the optimizer package.

Known limitations

  • Intended for downsampling: a rate finer than the native sample spacing yields more rows than the raw channel.
  • With double timestamps and a fractional rate, grid points can differ in the last bit from numpy builds that use fused multiply-add.
  • Each selection is assumed to identify at most one channel per container. If several match, which one is used is unspecified, as before. With pushdown, the native calculated-channel path then merges the rows of all matches, and other solves may pick a different match than without pushdown. Deterministic resolution is a follow-up, together with Two direct selectors matching the same channel: only the first selector resolves #124.
  • Calculated channels don't support a string channel_id yet (Support string channel_id for calculated channels #127). solve() with pushdown works with string channel_ids.
  • Rules that compute on another pushdown's result are modelled but not executed yet.

Introduce a rule-based query optimizer that computes supported expression subtrees natively in Spark before the pandas UDF, starting with `channel.resample(rate)` on physical channels.

- Add `QueryOptimizer`, `PushdownPlan`, `PushdownRule`, and `PushdownContext` in `analyze.query.optimizer`.
- Implement `ResamplePushdownRule` to push `SampleSeries.resample` down to Spark, reproducing the pandas behavior block-by-block.
- Wire `enable_pushdown` through `DefaultSolver`, `QueryEngine`, and report config; default is `false`.
- Make `TimeSeriesOp.build` consult `SeriesCache.materialized` so pushed-down results can be injected into the evaluation tree.
- Add optimizer API reference pages, update configuration docs, and document the Spark pushdown opt-in behavior in query solver and TSAL guides.
…ndas bucket semantics

Update `ResamplePushdownRule` so each sample is assigned the bucket of the first grid point at or after its `tstart`, and only the last sample of a bucket emits the grid points it covers. This fixes cases where grid points land exactly on sample starts and avoids emitting duplicate or over-wide rows. Add unit tests covering grid-on-sample-start scenarios, including nanosecond-epoch timestamps that require the bucket-index correction step.
Adjust `_with_first_grid_index` to nudge the estimated bucket index up or down only when the reachability check at the adjacent index differs, falling back to the raw estimate otherwise. This tightens the correction step introduced for nanosecond-epoch timestamps without over-adjusting cases where the initial estimate already lands on the correct grid point.
…lementation details

Update API reference docs for `PushdownRule.to_spark` to describe the single-pass, lineage-preserving contract for replacing variant rows with derived rows. Expand `ResamplePushdownRule` and query solver docs to explain grid bucket assignment, last-sample row emission, and the bounded memory per row.
Replace the `ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING` frame in `ResamplePushdownRule` with a two-pass approach using only linear frames. First pass computes per-block state with running/whole-partition windows; second pass derives block `t_max` and grid counts over the filtered last-sample rows. This prevents Spark from recomputing an unbounded-following frame per row. Add a unit test asserting the optimized plan contains no unbounded-following row frame.
… in Spark

Add `PushdownPlan.native_targets` and `PushdownRule.emits_result_rows` so `DefaultSolver.solve_calculated_channels` can bypass the pandas UDF when every selection is a `CalculatedChannel` whose entire expression is a pushdown that emits its result rows directly. Native rows are produced from the derived variant rows and attached to the same identity map as the UDF path.

Also make `DefaultSolver` resolve selectors to at most one channel per container, keeping the lowest `channel_id` (after alias priority), and give aliased and direct selections on the same tags distinct `selector_id`s. Update docs and add unit/integration tests covering native calculated channels, channel resolution deduplication, and pushdown parity.
Update `ResamplePushdownRule` to process each series in equal-time chunks of about `chunk_rows` samples (default 100,000) instead of whole series. Per-chunk windows keep worker memory bounded; chunk-boundary state (gaps, block start) is resolved from a small per-chunk summary broadcast-joined back. The summary shares one extra narrow read of the channel key/time columns, so Spark reads channel data once in full plus one narrow read.

- Add `chunk_rows` parameter with validation.
- Update `PushdownRule.to_spark` contract to allow broadcast-joining small summaries derived from `rows`.
- Stop filtering raw variant rows before the pushdown input so the narrow summary read can be shared across rules.
- Add unit tests for chunking, chunk-size validation, random series across chunk sizes, and parity with existing cases.
- Update API docs and query solver docs to describe chunked resampling and the two-channel-data-read plan.
Replace per-rule `PushdownRule.to_spark` with a shared `SeriesPipeline` that evaluates all ordered rules in one pass over the channel data. Rules now declare hooks (`row_columns`, `keep`, `kept_columns`, `emit`) operating on a `SeriesContext` of Spark columns, so reads and shuffles are shared across rules. Add `context.py` with `SeriesContext`, `Sample`, `Run`, and `RunBreak`; add `pipeline.py` with chunking, per-chunk windows, chunk summaries, and bounded-memory series processing. Convert `ResamplePushdownRule` to the new hook interface and remove its `chunk_rows` parameter. Update `DefaultSolver` to use `apply_pushdowns`, regenerate API docs, and expand query solver docs to describe the shared pushdown pass.
Add a new documentation page that walks through the five stages of a `solve()` call, from driver-side planning through metadata resolution, channel loading, Spark pushdown, and per-container pandas UDF evaluation. Include a supporting diagram and link it from the query engine index and TSAL evaluation docs.
… behavior

Update the "How a Query Is Solved" page and flow diagram to reflect that:
- `solve_calculated_channels()` skips driver-side type inference and emits sample rows.
- Alias resolution and unit conversion are now separate stages in the channel-match pipeline.
- Stage 4 pushdown rules share channel-data reads, with ordered rules running per chunk.
- Output encoding differs between `solve()` (serialized columns) and `solve_calculated_channels()` (rows).

Also fix the `query_solvers.md` example output schema to list `identity` instead of `channel_name`/`data_key`.
…and non-op nodes, fix alias selector ids and NaN conversion factors

- Enforce lowercase rule/column/run names and unique rule names to prevent pipeline column collisions; add `check_name` helper and validation in `QueryOptimizer` and `PushdownPlan`.
- Change pipeline column separators to `__` so rule names cannot clash with internal columns.
- Restrict pushdown matching to `TimeSeriesOp` nodes via `match_node`; aggregations, UDFs, and events now correctly keep raw rows.
- Fix `TimeSeriesSelector.selector_id` for aliased non-CONTINUOUS selections so aliased and direct selectors on the same tags remain distinct.
- Treat NaN unit-conversion factors like null factors, leaving values unconverted instead of nulling them.
- Handle null/NaN `tstart` and all-NaN `tend` in chunking so series ordering and chunk assignment stay consistent.
- Add unit tests for name validation, collision avoidance, non-op node exclusion, selector id distinctness, and NaN conversion factors.
…operands

Introduce `Classifier` to memoize bottom-up classification of `TimeSeriesExpression` nodes into pushdowns, replacing the top-down `match_node` helper. Rules now receive an `Operand` callback that exposes whether each operand is a raw selector, a pushed subexpression, or evaluated in pandas, enabling rules to absorb or chain pushed operands.

- Add `Classifier` and `Operand` to the optimizer public API and docs.
- Update `PushdownRule.match` signature to accept `operand`.
- Add `Pushdown.source` for pushdowns that compute on another pushdown's result; reject planned chained pushdowns in `PushdownPlan.build`.
- Update `QueryOptimizer` to accept only single-selector, non-chained pushdowns and share one `Classifier` across discovery.
- Update `ResamplePushdownRule` and test helpers to the new `match` interface.
- Cache one `Classifier` per container in `TimeSeriesCache` so UDF node lookups classify each node once.
- Add unit tests for bottom-up classification, chained/absorbed operands, unsupported parent fallback, and shared-node memoization.
… per container

Remove the "lowest channel_id wins" deduplication for direct selectors in `DefaultSolver` so a selector can resolve to every matching channel per container. Aliased selectors still resolve to one channel per container (best alias priority, then lowest channel_id). Update `TimeSeriesSelector.selector_id` to no longer encode `uses_alias`, since direct and aliased selections are now handled separately. Adjust docs, generated API reference, and tests to reflect the new resolution behavior.
…tring in pushdown solver

Verify that `DefaultSolver` pushdown produces identical results when `channel_id` is cast to `int` or `string` in the key-value store tables.
…preparing channel join

Move `_validate_calculated_channel_id_type` before `_prepare_channels_join` and read the `channel_id` dtype from the raw `channels` table schema instead of the joined query. This avoids triggering a Spark job during validation and ensures the check runs early. Update the test to assert that channel data preparation is not reached when the type is invalid.
Add `PushdownPlan.channel_selector_ids` to centralize casting, filtering nulls, and deduplicating a channel's `selector_ids` as `long`. Use it in both `channel_variants` and `DefaultSolver` native target lookup so variant tagging and calculated-channel rows agree on which selectors a channel carries. Add unit tests covering deduplication, null handling, and empty arrays.
@tombonfert
tombonfert requested a review from a team as a code owner October 16, 2026 13:11
@codecov

codecov Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.25490% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.18%. Comparing base (be3ea0b) to head (b205c3b).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...ery_engine/analyze/query/solvers/default_solver.py 93.39% 4 Missing and 3 partials ⚠️
..._query_engine/analyze/query/optimizer/traversal.py 90.76% 3 Missing and 3 partials ⚠️
...se_query_engine/analyze/query/optimizer/context.py 96.36% 4 Missing ⚠️
...e_query_engine/analyze/query/optimizer/pipeline.py 98.96% 1 Missing and 1 partial ⚠️
..._engine/analyze/query/optimizer/query_optimizer.py 96.36% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #129      +/-   ##
==========================================
+ Coverage   90.30%   91.18%   +0.87%     
==========================================
  Files          68       75       +7     
  Lines        6068     6818     +750     
  Branches      758      841      +83     
==========================================
+ Hits         5480     6217     +737     
- Misses        466      474       +8     
- Partials      122      127       +5     
Flag Coverage Δ
query_engine 88.84% <97.25%> (+2.04%) ⬆️
reporting 94.92% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
..._engine/analyze/metadata/time_series_expression.py 83.59% <100.00%> (+1.03%) ⬆️
...pulse_query_engine/analyze/query/optimizer/plan.py 100.00% <100.00%> (ø)
...e_query_engine/analyze/query/optimizer/pushdown.py 100.00% <100.00%> (ø)
...ry_engine/analyze/query/optimizer/resample_rule.py 100.00% <100.00%> (ø)
...query_engine/analyze/query/solvers/series_cache.py 89.47% <100.00%> (+1.97%) ⬆️
src/impulse_reporting/config/config_parser.py 97.22% <100.00%> (+0.01%) ⬆️
src/impulse_reporting/core/report.py 94.16% <ø> (ø)
...e_query_engine/analyze/query/optimizer/pipeline.py 98.96% <98.96%> (ø)
..._engine/analyze/query/optimizer/query_optimizer.py 96.36% <96.36%> (ø)
...se_query_engine/analyze/query/optimizer/context.py 96.36% <96.36%> (ø)
... and 2 more

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment on lines +29 to +31
T.StructField("tstart", T.DoubleType()),
T.StructField("tend", T.DoubleType()),
T.StructField("value", T.DoubleType()),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get the column names from the solver config.

Comment on lines +38 to +45
# Columns the pipeline provides, shared by all rules.
TS, TE = "_pd_ts", "_pd_te"
CHUNK = "_pd_chunk"
PREV_TS, PREV_TE, PREV_VALUE = "_pd_prev_ts", "_pd_prev_te", "_pd_prev_value"
NEXT_TS, NEXT_TE, NEXT_VALUE = "_pd_next_ts", "_pd_next_te", "_pd_next_value"
COUNT = "_pd_n"
LAST = "_pd_last"
BLOCK_END = "_pd_block_end"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

those column names should be added to the solver config

This branch has not been deployed

No deployments
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.

1 participant