From e3c21b5b26a94a3122f9cbcf2fa7cbf6849e291a Mon Sep 17 00:00:00 2001 From: Nouran Atef Date: Sat, 12 Jul 2025 04:05:30 +0200 Subject: [PATCH 1/4] Create Unlocking Configuration Cache with GSoC Contributor --- .../Unlocking Configuration Cache with GSoC Contributor | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/plugin-development/configuration-cache/Unlocking Configuration Cache with GSoC Contributor diff --git a/docs/plugin-development/configuration-cache/Unlocking Configuration Cache with GSoC Contributor b/docs/plugin-development/configuration-cache/Unlocking Configuration Cache with GSoC Contributor new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/plugin-development/configuration-cache/Unlocking Configuration Cache with GSoC Contributor @@ -0,0 +1 @@ + From b891e1a76382b59f628b033210172251fb1cd6b2 Mon Sep 17 00:00:00 2001 From: Nouran Atef Date: Tue, 15 Jul 2025 02:45:41 +0200 Subject: [PATCH 2/4] Update and rename Unlocking Configuration Cache with GSoC Contributor to unlocking-config-cache-gsoc --- ... Configuration Cache with GSoC Contributor | 1 - .../unlocking-config-cache-gsoc | 349 ++++++++++++++++++ 2 files changed, 349 insertions(+), 1 deletion(-) delete mode 100644 docs/plugin-development/configuration-cache/Unlocking Configuration Cache with GSoC Contributor create mode 100644 docs/plugin-development/configuration-cache/unlocking-config-cache-gsoc diff --git a/docs/plugin-development/configuration-cache/Unlocking Configuration Cache with GSoC Contributor b/docs/plugin-development/configuration-cache/Unlocking Configuration Cache with GSoC Contributor deleted file mode 100644 index 8b13789..0000000 --- a/docs/plugin-development/configuration-cache/Unlocking Configuration Cache with GSoC Contributor +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/plugin-development/configuration-cache/unlocking-config-cache-gsoc b/docs/plugin-development/configuration-cache/unlocking-config-cache-gsoc new file mode 100644 index 0000000..c64d039 --- /dev/null +++ b/docs/plugin-development/configuration-cache/unlocking-config-cache-gsoc @@ -0,0 +1,349 @@ +# Unlocking Configuration Cache +## Reflections from a GSoC Contributor + +--- + +> A hands-on journey into Gradle’s Configuration Cache , lessons learned, obstacles tackled, and contributions made during Google Summer of Code. + +--- + +## Overview + +Welcome! This document details my journey improving Configuration Cache compatibility in Gradle plugins as a Google Summer of Code contributor. My goal is to offer a practical reference for plugin developers, Gradle users, and future GSoC applicants. + +***To demonstrate how to add this compatibility, we'll use my work on the gradle-lint-plugin as a real-world example of refactoring a plugin that relies heavily on the Project object.*** + +# LintGradleTask + +The Problem: Accessing project at Execution Time +This is violate the Configuration Cache rules in a few key places: + +1. Direct project access in the task action: The line ```new LintService().lint(project, ...) ```inside the @TaskAction directly used the project object. + +2. Passing project to services: The GradleLintPatchAction and GradleLintInfoBrokerAction were created in the constructor with a direct reference to the project object, which they held onto until the task executed. + +``` +@TaskAction + void lint() { + //TODO: address Invocation of Task.project at execution time has been deprecated. + DeprecationLogger.whileDisabled { + def violations = new LintService().lint(project, onlyCriticalRules.get()).violations + .unique { v1, v2 -> v1.is(v2) ? 0 : 1 } + + (getListeners() + new GradleLintPatchAction(project) + new GradleLintInfoBrokerAction(project) + consoleOutputAction).each { + it.lintFinished(violations) + } + } + } +``` + + +# Solution: + +The Configuration Cache works by recording the state of all tasks after the configuration phase and saving it. When you run the build again, Gradle can restore this saved state instead of re-configuring the entire project, leading to significant performance gains. This process fails if a task holds a direct reference to the live Project model, which is not serializable. + +We solved this by creating simple, serializable data containers `ProjectInfo and ProjectTree` that act as a "projection" of the data we need from the Project object. + +Here’s a breakdown of the key changes and the "why" behind them: + +1) Create Serializable Data Containers (ProjectInfo & ProjectTree) +The first step was to define classes that could hold the project data we needed, but in a simple, serializable way. + +`ProjectInfo`: It stores primitive data like name, path, File objects, and serializable collections like Map. It safely carry information from the configuration phase to the execution phase. + +`ProjectTree`: This class holds a list of ProjectInfo objects, representing the entire project structure needed for the linting process. It is also serializable. + +**Learning**: If you need data from the Project object during execution, create a dedicated, Serializable class to hold that data. + + + +``` +/** + * A CC-compatible projection of project data. + */ +class ProjectInfo implements Serializable{ + String name + String path + File rootDir + File buildFile + File projectDir + File buildDirectory + GradleLintExtension extension + Map properties + Supplier projectSupplier + + static ProjectInfo from(Task task, Project subproject) { + String subprojectPath = subproject.path + return build(subproject, { task.project.project(subprojectPath) }) + } + + static ProjectInfo from(Task task) { + return build(task.project, task::getProject) + } + + @VisibleForTesting + private static ProjectInfo build(Project project, Supplier projectSupplier) { + GradleLintExtension extension = + project.extensions.findByType(GradleLintExtension) ?: + project.rootProject.extensions.findByType(GradleLintExtension) + Map properties = [:] + if (project.hasProperty('gradleLint.rules')) { + properties['gradleLint.rules'] = project.property('gradleLint.rules') + } + if (project.hasProperty('gradleLint.excludedRules')) { + properties['gradleLint.excludedRules'] = project.property('gradleLint.excludedRules') + } + + return new ProjectInfo( + name:project.name, + path:project.path, + rootDir:project.rootDir, + buildFile: project.buildFile, + projectDir:project.projectDir, + extension: extension, + properties: properties, + projectSupplier: projectSupplier, + buildDirectory : project.buildDir + ) + + } +} + +class ProjectTree{ + List allProjects + + ProjectTree(List allProjects){ + this.allProjects = allProjects + } + + /** + * Returns the base project this tree was built from. + */ + ProjectInfo getBaseProject() { + return allProjects.head() + } + + /** + * Build a project tree based on the given task's project. + * + * @return a project tree reflecting information and the structure of the given task's project + */ + static from(Task task) { + def baseProject = task.project + List projectInfos = [ProjectInfo.from(task)] + baseProject.subprojects.collect { Project p -> ProjectInfo.from(task, p) } + return new ProjectTree(projectInfos) + } +} +``` +2. Capture Project Data During the Configuration Phase +The task constructor is part of the configuration phase, so it's the perfect place to safely access the project object and extract our data. We use Gradle's Provider API to do this lazily. + +``` +LintGradleTask() { + failOnWarning.convention(false) + onlyCriticalRules.convention(false) + projectTree.set(project.provider {ProjectTree.from(this) }) + projectInfo.convention(projectTree.map(ProjectTree::getBaseProject)) + projectRootDir.set(project.rootDir) + infoBrokerAction = new GradleLintInfoBrokerAction(this) + patchAction = new GradleLintPatchAction(getProjectInfo().get()) + group = 'lint' + } +``` + +``` project.provider { ... }```: This creates a Provider. The code inside the closure is executed by Gradle during the configuration phase. It accesses the project and creates our serializable ProjectTree. + +```.set(...)```: We set this Provider as the value for our projectTree property. + +```.map(...)```: We then create a derived provider for the projectInfo by transforming the result of the projectTree provider. + +**Learning**: Use the Provider API ```(provider { ... }, map)``` inside your task's constructor to capture and transform project data without breaking task-configuration avoidance. + +# LintService +We transform LintService from a class deeply integrated with Gradle's live project model into a stateless service. The new design makes it compatible with the Configuration Cache by ensuring it operates exclusively on simple, serializable data during the task's execution phase. + +## The Problem: A service that is associated with the Project instance +The [original LintService](https://github.com/Nouran-11/gradle-lint-plugin/blob/main/src/main/groovy/com/netflix/nebula/lint/plugin/LintService.groovy) was not compatible with the Configuration Cache because its methods required a live Project object as a parameter. + +Methods like ``` lint(Project project, ...)``` and ``` ruleSetForProject(Project p, ...) ``` directly manipulated Project objects. + +It would read configuration dynamically by calling ``` p.extensions.getByType(...) ``` and ``` p.property(...)```. + +This design meant the service could only function when connected to the live Gradle build model, making it impossible for Gradle to serialize the task that uses it. + +## The Solution: A stateless service using pre-existing configuration data from the Project object +[Updated LintService Full Code](https://github.com/Nouran-11/gradle-lint-plugin/blob/fixing-cc-nebula/src/main/groovy/com/netflix/nebula/lint/plugin/LintService.groovy) + +The refactored LintService is now completely decoupled from the Project object at execution time. It operates like a pure function: it receives all the data it needs as input (ProjectTree and ProjectInfo) and produces a result. + +1. Accepting Data Projections Instead of Project Objects +The most significant change is in the method signatures. + +Before:``` lint(Project project, ...)``` + +After:``` lint(ProjectTree projectTree, ...)``` + +The service no longer receives the Project object. Instead, it gets the ProjectTree ,our serializable snapshot of the entire project structure. All subsequent operations, like iterating over subprojects, are done using this simple data object ```(projectTree.allProjects.each { ... })```. + +2. Reading Configuration from Snapshots +The service now gets all its configuration from the ProjectInfo data transfer object. + +Before: It actively queried the project for properties and extensions: ```p.hasProperty(...)``` or ```p.extensions.getByType(...)```. + +After: It passively reads pre-extracted data from the input: ```projectInfo.properties[...]``` and ```projectInfo.extension```. + +This ensures the service doesn't need to communicate with the live build model. All the decisions were already made during the configuration phase, and the results were stored in ProjectInfo. + +3. Isolating Operations That Require the Live Model +The new code cleverly handles rules that must access the live project model **ModelAwareGradleLintRule**. + +It first checks if any such rule is present ```(if (containsModelAwareRule))```. + +Only if it's true does it use the ```supplier (Project project = p.projectSupplier.get())``` to re-acquire the Project object **when it really needed**. + +**This is an advanced pattern that minimizes the impact on caching. The non-cache-friendly code path is isolated and only executed when absolutely necessary.** + +**Learning**: Services called by a cacheable task must also be cache-friendly. They should be designed to be stateless, receiving all necessary information through their method arguments and avoiding any direct interaction with the Project object during the execution phase. + +# LintRuleRegistry +## The Problem: Directly Injecting the Project Object + + +The [original LintRuleRegistery ](https://github.com/Nouran-11/gradle-lint-plugin/blob/main/src/main/groovy/com/netflix/nebula/lint/plugin/LintRuleRegistry.groovy) created a direct and hard dependency on the Project object, which is a blocker for the Configuration Cache. + +The buildRules method took a Project as a parameter and, for certain rules, assigned it directly to a field: + +```(r as ModelAwareGradleLintRule).project = project``` + +This meant that any ModelAwareGradleLintRule instance created by the registry held a reference to the non-serializable Project object, making it impossible for Gradle to cache any task that used this registry. + +## The Solution: Lazily Access Project with a Supplier when it needed +[Updated LintRuleRegistery ](https://github.com/Nouran-11/gradle-lint-plugin/blob/fixing-cc-nebula/src/main/groovy/com/netflix/nebula/lint/plugin/LintRuleRegistry.groovy) +The solution was to stop passing the Project object itself and instead pass a ```Supplier```a lightweight, serializable object that knows how to get the Project object later. + +***Injecting the Supplier, Not the Project*** +A new, primary buildRules method was introduced with a new signature. + +Before: ```buildRules(String ruleId, Project project, ...)``` + +After:``` buildRules(String ruleId, Supplier projectSupplier, ...)``` + +The critical line inside was changed to inject this new supplier: + +```(r as ModelAwareGradleLintRule).projectSupplier = projectSupplier``` + +***This is the core of the fix. Instead of giving the rule the Project object directly, we are now giving it a "recipe" to get the project if and when it actually needs it. The Supplier is serializable, so the entire process becomes cache-friendly.*** + +***Learning :*** Object creation logic is a common source of Configuration Cache issues. When an object you are creating needs access to the Project model, inject a Supplier instead of the Project object itself. This defers the access and allows the object and its creation process to be serializable. + +## Guiding Principle for the Remaining Classes +The rest of the refactoring follows a single, consistent rule: Any class used during the task's execution phase must be decoupled from the Project object. + +This is achieved by applying one of two patterns: + +* Operating on Data (ProjectInfo): If a class needs to read data from the project, its methods are changed to accept a ProjectInfo or ProjectTree object as a parameter. All internal logic is then updated to read from this serializable data instead of the live Project object. + +* Deferring Access (Supplier): If a class absolutely must interact with the live project model (the "escape hatch" scenario), it's given a Supplier. This allows it to re-acquire the Project object on-demand, a process that is compatible with the Configuration Cache. + + +* [ Original Source Code (Before Changes): ](https://github.com/Nouran-11/gradle-lint-plugin) + +* [Updated Source Code (After CC Compatibility Changes): ](https://github.com/Nouran-11/gradle-lint-plugin) + +# Technical Notes +## findByType() vs. getByType() +When working with Gradle extensions, there are two common methods to retrieve an extension by its class: ```findByType()``` and ```getByType()```. The key difference lies in how they behave when an extension is not found. + +```getByType(Class type)``` + +***Returns***: The extension instance if it exists. + +***Throws***: An UnknownDomainObjectException if the extension does not exist. + +***Use this when you consider the extension's presence mandatory for your plugin's logic to proceed.*** + +```findByType(Class type)``` + +***Returns :*** The extension instance if it exists. + +***Returns :*** null if the extension does not exist. + +***Use this when the extension is optional, and your code needs to safely handle cases where it might be missing.*** + +## Notes on Gradle TestKit +For those new to testing Gradle plugins, the Gradle TestKit is an essential tool. It allows you to run real builds as part of your tests, giving you confidence that your plugin behaves correctly for end-users. + +I started by learning from the [official TestKit documentation ](https://docs.gradle.org/current/userguide/test_kit.html) and by studying the extensive examples in the nebula-lint-plugin's existing test suite. I also found the talk ["Testing the Build with Testkit"](https://www.youtube.com/watch?v=P7SvwkRXjSU) to be very helpful for understanding the different types of tests. + +Here are some key takeaways: + +Types of Tests: + +***Unit Tests:*** + +* Focus on a single class in isolation. + +* The class under test does not use the Gradle API. + +***Integration Tests:*** + +* The class(es) under test use Gradle APIs, often interacting with a Project instance. + +* These do not execute a full build but test the integration with Gradle's model. + +***Functional Tests (The focus of TestKit):*** + +* Executes a build script from an end-user's perspective. + +* Examines the build outcome, output, and any created files. + +* Runs in a fully isolated test environment. + +The Structure of a TestKit Test +Most functional tests follow a clear "Given-When-Then" structure, which makes them easy to read and understand: + +***given block:*** Sets up the test environment. This is where you create a temporary project directory and write the build.gradle or other files needed for the test. + +***when block:*** Executes a Gradle task using the GradleRunner. This is where you run the build (e.g., ./gradlew lintGradle). + +***then block:*** Verifies the outcome. Here, you use assertions to check if the build succeeded or failed, inspect the build output, or verify that files were created or modified as expected. + +--- + +***The following notes serve as a technical blueprint for completing the Configuration Cache compatibility work in the Nebula Lint plugin or for tackling similar challenges in other Gradle plugins. This strategic plan breaks down the complex problem into manageable steps.*** + +***1. Understanding the Challenge*** +What the Plugin Does: The plugin works by building an Abstract Syntax Tree (AST) of a build script and then traversing it with a set of rules. + +***The Core Issue:*** Many of these rules rely on querying Project.configurations at runtime to analyze dependency setups. This dynamic querying is precisely what breaks the Gradle Configuration Cache, which forbids accessing the live Project model during the execution phase. + +***2. Defining the Goal*** +The objective is to refactor the plugin so that rules operate on pre-computed, serializable data instead of the live Project object. + +* Separate Configuration Queries: Decouple rules from Project.configurations by having them work with a "data projection"—a snapshot of the configuration data taken at the right time. + +* Enable Configuration-Time Setup: Move all data gathering to Gradle's configuration phase, avoiding runtime queries entirely. + +* Refactor Incrementally: Start with simpler, purely syntactical rules (like SpaceAssignmentRule and DependencyParenthesesRule) to build momentum and prove the approach. + +***3. The Step-by-Step Plan*** + +* Identify Configuration Usage: First, examine each rule to understand how it interacts with Project.configurations. Does it read dependencies? Resolve configurations? Check attributes? + +* Extract Configuration Data: Create a simple data object (e.g., ConfigurationInfo) to hold only the necessary information. This data should be computed once during the configuration phase using Gradle's Provider API. + +* Simplify Rule Dependencies: Refactor the rules to depend on the new ConfigurationInfo data object instead of accessing Project.configurations directly. + +* Modify the Data Flow: Enhance the main ProjectInfo data object to include this new ConfigurationInfo structure, making it available to all rules that need it. + +* Test Incrementally: After refactoring each rule, test it thoroughly with various build scripts to ensure its original functionality remains intact and that it no longer violates Configuration Cache rules. + +By following this iterative plan, anyone can progressively improve a plugin’s compatibility with the Gradle Configuration Cache while ensuring it remains stable and functional. + + +## A Heartfelt Thank You +A sincere thank you to the Google Summer of Code program for this experience. I am especially grateful to my mentors for their exceptional support and guidance throughout the project. + +

+ Written by Nouran Atef, GSoC 2025 Contributor @Gradle +

From 7f7893ca7a49e98b312d310ec155478b3b294a82 Mon Sep 17 00:00:00 2001 From: Nouran Atef Date: Wed, 27 Aug 2025 11:30:59 +0300 Subject: [PATCH 3/4] Configuration cache example from liquibase Plugin --- .../unlocking-config-cache-gsoc | 410 ++++-------------- 1 file changed, 92 insertions(+), 318 deletions(-) diff --git a/docs/plugin-development/configuration-cache/unlocking-config-cache-gsoc b/docs/plugin-development/configuration-cache/unlocking-config-cache-gsoc index c64d039..cfe410f 100644 --- a/docs/plugin-development/configuration-cache/unlocking-config-cache-gsoc +++ b/docs/plugin-development/configuration-cache/unlocking-config-cache-gsoc @@ -1,349 +1,123 @@ -# Unlocking Configuration Cache -## Reflections from a GSoC Contributor +# Enabling Gradle Configuration Cache Support ---- - -> A hands-on journey into Gradle’s Configuration Cache , lessons learned, obstacles tackled, and contributions made during Google Summer of Code. - ---- - -## Overview - -Welcome! This document details my journey improving Configuration Cache compatibility in Gradle plugins as a Google Summer of Code contributor. My goal is to offer a practical reference for plugin developers, Gradle users, and future GSoC applicants. - -***To demonstrate how to add this compatibility, we'll use my work on the gradle-lint-plugin as a real-world example of refactoring a plugin that relies heavily on the Project object.*** - -# LintGradleTask - -The Problem: Accessing project at Execution Time -This is violate the Configuration Cache rules in a few key places: - -1. Direct project access in the task action: The line ```new LintService().lint(project, ...) ```inside the @TaskAction directly used the project object. - -2. Passing project to services: The GradleLintPatchAction and GradleLintInfoBrokerAction were created in the constructor with a direct reference to the project object, which they held onto until the task executed. - -``` -@TaskAction - void lint() { - //TODO: address Invocation of Task.project at execution time has been deprecated. - DeprecationLogger.whileDisabled { - def violations = new LintService().lint(project, onlyCriticalRules.get()).violations - .unique { v1, v2 -> v1.is(v2) ? 0 : 1 } - - (getListeners() + new GradleLintPatchAction(project) + new GradleLintInfoBrokerAction(project) + consoleOutputAction).each { - it.lintFinished(violations) - } - } - } -``` +## 1. Summary +This page outlines the changes made to the Liquibase Gradle plugin to add support for Gradle's **Configuration Cache**. The primary goal was to improve build performance by making the plugin's tasks compatible with this powerful Gradle feature. -# Solution: +The core of the work involved refactoring the `LiquibaseTask` and its helper classes to **decouple the task execution logic from the Gradle `Project` model**. This ensures that the task's configuration can be cached and reused across builds, leading to significantly faster development cycles. -The Configuration Cache works by recording the state of all tasks after the configuration phase and saving it. When you run the build again, Gradle can restore this saved state instead of re-configuring the entire project, leading to significant performance gains. This process fails if a task holds a direct reference to the live Project model, which is not serializable. +--- -We solved this by creating simple, serializable data containers `ProjectInfo and ProjectTree` that act as a "projection" of the data we need from the Project object. +## 2. The Problem: Configuration Cache Incompatibility -Here’s a breakdown of the key changes and the "why" behind them: +Gradle's Configuration Cache is a feature that dramatically speeds up builds by serializing the task graph and reusing it for subsequent builds. This allows Gradle to skip the expensive "configuration" phase entirely. -1) Create Serializable Data Containers (ProjectInfo & ProjectTree) -The first step was to define classes that could hold the project data we needed, but in a simple, serializable way. +For the cache to work, a critical rule must be followed: **tasks must not access the `Project` object or related services during their execution phase**. The `Project` model is not serializable and is only available during configuration. -`ProjectInfo`: It stores primitive data like name, path, File objects, and serializable collections like Map. It safely carry information from the configuration phase to the execution phase. +The previous implementation of the plugin violated this rule in several ways: -`ProjectTree`: This class holds a list of ProjectInfo objects, representing the entire project structure needed for the linting process. It is also serializable. +* **Direct Project Access in `LiquibaseTask`:** The main task action (`@TaskAction`) directly accessed the project's `liquibase` extension to get its configuration at execution time. + ```groovy + // Violation inside LiquibaseTask's execution logic: + def activities = project.liquibase.activities + def runList = project.liquibase.runList + jvmArgs(project.liquibase.jvmArgs) + ``` +* **Project Dependency in `ArgumentBuilder`:** The `ArgumentBuilder` class, used during task execution, held a reference to the `project` and used it to access project properties, the logger, and the build directory. + ```groovy + // Violations inside ArgumentBuilder: + project.properties.findAll { ... } + commandArguments += "--output-directory=${project.buildDir}/database/docs" + ``` +* **Runtime Configuration Resolution:** The task resolved its `liquibaseRuntime` classpath inside its execution logic instead of during the configuration phase. + ```groovy + // Violation inside LiquibaseTask's execution logic: + def classpath = project.configurations.getByName(LiquibasePlugin.LIQUIBASE_RUNTIME_CONFIGURATION) + ``` -**Learning**: If you need data from the Project object during execution, create a dedicated, Serializable class to hold that data. +These violations made the plugin incompatible with the configuration cache, forcing Gradle to reconfigure the project on every single run and preventing users from benefiting from the potential performance gains. +--- +## 3. The Solution: Decoupling Execution from Configuration -``` -/** - * A CC-compatible projection of project data. - */ -class ProjectInfo implements Serializable{ - String name - String path - File rootDir - File buildFile - File projectDir - File buildDirectory - GradleLintExtension extension - Map properties - Supplier projectSupplier +The solution was to refactor the plugin to adopt a pattern where all necessary information is gathered from the `Project` during the **configuration phase** and passed into the task as **serializable inputs**. The task's execution logic then operates only on these inputs, with no knowledge of the `Project` object. - static ProjectInfo from(Task task, Project subproject) { - String subprojectPath = subproject.path - return build(subproject, { task.project.project(subprojectPath) }) - } +### Step 1: Introduce Serializable Data Transfer Objects (DTOs) - static ProjectInfo from(Task task) { - return build(task.project, task::getProject) - } +Two new classes were created to act as simple, serializable data containers: - @VisibleForTesting - private static ProjectInfo build(Project project, Supplier projectSupplier) { - GradleLintExtension extension = - project.extensions.findByType(GradleLintExtension) ?: - project.rootProject.extensions.findByType(GradleLintExtension) - Map properties = [:] - if (project.hasProperty('gradleLint.rules')) { - properties['gradleLint.rules'] = project.property('gradleLint.rules') - } - if (project.hasProperty('gradleLint.excludedRules')) { - properties['gradleLint.excludedRules'] = project.property('gradleLint.excludedRules') +1. **`LiquibaseInfo`**: This class is responsible for carrying project-level information that the `ArgumentBuilder` needs. +* *See the new class file: [`LiquibaseInfo.groovy`]([lhttps://github.com/Nouran-11/liquibase-gradle-plugin/blob/fix-cc/src/main/groovy/org/liquibase/gradle/LiquibaseInfo.groovy$0])* + ```groovy + // org/liquibase/gradle/LiquibaseInfo.groovy + class LiquibaseInfo { + Logger logger + File buildDir + Map liquibaseProperties + + static LiquibaseInfo fromProject(Project project) { + // ... logic to extract properties from the project ... + return new LiquibaseInfo(project.logger, project.buildDir, liquibaseProperties) } - - return new ProjectInfo( - name:project.name, - path:project.path, - rootDir:project.rootDir, - buildFile: project.buildFile, - projectDir:project.projectDir, - extension: extension, - properties: properties, - projectSupplier: projectSupplier, - buildDirectory : project.buildDir - ) - - } -} - -class ProjectTree{ - List allProjects - - ProjectTree(List allProjects){ - this.allProjects = allProjects - } - - /** - * Returns the base project this tree was built from. - */ - ProjectInfo getBaseProject() { - return allProjects.head() } - - /** - * Build a project tree based on the given task's project. - * - * @return a project tree reflecting information and the structure of the given task's project - */ - static from(Task task) { - def baseProject = task.project - List projectInfos = [ProjectInfo.from(task)] + baseProject.subprojects.collect { Project p -> ProjectInfo.from(task, p) } - return new ProjectTree(projectInfos) + ``` +2. **`ProjectInfo`**: This class holds configuration from the `liquibase { ... }` extension block. +* *See the new class file: [`ProjectInfo.groovy`]([https://github.com/Nouran-11/liquibase-gradle-plugin/blob/fix-cc/src/main/groovy/org/liquibase/gradle/ProjectInfo.groovy$0])* + ```groovy + // org/liquibase/gradle/ProjectInfo.groovy + class ProjectInfo { + List activities + String runList + List jvmArgs + + static ProjectInfo fromProject(Project project) { + // ... logic to extract data from project.liquibase extension ... + return new ProjectInfo(activities, runList, jvmArgs) + } } + ``` +The key feature of these classes is the static `fromProject()` factory method. This method acts as the bridge, safely extracting data at configuration time. + +### Step 2: Refactor `ArgumentBuilder` to be Stateless + +The `ArgumentBuilder` was refactored to remove its dependency on the `Project` object. Instead of holding a reference to the project, its methods now require a `LiquibaseInfo` object to be passed in. +* *See the full class changes: **[`ArgumentBuilder.groovy`]([https://github.com/liquibase/liquibase-gradle-plugin/blob/master/src/main/groovy/org/liquibase/gradle/ArgumentBuilder.groovy$0])** -> **[`ArgumentBuilder.groovy`]([https://github.com/Nouran-11/liquibase-gradle-plugin/blob/fix-cc/src/main/groovy/org/liquibase/gradle/ArgumentBuilder.groovy$0])*** +**Before:** +```groovy +// ArgumentBuilder.groovy +def buildLiquibaseArgs(Activity activity, commandName, supportedCommandArguments) { + // ... code that used internal `project` reference ... } ``` -2. Capture Project Data During the Configuration Phase -The task constructor is part of the configuration phase, so it's the perfect place to safely access the project object and extract our data. We use Gradle's Provider API to do this lazily. - -``` -LintGradleTask() { - failOnWarning.convention(false) - onlyCriticalRules.convention(false) - projectTree.set(project.provider {ProjectTree.from(this) }) - projectInfo.convention(projectTree.map(ProjectTree::getBaseProject)) - projectRootDir.set(project.rootDir) - infoBrokerAction = new GradleLintInfoBrokerAction(this) - patchAction = new GradleLintPatchAction(getProjectInfo().get()) - group = 'lint' - } +**After:** +```groovy +// ArgumentBuilder.groovy +def buildLiquibaseArgs(Activity activity, commandName, supportedCommandArguments, LiquibaseInfo liquibaseInfo) { + // ... code now uses liquibaseInfo.logger, liquibaseInfo.buildDir, etc. ... +} ``` +## Step 3: Update LiquibaseTask to Use Inputs -``` project.provider { ... }```: This creates a Provider. The code inside the closure is executed by Gradle during the configuration phase. It accesses the project and creates our serializable ProjectTree. - -```.set(...)```: We set this Provider as the value for our projectTree property. - -```.map(...)```: We then create a derived provider for the projectInfo by transforming the result of the projectTree provider. - -**Learning**: Use the Provider API ```(provider { ... }, map)``` inside your task's constructor to capture and transform project data without breaking task-configuration avoidance. - -# LintService -We transform LintService from a class deeply integrated with Gradle's live project model into a stateless service. The new design makes it compatible with the Configuration Cache by ensuring it operates exclusively on simple, serializable data during the task's execution phase. - -## The Problem: A service that is associated with the Project instance -The [original LintService](https://github.com/Nouran-11/gradle-lint-plugin/blob/main/src/main/groovy/com/netflix/nebula/lint/plugin/LintService.groovy) was not compatible with the Configuration Cache because its methods required a live Project object as a parameter. - -Methods like ``` lint(Project project, ...)``` and ``` ruleSetForProject(Project p, ...) ``` directly manipulated Project objects. - -It would read configuration dynamically by calling ``` p.extensions.getByType(...) ``` and ``` p.property(...)```. - -This design meant the service could only function when connected to the live Gradle build model, making it impossible for Gradle to serialize the task that uses it. - -## The Solution: A stateless service using pre-existing configuration data from the Project object -[Updated LintService Full Code](https://github.com/Nouran-11/gradle-lint-plugin/blob/fixing-cc-nebula/src/main/groovy/com/netflix/nebula/lint/plugin/LintService.groovy) - -The refactored LintService is now completely decoupled from the Project object at execution time. It operates like a pure function: it receives all the data it needs as input (ProjectTree and ProjectInfo) and produces a result. - -1. Accepting Data Projections Instead of Project Objects -The most significant change is in the method signatures. - -Before:``` lint(Project project, ...)``` - -After:``` lint(ProjectTree projectTree, ...)``` - -The service no longer receives the Project object. Instead, it gets the ProjectTree ,our serializable snapshot of the entire project structure. All subsequent operations, like iterating over subprojects, are done using this simple data object ```(projectTree.allProjects.each { ... })```. - -2. Reading Configuration from Snapshots -The service now gets all its configuration from the ProjectInfo data transfer object. - -Before: It actively queried the project for properties and extensions: ```p.hasProperty(...)``` or ```p.extensions.getByType(...)```. - -After: It passively reads pre-extracted data from the input: ```projectInfo.properties[...]``` and ```projectInfo.extension```. - -This ensures the service doesn't need to communicate with the live build model. All the decisions were already made during the configuration phase, and the results were stored in ProjectInfo. - -3. Isolating Operations That Require the Live Model -The new code cleverly handles rules that must access the live project model **ModelAwareGradleLintRule**. - -It first checks if any such rule is present ```(if (containsModelAwareRule))```. - -Only if it's true does it use the ```supplier (Project project = p.projectSupplier.get())``` to re-acquire the Project object **when it really needed**. - -**This is an advanced pattern that minimizes the impact on caching. The non-cache-friendly code path is isolated and only executed when absolutely necessary.** - -**Learning**: Services called by a cacheable task must also be cache-friendly. They should be designed to be stateless, receiving all necessary information through their method arguments and avoiding any direct interaction with the Project object during the execution phase. - -# LintRuleRegistry -## The Problem: Directly Injecting the Project Object - - -The [original LintRuleRegistery ](https://github.com/Nouran-11/gradle-lint-plugin/blob/main/src/main/groovy/com/netflix/nebula/lint/plugin/LintRuleRegistry.groovy) created a direct and hard dependency on the Project object, which is a blocker for the Configuration Cache. - -The buildRules method took a Project as a parameter and, for certain rules, assigned it directly to a field: - -```(r as ModelAwareGradleLintRule).project = project``` - -This meant that any ModelAwareGradleLintRule instance created by the registry held a reference to the non-serializable Project object, making it impossible for Gradle to cache any task that used this registry. - -## The Solution: Lazily Access Project with a Supplier when it needed -[Updated LintRuleRegistery ](https://github.com/Nouran-11/gradle-lint-plugin/blob/fixing-cc-nebula/src/main/groovy/com/netflix/nebula/lint/plugin/LintRuleRegistry.groovy) -The solution was to stop passing the Project object itself and instead pass a ```Supplier```a lightweight, serializable object that knows how to get the Project object later. - -***Injecting the Supplier, Not the Project*** -A new, primary buildRules method was introduced with a new signature. - -Before: ```buildRules(String ruleId, Project project, ...)``` - -After:``` buildRules(String ruleId, Supplier projectSupplier, ...)``` - -The critical line inside was changed to inject this new supplier: - -```(r as ModelAwareGradleLintRule).projectSupplier = projectSupplier``` - -***This is the core of the fix. Instead of giving the rule the Project object directly, we are now giving it a "recipe" to get the project if and when it actually needs it. The Supplier is serializable, so the entire process becomes cache-friendly.*** - -***Learning :*** Object creation logic is a common source of Configuration Cache issues. When an object you are creating needs access to the Project model, inject a Supplier instead of the Project object itself. This defers the access and allows the object and its creation process to be serializable. - -## Guiding Principle for the Remaining Classes -The rest of the refactoring follows a single, consistent rule: Any class used during the task's execution phase must be decoupled from the Project object. - -This is achieved by applying one of two patterns: - -* Operating on Data (ProjectInfo): If a class needs to read data from the project, its methods are changed to accept a ProjectInfo or ProjectTree object as a parameter. All internal logic is then updated to read from this serializable data instead of the live Project object. - -* Deferring Access (Supplier): If a class absolutely must interact with the live project model (the "escape hatch" scenario), it's given a Supplier. This allows it to re-acquire the Project object on-demand, a process that is compatible with the Configuration Cache. - - -* [ Original Source Code (Before Changes): ](https://github.com/Nouran-11/gradle-lint-plugin) - -* [Updated Source Code (After CC Compatibility Changes): ](https://github.com/Nouran-11/gradle-lint-plugin) - -# Technical Notes -## findByType() vs. getByType() -When working with Gradle extensions, there are two common methods to retrieve an extension by its class: ```findByType()``` and ```getByType()```. The key difference lies in how they behave when an extension is not found. - -```getByType(Class type)``` - -***Returns***: The extension instance if it exists. - -***Throws***: An UnknownDomainObjectException if the extension does not exist. - -***Use this when you consider the extension's presence mandatory for your plugin's logic to proceed.*** - -```findByType(Class type)``` - -***Returns :*** The extension instance if it exists. - -***Returns :*** null if the extension does not exist. - -***Use this when the extension is optional, and your code needs to safely handle cases where it might be missing.*** - -## Notes on Gradle TestKit -For those new to testing Gradle plugins, the Gradle TestKit is an essential tool. It allows you to run real builds as part of your tests, giving you confidence that your plugin behaves correctly for end-users. - -I started by learning from the [official TestKit documentation ](https://docs.gradle.org/current/userguide/test_kit.html) and by studying the extensive examples in the nebula-lint-plugin's existing test suite. I also found the talk ["Testing the Build with Testkit"](https://www.youtube.com/watch?v=P7SvwkRXjSU) to be very helpful for understanding the different types of tests. - -Here are some key takeaways: - -Types of Tests: - -***Unit Tests:*** - -* Focus on a single class in isolation. - -* The class under test does not use the Gradle API. - -***Integration Tests:*** - -* The class(es) under test use Gradle APIs, often interacting with a Project instance. - -* These do not execute a full build but test the integration with Gradle's model. - -***Functional Tests (The focus of TestKit):*** - -* Executes a build script from an end-user's perspective. - -* Examines the build outcome, output, and any created files. - -* Runs in a fully isolated test environment. - -The Structure of a TestKit Test -Most functional tests follow a clear "Given-When-Then" structure, which makes them easy to read and understand: - -***given block:*** Sets up the test environment. This is where you create a temporary project directory and write the build.gradle or other files needed for the test. - -***when block:*** Executes a Gradle task using the GradleRunner. This is where you run the build (e.g., ./gradlew lintGradle). - -***then block:*** Verifies the outcome. Here, you use assertions to check if the build succeeded or failed, inspect the build output, or verify that files were created or modified as expected. +The `@TaskAction` method (`exec` and its helpers) was rewritten to read from configured input properties instead of the project. This table highlights the specific transformations that eliminate configuration cache violations at execution time: +* *See the full class changes: **[`LiquibaseTask.groovy`]([https://github.com/liquibase/liquibase-gradle-plugin/blob/master/src/main/groovy/org/liquibase/gradle/LiquibaseTask.groovy$0])** -> **[`LiquibaseTask.groovy`]([https://github.com/Nouran-11/liquibase-gradle-plugin/blob/fix-cc/src/main/groovy/org/liquibase/gradle/LiquibaseTask.groovy$0])*** +| Violation Type | Before | After | +|---------------------------|-------------------------------------------|---------------------------------------------| +| Accessing Extension Data | `jvmArgs(project.liquibase.jvmArgs)` | `jvmArgs(projectInfo.get().jvmArgs)` | +| Resolving Classpaths | `setClasspath(project.configurations.getByName(...))` | The `classPath` property is assigned in the task's constructor | +| Accessing Project Properties | `argumentBuilder` reads `project.properties` | `argumentBuilder` receives `liquibaseInfo` with properties | +| Accessing buildDir | `argumentBuilder` reads `project.buildDir` | `argumentBuilder` receives `liquibaseInfo` with build dir | --- +## 4. Verification: The Configuration Cache Test -***The following notes serve as a technical blueprint for completing the Configuration Cache compatibility work in the Nebula Lint plugin or for tackling similar challenges in other Gradle plugins. This strategic plan breaks down the complex problem into manageable steps.*** - -***1. Understanding the Challenge*** -What the Plugin Does: The plugin works by building an Abstract Syntax Tree (AST) of a build script and then traversing it with a set of rules. - -***The Core Issue:*** Many of these rules rely on querying Project.configurations at runtime to analyze dependency setups. This dynamic querying is precisely what breaks the Gradle Configuration Cache, which forbids accessing the live Project model during the execution phase. - -***2. Defining the Goal*** -The objective is to refactor the plugin so that rules operate on pre-computed, serializable data instead of the live Project object. - -* Separate Configuration Queries: Decouple rules from Project.configurations by having them work with a "data projection"—a snapshot of the configuration data taken at the right time. - -* Enable Configuration-Time Setup: Move all data gathering to Gradle's configuration phase, avoiding runtime queries entirely. +To provide concrete proof of these improvements, a dedicated integration test was added. This test runs a build with the configuration cache enabled, verifying that the plugin's tasks execute successfully and are correctly stored and retrieved from the cache on subsequent runs. This confirms that the refactoring was successful and the plugin is now fully compatible. -* Refactor Incrementally: Start with simpler, purely syntactical rules (like SpaceAssignmentRule and DependencyParenthesesRule) to build momentum and prove the approach. +See the test in action: [Configuration Cache Test]([https://github.com/Nouran-11/liquibase-gradle-plugin/blob/master/src/test/groovy/org/liquibase/gradle/ConfigurationCacheSpec.groovy$0]) -***3. The Step-by-Step Plan*** - -* Identify Configuration Usage: First, examine each rule to understand how it interacts with Project.configurations. Does it read dependencies? Resolve configurations? Check attributes? - -* Extract Configuration Data: Create a simple data object (e.g., ConfigurationInfo) to hold only the necessary information. This data should be computed once during the configuration phase using Gradle's Provider API. - -* Simplify Rule Dependencies: Refactor the rules to depend on the new ConfigurationInfo data object instead of accessing Project.configurations directly. - -* Modify the Data Flow: Enhance the main ProjectInfo data object to include this new ConfigurationInfo structure, making it available to all rules that need it. - -* Test Incrementally: After refactoring each rule, test it thoroughly with various build scripts to ensure its original functionality remains intact and that it no longer violates Configuration Cache rules. - -By following this iterative plan, anyone can progressively improve a plugin’s compatibility with the Gradle Configuration Cache while ensuring it remains stable and functional. +--- +## 5. Conclusion -## A Heartfelt Thank You -A sincere thank you to the Google Summer of Code program for this experience. I am especially grateful to my mentors for their exceptional support and guidance throughout the project. +In conclusion, this refactoring effort was centered on achieving full compatibility with Gradle's Configuration Cache. By decoupling task execution from the project model, the plugin now allows Gradle to serialize its state and entirely skip the configuration phase on subsequent runs. This unlocks the core performance promise of the configuration cache, resulting in a dramatically faster and more efficient development workflow. -

- Written by Nouran Atef, GSoC 2025 Contributor @Gradle -

From 956225ae9546808a9c45bc9fe19747364bc097a9 Mon Sep 17 00:00:00 2001 From: Oleg Nenashev Date: Wed, 27 Aug 2025 13:35:38 +0200 Subject: [PATCH 4/4] Fix the file name and the title --- ...nfig-cache-gsoc => example-config-cache-liquibase-plugin.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/plugin-development/configuration-cache/{unlocking-config-cache-gsoc => example-config-cache-liquibase-plugin.md} (99%) diff --git a/docs/plugin-development/configuration-cache/unlocking-config-cache-gsoc b/docs/plugin-development/configuration-cache/example-config-cache-liquibase-plugin.md similarity index 99% rename from docs/plugin-development/configuration-cache/unlocking-config-cache-gsoc rename to docs/plugin-development/configuration-cache/example-config-cache-liquibase-plugin.md index cfe410f..a61eb44 100644 --- a/docs/plugin-development/configuration-cache/unlocking-config-cache-gsoc +++ b/docs/plugin-development/configuration-cache/example-config-cache-liquibase-plugin.md @@ -1,4 +1,4 @@ -# Enabling Gradle Configuration Cache Support +# Example - Enabling Gradle Configuration Cache Support in the Liquibase Plguin ## 1. Summary