Todos os artigos

// Knowledge.log — 技術記事

StructuredTaskScope no Java 25: fan-out no Spring Boot

Organize fan-out por request com StructuredTaskScope no Java 25, incluindo cancelamento, timeout, testes e comparação com CompletableFuture.

Uma requisição de cotação precisa buscar preço e estoque em dois serviços independentes. A resposta só faz sentido quando as duas consultas terminam bem. Se uma falhar, a outra deixa de ter utilidade; se o prazo da operação expirar, ambas devem parar. O resultado esperado é um QuoteView completo ou uma falha única e previsível — nunca meia cotação acompanhada de uma tarefa esquecida no executor.

Esse fan-out cabe em poucas linhas até surgirem cancelamento, timeout e propagação de exceção. Com CompletableFuture, esses detalhes ficam por conta da aplicação. A StructuredTaskScope do Java 25 coloca as tarefas sob o mesmo tempo de vida da requisição: o thread dono abre o escopo, cria os filhos, espera, lê os resultados e fecha o escopo.

O ganho aqui não é uma promessa de velocidade. É tornar explícita a regra “ou todos terminam, ou nenhum continua”. Parece óbvio depois de escrito. Antes disso, costuma morar naquele finally que seria adicionado na próxima sprint.

Versões, suporte e a flag de preview

A combinação usada é Spring Boot 4.1.1 com Java 25 LTS. A matriz oficial do Spring Boot informa que a versão 4.1.1 requer Java 17 ou superior e aceita até Java 26, portanto o Java 25 está dentro do conjunto suportado.

A structured concurrency continua sendo uma API de preview no JDK 25, definida pela JEP 505. Isso exige --enable-preview na compilação, nos testes e na execução, além de aceitar possíveis mudanças de API em uma atualização futura do JDK. O Maven pode ser configurado assim:

<properties>
    <java.version>25</java.version>
    <maven.compiler.release>25</maven.compiler.release>
</properties>

<build>
    <plugins>
        <plugin>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <compilerArgs>
                    <arg>--enable-preview</arg>
                </compilerArgs>
            </configuration>
        </plugin>
        <plugin>
            <artifactId>maven-surefire-plugin</artifactId>
            <configuration>
                <argLine>--enable-preview</argLine>
            </configuration>
        </plugin>
    </plugins>
</build>

Para executar o artefato empacotado, a flag também acompanha a JVM:

java --enable-preview -jar target/quotes-api.jar
ComponenteVersãoPapel no exemplo
Java25 LTSAPI StructuredTaskScope e virtual threads dos filhos
Spring Boot4.1.1Endpoint HTTP e ciclo da aplicação
Maven Compiler/Surefireversão gerenciada pelo projetoHabilitar preview em código e testes

O escopo pertence à requisição

O fluxo da API no JDK 25 é openforkjoin → leitura dos resultados → close. O try com recursos garante o fechamento. Todas essas operações de controle pertencem ao thread dono, que, neste caso, é o mesmo thread que atende a requisição.

Isso significa que não se coloca @Async no método que abre o escopo e não se entrega o escopo ao TaskExecutor do Spring. A StructuredTaskScope já cria, por padrão, virtual threads sem nome para executar os filhos; ela não é um ExecutorService e não depende do applicationTaskExecutor.

O serviço pode consolidar duas dependências de tipos diferentes com Joiner.awaitAllSuccessfulOrThrow():

package academy.devdojo.quotes;

import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;

public final class QuoteService {
    private final PriceClient prices;
    private final InventoryClient inventory;

    public QuoteService(PriceClient prices, InventoryClient inventory) {
        this.prices = prices;
        this.inventory = inventory;
    }

    public QuoteView load(String sku) throws InterruptedException {
        try (var scope = StructuredTaskScope.open(
                StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
                config -> config.withTimeout(Duration.ofSeconds(2)))) {

            var price = scope.fork(() -> prices.get(sku));
            var stock = scope.fork(() -> inventory.get(sku));

            scope.join();
            return new QuoteView(price.get(), stock.get());
        } catch (StructuredTaskScope.FailedException failure) {
            throw new UpstreamCallException(failure.getCause());
        } catch (StructuredTaskScope.TimeoutException timeout) {
            throw new UpstreamDeadlineException(timeout);
        }
    }
}

