Skip to content

ES|QL:Fix Tdigest percentiles circuit breaker - #155586

Merged
hawkhuang-collab merged 50 commits into
elastic:mainfrom
hawkhuang-collab:fix/tdigest-percentiles-circuit-breaker
Aug 15, 2026
Merged

ES|QL:Fix Tdigest percentiles circuit breaker#155586
hawkhuang-collab merged 50 commits into
elastic:mainfrom
hawkhuang-collab:fix/tdigest-percentiles-circuit-breaker

Conversation

@hawkhuang-collab

@hawkhuang-collab hawkhuang-collab commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes: #1639

Important Notes

This fix is targeting data node OOMs and will solve the the incident and we've also made the coordinator side that no memory tracking explicit. Good news is, typically the data node will trim data to topN, for example top 25 buckets only and thus what coordinator node received is rather small and practically it is safe.

Problem

AbstractTDigestPercentilesAggregator creates one HistogramUnionState sketch per collected bucket. Three bugs made this memory invisible to the circuit breaker and prevented proper cleanup.

1. NOOP_BREAKER during collection. Every sketch was created with NOOP_BREAKER:

state = HistogramUnionState.create(HistogramUnionState.NOOP_BREAKER, executionHint, compression);

NoopCircuitBreaker ignores every byte. All TDigest memory (centroid arrays, sort buffers) was invisible to the request breaker. Under a high-cardinality TERMS + PERCENTILES query the heap fills up and the node OOMs rather than the breaker throwing CircuitBreakingException.

2. Bytes never released after buildAggregation. doClose() called Releasables.close(states), which frees the backing ObjectArray container but never calls HistogramUnionState.close() on the elements inside it. Breaker bytes leaked permanently.

3. Reducer inherited a closed breaker. getLeaderReducer() called HistogramUnionState.createUsingParamsFrom(shard_state), copying the shard's breaker into the merge accumulator. If that breaker was a PreallocatedCircuitBreaker already torn down before reduction ran, any allocation would throw.

Fix

1. Pass context.breaker() instead of NOOP_BREAKER

context is a protected final field from AggregatorBase. Passing the real REQUEST breaker wires up the full tracking chain:

HistogramUnionState.create(context.breaker())
  -> stores this.breaker
  -> getOrInitializeTDigestState() -> TDigestState.create(breaker, ...)
    -> new MemoryTrackingTDigestArrays(breaker)
      -> every centroid array allocation charges the real REQUEST breaker

MemoryTrackingTDigestArrays was already wired correctly through HistogramUnionState. It was just never reached because NOOP_BREAKER was passed at the top.

2. Return bytes in takeState(), not in doClose()

There is a timing constraint that shapes the fix: InternalAggregation has no Releasable lifecycle (see #99815). By the time the result is serialized, the PreallocatedCircuitBreaker wrapping the REQUEST breaker is already closed. Any addWithoutBreaking() call at that point throws IllegalStateException: already closed. The only window to return bytes is inside buildAggregation, while the aggregation context is still open.

buildAggregation() calls takeState() instead of getState(). takeState() nulls the slot in states (so doClose() skips it) and returns the bytes immediately:

context.breaker().addWithoutBreaking(-state.ramBytesUsed());

This runs while the aggregation context is still open. The state's data stays intact for serialization and reduction; only the breaker accounting is released.

3. Fix doClose() to close elements individually

try {
    for (long i = 0; i < states.size(); i++) {
        HistogramUnionState state = states.get(i);
        if (state != null) {
            Releasables.closeWhileHandlingException(state);
        }
    }
} finally {
    Releasables.close(states);
}

closeWhileHandlingException keeps going if one slot fails. doClose() now only handles the failure path (states not handed off via takeState()). The null guard on states itself covers the case where the breaker trips inside the constructor before the field is assigned.

4. Fix the reducer to use NOOP_BREAKER explicitly

getLeaderReducer() now passes NOOP_BREAKER explicitly to createUsingParamsFrom(). For same node reduction, the shard's PreallocatedCircuitBreaker is already closed before reduction runs, so any charge would throw IllegalStateException.
AggregatorReducer.close() is a no-op, so a live breaker would charge bytes that are never returned.

Background

Issue #99815 ("Integrate TDigestState with circuit breakers") was closed noting that the ES|QL path was fixed but the _search agg path "would require bigger refactors." MemoryTrackingTDigestArrays and the breaker threading inside HistogramUnionState now exist and are complete which make this PR much easier.

To Consider

MovingPercentilesPipelineAggregator, AbstractBoxplotAggregator and AbstractTDigestOrExponentialMergingAggregator also still using NOOP_BREAKER, they could OOM the same way. Separated follow up PRs.

`AbstractTDigestPercentilesAggregator` was creating every per-bucket
`HistogramUnionState` with `NOOP_BREAKER`, making all TDigest memory
invisible to the request circuit breaker. Under high-cardinality
`TERMS` + `PERCENTILES` queries the heap could fill and OOM the node
instead of the breaker throwing `CircuitBreakingException` and
rejecting the query gracefully.

Pass `context.breaker()` so the full tracking chain activates:
`HistogramUnionState` -> `TDigestState` -> `MemoryTrackingTDigestArrays`,
which charges every centroid array allocation against the REQUEST breaker.

Also fix `doClose()` to close each `HistogramUnionState` individually
before releasing the backing array, so the bytes charged at creation
are returned to the breaker rather than leaked in its accounting.

Closes elastic#99815 (_search agg path)
@hawkhuang-collab hawkhuang-collab added >bug Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) :Analytics/ES|QL AKA ESQL and removed v9.6.0 labels Jul 31, 2026
Remove element-level close from doClose: HistogramUnionState is
aliased by the InternalAggregation built in buildAggregation, and
AggregatorCollector calls releaseAggregations() immediately after
buildTopLevel(), making element-level close unsafe. Bytes from
collection states are not explicitly returned; that requires
making InternalTDigestPercentiles Releasable (deferred, elastic#99815).

Add testCircuitBreakerTripsOnHighCardinality to verify the REQUEST
breaker trips during collection when centroid arrays exceed the
configured limit, confirming the context.breaker() fix works.
The HistogramUnionState built during collection holds a reference to
the PreallocatedCircuitBreaker from the aggregation context. That
breaker is closed when the context closes (after buildTopLevel()),
before the reduce phase runs on the coordinator.

When getLeaderReducer() called createUsingParamsFrom(percentiles.state)
it inherited the now-closed breaker, causing
  IllegalStateException: already closed
at addEstimateBytesAndMaybeBreak inside TDigestState.createUsingParamsFrom.

Fix: add a breaker-explicit overload to both TDigestState and
HistogramUnionState createUsingParamsFrom, and pass NOOP_BREAKER when
creating the reduce-phase accumulator. Wire-deserialized states already
used NOOP_BREAKER; this brings local-reduce into parity. The merged
accumulator is never closed, so NOOP_BREAKER is correct here.
…STANCE

testCircuitBreakerTripsOnHighCardinality used new MatchAllDocsQuery()
directly; ES policy requires Queries.ALL_DOCS_INSTANCE instead.
buildAggregation() previously returned a live HistogramUnionState
reference to InternalTDigestPercentiles while doClose() would also
attempt to close it (via Releasables.close(states) which only closes
the ObjectArray container, not its elements -- but the element-level
close was missing entirely).

Add takeState() to the base class: atomically reads and nulls out a
slot, transferring ownership to the caller. Both buildAggregation()
overrides now use takeState() instead of getState() so doClose() can
no longer double-close a state that was handed off successfully.

doClose() now iterates over the remaining (non-null) slots and closes
each one individually before closing the container. This ensures
circuit-breaker bytes are returned on the failure path -- i.e. when
a CircuitBreakingException aborts collection before buildAggregation
is ever called, leaked states are released rather than left charged
against the breaker until the parent breaker resets.

Also guard against states == null in doClose(): a cranky circuit
breaker can trip inside the constructor before the field is assigned.
Release breaker bytes in takeState() while the aggregation
context is still open — the only safe window before the
PreallocatedCircuitBreaker closes. Replace merge() swap
logic with merged.add() to prevent the accumulator from
inheriting a shard-side breaker that may already be closed.
Use closeWhileHandlingException in doClose() so a failure
on one slot does not prevent the remaining slots and the
container from being released.
Verify that doClose() returns all partial breaker bytes after
CircuitBreakingException by asserting the REQUEST breaker is
at zero after expectThrows. Add explicit null check inside the
doClose loop so taken slots are visibly skipped rather than
relying on Releasables null-tolerance.
Remove diff-anchored and redundant comments; fix em dashes in
Javadoc; trim doClose block comment to two lines; extract
requestBreakerService() helper to reduce test setup boilerplate.
@hawkhuang-collab
hawkhuang-collab marked this pull request as ready for review August 7, 2026 12:58
@elasticsearchmachine

Copy link
Copy Markdown
Collaborator

Pinging @elastic/es-analytical-engine (Team:Analytics)

});
}

private void withSequentialIndex(int docCount, CheckedConsumer<DirectoryReader, IOException> body) throws IOException {

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.

It does?


@Override
protected void doClose() {
// states is null if the constructor threw before assigning it because assignment is after super()

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.

As discussed offline, the assignment being after super() is only telling half the story: super() registers this in the constructor, so doClose can be called before the constructor of this class finishes.

  1. Please update the above comment to reflect this.
  2. Please update AggregatorBase#doClose javadoc to reflect that this method may be called before the constructor has finished.

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.

Yeah, updated

// The shard's breaker is a PreallocatedCircuitBreaker closed with the aggregation
// context, before reduction runs on the coordinator. NOOP_BREAKER is correct: the
// accumulator is short-lived and never closed explicitly.
merged = HistogramUnionState.createUsingParamsFrom(percentiles.state, HistogramUnionState.NOOP_BREAKER);

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.

Gal's Minion 🤖: No argument with the scoping. Could you open a follow-up issue before this merges, though? Right now both deferrals only live in places that vanish on merge — this thread and the "To Consider" section.

Worth covering two separate things:

  1. Coordinator-side accounting: getLeaderReducer and the AbstractInternalTDigestPercentiles(StreamInput) path, plus MovingPercentilesPipelineAggregator. Agreed that topN trimming bounds this in practice.
  2. The shard-level siblings from "To Consider": AbstractBoxplotAggregator.getExistingOrNewHistogram and AbstractTDigestOrExponentialMergingAggregator.getExistingOrNewHistogram are copies of the method being fixed here, NOOP_BREAKER and all, and their doClose() closes only the ObjectArray, never the elements. Those sit on the in-scope side of your own line — same data node, same per-bucket sketch, same high-cardinality terms shape — so boxplot can still OOM the node after this merges.

Linking the issue from the inline comment on this line would help too; as written it says what NOOP_BREAKER does here, but not that the gap is deliberate.

@hawkhuang-collab

hawkhuang-collab commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Mute these lookup join test case, they are revealed by our BWC test PR, make change here to get the build pass, but they have nothing to do with this PR.

@hawkhuang-collab
hawkhuang-collab merged commit 2cf458e into elastic:main Aug 15, 2026
43 checks passed
@elasticsearchmachine

Copy link
Copy Markdown
Collaborator

💚 Backport successful

Status Branch Result
9.4
9.5

elasticsearchmachine pushed a commit that referenced this pull request Aug 15, 2026
* Integrate circuit breaker into TDigest percentiles agg

`AbstractTDigestPercentilesAggregator` was creating every per-bucket
`HistogramUnionState` with `NOOP_BREAKER`, making all TDigest memory
invisible to the request circuit breaker. Under high-cardinality
`TERMS` + `PERCENTILES` queries the heap could fill and OOM the node
instead of the breaker throwing `CircuitBreakingException` and
rejecting the query gracefully.

Pass `context.breaker()` so the full tracking chain activates:
`HistogramUnionState` -> `TDigestState` -> `MemoryTrackingTDigestArrays`,
which charges every centroid array allocation against the REQUEST breaker.

Also fix `doClose()` to close each `HistogramUnionState` individually
before releasing the backing array, so the bytes charged at creation
are returned to the breaker rather than leaked in its accounting.

Closes #99815 (_search agg path)

* Revert unsafe doClose; add circuit breaker test

Remove element-level close from doClose: HistogramUnionState is
aliased by the InternalAggregation built in buildAggregation, and
AggregatorCollector calls releaseAggregations() immediately after
buildTopLevel(), making element-level close unsafe. Bytes from
collection states are not explicitly returned; that requires
making InternalTDigestPercentiles Releasable (deferred, #99815).

Add testCircuitBreakerTripsOnHighCardinality to verify the REQUEST
breaker trips during collection when centroid arrays exceed the
configured limit, confirming the context.breaker() fix works.

* fix reduce-path crash: use NOOP_BREAKER in getLeaderReducer

The HistogramUnionState built during collection holds a reference to
the PreallocatedCircuitBreaker from the aggregation context. That
breaker is closed when the context closes (after buildTopLevel()),
before the reduce phase runs on the coordinator.

When getLeaderReducer() called createUsingParamsFrom(percentiles.state)
it inherited the now-closed breaker, causing
  IllegalStateException: already closed
at addEstimateBytesAndMaybeBreak inside TDigestState.createUsingParamsFrom.

Fix: add a breaker-explicit overload to both TDigestState and
HistogramUnionState createUsingParamsFrom, and pass NOOP_BREAKER when
creating the reduce-phase accumulator. Wire-deserialized states already
used NOOP_BREAKER; this brings local-reduce into parity. The merged
accumulator is never closed, so NOOP_BREAKER is correct here.

* fix forbidden API: replace MatchAllDocsQuery with Queries.ALL_DOCS_INSTANCE

testCircuitBreakerTripsOnHighCardinality used new MatchAllDocsQuery()
directly; ES policy requires Queries.ALL_DOCS_INSTANCE instead.

* fix doClose: null-out states after buildAggregation, close elements

buildAggregation() previously returned a live HistogramUnionState
reference to InternalTDigestPercentiles while doClose() would also
attempt to close it (via Releasables.close(states) which only closes
the ObjectArray container, not its elements -- but the element-level
close was missing entirely).

Add takeState() to the base class: atomically reads and nulls out a
slot, transferring ownership to the caller. Both buildAggregation()
overrides now use takeState() instead of getState() so doClose() can
no longer double-close a state that was handed off successfully.

doClose() now iterates over the remaining (non-null) slots and closes
each one individually before closing the container. This ensures
circuit-breaker bytes are returned on the failure path -- i.e. when
a CircuitBreakingException aborts collection before buildAggregation
is ever called, leaked states are released rather than left charged
against the breaker until the parent breaker resets.

Also guard against states == null in doClose(): a cranky circuit
breaker can trip inside the constructor before the field is assigned.

* fix takeState byte release and reducer swap

Release breaker bytes in takeState() while the aggregation
context is still open — the only safe window before the
PreallocatedCircuitBreaker closes. Replace merge() swap
logic with merged.add() to prevent the accumulator from
inheriting a shard-side breaker that may already be closed.
Use closeWhileHandlingException in doClose() so a failure
on one slot does not prevent the remaining slots and the
container from being released.

* add breaker-balance assert; null-check doClose slots

Verify that doClose() returns all partial breaker bytes after
CircuitBreakingException by asserting the REQUEST breaker is
at zero after expectThrows. Add explicit null check inside the
doClose loop so taken slots are visibly skipped rather than
relying on Releasables null-tolerance.

* clean up comments and simplify test boilerplate

Remove diff-anchored and redundant comments; fix em dashes in
Javadoc; trim doClose block comment to two lines; extract
requestBreakerService() helper to reduce test setup boilerplate.

* add cranky breaker test; extract withSequentialIndex/collectWithBreaker helpers

* restore merge() helper; only the NOOP_BREAKER initialisation needed to change

* Update docs/changelog/155586.yaml

* Update docs/changelog/155586.yaml

* update comments.

* address review feedback

- @nullable + final on takeState(); document null cases
- doClose(): early exit, drop redundant null guard in loop,
  remove try/finally (closeWhileHandlingException never rethrows)
- NOOP_BREAKER comment: honest about coordinator gap + follow-up
- remove redundant @param/@return from createUsingParamsFrom javadoc
- rename tests; add terms+percentiles high-cardinality trip test;
  drop cranky test (coverage held by deterministic trip tests)

* spotless: remove final from takeState()

* [CI] Auto commit changes from spotless

* simplify NOOP_BREAKER comment in getLeaderReducer

* fix NOOP_BREAKER comment: describe use-after-close problem

* update comments.

* fix comment: NOOP_BREAKER is for unreleased accumulator

* fix comment: explain both reasons for NOOP_BREAKER

* simplify comments in AbstractTDigestPercentilesAggregator

* simplify NOOP_BREAKER comment in getLeaderReducer

* address review: static helper, drop redundant javadoc tags

* mark takeState() final to prevent subclass breaker accounting bypass

* add multi-bucket success and PercentileRanks circuit breaker tests

* revert PercentileRanks breaker tests: fix is in the abstract class, already covered

* remove duplicate multi-bucket success breaker test

* remove redundant comments above breaker assertions

* update comments, explain more clearly

* address review feedback, add more clear comments.

* make withSequentialIndex method static

* Mute LookupJoinExpression CurrentCoordinator BWC tests

* Revert LookupJoinExpression CurrentCoordinator BWC test mutes

---------

Co-authored-by: elasticsearchmachine <infra-root+elasticsearchmachine@elastic.co>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

:Analytics/ES|QL AKA ESQL auto-backport Automatically create backport pull requests when merged >bug Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) v9.4.6 v9.5.2 v9.6.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants