Series navigation
Written by
Jagdish Salgotra
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Was this useful?
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.
Written by
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Was this useful?
Note Examples in this article use Java 21 preview
StructuredTaskScopeAPIs (JEP 453). See Part 9 for Java 25 migration mapping. Compile and run with--enable-preview.
Many operations are not just "run N tasks and wait":
Structured concurrency can support this without abandoning clear lifecycle boundaries.
Java 21 preview provides joinUntil(...), which can be used for polling-style progress loops.
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.
Nested scopes can mirror service boundaries cleanly.
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.
Sometimes parent response can proceed even when child enrichment scope fails.
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.
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.throwIfFailed() when using ShutdownOnFailure and full-success semantics.For progressive/hierarchical flows, test:
javac --release 21 --enable-preview ...
java --enable-preview ...