Skip to content

Add streaming sorted merge join with colocation support - #19122

Draft
rohityadav1993 wants to merge 3 commits into
apache:masterfrom
rohityadav1993:oss/pr3-sorted-merge-join
Draft

Add streaming sorted merge join with colocation support#19122
rohityadav1993 wants to merge 3 commits into
apache:masterfrom
rohityadav1993:oss/pr3-sorted-merge-join

Conversation

@rohityadav1993

Copy link
Copy Markdown
Contributor

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.

@codecov-commenter

codecov-commenter commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.23275% with 204 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.60%. Comparing base (a8b207e) to head (ba25587).
⚠️ Report is 17 commits behind head on master.

Files with missing lines Patch % Lines
...bine/StreamingSelectionOrderByCombineOperator.java 72.41% 39 Missing and 17 partials ⚠️
...uery/runtime/operator/SortedMergeJoinOperator.java 82.88% 26 Missing and 19 partials ⚠️
...runtime/operator/SortedMailboxReceiveOperator.java 84.35% 20 Missing and 8 partials ⚠️
...rator/query/StreamingSelectionOrderByOperator.java 88.26% 21 Missing and 6 partials ⚠️
...me/operator/utils/BlockingMultiStreamConsumer.java 81.81% 10 Missing and 4 partials ⚠️
...he/pinot/query/planner/logical/PlanFragmenter.java 84.44% 4 Missing and 3 partials ⚠️
...not/query/runtime/operator/MultiStageOperator.java 14.28% 6 Missing ⚠️
...va/org/apache/pinot/core/plan/CombinePlanNode.java 50.00% 0 Missing and 4 partials ⚠️
...e/pinot/query/runtime/InStageStatsTreeBuilder.java 20.00% 3 Missing and 1 partial ⚠️
.../apache/pinot/query/planner/plannode/JoinNode.java 40.00% 2 Missing and 1 partial ⚠️
... and 6 more
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19122      +/-   ##
============================================
+ Coverage     65.49%   65.60%   +0.11%     
  Complexity     1423     1423              
============================================
  Files          3430     3435       +5     
  Lines        218010   219177    +1167     
  Branches      34648    34920     +272     
============================================
+ Hits         142784   143796    +1012     
- Misses        63666    63758      +92     
- Partials      11560    11623      +63     
Flag Coverage Δ
custom-integration1 100.00% <ø> (ø)
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 65.60% <81.23%> (+0.11%) ⬆️
temurin 65.60% <81.23%> (+0.11%) ⬆️
unittests 65.60% <81.23%> (+0.11%) ⬆️
unittests1 57.05% <81.23%> (+0.19%) ⬆️
unittests2 37.71% <1.28%> (-0.14%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

An unbounded leaf-stage ORDER BY (as injected for sorted merge join inputs)
routes to MinMaxValueBasedSelectionOrderByCombineOperator, which merges every
segment's rows into a single block before returning anything. At large data
volumes this exceeds the leaf stage's CPU budget and ThreadAccountant raises
EarlyTerminationException inside SelectionOperatorUtils.mergeWithOrdering(),
surfacing to the broker as a spurious "Cancelled by sender".

This adds a streaming alternative, opt-in via the `streamingSelectionOrderBy`
query option:

- StreamingSelectionOrderByOperator emits sorted blocks incrementally for a
  segment that is physically sorted on the leading ORDER BY column, reading
  the sorted forward index in order instead of building a priority queue.
- StreamingSelectionOrderByCombineOperator performs a k-way heap merge across
  segment operators and emits bounded blocks (`streamingSelectionOrderByBlockSize`,
  default 10000) rather than one materialized result.
- SelectionPlanNode and CombinePlanNode select these operators when the option
  is set and the sortedness precondition holds; otherwise behaviour is unchanged.

Part of apache#18667.
SortedMailboxReceiveOperator accumulates every row from all senders and then
sorts, even when each sender's stream is already sorted on the collation keys
(noted as a TODO in the class). That defeats streaming and makes the receive
stage's peak memory proportional to the full input.

When the sending stage is known to produce sorted output, the receiver now
performs a k-way heap merge across mailboxes and emits bounded sorted blocks
as data arrives:

- BlockingMultiStreamConsumer gains a StreamHandle<T> abstraction with
  awaitDataOrTerminal()/poll(), so a consumer can peek the head of each stream
  without draining it — the primitive the heap merge needs.
- PlanFragmenter/PinotLogicalQueryPlanner propagate sender-side collation so
  the receiver can tell whether its inputs are individually sorted.
- Opt-in via the `streamingSortedMailboxReceive` query option, with
  `streamingSortedMailboxReceiveBlockSize` controlling emitted block size.
  Without the option, the existing accumulate-then-sort path is used unchanged.

Part of apache#18667.
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.

- SortedMergeJoinOperator performs a two-pointer merge with lazy block reads
  (one block per side held in memory), type-dispatched key comparison, non-equi
  residual filter support, split equi-only/filter paths, maxRowsInJoin overflow
  handling (THROW/BREAK) on both the buffered right-key run and the emitted
  rows, periodic termination checks, and early-termination propagation so a
  downstream LIMIT stops the join instead of completing the full cross-product
  of buffered input.
- Selected via /*+ joinOptions(join_strategy='sorted') */, carried through the
  plan as JoinStrategy.SORTED.
- PinotJoinExchangeNodeInsertRule injects a LogicalSort below the sort exchange
  so both inputs arrive globally sorted, not merely sorted per sender. Join keys
  are collated NULLS LAST, matching the operator's key comparator.
- Colocation: joinOptions(is_colocated_by_join_keys='true') sets the existing
  prePartitioned field on the sort exchange, reusing the hash-join
  prePartitioned machinery, so co-partitioned inputs get a direct 1:1 exchange
  (receive fanIn drops to 1, no cross-server shuffle) with no changes needed in
  MailboxAssignmentVisitor or WorkerManager.

Part of apache#18667.
@rohityadav1993
rohityadav1993 force-pushed the oss/pr3-sorted-merge-join branch from f62bb17 to 3d2219d Compare August 5, 2026 19:56
@Jackie-Jiang Jackie-Jiang added release-notes Referenced by PRs that need attention when compiling the next release notes query Related to query processing performance Related to performance optimization multi-stage Related to the multi-stage query engine feature New functionality labels Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New functionality multi-stage Related to the multi-stage query engine performance Related to performance optimization query Related to query processing release-notes Referenced by PRs that need attention when compiling the next release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants