ES|QL:Fix Tdigest percentiles circuit breaker - #155586
Conversation
`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)
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.
|
Pinging @elastic/es-analytical-engine (Team:Analytics) |
| }); | ||
| } | ||
|
|
||
| private void withSequentialIndex(int docCount, CheckedConsumer<DirectoryReader, IOException> body) throws IOException { |
|
|
||
| @Override | ||
| protected void doClose() { | ||
| // states is null if the constructor threw before assigning it because assignment is after super() |
There was a problem hiding this comment.
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.
- Please update the above comment to reflect this.
- Please update
AggregatorBase#doClosejavadoc to reflect that this method may be called before the constructor has finished.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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:
- Coordinator-side accounting:
getLeaderReducerand theAbstractInternalTDigestPercentiles(StreamInput)path, plusMovingPercentilesPipelineAggregator. Agreed that topN trimming bounds this in practice. - The shard-level siblings from "To Consider":
AbstractBoxplotAggregator.getExistingOrNewHistogramandAbstractTDigestOrExponentialMergingAggregator.getExistingOrNewHistogramare copies of the method being fixed here,NOOP_BREAKERand all, and theirdoClose()closes only theObjectArray, never the elements. Those sit on the in-scope side of your own line — same data node, same per-bucket sketch, same high-cardinalitytermsshape — soboxplotcan 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.
|
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. |
…breaker' into fix/tdigest-percentiles-circuit-breaker
* 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>
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
AbstractTDigestPercentilesAggregatorcreates oneHistogramUnionStatesketch 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:NoopCircuitBreakerignores every byte. All TDigest memory (centroid arrays, sort buffers) was invisible to the request breaker. Under a high-cardinalityTERMS+PERCENTILESquery the heap fills up and the node OOMs rather than the breaker throwingCircuitBreakingException.2. Bytes never released after
buildAggregation.doClose()calledReleasables.close(states), which frees the backingObjectArraycontainer but never callsHistogramUnionState.close()on the elements inside it. Breaker bytes leaked permanently.3. Reducer inherited a closed breaker.
getLeaderReducer()calledHistogramUnionState.createUsingParamsFrom(shard_state), copying the shard's breaker into the merge accumulator. If that breaker was aPreallocatedCircuitBreakeralready torn down before reduction ran, any allocation would throw.Fix
1. Pass
context.breaker()instead ofNOOP_BREAKERcontextis aprotected finalfield fromAggregatorBase. Passing the real REQUEST breaker wires up the full tracking chain:MemoryTrackingTDigestArrayswas already wired correctly throughHistogramUnionState. It was just never reached becauseNOOP_BREAKERwas passed at the top.2. Return bytes in
takeState(), not indoClose()There is a timing constraint that shapes the fix:
InternalAggregationhas noReleasablelifecycle (see #99815). By the time the result is serialized, thePreallocatedCircuitBreakerwrapping the REQUEST breaker is already closed. AnyaddWithoutBreaking()call at that point throwsIllegalStateException: already closed. The only window to return bytes is insidebuildAggregation, while the aggregation context is still open.buildAggregation()callstakeState()instead ofgetState().takeState()nulls the slot instates(sodoClose()skips it) and returns the bytes immediately: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 individuallycloseWhileHandlingExceptionkeeps going if one slot fails.doClose()now only handles the failure path (states not handed off viatakeState()). The null guard onstatesitself covers the case where the breaker trips inside the constructor before the field is assigned.4. Fix the reducer to use
NOOP_BREAKERexplicitlygetLeaderReducer()now passesNOOP_BREAKERexplicitly tocreateUsingParamsFrom(). For same node reduction, the shard'sPreallocatedCircuitBreakeris 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
_searchagg path "would require bigger refactors."MemoryTrackingTDigestArraysand the breaker threading insideHistogramUnionStatenow 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.