O relógio do timeout começa em open. Se um filho falhar ou o prazo expirar, o escopo cancela os filhos inacabados por interrupção. O close() espera que eles terminem; portanto, PriceClient e InventoryClient precisam usar operações que respondam à interrupção. Um driver ou cliente bloqueado que a ignore não ganha cancelamento por osmose: o fechamento continuará esperando.

No controller, a chamada permanece síncrona no thread da requisição. Se join() lançar InterruptedException, restaure o estado de interrupção antes de convertê-la em resposta da aplicação:

@GetMapping("/quotes/{sku}")
QuoteView quote(@PathVariable String sku) {
    try {
        return quoteService.load(sku);
    } catch (InterruptedException interrupted) {
        Thread.currentThread().interrupt();
        throw new ResponseStatusException(
                HttpStatus.SERVICE_UNAVAILABLE,
                "request interrupted",
                interrupted);
    }
}

Para observar isso em produção, registre um contador por desfecho — sucesso, falha de dependência, deadline e interrupção da requisição — e um log estruturado com o identificador da operação e a classe da causa. Evite registrar respostas completas dos serviços. Além de ruído, elas têm o hábito desagradável de conter dados que ninguém pretendia colocar no log.

Escolhendo o Joiner do Java 25

A política de conclusão fica no Joiner, não em subclasses ou construtores públicos. Os três casos mais úteis para fan-out são:

  • Joiner.awaitAllSuccessfulOrThrow(): espera todas as tarefas, retorna Void e permite reunir resultados heterogêneos pelos objetos Subtask;
  • Joiner.allSuccessfulOrThrow(): espera todas e devolve um Stream de subtarefas, conveniente quando os resultados têm o mesmo tipo;
  • Joiner.anySuccessfulResultOrThrow(): devolve o primeiro resultado bem-sucedido e cancela os demais, adequado para uma corrida entre fontes equivalentes.

A documentação da 0 no Java 25 também mostra as regras do thread dono e os estados das subtarefas. Chame get() somente depois de um join() bem-sucedido. Após falha ou timeout, uma subtarefa cancelada pode não ter resultado disponível.

Para uma coleção homogênea, a forma muda pouco:

try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<Offer>allSuccessfulOrThrow(),
        config -> config.withTimeout(Duration.ofSeconds(2)))) {

    skus.forEach(sku -> scope.fork(() -> offerClient.fetch(sku)));
    return scope.join()
            .map(StructuredTaskScope.Subtask::get)
            .toList();
}

Se a lista puder crescer durante o fork, verifique scope.isCancelled() no laço e pare de criar trabalho quando o escopo já estiver cancelado. A observação segura é medir a quantidade de filhos criada por requisição e alertar para crescimento fora do limite definido pelo domínio, em vez de descobrir o fan-out acidental pela conta do serviço externo.

A mesma coordenação com CompletableFuture

CompletableFuture.allOf continua válido, mas não oferece a mesma política de vida útil por construção. Em complexidade equivalente, a aplicação precisa fornecer o executor, aplicar o prazo e decidir o que fazer com cada futuro na saída excepcional:

var price = CompletableFuture.supplyAsync(
        () -> prices.getUnchecked(sku), executor);
var stock = CompletableFuture.supplyAsync(
        () -> inventory.getUnchecked(sku), executor);

try {
    CompletableFuture.allOf(price, stock).get(2, TimeUnit.SECONDS);
    return new QuoteView(price.join(), stock.join());
} catch (Exception failure) {
    price.cancel(true);
    stock.cancel(true);
    throw failure;
}

Uma falha em price não faz allOf cancelar stock automaticamente. Além disso, o argumento de interrupção de CompletableFuture.cancel(true) não garante que a computação iniciada por supplyAsync seja interrompida. Para equivaler ao escopo, ainda é preciso definir a estratégia do executor, coordenar o encerramento e observar tarefas que sobreviveram à requisição.

Isso não torna CompletableFuture errado nem a StructuredTaskScope mais rápida. CF é uma escolha melhor quando o grafo deliberadamente atravessa threads e tempos de vida, quando a aplicação já possui uma composição assíncrona estável ou quando o resultado será completado longe do ponto que iniciou o trabalho. STS é mais legível quando os filhos existem apenas para produzir uma resposta do pai.

Verificando que a falha interrompe o irmão

Um teste útil não precisa medir tempo. Ele precisa provar que o filho bloqueado começou, que o outro filho falhou, que join() reportou FailedException e que o bloqueado recebeu interrupção:

@Test
void failingChildInterruptsSleepingSibling() throws Exception {
    var sleeperStarted = new CountDownLatch(1);
    var siblingInterrupted = new AtomicBoolean(false);
    var expected = new IllegalStateException("inventory unavailable");

    try (StructuredTaskScope<Object, Void> scope =
            StructuredTaskScope.open()) {

        scope.fork(() -> {
            sleeperStarted.countDown();
            try {
                Thread.sleep(Duration.ofMinutes(1));
                return "unexpected";
            } catch (InterruptedException interrupted) {
                siblingInterrupted.set(true);
                throw interrupted;
            }
        });

        scope.fork(() -> {
            sleeperStarted.await();
            throw expected;
        });

        var failure = assertThrows(
                StructuredTaskScope.FailedException.class,
                scope::join);

        assertSame(expected, failure.getCause());
    }

    assertTrue(siblingInterrupted.get());
}

O smoke local com Java 25.0.4 e --enable-preview compilou esse caminho da API: joiner padrão, FailedException e interrupção do irmão adormecido. O teste registra apenas passou ou falhou; ele não demonstra desempenho.

Há três diagnósticos que merecem gate próprio no CI e sinal próprio em produção:

ProblemaCorreção concretaObservação segura em produção
Preview habilitado na compilação, mas não nos testes ou runtimeConfigurar Compiler, Surefire e comando java com --enable-previewFalhar o smoke de inicialização na mesma imagem usada no deploy
Cliente de I/O ignora interrupçãoDefinir timeout no próprio cliente e testar cancelamento com uma dependência controladaContar scopes fechando após deadline e chamadas externas ainda ativas
Escopo controlado por outro threadManter open, fork, join e close no método chamado pelo controllerTratar WrongThreadException como erro de programação e alertar, não como retry

Use StructuredTaskScope.TimeoutException no tratamento do prazo. java.util.concurrent.TimeoutException pertence a outras APIs e não captura esse caso. Também não tente ler Subtask.get() depois que join() falhou; trate a causa de FailedException e encerre a resposta.

Quando não usar

STS não é a ferramenta para trabalho CPU-bound que pede divisão recursiva; ForkJoinPool ou streams paralelos atendem outro tipo de problema. Também não resolve I/O que ignora interrupção, streaming contínuo entre tarefas, filas sem limite ou coordenação distribuída entre serviços. Um fan-out enorme continua sendo um fan-out enorme, agora apenas mais bem organizado.

A DevDojo adotaria a API em um serviço que já está fixado no Java 25, aceita conscientemente o contrato de preview e possui fan-out pequeno, limitado pela request, com clientes comprovadamente interruptíveis. Manteria CompletableFuture quando o fluxo atravessa o tempo de vida da requisição, depende de um grafo assíncrono existente ou quando a política da equipe não permite API de preview em produção.

O próximo passo é pequeno: escolha um endpoint com duas dependências independentes, coloque o escopo no thread da requisição e execute o teste de interrupção. Depois provoque falha e deadline em ambiente controlado e confira os quatro desfechos nos logs e contadores. Se o filho bloqueado não terminar, o problema não está na sintaxe do joiner; está na dependência que não coopera com cancelamento — exatamente o tipo de verdade útil que um teste deve trazer cedo.

javaspring-bootconcurrency

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos