Series navigation
Written by
Jagdish Salgotra
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Throwing inside a subtask cancels siblings before they waste capacity, and sometimes that is wrong. Where it backfires.
Written by
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
All code in this series targets Java 21 preview APIs. Part 9 covers migration to Java 25.
Part 2 was about clocks. A parent scope starts work, waits until a deadline, and then chooses whether to fail, return partial data, or use a fallback.
But time is not the only reason to stop work.
Sometimes a sibling task produces a result that makes the remaining work unnecessary. Sometimes a required branch fails quickly. Sometimes a dependency should not be called at all because a breaker is open. Sometimes an enrichment should fall back instead of making the whole request fail.
Those are conditional cancellation problems. The parent stops work not because a timer expired, but because the state of the workflow changed.
This article is still learning material. The examples are deliberately small and the circuit breakers are intentionally simple. The goal is to understand where the policy belongs:
Mixing those responsibilities is where concurrent service code becomes hard to reason about.
This article is learning material. The main branch now builds with OpenJDK 25.0.2 and uses the Java 25 preview structured-concurrency API, with the Java 21 version separately managed in the feature/java-21 branch. The measurements below were generated from the current Java 25 code. The Java 21 preview syntax shown here remains valid for learning purposes, and Part 9 covers the migration details.
In Java 21 preview, the simplest fail-fast shape uses ShutdownOnFailure.
If a subtask discovers a terminal condition, it throws. The scope sees that failure and cancels its siblings.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> requiredA = scope.fork(() -> callRequiredA());
Subtask<String> requiredB = scope.fork(() -> callRequiredB());
Subtask<String> requiredC = scope.fork(() -> callRequiredC());
scope.join();
scope.throwIfFailed();
return combine(requiredA.get(), requiredB.get(), requiredC.get());
}
That is the same shape from Part 1, but the meaning is sharper now. A failure is not just an exception. It is also the cancellation signal for sibling work.
This is useful when the failed branch makes the whole response invalid. If payment authorization fails, there is usually no point calculating shipping estimates. If a required identity check fails, optional personalization work is wasted. In a learning example, if one required simulated dependency throws, the remaining simulated work should stop because the final result cannot be built.
The key is to keep the signal explicit. If a business condition should stop siblings, represent it as a clear failure at the subtask boundary. Do not hide it as "status": "failed" and then expect every sibling to discover that status later.
A circuit breaker answers a different question: should this dependency call even be attempted?
That decision should happen before the scope forks work for that dependency.
public String callProtectedService(String request) throws Exception {
if (breaker.isOpen()) {
throw new RuntimeException("Circuit breaker is OPEN - failing fast");
}
try {
String result = scopedHandler.runInScope(() -> callUnreliableService(request));
breaker.onSuccess();
return result;
} catch (Exception failure) {
breaker.onFailure();
throw failure;
}
}
The scope is still valuable, but it has a narrower job. It owns the lifetime of the attempted call. The breaker decides whether the attempted call should exist.
That separation matters. A breaker-open rejection is not the same thing as a dependency attempt that failed. In a real metric model, those should be counted separately. For this learning code, the important part is simpler: the open breaker path returns before doing downstream work.
The companion ImprovedBusinessService demo showed that behavior clearly in one run:
Call 3: failure 1/3
Call 4: failure 2/3
Call 5: failure 3/3
Call 6: Circuit breaker is OPEN for DB_SERVICE - failing fast
Call 7: Circuit breaker is OPEN for DB_SERVICE - failing fast
Call 8: Circuit breaker is OPEN for DB_SERVICE - failing fast
After 5 seconds: still open, next retry in 22s
The threshold in that demo is 3 failures and the timeout is 30 seconds. The run happened to produce three consecutive failures, so the next calls were rejected immediately by the breaker.
That is the teaching point: once admission is closed, structured concurrency should not be asked to coordinate work that should never be forked.
The cleaner HTTP service exposes the same idea through /protected/service.
I called it 12 times in one run. The result was:
protected-01 failure: Unreliable service failed
protected-02 success: Unreliable-test-request
protected-03 failure: Unreliable service failed
protected-04 failure: Unreliable service failed
protected-05 failure: Unreliable service failed
protected-06 open: Circuit breaker is OPEN - failing fast
protected-07 open: Circuit breaker is OPEN - failing fast
protected-08 open: Circuit breaker is OPEN - failing fast
protected-09 open: Circuit breaker is OPEN - failing fast
protected-10 open: Circuit breaker is OPEN - failing fast
protected-11 open: Circuit breaker is OPEN - failing fast
protected-12 open: Circuit breaker is OPEN - failing fast
That sequence is useful because it shows both reset and open behavior. Call 2 succeeded, so the earlier failure did not keep accumulating. Calls 3, 4, and 5 then failed consecutively. After that, calls 6 through 12 were rejected by the breaker before the unreliable service was called.
The companion advanced endpoint /service/circuit-breaker had a different run:
12 calls: 9 successes, 3 failures, 0 open-breaker responses
successful calls completed in about 104-107ms
That is not a contradiction. That implementation resets its failure counter on success. The failures were not consecutive enough to open the breaker. The numbers are a useful reminder not to overread stochastic demos. A breaker is a state machine, and the sequence matters.
Fallback is another place where structure matters.
If a primary optional branch fails, one option is to run fallback logic. But that does not mean the fallback has to be a sibling in the same scope.
The companion ScopedRequestHandler.runWithFallback() keeps the shape simple:
public <T> T runWithFallback(Callable<T> primary, Callable<T> fallback) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var primaryFuture = scope.fork(primary);
scope.join();
scope.throwIfFailed();
return primaryFuture.get();
} catch (Exception primaryFailure) {
return fallback.call();
}
}
The primary attempt is scoped. If it succeeds, the parent returns the primary result. If it fails, the fallback is called after the primary scope has finished.
That sequencing is important. It avoids accidentally doubling load by running primary and fallback every time. It also makes the fallback contract easier to read: fallback is a degraded response path after primary failure, not a competing sibling unless you explicitly choose a race.
The HTTP endpoint /data/with-fallback?key=userdata showed the fallback path in one run:
Secondary-userdata (Duration: 260ms)
The code explains that timing. The primary database path sleeps for 100ms and fails randomly. The secondary path sleeps for 150ms. A fallback response therefore lands around 250-260ms.
The focused load check mixed primary successes and fallback responses:
wrk -t4 -c40 -d10s http://localhost:8085/data/with-fallback?key=userdata
Latency avg: 150.00ms
Requests/sec: 263.86
Requests: 2,661
The wide standard deviation in that run is expected. Some requests took the 100ms primary path. Others took the primary-fail-plus-secondary path.
Retry is related to resilience, but it is not the same policy as cancellation.
Cancellation asks: should sibling work keep running?
Retry asks: should this operation be attempted again?
Those policies should not be hidden inside each other. If a subtask quietly retries many times, it can hold a scope open much longer than the parent expects. If every sibling has its own hidden retry loop, failure can multiply work instead of reducing it.
The cleaner helper ScopedRequestHandler.runWithRetry() makes retry visible:
public <T> T runWithRetry(Callable<T> task, int maxRetries, Duration retryDelay) throws Exception {
Exception lastException = null;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
return runInScope(task);
} catch (Exception failure) {
lastException = failure;
if (attempt < maxRetries) {
Thread.sleep(retryDelay.toMillis());
}
}
}
throw new RuntimeException("All " + maxRetries + " attempts failed", lastException);
}
The direct Thread.sleep(...) is acceptable in this learning code because these requests run on virtual threads, so the delay parks the virtual thread instead of occupying a carrier thread. On platform threads, the same sleep would deserve a closer look.
The standalone demo produced:
Attempt 1 failed: External service failed
RETRY SUCCESS: External-important-task
The advanced HTTP retry endpoint showed a different stateful pattern:
advanced-retry-01 failure: attempt 2
advanced-retry-02 success: retryable-service-ok-after-3-attempts
advanced-retry-03 failure: attempt 1
advanced-retry-04 failure: attempt 2
That endpoint increments an attempt counter across requests and succeeds on the third attempt. It is useful as a teaching example because it makes the retry state visible, but it is not the same as doing all retry attempts inside one request.
Again, the lesson is not "use this exact retry implementation." The lesson is to keep retry policy explicit and bounded.
Bulkheads are not the main pattern in this article, but they connect to the same idea: do not let one class of work consume capacity meant for another.
The advanced endpoint /pattern/bulkhead uses two scopes:
critical-auth at 100ms and critical-payment at 150ms,analytics at 200ms and logging at 50ms.One request returned:
Bulkhead Pattern: Critical[critical-auth-ok, critical-payment-ok] Non-Critical[analytics-ok, logging-ok]
Duration: 206ms
The focused load check matched the slowest non-critical branch:
wrk -t4 -c40 -d10s http://localhost:8082/pattern/bulkhead
Latency avg: 206.56ms
Requests/sec: 190.13
Requests: 1,920
The useful teaching point is not the throughput number. It is that the code separates critical and non-critical work into different scopes. That makes the capacity policy visible instead of burying all tasks in one undifferentiated fan-out.
The policy follows the reason you are stopping or redirecting work. If one failed branch makes the whole response invalid, make that branch fail the scope and let the sibling work stop. If the dependency should not be attempted at all, put a circuit breaker in front of the call and reject before any downstream work is forked. If the primary path is optional or replaceable, run fallback after primary failure and make the degraded response explicit. If the same operation may succeed after another attempt, use a bounded retry policy and keep the attempt count visible. If critical and non-critical work should not share the same failure or capacity boundary, split them into separate scopes.
The trap is treating all of these as generic "error handling." They are different policies. Structured concurrency helps by giving the task lifetime a visible boundary, but the policy still has to be named.
For conditional cancellation and circuit-breaker flows, test sequences, not just individual requests. A failure counter that resets on success behaves very differently from one that accumulates, and a single request will not catch that difference. The open-breaker rejection path needs its own check: does the code actually skip the downstream call, or does it attempt the call and then discard the result?
Fallback and retry paths need bounds checks too. Fallback should run only after primary failure, and retry attempts should be visible and capped. For bulkhead-style code, test that critical and non-critical branches really have separate policy boundaries instead of sharing one undifferentiated scope.
The port 8082 metrics after this local run reported 1,970 total requests, 0 timeout responses, and 204.88ms average response time across the mixed circuit-breaker, retry, and bulkhead checks. Those aggregate numbers are secondary; the important evidence is the per-flow behavior above.
Part 4 moves from single-level fan-out to progressive and hierarchical task management. That is where the parent no longer just waits for a set of siblings; it also tracks progress and nested ownership.
The companion repository at github.com/salgotraja/project-loom has runnable examples for every post in this series. The README covers build setup and the scripts used to reproduce the checks.