DEV Community

Jagdish Salgotra
Jagdish Salgotra

Posted on

Conditional Cancellation in Java 21: When Sibling Work Should Stop

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.

Originally published on engnotes.dev:
Conditional Cancellation in Java 21

This is a shortened version with the same core code and takeaways.

Timeouts are only one kind of failure.

A lot of expensive failures happen when the outcome is already decided, but parts of the request are still doing work anyway. Payment failed. Risk check failed. Circuit breaker is already open. In those cases, letting sibling tasks continue is usually just wasted load.

That is where conditional cancellation becomes useful.

The Fail-Fast Version

One practical Java 21 pattern is to turn a terminal business failure into an exception inside the subtask, so ShutdownOnFailure can cancel sibling work:

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();

        return "Circuit Breaker Result: " + primaryService.get();
    }
}
Enter fullscreen mode Exit fullscreen mode

What I like here is that the fail-fast rule is not buried somewhere else in the request flow. It is part of the scope behavior.

Circuit Breaker Policy Is Still Separate

The article makes an important distinction I would keep:

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();
        return result;
    } catch (Exception e) {
        dbCircuitBreaker.onFailure();
        throw e;
    }
}
Enter fullscreen mode Exit fullscreen mode

That separation is worth keeping straight.

  • the circuit breaker decides whether a call should even be attempted
  • the structured scope manages lifecycle for work that actually starts

Mix those up too much and the code gets harder to reason about.

Fallback Should Be Intentional

Fallback is useful too, but only when degraded results are genuinely acceptable:

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I would not use this as a default escape hatch for every failure. It makes sense for optional enrichments. It is a bad habit when it starts hiding broken primary paths.

The Java 21 Review Rule

For this style of code, I would still review the same basics first:

  • convert terminal business failures into explicit exceptions if they should stop sibling work
  • call join() before reading results
  • call throwIfFailed() before get
  • keep retries bounded and visible
  • keep side effects idempotent where cancellation can happen mid-flow

Those are the details that make fail-fast code trustworthy instead of just clever.

The Practical Takeaway

What structured concurrency gives you here is a cleaner boundary for business-condition failure.

If the request is already done in business terms, the code should be able to say that clearly and stop the rest. That is a better model than letting work leak on just because the tasks were launched.

Full article with more examples, testing guidance, runnable repo, and live NoteSensei chat:

Conditional Cancellation in Java 21

Top comments (0)