Skip to content

Count failed msearch sub-search responses against the circuit breaker - #156683

Merged
drempapis merged 23 commits into
elastic:mainfrom
drempapis:msearch-failure-breaker-accounting
Aug 15, 2026
Merged

drempapis merged 23 commits into
elastic:mainfrom
drempapis:msearch-failure-breaker-accounting

Conversation

@drempapis

Copy link
Copy Markdown
Contributor

The problem

For a _msearch request with lots of sub-searches, the code keeps every sub-search's response in memory until all of them are done. For successful searches we already count that memory against the request circuit breaker, so a big _msearch can't quietly eat all the heap.

Failed sub-searches were not counted. If a lot of sub-searches fail at once, those failure objects — including the full error details for every shard that failed — pile up in memory completely invisible to the breaker. In a bad enough case this can contribute to an OOM, since the breaker thinks less memory is in use than actually is.

Fix

To estimate the memory a failed sub-search response would use, the same way we already do for successful ones, and charges it to the CB. This includes looking inside the error for individual shard failures, since those can also carry sizeable stack traces.

We size a failed sub-search the same way and try to reserve that memory on the breaker. If there's no room left, we still count it rather than reject it — a failure item can't be dropped or retried, so refusing to account for it would leave the request stuck with no way to ever finish. Either way, all of it gets released back to the breaker once the whole _msearch completes, just like successful responses already do.

@drempapis drempapis added >bug Team:Search Foundations Meta label for the Search Foundations team in Elasticsearch :Search Foundations/Search Catch all for Search Foundations v9.6.0 labels Aug 13, 2026
@elasticsearchmachine

Copy link
Copy Markdown
Collaborator

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

@elasticsearchmachine

Copy link
Copy Markdown
Collaborator

Hi @drempapis, I've created a changelog YAML for you.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

🔍 Preview links for changed docs

⏳ Building and deploying preview... View progress

This comment will be updated with preview links when the build is complete.

@github-actions

Copy link
Copy Markdown
Contributor

ℹ️ Important: Docs version tagging

👋 Thanks for updating the docs! Just a friendly reminder that our docs are now cumulative. This means all 9.x versions are documented on the same page and published off of the main branch, instead of creating separate pages for each minor version.

We use applies_to tags to mark version-specific features and changes.

Expand for a quick overview

When to use applies_to tags:

✅ At the page level to indicate which products/deployments the content applies to (mandatory)
✅ When features change state (e.g. preview, ga) in a specific version
✅ When availability differs across deployments and environments

What NOT to do:

❌ Don't remove or replace information that applies to an older version
❌ Don't add new information that applies to a specific version without an applies_to tag
❌ Don't forget that applies_to tags can be used at the page, section, and inline level

🤔 Need help?

…apis/elasticsearch into msearch-failure-breaker-accounting

@eranweiss-elastic eranweiss-elastic 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 working on this. Overall looks good, but I have one concern, around the de-allc in accountFailureItem. Other comments are minor.

String coordinatorNode = internalCluster().startCoordinatingOnlyNode(Settings.EMPTY);
assumeFalse("coordinator uses a noop request breaker, skipping test", noopBreakerUsed(coordinatorNode));

int numSearches = scaledRandomIntBetween(10, 25);

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: Does randomness adds anything here? Predictable tests are generally better.

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.

Good catch - unlike the success-path test, this one doesn't sample a timing-sensitive peak-usage window, so the random scale wasn't adding coverage. Switched to a fixed numSearches


private void handleResponse(final int responseSlot, final MultiSearchResponse.Item item) {
responses.set(responseSlot, item);
responses.set(responseSlot, item.isFailure() ? accountFailureItem(breakerAccounting, item) : item);

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 find using the ternary operator for side-effect while returning the same object confusing.
I think it would be clearer as:

if (item.isFailure()) {
   item = accountFailureItem(breakerAccounting, item);
}
responses.set(responseSlot, item);

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.

Agreed, applied as suggested.

try {
circuitBreaker.addEstimateBytesAndMaybeBreak(bytes, "<msearch_failure>");
} catch (CircuitBreakingException tripped) {
return accountBoundedFailureSubstitute(breakerAccounting, tripped);

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.

Shouldn't this flow first de-alloc item?

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.

This code path runs when item.isFailure() is true, and a failure item do not carry a SearchResponse — both places in this file that construct one pass response=null for the failure case.

* legitimate trip: it's logged, and the original bytes are force-added so a defect here can't hang
* the msearch.
*/
private MultiSearchResponse.Item accountFailureItem(MultiSearchBreakerAccounting breakerAccounting, MultiSearchResponse.Item item) {

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.

This not only accounts, but can replace. The name can be misleading.

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.

Fair point! Renamed to accountOrSubstituteFailureItem to reflect that it can replace the item, not just account for it.

() -> assertThat(
"request breaker should be at baseline before msearch",
requestBreakerEstimated(coordinatorNode),
lessThanOrEqualTo(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.

Just for my own knowledge, when would we expect the breaker to be less than zero 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.

That should never happen. I picked up that pattern from a previously declared test. I've updated all occurrences in the test class.

() -> assertThat(
"request breaker should return to baseline after an all-failure msearch completes",
requestBreakerEstimated(coordinatorNode),
lessThanOrEqualTo(baseline)

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 this be equalTo()? If the breaker returns to a value lower than the baseline then wouldn't that mean that something has been miscounted?

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.

Fair point. Updated in the previous commit.


/**
* Accounts for a small, bounded {@link CircuitBreakingException} substituted for a failure item that
* didn't fit. Unlike the original, it's safe to force through as a last resort if it still doesn't fit.

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.

Is it that it's safe, or just that we have no alternative at this point? It's possible that we could still OOM due to the CircuitBreakingException if there are enough of them, I think?

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.

This is the core of that PR, safe was too strong; updated the wording.

A failed sub-search still has to produce a response item — it can't be dropped or retried — so when reserving breaker bytes for it trips the limit, rejecting it outright isn't an option.

This isn't just theoretical either. Estimating a failure's size means walking its whole cause chain, and for a SearchPhaseExecutionException we also have to sum up every ShardSearchFailure's cause and reason string. A search that fails across a lot of shards ends up with a genuinely large exception. On top of that, _msearch buffers every item — successes and failures — until the whole batch finishes, and all of that is reserved against the same shared REQUEST breaker as everything else running on the node. So a big failure can trip it on its own, and it's also competing for headroom with whatever else happens to be using that breaker at the time. (This is what happened in a real-world scenario.)

What we do instead is substitute a small, fixed-size CircuitBreakingException and account for that rather than the original, which has no upper bound. That doesn't remove the risk, it just bounds it: if the breaker's already so full that even the small substitute won't fit, we still force it through with addWithoutBreaking, since refusing to record it at all would leave the request stuck forever. The important part is that what gets force-added is now capped and no longer tied to how bad the original failure was, so the worst case is a small, bounded overshoot instead of one that scales with the failure.

Either way, all of it — original or substitute — gets released back to the breaker once the _msearch finishes, same as successful responses.

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.

safe was too strong; updated the wording.

Not a major issue, but the wording still says "safe", just checking that this wasn't something that was intended to be changed but got missed

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 fix wasn't committed yet, only staged locally. Pushing it now.

*/
public void testAllSubSearchesFailStillTripsBreaker() throws Exception {
int numRequests = 20;
long byteLimit = 2048L;

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.

Possibly overkill, but could the byteLimit be some fraction of originalFailureBytes so that it's guaranteed to always be below the failure bytes? The current value of 2048 seems arbitrary if you don't know what a reasonable expected value for originalFailureBytes 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.

Good idea, the byteLimit is now originalFailureBytes / 2, so it's always guaranteed to be below the failure size, regardless of the estimation constants.

}

/**
* Even when the tracking breaker's byte limit is exhausted partway through, every failure item must

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.

This comment seems slightly misleading since the byte limit is exhausted immediately by the first failure. Would the test be better if the first few failures did not trip the breaker, but then subsequent ones did?

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.

+1 Set the limit to fit exactly the first 2 original failures, and everything after is substituted

assertThat(breaker.getUsed(), equalTo(0L));
}

public void testAccountFailureItemSubstitutesBoundedExceptionWhenTripped() throws Exception {

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.

Is this test doing anything meaningfully different than the one above it? Both cause the breaker to trip immediately due to a too-large SearchPhaseExecutionException and then assert that the failure we store is a CircuitBreakingException. This test just uses a single failure rather than several, so it seems like the behaviour is already covered in the above test.

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.

They test different outcomes: this one shows the substitute always fits (maxWithoutBreaking() == 0), the one above shows it can also get force-added when the breaker's already full (maxWithoutBreaking() > 0). Prefer keeping them separate so a failure points to the exact broken behavior.

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.

Ah, I see, thanks for the clarification!

Comment on lines +1098 to +1099
private final AtomicLong totalWithoutBreaking = new AtomicLong();
private final AtomicLong maxWithoutBreaking = new AtomicLong();

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.

The value of totalWithoutBreaking is never used, so could it be removed? Also, would maxWithoutBreaking be clearer as largestAddWithoutBreaking? I wasn't initially sure what maxWithoutBreaking meant before looking at the implementation of addWithoutBreaking().

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.

Good catch, totalWithoutBreaking was dead and removed it. Renamed maxWithoutBreaking to largestAddWithoutBreaking for clarity as proposed

@drempapis drempapis added auto-backport Automatically create backport pull requests when merged v9.5.0 labels Aug 14, 2026

/**
* Accounts for a small, bounded {@link CircuitBreakingException} substituted for a failure item that
* didn't fit. Unlike the original, it's safe to force through as a last resort if it still doesn't fit.

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.

safe was too strong; updated the wording.

Not a major issue, but the wording still says "safe", just checking that this wasn't something that was intended to be changed but got missed

assertThat(breaker.getUsed(), equalTo(0L));
}

public void testAccountFailureItemSubstitutesBoundedExceptionWhenTripped() throws Exception {

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.

Ah, I see, thanks for the clarification!

public void testAccountFailureItemSubstitutesBoundedExceptionWhenTripped() throws Exception {
long largeFailureBytes = TransportMultiSearchAction.estimateFailureBytes(searchPhaseExecutionExceptionWithShardFailures(50));
// Just enough headroom for a bounded substitute, but not for the large original failure.
TrackingCircuitBreaker breaker = new TrackingCircuitBreaker(largeFailureBytes - 1);

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.

Do we need to use the same approach as in testAllSubSearchesFailStillTripsBreaker() to determine the appropriate limit for the breaker here? If I add some logging to TrackingCircuitBreaker.addEstimateBytesAndMaybeBreak() I see byteLimit = 937996 and bytes = 1162917, which is a difference of significantly more than 1. I don't think it affects the test in a particularly meaningful way, but the fact that largeFailureBytes is ~20% smaller than the actual size of the failure that gets passed to the circuit breaker could cause some confusion. For example, it might be expected that the test would start failing if we used new TrackingCircuitBreaker(largeFailureBytes + 1) because the first failure wouldn't trip the breaker, but it still passes.

Maybe it would be enough to just use new TrackingCircuitBreaker(largeFailureBytes) and add a comment saying that the actual size of the failure that makes it to accountOrSubstituteFailureItem() is bigger than the SearchPhaseExecutionException the test creates because the stack is slightly different. The one we use to calculate largeFailureBytes has:

java.lang.RuntimeException: simulated shard failure 0
	at org.elasticsearch.action.search.TransportMultiSearchActionTests.searchPhaseExecutionExceptionWithShardFailures(TransportMultiSearchActionTests.java:1001)
	at org.elasticsearch.action.search.TransportMultiSearchActionTests.testAccountFailureItemSubstitutesBoundedExceptionWhenTripped(TransportMultiSearchActionTests.java:976)
...

but the one that makes it to accountOrSubstituteFailureItem() has:

java.lang.RuntimeException: simulated shard failure 0
	at org.elasticsearch.action.search.TransportMultiSearchActionTests.searchPhaseExecutionExceptionWithShardFailures(TransportMultiSearchActionTests.java:1001)
	at org.elasticsearch.action.search.TransportMultiSearchActionTests.lambda$testAccountFailureItemSubstitutesBoundedExceptionWhenTripped$31(TransportMultiSearchActionTests.java:987)
	at org.elasticsearch.action.search.TransportMultiSearchActionTests$3.search(TransportMultiSearchActionTests.java:1064)
	at org.elasticsearch.action.search.TransportMultiSearchAction.doExecuteSearch(TransportMultiSearchAction.java:603)
	at org.elasticsearch.action.search.TransportMultiSearchAction.executeSearch(TransportMultiSearchAction.java:586)
	at org.elasticsearch.action.search.TransportMultiSearchAction.doExecute(TransportMultiSearchAction.java:248)
	at org.elasticsearch.action.search.TransportMultiSearchAction.doExecute(TransportMultiSearchAction.java:59)
	at org.elasticsearch.action.support.TransportAction$RequestFilterChain.proceed(TransportAction.java:135)
	at org.elasticsearch.action.support.TransportAction.handleExecution(TransportAction.java:96)
	at org.elasticsearch.action.support.TransportAction.execute(TransportAction.java:59)
	at org.elasticsearch.action.search.TransportMultiSearchActionTests.runMsearchWithBreaker(TransportMultiSearchActionTests.java:1092)
	at org.elasticsearch.action.search.TransportMultiSearchActionTests.testAccountFailureItemSubstitutesBoundedExceptionWhenTripped(TransportMultiSearchActionTests.java:982)
...

(with everything after the ... being the same in both stacks)

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 are right, the -1 implied more precision than it actually has. Went with your suggestion: dropped it and added a comment explaining the real failure ends up bigger due to the deeper stack trace it picks up going through the actual execution chain.

@drempapis
drempapis merged commit 9194032 into elastic:main Aug 15, 2026
37 checks passed
@elasticsearchmachine

Copy link
Copy Markdown
Collaborator

💚 Backport successful

Status Branch Result
✅ 9.5

@donniemcneil-droid

Copy link
Copy Markdown

Hi @drempapis
Donnie from Support again. The support case notes that we're waiting to see if the merge fix solves the issue going forward. Is it safe to assume that since this is closed now? Thanks!

@DaveCTurner

Copy link
Copy Markdown
Member

a failure item can't be dropped or retried, so refusing to account for it would leave the request stuck with no way to ever finish

This isn't true btw. If we don't have space for intermediate results we can reasonably cancel any ongoing sub-searches, drop all intermediate results, and ultimately return a top-level 429 response. I appreciate that trying to return the results of the successful searches is going to be slightly more useful in some cases, but it's going to be much less useful if the node dies before it returns anything.

@drempapis

Copy link
Copy Markdown
Contributor Author

This isn't true btw. If we don't have space for intermediate results we can reasonably cancel any ongoing sub-searches, drop all intermediate results, and ultimately return a top-level 429 response. I appreciate that trying to return the results of the successful searches is going to be slightly more useful in some cases, but it's going to be much less useful if the node dies before it returns anything.

Thank you, @DaveCTurner, for your feedback. I'll have another look at it.

@drempapis

Copy link
Copy Markdown
Contributor Author

Donnie from Support again. The support case notes that we're waiting to see if the merge fix solves the issue going forward. Is it safe to assume that since this is closed now? Thanks!

@donniemcneil-droid That merge should help mitigate the issue. I'll iterate on it to improve it further.

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

Labels

auto-backport Automatically create backport pull requests when merged >bug :Search Foundations/Search Catch all for Search Foundations Team:Search Foundations Meta label for the Search Foundations team in Elasticsearch v9.5.0 v9.6.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants