Skip to content

Delay circuit breaker release until fetch response is sent#139243

Merged
drempapis merged 34 commits intoelastic:mainfrom
drempapis:fix/fetch-phase-breaker-lifecycle
Jan 26, 2026
Merged

Delay circuit breaker release until fetch response is sent#139243
drempapis merged 34 commits intoelastic:mainfrom
drempapis:fix/fetch-phase-breaker-lifecycle

Conversation

@drempapis
Copy link
Copy Markdown
Contributor

Circuit breaker bytes were being released too early in the fetch phase. The bytes were released immediately after building SearchHits in FetchPhase.buildSearchHits(), but before the response was serialized and sent to the coordinator node. This created a timing window where:

  1. `SearchHits are still in memory, consuming resources
  2. Circuit breaker has already released the bytes, believing the memory is freed
  3. This leads to circuit breaker undercounting, potentially allowing OOM conditions, especially for small setups with low heap resources.

This PR fixes the timing issue by delaying circuit breaker release until after the response has been successfully sent to the coordinator. We achieve this by:

  1. Storing circuit breaker bytes in FetchSearchResult when hits are built
  2. Releasing after transmission via listener wrappers that free bytes after listener.onResponse() completes
  3. Handling exceptions safely by releasing immediately if an error occurs before the result is created

@elasticsearchmachine elasticsearchmachine added needs:triage Requires assignment of a team area label v9.3.0 labels Dec 9, 2025
@drempapis drempapis added >non-issue Team:Search Foundations Meta label for the Search Foundations team in Elasticsearch :Search Foundations/Search Catch all for Search Foundations and removed needs:triage Requires assignment of a team area label labels Dec 9, 2025
@drempapis drempapis changed the title Fix/fetch phase breaker lifecycle Delay circuit breaker release until fetch response is sent Dec 9, 2025
@elasticsearchmachine
Copy link
Copy Markdown
Collaborator

Pinging @elastic/es-search-foundations (Team:Search Foundations)

@drempapis
Copy link
Copy Markdown
Contributor Author

@elasticmachine run elasticsearch-ci/part-1

@drempapis
Copy link
Copy Markdown
Contributor Author

@elasticmachine run elasticsearch-ci/part-2

@drempapis
Copy link
Copy Markdown
Contributor Author

@elasticmachine run elasticsearch-ci/part-1

Copy link
Copy Markdown
Contributor

@andreidan andreidan left a comment

Choose a reason for hiding this comment

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

Thanks for working on this Dimi (and wow, sorry for the late review 🙏 )

I had an initial look over this and left a few comments.

Did you test this locally with a running Elasticsearch? (i.e. issue some requests without the fix and see Elasticsearch OOM, then the same with the fix in place and see Elasticsearch 429 instead?)

Comment on lines +67 to +96
private void createSmallIndex() throws IOException {
createIndex(INDEX_SMALL);
populateIndex(INDEX_SMALL, 50, 10_000);
}

private void createLargeIndex() throws IOException {
createIndex(INDEX_LARGE);
populateIndex(INDEX_LARGE, 50, 100_000);
}

private void createIndex(String indexName) {
assertAcked(
prepareCreate(indexName).setMapping(
SORT_FIELD,
"type=long",
"text",
"type=text,store=true",
"large_text_1",
"type=text,store=false",
"large_text_2",
"type=text,store=false",
"large_text_3",
"type=text,store=false",
"keyword",
"type=keyword"
)
);
}

private void populateIndex(String indexName, int nDocs, int textSize) 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.

I think we usually have private methods at the bottom to enhance readability

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.

Done!!

ensureSearchable(INDEX_SMALL);
}

private void createSmallIndex() 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.

nit: I think this rather shallow method hinders readability (and it's only used once)
Would it be easier to just call the code directly?

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.

Correct, updated

populateIndex(INDEX_SMALL, 50, 10_000);
}

private void createLargeIndex() 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.

I think this isn't used

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.

Removed

}

private long getRequestBreakerUsed() {
CircuitBreakerService breakerService = internalCluster().getInstance(CircuitBreakerService.class);
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.

I think this gets the CircuitBreakerService from a random node in the cluster so it might create test flakiness?
Do we need to be more specific about which CircuitBreakerService are we retrieving?

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.

That's a good point!!

I updated the code to use

 @ClusterScope(scope = TEST, numDataNodes = 0, numClientNodes = 0) 

to start with an empty cluster and explicitly start a data node and coordinator node per test.

So now I pass the data node name to getRequestBreakerUsed(String node) to retrieve the circuit breaker from the specific node where shards are allocated and fetch phase executes.


private SearchHits hits;

private transient long circuitBreakerBytes = 0L;
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.

Shall we name this something that indicates what the field is? (i.e. searchHitsSizeBytes or something like that? - it's currently named based on what it is used for, not what it is)

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.

Updated, ty!

Comment on lines +516 to +523
private static class SearchHitsWithBreakerBytes {
final SearchHits hits;
final long circuitBreakerBytes;

SearchHitsWithBreakerBytes(SearchHits hits, long circuitBreakerBytes) {
this.hits = hits;
this.circuitBreakerBytes = circuitBreakerBytes;
}
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.

should we rename this to SearchHitsAndBytesSize ?

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.

Updated, ty!

? Profiler.NOOP
: Profilers.startProfilingFetchPhase();
SearchHits hits = null;
long circuitBreakerBytes = 0L;
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.

Suggested change
long circuitBreakerBytes = 0L;
long searchHitsBytesSize = 0L;

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.

That's a good suggestion, updated!

Comment on lines +60 to +62
/**
* Test the circuit breaker release helper for QueryFetchSearchResult.
*/
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.

nit: these comments are a bit confusing for me (what helper?) I'd prefer if we remove them and bake the logic of what we're testing in the test name

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.

Removed the comments

assertThat(
"Circuit breaker should not grow after multiple searches (no leaks)",
getRequestBreakerUsed(),
lessThanOrEqualTo(initialBreaker + ByteSizeValue.ofKb(100).getBytes()) // Allow small variance
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.

would the variance not mask a leak here?

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.

You’re right; I removed that and added it within an assertBusy block

Comment on lines +156 to +158
/**
* Test circuit breaker release with scroll search (scroll fetch path).
*/
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.

test name is descriptive enough IMO

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 added the comment to stay consistent with the rest of the tests. Do you think we should remove these comments across all tests for consistency? The tests code is fairly straightforward to read.

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.

Yes please, unless you find them useful (they currently mostly just restate what the test method names are saying)

@drempapis
Copy link
Copy Markdown
Contributor Author

Did you test this locally with a running Elasticsearch? (i.e. issue some requests without the fix and see Elasticsearch OOM, then the same with the fix in place and see Elasticsearch 429 instead?)

@andreidan thank you for the review.

I tested this locally against a running Elasticsearch cluster during implementation. Before applying the request circuit breaker, I was able to reproduce the problematic behavior by issuing large fetch requests, which caused the data node to run out of memory during the fetch phase. I used a profiler to monitor heap usage while the fetch was executing and observed the OOM.

With the fix applied, the same requests fail with a CircuitBreakingException and are mapped to 429, without destabilizing the node. In this PR, I added integration tests that intentionally lower indices.breaker.request.limit and assert that large fetches now trip the request breaker and return 429s, as well as that breaker usage is properly released afterward.

Copy link
Copy Markdown
Contributor

@andreidan andreidan left a comment

Choose a reason for hiding this comment

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

Thanks for working on this Dimi.

LGTM

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

Labels

>non-issue :Search Foundations/Search Catch all for Search Foundations Team:Search Foundations Meta label for the Search Foundations team in Elasticsearch v9.4.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants