Add RepositoryOperationGuard circuit breaker#22523
Conversation
PR Reviewer Guide 🔍(Review updated until commit d7762d1)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to d7762d1
Previous suggestionsSuggestions up to commit 928cc44
|
| if (current >= limit) { | ||
| throw new RepositoryException( | ||
| repository, | ||
| "circuit open — repository unreachable: [" + current + "] outstanding operations at limit [" + limit + "]" |
There was a problem hiding this comment.
Let's not use circuit open in the exception message here. This is an implementation detail that we are using a circuit breaker logic.
| * @opensearch.internal | ||
| */ | ||
| public final class RepositoryOperationGuard { | ||
| private final Map<String, AtomicInteger> outstandingOpsByRepository = new ConcurrentHashMap<>(); |
There was a problem hiding this comment.
We should also have a cleanup method to clear off cached repository keys in this map once the repository is removed from the cluster. Or else this would be a point of memory leak
There was a problem hiding this comment.
Makes sense. Going with cleanup-on-zero: release() will remove a repo's map entry once its count hits 0, done atomically via ConcurrentHashMap.compute (so it can't race with a concurrent tryAcquire on the same key). This bounds the map to repos with currently in-flight ops, a removed repo's entry drains out on its own once its last op completes, no separate hook needed.
| assert false : "release() called for repository [" + repository + "] with no outstanding operations tracked"; | ||
| return; | ||
| } | ||
| final int updated = outstanding.decrementAndGet(); |
There was a problem hiding this comment.
This needs a guard to prevent this from going into negative
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #22523 +/- ##
============================================
- Coverage 73.48% 73.47% -0.01%
+ Complexity 76460 76433 -27
============================================
Files 6104 6104
Lines 346582 346582
Branches 49879 49879
============================================
- Hits 254675 254648 -27
+ Misses 71664 71630 -34
- Partials 20243 20304 +61 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| * Acquires a permit for {@code repository}, or throws {@link RepositoryException} if the circuit is open. On | ||
| * success the caller must call {@link #release(String)} exactly once for this acquisition. | ||
| */ | ||
| public void tryAcquire(String repository) { |
There was a problem hiding this comment.
Does this needs to be synchronized?
There was a problem hiding this comment.
No synchronization needed here. The CAS (compareAndSet) already makes this thread-safe. That said, I'm switching to ConcurrentHashMap.compute for the rewrite, which does the check-and-increment atomically under the map's per-key lock, so the thread-safety is structural instead of something you have to trace through CAS logic.
There was a problem hiding this comment.
Can we add concurrency tests?
| public void tryAcquire(String repository) { | ||
| final AtomicInteger outstanding = outstandingOpsByRepository.computeIfAbsent(repository, ignored -> new AtomicInteger(0)); | ||
| final int limit = maxOutstandingOps; | ||
| while (true) { |
There was a problem hiding this comment.
Why do we have a while loop? We are throwing anyways above the limit and for lower limit we are incrementing the value
There was a problem hiding this comment.
Removed. Switched tryAcquire/release to ConcurrentHashMap.compute, so the loop isn't needed anymore.
| "circuit open — repository unreachable: [" + current + "] outstanding operations at limit [" + limit + "]" | ||
| ); | ||
| } | ||
| if (outstanding.compareAndSet(current, current + 1)) { |
There was a problem hiding this comment.
What if any other thread increased the value for outstanding while we reach here?
There was a problem hiding this comment.
Should not be possible now, the check-and-increment happens inside the same compute call, which is atomic per key.
Bounds outstanding cluster-manager-side repository operations per repository so a degraded store cannot exhaust the shared snapshot thread pool. Unwired for now; PR-F and PR-G will consume it. Signed-off-by: Harshita Kaushik <harycash@amazon.com>
928cc44 to
d7762d1
Compare
|
Persistent review updated to latest commit d7762d1 |
|
Can we try to see if we can use the |
| * Acquires a permit for {@code repository}, or throws {@link RepositoryException} if the circuit is open. On | ||
| * success the caller must call {@link #release(String)} exactly once for this acquisition. | ||
| */ | ||
| public void tryAcquire(String repository) { |
There was a problem hiding this comment.
Can we add concurrency tests?
| public void release(String repository) { | ||
| outstandingOpsByRepository.compute(repository, (repo, current) -> { | ||
| if (current == null || current <= 0) { | ||
| assert false : "release() with no outstanding operations tracked for repository [" + repo + "]"; |
There was a problem hiding this comment.
Can we print a warn message here? Ideally this should not happen, similarly above
There was a problem hiding this comment.
Will do.
| return null; | ||
| } | ||
| final int updated = current - 1; | ||
| return updated == 0 ? null : updated; |
There was a problem hiding this comment.
Will this remove the "repo" key in case the updated has become zero?
There was a problem hiding this comment.
Yes. Returning null from the compute remapping function removes the mapping (per ConcurrentHashMap.compute's contract). So the entry is dropped the moment the count reaches 0. That's the cleanup-on-zero. Have verified by inspecting the map directly.
Checked on it, One cost worth flagging: GatedCloseable implements Closeable, so close()/release() is |
What is It should be fine to just add the |
Description
Adds
RepositoryOperationGuard, a per-repository circuit breaker that bounds the number of outstanding cluster-manager-side repository operations. This guards against a degraded repository parking enough worker threads (via the upcoming operation-level timeout) to drain the small, shared snapshot thread pool and starve other repositories.This class is registered nowhere and consumed by no one yet. A follow-up PR wires it into the finalize/delete dispatch paths.
tryAcquire(repo)RepositoryExceptiononce a repository is at its limit, without dispatchingrelease(repo)setMaxOutstandingOps(int)Testing
RepositoryOperationGuardTests: fail-fast at limit, no permit taken on rejection.ActionListener.runAfter+ListenerTimeoutsstack, including a timeout followed by a late orphanedcompletion.
DeterministicTaskQueue; noThread.sleep.Related Issues
Check List
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check
here.