engnotes.dev
NotebookTopicsAbout

Weekly Digest

The engineering brief.

Weekly insights · No spam · RSS

Site

  • Notebook
  • Topics
  • About
  • Contact

Topics

Structured Concurrency9

Elsewhere

  • GitHub
  • X (Twitter)
  • LinkedIn
  • Email
engnotes.dev© 2026 Jagdish Salgotra · written on personal time. not on employer time.
PrivacyTermsCookies
blog/structured-concurrency/part 4
Structured Concurrency

Two workflow shapes that show up after fork-and-wait

Past basic fork-and-wait, two workflow shapes dominate. Streaming progress as subtasks finish works for user-facing flows. Nested scopes that mirror the service tree work for fan-out into fan-out. What each one costs.

J
Jagdish Salgotra
2026-04-12·10 min read·~306 words

Series navigation

← PreviousCancelling siblings before they burn capacityNext →Why downstream capacity is the real ceiling on fan-out
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

  • 2026-05-1712 min readMigrating our fan-out service from Java 21 to Java 25
  • 2026-05-1010 min readFour operational checks we run on every StructuredTaskScope
  • 2026-05-048 min readThree structured-concurrency patterns we run in a fan-out service

Was this useful?

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 Examples in this article use Java 21 preview StructuredTaskScope APIs (JEP 453). See Part 9 for Java 25 migration mapping. Compile and run with --enable-preview.

Why These Patterns Matter

Many operations are not just "run N tasks and wait":

  • you may want to stream progress as subtasks finish,
  • you may need nested scope ownership that mirrors business layers,
  • you may need controlled cancellation at each level.

Structured concurrency can support this without abandoning clear lifecycle boundaries.

Pattern 1: Progressive Completion Tracking

Java 21 preview provides joinUntil(...), which can be used for polling-style progress loops.

java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    List<StructuredTaskScope.Subtask<T>> subtasks = new ArrayList<>();

    for (int i = 0; i < request.getTasks().size(); i++) {
        final int taskIndex = i;
        Callable<T> task = request.getTasks().get(i);

        subtasks.add(scope.fork(() -> {
            try {
                logger.debug("Starting task {}", taskIndex);
                T result = task.call();
                progressTracker.updateProgress(executionId, taskIndex, "completed");
                request.getProgressCallback().accept(new ProgressUpdate<>(taskIndex, result, null));
                logger.debug("Task {} completed successfully", taskIndex);
                return result;
            } catch (Exception e) {
                logger.debug("Task {} failed: {}", taskIndex, e.getMessage());
                progressTracker.updateProgress(executionId, taskIndex, "failed: " + e.getMessage());
                request.getProgressCallback().accept(new ProgressUpdate<>(taskIndex, null, e));
                throw e;
            }
        }));
    }

    Duration timeout = request.getTimeout();
    Instant deadline = Instant.now().plus(timeout);

    while (totalCompleted < subtasks.size() && Instant.now().isBefore(deadline)) {
        try {
            scope.joinUntil(Instant.now().plusMillis(50)); // Short polling interval for responsiveness; tune to avoid CPU spikes in high-task counts.
        } catch (TimeoutException e) {

        }

        for (int i = 0; i < subtasks.size(); i++) {
            if (!completed[i]) {
                var subtask = subtasks.get(i);
                var state = subtask.state();

                switch (state) {
                    case SUCCESS:
                    case FAILED:
                        completed[i] = true;
                        totalCompleted++;
                        break;
                    case UNAVAILABLE:
                        break;
                }
            }
        }
    }

    if (Instant.now().isAfter(deadline)) {
        scope.shutdown();
    }
}

Use short polling intervals and keep progress handlers lightweight. When run on virtual threads, parking during waits keeps carrier efficiency.

Pattern 2: Hierarchical Scope Ownership

Nested scopes can mirror service boundaries cleanly.

java
public String executeHierarchical() throws Exception {
    try (var parentScope = new StructuredTaskScope.ShutdownOnFailure()) {

        var childTask1 = parentScope.fork(() -> executeChildTasks("Group-1"));
        var childTask2 = parentScope.fork(() -> executeChildTasks("Group-2"));
        var childTask3 = parentScope.fork(() -> executeChildTasks("Group-3"));

        parentScope.join();
        parentScope.throwIfFailed();

        return String.format("Parent completed: [%s, %s, %s]",
                           childTask1.get(), childTask2.get(), childTask3.get());
    }
}

private String executeChildTasks(String group) throws Exception {
    try (var childScope = new StructuredTaskScope.ShutdownOnFailure()) {

        var task1 = childScope.fork(() -> {
            Thread.sleep(50);
            return group + "-Task-1";
        });

        var task2 = childScope.fork(() -> {
            Thread.sleep(100);
            return group + "-Task-2";
        });

        childScope.join();
        childScope.throwIfFailed();

        return String.format("%s: [%s, %s]", group, task1.get(), task2.get());
    }
}

Each scope has clear responsibility and failure boundary.

Pattern 3: Partial Hierarchical Degradation

Sometimes parent response can proceed even when child enrichment scope fails.

java
public String bulkheadPattern() throws Exception {
    try (var criticalScope = new StructuredTaskScope.ShutdownOnFailure();
         var nonCriticalScope = new StructuredTaskScope.ShutdownOnFailure()) {

        var criticalService1 = criticalScope.fork(() -> simulateServiceCall("critical-auth", 100));
        var criticalService2 = criticalScope.fork(() -> simulateServiceCall("critical-payment", 150));

        var nonCriticalService1 = nonCriticalScope.fork(() -> simulateServiceCall("analytics", 200));
        var nonCriticalService2 = nonCriticalScope.fork(() -> simulateServiceCall("logging", 50));
        criticalScope.join();
        criticalScope.throwIfFailed();
        try {
            nonCriticalScope.join();
            nonCriticalScope.throwIfFailed();
        } catch (Exception e) {
            // Non-critical scope failure caught separately for degraded response.
            logger.warn("Non-critical services failed: {}", e.getMessage());
        }

        String result = String.format("Bulkhead Pattern: Critical[%s, %s] Non-Critical[%s, %s]",
            criticalService1.get(), criticalService2.get(),
            "analytics-ok", "logging-ok");
        return result;
    }
}

Keep fallback behavior explicit and product-reviewed.

Java 21 Preview Limitations

  • joinUntil(...) loops can become busy waits if interval is too small; in Java 21 experiments, tight polling loops occasionally caused minor CPU spikes until intervals were tuned.
  • Avoid heavy work inside progress callbacks.
  • Always call throwIfFailed() when using ShutdownOnFailure and full-success semantics.
  • Define whether child-scope failure is terminal or optional for parent response.
  • Nested scopes improve lifecycle visibility but deep hierarchies can be harder to debug; keep nesting shallow where possible.

Testing Guidance

For progressive/hierarchical flows, test:

  • All subtasks complete quickly.
  • Some subtasks fail.
  • Budget expires with partial completion.
  • Child scope fails while parent continues with fallback.
  • No orphaned work remains after deadline.

Build and Runtime Reminder

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

Resources

  • JEP 453: Structured Concurrency (Preview)
  • Java 21 API: StructuredTaskScope (Preview)
  • Resilience4j