rohityadav1993 opened a new pull request, #19122:
URL: https://github.com/apache/pinot/pull/19122

   Hash join materializes the entire right side into a hash table before 
probing, so peak memory grows
   linearly with right-side row count, and multi-column keys fall back to 
`ObjectLookupTable` with a
   composite key allocation per row. When both inputs are already sorted on the 
join keys, a merge join
   avoids the hash table entirely and streams. This is the **Sorted Merge 
join** proposal in #18667.
   
   ### Approach
   
   - **`SortedMergeJoinOperator`** (new) performs a two-pointer merge holding 
one block per side, with
     type-dispatched key comparison, non-equi residual filter support, split 
equi-only and filtered
     paths, `maxRowsInJoin` overflow handling (`THROW` / `BREAK`) applied to 
**both** emitted rows and
     the buffered right run, periodic deadline/termination sampling driven by a 
monotonic row counter,
     and early-termination propagation so a downstream `LIMIT` stops the join 
rather than completing the
     cross-product of buffered input.
   - Selected via `/*+ joinOptions(join_strategy='sorted') */`, carried through 
the plan as
     `JoinNode.JoinStrategy.SORTED` and the new proto value 
`JoinStrategy.SORTED = 3`.
     `RelToPlanNodeConverter` restricts it to `INNER` and `LEFT` joins with at 
least one equi key.
   - **`PinotJoinExchangeNodeInsertRule`** injects a `LogicalSort` below the 
sort exchange on each side,
     so inputs arrive globally sorted rather than merely sorted per sender. 
Collation is
     `ASCENDING NULLS LAST`, and the merge's comparator is null-aware with 
matching nulls-last
     semantics, so a null-key row terminates a run and falls through to the 
outer loop.
     Distribution-type hints are rejected rather than ignored — `broadcast` in 
particular would break
     the merge's partitioning assumption — mirroring how the `lookup` branch 
rejects them.
   - **Colocation** reuses machinery that is already on master. The rule reads 
the existing
     `joinOptions(is_colocated_by_join_keys='true')` hint via
     `PinotHintOptions.JoinHintOptions.isColocatedByJoinKeys(join)` and passes 
it as the
     `prePartitioned` argument of the existing 
`PinotLogicalSortExchange.create(...)` overload. **No new
     field or plumbing is added by this PR** — co-partitioned inputs then get a 
direct 1:1 exchange with
     no changes in `MailboxAssignmentVisitor` or `WorkerManager`.
   - **Observability:** new `MultiStageOperator.Type.SORTED_MERGE_JOIN` (id 17) 
and an
     `InStageStatsTreeBuilder` case, so the operator appears under its own name 
in `stageStats`.
     `LOOKUP` is now an explicit case there and `default:` throws, replacing an 
`assert`-guarded
     fall-through that would have silently mislabelled a future strategy in 
production.
   
   ### No behaviour change without the hint
   
   Joins default to `JoinStrategy.HASH`. `HASH = 0` is the proto default, so a 
plan from an
   older-version broker carrying no `joinStrategy` field deserializes to the 
existing behaviour.
   `PlanNodeDeserializer` throws on an unknown strategy rather than degrading 
to `HASH`, keeping it
   symmetric with `PlanNodeSerializer` — a silent degrade would run e.g. an 
`AS_OF` plan as a hash join
   with its `matchCondition` dropped.
   
   ### Tests
   
   | Test class | Count |
   |---|---|
   | `SortedMergeJoinOperatorTest` (new) | 26 — hash-join parity, multi-block 
streaming, null keys sorted last, error propagation, non-equi conditions, 
overflow modes, early termination, non-globally-sorted input |
   | `QueryCompilationTest` (extended) | 228, incl. 
`testColocatedSortedMergeJoinIsPrePartitioned` and 
`testNonColocatedSortedMergeJoinIsNotPrePartitioned` |
   | `PlanNodeSerDeTest` (extended) | 126, incl. `testJoinStrategySerDe` 
(iterates every `JoinStrategy`, so it fails when a future strategy is added 
without serde wiring) and `testUnknownJoinStrategyFailsFast` |
   | `SortedMergeJoin.json` (new, via `ResourceBasedQueriesTest`) | 5 E2E 
queries validated against H2 |
   | full `pinot-query-planner` suite | 1453 |
   
   ### Known gaps
   
   - **The E2E queries do not assert that the sorted strategy actually ran.** 
All 5 validate against H2
     only, and a hash join produces the identical multiset for every one of 
them. They are correctness
     coverage, not routing coverage.
   - **The colocated pre-partitioned path is not exercised by any test** beyond 
the two planner-level
     assertions above; it has been validated on a cluster (below), not in CI.
   - `SortedMergeJoinOperator` does not extend `BaseJoinOperator` and 
re-implements roughly 120 lines of
     hint and option parsing, which has already begun to drift from the base. 
Worth consolidating in a
     follow-up.
   - `join_strategy='sorted'` on its own still routes the receive side through 
accumulate-then-sort
     unless `streamingSortedMailboxReceive` is also set. The join is correct 
either way; only the
     streaming property of the receive stage is lost.
   
   ### Cluster verification
   
   Two separate runs, on different builds. Both used an 87.8M-doc, 56-segment 
table.
   
   **On the current commit (4 servers, non-colocated, funnel-shaped join):** 
results are byte-identical
   to the hash-join baseline across all 196 output buckets. The join operator's 
`selfExecutionTimeMs` is
   44 ms versus 2,079 ms for hash join, and `timeBuildingHashTableMs` (1,412 
ms) disappears entirely —
   the operator holds no hash table. **End-to-end this query shape is ~1.28x 
slower than hash join**
   (median 907 ms vs 708 ms), because the sorted plan post-filters where hash 
join pushes the
   time-bucket equality into `ON`. The win here is capability and bounded 
memory, not wall clock.
   
   **On an earlier, pre-rebase build (table Murmur-partitioned 128 ways on the 
join key), enabling
   colocation:**
   
   | Metric (per join-input receive) | Colocated | Non-colocated |
   |---|---|---|
   | receive `fanIn` | 1 | 2 |
   | send `fanOut` | 1 (direct 1:1) | 2 (all-to-all) |
   | cross-server bytes | ~0 (same server) | ~50.5 MB `deserializedBytes` |
   | join output rows | 6,693,571 | 6,693,571 (identical) |
   
   This colocation measurement has **not** been re-taken on the current commit; 
the colocation code path
   is unchanged in substance since (it now reuses the upstream `prePartitioned` 
field instead of a
   locally added one). The logical `EXPLAIN` is unchanged either way — the 
difference appears only in
   the dispatched plan and `stageStats`.
   
   **Caveats for colocation:** the query must carry the table partition hints 
so `WorkerManager` can
   validate hint-vs-actual partition info; `partition_size` must divide the 
actual partition count; and
   on datasets with empty partitions a smaller `partition_size` is needed to 
route through
   `assignMultiplePartitionsPerWorker`, since `assignOnePartitionPerWorker` 
requires a segment for every
   partition (a pre-existing limitation).
   
   ### Stacking
   
   > Stacked on #19121. The GitHub diff for this PR includes its parents' 
commits until they merge —
   > **review only the top commit.** This PR compiles independently of its 
immediate parent; it is
   > stacked for review ordering, since the three PRs together implement one 
feature.
   
   Part of #18667.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to