В первой части было показано общее поведение configuration cache на простых примерах. Теперь перейдем в плоскость практики: рассмотрим с какими проблемами сталкиваются разработчики плагинов и как их можно решать.

Напоминаю что полные версии примеров и тестов можно найти в репозитории. Имена классов в статье и репозитории совпадают, но в статье примеры упрощены для лучшего восприятия.

Возможные проблемы

Есть два класса проблем:

  1. Проблемы сериализации, возникающие при создании кэша (первом запуске). Эти проблемы gradle репортит сам. К сожалению, причины не всегда очевидны, но, зато, необходимые правки (как правило) очень просты.

  2. Неправильная работа из кэша (второй запуск). Возникают из-за ошибочных допущений (о том как работает кэш). Например, плагин может пологаться на состояние build сервиса, которое не восстанавливается при работе из кэша.

Ниже рассмотрим оба типа проблем.

Использование project в задаче

Начнем с самой распространенной проблемы: в рантайм блоках нельзя использовать объект project.

Чаще всего project используется в задаче (task). Например, нужно использовать имя проекта в логах:

public abstract class Fail1Task extends DefaultTask {

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

Простейший плагин:

public abstract class Fail1Plugin implements Plugin<Project> {

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

Запускаем с включенным кэшем
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.

--configuration-cache-problems=warn не дает gradle упасть на первой же проблеме (а, как правило, их будет много). Вместо этого, все найденные ошибки просто логируются: и можно спокойно разбираться сразу со всеми.

Ошибка сериализации означает что плагин не смог сохранить конфигурацию и, значит, не сможет нормально работать из кэша.

Для исправления большенства подобных проблем достаточно переместить работу с project в фазу конфигурации:

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);
    }
}

Если значение не может быть получено в фазе конфигурации (слишком рано) - просто заворачиваем в провайдер:

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());
    }
}

Как мы видели в предидущей части, провайдер будет вызван в фазе конфигурации и значение закэшировано.

Не буду показывать примеры с другими объектами потому что их полно в документации.

Использование project в плагине

Использование project или любого другого запрещенного объекта может обнаружиться и в рантайм блоке плагина:

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()));
        });
    }
}

Что интересно, даже с включенными ворнингами сериализации, первый запуск упадет
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.

Как было показано в первой части, уже при первом запуске с включенным кэшем, необходимая конфигурация для рантайм блоков сериализуется и десериализуется. Поскольку сериализовать project нельзя, то в рантайм блоке project = null. В случае с задачей (первый пример), getProject это действие в рантайме, а не обращение к сохраненному состоянию.

Решается точно так же, как и с задачей - готовим необходимое состояние на фазе конфигурации, либо заворачиваем в провайдер:

    @Override
    public void apply(Project project) {

        // fix 1: используем переменную
        final String projectName = project.getName();
        project.getTasks().register("fail2Fix", task ->  {
            task.doLast(task1 ->
                    System.out.println("[run] Project name: " + projectName));
        });

        // fix 2: используем провайдер
        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()));
        });
    }

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.

(пораядок задач случаен поскольку gradle запускает их параллельно)

Именно рантайм блоки в плагине являются источником большенства "сложных" проблем поскольку не всегда очевидно что конкретно gradle пытается сериализовать.

Излишняя сериализация

Это самая неприятная проблема потому что gradle не может явно указать на источник.
Для примера возьмем расширение с несериализуемым объектом (SourceSet):

public class Fail3Extension {

    public Set<SourceSet> sets;
    public String message;

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

Плагин не использует несериализуемое поле расширения:

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));
        });
    }
}

Но gradle попытается сериализовать весь объект расширения:
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.

Как можно видеть, gradle указывает на несериализуемый тип, но, вот откуда он берется, придется искать самому (анализируя рантайм блоки).

Чтобы исправить эту проблему достаточно убрать использование экстеншена из рантайм блока ("сузить" кэширование). Например, можно вынести доступ к объекту в фазу конфигурации, а в рантайме использовать только значение переменной:

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

    project.getTasks().register("fail3Task", task ->  {
        // теперь gradle может сериализовать только значение переменной
        String message = ext.message;
        task.doLast(task1 ->
                System.out.println("[run] Message: " + message));
    });
}

Здесь важно что доступ к расширунию выполняется в ленивом блоке конфигурации задачи (который будет выполняться после применения конфигурации из build скрипта). Важно не заводить такие переменные слишком рано, например:

// NULL! слишком рано - конфигурация из build файла еще не применена
String message = ext.message;
project.getTasks().register("fail3Task", task ->  {
    task.doLast(task1 ->
            System.out.println("[run] Message: " + message));
});

С другой стороны, при использовании провайдера, абсолютно не важно где его декларировать:

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

Локальные переменные, провайдеры и некэшируемые провайдеры (ValueSource) - это настоящая волшебная палочка при решении проблем с кэшем! В большенстве случаев это все что вам понадобится для совместимости.

В ситуации, когда одной переменной не обойтись, никто не мешает сдеать свой value object и дать gradle закэшировать именно его. Например если нужно и имя SourceSet и список директорий, можно завести объект SourceSetModel и (в фазе конфигурации) сохранить в него все что нужно (сериализацияFileCollection не запрещена, п.э. sourceSet.getJava().getSourceDirectories() можно сохранить в объекте).

Только не забывайте что value object должен быть сериализуемым (implements Serializable). Подробный пример будет ниже.

Разница сериализации плагинов и задач

Чтобы показать разницу сериализации, используем несериализуемое поле (SourceSet) в поле задачи и плагина. В обоих случаях поле не будет использовано!

Задача:

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);
    }
}

Плагин:

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));
        });
    }
}

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 ругается только на задачу! Таким образом, задача сериализуестся полностью, а плагин нет!
Если убрать "плохое" поле из задачи, у gradle больше не будет вопросов:

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);
    }
}

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.

Таким образом, можно хранить в полях плагина что угодно, главное не использовать "плохие" поля в рантайме. Или же заворачивать доступ в провайдер, возвращающий что-то сериализуемое.

Впринципе, это все что нужно знать про кэш. Дальше начинаются проблемы рантайма (когда все сериализуется, но работает не так как хотелось бы).

Как реагировать на выполненные задачи

Это не относится напрямую к теме configuration cache, но является важной особенностью поведения gradle, которая может заставить использовать build сервисы там где, казалось бы, можно и обойтись.

Допустим, нам нужно слушать выполнение задач и выполнять что-то (всегда!) после них. Часто бывает, что слушать нужно не свои задачи (например, задачи другого плагина - то что плагин не контролирует). Есть два пути (совместимых с кэшем): doLast блок и build сервис.

Вот только doLast блок не вызывается если задача не выполнялась: UP-TO-DATE или FROM-CACHE (build cache). Таким образом, doLast не подходит если нужно реагировать всегда.

Проверим на примере. Пусть и сервис-слушатель и doLast используются в плагине:

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

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

Кэшируемая задача:

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();
    }
}

Плагин:

public abstract class Sample8Plugin implements Plugin<Project> {

    @Inject
    public abstract BuildEventsListenerRegistry getEventsListenerRegistry();

    @Override
    public void apply(Project project) {        
        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"));
        });
    }
}

При первом запуске
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.

и doLast и сервис отработали.

Запуск из кэша
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.

Задача UP-TO-DATE поэтому doLast не вызывается (задача реально не запускалась), а вот сервис прекрасно "видит" выполнение задачи.

Единственная проблема с сервисом-слушателем: он не имеет доступа непосредственно к задаче - только знает ее полное имя (path). П.э., если дополнительные сведения о задаче нужны, их придется готовить в фазе конфигурации.

Кэшируем данные в build сервисе

Build сервис кэширует параметры (не поля - именно параметры!) в момент создания сервиса. Любые последующие модификации параметров не сохраняются! Т.е. мы не можем использовать сервис в фазе конфигурации чтобы "накопить" (множеством вызовов) какую-то информация и затем использвать ее в рантайме.

С другой стороны, а нам и не надо! Достаточно отложить инициализацию сервиса чтобы мы успели подготовить все нужные данные, а затем передать их в параметр сервиса (где уже готовое значение будет закэшировано).

Берем сервис с одним п��раметром:

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();
    }
}

Плагин:

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 -> {
                    // хоть сервис и не создается(!) в фазе конфигурации,
                    // этот блок будет вызван в фазе конфигурации!
                    System.out.println("[configuration] Initial service configuration: " + values);
                    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()))
        );
    }
}

Сервис будет первый раз создан только в рантайме, но параметры сериализует в фазе конфигурации.

Первый запуск
sample7Task --configuration-cache --configuration-cache-problems=warn :

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

> Configure project :
[configuration] Initial service configuration: []

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

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

Обратите внимание что блок инициализации сервиса вызван в фазе конфигурации когда лист не был заполнен!

[configuration] Initial service configuration: []

Тем не менее при создании сервиса значения правильные:

Service created with state: [val1, val2]

ПОЧЕМУ оно работает - загадка (магия): понятно что он как-то сохраняет ссылку до реальной инициализации, но вот так, например, тоже работает: spec.getParameters().getValues().addAll(values).

Для душевного спокойствия можно использовать провайдер:
spec.getParameters().getValues().value(project.provider(() -> values)); (провайдер честно вызывается в конце фазы инициализации - проверял)

Работа из кэша
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.

В большинстве случаев, такой трюк просто не нужен, потому что можно использовать сервис в фазе конфигурации для сбора информации, а потом просто прокэшировать "собранное" состояние:

project.getTasks().register("sample7Task", task ->
        // все еще фаза конфигурации - инстанс сервиса тот-же
        List<String> state = service.get().getParameters().getValues().get()
        task.doFirst(task1 ->
                System.out.println("Task see state: " + state))
);

Здесь рантайм блок не будет использовать сервис - ему достаточно состояния в переменной (которое и закэшивано).

Зачем же было показывать этот трюк с сериализацией, если можно прокэшировать? Потому что бывают случаи когда уникальность объектов важна (один и тот-же объект должен использоваться в разных блоках) - а значит нужен сервис (про потерянную уникальность было в первой части).

Случай из практики

Случай из разряда "повезло так повезло" и очень сомневаюсь что кто-то еще встретится с подобным в своих плагинах. Тем не менее, это, наверно, была самая большая проблема при миграции на configuration cache что я встречал. По меньшей мере, должно быть просто интересно.

Случилось это при обновлении quality плагина: плагин слушает выполнение quality задач внешних плагинов (pmd, checkstyle, spotbugs и т.д.) и выводит в консоль все найденные ошибки (поскольку сами quality плагины только создают репорты). Использовать дальше doLast было нельзя (причина описана выше) - значит остается только build сервис. Но в сервис не прилетает сама задача - а для генерации отчета в консоли нужен xml файлик репорта.

Еще маленькая особенность: doLast вызывается сразу после выполнения задачи, а build сервис может немного подзадержаться. Обычно эта задержка не критична, все же, хочется чтобы консольный репорт был прям под выполненной задачей а не где-то после. П.э. приходится doLast ��се-таки использовать тоже (для более точного вывода в консоли когда это возможно). Но, так же это значит что, в некоторых случаях, завершение задачи будет вызываться дважды (doLast и сервис), а значит нужно где-то хранить выполненный статус для отмены повторного вывода.

Пришлось использовать трюк, описанный выше: собирать информацию о задачах в фазе конфигурации и кэшировать ее в build сервисе. Закэшировать в переменных было нельзя поскольку build сервису нужно полное знание о задачах.

Упрощенный пример сервиса, хранящего информацию о всех интересных задачах:

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

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

    @Override
    public void onFinish(FinishEvent finishEvent) {
        if (finishEvent instanceof TaskFinishEvent) {

            // берем необходимую информацию о задаче из параметра (используя путь задачи)
            TaskFinishEvent taskEvent = (TaskFinishEvent) finishEvent;
            String taskPath = taskEvent.getDescriptor().getTaskPath();
            TaskDesc desc = getParameters().getValues().get().stream()
                    .filter(it -> it.getPath().equals(taskPath))
                    .findFirst().orElse(null);

            // делаем все что необходимо, зная все о задаче
             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 {
        // значение инициализируется при создании сервиса
        ListProperty<TaskDesc> getValues();
    }
}

TaskDesk это просто value объект (сохраненное состояние задачи):

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;
    }
}

Плагин собирает необходимую информацию в фазе конфигурации и передает в сервис:

public abstract class Sample10Plugin implements Plugin<Project> {

    @Inject
    public abstract BuildEventsListenerRegistry getEventsListenerRegistry();

    // собираем информацию о задачах в поле класса
    private final List<TaskDesc> tasksInfo = new CopyOnWriteArrayList<>();

    @Override
    public void apply(Project project) {
        Provider<Service> service = project.getGradle().getSharedServices()
                .registerIfAbsent("service", Service.class, spec -> {
                    System.out.println("Service configured: " + tasksInfo);
                    spec.getParameters().getValues().value(tasksInfo);
                });

        getEventsListenerRegistry().onTaskCompletion(service);

        // какие-то задачи которые нужно слушать
        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()));
    }
}

