Skip to main content

Command Palette

Search for a command to run...

Gradle configuration cache by example. Part 2: problems

Updated
17 min read

In the first part we have seen how configuration cache is working. Now I will briefly show what problems configuration cache brings to plugin authors with possible solutions.

Searching for problems

There are two types of problems you may face:

  • On first run (with cache enabled) gradle could indicate problems with configuration state serialization . Not always obvious to track, but, ususallly, quite easy to fix.

  • On second run (from cache) there might be problems because of incorrect assumptions. For example, you rely on build service data (filled at configuration time), which is not preserved when running from cache (build service state not serialized).

We will see both types of problems below.

Access project at runtime

The simplest and the most popular problem is using not allowed objects at runtime. Most likely, you’ll face project usage in task:

public abstract class Fail1Task extends DefaultTask {

    @TaskAction
    public void run() {
        System.out.println("[run] Task executed for project: " + getProject().getName());
    }
}

And a very simple plugin:

public abstract class Fail1Plugin implements Plugin<Project> {

    @Override
    public void apply(Project project) {
          project.getTasks().register("fail1Task", Fail1Task.class);
    }
}

It will fail to run fail1Task --configuration-cache --configuration-cache-problems=warn

Calculating task graph as no cached configuration is available for tasks: fail1Task

> Task :fail1Task
[run] Task executed for project: junit14814233387960710613

1 problem was found storing the configuration cache.
- Task `:fail1Task` of type `ru.vyarus.gradle.plugin.fails.fail1.Fail1Task`: invocation of 'Task.project' at execution time is unsupported.
  See https://docs.gradle.org/8.13/userguide/configuration_cache.html#config_cache:requirements:use_project_during_execution

See the complete report at file:///tmp/junit14814233387960710613/build/reports/configuration-cache/2n0qomukpcgdka6np2vpry01g/a4zpdprgh0056ayfdj5dwvtkk/configuration-cache-report.html

[Incubating] Problems report is available at: file:///tmp/junit14814233387960710613/build/reports/problems/problems-report.html

BUILD SUCCESSFUL
1 actionable task: 1 executed
Configuration cache entry stored with 1 problem.

Note: configuration-cache-problems=warn is used to prevent build failure for simpler output (without stacktraces) and to show some additional side effects (in later cases).

To fix this, project object access must be moved to configuration phase. It could be done by storing required data in task property in the constructor (called at configuration time):

public abstract class Fail1FixTask extends DefaultTask {

    private final String projectName;

    public Fail1FixTask() {
        projectName = getProject().getName();
    }

    @TaskAction
    public void run() {
        System.out.println("[run] Task executed for project: " + projectName);
    }
}

Or, with provider (if state resolution must be delayed):

public abstract class Fail1Fix2Task extends DefaultTask {

    private final Provider<String> projectName;

    public Fail1Fix2Task() {
        projectName = getProject().provider(() -> getProject().getName());
    }

    @TaskAction
    public void run() {
        System.out.println("[run] Task executed for project: " + projectName.get());
    }
}

IMPORTANT: Cases with other gradle objects are not shown becuase they are clearly described in the docs.

Project usage in plugin

Project (or other not allowed objects) usage at plugin’s run block also fails. The plugin blow access project in the task’s doLast block (executed at runtime):

public abstract class Fail2Plugin implements Plugin<Project> {

    @Override
    public void apply(Project project) {
        System.out.println("[configuration] Project name: " + project.getName());

        project.getTasks().register("fail2Task", task ->  {
            task.doLast(task1 ->
                    System.out.println("[run] Project name: " + project.getName()));
        });
    }
}

Even with cache warnings enabled, task execution will fail
fail2Task --configuration-cache --configuration-cache-problems=warn:

Calculating task graph as no cached configuration is available for tasks: fail2Task

> Configure project :
[configuration] Project name: junit16727929338108926619

> Task :fail2Task FAILED

2 problems were found storing the configuration cache.
- Task `:fail2Task` of type `org.gradle.api.DefaultTask`: cannot deserialize object of type 'org.gradle.api.Project' as these are not supported with the configuration cache.
  See https://docs.gradle.org/8.13/userguide/configuration_cache.html#config_cache:requirements:disallowed_types
- Task `:fail2Task` of type `org.gradle.api.DefaultTask`: cannot serialize object of type 'org.gradle.api.internal.project.DefaultProject', a subtype of 'org.gradle.api.Project', as these are not supported with the configuration cache.
  See https://docs.gradle.org/8.13/userguide/configuration_cache.html#config_cache:requirements:disallowed_types

See the complete report at file:///tmp/junit16727929338108926619/build/reports/configuration-cache/47ehjfb227oo5kzbg615m5h3q/dghkqx053bip47mmv9kiov6f3/configuration-cache-report.html

[Incubating] Problems report is available at: file:///tmp/junit16727929338108926619/build/reports/problems/problems-report.html

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':fail2Task'.
> Cannot invoke "org.gradle.api.Project.getName()" because "project" is null

* Exception is:
org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':fail2Task'.
...    
Caused by: java.lang.NullPointerException: Cannot invoke "org.gradle.api.Project.getName()" because "project" is null
...

BUILD FAILED in 3s
1 actionable task: 1 executed
Configuration cache entry stored with 2 problems.

It failed with NullPointerException becuase, even on first run, it serialize and deserialize state, required for runtime blocks (in the separate task case above, the project call was a part of runtime action, not configuration).

Same as with task case, the fix is to store required data in a variable or using provider in configuration phase:

    @Override
    public void apply(Project project) {

        final String projectName = project.getName();
        project.getTasks().register("fail2Fix", task ->  {
            task.doLast(task1 ->
                    System.out.println("[run] Project name: " + projectName));
        });

        final Provider<String> nameProvider = project.provider(() -> {
            System.out.println("[configuration] Provider called");
            return project.getName();
        });
        project.getTasks().register("fail2Fix2", task ->  {
            task.doLast(task1 ->
                    System.out.println("[run] Project name: " + nameProvider.get()));
        });
    }

Running fail2Fix fail2Fix2 --configuration-cache --configuration-cache-problems=warn:

Calculating task graph as no cached configuration is available for tasks: fail2Fix fail2Fix2
[configuration] Provider called

> Task :fail2Fix2
[run] Project name: junit17295962152626235093

> Task :fail2Fix
[run] Project name: junit17295962152626235093

BUILD SUCCESSFUL in 3s
2 actionable tasks: 2 executed
Configuration cache entry stored.

(the order of tasks is not determinate as they run in parallel)

Runtime blocks in plugin is the main “problem” for configuration cache: it applies much efforts analyzing them to serialize (only) required configuration state.

Too broad serialization

This is the most annoying problem as gradle can’t hint you about its source. For example, here is an extension with not serializable object (SourceSet):

public class Fail3Extension {

    public Set<SourceSet> sets;
    public String message;

    public Fail3Extension(Project project) {
        this.sets = project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets();
    }
}

Plugin only use string extension property at runtime (and does not call not serializable property!):

public class Fail3Plugin implements Plugin<Project> {

    @Override
    public void apply(Project project) {
        Fail3Extension ext = project.getExtensions().create("fail3", Fail3Extension.class, project);

        project.getTasks().register("fail3Task", task ->  {
            task.doLast(task1 ->
                    System.out.println("[run] Message: " + ext.message));
        });
    }
}

But gradle would try to serialize the entire extension here (object, referenced at runtime)
fail3Task --configuration-cache --configuration-cache-problems=warn:

Calculating task graph as no cached configuration is available for tasks: fail3Task

> Task :fail3Task
[run] Message: Configured!

2 problems were found storing the configuration cache.
- Task `:fail3Task` of type `org.gradle.api.DefaultTask`: cannot deserialize object of type 'org.gradle.api.tasks.SourceSetContainer' as these are not supported with the configuration cache.
  See https://docs.gradle.org/8.13/userguide/configuration_cache.html#config_cache:requirements:disallowed_types
- Task `:fail3Task` of type `org.gradle.api.DefaultTask`: cannot serialize object of type 'org.gradle.api.internal.tasks.DefaultSourceSetContainer', a subtype of 'org.gradle.api.tasks.SourceSetContainer', as these are not supported with the configuration cache.
  See https://docs.gradle.org/8.13/userguide/configuration_cache.html#config_cache:requirements:disallowed_types

See the complete report at file:///tmp/junit10544361871289601409/build/reports/configuration-cache/5tucq48qd6ozznv386suhaql7/9u8dhuyl2ja1knarlvrf25z8q/configuration-cache-report.html

[Incubating] Problems report is available at: file:///tmp/junit10544361871289601409/build/reports/problems/problems-report.html

BUILD SUCCESSFUL in 2s
1 actionable task: 1 executed
Configuration cache entry stored with 2 problems.

You can see here that gradle only points you to a not serializable type and you must analyze all runtime blocks yourself to understand the source of problem.

To fix this particular problem, reduce caches scope by assigninig exact required data to a variable in the configuration phase:

@Override
public void apply(Project project) {
    Fail3Extension ext = project.getExtensions().create("fail3", Fail3Extension.class, project);

    project.getTasks().register("fail3Task", task ->  {
        // reduce serialization scope
        String message = ext.message;
        task.doLast(task1 ->
                System.out.println("[run] Message: " + message));
    });
}

Here variable is assigned in the task’s lazy initialization block (and so user configuration is applied). It is important to not assign variable too early as user configuration may not be applied, like here:

// it would be NULL! Too early
String message = ext.message;
project.getTasks().register("fail3Task", task ->  {
    task.doLast(task1 ->
            System.out.println("[run] Message: " + message));
});

With provider the exact declaration place is not important:

Provider<String> message = project.provider(() -> ext.message);
project.getTasks().register("fail3Task", task ->  {
    task.doLast(task1 ->
            System.out.println("[run] Message: " + message));
});

Local variable, cachable or non-cachable (ValueSource) provider usage is the magic wand for configuration cache compatiblity.

The difference of plugin and task serialization

Tasks and plugins are serialized differently! To show it, we will store not allowed object (SourceSet) in task and plugin fields, but will not use it at runtime.

The task:

public abstract class Fail4Task extends DefaultTask {

    private SourceSet sourceSet;
    private String name;

    public Fail4Task() {
        sourceSet = getProject().getExtensions().getByType(JavaPluginExtension.class).getSourceSets().getByName("main");
        name = sourceSet.getName();
    }

    @TaskAction
    public void run() {
        System.out.println("[run] Task source set: " + name);
    }
}

The plugin:

public class Fail4Plugin implements Plugin<Project> {

    private SourceSet sourceSet;

    @Override
    public void apply(Project project) {
        sourceSet = project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets().getByName("test");

        project.getTasks().register("fail4Task", Fail4Task.class, task ->  {
            String set = sourceSet.getName();
            task.doLast(task1 ->
                    System.out.println("[run] Project source set: " + set));
        });
    }
}

Run task fail4Task --configuration-cache --configuration-cache-problems=warn:

Calculating task graph as no cached configuration is available for tasks: fail4Task

> Task :fail4Task
[run] Task source set: main
[run] Project source set: test

2 problems were found storing the configuration cache.
- Task `:fail4Task` of type `ru.vyarus.gradle.plugin.fails.fail4.Fail4Task`: cannot deserialize object of type 'org.gradle.api.tasks.SourceSet' as these are not supported with the configuration cache.
  See https://docs.gradle.org/8.13/userguide/configuration_cache.html#config_cache:requirements:disallowed_types
- Task `:fail4Task` of type `ru.vyarus.gradle.plugin.fails.fail4.Fail4Task`: cannot serialize object of type 'org.gradle.api.internal.tasks.DefaultSourceSet', a subtype of 'org.gradle.api.tasks.SourceSet', as these are not supported with the configuration cache.
  See https://docs.gradle.org/8.13/userguide/configuration_cache.html#config_cache:requirements:disallowed_types

See the complete report at file:///tmp/junit3909871766538813794/build/reports/configuration-cache/ej5hwigx1x3swf7o2d6u9466r/6lf4x0dgeq19zwf53mjr1wh3t/configuration-cache-report.html

[Incubating] Problems report is available at: file:///tmp/junit3909871766538813794/build/reports/problems/problems-report.html

BUILD SUCCESSFU
1 actionable task: 1 executed
Configuration cache entry stored with 2 problems.

Gradle complains only about the task! So the task is serialized completely, but the plugin is not!

It would be easy to proove, just fixing the task:

public class Fail4FixTask extends DefaultTask {

    private String name;

    public Fail4FixTask() {
        SourceSet sourceSet = getProject().getExtensions()
                .getByType(JavaPluginExtension.class).getSourceSets().getByName("main");
        name = sourceSet.getName();
    }

    @TaskAction
    public void run() {
        System.out.println("[run] Task source set: " + name);
    }
}

Run fail4Fix --configuration-cache --configuration-cache-problems=warn:

Calculating task graph as no cached configuration is available for tasks: fail4Fix

> Task :fail4Fix
[run] Task source set: main
[run] Project source set: test

BUILD SUCCESSFUL
1 actionable task: 1 executed
Configuration cache entry stored.

No problems! So you can use plugin’s private fields to store any data you need, just don’t reference it in runtime blocks (or wrap access with providers, producing something serializable).

Listening tasks

This is not stricly related to the configuration cache, but it would be important to highlight this behavor before the next chapter, covering build services.

Suppose you need to do somethig after a task execution (always), but you don’t control the task (3rd party plugin’s task). You have 2 (conf.cache - legal) ways: use doLast block and build service as listener.

The catch is: doLast block will not be called if task is not executed. Task is not executed when it’s UP-TO-DATEor FROM-CACHE (when build cache enabled). So the only way to always detect task execution is by using build service.

Lets check. Here is a simple build service, listening for tasks:

public abstract class Service implements BuildService<BuildServiceParameters.None>,
        OperationCompletionListener {

    @Override
    public void onFinish(FinishEvent finishEvent) {
        System.out.println("[run] Finish event: " + finishEvent.getDescriptor().getName());
    }
}

Task with output file (cachable):

public abstract class Sample8Task extends DefaultTask {

    @OutputFile
    public abstract Property<File> getOut();

    @TaskAction
    public void run() throws Exception {
        System.out.println("[run] Task executed");
        File out = getOut().get();

        BufferedWriter writer = new BufferedWriter(new FileWriter(out));
        writer.append("Sample file content");
        writer.close();
    }
}

And plugin, declaring the task and configuring doLast block:

public abstract class Sample8Plugin implements Plugin<Project> {

    @Inject
    public abstract BuildEventsListenerRegistry getEventsListenerRegistry();

    @Override
    public void apply(Project project) {
        // service listen for tasks
        final Provider<Service> service = project.getGradle().getSharedServices().registerIfAbsent(
                "service", Service.class);
        getEventsListenerRegistry().onTaskCompletion(service);

        project.getTasks().register("sample8Task", Sample8Task.class, task -> {
            task.getOut().set(project.getLayout().getBuildDirectory()
                        .dir("sample8/out.txt").get().getAsFile());
            task.doLast(t -> System.out.println("[run] doLast for sample8Task"));
        });
    }
}

Run sample8Task --configuration-cache --configuration-cache-problems=warn:

Calculating task graph as no cached configuration is available for tasks: sample8Task

> Task :sample8Task
[run] Task executed
[run] doLast for sample8Task
[run] Finish event: :sample8Task

BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed
Configuration cache entry stored.

Both doLast and build service executed.

Run again sample8Task --configuration-cache --configuration-cache-problems=warn:

Reusing configuration cache.
> Task :sample8Task UP-TO-DATE
Finish event: :sample8Task

BUILD SUCCESSFUL in 80ms
1 actionable task: 1 up-to-date
Configuration cache entry reused.

The tasks is UP-TO-DATE and so doLast was not called, but the build service was notified.

The only problem with build service is that it does not provide you a task instance, only task path. So you may need to store some additional task data, collected at configuration phase.

Caching data in build service

Build service cache parameters state at the time of service creation. Any further paramters modifications would not be serializaed (will be lost after the current tasks execution).

Sample service:

public abstract class Service implements BuildService<Service.Params> {

    public Service() {
        System.out.println("Service created with state: " + getParameters().getValues().get());
    }

    interface Params extends BuildServiceParameters {
        ListProperty<String> getValues();
    }
}

Plugin:

public class Sample7Plugin implements Plugin<Project> {

    @Override
    public void apply(Project project) {
        final List<String> values = new ArrayList<>();

        Provider<Service> service = project.getGradle().getSharedServices().registerIfAbsent(
                "service", Service.class, spec -> {
                    // initial "persisted storage" value
                    spec.getParameters().getValues().value(values);
                });

        values.add("val1");
        values.add("val2");

        project.getTasks().register("sample7Task", task ->
                task.doFirst(task1 ->
                        System.out.println("Task see state: " + service.get().getParameters().getValues().get()))
        );
    }
}

Service will initialize only at runtime and so the collected configuration state would be preserved.

First run sample7Task --configuration-cache --configuration-cache-problems=warn:

Calculating task graph as no cached configuration is available for tasks: sample7Task

> Task :sample7Task
Service created with state: [val1, val2]
Task see state: [val1, val2]

BUILD SUCCESSFUL
1 actionable task: 1 executed
Configuration cache entry stored.

Second run sample7Task --configuration-cache --configuration-cache-problems=warn:

Reusing configuration cache.

> Task :sample7Task
Service created with state: [val1, val2]
Task see state: [val1, val2]

BUILD SUCCESSFUL in 67ms
1 actionable task: 1 executed
Configuration cache entry reused.

State, prepared in plugin during configuration phase was serialized in service parameter.

What is important:

  • Required state is collected in plugin field

  • The field is used to initialize service parameter (on creation)

  • Service is not initializard during configuration phase

In the majority of cases, this trick (almost hack) would not be required becuase you can simply cache the value returned from service instead of re-recovering real service state. For example:

project.getTasks().register("sample7Task", task ->
        // would be cached, because its referenced in run block
        List<String> state = service.get().getParameters().getValues().get()
        task.doFirst(task1 ->
                System.out.println("Task see state: " + state))
);

Here service state would be cached and run from cache will not use the service at all.

The case when such caching is not possible is described below (as a real life example).

The real case

The case I faced updating my quality plugin: the plugin is listening for quality tasks (from 3rd party plugins: pmd, checkstyle, findbugs, etc.) and print detected problems into console, using xml reports (produced by quality trasks). As you have seen above, the only way to always print console report after a task is to use build service (all quality tasks are cachable). But build service will receive just a task path, so information about reportable quality tasks must be collected under configuration phase (and cached).

doLast block is called just after the task whereas build service (as task listener) could be called a bit later and doLast it is better for performing output (to print it just below the task output and inside of output of some other task, executed concurrently).

Using both methods (doLast and service) would lead to duplicate output, and so some “execution marker” must be stored somewhere to avoid duplicates. That’s why it would not be possible to just cache service state in a variable (in this case each task would have its own instance of cache and will not see an “execution marker”). So full service state recovery is required.

Simple service, which must contain the required information about tasks:

public abstract class Service implements BuildService<Service.Params>,
                    OperationCompletionListener {

    @Override
    public void onFinish(FinishEvent finishEvent) {
        if (finishEvent instanceof TaskFinishEvent) {
            // not important for example, just showing for completeness
            TaskFinishEvent taskEvent = (TaskFinishEvent) finishEvent
            String taskPath = taskEvent.descriptor.taskPath;
            TaskDesc desc = getParameters().getValues().get().stream()
                .filter(it -> it.path.equals(taskPath))
                .findFirst().orElse(null)
            // do something, knowing additional task data
             if (desc != null) {
                if (!desc.isCalled()) {
                    System.out.println("Task " + taskPath + " listened by service");
                    desc.setCalled(true);
                } else {
                    System.out.println("Task " + taskPath + " listened, but ignored");
                }
            }
        }
    }

    interface Params extends BuildServiceParameters {
        // will hold all required data, prepared by plugin
        ListProperty<TaskDesc> getValues();
    }
}

TaskDesc is a simpe value class (with properties only):

public class TaskDesc implements Serializable {
    private String name;
    private String path;
    private boolean called;

    public TaskDesc() {
    }

    public TaskDesc(String name, String path) {
        this.name = name;
        this.path = path;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPath() {
        return path;
    }

    public void setPath(String path) {
        this.path = path;
    }

    public boolean isCalled() {
        return called;
    }

    public void setCalled(boolean called) {
        this.called = called;
    }

    @Override
    public String toString() {
        return name;
    }
}

The plugin register task and prepare a collection for caching in service parameter:

public abstract class Sample7Plugin implements Plugin<Project> {

    @Inject
    public abstract BuildEventsListenerRegistry getEventsListenerRegistry();

    // collecting tasks info during configuration phase
    private final List<TaskDesc> tasksInfo = new ArrayList<>();

    @Override
    public void apply(Project project) {
        // service 1 with "state" in a private field
        Provider<Service> service = project.getGradle().getSharedServices()
                .registerIfAbsent("service", Service.class, spec -> 
                    // on first creation, service will cache list value, but it is important
                    // to not create it too early
                    spec.getParameters().getValues().set(tasksInfo));

        getEventsListenerRegistry().onTaskCompletion(service);

        // tasks to demostrate behavior
        project.getTasks().register("task1", TrackedTask.class);
        project.getTasks().register("task2", TrackedTask.class);

        project.getTasks().withType(TrackedTask.class).configureEach(task -> {
                captureTaskInfo(task);
                task.doLast(task1 -> {
                    final TaskDesc desc = service.get().getParameters().getValues().get().stream()
                            .filter(taskDesc -> taskDesc.getPath().equals(task.getPath()))
                            .findAny().orElse(null);
                    if (!desc.isCalled()) {
                        System.out.println("Task " + task1.getName() + " doLast");
                        desc.setCalled(true);
                    }
                });
        });
    }

    private void captureTaskInfo(Task task) {
         System.out.println("Store task descriptor: " + task.getName());
         tasksInfo.add(new TaskDesc(task.getName(), task.getPath()));
    }
}

Creating cache task1, task2, --configuration-cache, --configuration-cache-problems=warn:

Calculating task graph as no cached configuration is available for tasks: task1 task2

> Configure project :
Service configured: []
Store task descriptor: task1
Store task descriptor: task2

> Task :task1
Service created with state: [task1, task2]
Task task1 doLast

> Task :task2
Task task2 doLast
Task :task1 listened, but ignored
Task :task2 listened, but ignored

BUILD SUCCESSFUL in 3s
2 actionable tasks: 2 executed
Configuration cache entry stored.

Run from cache task1, task2, --configuration-cache, --configuration-cache-problems=warn:

Reusing configuration cache.

> Task :task1
Service created with state: [task1, task2]
Task task1 doLast

> Task :task2
Task task2 doLast
Task :task1 listened, but ignored
Task :task2 listened, but ignored

BUILD SUCCESSFUL in 74ms
2 actionable tasks: 2 executed
Configuration cache entry reused.

In this example sample task was not cachable and so here doLast was called, but, as you can see, listener is also called and has access to task data (and so in case of cachable task would work correctly).

For sure, this is an edge case, which you, most likely, would never face. Its here just to show what is possible under configuration cache.

Multi-module builds

Configuration cache may force you to use build services, but it is important to keep in mind that you plugin might be used in a multi-module build.

If the plugin from parameters caching sample would be used in the multi-module build like this:

plugins {
    id 'com.mycompany.myplugin' version '1.0' apply false
}

subprojects {
    apply plugin: 'com.mycompany.myplugin'
}

Then each module would create it’s own plugin instance with it’s own inner state. So the trick like this would not work anymore:

    @Override
    public void apply(Project project) {
        // in multi-module project each module would create this list
        final List<String> values = new ArrayList<>();

        // but the service is global (initiated just once)
        Provider<Service> service = project.getGradle().getSharedServices().registerIfAbsent(
                "service", Service.class, spec -> {
                    spec.getParameters().getValues().value(values);
                });

        ...
    }

The service is global, and so only one configuration will be applied (from one subproject) and, as the result, it would contain the state, collected in one subproject only!

The simple workaround is to use separate service instances per project, by modifying service name:

Provider<Service> service = project.getGradle().getSharedServices().registerIfAbsent(
        "service" + project.getName(), Service.class, spec -> {
             // initial "persisted storage" value
            spec.getParameters().getValues().value(values);
        });

For example, plugin in subproject “sub” would create service “servicesub”.

Note: you may have problems with the global service only in advanced cases when you have to store state in such service (in parameters, using plugin’s variable).

Different classpaths

Another possible multi-module case is if your plugin would be applied independently in submodules:

// pseudo configuration (in real life it would be different config files)

subrojects("sub1") {
    plugins {
        id 'com.mycompany.myplugin' version '1.0'
    }
}

subrojects("sub2") {
    plugins {
        id 'com.mycompany.myplugin' version '1.0'
    }
}

Then plugins would be loaded in different classpaths and so they would not be able to use a global service (there would be “class cannot be cast to class” error becuase service classes would be different in modules).

Just pay attention to this moment - in most cases, project-scoped service is a better way.

Jococo

When running TestKit-based tests with enabled jococo plugin (for coverage), you'll always have an issue:

1 problem was found storing the configuration cache.
- Gradle runtime: support for using a Java agent with TestKit builds is not yet implemented with the configuration cache.
  See https://docs.gradle.org/8.14.3/userguide/configuration_cache.html#config_cache:not_yet_implemented:testkit_build_with_java_agent

But, it's not a critical problem: test must check that it was THE ONLY problem:

BuildResult result = run('someTask', '--configuration-cache', '--configuration-cache-problems=warn');
Assertions.assertThat(result.getOutput()).contains(
                "1 problem was found storing the configuration cache",
                "Gradle runtime: support for using a Java agent with TestKit",
                "Calculating task graph as no cached configuration is available for tasks:"
);

See repository tests for complete configuration cache testing example.

Summary

The summary does not change from part 1.

Just paying additional attention to the “magic wand” for configuration cache compatibilty:

  1. Assigning state to a variable (at correct time) could easilly reduce serialization scope

  2. Provider could “move” code execution from runtime into configuration phase (so let you call not allowed staff at runtime).

  3. ValueSource workarounds caching

In most cases, its simpler to let gradle cache state (with variable or provider), instead of reproducing it under the cache.

it is safier to create build service per plugin instance, instead of global due to potential problems in multi-module projects.

Not allowed gradle projects replacements are perfectly described in the gradle docs.
The only case in my practice that wasn’t described there was project.getAntBuilder() usage in task. In groovy plugin, the solution was to use groovy’s AntBuilder (not gradle builder) directly (new AntBuilder()…).
The runtime objects restrictions mostly driven by configuration state preserved in the disallowed objects, but direct usage of anything (not implicitly aware of gradle state) is completely fine.