engnotes.dev
NotebookTopicsAbout

Subscribe

One email when a new post goes up. Nothing else.

one per post · no tracking · also on RSS

Site

  • Notebook
  • Topics
  • About
  • Contact

Topics

Project Loom9Structured Concurrency9Tail Latency & System Behavior4

Elsewhere

  • GitHub
  • X
  • LinkedIn
  • Email
engnotes.dev© 2026 Jagdish Salgotra · written on personal time. not on employer time.
PrivacyTermsCookies
blog/structured-concurrency/part 3
Structured Concurrency · Part 3 of 9

Cancelling siblings before they burn capacity

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.

J
Jagdish Salgotra
2026-04-06·10 min read·~368 words

Series navigation

← PreviousWhat a missed deadline should do, and what it should notNext →Two workflow shapes that show up after fork-and-wait
Code repositoryproject-loom
#structured-concurrency
share
J

Written by

Jagdish Salgotra

Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.

all posts

Keep reading · rest of the series

  • 2026-03-2215 min read
    Part 1
    What structured scopes actually catch
  • 2026-03-3010 min read
    Part 2
    What a missed deadline should do, and what it should not
  • 2026-04-1210 min read
    Part 4
    Two workflow shapes that show up after fork-and-wait
  • 2026-04-1910 min read
    Part 5
    Why downstream capacity is the real ceiling on fan-out
Was this article helpful? or email →
anonymous · no account needed

On this page

Reading progress

0 min of 10 · ~10 left

Ask the post

Any answer points back at the paragraph it came from.

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.

Why Timeout Alone Is Not Enough

Timeouts handle slow dependencies, but many production incidents are business-condition failures:

  • payment authorization fails,
  • risk checks fail,
  • dependency breaker opens.

In these cases, continuing sibling work is wasted load. Conditional cancellation addresses this.

Pattern 1: Business-Condition Fail-Fast

In Java 21 preview, a practical pattern is to convert terminal business failures into exceptions inside subtasks so ShutdownOnFailure can cancel siblings.

java
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.

Pattern 2: Circuit Breaker + Structured Scope

Circuit breakers decide whether a call should even be attempted. Structured scopes then coordinate lifecycle for calls that do proceed.

java
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.

Pattern 3: Conditional Fallback Scope

For non-critical enrichments, branch into a fallback path instead of failing the whole request.

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

Java 21 Preview Limitations

  1. ShutdownOnFailure uses exception semantics; if a business condition should fail fast, convert it into an explicit exception.

  2. Avoid hidden retry loops in subtasks; retries can multiply traffic during outages, so keep retry policy bounded and observable.

  3. Always check throwIfFailed() after join() in Java 21 preview style.

  4. Keep side effects idempotent where cancellation can occur mid-flow.

  5. Scope shutdown does not interrupt in-flight I/O instantly; virtual threads park gracefully, but long operations may still complete.

  6. Pre-breaker rejections should be metered separately from attempt failures.

Testing Guidance

For conditional cancellation flows, test:

  • Primary success path.
  • Terminal business failure (for example payment declined).
  • Dependency timeout + breaker transition.
  • Fallback path correctness.
  • Idempotency under retries/cancellation.

Build and Runtime Reminder

bash
javac --release 21 --enable-preview ...
java --enable-preview ...

Resources

  • JEP 453: Structured Concurrency (Preview)
  • JEP 444: Virtual Threads
  • Resilience4j