namalzi

← All posts

Digging into Gradle's Source 8: Tasks and Lazy Configuration — DefaultTask, TaskContainer and Provider

Gradle · Published

A single tasks.register line does not create a task. We follow when the TaskCreatingProvider that only holds a name and a configuration block becomes a real task, how configureEach reaches tasks that exist now and tasks that come later, which APIs like withType { } quietly create every task, how TaskFactory and DefaultTask hold the action list, why a Provider that carries "the task that makes this value" gives you ordering without dependsOn, and the three ways a Property gets locked — checked against the source with three runnable examples.

What this post covers

This is the eighth post in the "Digging into Gradle's Source" series. In part 7 we followed a plugin from the plugins {} block all the way to apply(), and at the end we saw plugins like JavaPlugin register a whole batch of tasks with tasks.register(…). This post follows how those registered tasks come into being, get configured, and get wired to each other.

Going back to the map in part 2, this post zooms into the third box of the "D · configuration phase" area on the big map: "task registration · Provider (lazy configuration)". Part 6 ran the build script and part 7 applied plugins; most of what those two do ends up as registering something in the task container and wiring values into Property objects. In part 5 we saw that the configuration block of a registered task printed after projectsEvaluated. Why that happens is this post's first topic.

We'll answer four questions:

Item Details
Target Gradle Build Tool
Version 9.10.0 development build (master)
Commit 07ecbf9ead (2026-10-24)
Main classes DefaultTaskContainer (+TaskCreatingProvider), DefaultNamedDomainObjectCollection, DefaultDomainObjectCollection, TaskFactory, AnnotationProcessingTaskFactory, AbstractTask/DefaultTask, AbstractMinimalProvider, TransformBackedProvider, AbstractProperty/DefaultProperty, ValueSupplier.ValueProducer, ValueState, DefaultTaskInputs

All line numbers in this post refer to this commit. How to check out the same commit and build the Docker image used for the examples is in part 1. I also use this blog's own build.gradle.kts as an example a couple of times; that build uses Gradle 9.5.1 through its wrapper, so it is a different version from the source we read here.

Domain object collections and the Provider API are among the largest chunks under platforms/core-configuration (ValueSupplier.java alone is over 900 lines). This post sticks to the main path. What I left out is listed at the end under "What this post leaves out", with names and locations only.

Terms to know first

Task container: the list of tasks that every project has exactly one of. The tasks in a build script is this object, and its implementation is DefaultTaskContainer. It is created as a project-scoped service (ProjectScopeServices; the service registry from part 4).

Domain object collection: the collection type Gradle uses for all the "named things users pile up". The task container, configurations, sourceSets and repositories all belong to this family, which runs DefaultDomainObjectCollection → DefaultNamedDomainObjectCollection → … → DefaultTaskContainer. So what we see about configureEach, named and withType applies not only to tasks but to the whole family.

Register and realize are the two key words of this post. Registering only hands over a promise: "there will be a task with this name and type, and when you create it, configure it with this block". Realizing takes that promise out and actually creates the task object and runs the stored blocks. The build operation names in the source (a build operation is a named record of "what Gradle did, from when to when"; see part 5, section 8) are literally Register task :x and Realize task :x.

Lazy configuration is the umbrella term for this "don't create or compute anything until it's needed" approach. The user manual calls the task side "configuration avoidance" (the page the TaskContainer Javadoc links to is named task_configuration_avoidance).

Provider and Property are objects that hold "a way to get a value" instead of the value itself. A Provider<T> is read-only; a Property<T> can be given a value (or another Provider). The value is computed only when you call get().

Producer: the task that produces a value. In the source it is a small object, ValueSupplier.ValueProducer, and every Provider can report "my value is made by this task". It is the star of section 6.

1. The big picture: register does not create a task

Let's start with the most common line in any build script. In the Kotlin DSL, tasks.register("lazyA") { … } is TaskContainer.register(String, Action), and it ends up in this method of DefaultTaskContainer:

    private <T extends Task> TaskProvider<T> registerTask(final String name, final Class<T> type, @Nullable final Action<? super T> configurationAction, final Object... constructorArgs) {
        if (hasWithName(name)) {
            failOnDuplicateTask(name);
        }

        final TaskIdentity<T> identity = taskIdentityFactory.create(name, type, project, getUserCodeSource());

        TaskProvider<T> provider = buildOperationRunner.call(new CallableBuildOperation<TaskProvider<T>>() {
            // ... description(): "Register task :<name>" ...
            @Override
            public TaskProvider<T> call(BuildOperationContext context) {
                TaskProvider<T> provider = Cast.uncheckedNonnullCast(
                    getInstantiator().newInstance(
                        TaskCreatingProvider.class, DefaultTaskContainer.this, identity, configurationAction, constructorArgs
                    )
                );
                addLaterInternal(provider);
                context.setResult(REGISTER_RESULT);
                return provider;
            }
        });
        // ...
        return provider;
    }

subprojects/core/src/main/java/org/gradle/api/internal/tasks/DefaultTaskContainer.java lines 440–471

The method does exactly four things:

  1. If the name already exists, it fails (hasWithName() checks both created and pending ones).
  2. It builds a TaskIdentity that bundles name, type and project.
  3. It creates one TaskCreatingProvider. What goes into its constructor is the identity and the configuration block (configurationAction). Not a task object.
  4. It puts that provider on the collection's pending list with addLaterInternal() and returns it.

No Task object is created anywhere. The configuration block isn't called either; it is just stored inside the provider. All of this is wrapped in a build operation called Register task :lazyA. The elided lines 466–468 call get() right away if eagerlyCreateLazyTasks is set; that flag is an internal switch, turned on by the system property org.gradle.internal.tasks.eager (line 79), that makes registration create tasks immediately.

The order of register and realization. ① The build script calls register("lazyA") { … } on DefaultTaskContainer. ② The container creates a TaskCreatingProvider and hands it the configuration block, ③ puts it on the pending list with addLater, and ④ returns the TaskProvider. Up to here no task object exists, and the build operation is Register task. Later, ⑤ task selection (TaskNameResolver) calls getByName("lazyA"), ⑥ the container calls the provider's getOrNull(), ⑦ the provider calls the task factory's create() from tryCreate() and ⑧ gets back a _Decorated task object. ⑨ doAdd() runs the stored actions in order — the configureEach block, the register block, the named block. The build operation is Realize task. The top half (①–④) happens while the build script runs in the configuration phase; the bottom half (⑤–⑨) happens when someone first needs that task.

Example 1: when does the configuration block run?

I wrote a build script that prints a line from every block.

// This example was written for this post (it is not Gradle's source)
// Print from every block to see *when* a task's configuration block runs.

println("[script] start")

// Lazy configuration that applies to tasks that exist now and tasks created later
tasks.configureEach {
    println("[configureEach] $name")
}

// register: only hand over a name and a configuration block
val lazyA = tasks.register("lazyA") {
    println("[register block] lazyA")
    // ... with -Pstack, print the call stack (section 2) ...
    doLast { println("[action] lazyA") }
}
println("[script] register returned ${lazyA.javaClass.simpleName}")

tasks.register("lazyB") {
    println("[register block] lazyB")
}
// Adding configuration with named still does not create the task
tasks.named("lazyB") {
    println("[named block] lazyB")
}

// create: creates and configures on the spot (@Deprecated at this commit)
@Suppress("DEPRECATION")
tasks.create("eager") {
    println("[create block] eager")
}

// ... with -PeagerAll, add withType<DefaultTask> { } (section 3) ...

println("[script] end")

[Shortened from the example ex/u08-register-vs-create/project/build.gradle.kts]

This example was run with the 9.10.0 development build of Gradle, built straight from the base commit (07ecbf9ead), in a Docker container (Eclipse Temurin JDK 25). run.sh runs gradle help --offline --console=plain and keeps only lines starting with [ (the other outputs in this post are also filtered by run.sh with grep).

=== gradle help ===
[script] start
[script] register returned DefaultTaskContainer$TaskCreatingProvider_Decorated
[configureEach] eager
[create block] eager
[script] end
[configureEach] help
BUILD SUCCESSFUL in 20s

There are four things to read here. (Timings differ on every run, in this output and in every other output in this post. Every container starts a fresh daemon and compiles the script from scratch, so the first run took anywhere from 20 seconds to about a minute and a half across my runs.)

Running the same build again with the build operation trace (-Dorg.gradle.internal.operations.trace=/tmp/trace, see part 1) and keeping only Register task and Realize task gives this:

=== build operations of gradle help: Register / Realize ===
"displayName":"Register task :help"
"displayName":"Register task :projects"
"displayName":"Register task :tasks"
...
"displayName":"Register task :kotlinDslAccessorsReport"
"displayName":"Register task :lazyA"
"displayName":"Register task :lazyB"
"displayName":"Realize task :eager"
"displayName":"Realize task :help"

(There are eighteen Register task lines; I cut the twelve in the middle.) Even though this build applies no plugins, Gradle registers sixteen tasks on its own (help, tasks, wrapper, …). Add my two, and we get 18 registered, 2 realized. eager was made with create, so it goes straight to Realize without a Register; the other seventeen stayed registered-only until the build ended.

How is create different?

The body of create (doCreate()) does this inside a Realize task build operation:

                    T task = createTask(identity, constructorArgs);
                    statistics.eagerTask(identity.getTaskType());
                    addTask(task, behavior);
                    configureAction.execute(task);

subprojects/core/src/main/java/org/gradle/api/internal/tasks/DefaultTaskContainer.java lines 336–339

It creates the task on the spot (createTask), adds it to the collection (addTask — this is when container-level actions such as configureEach run), and then calls the configuration block. That order is why [configureEach] eager printed before [create block] eager in the example.

Easy to get wrong

2. When does realization happen?

A stored provider turns into a real task when someone computes the provider's value. That code lives in TaskCreatingProvider's parent, AbstractDomainObjectCreatingProvider (shared by all domain object collections).

        @Override
        protected Value<? extends I> calculateOwnValue(ValueConsumer consumer) {
            // ...
            if (failure != null) {
                throw failure;
            }
            if (object == null) {
                object = getType().cast(findByNameWithoutRules(getName()));
                if (object == null) {
                    tryCreate();
                }
            }
            return Value.of(object);
        }

        protected void tryCreate() {
            try {
                // Collect any container level add actions added since the last call to configure()
                onCreate = onCreate.mergeFrom(getEventRegister().getAddActions());
                // ...
                    object = createDomainObject();
                    // ...
                        doAdd(object, onCreate);
                        // ...
            } catch (Throwable ex) {
                failure = domainObjectCreationException(ex);
                throw failure;
            } finally {
                // Discard state that is no longer required
                onCreate = ImmutableActionSet.empty();
            }
        }

platforms/core-configuration/domain-object-collections/src/main/java/org/gradle/api/internal/DefaultNamedDomainObjectCollection.java lines 981–1024

TaskCreatingProvider wraps this tryCreate() once more in a Realize task :x build operation (lines 737–756).

Who asks for the value? Example 1 with gradle lazyA

I ran the same example as gradle lazyA -Pstack. With -Pstack, lazyA's configuration block prints the Gradle frames that called it.

    if (providers.gradleProperty("stack").isPresent) {
        // Who calls this block? Print only Gradle's frames
        Throwable().stackTrace
            .filter { it.className.startsWith("org.gradle.") }
            .forEach { println("    at ${it.className}.${it.methodName}") }
    }

[From the example ex/u08-register-vs-create/project/build.gradle.kts]

=== gradle lazyA -Pstack ===
...
[script] end
[configureEach] lazyA
[register block] lazyA
    ...
    at org.gradle.api.internal.DefaultDomainObjectCollection.doAdd
    at org.gradle.api.internal.DefaultNamedDomainObjectCollection.doAdd
    at org.gradle.api.internal.DefaultNamedDomainObjectCollection$AbstractDomainObjectCreatingProvider.tryCreate
    ...
    at org.gradle.api.internal.DefaultNamedDomainObjectCollection$AbstractDomainObjectCreatingProvider.calculateOwnValue
    at org.gradle.api.internal.provider.AbstractMinimalProvider.getOrNull
    at org.gradle.api.internal.DefaultNamedDomainObjectCollection.findByName
    ...
    at org.gradle.api.internal.DefaultNamedDomainObjectCollection.getByName
    at org.gradle.api.internal.tasks.DefaultTaskCollection.getByName
    at org.gradle.execution.TaskNameResolver.getExistingTask
    ...
    at org.gradle.execution.DefaultTaskSelector.getSelection
    ...
    at org.gradle.execution.commandline.CommandLineTaskParser.parseTasks
    ...
    at org.gradle.initialization.DefaultTaskExecutionPreparer.scheduleRequestedTasks
    ...
    at org.gradle.internal.build.DefaultBuildLifecycleController$DefaultWorkGraphBuilder.addRequestedTasks
    ...
> Task :lazyA
[action] lazyA
BUILD SUCCESSFUL in 1s

There were 191 org.gradle. frames; above I kept only the lines relevant to this post and cut the rest as ... (the first five lines are the same as in the gradle help run, so I dropped them too). The stack reads innermost-first. Reading from the bottom up:

  1. In the lifecycle step from part 5 that "adds the requested tasks to the work graph" (addRequestedTasks → scheduleRequestedTasks),
  2. CommandLineTaskParser → DefaultTaskSelector → TaskNameResolver, which resolve the name lazyA from the command line,
  3. called tasks.getByName("lazyA"); findByName() found the provider on the pending list and asked for its value with getOrNull(),
  4. so calculateOwnValue() → tryCreate() → doAdd() ran the stored actions, and my block was called.

findByName() looks like this:

    @Override
    @Nullable
    public T findByName(String name) {
        T value = findByNameWithoutRules(name);
        if (value != null) {
            return value;
        }
        ProviderInternal<? extends T> provider = index.getPending(name);
        if (provider != null) {
            // TODO - this isn't correct, assumes that a side effect is to add the element
            provider.getOrNull();
            // Use the index here so we can apply any filters to the realized element
            return index.get(name);
        }
        // ... if not found, try the rules (addRule) ...

platforms/core-configuration/domain-object-collections/src/main/java/org/gradle/api/internal/DefaultNamedDomainObjectCollection.java lines 314–332

If the name isn't among the created objects, it looks at the pending list (index.getPending); if it is there, it calls getOrNull() to trigger the side effect of creation and then looks again. The source comment says as much: it "assumes that a side effect is to add the element". So findByName and getByName are lookup methods and realization methods at the same time.

To save realizations, TaskNameResolver first only checks whether the name is in the name list (getNames().contains(taskName), TaskNameResolver.java lines 110–112). The name list includes the names of pending providers, so it can answer without creating anything. Realization happens only for the selected task, through getByName() (lines 114–120). Following the selected task's dependencies and realizing those is the work graph's job; we'll see it in part 9.

What triggers realization and what doesn't. For a registered-only task (TaskCreatingProvider, pending list), configureEach { }, named(name) { }, and building a new Provider with map/flatMap only store actions and do not create it. get()/getOrNull()/findByName/getByName, a task name on the command line (TaskNameResolver), following dependencies (TaskProvider.getProducer()), iterator/forEach/for loops (realizePending()), and all { }/whenObjectAdded/withType(T) { } create it. The last two groups are red. Once created, the task object exists, the configuration block runs, and a Realize task build operation is recorded. The red boxes create many tasks at once "behind your back". We'll see them in section 3.

Example 1-2: counting in a build with the java plugin

My two tasks don't make the point very vividly, so I counted in a build containing nothing but plugins { java }. Gradle has an internal switch for exactly this. Pass the system property org.gradle.internal.tasks.stats and TaskStatistics prints registration and realization counts at the end of the build (TaskStatistics.java line 43; it prints in lines 116–127). The counting happens in statistics.lazyTask() in the TaskCreatingProvider constructor and in onLazyDomainObjectRealized() on realization.

=== java project: gradle help with task statistics ===
BUILD SUCCESSFUL in 3s
Task counts: Old API 0, New API 31, total 31
Task counts: created 2, avoided 29, %-lazy 94
Task types that were registered with the new API but were created anyways
class org.gradle.api.tasks.Delete 1
class org.gradle.configuration.Help 1
=== java project: gradle tasks --all with task statistics ===
BUILD SUCCESSFUL in 2s
Task counts: Old API 0, New API 31, total 31
Task counts: created 31, avoided 0, %-lazy 0
Task types that were registered with the new API but were created anyways
class org.gradle.api.DefaultTask 8
class org.gradle.language.jvm.tasks.ProcessResources 2
class org.gradle.api.tasks.compile.JavaCompile 2
...

(The second list has twenty-two lines; I kept only the first three. The order of lines with equal counts can change between runs.)

But I only ran help, and one task of type Delete (clean) got created. If you give the same switch a file path (-Dorg.gradle.internal.tasks.stats=/tmp/stats.txt), it writes a stack trace to that file for every realization (TaskStatistics.java lines 107–109), so you can find the culprit. The core of the shortened stack:

	...
	at org.gradle.api.internal.DefaultNamedDomainObjectCollection$AbstractDomainObjectCreatingProvider.get(DefaultNamedDomainObjectCollection.java:978)
	at org.gradle.api.internal.tasks.DefaultTaskContainer$TaskCreatingProvider.getProducer(DefaultTaskContainer.java:734)
	at org.gradle.api.internal.provider.TransformBackedProvider.beforeRead(TransformBackedProvider.java:94)
	at org.gradle.api.internal.provider.TransformBackedProvider.calculateOwnValue(TransformBackedProvider.java:79)
	...
	at org.gradle.internal.cleanup.DefaultBuildOutputCleanupRegistry.resolveOutputs(DefaultBuildOutputCleanupRegistry.java:79)
	at org.gradle.internal.build.DefaultBuildWorkPreparer.finalizeWorkGraph(DefaultBuildWorkPreparer.java:66)

While finalizing the work graph, Gradle resolves its "build output cleanup list" (DefaultBuildOutputCleanupRegistry); a TransformBackedProvider (a Provider made with map, section 5) calls TaskCreatingProvider.getProducer(), which creates clean with get(). The one who set up that map is Gradle's own LifecycleBasePlugin:

        final Provider<Delete> clean = project.getTasks().register(CLEAN_TASK_NAME, Delete.class, cleanTask -> {
            cleanTask.setDescription("Deletes the build directory.");
            cleanTask.setGroup(BUILD_GROUP);
            cleanTask.delete(buildDir);
        });
        buildOutputCleanupRegistry.registerOutputs(clean.map(cl -> cl.getTargetFiles()));

platforms/software/platform-base/src/main/java/org/gradle/language/base/plugins/LifecycleBasePlugin.java lines 89–94

The root cause is how TaskCreatingProvider reports its producer:

        @Override
        public ValueProducer getProducer() {
            return ValueProducer.taskState(get());
        }

subprojects/core/src/main/java/org/gradle/api/internal/tasks/DefaultTaskContainer.java lines 732–735

To answer "who makes this value?", it calls get() on the task. A Provider made with map checks its producer before reading the value (the beforeRead() in section 5), so reading a TaskProvider.map {…} ends up realizing that task. That's why the diagram puts "following dependencies" on the realization side.

Easy to get wrong

3. configureEach — what exists now and what comes later

tasks.configureEach { … } attaches a block to "every task that exists now and every task that will ever exist". And it realizes nothing while doing so. Let's see how.

    @Override
    public void configureEach(Action<? super T> action) {
        assertEagerContext("configureEach(Action)");
        Action<? super T> wrappedAction = wrapLazyAction(decorate(action));
        Action<? super T> registerLazyAddActionDecorated = eventRegister.registerLazyAddAction(wrappedAction);

        // ...
        Iterator<T> iterator = iteratorNoFlush();
        // ... collect the already-created elements into copied ...
        if (copied != null) {
            for (T next : copied) {
                registerLazyAddActionDecorated.execute(next);
            }
        }
    }

platforms/core-configuration/domain-object-collections/src/main/java/org/gradle/api/internal/DefaultDomainObjectCollection.java lines 194–220

There are two branches:

  1. What comes later: eventRegister.registerLazyAddAction() puts the block on the collection's list of "things to do when an element is added" (addActions). When section 2's tryCreate() creates a task, it merges this list in with onCreate.mergeFrom(getEventRegister().getAddActions()) and runs it, so a pending task receives the block at the moment it is created.
  2. What exists now: iteratorNoFlush() walks only the elements already created and runs the block on them now. The "NoFlush" in the name is the key: it's an iterator that does not flush (that is, realize) the pending list. The elided comment (lines 206–209) even says that sizing the copy with estimatedSize() would realize pending elements and make configureEach eager, so it isn't used.

Blocks handed over at registration go into the same onCreate. Here are the provider constructor and configure():

        public AbstractDomainObjectCreatingProvider(String name, Class<I> type, @Nullable Action<? super I> configureAction) {
            super(name, type);
            this.onCreate = ImmutableActionSet.<I>empty().mergeFrom(getEventRegister().getAddActions());

            if (configureAction != null) {
                configure(configureAction);
            }
        }
        // ...
        @Override
        public void configure(final Action<? super I> action) {
            // ...
            if (object != null) {
                // Already realized, just run the action now
                decoratedAction.execute(object);
                return;
            }
            // Collect any container level add actions then add the object specific action
            onCreate = onCreate.mergeFrom(getEventRegister().getAddActions()).add(decoratedAction);
        }

platforms/core-configuration/domain-object-collections/src/main/java/org/gradle/api/internal/DefaultNamedDomainObjectCollection.java lines 939–971

When the provider is created, it first takes the container actions registered so far (configureEach blocks), then appends the register block. A block added later with named(…) { } is appended after that. If the object has already been created (object != null), the block isn't stored but run immediately. In example 1, configureEach was set up before register, and the output came out as [configureEach] lazyA → [register block] lazyA — this order.

The difference between configureEach and all. Left: configureEach(action) adds the block to eventRegister.addActions (registerLazyAddAction); tasks already created are walked with iteratorNoFlush() and run now, and pending tasks get it when created, as tryCreate() merges addActions into onCreate and runs them. Right (red): all(action) and withType(T) { } create every pending one right now with store.realizePending(type), subscribe to that type with registerEagerAddAction(), and later register calls of that type are created at once in addPending(). Green boxes postpone realization; red boxes pull it forward. Both end up "attaching a block to every task".

APIs that break laziness: all and withType with a block

all { } looks like it does the same as configureEach, but it works the opposite way.

    @Override
    public <S extends T> DomainObjectCollection<S> withType(Class<S> type, Action<? super S> configureAction) {
        assertEagerContext("withType(Class, Action)");
        DomainObjectCollection<S> result = withType(type);
        result.all(configureAction);
        return result;
    }

platforms/core-configuration/domain-object-collections/src/main/java/org/gradle/api/internal/DefaultDomainObjectCollection.java lines 231–237

    private Action<? super T> addEagerAction(Action<? super T> action) {
        store.realizePending(type);
        return eventRegister.registerEagerAddAction(type, decorate(action));
    }

platforms/core-configuration/domain-object-collections/src/main/java/org/gradle/api/internal/DefaultDomainObjectCollection.java lines 255–258

all() (lines 168–192) and whenObjectAdded() (lines 245–248) call this addEagerAction(). Its first line, store.realizePending(type), creates every pending element of that type right now. Then registerEagerAddAction() marks that type as "subscribed", and a subscribed type gets created immediately, the moment a later register puts it on the pending list (the isSubscribed check in SortedSetElementSource.addPending(), lines 175–183). Once you use all, even the register calls after it lose their laziness.

The trap here is the Kotlin DSL's tasks.withType<Test> { … }. This extension function, the one that takes a block, calls withType(S::class.java, configuration) — the withType(Class, Action) above (DomainObjectCollectionExtensions.kt lines 34–35). withType with a block is all. To stay lazy, filter with withType() and attach configureEach to the result: tasks.withType<Test>().configureEach { … }.

This blog's own build.gradle.kts has exactly that shape:

tasks.withType<Test> {
    useJUnitPlatform()
}

[Lines 34–36 of this blog repository's build.gradle.kts. This is the build that produces the blog, not Gradle's source]

In the portal's Gradle (9.5.1) too, withType(Class, Action) calls all() and addEagerAction() calls realizePending() (in 9.5.1 this file lived under subprojects/core; at the base commit it has moved to platforms/core-configuration/domain-object-collections). This build has only one Test task, test, and running ./gradlew test creates it anyway, so the cost is tiny — but it does mean test gets created even when you run other tasks such as preview.

Example 1-3: adding withType { } with -PeagerAll

Passing -PeagerAll to example 1 turns on this block:

// With -PeagerAll: withType with a block is all(), so it also creates registered-only tasks
if (providers.gradleProperty("eagerAll").isPresent) {
    tasks.withType<DefaultTask> {
        println("[withType block] $name")
    }
}

[From the example ex/u08-register-vs-create/project/build.gradle.kts]

=== gradle help -PeagerAll ===
...
[create block] eager
[configureEach] help
[configureEach] projects
...
[configureEach] lazyA
[register block] lazyA
[configureEach] lazyB
[register block] lazyB
[named block] lazyB
[withType block] artifactTransforms
...
[withType block] lazyA
[withType block] lazyB
...
[withType block] wrapper
[script] end
BUILD SUCCESSFUL in 2s

(I dropped the first three lines, which are the same as in the gradle help run, and cut the middle of the [configureEach] lines and the [withType block] lines, which are eighteen and nineteen lines long.)

Only help is being run, yet all eighteen were created before [script] end, at that line of the script. Even lazyB's named block ran. realizePending() created them in pending order (help … lazyB), then all() walked the collection and ran the block; the task container is a name-sorted set (SortedSetElementSource), so the [withType block] lines came out alphabetically.

Easy to get wrong

4. Who creates the task object? The task factory and DefaultTask

Section 2's createDomainObject() goes through DefaultTaskContainer.createTask() to the task factory. There are two task factories stacked on each other. A build-scoped service builds them as new AnnotationProcessingTaskFactory(instantiator, taskClassInfoStore, new TaskFactory()) (BuildScopeServices.java lines 489–496), and each project derives its own with createChild(). The outer AnnotationProcessingTaskFactory calls the inner TaskFactory.

The inner TaskFactory.create() actually creates the object:

        final Class<? extends DefaultTask> implType;
        if (identity.getTaskType() == Task.class) {
            implType = DefaultTask.class;
        } else if (DefaultTask.class.isAssignableFrom(identity.getTaskType())) {
            implType = identity.getTaskType().asSubclass(DefaultTask.class);
        } else if (identity.getTaskType() == org.gradle.api.internal.AbstractTask.class || identity.getTaskType() == TaskInternal.class) {
            // ...
        } else {
            throw new InvalidUserDataException(String.format(
                "Cannot create task '%s' of type '%s' as directly extending AbstractTask is not supported.",
                // ...
        }
        // ...
        return org.gradle.api.internal.AbstractTask.injectIntoNewInstance(project, identity, new Callable<S>() {
            // ...
                        instance = instantiationScheme.instantiator().newInstanceWithDisplayName(implType, displayName, constructorArgs);

subprojects/core/src/main/java/org/gradle/api/internal/project/taskfactory/TaskFactory.java lines 63–88

The outer AnnotationProcessingTaskFactory adds what it reads from the class to the freshly created task:

    private <S extends Task> S process(S task) {
        TaskClassInfo taskClassInfo = taskClassInfoStore.getTaskClassInfo(task.getClass());

        for (TaskActionFactory actionFactory : taskClassInfo.getTaskActionFactories()) {
            ((TaskInternal) task).prependParallelSafeAction(actionFactory.create(instantiator));
        }

subprojects/core/src/main/java/org/gradle/api/internal/project/taskfactory/AnnotationProcessingTaskFactory.java lines 50–55

Methods annotated with @TaskAction enter the action list at this point — right after the task is created and before any configuration block runs. Then lines 57–62 turn on caching for @CacheableTask and turn off state tracking if the class asks for that (part 11).

Task class structure. The interface TaskInternal (getTaskActions, prependParallelSafeAction) extends the interface Task (doFirst, doLast), and AbstractTask, marked deprecated, implements TaskInternal. AbstractTask holds the actions list, taskInputs (DefaultTaskInputs) and taskOutputs (DefaultTaskOutputs). The abstract DefaultTask extends AbstractTask, the user's abstract Gen (getOutputFile, @TaskAction run) extends DefaultTask, and Gen_Decorated, generated by Gradle, extends Gen. Gen_Decorated's getOutputFile() creates the property object on first call. AbstractTask points to TaskMutator, which blocks changes once the task is running, and to DefaultTaskInputs, which holds input declarations. The only thing I write is Gen; the classes above it and Gen_Decorated below it are all filled in by Gradle.

The action list — doFirst at the front, doLast at the back

    public Task doFirst(final String actionName, final Action<? super Task> action) {
        // ...
        taskMutator.mutate("Task.doFirst(Action)", new Runnable() {
            @Override
            public void run() {
                getTaskActions().add(0, wrap(action, actionName));
            }
        });
        // ...
    public Task doLast(final String actionName, final Action<? super Task> action) {
        // ...
        taskMutator.mutate("Task.doLast(Action)", new Runnable() {
            @Override
            public void run() {
                getTaskActions().add(wrap(action, actionName));
            }
        });

subprojects/core/src/main/java/org/gradle/api/internal/AbstractTask.java lines 540–570

A task's actions are a single ArrayList (actions, line 119). doFirst inserts at index 0 and doLast appends at the end. @TaskAction is already in the list from creation time, so if the configuration block adds one doFirst and one doLast, the order becomes doFirst → @TaskAction → doLast. If you call doFirst several times, the last one added runs first. The show task in section 6's example adds one doFirst and one doLast in its configuration block and prints actions.size — and gets 3, not 2. The extra one is the @TaskAction that was already there.

Both methods modify the list inside taskMutator.mutate(…). TaskMutator.mutate() is a short method that throws IllegalStateException unless the task state is isConfigurable(), and otherwise runs the given work (TaskMutator.java lines 34–39). isConfigurable() means "not executing yet and not finished executing" (TaskStateInternal). So once a task has started executing, you can no longer change its action list or its input/output declarations (DefaultTaskInputs and DefaultTaskOutputs use the same taskMutator). We'll see the actual message in section 7's example.

5. Provider and Property — "how to get the value" instead of the value

The Provider API applies the same idea as "create tasks late" to values. Let's start with the structure.

The Provider hierarchy. The internal interface ProviderInternal extends both the public interface Provider (get, map) and ValueSupplier (getProducer → ValueProducer). The abstract class AbstractMinimalProvider implements ProviderInternal; its map() returns a TransformBackedProvider and calculateOwnValue() is filled in by subclasses. TransformBackedProvider (holding the source provider and the transformer) and the abstract AbstractProperty (state ValueState, value supplier, producer owning task) extend AbstractMinimalProvider, and DefaultProperty (set(T), set(Provider)) extends AbstractProperty. ValueSupplier points to ValueProducer (visitProducerTasks), the side that makes the value. The left trunk is "how do we get the value"; ValueProducer on the right is "who makes that value". Section 6 looks at the right side.

map postpones the computation

    @Override
    public <S> ProviderInternal<S> map(final Transformer<? extends @Nullable S, ? super T> transformer) {
        requireNonNull(transformer, "Cannot map a provider using a null transformer.");
        // Could do a better job of inferring the type
        return new TransformBackedProvider<>(null, this, transformer);
    }

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/AbstractMinimalProvider.java lines 40–45

map computes nothing; it just returns a TransformBackedProvider that holds "the source Provider and the transform function". The computation happens when someone asks that Provider for its value:

    @Override
    protected Value<? extends OUT> calculateOwnValue(ValueConsumer consumer) {
        // ...
            beforeRead(context);
            Value<? extends IN> value = provider.calculateValue(consumer);
            return mapValue(context, value);
        // ...
    protected void beforeRead(EvaluationScopeContext context) {
        provider.getProducer().visitContentProducerTasks(producer -> {
            if (!producer.getState().getExecuted()) {
                throw new ProducerNotCompletedException("mapped", provider, producer);
            }
        });
    }

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/TransformBackedProvider.java lines 76–99

Only when asked does it compute the source value (provider.calculateValue) and transform it. We'll come back to the beforeRead() in front of it in section 6. It was this very beforeRead()'s provider.getProducer() that created clean in section 2.

A Property is a Provider you can "put a value into". What objects.property<String>() creates is a DefaultProperty (DefaultPropertyFactory.property(), lines 50–68), and set(T), which takes a fixed value, wraps it with Providers.fixedValue(…) and passes it to setSupplier() (DefaultProperty.java lines 81–88). The overload that takes a Provider looks like this:

    @Override
    public void set(Provider<? extends T> provider) {
        requireNonNull(provider, "Cannot set the value of a property using a null provider.");
        ProviderInternal<? extends T> p = Providers.internal(provider);
        setSupplier(p.asSupplier(getValidationDisplayName(), type, sanitizer));
    }

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/DefaultProperty.java lines 115–120

Even a fixed value goes in wrapped as a Provider. What a Property holds is always "a supplier that hands out the value" (the value field, whose type parameter is S extends ValueSupplier). Put another Provider in and it holds that Provider as is; the value is computed by following that chain of suppliers at get() time.

Who fills in a task's abstract val …: Property<String>?

The example tasks declare properties only as abstract getters, like @get:Input abstract val who: Property<String>. These getters are implemented by the _Decorated subclass that Gradle generates. In part 4 we saw getters that inject services; getters for properties are generated like this:

        public void applyReadOnlyManagedStateToGetter(PropertyMetadata property, Method getter, boolean applyRole) {
            // GENERATE public <type> <getter>() {
            //     if (<field> == null) {
            //         <field> = getFactory().newInstance(this, <display-name>, <type>, <prop-name>);
            //     }
            //     return <field>;
            // }

platforms/core-configuration/model-core/src/main/java/org/gradle/internal/instantiation/generator/AsmBackedClassGenerator.java lines 1176–1182

On the first call it creates the property object with ManagedObjectFactory.newInstance(), stores it in a field, and attaches an owner label. When I printed it in example 3, the real class of who was org.gradle.api.internal.provider.DefaultProperty and its toString() was task ':greet' property 'who'. Error messages use this label (section 7). Lines 1221–1224 call applyRole() when needed; that's where section 6 attaches the producer to output properties.

Example 3 (part): map sees the later value

// 3) map postpones the computation — change the source later and the result changes too
val name = objects.property<String>()
val greeting = name.map { "hello, $it" }
name.set("first")
name.set("second")
println("[map] ${greeting.get()}")

[From the example ex/u08-property-finalize/project/build.gradle.kts]

[map] hello, second

name was still empty when map was called, and that was no problem; the result was computed from the last value put in. This is the Provider API's promise that "you don't have to worry about configuration order". A plugin can wire up a value first, the user can change it later in the build script, and whoever reads it later sees the last value.

6. Why ordering appears without dependsOn — ValueProducer

Now for the last big question of this post. I wired a task gen that writes a file to a task show that reads it, like this.

Example 2: wiring an output Provider into an input

// This example was written for this post (it is not Gradle's source)
// Wire a producer task's output Provider into a consumer task's input, and ordering appears without dependsOn.

import org.gradle.api.internal.provider.ProviderInternal

// A task that writes a file
abstract class Gen : DefaultTask() {
    @get:Input
    abstract val text: Property<String>

    @get:OutputFile
    abstract val outputFile: RegularFileProperty
    // ... @TaskAction: writes text to outputFile and prints "[gen @TaskAction] wrote …" ...
}

// A task that reads that file
abstract class Show : DefaultTask() {
    @get:InputFile
    abstract val inputFile: RegularFileProperty
    // ... @TaskAction: reads inputFile and prints "[show @TaskAction] read: …" ...
}

val gen = tasks.register<Gen>("gen") {
    text.set("hello from gen")
    outputFile.set(layout.buildDirectory.file("gen/message.txt"))
}

tasks.register<Show>("show") {
    // No dependsOn(gen) — we only wire the output Provider into the input
    inputFile.set(gen.flatMap { it.outputFile })
    doFirst { println("[show doFirst]") }
    doLast { println("[show doLast]") }

    // Peek with an internal API: who makes this input value?
    val producer = (inputFile as ProviderInternal<*>).producer
    println("[configure show] producer of inputFile = ${producer.javaClass.simpleName}")
    producer.visitProducerTasks { println("[configure show] producer task = $path") }
    println("[configure show] actions = ${actions.size}")
}
// ... the -PreadEarly block (below), printing the graph with taskGraph.whenReady ...

[Shortened from the example ex/u08-provider-wiring/project/build.gradle.kts]

ProviderInternal is an internal interface, not public API. I only used it to see with my own eyes what I had read in the source; it's not something to use in a real build script. Here are the results of gradle show --dry-run (-m, which selects tasks without running them) and gradle show:

=== gradle show --dry-run ===
[configure show] producer of inputFile = TaskProducer
[configure show] producer task = :gen
[configure show] actions = 3
[graph] :gen -> :show
:gen SKIPPED
:show SKIPPED
BUILD SUCCESSFUL in 32s
=== gradle show ===
[configure show] producer of inputFile = TaskProducer
[configure show] producer task = :gen
[configure show] actions = 3
[graph] :gen -> :show
> Task :gen
[gen @TaskAction] wrote message.txt
> Task :show
[show doFirst]
[show @TaskAction] read: hello from gen
[show doLast]
BUILD SUCCESSFUL in 3s

I only asked for show and there is no dependsOn, yet :gen entered the graph ahead of :show. Asked who produces its value, show's input inputFile answers TaskProducer, and that task is :gen. The execution order is also as section 4 said: doFirst → @TaskAction → doLast.

Where does the producer get attached?

It starts with gen's @OutputFile property. When the generated getter from section 5 creates the property object, it calls applyRole(), and for a property carrying an output annotation (@OutputFile, @OutputDirectory, …) this handler is called:

    @Override
    public void applyRoleTo(ModelObject owner, Object target) {
        if (target instanceof PropertyInternal) {
            ((PropertyInternal<?>) target).attachProducer(owner);
        }
    }

subprojects/core/src/main/java/org/gradle/api/internal/tasks/properties/annotations/OutputPropertyRoleAnnotationHandler.java lines 44–49

owner is the task that owns the property (gen). From now on, gen.outputFile knows "I am gen's output". AbstractProperty.getProducer() uses that:

    @Override
    public ValueProducer getProducer() {
        Task task = getProducerTask();
        if (task != null) {
            return ValueProducer.task(task);
        } else {
            try (EvaluationScopeContext context = openScope()) {
                return getSupplier(context).getProducer();
            }
        }
    }

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/AbstractProperty.java lines 231–241

ValueProducer.task() and taskState() look like this:

        static ValueProducer task(Task task) {
            return new TaskProducer(task, true);
        }
        // ...
        static ValueProducer taskState(Task task) {
            return new TaskProducer(task, false);
        }

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/ValueSupplier.java lines 105–114

The only difference is one content flag. Per their Javadoc, task() means "the value and its contents are produced by the task" (an output file — the path is known at configuration time, but the contents only exist after the task runs), and taskState() means "the value comes from the task's properties and only carries a dependency on the task" (the TaskProvider itself, from section 2). This flag decides the error in the -PreadEarly example below.

Who asks for dependencies?

What show depends on is asked while building the work graph. A task's dependency list contains not only what you added with dependsOn but also all of its input declarations (taskInputs) (configurableDependency(ImmutableSet.of(taskInputs, lifecycleDependencies)) on line 207 of the AbstractTask constructor). Input-side dependencies are counted by DefaultTaskInputs.visitDependencies() (lines 193–216). PropertyWalker walks the getters of the task class looking for annotations like @Input and @InputFile (the implementation is DefaultPropertyWalker; the per-annotation handler is chosen in DefaultPropertyWalker.java lines 89–101) and calls a visitor for each property. There are two branches:

    @Override
    public void visitDependencies(TaskDependencyResolveContext context) {
        ValueSupplier.ValueProducer producer = provider.getProducer();
        if (producer.isKnown()) {
            producer.visitDependencies(context);
        } else {
            // Producer is unknown, so unpack the value
            // ...
        }
    }

platforms/core-configuration/file-collections/src/main/java/org/gradle/api/internal/file/collections/ProviderBackedFileCollection.java lines 48–58

Either way, it ends at getProducer(). TaskProducer.visitDependencies() hands over one task (:gen) through visitProducerTasks() (ValueSupplier.java lines 66–70 and 127–129), and that becomes a dependency of show. If the producer is unknown (isKnown() is false), it unpacks the value itself. Taking these dependencies, connecting nodes and deciding the order is the execution plan in part 9.

How a dependency appears without dependsOn. gen's outputFile has attachProducer(gen) attached because of @OutputFile. Putting gen.flatMap { it.outputFile } (FlatMapProvider) into show's inputFile.set(…) makes show's input hold a supplier instead of a value. While building the work graph, when show's dependencies are asked for, DefaultTaskInputs.visitDependencies() finds @InputFile through PropertyWalker, and inputFile.getProducer() follows the supplier and calls the supplier's getProducer(), reaching TaskProducer(:gen). As a result, :gen → :show appears in the execution plan (part 9). Gray is what the build script did; blue is what Gradle does when it asks for dependencies. The dotted line is the path of asking "by following the supplier".

Why is it designed this way? dependsOn(gen) only says "order"; it doesn't know which file changes hands. Wiring an output Provider into an input declares "what is passed" (the file path) and "who goes first" (the producer) in one go. You can change the output path without touching the consumer, and you can't forget to put the producer task into the graph.

Example 2-2: reading the output "contents" during configuration

With -PreadEarly, example 2 tries to read the contents of gen's output file with map during the configuration phase.

// -PreadEarly: try to read the producer task's output "contents" during configuration
if (providers.gradleProperty("readEarly").isPresent) {
    val content = gen.flatMap { it.outputFile }.map { it.asFile.readText() }
    try {
        println(content.get())
    } catch (e: Exception) {
        println("[readEarly] ${e.javaClass.simpleName}: ${e.message}")
    }
}

[From the example ex/u08-provider-wiring/project/build.gradle.kts]

=== gradle help -PreadEarly ===
[readEarly] ProducerNotCompletedException: Querying the mapped value of flatmap(provider(task 'gen', class Build_gradle$Gen)) before task ':gen' has completed is not supported. The mapped value may depend on the content produced by task ':gen', so it can only be calculated after task ':gen' has completed.
BUILD SUCCESSFUL in 2s

This is the exception thrown by section 5's TransformBackedProvider.beforeRead(). The source (flatMap)'s producer was made by task(), i.e. content == true, so visitContentProducerTasks() handed over :gen; :gen hadn't run yet (getExecuted() == false), so the read was refused. The source enforces the distinction that a file's path can be known at configuration time, but its contents only after the task has run. ProducerNotCompletedException also carries three suggested fixes (declare it as an input of the other task; get() it inside that task's actions; use locationOnly if you only need the location) (ProducerNotCompletedException.java lines 30–42). In the example I caught the exception and only printed the message, so the build ended successfully.

Easy to get wrong

7. Locking values — finalizeValueOnRead, disallowChanges, and right before execution

Provider's "compute later" comes at a price. If a value changes after someone has read it, an earlier reader and a later reader see different values. So Property has three ways to finalize a value.

    public void disallowChanges() {
        state.disallowChanges();
    }
    // ...
    public void finalizeValueOnRead() {
        state.finalizeOnNextGet();
    }
    // ...
    public void implicitFinalizeValue() {
        if (state.isUpgradedPropertyValue()) {
            // Upgraded properties should not be finalized to simplify migration.
            // This behaviour should be removed with Gradle 10.
            state.warnOnUpgradedPropertyValueChanges();
        } else {
            state.disallowChangesAndFinalizeOnNextGet();
        }
    }

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/AbstractProperty.java lines 253–271

All three only raise flags on state (ValueState). The non-finalized state (NonFinalizedValue) packs its flags as bits into a single byte (flags) (lines 194–202), and the source comment explains why: in a build like Gradle's own there are more than a million ValueState instances, so adding a single reference noticeably increases memory (lines 184–191). The flags actually matter when someone tries to change the value:

        @Override
        public void beforeMutate(Describable displayName) {
            if ((flags & DISALLOW_CHANGES) != 0) {
                throw new IllegalStateException(String.format("The value for %s cannot be changed any further.", displayName.getDisplayName()));
            } else if ((flags & WARN_ON_UPGRADED_PROPERTY_CHANGES) != 0) {
                // ...

platforms/core-configuration/model-core/src/main/java/org/gradle/api/internal/provider/ValueState.java lines 245–249

This is the check for the not-yet-finalized state with the DISALLOW_CHANGES flag raised. The fully finalized state (FinalizedValue) has a beforeMutate() that throws "The value for %s is final and cannot be changed any further." without asking anything (lines 436–439). set() goes through setSupplier() → assertCanMutate() → state.beforeMutate() (AbstractProperty.java lines 285–288 and 401–403), so this is where it gets stopped. Whether the message contains "is final" tells you which state it was in.

But beforeMutate() doesn't look at FINALIZE_ON_NEXT_GET. finalizeValueOnRead() is just a mark saying "finalize at the next read"; until something reads it, you can still change it. The actual finalization happens on read, when beforeRead() → finalizeOnReadIfNeeded() sees the flag, finalizeNow() computes and fixes the value, and the state switches to FinalizedValue (lines 298–321).

The states of a Property value. It starts out changeable (NonFinalizedValue). finalizeValueOnRead() raises the finalize-on-next-read mark (FINALIZE_ON_NEXT_GET), and set() still works. disallowChanges() moves to changes disallowed (DISALLOW_CHANGES), and set() fails with cannot be changed any further. implicitFinalizeValue() raises both, and Gradle sets it right before a task executes. finalizeValue() goes straight to finalized. From the finalize-on-next-read state and the both state, the first get() moves to finalized (FinalizedValue), fixing the value, and later set() fails with is final. disallowChanges() alone never reaches the finalized state. set() is blocked, but the value is still computed through the supplier chain on every read.

Gradle locks a task's inputs right before it executes

On a task's input and output properties, Gradle itself calls implicitFinalizeValue(). It happens when the task node is executed, right before handing it to the execution engine (part 10).

    private ExecutionEngine.@Nullable Result executeFinalizingProperties(LocalTaskNode node) {
        TaskProperties properties = node.getTaskProperties();
        for (LifecycleAwareValue value : properties.getLifecycleAwareValues()) {
            value.prepareValue();
        }
        try {
            return executeTrackingTaskIdentity(node);
        // ... finally: cleanupValue() ...

subprojects/core/src/main/java/org/gradle/api/internal/tasks/execution/DefaultTaskNodeExecutor.java lines 312–324

prepareValue() calls the property value's maybeFinalizeValue() (AbstractValidatingProperty.java lines 92–95), which calls implicitFinalizeValue() when the value is a HasConfigurableValue (that is, the Property family) (DefaultPropertyWalker.java lines 141–147). The Javadoc of executeFinalizingProperties() quoted above (lines 304–306) explains the reason: it notifies the task properties of the start of execution "so they may finalize and cache whatever state is required to efficiently fingerprint inputs and outputs or apply validation". Inputs must not change while the execution engine decides "are the inputs the same as last time?", nor while the task actions run.

Example 3: three ways of locking, and changes during execution

// This example was written for this post (it is not Gradle's source)
// See from *when* a Property can no longer be changed.

// ... attempt(label) { … }: runs the block and prints "[label] ok" or the IllegalStateException message ...

// 1) finalizeValueOnRead: only marks it; it is finalized on the first read
val a = objects.property<String>()
println("[a: class] ${a.javaClass.name}")
a.set("one")
a.finalizeValueOnRead()
attempt("a: set before read") { a.set("two") }
println("[a: get] ${a.get()}")
attempt("a: set after read") { a.set("three") }

// 2) disallowChanges: blocks changes on the spot
// ... b: set("x") → disallowChanges() → attempt { b.set("y") } ...
// ... 3) the map example (section 5) ...

// 4) Gradle locks a task's input properties right before execution
abstract class Greet : DefaultTask() {
    @get:Input
    abstract val who: Property<String>

    @TaskAction
    fun run() {
        println("[greet] hello, ${who.get()}")
        // Calling the script's function (attempt) would make this an inner class bound to the script, so use try directly here
        try {
            who.set("changed")
        } catch (e: IllegalStateException) {
            println("[greet: set input during execution] ${e.message}")
        }
        // ... call doLast { } the same way ("[greet: doLast during execution] …") ...
    }
}

tasks.register<Greet>("greet") {
    who.set("gradle")
    // The real object returned by the abstract getter
    println("[configure greet] who is ${who.javaClass.name}: $who")
}

[Shortened from the example ex/u08-property-finalize/project/build.gradle.kts]

=== gradle greet ===
[a: class] org.gradle.api.internal.provider.DefaultProperty
[a: set before read] ok
[a: get] two
[a: set after read] The value for this property is final and cannot be changed any further.
[b: set after disallowChanges] The value for this property cannot be changed any further.
[map] hello, second
[configure greet] who is org.gradle.api.internal.provider.DefaultProperty: task ':greet' property 'who'
> Task :greet
[greet] hello, gradle
[greet: set input during execution] The value for task ':greet' property 'who' is final and cannot be changed any further.
[greet: doLast during execution] Cannot call Task.doLast(Action) on task ':greet' after task has started execution.
BUILD SUCCESSFUL in 25s

Easy to get wrong

What this post leaves out

Summary

Concept One-line summary
register Only creates a TaskCreatingProvider and puts it on the pending list. No task object, no configuration block run (Register task)
Realization The first time the provider's value is asked for, tryCreate() creates the task and runs the onCreate actions (Realize task)
What triggers realization get/findByName/getByName, resolving command-line names, iteration (not size), all/whenObjectAdded/withType with a block, asking for the producer
create Creates and configures on the spot. @Deprecated at this commit
configureEach Added to the collection's addActions; runs now only on created ones via iteratorNoFlush(). Pending ones get it when created
withType<T> { } Is all(). Creates every pending one and subscribes to the type, so later register calls are created immediately too
Task factory AnnotationProcessingTaskFactory wraps TaskFactory. Only DefaultTask subclasses; @TaskAction enters the action list at creation
Action list A single ArrayList. doFirst at index 0, doLast at the end. TaskMutator blocks changes once execution starts
map Only returns a TransformBackedProvider. Computed at get(), from the source's latest value
Property.set Even a fixed value is held wrapped in a supplier. Put in another Provider and it holds that chain
ValueProducer "Who makes this value?" Output properties know their owning task through attachProducer()
Ordering without dependsOn Input property → getProducer() → TaskProducer → dependency. DefaultTaskInputs.visitDependencies()
ProducerNotCompletedException Reading with map, before the task runs, a value whose contents that task produces
Locking finalizeValueOnRead (on read), disallowChanges (no set from now on), implicitFinalizeValue (right before execution, by Gradle)

What's next

In this post, show got as far as reporting its reliance on gen as a dependency. What comes next — starting from the requested tasks, following dependencies to collect nodes, deciding the order including mustRunAfter and finalizedBy, and running them spread over several worker threads — is the work graph and the execution plan. How the task picked by section 2's TaskNameResolver becomes a node in DefaultExecutionPlan, and why only one task runs at a time without --parallel, is in part 9: The Task Graph and the Execution Plan — What, in What Order, How Many at Once.

Earlier series covered similar ideas. "Register now, create when needed" is the same idea as BeanDefinition in Spring part 3: Spring also registers a bean's blueprint (BeanDefinition) first and creates the object later. But Spring's default is to create all non-lazy-init singletons up front at the end of refresh() (Spring part 7), while Gradle's default is the opposite: only create what enters the graph. On the value side, Providers resemble ObjectProvider and @Lazy from Spring part 5 in that they "don't look it up now, fetch it later" — the difference is that Gradle's Provider also carries "who makes it" (the producer), and that decides the execution order.

More posts