feat(query_expr): Concat discriminator unique_keys override (#228) - #291
Open
zzylol wants to merge 3 commits into
Open
feat(query_expr): Concat discriminator unique_keys override (#228)#291zzylol wants to merge 3 commits into
zzylol wants to merge 3 commits into
Conversation
…_keys override Investigated whether any current Concat call site (PromQL histogram_quantiles, SQL ROLLUP/CUBE/GROUPING SETS lowering) pays for a redundant Dedup that a discriminator-based unique_keys override could prove unnecessary. Finding: neither call site emits a Dedup (or equivalent) after its Concat today, and no consumer of Schema::unique_keys (CSE's share_common_subtrees, asap_aware_mapping::rollup's is_legal_rollup_source) is exercised by either Concat in any current test or workload. SQL grouping-set lowering also actively discards the one natural discriminator (__grouping_id) today, since this front end rejects GROUPING(). Recommendation: defer Option 1 (producer-supplied unique_keys override) until a real consumer exists; building it now would be speculative surface area with no call site to justify or exercise it. No behavior change — Concat still drops unique_keys unconditionally, per merge_drops_the_branches_unique_keys / merge_and_setop_agree_on_unique_keys (both still passing, unchanged). Ref #228 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per explicit user direction overriding the investigation's own defer recommendation: build Option 1 (producer-supplied unique_keys override) ahead of a proven call-site win, as a deliberate 'ship the extension point now' decision. - QueryExpr::Concat gains an opt-in discriminator_unique_key: Option< ConcatDiscriminatorKey<C>> field. ConcatDiscriminatorKey's fields (discriminator, inner_key) are private; the only constructor, ConcatDiscriminatorKey::new, requires the discriminator column to be named explicitly by the caller -- nothing infers or defaults one. - QueryExpr::concat(children) is the new ordinary constructor (discriminator_unique_key: None), replacing the bare struct literal at every call site in the tree. - QueryExpr::concat_with_discriminator(children, discriminator, inner_key) is the override constructor. - output_schema()'s Concat arm: unchanged default (drop unique_keys unconditionally) when the field is None; when Some, asserts (discriminator, inner_key) as the sole unique key, trusting the caller's claim without verifying it. - resolve.rs resolves a pre-bind discriminator key into its post-bind ColumnId equivalent against the first resolved branch's schema, so the feature is correct end-to-end for a future caller upstream of resolve_root. - Every other Concat match/construction site across the tree (canonicalize, cse, binder, dag_export, asap-aware-mapping's replacement/explanation, and test/tooling AST walkers) updated mechanically; cse.rs's rebuild_children (CSE interning) threads the field through unchanged rather than dropping it. Not wired into any real lowering call site, per instruction: neither histogram_quantiles nor SQL ROLLUP/CUBE/GROUPING SETS lowering calls concat_with_discriminator. Both still call the plain concat() builder -- byte-for-byte the same output_schema() behavior as before. SQL's natural discriminator (__grouping_id) is still discarded because this front end rejects GROUPING(); PromQL's phi discriminator is structurally available but nothing downstream needs the resulting key yet. Both noted as future work. Tests (crates/types/src/pre_asap/query_expr.rs): - merge_drops_the_branches_unique_keys / merge_and_setop_agree_on_unique_keys: unchanged, still pass. - discriminator_override_produces_a_compound_unique_key: new, concat_with_discriminator on branches individually deduplicated on the same column yields unique_keys == [[discriminator, inner_key]]. - ordinary_concat_struct_literal_still_drops_unique_keys_by_default: new, the bare struct literal with discriminator_unique_key: None still drops unique_keys. - no_way_to_fabricate_a_unique_key_without_naming_a_discriminator: new misuse check -- neither the ordinary builder nor an explicit None literal can produce a unique key without a call site literally naming a discriminator column via ConcatDiscriminatorKey::new. docs/design_docs/concat-unique-keys-decision.md updated to record the investigation (unchanged), the reversal, and the safety argument for shipping the override unused. cargo build --workspace, cargo test --workspace, and cargo clippy --workspace --tests all clean. Ref #228 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…c caveat (#228) Review fixes on the Concat discriminator_unique_key override: 1. binder.rs's collect_referenced_columns didn't walk discriminator_unique_key's ColumnRefs -- the Concat arm was updated with ', ..' only, unlike the analogous Dedup.cols case (which is walked). This function seeds every name a query references into the Binder's usage-derived fallback schema; a discriminator column not otherwise referenced anywhere else in the tree, over a schema-less leaf Scan, would be absent from that fallback schema, and resolve.rs's later resolve_column_ref call would fail NotFound for a column the caller correctly named. Fixed: the Concat arm now pushes key.discriminator() and every key.inner_key() column into the walk, mirroring Dedup.cols exactly. New regression tests: binder.rs's concat_discriminator_key_is_seeded_into_the_binder_schema and resolve.rs's resolve_root_seeds_and_resolves_an_otherwise_unreferenced_discriminator_column (end-to-end through resolve_root). 2. Resolved ColumnIds in discriminator_unique_key could go stale after canonicalize() runs. resolve() resolves the key's ColumnRefs against children.first()'s pre-canonicalize output schema; canonicalize() then runs afterward and can restructure that same branch (try_promote_heavy_hitter, try_rewrite_rownumber_topk both replace a Limit{Sort{Aggregate}}/Filter{...} shape with a differently-shaped Aggregate, anywhere within the branch), changing its column count/order with no consistency check downstream. Fixed in canon() (canonicalize.rs): snapshot the first branch's output schema before recursing into a Concat's children (exactly what resolve.rs resolved against), and after recursing, drop the key (never re-derive it by guessing) if the schema differs at all -- erring conservatively, since a wrong unique_keys claim is a wrong query answer, not a missed optimization. Two new tests in canonicalize.rs pin both outcomes: the key survives an untouched branch, and is dropped when the branch matches the heavy-hitter promotion trigger. 3. Documentation accuracy: the doc comment on ConcatDiscriminatorKey and the design doc's safety argument overclaimed "there is no path to a non-empty unique_keys claim that doesn't go through a call site literally naming the discriminator" -- true for other Rust code, but #[derive(Deserialize)] is same-module generated code that builds the struct directly from arbitrary field values, bypassing new() entirely. Currently unreachable (the only whole-QueryExpr deserialization call site in the repo is a same-file unit test), but the claim was factually incomplete as stated. Fixed by scoping both claims to 'other Rust code' and stating the Deserialize caveat explicitly, rather than adding speculative runtime hardening for a currently-unreachable path. docs/design_docs/concat-unique-keys-decision.md updated with a 'Review fixes' section covering all three, and both doc-accuracy fixes applied inline. cargo build --workspace, cargo test --workspace (115 passed, 0 failed), cargo clippy --workspace --all-targets -- -D warnings, and cargo fmt --all -- --check all clean. Ref #228 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Issue #228 asked a design question first: should
QueryExpr::Concatbe ableto assert a compound
unique_keysoverride ((discriminator_col, inner_key)) when a caller can prove branch disjointness via a discriminatorcolumn? The investigation (first commit on this PR) found that neither
current
Concat-constructing call site — PromQLhistogram_quantiles, SQLROLLUP/CUBE/GROUPING SETSlowering — pays for a redundantDedupthatthis would let it remove, and recommended deferring.
Update: the repo owner reviewed that finding and explicitly asked for
Option 1 (producer-supplied override) to be implemented anyway — a
deliberate "ship the extension point ahead of a proven call-site win"
decision, not a disagreement with the investigation. This PR now contains
that implementation, plus a round of review fixes (see "Review fixes"
below). Full writeup (investigation + reversal + safety argument + review
fixes):
docs/design_docs/concat-unique-keys-decision.md.What shipped
QueryExpr::Concatgains an opt-in field:discriminator_unique_key: Option<ConcatDiscriminatorKey<C>>.ConcatDiscriminatorKey<C>— privatediscriminator/inner_keyfields,buildable only via
ConcatDiscriminatorKey::new(discriminator, inner_key).This is the enforcement mechanism for "the caller must explicitly name a
discriminator column" — from other Rust code, there's no path to a
non-empty
unique_keysclaim that doesn't go through a call siteliterally writing out which column it's proving is distinct (see the
Deserializecaveat under "Review fixes" below). Mirrors the existingGroupKeysprivate-fields-plus-smart-constructor shape already in thisfile.
QueryExpr::concat(children)— the new ordinary constructor(
discriminator_unique_key: None), now used everywhere in the tree inplace of the bare
QueryExpr::Concat { children }struct literal.QueryExpr::concat_with_discriminator(children, discriminator, inner_key)— the override constructor.
output_schema()'sConcatarm: unchanged default when the field isNone(unique_keyscleared unconditionally, exactly as before); whenSome, asserts(discriminator, inner_key)as the sole unique key,trusting the caller's claim without verifying it.
resolve.rsresolves a pre-bind (ColumnRef) discriminator key into itspost-bind (
ColumnId) equivalent against the first resolved branch's ownoutput schema — the same schema
output_schema()derives the merged shapefrom — so the feature is correct end-to-end for a future caller upstream
of
resolve_root.Concatmatch/construction site across the tree(
canonicalize.rs,cse.rs,binder.rs,dag_export.rs,asap-aware-mapping'sreplacement.rs/explanation.rs, and everytest/tooling AST walker) updated mechanically — most just bind/ignore the
new field; the one place that rebuilds a
Concatnode (cse.rs'srebuild_children, part of CSE interning) threadsdiscriminator_unique_keythrough unchanged rather than silently droppingit.
Not wired into any lowering call site (deliberately)
Per the explicit instruction accompanying this decision,
histogram_quantilesand SQL's
lower_grouping_setswere not changed to callconcat_with_discriminator. Both still call the plainconcat(children)builder — byte-for-byte the same
output_schema()behavior as before thisissue. Why: PromQL's φ discriminator is structurally available
(
PromqlRelabel) but nothing downstream needs the resulting unique key yet;SQL's natural discriminator (
__grouping_id) is actively discarded todaybecause this front end rejects
GROUPING()— wiring that one in means firstreopening that rejection, a separate, larger design decision. Both are noted
as future work at their call sites and in the design doc.
Safety argument (why the default is unaffected)
None—QueryExpr::concathardcodes it, and no call site (including both reallowering call sites) uses
concat_with_discriminator.output_schema()'sNonearm is textually the sameclear-and-return the code already did.
merge_drops_the_branches_unique_keysand
merge_and_setop_agree_on_unique_keyspass unchanged.#[serde(default)]on the field means a pre-Concat drops unique_keys unconditionally — no way for a producer to assert cross-branch disjointness #228 serializedConcat(missing the field) deserializes to
None— same convention already usedelsewhere on this enum (
Aggregate.output_names,Scan.predicates).Safety argument (why the discriminator must be caller-proven, not inferred)
ConcatDiscriminatorKey's private fields plus its single, explicit-argumentconstructor mean
output_schema()never has enough information to fabricatea discriminator on its own — it can only read one a constructor already
supplied. Nothing stops a caller from asserting a wrong discriminator (the
type system enforces "you named a column," not "you were right about it") —
that obligation is documented on the type and is the same shape of
unverified claim
QueryExpr::Dedup.colsalready carries elsewhere in thismodule.
Review fixes
A code review of the initial implementation found two real correctness gaps
in the untested
resolve()/canonicalize()path, plus a documentationaccuracy issue. All three are fixed on this branch:
binder.rs'scollect_referenced_columnsdidn't walkdiscriminator_unique_key'sColumnRefs. This function seeds everyname a query references into the Binder's usage-derived fallback schema,
which a schema-less
Scanleaf (PromQL) falls back to. TheConcatarmhad only been updated with
, .., unlike the analogousDedup.colscase(which is walked). Concretely: a future
concat_with_discriminatorcall over an open query where the discriminator column isn't otherwise
referenced anywhere else in the tree, with a schema-less leaf
Scaninthe first branch — the Binder's fallback schema wouldn't contain the
discriminator name, and
resolve.rs's laterresolve_column_refcallwould fail
NotFoundfor a column the caller correctly named. Fixed:the
Concatarm now pusheskey.discriminator()and everykey.inner_key()column into the walk, mirroringDedup.colsexactly.New tests:
binder.rs'sconcat_discriminator_key_is_seeded_into_the_binder_schemaandresolve.rs'sresolve_root_seeds_and_resolves_an_otherwise_unreferenced_discriminator_column(full
resolve_rootend-to-end).Resolved
ColumnIds indiscriminator_unique_keycould go stale aftercanonicalize()runs.resolve()resolves the key'sColumnRefsagainst
children.first()'s output schema as it stood at that point;canonicalize()then runs afterward and can restructure that samebranch —
try_promote_heavy_hitterandtry_rewrite_rownumber_topkbothreplace a
Limit{Sort{Aggregate}}/Filter{...}shape with adifferently-shaped
Aggregate, anywhere within the branch (not only atits own top level) — with no consistency check downstream, so a matching
branch could silently produce a wrong
unique_keysclaim: a wrong queryanswer, not a missed optimization. Fixed in
canon()(
canonicalize.rs): snapshot the first branch's output schema beforerecursing into a
Concat's children — exactly the schemaresolve.rsresolved the key against — and afterward, drop the key (
None) if thatschema differs at all (full
Schemaequality, not just acolumn-count/type heuristic). The key is never re-derived by guessing at
name or position; dropping is the only sound outcome once the schema has
moved. Two new tests in
canonicalize.rscover both outcomes:concat_discriminator_key_survives_canonicalize_when_first_branch_is_unaffectedand
concat_discriminator_key_is_dropped_when_first_branch_gets_rewritten.Documentation overclaimed the privacy guarantee. Both the doc comment
on
ConcatDiscriminatorKeyand this PR's original "Safety argument"section said flatly "there is no path to a non-empty
unique_keysclaimthat doesn't go through a call site literally naming the discriminator"
— true for other Rust code, but not for
#[derive(Deserialize)],which is same-module generated code that builds the struct directly from
field values in arbitrary JSON, bypassing
new()entirely. Currentlyunreachable (the only place a whole
QueryExpris deserialized in therepo is a same-file unit test, not an external-input path), but the claim
was factually incomplete as stated. Fixed by narrowing both to "from
other Rust code" and stating the
Deserializecaveat explicitly, ratherthan adding speculative runtime hardening for a currently-unreachable
path (per the review's own preferred option).
Tests
crates/types/src/pre_asap/query_expr.rs:merge_drops_the_branches_unique_keys/merge_and_setop_agree_on_unique_keys— unchanged, still pass.
discriminator_override_produces_a_compound_unique_key—concat_with_discriminatoron branches individually deduplicated on the same column yields
unique_keys == [[discriminator, inner_key…]].ordinary_concat_struct_literal_still_drops_unique_keys_by_default— thebare struct literal with
discriminator_unique_key: Nonestill dropsunique_keys.no_way_to_fabricate_a_unique_key_without_naming_a_discriminator— misusecheck: neither the ordinary
concatbuilder nor an explicitNoneliteral can produce a unique key without a call site literally naming a
discriminator column via
ConcatDiscriminatorKey::new.Review-fix regression tests:
crates/types/src/pre_asap/binder.rs:concat_discriminator_key_is_seeded_into_the_binder_schema.crates/types/src/pre_asap/resolve.rs:resolve_root_seeds_and_resolves_an_otherwise_unreferenced_discriminator_column.crates/types/src/pre_asap/canonicalize.rs:concat_discriminator_key_survives_canonicalize_when_first_branch_is_unaffected,concat_discriminator_key_is_dropped_when_first_branch_gets_rewritten.Testing
cargo build --workspace— clean, no warnings.cargo test --workspace— all green: 115 passed inasap-types's unitsuite (108 before this PR, +7 new tests across both commits), 0 failures
anywhere in the workspace.
cargo clippy --workspace --all-targets -- -D warnings— clean.cargo fmt --all -- --check— clean.Open questions for review
ConcatDiscriminatorKeysufficient API-level protection given theDeserializecaveat, or is that worth closing now even though nothingreaches it yet?
histogram_quantilesactually get wired up toconcat_with_discriminatorin a follow-up, given the discriminator isalready sitting right there? (Left as future work since nothing downstream
reads it yet.)
GROUPING()rejection.
post-canonicalize schema change the right call, or should a future change
attempt a narrower re-derivation for the specific rewrite shapes involved?
Ref #228
🤖 Generated with Claude Code