Worker API & Build Services

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Gradle 9.8.1 (released 2026-10-07). Every example was run through the 9.8.1 wrapper on 2026-10-16, on macOS, with a JDK 26 daemon (the task classes were compiled by that JDK in buildSrc; no toolchain was involved), once with Kotlin DSL build files and once with Groovy DSL build files. The two builds share the same Java classes, so the output is the same apart from the order of lines that are produced by parallel work. Each example was run with --configuration-cache and is compatible with it; the one that is not (a task calling Task.project while it runs) is shown on purpose. Where a listing shows a line with only ..., output was left out. The listings leave out blank lines and the "Calculating task graph", "Reusing configuration cache" and "Configuration cache entry" lines; the daemon process id in the output (91866 here) and the ports and process ids of worker processes differ from run to run. Gradle 8.14 was not available here, so what the text says about 8.14 comes from the release notes and the user manual and is labelled "not run here".

A build is parallel at three levels, and this page is about the two lowest ones. Gradle’s --parallel option runs tasks of different projects at the same time; the Build Environment & Properties page lists it with org.gradle.workers.max. The Worker API runs the independent pieces of one task action at the same time, optionally in an isolated classloader or in a separate process. Shared build services are the objects that such concurrent tasks and workers share, and that can limit how many of them use a resource at once. A third API, dataflow actions, runs small pieces of work that are not tasks, such as reacting to the build result. The task side is on Writing Custom Tasks and the lazy Property types used for parameters on Lazy Configuration & Providers.

The example build

The tidepool build used on all the Gradle pages predicts tide heights from harmonic constituents. For this page the build has a root project, which holds the data file and a tideTables task, and three small subprojects, tides-a, tides-b and tides-c, that apply a plugin from buildSrc. The classes live in buildSrc (see Writing Custom Tasks for the other places task classes can go). The data file is the one the other pages use:

id,name,latitude,longitude,constituents
SF,San Francisco,37.8063,-122.4659,M2:0.55:120;S2:0.13:150;K1:0.38:210
BOS,Boston,42.3539,-71.0503,M2:1.35:15;S2:0.21:42;N2:0.28:350
HNL,Honolulu,21.3067,-157.8645,M2:0.14:90;K1:0.17:250;O1:0.10:230

The buildSrc build only needs the java-gradle-plugin plugin, which gives the classes the Gradle API and registers the io.tidepool.archive plugin used further down:

Kotlin DSL (buildSrc/build.gradle.kts)
plugins {
    `java-gradle-plugin`
}

gradlePlugin {
    plugins {
        register("archive") {
            id = "io.tidepool.archive"
            implementationClass = "io.tidepool.build.ArchivePlugin"
        }
    }
}
Groovy DSL (buildSrc/build.gradle)
plugins {
    id 'java-gradle-plugin'
}

gradlePlugin {
    plugins {
        archive {
            id = 'io.tidepool.archive'
            implementationClass = 'io.tidepool.build.ArchivePlugin'
        }
    }
}

The settings file and the root build file apply the base plugin (for clean) and the plugin:

Kotlin DSL (settings.gradle.kts)
rootProject.name = "tidepool"

include("tides-a", "tides-b", "tides-c")
Groovy DSL (settings.gradle)
rootProject.name = 'tidepool'

include 'tides-a', 'tides-b', 'tides-c'
Kotlin DSL (build.gradle.kts)
plugins {
    base
    id("io.tidepool.archive")
}

tasks.register<io.tidepool.build.TideTables>("tideTables") {
    stations = layout.projectDirectory.file("data/stations.csv")
    isolation = "none"
    outputDir = layout.buildDirectory.dir("tides")
}
Groovy DSL (build.gradle)
plugins {
    id 'base'
    id 'io.tidepool.archive'
}

tasks.register('tideTables', io.tidepool.build.TideTables) {
    stations = layout.projectDirectory.file('data/stations.csv')
    isolation = 'none'
    outputDir = layout.buildDirectory.dir('tides')
}

Each subproject has a two-line build file that applies io.tidepool.archive: plugins { id("io.tidepool.archive") } in the Kotlin DSL and plugins { id 'io.tidepool.archive' } in the Groovy DSL.

The Worker API

A task action runs on one thread. If the action is a loop whose iterations do not depend on each other, such as one tide table per station, it can submit each iteration as a unit of work to a WorkerExecutor and let Gradle run them concurrently. Three types describe a unit of work:

  • WorkParameters is an interface with the inputs of one unit, declared as getters of lazy Property, RegularFileProperty or DirectoryProperty types. Gradle generates the implementation, as it does for task classes. The parameters must be Gradle-managed properties, not plain fields.

  • WorkAction<P> is an interface with an execute() method; you write an abstract class that implements it. It reads its inputs through getParameters(), which Gradle implements, and is created by Gradle for each unit.

  • WorkQueue (since 5.6) is what noIsolation(), classLoaderIsolation() or processIsolation() return; every unit submitted to one queue has the same isolation requirements.

The task gets the WorkerExecutor service by declaring an abstract getter annotated with @Inject. Here are the parameters, the action and the task:

TideParameters.java
package io.tidepool.build;

import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.Property;
import org.gradle.workers.WorkParameters;

/** The parameters of one unit of work: one station, one output file. */
public interface TideParameters extends WorkParameters {
    Property<String> getStationLine();

    RegularFileProperty getTableFile();
}
TideTableAction.java
package io.tidepool.build;

import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import org.gradle.workers.WorkAction;

/** One unit of work: a 24-hour tide table for one station (harmonic sum, as in the tidepool engine). */
public abstract class TideTableAction implements WorkAction<TideParameters> {
    @Override
    public void execute() {
        String[] f = getParameters().getStationLine().get().split(",");
        String id = f[0];
        System.out.printf("%s: thread=%s pid=%d classLoader=%s%n", id, Thread.currentThread().getName(),
                ProcessHandle.current().pid(), getClass().getClassLoader().getClass().getSimpleName());
        try {
            Thread.sleep(2000); // stands in for an expensive computation
            List<String> lines = new ArrayList<>();
            for (int hour = 0; hour < 24; hour++) {
                double sum = 0;
                for (String c : f[4].split(";")) {
                    String[] p = c.split(":");
                    double speed = switch (p[0]) {
                        case "M2" -> 28.984104;
                        case "S2" -> 30.0;
                        case "N2" -> 28.43973;
                        case "K1" -> 15.041069;
                        case "O1" -> 13.943036;
                        default -> 0;
                    };
                    sum += Double.parseDouble(p[1]) * Math.cos(Math.toRadians(speed * hour - Double.parseDouble(p[2])));
                }
                lines.add(String.format(java.util.Locale.ROOT, "%02d:00 %.3f", hour, sum));
            }
            Files.write(getParameters().getTableFile().get().getAsFile().toPath(), lines);
        } catch (IOException | InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
TideTables.java
package io.tidepool.build;

import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import javax.inject.Inject;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.OutputDirectory;
import org.gradle.api.tasks.PathSensitive;
import org.gradle.api.tasks.PathSensitivity;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.options.Option;
import org.gradle.workers.WorkQueue;
import org.gradle.workers.WorkerExecutor;

/** Writes one tide table per station, one work item per station. */
public abstract class TideTables extends DefaultTask {
    @InputFile
    @PathSensitive(PathSensitivity.NONE)
    public abstract RegularFileProperty getStations();

    @Input
    @Option(option = "isolation", description = "none, classloader or process")
    public abstract Property<String> getIsolation();

    @OutputDirectory
    public abstract DirectoryProperty getOutputDir();

    @Inject
    protected abstract WorkerExecutor getWorkerExecutor();

    @TaskAction
    public void run() throws IOException {
        WorkQueue queue = switch (getIsolation().get()) {
            case "none" -> getWorkerExecutor().noIsolation();
            case "classloader" -> getWorkerExecutor().classLoaderIsolation();
            case "process" -> getWorkerExecutor().processIsolation(spec ->
                    spec.forkOptions(options -> options.setMaxHeapSize("64m")));
            default -> throw new IllegalArgumentException("unknown isolation " + getIsolation().get());
        };
        List<String> lines = Files.readAllLines(getStations().get().getAsFile().toPath());
        for (String line : lines.subList(1, lines.size())) {
            String id = line.substring(0, line.indexOf(','));
            queue.submit(TideTableAction.class, parameters -> {
                parameters.getStationLine().set(line);
                parameters.getTableFile().set(getOutputDir().file(id + ".txt"));
            });
        }
    }
}

The action prints which thread, process and class loader it runs on, and sleeps for two seconds to stand in for a computation that is worth parallelising. The task chooses the isolation mode from an --isolation command-line option (@Option, covered on Writing Custom Tasks). getOutputDir() is declared as @OutputDirectory, so Gradle knows what the task produces. The task action itself only submits; Gradle waits for every submitted unit before it reports the task as finished. WorkQueue.await() (since 5.6) waits explicitly, for a task that needs the results before it continues.

Isolation modes

The three methods of WorkerExecutor trade isolation against start-up cost:

Method Where the unit runs Class path Cost and use

noIsolation()

a thread of the build daemon

the class loader the task class was loaded from; shared by all units, so static state is shared

the cheapest; units run concurrently but cannot be given a different library version

classLoaderIsolation()

a thread of the build daemon

a new class loader with the class path of the action plus the entries added with getClasspath() of the worker spec

a little more set-up; use it to run a different, compatible version of a library, or to keep static state of different units apart

processIsolation()

a separate Java process, a worker daemon

the class path of the action plus the entries added to the spec

the most expensive; use it for a different JVM, heap size, system properties or a library that is not safe to run in the Gradle JVM

Running the task in the three modes, with clean first so that the task is not up to date, shows where the units ran. The thread names are Gradle’s; the class loader name tells the modes apart, and the process id tells whether a unit ran in the build daemon (91866 here) or in its own process:

Output of the Kotlin DSL build (the Groovy DSL build prints the same lines)
$ ./gradlew --configuration-cache clean tideTables --isolation=none
> Task :clean
> Task :tideTables
SF: thread=WorkerExecutor Queue pid=91866 classLoader=InstrumentingVisitableURLClassLoader
BOS: thread=WorkerExecutor Queue Thread 2 pid=91866 classLoader=InstrumentingVisitableURLClassLoader
HNL: thread=WorkerExecutor Queue Thread 3 pid=91866 classLoader=InstrumentingVisitableURLClassLoader
BUILD SUCCESSFUL in 3s
6 actionable tasks: 4 executed, 2 up-to-date
$ ./gradlew --configuration-cache clean tideTables --isolation=classloader
> Task :clean
> Task :tideTables
BOS: thread=WorkerExecutor Queue Thread 2 pid=91866 classLoader=VisitableURLClassLoader
SF: thread=WorkerExecutor Queue pid=91866 classLoader=VisitableURLClassLoader
HNL: thread=WorkerExecutor Queue Thread 3 pid=91866 classLoader=VisitableURLClassLoader
BUILD SUCCESSFUL in 3s
6 actionable tasks: 2 executed, 4 up-to-date
$ ./gradlew --configuration-cache clean tideTables --isolation=process
> Task :clean
> Task :tideTables
BOS: thread=/127.0.0.1:53479 to /127.0.0.1:53476 workers pid=13659 classLoader=VisitableURLClassLoader
SF: thread=/127.0.0.1:53481 to /127.0.0.1:53477 workers pid=13661 classLoader=VisitableURLClassLoader
HNL: thread=/127.0.0.1:53480 to /127.0.0.1:53478 workers pid=13655 classLoader=VisitableURLClassLoader
BUILD SUCCESSFUL in 3s
6 actionable tasks: 2 executed, 4 up-to-date

All three units start at the same time (the build takes about three seconds, not six). In noIsolation() mode the loader is the one that loaded the task class (InstrumentingVisitableURLClassLoader). In classLoaderIsolation() it is a fresh loader. In processIsolation() mode there are three different process ids, none of them the daemon’s: Gradle started three worker daemons because the three units were submitted at the same time and one worker process runs one unit at a time. The same commands with the Groovy DSL build files print the same lines, with the stations in a different order. Both builds were run.

The number of units that run at the same time is limited by the max workers setting, which is also what limits --parallel. It is --max-workers=n on the command line and org.gradle.workers.max in gradle.properties (see Command-Line Interface); the default is the number of processors. With one worker the same build runs the three units one after the other:

$ ./gradlew --configuration-cache clean tideTables --isolation=none --max-workers=1
> Task :clean
> Task :tideTables
SF: thread=WorkerExecutor Queue pid=91866 classLoader=InstrumentingVisitableURLClassLoader
BOS: thread=WorkerExecutor Queue pid=91866 classLoader=InstrumentingVisitableURLClassLoader
HNL: thread=WorkerExecutor Queue pid=91866 classLoader=InstrumentingVisitableURLClassLoader
BUILD SUCCESSFUL in 6s
2 actionable tasks: 2 executed

Worker daemons

With processIsolation() Gradle starts a JVM for the unit, or reuses an idle one that is compatible with what the unit asked for. The compatibility rules compare what is set on the worker spec with what each running worker was started with:

  • the Java executable must be the same;

  • the class path of the worker must contain exactly the requested entries;

  • the heap size of the worker must be at least the requested size; the default maximum heap is 512 MB, and forkOptions { maxHeapSize = "64m" } (as the TideTables task does) is a request;

  • the worker must have all requested JVM arguments, system properties, environment variables and bootstrap class path entries (it may have more);

  • debug, enable-assertions and the default character encoding must be equal.

A worker daemon is session-scoped: it lives for one build and can be reused only inside it. Gradle stops idle workers when the build finishes or when memory runs low. Running the same processIsolation() build a second time shows it: the worker processes of the first run are gone and three new ones (new process ids) do the work, while the build daemon is the same one:

$ ./gradlew --configuration-cache clean tideTables --isolation=process
> Task :clean
> Task :tideTables
SF: thread=/127.0.0.1:53642 to /127.0.0.1:53640 workers pid=16769 classLoader=VisitableURLClassLoader
HNL: thread=/127.0.0.1:53644 to /127.0.0.1:53641 workers pid=16771 classLoader=VisitableURLClassLoader
BOS: thread=/127.0.0.1:53643 to /127.0.0.1:53639 workers pid=16770 classLoader=VisitableURLClassLoader
BUILD SUCCESSFUL in 3s
2 actionable tasks: 2 executed

A task that does not react to an interrupt of its thread within ten seconds makes Gradle shut the build daemon down, so work should respond to thread interruption to support Ctrl+C and task timeouts.

A failing unit of work

A failure in a unit fails the task, and the build output names the action class and the cause. Changing one constituent in the data file to K1:abc:250 and running the same build:

> Task :clean
> Task :tideTables
SF: thread=WorkerExecutor Queue pid=91866 classLoader=InstrumentingVisitableURLClassLoader
BOS: thread=WorkerExecutor Queue Thread 2 pid=91866 classLoader=InstrumentingVisitableURLClassLoader
HNL: thread=WorkerExecutor Queue Thread 3 pid=91866 classLoader=InstrumentingVisitableURLClassLoader
> Task :tideTables FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tideTables' (registered in build file 'build.gradle.kts').
> A failure occurred while executing io.tidepool.build.TideTableAction
   > For input string: "abc"
BUILD FAILED in 2s
2 actionable tasks: 2 executed

The task never called await(), yet the failure of a unit surfaced as the failure of the task: all three units had started (their lines are in the output) before it was reported. The data file was restored before the other examples were run.

Build services

A build service is an object that Gradle creates and owns for the duration of one build. Tasks, other services, artifact transforms and work actions use it to share state or a resource: a cache of pre-computed values, a web server or database used by the tests, a rate limiter. Gradle creates the service on first use, closes it when the last user is done if it is AutoCloseable, and can limit how many tasks use it at the same time. Build services are compatible with the configuration cache. They are also the configuration-cache-compatible place for build-wide state that older builds kept in a static field or in a gradle.taskGraph listener.

Defining a service

A service is an abstract class that implements BuildService<P>, where P extends BuildServiceParameters (or is BuildServiceParameters.None). The parameters follow the Gradle-managed property rules: JavaBeans getters of Property types. Gradle implements getParameters(). A service must be thread safe, because several tasks may call it at once.

ArchiveService.java
package io.tidepool.build;

import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.gradle.api.provider.Property;
import org.gradle.api.services.BuildService;
import org.gradle.api.services.BuildServiceParameters;

/** A shared, thread-safe resource: pretends to be a single-writer archive. */
public abstract class ArchiveService implements BuildService<ArchiveService.Params>, AutoCloseable {
    public interface Params extends BuildServiceParameters {
        Property<String> getName();
    }

    private final long created = System.nanoTime();
    private final AtomicBoolean opened = new AtomicBoolean();
    private final AtomicInteger writes = new AtomicInteger();

    public void write(String who) throws InterruptedException {
        if (opened.compareAndSet(false, true)) {
            System.out.println("[archive] opened " + getParameters().getName().get());
        }
        System.out.printf(java.util.Locale.ROOT, "[archive] %s start at %.1fs%n", who, seconds());
        Thread.sleep(1500);
        writes.incrementAndGet();
        System.out.printf(java.util.Locale.ROOT, "[archive] %s end   at %.1fs%n", who, seconds());
    }

    public int getWrites() {
        return writes.get();
    }

    private double seconds() {
        return (System.nanoTime() - created) / 1e9;
    }

    @Override
    public void close() {
        if (opened.get()) {
            System.out.println("[archive] closed after " + writes.get() + " writes");
        }
    }
}

The write method stands in for an operation that must not be run by too many tasks at once: it announces its start, waits 1.5 seconds and announces its end, with times counted from the creation of the service. Because the service implements AutoCloseable, Gradle calls close() when it discards the service, sometime between the last task that used it and the end of the build.

Registering a service and connecting it to a task

A service is registered once with gradle.sharedServices.registerIfAbsent(name, type) { ... }, usually by a plugin. Registering does not create the instance. The same name registered again (by the plugin applied in another project) returns the existing registration, so every project shares one instance; a build service is scoped to a build, not to a project. There are two ways for a task to receive the service:

  • a property annotated with @ServiceReference("archive"), as the example below does; Gradle assigns the service registered under that name (or of that type, when the name is left out and only one matches) and records that the task uses it;

  • a property annotated with @Internal, assigned with the provider that registerIfAbsent returned, plus a call to usesService(provider) on the task. Both are needed with @Internal: without the declaration, Gradle does not know the task uses the service and cannot apply the limit described below.

Any other annotation on a service property is not supported; a service cannot be a task input.

ArchiveTask.java
package io.tidepool.build;

import org.gradle.api.DefaultTask;
import org.gradle.api.provider.Property;
import org.gradle.api.services.ServiceReference;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.TaskAction;

/** Writes to the shared archive; Gradle wires the service through the reference. */
public abstract class ArchiveTask extends DefaultTask {
    @ServiceReference("archive")
    protected abstract Property<ArchiveService> getArchive();

    @Input
    public abstract Property<String> getLabel();

    @TaskAction
    public void run() throws InterruptedException {
        getArchive().get().write(getLabel().get());
    }
}
ArchivePlugin.java
package io.tidepool.build;

import javax.inject.Inject;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.flow.FlowProviders;
import org.gradle.api.flow.FlowScope;

public abstract class ArchivePlugin implements Plugin<Project> {
    @Inject
    protected abstract FlowScope getFlowScope();

    @Inject
    protected abstract FlowProviders getFlowProviders();

    @Override
    public void apply(Project project) {
        int max = project.getProviders().gradleProperty("archive.maxParallelUsages")
                .map(Integer::parseInt).getOrElse(1);
        project.getGradle().getSharedServices().registerIfAbsent("archive", ArchiveService.class, spec -> {
            spec.getParameters().getName().set("tide-archive");
            if (max > 0) {
                spec.getMaxParallelUsages().set(max);
            }
        });
        project.getTasks().register("archive", ArchiveTask.class, task -> task.getLabel().set(project.getPath()));
        if (project == project.getRootProject()) {
            getFlowScope().always(BuildSummary.class, spec ->
                    spec.getParameters().getStatus().set(getFlowProviders().getBuildWorkResult()
                            .map(result -> result.getFailure().isPresent() ? "FAILED" : "OK")));
        }
    }
}

The plugin reads the Gradle property archive.maxParallelUsages (default 1) and sets getMaxParallelUsages() on the registration when it is greater than zero. With a limit of one, the four archive tasks (one in the root project and one in each subproject) cannot overlap, although the configuration cache lets Gradle run them all at once, as a later section explains. Without a limit, they run together:

$ ./gradlew --configuration-cache archive
> Task :archive
[archive] opened tide-archive
[archive] : start at 0.0s
[archive] : end   at 1.5s
> Task :tides-a:archive
[archive] :tides-a start at 1.5s
[archive] :tides-a end   at 3.0s
> Task :tides-b:archive
[archive] :tides-b start at 3.0s
[archive] :tides-b end   at 4.5s
> Task :tides-c:archive
[archive] :tides-c start at 4.5s
[archive] :tides-c end   at 6.0s
[summary] build OK, archive writes: 4
[archive] closed after 4 writes
BUILD SUCCESSFUL in 6s
8 actionable tasks: 4 executed, 4 up-to-date
$ ./gradlew --configuration-cache -Parchive.maxParallelUsages=0 archive
Calculating task graph as configuration cache cannot be reused because Gradle property 'archive.maxParallelUsages' has changed.
> Task :tides-a:archive
[archive] :tides-a start at 0.0s
[archive] :tides-a end   at 1.5s
> Task :archive
[archive] opened tide-archive
[archive] : start at 0.0s
[archive] : end   at 1.5s
> Task :tides-c:archive
[archive] :tides-c start at 0.0s
[archive] :tides-c end   at 1.5s
> Task :tides-b:archive
[archive] :tides-b start at 0.0s
[archive] :tides-b end   at 1.5s
[summary] build OK, archive writes: 4
[archive] closed after 4 writes
BUILD SUCCESSFUL in 2s
8 actionable tasks: 4 executed, 4 up-to-date

The start and end times are counted from the creation of the service: with one usage at a time the four writes follow each other (0.0 to 6.0 seconds, six seconds for the build), without a limit the four run at the same time (1.5 seconds of work, two seconds for the build). Changing the Gradle property invalidated the configuration cache entry, which is why the second run recalculates the task graph. The limit applies when the consuming property is @ServiceReference, or @Internal with usesService. Gradle does not discover indirect use: a service used only by another service must be declared on the task as well, with another @ServiceReference property or usesService.

Using a service from a worker, a configuration action or a listener

A work action receives a service the same way as other inputs: a Property<ServiceType> in its parameters, set from the task’s own reference. The user manual states two limits: this works with noIsolation() only, because the service cannot be passed to a classloader-isolated or process-isolated worker, and the service should be used at configuration time (provider.get()) only when that is really needed, because services are usually expensive to create (not run here; both limits are quoted from the manual).

A service that also implements OperationCompletionListener can receive task-finish events when it is registered with the BuildEventsListenerRegistry service (onTaskCompletion(serviceProvider)), which is the replacement the configuration cache documentation gives for the gradle.taskGraph.afterTask callback (not run here; see the manual page).

Build services, tasks and the configuration cache

Build services are serialised into the configuration cache entry as their parameters, and the instance is created again in every build, so a service must not keep state that has to survive from one build to the next. Task classes must not reach into the project at execution time. This is a classic mistake in a task that wants to know where it belongs; the example below uses getProject() in @TaskAction:

    @TaskAction
    public void run() throws InterruptedException {
        getArchive().get().write(getProject().getPath());
    }
> Task :tides-a:archive FAILED
1 problem was found storing the configuration cache.
- Task `:tides-a:archive` of type `io.tidepool.build.ArchiveTask`: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
  See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:use_project_during_execution
...
* What went wrong:
Execution failed for task ':tides-a:archive' (registered by plugin 'io.tidepool.archive').
> Invocation of 'Task.project' by task ':tides-a:archive' at execution time is unsupported with the configuration cache.
...
BUILD FAILED in 1s
5 actionable tasks: 5 executed
Configuration cache entry discarded with 1 problem.

The fix is the one ArchiveTask uses: copy the value into an @Input property at configuration time (the plugin sets getLabel() from project.getPath()) and read the property in the action.

The configuration cache has a second effect on the example. With it enabled, Gradle runs the tasks of a build in parallel, even within one project, subject to the declared dependencies; the --parallel option no longer controls this, and --no-parallel or org.gradle.parallel=false has no effect on a build that uses the cache. The build services, not the order of tasks, then have to be what keeps tasks apart: that is why maxParallelUsages is useful. The user manual says builds that depend on sequential tasks should wire inputs and outputs, or use build services to limit concurrency (see Lazy Configuration & Providers for the properties involved).

Dataflow actions

Some work does not fit a task, such as reacting to the outcome of the whole build. A dataflow action is a small, isolated piece of work that Gradle runs as soon as all its input parameters are available. It replaces the gradle.buildFinished callback, which does not work with the configuration cache. Dataflow actions are incubating in 9.8.1 (see Versions, Compatibility & Release History for what incubating means) and the API may change.

You implement FlowAction<P>, where P extends FlowParameters; every parameter must be annotated, with @Input or with @ServiceReference for a build service. You do not create the action; you ask the FlowScope service to run it with always(actionClass, spec -> ...), in the scope of the build. A parameter that should wait for the build result is derived from the provider FlowProviders.getBuildWorkResult(), which makes Gradle run the action after the work is done. The example’s BuildSummary takes the result and the archive service, and prints only if the archive was used:

BuildSummary.java
package io.tidepool.build;

import org.gradle.api.flow.FlowAction;
import org.gradle.api.flow.FlowParameters;
import org.gradle.api.provider.Property;
import org.gradle.api.services.ServiceReference;
import org.gradle.api.tasks.Input;

/** A dataflow action: runs once the build result is known. */
public abstract class BuildSummary implements FlowAction<BuildSummary.Params> {
    public interface Params extends FlowParameters {
        @ServiceReference("archive")
        Property<ArchiveService> getArchive();

        @Input
        Property<String> getStatus();
    }

    @Override
    public void execute(Params parameters) {
        int writes = parameters.getArchive().get().getWrites();
        if (writes > 0) {
            System.out.println("[summary] build " + parameters.getStatus().get() + ", archive writes: " + writes);
        }
    }
}

The plugin shown earlier registers it in the root project, which is the line starting getFlowScope().always(. It produced the [summary] line at the end of the two archive runs above, after the last task, and [archive] closed after 4 writes follows it as Gradle closes the service. A failed build calls the action as well, with status FAILED. FlowScope and FlowProviders can be injected into project and settings plugins.

Which one to use

You need Use

split one task’s work into independent parts that run at the same time

the Worker API with noIsolation()

a different version of a library, or separate static state, per task

classLoaderIsolation()

a different JVM, heap or system properties, or protection from a crash or leak of a library

processIsolation() (Gradle itself uses worker processes for the compiler daemon and for code quality tools)

state or a resource shared between tasks, workers or other services

a build service

at most n tasks using a resource at once

a build service with maxParallelUsages

to react to task completion

a build service that implements OperationCompletionListener, registered with BuildEventsListenerRegistry

to react to the result of the build

a dataflow action with FlowProviders.getBuildWorkResult()

Gradle 8.14 and Gradle 9

The APIs have been stable for a long time; the notes below come from the release notes and the manual. Nothing here was run on 8.14.

Release Change

5.6 (2019)

WorkAction, WorkParameters and the WorkQueue returned by noIsolation(), classLoaderIsolation() and processIsolation() replace the Runnable-based WorkerExecutor.submit(), deprecated in 5.6 and removed in 8.0

6.0 (2019-11-08)

services such as ObjectFactory, ProviderFactory, ProjectLayout, FileSystemOperations and ExecOperations can be injected into work actions

7.0 (2021-04-09)

the Worker API leaves incubation

7.4 (2022-02-08)

shared build services are promoted to stable

8.12

service reference properties (@ServiceReference) are stable

8.14 (2025-04-25)

the last 8.x minor; the problems API can report from a worker task (not run here)

9.0.0 (2025-07-31)

the release notes do not mention these APIs; Gradle runs on JVM 17 or later, which is also the JVM of worker processes unless the fork options select another executable

9.8.1 (2026-10-07)

fixes an unauthenticated worker-to-daemon channel that deserialised untrusted Java objects, one of the three high-severity security fixes of that release; upgrade to it

The same idea in Ant and Maven

Concept Ant Maven Gradle

parallel work

the parallel task runs its nested tasks in threads; see Conditions & Control Flow

-T runs modules in parallel, and every plugin has to be thread safe (threadSafe on the Mojo); see Build Performance, Cache, Parallel & mvnd

--parallel across projects, the configuration cache across tasks, the Worker API inside a task action

shared state between tasks

properties and references in the project; no service lifecycle

plugin components and the session; see Writing Plugins

a build service, with parameters, a lifecycle and a concurrency limit

isolating work

the fork attribute of the Java tasks starts a new JVM per task

plugins run in the Maven JVM, in a class realm of their own

class loader or process isolation of each unit of work

Where the book is outdated

Gradle in Action (Gradle 1.7, 2014) has neither of these APIs. The Worker API arrived in 3.5 (WorkerExecutor is annotated "Since: 3.5" in the API docs), in its current WorkAction form in 5.6 and stable in 7.0; shared build services became stable in 7.4; dataflow actions are newer still. The book’s mechanisms for the same needs, gradle.buildFinished, gradle.taskGraph listeners and static fields, either have been removed or do not work with the configuration cache; the replacements are the build services and dataflow actions above. The book’s idea of parallel execution is the --parallel option across projects; running the tasks of one project in parallel, which the configuration cache does, did not exist.

Summary

  • The Worker API splits one task action into units that run concurrently: WorkParameters and WorkAction describe a unit, WorkerExecutor gives a WorkQueue in one of three isolation modes.

  • noIsolation() is a thread with a shared class loader, classLoaderIsolation() a thread with its own, processIsolation() a worker daemon; worker daemons live for one build and are reused only inside it.

  • The number of concurrent units is limited by --max-workers / org.gradle.workers.max.

  • A build service is a thread-safe, parameterised, lazily created object shared by a build; register it with registerIfAbsent and consume it with @ServiceReference or @Internal plus usesService.

  • maxParallelUsages limits how many tasks use a service at once, which matters because the configuration cache runs tasks in parallel.

  • Services are not available to class-loader or process-isolated workers.

  • A task must not call Task.project while it runs; copy the value into an @Input property.

  • Dataflow actions (incubating) replace buildFinished: derive the parameters from FlowProviders.getBuildWorkResult().