Skip to content

Enable Gradle build caching and upgrade to Gradle 9.4.1#352

Open
holodorum wants to merge 2 commits intokson-org:mainfrom
holodorum:gradle-caching
Open

Enable Gradle build caching and upgrade to Gradle 9.4.1#352
holodorum wants to merge 2 commits intokson-org:mainfrom
holodorum:gradle-caching

Conversation

@holodorum
Copy link
Collaborator

  • Declare task inputs and outputs across the build so Gradle can determine up-to-date status and skip unchanged tasks. Previously, many tasks (npm install, native tests, python tests, rust tests, CLI native image, etc.) re-executed on every ./gradlew check even when nothing changed. Tasks that produce artifacts declare them as outputs; verification-only tasks use a stamp file pattern.
  • Upgrade Gradle from 8.6 to 9.4.1 and migrate the IntelliJ plugin from v1 (org.jetbrains.intellij) to v2 (org.jetbrains.intellij.platform). The v1 plugin had a classpathIndexCleanup task that always invalidated downstream tasks, and was incompatible with Gradle 9.

Many tasks re-executed on every `./gradlew check` even when nothing
changed, because Gradle requires both inputs and outputs to determine
up-to-date status.

For each affected task, declare the source files/configs as inputs.
For tasks that produce build artifacts (cli native image, LSP compiled
output), declare those as outputs. For verification tasks that don't
produce artifacts (pytest, cargo test, pyright), use a "stamp file"
pattern: write a marker file on success and declare it as the output.

Also fix `generateConstants` which used `upToDateWhen { false }` as a
blunt workaround — replaced with proper `inputs.property()` for the
version and config values it depends on.
The IntelliJ Gradle Plugin v1 (`org.jetbrains.intellij` 1.17.2) had a
`classpathIndexCleanup` task that always ran and deleted classpath.index
files, causing `instrumentCode`, `instrumentTestCode`, and `test` to
re-execute every build.  Additionally, v1 is incompatible with Gradle 9.

This commit:
- Upgrades the Gradle wrapper from 8.6 to 9.4.1
- Migrates from `org.jetbrains.intellij` v1 to
  `org.jetbrains.intellij.platform` v2 (2.13.1), which properly
  declares task inputs/outputs for caching
- Updates `PixiExecTask` to use injected `ExecOperations` instead of
  the removed `Project.exec()` method
- Updates `kotlinx-serialization-json` in buildSrc from 1.6.1 to 1.8.1
  for compatibility with Kotlin 2.2.20 (the 1.6.1 version had a binary
  incompatibility with the serialization compiler plugin)
- Adds `junit:junit:4.13.2` test dependency for JetBrains plugin tests
  (v2 doesn't transitively expose JUnit like v1 did)

The generated Schema test suite files have minor formatting changes
(empty `{}` instead of multi-line) due to the serialization library
update.
Copy link
Collaborator

@aochagavia aochagavia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love the idea of being more aggressive with caching in our builds, but I find it challenging from a maintainability perspective. Let me explain my thought process and suggest potential solutions.

Challenges

  1. Many of the proposed changes track inputs in a way that breaks encapsulation. For example, gradle needs to know which file dependencies are used inside package.json, and we will most probably forget to update the gradle side when we change the package.json file in the future.
  2. It is difficult to be 100% sure we are properly tracking all the inputs and outputs. Also, the potential downside of missing an input could be huge, since we could end up with outdated build artifacts! Getting this wrong could maybe even lead to our CI passing when a clean checkout would result in test failures, because our CI is cached.

So... I'd very much like to avoid the risk of caching issues 😅.

What can we do?

Some alternatives I've thought of:

  1. Find well-maintained Gradle plugins for npm, cargo, etc. that we can trust properly track inputs/outputs. I don't think this exists, at least not for npm.
  2. Have npm and friends spit out a specification of the inputs/outputs used when running a build or test, then use that to configure Gradle inputs/outputs. I don't think this is possible, at least not for npm.
  3. Rely on npm and friends' built-in caching mechanisms, without attempting to Gradle-ify them (this is what we have been doing until this PR).

From a pragmatic standpoint, I'd say we could live with alternative 3. After all, npm, cargo and uv know to stop immediately if there is no work to do. The only caveats are:

  1. Running npm ci is somewhat slow (up to 5 seconds on my machine). This happens because npm ci throws away the existing node_modules and recreates it from scratch. We could probably bring this down to less than a second if we switched to pnpm (which is npm-compatible, focuses on performance, and has a strong community).
  2. Tests always run, including heavy ones (in constrast to Gradle, which skips tests when their inputs haven't changed). Most tests complete in an instant, but :tooling:lsp-clients:run_npm_test takes ~30 seconds on my machine (assuming the previous build was cached). I guess this an issue if you run ./gradlew check frequently during local development, but we could maybe introduce a flag that allows skipping those tests (e.g., ./gradlew check --skip-heavy-tests).

With the two mitigations above, I think we should see ./gradlew check --skip-heavy-tests complete in ~5 seconds, which sounds acceptable to me. I'm currently testing this and will push to a separate branch once I'm done, for comparison.

@holodorum what do you think?

tasks.named("nativeTest") {
inputs.file(layout.buildDirectory.file("native/nativeTestCompile/native-tests-tests"))
outputs.dir(layout.buildDirectory.dir("test-results/test-native"))
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this necessary? We are using Kotlin's built-in testing mechanism, so I'd hope everything works out-of-the-box without extra plumbing...

val outputDir = layout.buildDirectory.dir("native/nativeCompile").get().asFile
val outputFile = file("$outputDir/$cliName")

inputs.files(tasks.jar.map { it.archiveFile })
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now we are adding this, we could get rid of dependsOn(tasks.jar) since Gradle can deduce the dependency automatically (I just discovered this is indeed recommended as a best practice in their docs).

val outputFile = file("$outputDir/$cliName")

inputs.files(tasks.jar.map { it.archiveFile })
inputs.files(configurations.runtimeClasspath)
Copy link
Collaborator

@aochagavia aochagavia Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this last line necessary? If it's obvious and I'm missing it due to lack of knowledge, please enlighten me! And if it's not obvious... Then we should probably have a comment explaining the why :)

@aochagavia
Copy link
Collaborator

Just pushed a branch to test the pnpm hypothesis and I'm not fully convinced after all, so I'd stay in npm-land for now. Still, I'd rather have that branch's ce92cac instead of this branch's 7f88f93 (i.e., let npm, uv, cargo do their thing without trying to be smart on our end).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants