Repository navigation
Count failed msearch sub-search responses against the circuit breaker - #156683
Conversation
|
Pinging @elastic/es-search-foundations (Team:Search Foundations) |
|
Hi @drempapis, I've created a changelog YAML for you. |
🔍 Preview links for changed docs⏳ Building and deploying preview... View progress This comment will be updated with preview links when the build is complete. |
ℹ️ 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 overviewWhen to use applies_to tags:✅ At the page level to indicate which products/deployments the content applies to (mandatory) What NOT to do:❌ Don't remove or replace information that applies to an older version 🤔 Need help?
|
…apis/elasticsearch into msearch-failure-breaker-accounting
eranweiss-elastic
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
Nit: Does randomness adds anything here? Predictable tests are generally better.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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);
There was a problem hiding this comment.
Agreed, applied as suggested.
| try { | ||
| circuitBreaker.addEstimateBytesAndMaybeBreak(bytes, "<msearch_failure>"); | ||
| } catch (CircuitBreakingException tripped) { | ||
| return accountBoundedFailureSubstitute(breakerAccounting, tripped); |
There was a problem hiding this comment.
Shouldn't this flow first de-alloc item?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
This not only accounts, but can replace. The name can be misleading.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Just for my own knowledge, when would we expect the breaker to be less than zero here?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
That fix wasn't committed yet, only staged locally. Pushing it now.
| */ | ||
| public void testAllSubSearchesFailStillTripsBreaker() throws Exception { | ||
| int numRequests = 20; | ||
| long byteLimit = 2048L; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
+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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Ah, I see, thanks for the clarification!
| private final AtomicLong totalWithoutBreaking = new AtomicLong(); | ||
| private final AtomicLong maxWithoutBreaking = new AtomicLong(); |
There was a problem hiding this comment.
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().
There was a problem hiding this comment.
Good catch, totalWithoutBreaking was dead and removed it. Renamed maxWithoutBreaking to largestAddWithoutBreaking for clarity as proposed
|
|
||
| /** | ||
| * 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. |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
…apis/elasticsearch into msearch-failure-breaker-accounting
💚 Backport successful
|
|
Hi @drempapis |
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. |
@donniemcneil-droid That merge should help mitigate the issue. I'll iterate on it to improve it further. |
The problem
For a
_msearchrequest 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_msearchcan'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
_msearchcompletes, just like successful responses already do.