Summary
A UNION ALL matches its branches' columns by name instead of by position, on every
execution route. SQL-92 7.10 says the result's column NAMES come from the first branch and the
branches are matched positionally; the engine instead looks each of the first branch's names up
in the leg's row, null-fills what it does not find, and appends the rest.
So a branch that projects the right shape with different names loses its values. HTTP 200
throughout. Same family as #205 / #209 / #253: the query runs, returns rows, and the rows are wrong.
Mechanism: multiSearch hands requests.head's output names to parseResponseTree, which applies
ElasticConversion.rowNormalizer - a by-name lookup. SearchApi.unionAllRowNormalizer applies the
same function on the per-leg and licensed-cap routes, so all three routes share the behaviour.
Reproduction (measured on real Elasticsearch 8.18)
Two indices, mapping { id: keyword, category: keyword, tag: keyword, amount: integer }.
1. Same column names, different positions - the guards ADMIT it and column 2 is NULL
SELECT a, b FROM x UNION ALL SELECT a, a FROM y
Branch arity matches and the types match, so neither parse-time guard rejects it. Branch 2's rows
come back (category, R_CAT_n), (tag, null) - column 2 is null where SQL says it holds a's value.
Reproduced on all three routes (one-shot _msearch, per-leg, licensed cap).
2. An alias per branch - the most visible instance, and the first branch is affected too
SELECT id AS x FROM rc_left LIMIT 5 UNION ALL SELECT id AS y FROM rc_right LIMIT 5
Fixture: 1 shard / 0 replicas, two documents each, L_id_1, L_id_2, R_id_1, R_id_2.
Measured at b7f40955 (the merge of #352, before story 22.6):
| spelling |
rows |
LIMIT 5 on both legs (one-shot _msearch) |
{x -> null, y -> L_id_1} {x -> null, y -> L_id_2} {x -> null, y -> R_id_1} {x -> null, y -> R_id_2} |
no LIMIT (also one-shot at b7f40955) |
identical |
Note branch 1's own rows: the column the analyst asked for is null, and its value appears under
the other branch's alias.
3. A duplicate output name lets a leg's extra columns through
SELECT amount, amount FROM bt_a UNION ALL SELECT amount, MAX(amount) AS m FROM bt_b
A duplicate requested name sends rowNormalizer down its legacy per-row path with a requested-name
SET, so the leg's other columns - an internal aggregate key max#m included - survive into
user-visible rows, and the routes disagree:
cap route List(amount) ; List(amount, m, max#m)
per-leg route List(amount) ; List(amount, m, max#m)
one-shot List(amount) ; List(amount, max#m)
Status and scope
- Pre-existing - case 2 is reproduced at
b7f40955 above, and nothing in story 22.6 changed the
one-shot path.
- Where the branches agree on column names, which is the overwhelmingly common case and every
shape any captured BI workload emits, every route agrees and is correct.
- Story 22.6 gave the per-leg and licensed-cap routes the same normaliser the one-shot route already
used, so the three routes now agree except on case 2's first branch (the one-shot route is the
wrong one there) and case 3. Making them agree by copying the one-shot behaviour would have meant
shipping a wrong answer on purpose, so it was deliberately not done.
Suggested fix
Match branches POSITIONALLY, as SQL-92 does, with the first branch supplying the names - i.e. apply
each leg's own output-name mapping first and then zip positionally against the first branch's names,
rather than looking the first branch's names up in a leg that never used them. The duplicate-name
case falls out of the same change.
This touches the ES-native UNION ALL fast path (epic 22 AD-6: "one _msearch when every leg is
bounded, byte-for-byte today's request") and the shared row contract all three routes go through, so
it wants its own change with its own real-Elasticsearch coverage across the three routes.
Summary
A
UNION ALLmatches its branches' columns by name instead of by position, on everyexecution route. SQL-92 7.10 says the result's column NAMES come from the first branch and the
branches are matched positionally; the engine instead looks each of the first branch's names up
in the leg's row, null-fills what it does not find, and appends the rest.
So a branch that projects the right shape with different names loses its values. HTTP 200
throughout. Same family as #205 / #209 / #253: the query runs, returns rows, and the rows are wrong.
Mechanism:
multiSearchhandsrequests.head's output names toparseResponseTree, which appliesElasticConversion.rowNormalizer- a by-name lookup.SearchApi.unionAllRowNormalizerapplies thesame function on the per-leg and licensed-cap routes, so all three routes share the behaviour.
Reproduction (measured on real Elasticsearch 8.18)
Two indices, mapping
{ id: keyword, category: keyword, tag: keyword, amount: integer }.1. Same column names, different positions - the guards ADMIT it and column 2 is NULL
Branch arity matches and the types match, so neither parse-time guard rejects it. Branch 2's rows
come back
(category, R_CAT_n), (tag, null)- column 2 is null where SQL says it holdsa's value.Reproduced on all three routes (one-shot
_msearch, per-leg, licensed cap).2. An alias per branch - the most visible instance, and the first branch is affected too
Fixture: 1 shard / 0 replicas, two documents each,
L_id_1,L_id_2,R_id_1,R_id_2.Measured at
b7f40955(the merge of #352, before story 22.6):LIMIT 5on both legs (one-shot_msearch){x -> null, y -> L_id_1}{x -> null, y -> L_id_2}{x -> null, y -> R_id_1}{x -> null, y -> R_id_2}b7f40955)Note branch 1's own rows: the column the analyst asked for is
null, and its value appears underthe other branch's alias.
3. A duplicate output name lets a leg's extra columns through
A duplicate requested name sends
rowNormalizerdown its legacy per-row path with a requested-nameSET, so the leg's other columns - an internal aggregate key
max#mincluded - survive intouser-visible rows, and the routes disagree:
Status and scope
b7f40955above, and nothing in story 22.6 changed theone-shot path.
shape any captured BI workload emits, every route agrees and is correct.
used, so the three routes now agree except on case 2's first branch (the one-shot route is the
wrong one there) and case 3. Making them agree by copying the one-shot behaviour would have meant
shipping a wrong answer on purpose, so it was deliberately not done.
Suggested fix
Match branches POSITIONALLY, as SQL-92 does, with the first branch supplying the names - i.e. apply
each leg's own output-name mapping first and then zip positionally against the first branch's names,
rather than looking the first branch's names up in a leg that never used them. The duplicate-name
case falls out of the same change.
This touches the ES-native
UNION ALLfast path (epic 22 AD-6: "one_msearchwhen every leg isbounded, byte-for-byte today's request") and the shared row contract all three routes go through, so
it wants its own change with its own real-Elasticsearch coverage across the three routes.