Conversation
Review ChecklistHello reviewers! 👋 Please follow this checklist when reviewing this Pull Request. General
Tests
Documentation
New flags
If a workflow is added or modified:
Backward compatibility
|
There was a problem hiding this comment.
Pull request overview
Fixes #19545 by preventing incorrect shard-level merging when a reference table is the preserved side of an outer join.
Changes:
- Blocks unsafe outer-join route merging.
- Adds LEFT and rewritten RIGHT JOIN plan coverage.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
join_merging.go |
Prevents duplicate preserved rows across shards. |
reference_cases.json |
Verifies ApplyJoin fallback plans. |
| // every shard, so an unmatched preserved row would come back once per shard | ||
| // instead of once. A single-shard route runs once and cannot duplicate. | ||
| if !jm.joinType.IsInner() { | ||
| if !routingB.OpCode().IsSingleShard() { |
There was a problem hiding this comment.
Should we account for EqualUnique resolving to zero shards here? For example, this can still be merged into a single route:
ref_with_source
LEFT JOIN (SELECT * FROM user WHERE id = :id) AS u ON ...If :id is NULL—or a unique lookup has no mapping—the route resolves to DestinationNone. The merged SQL then executes nowhere, dropping the preserved reference rows. I think we should either exclude EqualUnique here or propagate NoRoutesSpecialHandling for this merge so it executes once on an arbitrary shard when routing finds no destination.
There was a problem hiding this comment.
Reproduced. With :id bound to NULL the vindex maps to DestinationNone, resolveShards comes back with zero shards, and executeShards short-circuits to an empty result — so the preserved reference rows are gone. A lookup vindex with no mapping for the value gets there the same way.
I went with the second option, propagating NoRoutesSpecialHandling, rather than excluding EqualUnique. Excluding it would remove the merge entirely rather than narrow it: in a sharded keyspace EqualUnique is the only single-shard opcode that reaches this branch — Unsharded and Reference are anyShard and are handled in the case above — so the allowance would become dead code and the N+1 shape from the earlier round would come back. With the flag, the zero-destination case runs on an arbitrary shard, where the predicate that routed nowhere matches nothing, which leaves exactly the preserved rows with NULLs that the outer join owes.
Pushed as a NoRoutesSpecialHandling field on the Route operator, set on this merge and propagated in routeToEngineRoute. The flag is not part of the plan description, so the golden files don't move — the regression test asserts it on the built primitive instead (TestReferencePreservedByOuterJoinRunsEvenWithoutADestination, red before the change, green after). planbuilder, operators, engine and vtgate suites pass, scripts/fmt clean.
One sibling I ran into while checking this, pre-existing rather than from this PR: the a == dual branch makes the same single-shard-is-enough call and has the same gap.
select 1 from (select 1 from dual) as d
left join (select * from `user` where id = :id) as u on u.col = 1
plans as a single EqualUnique route with the flag unset, so a NULL :id returns no row where the outer join owes one. Now that the field exists it's one line plus a test there. Happy to include it here or leave it for a separate PR — whichever you prefer.
Signed-off-by: Endika Iglesias <endika2@gmail.com>
Signed-off-by: Endika Iglesias <endika2@gmail.com>
Signed-off-by: Endika Iglesias <endika2@gmail.com>
d54f936 to
d84fbfb
Compare
…angelog Signed-off-by: Endika Iglesias <endika2@gmail.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The DML exemptions can cause incomplete or repeated reference-table writes.
Review details
Suppressed comments (2)
go/vt/vtgate/planbuilder/operators/join_merging.go:136
- This DML exemption is unsafe when the preserved reference table is itself the target and the other side is single-shard. For example,
delete r from ref r left join user u on u.id = 1adoptsu'sEqualUniquerouting here, so the merged delete runs on only one shard even thoughrefhas a physical copy on every shard; the copies then diverge. Reference-target DML needs to retain/force all-shard routing (while sharded-side targets may keep the current exemption), with a routed-predicate regression test.
targets := ctx.SemTable.DMLTargets
isDirectDMLRowSource := targets.NotEmpty() &&
TableID(preserved).Merge(TableID(other)).IsOverlapping(targets)
go/vt/vtgate/planbuilder/operators/join_merging.go:136
- Target overlap still does not establish that this join is the direct row source of a routed DML. With schema tracking, a multi-target update such as
update ref_with_source r left join user u on r.col = u.col set r.tt = u.col, u.foo = 3is converted toDMLWithInputbefore this merge (operators/update.go:129-130,159-199), whileDMLTargetsstill contains both tables. This condition therefore permits a scatter SELECT that returns every unmatchedrrow once per shard;engine/dml_with_input.go:116-141then executes the non-literal reference-table update once per duplicate input row, repeating writes and triggers. The exemption needs an explicit indication that the join is directly under a routed Update/Delete rather than merely checking target overlap.
targets := ctx.SemTable.DMLTargets
isDirectDMLRowSource := targets.NotEmpty() &&
TableID(preserved).Merge(TableID(other)).IsOverlapping(targets)
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Signed-off-by: Endika Iglesias <endika2@gmail.com>
031e88e to
2700c94
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2700c94f24
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| route, ok := primitive.(*engine.Route) | ||
| require.True(t, ok, "the join is expected to be merged into a single route, got %T", primitive) | ||
| require.Equal(t, engine.EqualUnique, route.Opcode) |
There was a problem hiding this comment.
Exercise the no-destination path in the test
This test only builds a plan and inspects NoRoutesSpecialHandling; it never supplies a value whose lookup resolves to no destination or executes the route, despite its name and assertion message claiming that preserved rows are returned in that case. A regression in engine.Route that ignores the flag would therefore leave this test green, so add an execution-level case that produces no destination and asserts the preserved reference row is returned.
AGENTS.md reference: AGENTS.md:L79-L80
Useful? React with 👍 / 👎.
| func (jm *joinMerger) merge(ctx *plancontext.PlanningContext, op1, op2 *Route, r Routing, conditions ...engine.Condition) *Route { | ||
| // The rows a join further down preserved are not read either when this merge is the DML's row | ||
| // source, so the target exempts them the same way it exempts the ones this join preserves. | ||
| preserved, canMerge := referenceRowsInvariant(r, owesReferenceRows(ctx, jm.joinType, op1, op2), op1, op2) |
There was a problem hiding this comment.
Drop preservation when a later join consumes the rows
referenceRowsInvariant unconditionally inherits preservation from both inputs even when a preserved route is the non-preserved RHS of this later join. For example, in user u LEFT JOIN (ref r LEFT JOIN user x ON r.col = x.id AND x.id = 1) ON u.id = x.id, the inner unmatched reference rows have x.id = NULL and cannot be emitted by the outer join, while the only matching x.id = 1 row is safely colocated by the shared unique vindex. The inherited flag nevertheless rejects the scatter merge and leaves an ApplyJoin that can issue an RHS lookup for every user row; only preservation that can affect the merged join's output should constrain its fanout.
AGENTS.md reference: AGENTS.md:L159-L160
Useful? React with 👍 / 👎.
Signed-off-by: Endika Iglesias <endika2@gmail.com>
Signed-off-by: Endika Iglesias <endika2@gmail.com>
08cd707 to
793b75f
Compare
|
@GrahamCampbell ready for another pass whenever you have a moment — everything from your Only DCO has run: the other workflows are still in Also still labelled |
Description
A reference table has the same rows on every shard, so merging it into the shard routes runs the whole join once per shard. That is correct for an inner join, but when the reference table is the preserved side of an outer join, each unmatched preserved row comes back once per shard and vtgate concatenates the duplicates.
ref LEFT JOIN sharded— and thesharded RIGHT JOIN refthat is rewritten into it — planned as a single Scatter route.The merge is now blocked for non-inner joins on that branch. The existing ApplyJoin fallback then plans the preserved side as a Reference route and the other side as a scatter probe, which yields each preserved row exactly once:
Inner joins, and outer joins whose preserved side is the sharded table, are unaffected — the whole planbuilder golden suite passes with no regeneration.
Related Issue(s)
Fixes #19545
Checklist
Deployment Notes
Affected queries previously returned duplicated rows and will now return the correct ones, and their plans change from a single scatter to an ApplyJoin. Worth a release-note callout.
AI Disclosure
AI-assisted: the comments, pr text and its tests were written with Claude Code, from a diagnosis and fix I had worked out beforehand and reviewed afterwards.