Skip to content

Copy blocks with mutable agg intermediates per local receiver in MSE broadcast exchange - #19353

Open
yashmayya wants to merge 2 commits into
apache:masterfrom
yashmayya:fix-spool-funnel-npe
Open

Copy blocks with mutable agg intermediates per local receiver in MSE broadcast exchange#19353
yashmayya wants to merge 2 commits into
apache:masterfrom
yashmayya:fix-spool-funnel-npe

Conversation

@yashmayya

@yashmayya yashmayya commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Queries fail with an NPE when useSpools = true and the spooled stage outputs mutable aggregation intermediate results (for example, funnelStepDurationStats):

Cannot read field "_timestamp" because "o" is null

The failure is not deterministic. The same query can also return wrong results without an error.

Root cause

BroadcastExchange routes the same block instance to every destination, and local (same-JVM) mailboxes deliver on-heap rows by reference. It is the only exchange that duplicates a block: hash partitions rows disjointly, and singleton/random pick one destination per block. Two receivers on the same server therefore see the same intermediate result objects — for example, the same PriorityQueue<FunnelStepEvent>.

Both receivers mutate these objects. AggregationFunction#merge can mutate its arguments (the funnel implementation calls addAll on the left one), and extractFinalResult drains the queue. Two operator chains that mutate the same PriorityQueue corrupt its heap array. This causes null slots and the NPE in FunnelStepEvent#compareTo. Without concurrent access, the second receiver sees a drained queue and silently returns wrong results.

Today this only fires through spools: a multi-send node fans each block out to its receiver-stage exchanges through a broadcast, and identical partial-aggregation stages get deduplicated into one spooled stage whose output rows hold the queues. But the same exposure exists for any broadcast to two co-located workers, if a broadcast edge ever carries intermediate results. The fix therefore covers all broadcasts, not only spools.

Fix

BroadcastExchange#route now copies blocks that carry aggregation intermediate results (OBJECT columns) instead of sharing them:

  • Every local destination except the first gets its own copy, made by the new RowHeapDataBlock#copyObjectColumns. It clones the row arrays and copies non-null OBJECT cells through the aggregation function's intermediate result serde — the same mechanism that ships these objects across servers.
  • Remote destinations only read the block to serialize it, so they keep the original. All reads of the original block finish before the one by-reference local delivery, so no receiver can mutate it while it is still read.
  • Blocks without OBJECT columns, and serialized (read-only) blocks, are shared by reference as before.

This makes the safety structural: the one exchange that duplicates blocks never hands the same mutable object to two receivers, for spools and for regular multi-worker broadcasts alike. No planner invariant is needed.

Performance

  • Blocks without OBJECT columns are unaffected. This includes broadcast join build sides and plain-projection spools (the common cases). The only added cost is one scan of the cached column-type array per block.
  • When the copy applies (a broadcast of intermediate results to N local destinations): N-1 copies. Only non-null OBJECT cells pay the serde round trip; keys and other cells are shared. These blocks hold post-aggregation output (one row per group per server), and remote receivers already pay the same serde cost today.

Testing

  • BroadcastExchangeTest covers copy isolation, per-column aggregation function mapping, null cells, remote-before-local ordering, sharing of no-OBJECT and serialized blocks, early termination, and the missing-agg-functions precondition.
  • A new WindowFunnelTest regression test runs a funnel GROUP BY CTE that feeds two different consumers under useSpools = true, with the spooled partial aggregation in a leaf stage and in an intermediate stage (below a window function). Without the fix it fails with the NPE above (as PriorityQueue.peek() is null and Index -1 out of bounds variants of the same corruption). The test also asserts that the plan contains a spool, so it cannot pass vacuously if the planner stops deduplicating the shared subtree.

Out of scope

Making AggregationFunction#merge and extractFinalResult non-mutating would remove the root hazard, but mutation in place is a deliberate performance choice that the single-stage engine also relies on. This PR keeps that contract and isolates the receivers instead.

…spool exchange

Queries with useSpools=true fail with an NPE (or return corrupted results)
when the spooled stage's output contains mutable aggregation intermediate
results, e.g. funnelStepDurationStats:

  Cannot read field "_timestamp" because "o" is null
  (FunnelStepEvent.compareTo, called from PriorityQueue sift during merge)

Root cause: a multi-send (spool) MailboxSendNode fans each block out to the
per-receiver-stage exchanges through a plain BroadcastExchange, which routes
the very same block instance to every destination. Local (same-JVM) mailboxes
then deliver the on-heap rows by reference, so two receiver stages running on
the same server observe the same intermediate result objects (e.g. the same
PriorityQueue<FunnelStepEvent>). Both consumers mutate those objects:
AggregationFunction#merge is allowed to mutate its arguments (the funnel
implementation does addAll on the left one), and extractFinalResult drains the
queue. Two operator chains mutating/draining the same PriorityQueue corrupts
its heap array (null slots -> NPE in compareTo) or silently produces wrong
results even without concurrency (the second consumer sees a drained queue).

This never happens without spools because hash/singleton exchanges route each
row to exactly one destination; only the spool fan-out delivers the same rows
to more than one consumer stage.

Fix: route multi-send blocks through a new SpoolBroadcastExchange that gives
every active receiver stage except the first its own copy of blocks carrying
mutable cells (OBJECT columns, i.e. aggregation intermediate results). The
copies round-trip the OBJECT cells through the aggregation function
intermediate result serde (the same mechanism used to ship them across
servers) and are made before the original is handed to a local receiver that
could start mutating it. Blocks without OBJECT columns - the common spool
case - are still shared by reference with zero overhead, and serialized
blocks are read-only so they are shared too.
@yashmayya yashmayya added bug Something is not working as expected multi-stage Related to the multi-stage query engine labels Aug 24, 2026
@yashmayya
yashmayya requested review from gortiz and xiangfu0 August 24, 2026 22:53
@codecov-commenter

codecov-commenter commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 67.48%. Comparing base (52a4a64) to head (542ea1e).
⚠️ Report is 97 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19353      +/-   ##
============================================
+ Coverage     57.71%   67.48%   +9.77%     
- Complexity        7     1430    +1423     
============================================
  Files          2659     3486     +827     
  Lines        159219   224000   +64781     
  Branches      26113    35339    +9226     
============================================
+ Hits          91896   151173   +59277     
- Misses        59512    60822    +1310     
- Partials       7811    12005    +4194     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 67.48% <100.00%> (+9.77%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 67.48% <100.00%> (+9.77%) ⬆️
unittests 67.48% <100.00%> (+9.77%) ⬆️
unittests1 57.55% <100.00%> (-0.16%) ⬇️
unittests2 39.33% <0.00%> (?)

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.

@gortiz gortiz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two structural points on where this fix lives and what copy() actually guarantees. The diagnosis and the mechanics look right — these are about scope and API shape rather than the correctness of what's here.

@Override
protected void route(List<SendingMailbox> destinations, MseBlock.Data block) {
int numDestinations = destinations.size();
if (numDestinations == 1 || !mayContainMutableCells(block)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spooling isn't really the defect here — in-place mutation of a payload that an exchange handed to more than one consumer is. A plain BROADCAST to a stage with two workers on the same server reproduces this with no spool involved.

Looking at the other exchanges: SingletonExchange asserts a single mailbox, RandomExchange picks one destination per block, and HashExchange partitions rows disjointly into fresh blocks. BroadcastExchange.route is the only other route() that hands the same MseBlock.Data instance to N mailboxes — and MailboxSendOperator creates one SendingMailbox per receiver worker, so any two workers of that stage co-located on one server get in-memory mailboxes sharing the block by reference. Same instance, same two consumers mutating it, same corruption.

So the invariant this PR relies on is a two-way one: no edge that duplicates a block ever carries a type that a downstream operator mutates in place. Today that holds only because the exchange above a partial aggregate is always hash/singleton (a broadcast there would double-count), which is a planner property now asserted in a runtime class's javadoc. Two ways to make it structural instead:

  1. Move the copy into BroadcastExchange (or into BlockExchange#sendBlock, for local mailboxes after the first). mayContainMutableCells() is a scan of a cached array (DataSchema caches getStoredColumnDataTypes()), so this is near-free, it covers the multi-worker broadcast case, and it makes SpoolBroadcastExchange unnecessary along with the unenforced invariant documented in BroadcastExchange.
  2. Fix the mutation contract insteadAggregationFunction#merge mutating its left argument and extractFinalResult draining the accumulator are what make a shared block unsafe. That's the deeper fix, but it's a deliberate perf choice, so (1) is the realistic one.

Either way, could you state in the PR description why the multi-worker broadcast case is out of scope, rather than leaving it implied?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm that's a good point, I'll restructure this to remove the spool broadcast exchange and fold it into the regular one. I disagree with the second suggestion of fixing the aggregation function merge mutation contract because as said it'll have a big perf impact.

return this;
}

/// Returns a copy of this block that does not share any mutable cell values with this block.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copy() promises more than it delivers: it copies OBJECT cells and shares every other mutable cell.

"a copy of this block that does not share any mutable cell values" isn't quite what the method does. OBJECT is one of roughly ten stored types backed by a mutable Java object: MAP holds a live Map, BYTES a ByteArray over a byte[], and every *_ARRAY type an int[]/long[]/String[]/Object[]. ColumnDataType.UUID's own javadoc in DataSchema already flags that its placeholder "wraps a mutable 16-byte array".

The javadoc's justification — "cells of all other column types are effectively immutable" — is a statement about current operator behaviour, not about the types, and it's exactly the assumption that will rot. The day an operator sorts an array cell in place or merges into a MAP cell, this method keeps silently sharing it and the bug comes back in a form nobody will connect to this code.

Suggestion: have the method take an EnumSet<ColumnDataType> of the types to copy, so the caller — which knows what operators are downstream — makes that decision explicitly, and the assumption becomes an argument someone has to look at rather than a hidden invariant. It also gives the BroadcastExchange case above somewhere to express a wider policy if it ever needs one. At minimum, renaming to copyAggregationIntermediates() / copyObjectColumns() would stop the name overpromising.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think rename + clarification is the better option here, I'll do that.

@yashmayya yashmayya changed the title Copy blocks with mutable agg intermediates per receiver stage in MSE spool exchange Copy blocks with mutable agg intermediates per local receiver in MSE broadcast exchange Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something is not working as expected multi-stage Related to the multi-stage query engine

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants