Series navigation
Written by
Jagdish Salgotra
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Not every failure is a timeout. A failed risk check turns the rest of the fan-out into wasted work, so the fix is to throw inside the subtask, cancelling its siblings before they burn capacity. The pattern, the implementation, and where it backfires.
Written by
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Note This article uses Java 21 preview structured concurrency APIs (JEP 453). See Part 9 for migration changes in Java 25 preview APIs. Compile and run with
--enable-preview.
Timeouts handle slow dependencies, but many production incidents are business-condition failures:
In these cases, continuing sibling work is wasted load. Conditional cancellation addresses this.
In Java 21 preview, a practical pattern is to convert terminal business failures into exceptions inside subtasks so ShutdownOnFailure can cancel siblings.
public String circuitBreakerExample() throws Exception {
if (circuitBreakerFailures.get() > 3) {
logger.warn("Circuit breaker is OPEN - failing fast");
return "Circuit breaker is OPEN - service unavailable";
}
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var primaryService = scope.fork(() -> {
if (Math.random() < 0.3) {
circuitBreakerFailures.incrementAndGet();
throw new RuntimeException("Service failure");
}
circuitBreakerFailures.set(0);
return simulateServiceCall("primary-service", 100);
});
scope.join();
scope.throwIfFailed(); // Standard Java 21 preview fail-fast check after join.
String result = "Circuit Breaker Result: " + primaryService.get();
logger.info("Circuit breaker example completed successfully");
return result;
}
}
This avoids burning downstream capacity after known terminal failures.
Circuit breakers decide whether a call should even be attempted. Structured scopes then coordinate lifecycle for calls that do proceed.
public String callProtectedService(String request) throws Exception {
if (dbCircuitBreaker.isOpen()) {
String message = String.format(
"Circuit breaker is OPEN for %s - failing fast (failures: %d/%d, next retry in: %s)",
dbCircuitBreaker.getServiceName(),
dbCircuitBreaker.getFailureCount(),
dbCircuitBreaker.getThreshold(),
dbCircuitBreaker.getTimeUntilRetry());
logger.warn(message);
throw new RuntimeException(message);
}
try {
String result = scopedHandler.runInScope(() -> callUnreliableService(request));
dbCircuitBreaker.onSuccess();
logger.info("Protected service call succeeded, circuit breaker reset");
return result;
} catch (Exception e) {
dbCircuitBreaker.onFailure();
logger.error("Protected service call failed (failure {}/{}): {}",
dbCircuitBreaker.getFailureCount(),
dbCircuitBreaker.getThreshold(),
e.getMessage());
throw e;
}
}
Keep breaker policy outside scope internals. Scope handles task lifecycle; breaker handles dependency admission policy. Record failure only on actual attempts; pre-breaker rejections should usually be tracked as a separate metric. For production systems, prefer libraries like Resilience4j for persisted breaker state and richer metrics; this example is illustrative.
For non-critical enrichments, branch into a fallback path instead of failing the whole request.
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();
try {
scope.throwIfFailed();
return primaryFuture.get();
} catch (Exception e) {
logger.warn("Primary task failed, using fallback: {}", e.getMessage());
return fallback.call();
}
}
}
ShutdownOnFailure uses exception semantics; if a business condition should fail fast, convert it into an explicit exception.
Avoid hidden retry loops in subtasks; retries can multiply traffic during outages, so keep retry policy bounded and observable.
Always check throwIfFailed() after join() in Java 21 preview style.
Keep side effects idempotent where cancellation can occur mid-flow.
Scope shutdown does not interrupt in-flight I/O instantly; virtual threads park gracefully, but long operations may still complete.
Pre-breaker rejections should be metered separately from attempt failures.
For conditional cancellation flows, test:
javac --release 21 --enable-preview ...
java --enable-preview ...