Repository navigation
Flow the array element of an explode wrapped in a struct - #4623
g31pranjal wants to merge 3 commits into
Conversation
cd8ca19 to
fe9b6d2
Compare
27e2937 to
6e2f6d2
Compare
fe9b6d2 to
4e19391
Compare
6e2f6d2 to
39fd5f9
Compare
4e19391 to
4fb7006
Compare
39fd5f9 to
ae2da85
Compare
4fb7006 to
b7db6bc
Compare
ae2da85 to
084b834
Compare
1818a02 to
f4a7009
Compare
3da0da9 to
fb8c044
Compare
d84e4be to
92aaead
Compare
fb8c044 to
9e9a04d
Compare
92aaead to
2979d09
Compare
2979d09 to
c781f27
Compare
9e9a04d to
e9867f1
Compare
6db6c25 to
758a824
Compare
2be5704 to
d038eda
Compare
d038eda to
3595415
Compare
f3ab945 to
d28361e
Compare
3595415 to
5e9397b
Compare
An `EXPLODE WITH ORDINALS`'s value is a single opaque `QueriedValue`, even though it flows 2 pieces of information - element and its position. Nothing inside it is reachable, which is fine for evaluation but it leaves no way to relate the element to anything. ### Why `RecordConstructorValue` is needed: An index utilizing an `UnnestedRecordType` requires that the `SyntheticRecordType` is expanded into a candidate. This expansion in turn requires unnesting with ordinality, because the proto descriptor of the `UnnestedRecordType`, as well as its primary key, carries the position of the element. A query that unnests the same collection but asks for no ordinals must be matched against this candidate, by relating the query's element to the candidate's element: `MaxMatchMap` has to find the query's value among the candidate's reachable sub-values. `MaxMatchMap` descends only into record constructors. An opaque `QueriedValue` of the struct type cannot be descended into. Having `RecordConstructorValue`, makes the element a reachable sub-value and the match findable structurally. This matching happens in #4619; the shape it needs is turned on in #4651. ### Why the shape is recorded rather than inferred The value a plan flows is part of the plan's identity: `RecordQueryExplodePlan` hashes it, so changing the shape changes the plan hash of every `WITH ORDINALITY` plan. A plan serialized by an earlier version flows the opaque value and was hashed as such, and it has to keep doing both when a newer version reads it back. Deciding the shape from `withOrdinality` alone would hence not be backward-compatible. So the shape becomes state the plan records and serializes. This PR adds that state and leaves it at the value every existing plan flows; #4651 changes what newly planned explodes ask for. ### What this PR does `ExplodeExpression` and `RecordQueryExplodePlan` gain a `flowsRecordConstructorValue` flag. What the flag decides differs between the two variants. With ordinality the struct is the result type either way, so only the shape of the value changes: an older version that cannot see the flag still executes such a plan identically. Plainly, the struct is the flag's doing — the result type is the element type without it and a one-column struct with it — so the plan produces that struct rather than the bare element. That is why each variant needs its own PR to turn the shape on: #4651 for the ordinality one, and #4623 for the plain one, which reaches into everything that reads what an explode flows. **Nothing asks for new shape yet.** That will be enabled in later PRs ### Compatibility The flag is serialized in porto. Absent means the opaque value, which is precisely what a plan serialized before the field existed flows, so such a plan keeps both its shape and its hash.
609becf to
65b6627
Compare
> Supersedes #4651, which GitHub marked as merged when this branch was messed up in the stack reordering #4650, now merged, added the `flowsRecordConstructorValue` flag but left every explode flowing the opaque value. This PR turns it on only for `EXPLODE WITH ORDINALS` (`LogicalOperator.generateCorrelatedFieldAccess`). Wrapping the element of a plain explode in a struct of its own is #4623. ### What changes in plans Nothing in `fdb-record-layer-core` changes, and no core test needed updating. What moves is the expected plan of the five `AT` queries that also carry an `IN`: an `IN` over a value read out of a record is costed differently than one over an opaque value. The movement goes in both directions and is worth a look: - `array-join-at:317`, `WHERE "at" IN (1, 2)`: was an explode of the `IN` list joined with a scan, and is now a single scan with a residual `FILTER _._1 + 1 IN @c19`. Cheaper. - `array-join-at:305` and `in-predicate:402`, both `IN` on the primary key: same operators, but the unnesting moves across the in-join boundary — into the in-join in the first, out of it in the second. - `in-predicate:382` and `in-predicate:410`, both `IN` on the unnested value: the `IN` list explode becomes the outer loop with the table scan inside it, i.e. one full scan per `IN` element. This is #4621 — the cost model scores a data access nested under an in-join the same as an unnested one.
d6852b0 to
30bc80a
Compare
…ximum A record type read off a descriptor carries the descriptor's field numbers, and those numbers may skip. `defineProtoType()` writes them back out as the field numbers of the descriptor it generates for the type, so two types that disagree about them generate descriptors that are not wire-compatible with each other. `maximumType()` rebuilt each field with `Field.of(type, name)`, which has no index, so `normalizeFields()` renumbered every field by position. The maximum of two record types that agree on their numbering therefore lost that numbering, and `MessageHelpers.deepCopyMessage()`, which relates the fields of two descriptors by number, would then route a value into the field that happens to carry the same number in the renumbered descriptor -- writing, for instance, a string into a field declared `int64`. Carry the index over whenever both sides agree on it, by the same rule already used for the field name. The indexes are only kept when all of them survive, since `normalizeFields()` renumbers all fields as soon as a single one is missing.
5e9397b to
3c9426f
Compare
constructor, but a plain explode still flowed the bare element. That left the two shapes incomparable: a plain explode on the query side could not be related to a `WITH ORDINALITY` explode on the candidate side, because `MaxMatchMap` descends into a record constructor but not into an opaque value, so there was nothing below the element to match. Every explode now flows a record -- `(element)` or `(element, ordinal)` -- and the element is reachable as `_0` on either side. The flag #4650 introduced is simply set for both variants. Everything that reads what an explode flows follows from that. `FieldValue.ofFieldNamesAndFuseIfPossible` fuses a field access onto an existing field value instead of nesting one inside the other, and the key expression expansion uses it. The pull-up rules require the child of the value being pulled up to be the quantified object value itself, so a nested access could not be pulled through the explode's quantifier at all. An in-join and an in-union bind an element of the collection under `__corr_«alias»`, not the struct the explode flows, so the plans they put underneath have to be translated: `ExplodeExpression.elementBindingTranslationMap` makes each such alias stand for the element alone, which composes `«alias»._0` away to a plain reference to the binding. Neither rule can take an explode with ordinality as a source, since there is no place to put the ordinal. On the SQL side, a quantifier over an unnesting stands for the struct, so the element is reached through `_0`: `LogicalOperator` unwraps it when it builds the unnesting's attributes, `QuantifierValues` sees through it when resolving an index definition down to the base record, and a star expansion over an unnesting expands the element's fields rather than the struct's. The `AT` ordinal is a property of the unnesting rather than a column of the row, so it is ephemeral: nameable, but not part of a star. `ExplodePlanTest` follows the same way: a plain explode flows a record, so the skip-and-limit tests read the element out of it, and the plans that have to flow the element itself ask for that shape explicitly. The hash of a plain explode moves accordingly; the hash a plan that flows the element itself has always had is pinned alongside it.
3c9426f to
515201a
Compare
📊 Metrics Diff Analysis ReportSummary
ℹ️ About this analysisThis automated analysis compares query planner metrics between the base branch and this PR. It categorizes changes into:
The last category in particular may indicate planner regressions that should be investigated. Plan and Metrics ChangedThese queries experienced both plan and metrics changes. This generally indicates that there was some planner change Total: 48 queries Statistical Summary (Plan and Metrics Changed)
Significant Regressions (Plan and Metrics Changed)There were 2 outliers detected. Outlier queries have a significant regression in at least one field. Statistically, this represents either an increase of more than two standard deviations above the mean or a large absolute increase (e.g., 100).
Minor Changes (Plan and Metrics Changed)In addition, there were 46 queries with minor changes. Only Metrics ChangedThese queries experienced only metrics changes without any plan changes. If these metrics have substantially changed, Total: 27 queries Statistical Summary (Only Metrics Changed)
Significant Regressions (Only Metrics Changed)There were 3 outliers detected. Outlier queries have a significant regression in at least one field. Statistically, this represents either an increase of more than two standard deviations above the mean or a large absolute increase (e.g., 100).
Minor Changes (Only Metrics Changed)In addition, there were 24 queries with minor changes. |
7f4ede7 to
6f6111a
Compare
#4696 made a
WITH ORDINALITYexplode flow its(element, ordinal)struct as a record constructor, but a plain explode still flowed the bare element. That left the two shapes incomparable:MaxMatchMapdescends into a record constructor but not into an opaque value, so a plain explode on the query side had nothing below the element for aWITH ORDINALITYexplode on the candidate side to be related to. Every explode now flows a record —(element)or(element, ordinal)— and the element is reachable as_0on either side. The flag #4650 introduced is simply set for both variants.The rest follows from that:
FieldValue.ofFieldNamesAndFuseIfPossiblefuses a field access onto an existing field value rather than nesting one inside the other, and the key expression expansion uses it. The pull-up rules require the child of the value being pulled up to be the quantified object value, so a nested access could not be pulled through an explode's quantifier at all.ExplodeExpression.elementBindingTranslationMap. Neither can take an explode with ordinality as its source._0when building an unnesting's attributes and when resolving an index definition to the base record, a star over an unnesting expands the element's fields, and theATordinal becomes ephemeral — nameable, but not part of a star.ExplodePlanTestfollows the same way: the skip-and-limit tests read the element out of the record a plain explode flows, the plans that have to flow the element itself ask for that shape explicitly, and the hash a plan flowing the element has always had is pinned alongside the plain explode's new one.State
Based on #4696, so the diff also shows #4650 and #4696 until they merge. It borrows nothing from #4619 or #4641: rebased straight onto #4696, every source file outside the three that #4650 itself rewrote applies unchanged, both modules compile, and
:yaml-tests:quickTestcomes back with exactly the failures this branch already had on its previous base — the lower lineage was neither carrying it nor holding it back. The end-to-end tests for an index over an unnesting (unnested-record-type-indexes.yamsql) stay with #4641, which introduces them.Two things are outstanding, both visible in
:yaml-tests:quickTest:field-index-tests-protoandskipped-field-number-protofail without Match fields by position rather than field number when deep-copying messages #4631. Wrapping the element makes a record type's field indexes matter where they did not before, andType.maximumTyperenumbers them. Verified again on this base: cherry-picking Match fields by position rather than field number when deep-copying messages #4631 on top makes both suites pass, so Match fields by position rather than field number when deep-copying messages #4631 belongs underneath this PR.aggregate-index-tests.yamsql:703cannot be planned (UnableToPlanException). The aggregate indexmv20is defined over a fan-out grouping element, and the candidate's select exposes_0rather than the element's fields, so the query side has nothing to match. The value-index form of the same problem is fixed by the expansion changes on Build a match candidate from an index defined on an unnested record type #4641; the aggregate form is not. That failure also blocks regenerating the suite's planner metrics, which have drifted at:506.The SQL
ATordinal stays 1-based here; making it 0-based is #4633's change, which this branch does not build on.