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

Structured Concurrency9Tail Latency & System Behavior2

Elsewhere

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

Migrating our fan-out service from Java 21 to Java 25

Most of the migration was mechanical. ShutdownOnFailure became a Joiner, throwIfFailed disappeared, and StructuredTaskScope.open replaced the constructor. Two things were not mechanical, and those are the ones worth reading.

J
Jagdish Salgotra
2026-05-17·12 min read·~1,800 words

Series navigation

← Previous · Part 8Four operational checks we run on every StructuredTaskScope
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-0610 min read
    Part 3
    Cancelling siblings before they burn capacity
  • 2026-04-1210 min read
    Part 4
    Two workflow shapes that show up after fork-and-wait
Was this article helpful? or email →
anonymous · no account needed

On this page

Reading progress

0 min of 12 · ~12 left

Ask the post

Any answer points back at the paragraph it came from.

The migration was smaller than I expected, but sharper.

Business logic did not move much. The simulated service delays stayed the same. The fan-out shapes stayed recognizable. What changed was the boundary around each scope: how a scope is opened, where failure policy lives, what join() returns, and what the owner is allowed to read before joining.

That is the right lesson for this series to end on. Structured concurrency is still preview, and preview APIs can move. The ideas from Java 21 still matter, but the Java 25 syntax is not a cosmetic rename.

This article is learning material. It is not a production migration report. The point is to show exactly what changed in this repository and what the migrated code proved when it ran locally.

The Java 21 version is separately managed in the feature/java-21 branch. The current branch has been migrated to Java 25 preview APIs. The measurements below were generated with OpenJDK 25.0.2. Java 25 structured concurrency is the fifth preview from JEP 505, and OpenJDK has already delivered a sixth preview for Java 26 in JEP 525. That reinforces the practical rule for the whole series: keep preview API usage contained.

The migration evidence

I compared feature/java-21 with the current Java 25 code without switching branches:

bash
git diff --stat feature/java-21..HEAD -- pom.xml src/main/java

The structured-concurrency migration touched 15 files:

text
15 files changed, 190 insertions(+), 169 deletions(-)

The scope-related counts tell the story:

PatternJava 21 branchCurrent Java 25 code
StructuredTaskScope.ShutdownOnFailure56 occurrences0 executable occurrences
StructuredTaskScope.ShutdownOnSuccess7 occurrences0 executable occurrences
throwIfFailed()48 occurrences0 occurrences
executable scope.joinUntil(...) calls4 occurrences0 occurrences
StructuredTaskScope.open(...)0 occurrences63 occurrences
StructuredTaskScope.Joiner0 occurrences63 occurrences

The Maven compiler target changed from 21 to 25 in pom.xml:

diff
- <maven.compiler.source>21</maven.compiler.source>
- <maven.compiler.target>21</maven.compiler.target>
+ <maven.compiler.source>25</maven.compiler.source>
+ <maven.compiler.target>25</maven.compiler.target>

Both versions still need preview enabled for structured concurrency:

xml
<compilerArgs>--enable-preview</compilerArgs>

That is the project-level migration. The rest is call-site work.

The build and smoke tests

For the Article 9 pass, the local toolchain was OpenJDK 25.0.2 and Maven 3.9.12:

bash
mvn clean compile -DskipTests

The build succeeded and compiled 35 source files.

Then I ran the migrated standalone examples:

CommandFresh result
java --enable-preview -cp "$CP" app.js.concurrent.StructuredExampleWithSuccessall three failure-policy subtasks completed, then first-success returned Service-C result
java --enable-preview -cp "$CP" app.js.structured.AdvancedStructuredPatternspartial results completed 2 of 4 tasks, conditional cancellation finished in 410ms, progressive results completed 4 of 4 tasks in 271ms, and the class ended with All advanced patterns completed!
java --enable-preview -cp "$CP" app.js.structured.ProgressiveHierarchicalDemoprogressive demo completed 5 of 5 tasks in 438ms, hierarchical demo completed in 1748ms, e-commerce workflow completed in 367ms
java --enable-preview -cp "$CP" app.js.scoped.ScopedValueExamplestructured-scope subtasks saw test-user and all five completed; the separate unstructured child virtual-thread section printed unknown for scoped values

The HTTP smoke checks also passed on the migrated services:

EndpointFresh result
GET /structured/aggregate on port 8082four advanced service branches completed in 203ms
GET /timeout/graceful on port 8082cache-service-ok won in 55ms
GET /async/race on port 8082hedge-ok won in 191ms
GET /timeout/short on port 8082failed with HTTP 500 after 503ms
GET /services/aggregate on port 8085four clean service branches completed in 207ms inside the service and 213ms over HTTP
GET /timed/operation?op=slow-task on port 8085completed in 1507ms
GET /cache/data?key=userdata on port 8085fell through to DB-userdata in 209ms

The focused load checks on the two aggregate endpoints landed almost identically:

EndpointLatency averageRequestsRequests/sec
GET /structured/aggregate on port 8082205.55ms98097.08
GET /services/aggregate on port 8085205.36ms98097.02

Those numbers are not a claim that Java 25 is faster than Java 21. I did not run the Java 21 branch under JDK 21 for this pass. They are proof that the migrated Java 25 code compiles and that the core examples still behave like the previous articles said they should.

The main substitution

In Java 21 preview, the simplest fail-fast scope used a policy subclass:

java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var fetch1 = scope.fork(() -> fetchFromService1());
    var fetch2 = scope.fork(() -> fetchFromService2());

    scope.join();
    scope.throwIfFailed();

    return fetch1.get() + fetch2.get();
}

In the current repository, the equivalent Java 25 shape uses StructuredTaskScope.open(...) with a Joiner:

java
try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow())) {
    var fetch1 = scope.fork(() -> fetchFromService1());
    var fetch2 = scope.fork(() -> fetchFromService2());

    scope.join();

    return fetch1.get() + fetch2.get();
}

The important migration is not just "constructor becomes factory." The failure policy moved from the subtype to the joiner. In Java 21, you opened a ShutdownOnFailure scope and then had to remember throwIfFailed() after joining. In the Java 25 code, awaitAllSuccessfulOrThrow() carries that policy.

That removed 48 throwIfFailed() calls from this repository.

You can see the cleanest version in ScopedRequestHandler.java. runInScope, both runInParallel overloads, aggregate, fallback, retry, and circuit-breaker helpers now all use the same joiner-based scope shape.

First success changed more than fail-fast

Fail-fast migration is mostly mechanical. First-success migration needs more thought because Java 21 exposed scope.result():

java
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<T>()) {
    for (Callable<T> task : tasks) {
        scope.fork(task);
    }

    scope.join();
    return scope.result();
}

The current Java 25 helper in ScopedRequestHandler.java expresses the policy with allUntil(...) and then filters joined subtasks:

java
try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<T>allUntil(s -> s.state() == Subtask.State.SUCCESS)
)) {
    for (Callable<T> task : tasks) {
        scope.fork(task);
    }

    Stream<Subtask<T>> results = scope.join();
    return results
        .filter(s -> s.state() == Subtask.State.SUCCESS)
        .findFirst()
        .map(Subtask::get)
        .orElseThrow(() -> new Exception("No successful result"));
}

That shape is a little noisier than the Java 21 teaching API, but it makes the policy explicit: join until a subtask succeeds, then select the successful subtask result.

The migrated first-success demo still behaved as expected:

text
Service-C completed on thread:
First successful result: Service-C result

Service-C is the 200ms branch. The 500ms and 1000ms branches did not decide the parent result.

joinUntil did not have a direct replacement in this code

The Java 21 branch had four executable scope.joinUntil(...) calls:

text
AdvancedStructuredPatterns.java:348
AdvancedStructuredPatterns.java:472
HierarchicalProgressiveHandler.java:107
ProgressiveResultsBenchmark.java:512

Those calls belonged to polling-style progressive and conditional flows. In the current Java 25 code, those loops use short sleeps and inspect subtask state:

java
while (totalCompleted < subtasks.size() && Instant.now().isBefore(maxTime)) {
    Thread.sleep(50);

    for (int i = 0; i < subtasks.size(); i++) {
        var subtask = subtasks.get(i);
        switch (subtask.state()) {
            case SUCCESS -> ...
            case FAILED -> ...
            case UNAVAILABLE -> ...
        }
    }
}

scope.join();

This is the least satisfying part of the migration, and the Article 8 timeout result shows why. A deadline checked after join() is not the same as a deadline that stops waiting at the boundary.

For the progressive demos, polling is acceptable learning code because the purpose is to publish progress while subtasks complete. For hard timeout enforcement, the wrapper needs more care than "replace joinUntil with join and check the clock later."

That is why Part 8 called out this result:

text
GET /timeout/short
Request timed out after 503ms

The configured deadline in that code is 300ms, but the slow branch takes 500ms and the timeout is observed after joining. The migration compiled, but the behavior is not the same as a wait-boundary timeout.

The owner must join before reading results

This rule mattered during the Article 4 work and it matters even more in Java 25.

The migrated progressive code only reads subtask results after the owner has joined the scope. In ProgressiveHierarchicalDemo.java and HierarchicalProgressiveHandler.java, the successful run produced:

text
Progressive execution completed: 5/5 tasks in 438ms
Hierarchical Results Summary: Duration: 1748 ms, Success: Yes
E-commerce Order Results: Processing Time: 367 ms, Status: Success

Earlier in the series, the broken migrated shape produced:

text
java.lang.IllegalStateException: join not called
java.lang.IllegalStateException: Owner did not join after forking

That failure was not incidental. It was the runtime enforcing the same ownership rule this series has been teaching: the parent that forks the work owns the join boundary.

Do not treat Subtask::get as a normal future read. In this API shape, reading a result before the owner has joined is a structure violation.

Scoped values were not the migration in this repository

Some Java migration guides discuss moving from ScopedValue.runWhere and callWhere to fluent ScopedValue.where(...) style. That is true as a general Java API history point, but it is not what happened in this repository.

The feature/java-21 branch already used:

java
ScopedValue.where(USER_ID, userId)
           .where(REQUEST_ID, requestId)
           .where(CORRELATION_ID, "corr-" + requestId)
           .run(() -> handleBusinessLogic());

So the repo migration for ScopedValueExample.java was still about structured scopes, not scoped-value syntax. ShutdownOnFailure changed to StructuredTaskScope.open(...), and throwIfFailed() disappeared.

The current run also showed a useful behavior distinction. Subtasks forked inside a structured scope saw the bound user:

text
Task 0 completed for test-user
Task 1 completed for test-user
Task 2 completed for test-user
Task 3 completed for test-user
Task 4 completed for test-user

But the separate virtual-thread inheritance demo printed:

text
Child context - inherited values: unknown
Grandchild context - inherited values: unknown

That output keeps the article honest. In this codebase, scoped values pair well with StructuredTaskScope subtasks. The unstructured child-thread demo is not evidence of inheritance across arbitrary virtual-thread starts.

What changed by file

The migration was spread across a small set of files:

FileWhat changed
pom.xmlcompiler source and target moved from 21 to 25 while keeping --enable-preview
ScopedRequestHandler.javareusable structured helpers moved to StructuredTaskScope.open(...) and joiners
ConcurrentServiceLayer.javaadvanced service examples migrated from policy subclasses to joiners
VirtualThreadMicroservice.javabasic structured endpoints migrated to Java 25 scope construction
AdvancedStructuredPatterns.javaprogressive and conditional polling paths replaced joinUntil(...) with sleep plus state inspection
HierarchicalProgressiveHandler.javaprogressive owner-join ordering was made explicit for Java 25
ProgressiveResultsBenchmark.javabenchmark progressive and hierarchical helpers migrated to joiners
ScopedValueExample.javastructured scopes migrated; scoped-value binding syntax was already fluent on the Java 21 branch

Several other example classes changed in the same mechanical way, but these are the files that explain the series.

The migration review I would use now

A migration review should start with the policy, not the syntax. A Java 21 ShutdownOnFailure scope should become a Java 25 scope whose joiner still fails the parent when any required child fails. A Java 21 ShutdownOnSuccess scope should become an explicit first-success policy, and the migrated code should show how it selects the winning result. Any Java 21 joinUntil(...) call deserves special attention because replacing it with join() plus a clock check can change timeout behavior.

After that, review ownership. The owner that forks must join before reading results. The Article 4 failure proved this with join not called and Owner did not join after forking. The migrated Article 4 run proved the fixed shape by completing the progressive and hierarchical demos.

Then run the code paths that represent the policies, not only the build. For this repository, the useful smoke checks were first-success, aggregate fan-out, progressive results, scoped values inside structured subtasks, timeout behavior, and the two HTTP aggregate endpoints.

Finally, keep the preview boundary small. OpenJDK delivered the Java 25 structured-concurrency changes in JEP 505, and Java 26 has already continued the preview in JEP 525 with more API movement. That is not a reason to avoid learning the model. It is a reason to keep the API close to helper classes and away from every business method.

What comes next

This series started with a simple question: what does structured concurrency give us that futures and callbacks do not?

After nine articles, the answer is still ownership. The parent owns the sibling tasks. The scope owns their lifetime. The policy owns success, failure, timeout, fallback, and cancellation behavior. The tests and benchmarks make that ownership visible.

Java 21 taught the model with ShutdownOnFailure, ShutdownOnSuccess, joinUntil, and throwIfFailed. Java 25 moved that model toward StructuredTaskScope.open(...) and Joiner. The exact API is still preview. The habit is the durable part: related concurrent work should have one local owner, one visible policy, and one place where the result becomes real.


Sources

  • JEP 444: Virtual Threads
  • JEP 453: Structured Concurrency (Preview)
  • JEP 505: Structured Concurrency (Fifth Preview)
  • JEP 506: Scoped Values
  • JEP 525: Structured Concurrency (Sixth Preview)
  • JDK 21 StructuredTaskScope API
  • JDK 25 StructuredTaskScope API
  • JDK 25 ScopedValue API