Создание кэша
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.

Несмотря на странность с блоком инициализации сервиса, все работает как запланировано.
Обратите внимание когда вызывается сервис!

Работа их кэша
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.

Поскольку задача была не кэшируемой, при работе из кэша тоже зовется doLast у задач, но, как можно видеть, листенер видит изменения состояния объетка (т.е. и в задаче и в сервисе используется один и тот же объект).

Вот теперь точно с кэшем все! И можно было бы на этом закончить, но есть еще пару "не относящихся к делу" моментов, в которые, естественно, влетел сам и о которых лучше помнить.

Много-модульные проекты

Configuration cache может заставить использовать build сервисы, но важно помнить что проект может быть много-модульным: в этом случае в каждом модуле будет свой инстанс плагина! А вот сервис может быть глобальным.

Применим плагин из примера про инициализацию сервиса в много-модульном проекте:

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

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

Каждый модуль создаст свой ��кземпляр плагина, со своим внутренним состоянием и трюк с инициализацией глобального сервиса перестанет работать:

    @Override
    public void apply(Project project) {
        // в мульти-модульном проекте каждый плагин создаст такой список
        final List<String> values = new ArrayList<>();

        // но сервис то глобальный - инициализируется только раз!
        Provider<Service> service = project.getGradle().getSharedServices().registerIfAbsent(
                "service", Service.class, spec -> {
                    spec.getParameters().getValues().value(values);
                });

        ...
    }

Только один плагин передаст накопленное состояние в инициализированный сервис (кто успеет) - остальные данные будут просто потеряны!

Выход достаточно прост: пусть каждый плагин создает свой собственный сервис (главное - уникальное имя сервиса):

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

Например, в модуле sub имя сервиса будет servicesub.

И, конечно, проблем с глобальным сервисом не будет без необходимости (со)хранить в нем состояние.

Разные classpath

Еще один неприятный момент с глобальным сервисом может случиться если плагин объявляется в разных модулях независимо:

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

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

В этом случае один и тот-же плагин будет загружен в разных classpath и, при объявлении глобального сервиса в одном модуле, второй плагин не сможет его использовать: "class cannot be cast to class".

Справедливости ради, в большинстве случаев плагины определяются в корневом модуле (используя apply false) для унификации версии, и, в таком случае, classpath будет один и проблем с глобальным сервисом не будет:

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

subrojects("sub1") {
    apply plugin: 'com.mycompany.myplugin'
}

subrojects("sub2") {
    apply plugin: 'com.mycompany.myplugin'
}

Jococo

При тестировании configuration cache TestKit-тестами с включенным jococo плагином (для измерения покрытия), gradle будет всегда выдавать дополнительную ошибку:

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

Но ничего страшного в этом нет - просто проверяем что ошибка всего одна:

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:"
);

Тесты в репозитории не используют такую проверку потому что запускаются без jococo, но в реальных проектах приходится пользоваться.

Выводы

А выводы не изменились с первой части:

  1. Используйте переменные чтобы сузить сериализацию

  2. Провайдер по��воляет использовать что угодно в рантайме (да, он выполнится в фазе конфигурации, но это, как правило, уже не важно)

  3. ValueSource позволяет избежать кэширования

В большинстве случаев, достаточно дать gradle закэшировать состояние в переменной или провайдере, а не пытаться восстанавливать состояние при работе из кэша.

Сервисы безопаснее использовать не глобальные.

То что нельзя использовать в рантайме прекрасно описано в документации. Единственный раз когда мне встретился не описанный случай был project.getAntBuilder() в задаче. В groovy плагине решением было использование groovy AntBuilder (не тот что предоставляет gradle!) напрямую.
Ограничения на рантайм объекты связаны с их внутренним состоянием (которое gradle попытается сериализовать). Тем не менее, это не значит что "все нельзя" - если что-то не описано, всегда можно проверить - вдруг сработает.

Надеюсь было полезно и сэкономит время при миграции плагинов.

Используйте репозиторий с примерами для своих экспериментов (проверки теорий) — я, честно, пожалел что не начал с этого — избежал бы кучи диких теорий.

Если вдруг в комментариях будет эксперт по кэшу — было бы очень интересно послушать как кэш устроен внутри (особенно как анализируются рантайм блоки в плагине).