Skip to content

feat(promql): support without(...) grouping via a GroupKeys exclusion mode (#39) - #105

Merged
zzylol merged 1 commit into
mainfrom
feat/39-without-grouping
Jul 6, 2026
Merged

zzylol merged 1 commit into
mainfrom
feat/39-without-grouping

Conversation

@zzylol

@zzylol zzylol commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Closes #39.

The idea

sum without (instance) (…) was rejected: without(labels) groups by every label except those listed, and the complement can't be enumerated under an open usage-derived schema (we don't know a metric's full label set). But it doesn't need to be enumerated at lowering time:

  • the excluded labels are named by the query, so the Binder already seeds them and they resolve positionally — just like by labels;
  • the kept set (the complement) is resolvable by the runtime, which sees the full label set.

So the honest representation stores the excluded positions and defers the complement — the output schema of a without aggregate stays open, whereas a by aggregate freezes it to closed.

Changes

L3 GroupKeys gains a by/without mode (it was a bare Vec<ColumnId> newtype). The stored keys are kept labels for by, excluded labels for without. The Deref/From/FromIterator/IntoIterator/PartialEq<Vec> impls are preserved, so the ~150 existing by: vec![..].into() construction sites compile unchanged — only the 7 explicit GroupKeys(..) sites became GroupKeys::by(..). Custom serde keeps the by wire format a bare array (backward-compatible); without serialises as {"without": [...]}.

Schema derivation branches on the mode. A without(excluded) aggregate emits every input label column except the excluded positions (and ts/value), then the aggregate column, and stays open with no unique key. A subtle correctness point: without () (empty exclusion = group by all labels) is cross-series, so it must not take the per-series-global path that by.is_empty() selects — the guard is by.is_empty() && !by.is_without().

Threading. L2 Aggregate gains a without flag. The PromQL front end's resolve_group now returns (keys, without) and no longer rejects the exclusion form; mark_without flips the outer aggregate's flag as a post-step (avoiding a param through ~23 builder call sites). SQL is untouched — GROUP BY is always inclusion (without: false).

Rejections preserved where the model doesn't extend. without on topk/bottomk (→ Sort.partition_by) and limitk/limit_ratio (→ Sample) would need without-partitioning; those are rejected cleanly rather than silently lowered as a by grouping.

Tests

  • GroupKeys by-vs-without semantics + serde (bare array for by, tagged object for without, round-trip).
  • Schema derivation: sum without (instance) over [ts, value, instance, job] → keeps job, drops instance, stays open, no unique key.
  • Conformance: sum_without_groups_by_the_complement (exclusion form + open schema) and without_on_topk_is_rejected.
  • Lowering: without_grouping_lowers_to_the_exclusion_form (over a per-series rate).
  • Exact-tree e2e pin q39_sum_without_instance_over_rate.
  • The three former "without is rejected" tests (promql_conformance, promql_lowering, the awesome-prometheus-alerts corpus GAP) are flipped to passing.
  • Docs: the Binder "resolution-policy" bullet and the nesting-contract table updated.

Notes

  • Touches asap-ir / asap-l2 / both front ends. Independent of other open work.
  • Verified: cargo test --workspace — 0 failures; cargo clippy --all-targets clean; production source (query_expr.rs, column_resolution.rs, relational.rs, lower.rs, promql.rs) carries zero net-new cargo fmt drift vs main (two test files keep their pervasive compact-destructure convention).

🤖 Generated with Claude Code

…GroupKeys (#39)

`sum without (instance) (...)` was rejected outright: `without` groups by
every label EXCEPT those listed, and the complement can't be enumerated
under an open usage-derived schema. But it doesn't need to be — the
excluded labels are named (so the Binder seeds and resolves them), and
the kept (complement) set is resolvable at runtime. So the honest
representation stores the excluded positions and defers the complement.

- L3 `GroupKeys` gains a `by`/`without` mode (was a bare `Vec<ColumnId>`
  newtype). Constructors `by`/`without`, accessors `is_without`/`keys`,
  and the Deref/From/IntoIterator/PartialEq impls preserve the existing
  slice-like API so the ~150 `by: vec![..].into()` call sites are
  untouched. Custom serde keeps the `by` wire format a bare array;
  `without` serialises as `{"without": [...]}`.
- Schema derivation branches: a `without` aggregate keeps every input
  label except the excluded ones (and ts/value), appends the agg column,
  and stays OPEN (the kept set is runtime-only) — unlike `by`, which
  freezes to closed. `without()` (empty exclusion) is cross-series, never
  the per-series-global path.
- L2 `Aggregate` gains a `without` flag; the PromQL front end resolves
  the excluded labels and `mark_without` flips the outer aggregate to the
  exclusion form. `without` on topk/bottomk/limitk (a ranking/sampling,
  not a reduction) is rejected rather than silently treated as `by`.
- SQL is unaffected (GROUP BY is always inclusion → `without: false`).

Tests: GroupKeys by/without semantics + serde; the without schema
derivation (kept labels minus excluded, stays open); conformance (the
grouping is the exclusion form, schema open) + the topk-without
rejection; an exact-tree e2e pin; and the three former rejection tests
flipped to passing. Docs updated (Binder section + nesting contract).

Closes #39

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zzylol
zzylol merged commit 1f2628a into main Jul 6, 2026
1 check passed
@zzylol
zzylol deleted the feat/39-without-grouping branch July 6, 2026 02:24
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.

PromQL without(...) grouping unsupported — usage-derived schema can't enumerate the complement

1 participant