Skip to content

[Query] Share table fragment exchange budgets and consume ready Collect inputs - #18869

Open
ColinLeeo wants to merge 15 commits into
apache:masterfrom
ColinLeeo:exchange-fanin-budget
Open

ColinLeeo wants to merge 15 commits into
apache:masterfrom
ColinLeeo:exchange-fanin-budget

Conversation

@ColinLeeo

@ColinLeeo ColinLeeo commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Per-handle exchange quotas and sequential Collect consumption can leave ready inputs waiting while other inputs hold unused capacity. This change enables ready-input Collect/MappingCollect consumption together with shared exchange quotas only within eligible table-model fragment instances (FIs). Tree-model queries and excluded table FIs retain sequential Collect consumption and independent quotas.

Exchange accounting

  • Decide eligibility after local operator generation, before estimating root operator memory. Require table Collect/MappingCollect, allow only Collect/Union as multi-input nodes, and stop traversal at Exchange boundaries. Keep independent plan-node quotas and sequential Collect consumption for FIs with MergeSort, Join, unsupported multi-input nodes, any TopK (including its single-child form), internal pipelines, or scheduling dependencies. The root pipeline factory is registered after memory estimation; any previously registered child pipeline excludes sharing.
  • FI context owns inputMaxBytes and outputMaxBytes. Give the full F to the only active direction; split INPUT = F / 2, OUTPUT = F - INPUT when both directions charge the FI. Unused directions get zero. Remote SourceHandles charge INPUT; fragment sinks and local queues charge their producing FI's OUTPUT. Fall back if an active direction cannot hold the configured maximum TsBlock.
  • Initialize all shared handles from the FI's corresponding limit. Preserve the public legacy per-handle quota calculation and the original tree-model initialization path. Restore DEBUG logs for independent quotas and add localized FI INPUT/OUTPUT shared-limit logs. MemoryPool keeps shared/independent accounting mode and usage counters, and checks the limit supplied with each request. Pending requests retain that limit for retries. The pool retains no FI context or FI budget fields; physical global capacity still bounds allocations.
  • Check quotas and update accounting under the FI lock with bounded atomic global allocation. Complete waiting futures outside that lock and retain the retry after publishing a waiter, including cancellation synchronization.

Collect and plan properties

  • Keep Collect sequential by default. Register table Collect/Union operators during generation and opt them into ready-input consumption only when their complete FI passes the budget and dependency checks. Choose the mode before memory estimation so retained-state accounting matches execution. Preserve the ready-queue optimization: activate live direct children once, retain asynchronous completions in a queue, and reobserve only the advanced child instead of scanning all inputs per block. Reuse pending-future listeners, preserve a selection during output backpressure, and call operators only on the driver thread. Refresh readiness after null/empty results and propagate failures and cancellation.
  • Retain MappingCollect's child-specific mapping and account for all active children. Clear invalid pre-grouping above unordered Collect/Union, preserve valid streaming aggregation in ordered branches, restore required sorting, and prevent sort elimination across unordered fan-in.
  • Keep OperatorTreeGenerator and visitTopK() identical to the PR base. TopK planning and runtime filter ID handling, along with MergeSort and Join consumption algorithms, remain unchanged. TopK-specific fixes are deferred; this PR excludes those FIs from the new mode.

SQL assertions

  • Preserve the existing TestUtils ResultSet assertion overloads. Add explicit CollectQueryAssertions for the affected tests, using the existing SQL parser to inspect final-result ordering before accepting unordered assertions.
  • Reject final ORDER BY and arbitrary top-level LIMIT/OFFSET on the unordered path. Nested UNION branches, subqueries, window ordering, comments, and string literals are distinguished through the AST. Compare every node independently and preserve duplicate multiplicity.
  • Add explicit outer ORDER BY to tests that require deterministic order. EXPLAIN checks use a separate guarded path to validate each node's plan semantics.

Validation

  • 147 targeted unit tests passed: 19 Collect tests and 128 DataNode tests, covering FI initialization, consistent handle limits, supplied request limits, physical pool bounds, sequential defaults, table-only opt-in, single-child TopK and pipeline fallback, retained-memory accounting, concurrent admission, wakeups, cancellation, and cleanup.
  • Seven deterministic SQL assertion regressions passed, covering final versus nested ordering, arbitrary slices, duplicate counts, and every-node checks.
  • The full English reactor build (including test compilation and targeted unit tests) and Chinese test-compile -DskipTests passed.
  • 23 targeted integration cases passed in both TableSimpleIT and TableClusterIT (three DataNodes), using the same rebuilt node template. Compatibility checks cover original tree-model device/time ordering and unchanged TopK results above a Collect path.
  • Spotless, Checkstyle, license checks, and git diff --check passed. Maven validation used offline mode.

This PR has:

  • been self-reviewed, including concurrent reservation, release, cancellation, and readiness paths.
  • documented modified behaviors in the PR description and code comments.
  • added comments explaining concurrency and ownership decisions.
  • added or updated unit tests.
  • added integration tests.
  • been tested in a test IoTDB cluster.

@ColinLeeo
ColinLeeo requested a balanced review from Copilot October 14, 2026 10:48

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI 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.

🔵 Needs a closer look

An unresolved block-size boundary affects exchange startup, and the change spans concurrent accounting and distributed query planning.

1 open finding

🧠 Review effort: Lite

Copilot AI 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.

🟡 Changes recommended

Shared-budget admission and dependency handling can leave queries stalled despite otherwise available progress paths.

4 open findings

🧠 Review effort: Balanced

@ColinLeeo
ColinLeeo requested a lite review from Copilot October 15, 2026 07:23

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ColinLeeo
ColinLeeo requested a lite review from Copilot October 15, 2026 10:20

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ColinLeeo ColinLeeo changed the title [Query] Share fragment exchange budgets and consume ready Collect inputs [Query] Share table fragment exchange budgets and consume ready Collect inputs Oct 15, 2026
Validate unordered results and EXPLAIN semantics independently on every DataNode while retaining ordered assertions. Add duplicate-preserving checks and deterministic regressions, and resolve receiver column names from metadata.
Read the address column rather than the socket state from lsof output and cover a real listening socket. Allow ClientManagerTest to use an explicitly configured port when local services occupy its default.
Retain independent plan-node quotas for dependent operators and internal pipelines, and fall back when a split side cannot hold one TsBlock. Make memory admission atomic without speculative charges and add regression coverage for MergeSort, queue forwarding, and contention.
Determine charged directions from remote sources and fragment sinks after planning. Split shared budgets only for bidirectional fragments and validate the maximum TsBlock size only for active directions. Add full-budget and local-input ownership regressions.
Cancel pending reservations before FI deregistration and keep legacy accounting independent per plan node. Guard late Collect mode changes, cover planner entry points, and order pipe verification queries by their complete primary key.
@ColinLeeo
ColinLeeo force-pushed the exchange-fanin-budget branch from ab11600 to 5d24b4c Compare October 16, 2026 01:35

@JackieTien97 JackieTien97 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.

Thanks for the quick follow-up in 788a995. I re-ran my probes on it. The shared account is now registered under the same fragmentId.instanceId key that the handles and deregistration use. With four real SourceHandles and F = 1024 B, the FI now reserves at most 1024 B (4 × 1024 B before), and the query entry is gone after deregistration.

Requesting changes for two problems that are still present at 788a995:

  1. Ready consumption under order-sensitive operators (inline on SharedExchangeMemoryEligibility):
    • FILL METHOD PREVIOUS/LINEAR without FILL_GROUP and diff() sit directly on a ready Collect.
    • The child FI that feeds COPY TO is eligible as well.
    • Filled and diff() values become wrong and can change between runs. No IT covers this.
  2. Cancelling pending reservations at FI deregistration (inline on MemoryPool):
    • It fixes a base bug where another query's free() hits the dead FI's request, throws an NPE and leaks pool memory.
    • But now SourceHandle.abort() fails for a handle that only has a pending request.
    • A request published while the FI is being deregistered still makes every later free() on the DataNode throw.

Non-blocking:

  • FIs that never enable ready mode still lose GroupNode and streaming aggregation: they get a full sort, or a hash aggregation that cannot spill (inline on visitGroup).
  • Many multi-region queries without ORDER BY now return rows in an order that varies between runs; several ITs had to switch to unordered or per-node checks. This is worth a line in the release notes.
  • Nits:
    • MemoryPool's maxBytesPerFragmentInstance constructor parameter is now only validated, never used.
    • EXCEPTION_BYTESTORESERVE_SHOULD_BE_IN_LEFT_PAREN_0_COMMA_MAXBYTESPERFI_RIGHT_BRACKET_DOT_M_0753BB69 (en and zh) is no longer referenced.
    • FragmentInstanceContext.inputMaxBytes/outputMaxBytes are only read back inside setExchangeMemoryLimits; local variables would do.

How I checked:

  • PlanTester dumps of every fragment, plus SharedExchangeMemoryEligibility.isEligible, on base d2b97bc and on 788a995.
  • Unit probes using real MemoryPool and SourceHandle objects (details in the inline comments).

Comment on lines +45 to +48
// TopK's input ordering assumptions remain unchanged, including its single-child form.
if (node instanceof TopKNode) {
return false;
}

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.

Order-sensitive single-input operators need an ordered Collect too. TopK is excluded here because it depends on input order. Fill without FILL_GROUP and diff() depend on input order as well, but they are not excluded.

PlanTester on 788a995 (the plans are the same as on base; only the runtime mode changed):

query root FI eligible
SELECT time, tag1, tag2, tag3, s1 FROM table1 FILL METHOD PREVIOUS PreviousFill → Collect(3 × Exchange) true
SELECT time, tag1, tag2, tag3, diff(s1) AS d FROM table1 Project[diff(s1)] → Collect(3 × Exchange) true
SELECT time, s1 FROM table1 WHERE tag1='shenzhen' AND tag2='B2' AND tag3='ZZ' FILL METHOD PREVIOUS PreviousFill → Collect(3 × Exchange) true
same, FILL METHOD LINEAR LinearFill → Collect true
same, FILL METHOD PREVIOUS ORDER BY time Sort → PreviousFill → Collect true
SELECT time, diff(s1) AS d FROM table1 WHERE tag1='shenzhen' AND tag2='B2' AND tag3='ZZ' Project[diff(s1)] → Collect true

Why the values go wrong:

  • In ready mode the Collect hands out one block per ready child in turn; CollectOperatorTest#testAllReadyChildrenHaveLinearSelectionCost asserts exactly that. So a device's rows from one region are interrupted by blocks from other regions.
  • Fill without FILL_GROUP and diff() carry state over from the previous row. After a block boundary, a NULL is filled from a row of whatever block arrived in between, and diff() is computed against that row. The values can change from run to run.
  • This hits multi-device queries without FILL_GROUP, and single devices whose time partitions live in different regions.
  • ORDER BY time does not help, because the Sort sits above the fill.
  • On base the children were drained one after another. Each region's rows stayed contiguous, and the same query returned the same values on every run.

COPY TO: the check cannot see order requirements outside its own FI. COPY table1 TO 'x.tsfile' plans CopyTo → Exchange in the root FI, and IdentitySink → Collect(3 × Exchange) in a child FI that is eligible. The TsFile writer rejects non-increasing timestamps per device (AlignedChunkGroupWriterImpl#checkIsHistoryData). So COPY TO of a device whose partitions live in two regions can now fail depending on block arrival order. Base already depends on the fixed child order there, but it at least behaves the same on every run.

Coverage: IoTDBCollectFanInIT only has a CONSTANT fill. It has no PREVIOUS/LINEAR fill, diff() or COPY TO case, so CI does not catch this.

Suggestion: decide ordering in the distributed planner instead of inferring it from the FI shape on the DataNode.

  • Mark a Collect/Union as unordered only when nothing above it relies on input order, in this FI or in the parent FI.
  • Serialize that flag, with ordered as the default, and enable ready consumption only for unordered Collects.
  • With an ordered default, plans from a coordinator without this PR keep today's semantics during a rolling upgrade.
  • It also lets visitGroup and validateAggregationGrouping keep their streaming forms where no ready Collect is involved (see the comment there).

A narrower fix would be to reject PreviousFillNode/LinearFillNode without grouping keys, and expressions that contain diff(), right here. COPY TO would still need information from the planner.

Comment on lines +692 to +697
} else if (groupingInvalidatedByCollect(
child, node.getOrderingScheme().getOrderBy().subList(0, node.getPartitionKeyCount()))) {
SortNode sort =
new SortNode(queryId.genPlanNodeId(), child, node.getOrderingScheme(), false, false);
nodeOrderingMap.put(sort.getPlanNodeId(), sort.getOrderingScheme());
result.add(sort);

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.

Non-blocking. groupingInvalidatedByCollect treats every multi-child Collect/Union as unordered. But the DataNode only enables ready consumption in eligible FIs. In an FI that is not eligible (TopK, joins, more than one pipeline), the Collect still drains its children one after another, as on base. There, this conversion mostly adds cost, and so does the preGrouped reset in validateAggregationGrouping.

PlanTester, base d2b97bc vs 788a995. In both queries the root FI has eligible=false because of the TopK:

-- base: GroupNode, this PR: SortNode (blocking full sort of the Collect output)
SELECT tag1, tag2, tag3, s1, SUM(s1) OVER (PARTITION BY tag1, tag2, tag3 ORDER BY s1) rs
FROM (SELECT tag1, tag2, tag3, s1, ROW_NUMBER() OVER () n FROM table1) t
WHERE n > 0 ORDER BY rs DESC LIMIT 10

-- base: preGrouped=[tag1, tag2, tag3], this PR: preGrouped=[]
SELECT tag1, tag2, tag3, SUM(s1) AS s
FROM (SELECT tag1, tag2, tag3, s1, COUNT(*) OVER () AS n FROM table1 WHERE tag1 != 'shenzhen') t
WHERE n > 0 GROUP BY tag1, tag2, tag3 ORDER BY s DESC LIMIT 10

The second case matters more. HashAggregationOperator has no spill path (its spillEnabled branch throws UnsupportedOperationException) and keeps every group in memory. So a high-cardinality GROUP BY that streamed on base can now run out of operator memory.

If some of these resets are also meant to fix sequential cases, for example devices that span regions, please limit them to those cases. Otherwise this goes away with the planner-side ordering decision suggested on SharedExchangeMemoryEligibility.

Comment on lines +308 to +316
// Pending requests must not outlive their FI registration. Cancel before removing the
// account so another query's free() cannot retry a request against a missing FI. Do not
// hold the registration lock while notifying cancellation listeners.
if (fragmentRelatedMemory != null) {
for (MemoryReservationFuture<Void> future :
fragmentRelatedMemory.pendingReservations.values()) {
tryCancel(future);
}
}

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.

Cancelling here breaks the code that owns these requests. It does fix a real base bug. On d2b97bc a pending request outlives its FI, and the next free() from any other query hits it: tryReserve throws a NullPointerException, and the request's bytes stay allocated in the pool for good.

The owners (SourceHandle, SinkChannel, SharedTsBlockQueue) rely on two rules: a pending future completes only after a successful reservation, and it is cancelled only by the owner itself through tryCancel(), which returns the pending bytes. This cancellation breaks both rules.

Probe setup: a 64 B pool fully held by query other. A SourceHandle for (q, 0.0) has one 64 B block pending. Then deRegisterFragmentInstanceFromQueryMemoryMap("q", "0.0", true), then free() by other, then handle.abort():

base d2b97bc 788a995
free() by other NPE; 64 B stay reserved with no live reservation ok
handle.abort() ok, onAborted called IllegalArgumentException: RelatedMemoryReserved can't be null when freeing memory; aborted stays false, onAborted not called

What happens on 788a995:

  • The cancellation runs the handle's blockedOnMemory listener while the handle is still live. That submits a GetDataBlocksTask for a block that has no reservation.
  • Later, tryCancel() returns 0 for the already-cancelled future. So abort() tries to free() the pending bytes against the removed account, and throws.
  • In MPPDataExchangeManager#forceDeregisterFragmentInstance, that exception escapes the loop; DriverScheduler only logs it. The FI's remaining handles are not aborted, and its sourceHandles entry is not removed.

Same pattern elsewhere (from reading the code):

  • SinkChannel.abort()/close() call tryCancel(blocked) and then free.
  • In SharedTsBlockQueue, the memory listener enqueues the TsBlock with reservedBytes that were never reserved.

SourceHandleTest#reproduceAbortAfterFragmentInstanceMemoryIsForceDeregistered already covers the case where the handle holds reserved bytes. This change extends the failure to handles that only have a pending request.

Suggestion: detach instead of cancel. Remove the FI's requests from both indexes without completing them. Then no listener fires, and the owner's later tryCancel() still returns the pending bytes. If you want to keep cancelling, the three owners need to treat a future cancelled by the pool as unreserved: no fetch or enqueue in the listener, and no free of its bytes. Either way, please add a regression test: a real SourceHandle blocked on memory, then force deregistration, then abort().

Comment on lines +501 to +506
Iterator<MemoryReservationFuture<Void>> iterator = memoryReservationFutures.values().iterator();
while (iterator.hasNext()) {
MemoryReservationFuture<Void> future = iterator.next();
synchronized (future) {
if (future.isCancelled() || future.isDone()) {
iterator.remove();
continue;
}
long bytesToReserve = future.getBytesToReserve();
String curQueryId = future.getQueryId();
String curFragmentInstanceId = future.getFragmentInstanceId();
String curPlanNodeId = future.getPlanNodeId();
long maxBytesCanReserve = future.getMaxBytesCanReserve();
if (tryReserve(
curQueryId, curFragmentInstanceId, curPlanNodeId, bytesToReserve, maxBytesCanReserve)) {
future.set(null);
iterator.remove();
} else {
rollbackReserve(curQueryId, curFragmentInstanceId, curPlanNodeId, bytesToReserve);
}
if (tryCompleteReservation(future)) {
removePendingReservation(future);
}

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.

A waiter whose FI account is gone makes every free() on the DataNode throw. The cancellation in deRegisterFragmentInstanceFromQueryMemoryMap does not shut out reserveWithPriority. The race:

  1. Deregistration reads pendingReservations (311–316).
  2. A handle then publishes a request (401–402), and its retry at 406 fails without throwing.
  3. Deregistration removes the account at 318. The request is still in memoryReservationFutures.

From then on, every free() releases its bytes and then throws from tryCompleteReservation → tryReserve → getFragmentReservation. Waiters queued behind that request are never reached. The try/catch at 409 only covers a retry that throws.

I forced this interleaving in a probe on 788a995, by hooking the pendingReservations.values() read. Another query's free() then threw IllegalArgumentException: Memory reservation is not registered for query x, fragment 0.0, plan node src. The pool was back to 0 used bytes, but a later 32 B waiter from query y was never woken. The window is narrow: a handle has to still be reserving while its FI is force-deregistered. But the failure lands on unrelated queries.

Suggestion: in this loop, treat a missing FI account as a dead waiter: remove it from the index and continue, instead of throwing. Alternatively, publish and deregister under the same FragmentMemoryReservation monitor, and have reserveWithPriority refuse to publish into a removed account.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants