From 7b1384b41312fa6d37577eaf31355756a124ad07 Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Fri, 4 Sep 2026 15:00:04 +0000 Subject: [PATCH 1/5] ci: let setup-gradle cache the Gradle home Caching it by hand meant every pull request saved its own copy of it. Those copies held 7 of the 10 GB the repository is allowed, so entries evicted each other within hours and even a second run of the same pull request started cold. Leaving the writing to pushes is what fixes that; the maintenance branches keep filling their own scope. The action's default cache provider is a proprietary component that its MIT licence does not cover and is free only as a preview, so this asks for the MIT one. That provider saves one entry over ~/.gradle/caches and ~/.gradle/wrapper, the same tree the hand written step cached, and deliberately passes no restore keys: a change to any build script or wrapper file now starts from an empty Gradle home rather than a near match, where the replaced step fell back on a prefix. --- .github/workflows/validation.yml | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index 84dc5709b55..21971bad874 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -377,14 +377,29 @@ jobs: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-maven- - - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + # Caching the Gradle home by hand meant every pull request saved its own + # copy of it. Those copies alone held 7 of the 10 GB the repository is + # allowed, so entries evicted each other within hours and even a second + # run of the same pull request started cold. What fixes that is + # cache-read-only below, which leaves the writing to pushes. + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 if: ${{ steps.decide.outputs.run == 'true' }} with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('flow-plugins/flow-gradle-plugin/**/*.gradle', 'flow-plugins/flow-gradle-plugin/gradle.properties', 'flow-plugins/flow-gradle-plugin/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle- + # The default 'enhanced' provider is a proprietary component that the + # MIT licence of the action does not cover, and it is free only as a + # preview. 'basic' is the MIT implementation. It saves one entry over + # ~/.gradle/caches and ~/.gradle/wrapper, the same tree the hand + # written step cached, and deliberately passes no restore keys, so a + # change to any build script or wrapper file starts from an empty + # Gradle home rather than a near match. gradle-home-cache-includes + # and its siblings are read only by the enhanced provider and would + # do nothing here. + cache-provider: basic + # The action would otherwise write only on the default branch, which + # this workflow no longer builds. A push is the only event that should + # write, so that the maintenance branches keep filling their own + # scope, and everything else reads. + cache-read-only: ${{ github.event_name != 'push' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 if: ${{ steps.decide.outputs.run == 'true' && github.run_attempt == 1 }} with: From 08a6998472bfa4da5796832e68379959eef14b79 Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Fri, 4 Sep 2026 15:00:05 +0000 Subject: [PATCH 2/5] ci: build main once per merge to warm the caches A run can only read caches from its own ref and from the default branch. Since #24103 dropped main from the validation push trigger, nothing writes to refs/heads/main any more, and a merge queue branch is single use, so its caches are never read either. Every pull request starts with an empty local repository, downloads the whole dependency tree, and saves a copy only it can read. One branch went from 4 minutes to 13.6 minutes in a day without changing. Putting main back in the validation trigger would fix that, but it also brings back the second full run per merge that #24103 removed: the build, four unit jobs, a fifteen way integration matrix and Sonar, for a tree the merge queue has just validated, with flaky tests now able to redden main. A single job gets the same cache: the build job is what fills an empty local repository, and every other job waits on it, so the entry it saves is the one they all read anyway. The Flow artifacts are removed before the cache is saved, since an entry every branch restores should not carry one branch's 999.99-SNAPSHOT jars, which could satisfy a resolution that ought to fail. --- .github/workflows/warm-caches.yml | 105 ++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 .github/workflows/warm-caches.yml diff --git a/.github/workflows/warm-caches.yml b/.github/workflows/warm-caches.yml new file mode 100644 index 00000000000..15d25686504 --- /dev/null +++ b/.github/workflows/warm-caches.yml @@ -0,0 +1,105 @@ +name: Warm Caches + +# Flow Validation caches ~/.m2/repository and, through setup-gradle, the Gradle +# home. A run can only read caches from its own ref and from the default branch, +# and #24103 removed main from that workflow's push trigger because the merge +# queue already validates every merge. Merge queue runs cannot fill the gap +# either: their branch name embeds the pull request number and the base sha, so +# each one is single use and no other run can read what it saved. +# +# Nothing therefore writes a cache that a pull request can read. Every one of +# them starts with an empty local repository, downloads the whole dependency +# tree, and then saves a copy only it can read. +# +# This builds main once per merge so that the default branch holds an entry they +# can all read. It is a single job rather than the validation matrix, so it does +# not bring back the duplicate validation that #24103 removed. + +on: + push: + branches: ['main'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # Only the most recent state of main is worth caching, and queueing runs + # instead would warm one superseded commit after another while the useful one + # waits behind them. Cancelling costs freshness, not availability: the + # previously saved entry stays in the store and the restore keys still match + # it, so a run that loses a race leaves every branch on a cache one merge + # behind rather than on none. Merges are far enough apart for the last run of + # a burst to finish anyway; over 18 days the median gap between pushes to main + # was 54 minutes. + group: ${{ github.workflow }} + cancel-in-progress: true + +env: + JAVA_VERSION: 21 + +jobs: + warm-caches: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24.20.0' + - name: Set up JDK + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: "${{ env.JAVA_VERSION }}" + distribution: 'temurin' + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + # See the note in validation.yml: the default provider is proprietary. + cache-provider: basic + # The action reads rather than writes off the default branch, which + # would make a manual run from another ref warm nothing. + cache-read-only: false + - name: Set flow version to 999.99-SNAPSHOT + # The cache key is hashed from the poms after this rewrite, so it has to + # happen here too for the key to match the one validation.yml computes. + run: | + ./scripts/computeMatrix.js set-version --version=999.99-SNAPSHOT + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-maven- + - name: Populate the local repository + # The same command the build job runs, so that what is cached is what a + # validation run asks for. A real build, because it resolves what a real + # build resolves: dependency:go-offline fails on this reactor over + # org.eclipse.m2e:lifecycle-mapping, which several poms declare so that + # Eclipse stays quiet and which resolves nowhere, and dependency:resolve + # would leave every build plugin uncached. + run: | + cmd="mvn install -B -ntp -DskipTests -pl \!flow-plugins/flow-gradle-plugin" + eval $cmd -T 2C -q || eval $cmd + - name: Populate the Gradle home + # Compiles every source set the validation job's `build` would, so that + # the test and functionalTest configurations are resolved too, but + # without running the tests, which is the slow part and warms nothing. + # Needs the step above, which installs the artifacts it resolves. + # + # Failing here must not take the local repository down with it: the + # step that saves a cache only runs on a successful job, and this + # workflow is the only writer of the caches every branch reads. + continue-on-error: true + working-directory: flow-plugins/flow-gradle-plugin + run: ./gradlew assemble testClasses functionalTestClasses + - name: Drop the Flow artifacts before the cache is saved + # The step that saves the cache runs after every regular step, so + # removing them here keeps them out of the entry every branch restores, + # where they could satisfy a resolution that ought to fail for a module + # a branch renamed or removed. Only what this build produced: the rest + # of the com/vaadin tree is ordinary downloads, license-checker, open, + # the external and TestBench artifacts, and is worth caching. Excluding + # them through the cache path does not work: actions/cache globs with + # implicitDescendants false, so ~/.m2/repository matches only the + # directory itself and tar recurses into it regardless of any negated + # pattern. + run: rm -rf ~/.m2/repository/com/vaadin/*/999.99-SNAPSHOT From b50d4a5c8d7789f7f3261299fd866b98684becd9 Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Mon, 7 Sep 2026 05:32:56 +0000 Subject: [PATCH 3/5] ci: stop the warm entry from crowding out the richer ones The warm entry used the key the unit and integration jobs compute. That key stops them saving: actions/cache skips its save when the primary key was an exact hit, and today those jobs miss it, match by prefix, and save what they added. What they add is most of it, because this build leaves flow-tests out, and with it the ninety or so artifacts only the integration tests pull. Give the warm entry a key of its own, one their restore keys still reach by prefix, so they start warm and keep saving. Warm Maven and Gradle in separate jobs. The step that saves a cache runs only on a successful job, so a Gradle failure was discarding a local repository that had nothing to do with it. The plugin only needs flow-plugin-base, so its job builds that rather than the whole reactor, and reads the Maven entry rather than racing for it. Let a failing Gradle build fail its job again. setup-gradle saves the Gradle home even then, and the basic provider never overwrites a key it restored, so a half resolved home stays frozen until a build script changes. That is worth seeing rather than hiding behind a green run. Key the concurrency group on the ref too, so a manual run cannot cancel the warm running for a merge, and drop a cache-read-only override that only ever wrote into a scope nothing else reads. Also say what the basic provider actually hashes: repository wide globs that no longer cover flow-gradle-plugin/gradle.properties, and that do cover two settings.gradle test fixtures. --- .github/workflows/validation.yml | 15 +++-- .github/workflows/warm-caches.yml | 93 ++++++++++++++++++++++--------- 2 files changed, 77 insertions(+), 31 deletions(-) diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index 21971bad874..7f8f11afdca 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -390,10 +390,17 @@ jobs: # preview. 'basic' is the MIT implementation. It saves one entry over # ~/.gradle/caches and ~/.gradle/wrapper, the same tree the hand # written step cached, and deliberately passes no restore keys, so a - # change to any build script or wrapper file starts from an empty - # Gradle home rather than a near match. gradle-home-cache-includes - # and its siblings are read only by the enhanced provider and would - # do nothing here. + # change to anything the key covers starts from an empty Gradle home + # rather than a near match. gradle-home-cache-includes and its + # siblings are read only by the enhanced provider and do nothing here. + # + # Its key is not the one the replaced step computed either. It hashes + # **/*.gradle*, **/gradle-wrapper.properties, gradle/*.versions.toml, + # **/versions.properties and a couple of buildSrc paths, across the + # whole repository. So flow-gradle-plugin/gradle.properties no longer + # takes part, a basename needs a literal .gradle in it, and the two + # settings.gradle fixtures under vaadin-dev-server/src/test/resources + # now do: editing one of those invalidates the Gradle home. cache-provider: basic # The action would otherwise write only on the default branch, which # this workflow no longer builds. A push is the only event that should diff --git a/.github/workflows/warm-caches.yml b/.github/workflows/warm-caches.yml index 15d25686504..92023cba280 100644 --- a/.github/workflows/warm-caches.yml +++ b/.github/workflows/warm-caches.yml @@ -11,9 +11,9 @@ name: Warm Caches # them starts with an empty local repository, downloads the whole dependency # tree, and then saves a copy only it can read. # -# This builds main once per merge so that the default branch holds an entry they -# can all read. It is a single job rather than the validation matrix, so it does -# not bring back the duplicate validation that #24103 removed. +# This builds main once per merge so that the default branch holds entries they +# can all read. It is two single jobs rather than the validation matrix, so it +# does not bring back the duplicate validation that #24103 removed. on: push: @@ -24,7 +24,10 @@ permissions: contents: read concurrency: - # Only the most recent state of main is worth caching, and queueing runs + # Keyed on the ref as well as the workflow, so that a manual run cannot cancel + # the warm running for a merge to main. + # + # Only the most recent state of a branch is worth caching, and queueing runs # instead would warm one superseded commit after another while the useful one # waits behind them. Cancelling costs freshness, not availability: the # previously saved entry stays in the store and the restore keys still match @@ -32,14 +35,16 @@ concurrency: # behind rather than on none. Merges are far enough apart for the last run of # a burst to finish anyway; over 18 days the median gap between pushes to main # was 54 minutes. - group: ${{ github.workflow }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: JAVA_VERSION: 21 jobs: - warm-caches: + # Kept apart from the Gradle warm so that one failing cannot discard what the + # other resolved: the step that saves a cache only runs on a successful job. + warm-maven: runs-on: ubuntu-24.04 timeout-minutes: 45 steps: @@ -52,13 +57,6 @@ jobs: with: java-version: "${{ env.JAVA_VERSION }}" distribution: 'temurin' - - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - with: - # See the note in validation.yml: the default provider is proprietary. - cache-provider: basic - # The action reads rather than writes off the default branch, which - # would make a manual run from another ref warm nothing. - cache-read-only: false - name: Set flow version to 999.99-SNAPSHOT # The cache key is hashed from the poms after this rewrite, so it has to # happen here too for the key to match the one validation.yml computes. @@ -67,8 +65,15 @@ jobs: - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.m2/repository - key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} - restore-keys: ${{ runner.os }}-maven- + # Deliberately not the key validation.yml computes, even though this + # entry is meant for it. actions/cache skips its save when the primary + # key was an exact hit, so claiming that key here would stop the test + # jobs saving theirs, and theirs is the richer one: this build leaves + # flow-tests out, and with it the roughly ninety artifacts only the + # integration tests pull. Their restore keys still reach this entry by + # prefix, so they start warm and keep saving what they add to it. + key: ${{ runner.os }}-maven-warm-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-maven-warm- - name: Populate the local repository # The same command the build job runs, so that what is cached is what a # validation run asks for. A real build, because it resolves what a real @@ -79,18 +84,6 @@ jobs: run: | cmd="mvn install -B -ntp -DskipTests -pl \!flow-plugins/flow-gradle-plugin" eval $cmd -T 2C -q || eval $cmd - - name: Populate the Gradle home - # Compiles every source set the validation job's `build` would, so that - # the test and functionalTest configurations are resolved too, but - # without running the tests, which is the slow part and warms nothing. - # Needs the step above, which installs the artifacts it resolves. - # - # Failing here must not take the local repository down with it: the - # step that saves a cache only runs on a successful job, and this - # workflow is the only writer of the caches every branch reads. - continue-on-error: true - working-directory: flow-plugins/flow-gradle-plugin - run: ./gradlew assemble testClasses functionalTestClasses - name: Drop the Flow artifacts before the cache is saved # The step that saves the cache runs after every regular step, so # removing them here keeps them out of the entry every branch restores, @@ -103,3 +96,49 @@ jobs: # directory itself and tar recurses into it regardless of any negated # pattern. run: rm -rf ~/.m2/repository/com/vaadin/*/999.99-SNAPSHOT + + warm-gradle: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24.20.0' + - name: Set up JDK + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: "${{ env.JAVA_VERSION }}" + distribution: 'temurin' + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + # See the note in validation.yml: the default provider is proprietary. + cache-provider: basic + - name: Set flow version to 999.99-SNAPSHOT + # The Gradle build takes its version from the root pom, so the poms have + # to match what is installed below for it to resolve flow-plugin-base. + run: | + ./scripts/computeMatrix.js set-version --version=999.99-SNAPSHOT + - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + # Read only: this job resolves a fraction of what warm-maven does, so it + # has nothing to add to that entry and should not race it for the key. + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-warm-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-maven-warm- + - name: Install what the plugin build resolves + # The plugin depends on com.vaadin:flow-plugin-base, so that module and + # what it is built from is all this job needs from the local repository. + run: | + mvn install -B -ntp -DskipTests -q -pl flow-plugins/flow-plugin-base -am + - name: Populate the Gradle home + # Compiles every source set the validation job's `build` would, so that + # the test and functionalTest configurations are resolved too, but + # without running the tests, which is the slow part and warms nothing. + # + # A failure here fails the job on purpose. setup-gradle saves whatever + # the Gradle home holds even then, and the basic provider never + # overwrites a key it restored, so a half resolved home would otherwise + # be frozen in place, silently, until a build script changes. + working-directory: flow-plugins/flow-gradle-plugin + run: ./gradlew assemble testClasses functionalTestClasses From 4ef28ab82177ea7991087f2d1dbc2762bdcb5c21 Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Mon, 7 Sep 2026 06:18:14 +0000 Subject: [PATCH 4/5] ci: make the Gradle cache recover and stop starving its own jobs A warm run that fails still saves. setup-gradle's post step runs unless the job was cancelled, its provider never overwrites a key it restored, and that key has no restore keys to fall back on, so one interrupted run froze a half resolved Gradle home in place. Every later run, successful ones included, restored it exactly and skipped its own save. Failing the job made that visible but repaired nothing. Delete the entry at the start of the job instead. Cleaning up after a failure cannot work, since post steps run last and would save over it. Deleting first leaves nothing to restore, so the save always writes what the run resolved, and a bad entry lasts until the next merge. Let everything but the merge queue write in validation as well. Read only is cheaper, but with no restore keys a pull request that edits a build script computes a key held in no scope and cannot save one either, so it downloads the whole Kotlin, Dokka and plugin-publish set again on every push to that branch. Those are the pull requests the job runs for. A merge queue branch is single use, so it still reads only. Say what the Gradle warm actually resolves: the compile classpaths of every source set, not the runtime ones, which only running the tests pulls in. Key the concurrency group on the event too, since a manual run targeting main carries the ref of the push it would cancel. And skip the whole thing on forks, which can only warm entries they alone can read. --- .github/workflows/validation.yml | 20 ++++++++++---- .github/workflows/warm-caches.yml | 46 +++++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index 7f8f11afdca..9c394876c55 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -380,8 +380,9 @@ jobs: # Caching the Gradle home by hand meant every pull request saved its own # copy of it. Those copies alone held 7 of the 10 GB the repository is # allowed, so entries evicted each other within hours and even a second - # run of the same pull request started cold. What fixes that is - # cache-read-only below, which leaves the writing to pushes. + # run of the same pull request started cold. What keeps them down now is + # that this job runs at all only when the Gradle plugin changed, and that + # a merge queue branch, which nothing can read from, no longer writes. - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 if: ${{ steps.decide.outputs.run == 'true' }} with: @@ -403,10 +404,17 @@ jobs: # now do: editing one of those invalidates the Gradle home. cache-provider: basic # The action would otherwise write only on the default branch, which - # this workflow no longer builds. A push is the only event that should - # write, so that the maintenance branches keep filling their own - # scope, and everything else reads. - cache-read-only: ${{ github.event_name != 'push' }} + # this workflow no longer builds. + # + # Everything but the merge queue writes. Read only would be cheaper, + # but the provider has no restore keys, so a pull request that edits a + # build script computes a key that exists in no scope and could not + # save one either: it would download the whole Kotlin, Dokka and + # plugin-publish set again on every push to that branch. Those are + # exactly the pull requests this job runs for, and the hand written + # step it replaced degraded to a prefix match instead. A merge queue + # branch is single use, so what it saves is read by nothing. + cache-read-only: ${{ github.event_name == 'merge_group' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 if: ${{ steps.decide.outputs.run == 'true' && github.run_attempt == 1 }} with: diff --git a/.github/workflows/warm-caches.yml b/.github/workflows/warm-caches.yml index 92023cba280..88678bb6606 100644 --- a/.github/workflows/warm-caches.yml +++ b/.github/workflows/warm-caches.yml @@ -24,8 +24,8 @@ permissions: contents: read concurrency: - # Keyed on the ref as well as the workflow, so that a manual run cannot cancel - # the warm running for a merge to main. + # Keyed on the event as well as the ref: a manual run targeting main carries + # the same github.ref as the push it would otherwise cancel. # # Only the most recent state of a branch is worth caching, and queueing runs # instead would warm one superseded commit after another while the useful one @@ -35,7 +35,7 @@ concurrency: # behind rather than on none. Merges are far enough apart for the last run of # a burst to finish anyway; over 18 days the median gap between pushes to main # was 54 minutes. - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true env: @@ -45,6 +45,9 @@ jobs: # Kept apart from the Gradle warm so that one failing cannot discard what the # other resolved: the step that saves a cache only runs on a successful job. warm-maven: + # A fork that syncs its main from here would otherwise spend its own minutes + # warming entries only that fork can read. + if: ${{ github.repository == 'vaadin/flow' }} runs-on: ubuntu-24.04 timeout-minutes: 45 steps: @@ -98,10 +101,32 @@ jobs: run: rm -rf ~/.m2/repository/com/vaadin/*/999.99-SNAPSHOT warm-gradle: + if: ${{ github.repository == 'vaadin/flow' }} runs-on: ubuntu-24.04 timeout-minutes: 30 + permissions: + contents: read + # For the cache deletion below. + actions: write steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - name: Forget the Gradle home entry for this ref + # setup-gradle saves whatever the Gradle home holds even when the job + # fails, its cache provider never overwrites a key it restored, and its + # key has no restore keys to fall back on. One interrupted run would + # otherwise freeze a half resolved home in place: every later run, + # successful ones included, restores it exactly and skips its own save. + # + # Deleting first is what makes this job self healing. It has to happen + # before setup-gradle restores, not after a failure: post steps run + # last, so a cleanup at the end would be overwritten by the save it is + # meant to undo. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh cache list --ref "$GITHUB_REF" --key setup-java- --limit 100 \ + --json id --jq '.[].id' \ + | xargs -r -n1 gh cache delete - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.20.0' @@ -132,13 +157,12 @@ jobs: run: | mvn install -B -ntp -DskipTests -q -pl flow-plugins/flow-plugin-base -am - name: Populate the Gradle home - # Compiles every source set the validation job's `build` would, so that - # the test and functionalTest configurations are resolved too, but - # without running the tests, which is the slow part and warms nothing. - # - # A failure here fails the job on purpose. setup-gradle saves whatever - # the Gradle home holds even then, and the basic provider never - # overwrites a key it restored, so a half resolved home would otherwise - # be frozen in place, silently, until a build script changes. + # Compiles every source set, so the compile classpaths of main, test and + # functionalTest are resolved, without running the tests, which is the + # slow part. What validation's `./gradlew build` resolves on top of this + # is the runtime classpaths, which only executing the tests pulls in: + # the Maven runtime scoped transitives of flow-plugin-base and the + # TestKit runtime. Those stay cold, and running the tests here to warm + # them would cost more than they save. working-directory: flow-plugins/flow-gradle-plugin run: ./gradlew assemble testClasses functionalTestClasses From 0eb47ebdc6fbe04a552d8280dddb53223895a29d Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Mon, 7 Sep 2026 07:13:39 +0000 Subject: [PATCH 5/5] ci: stop the warm jobs deleting and rebuilding more than they need The Gradle cache provider prefixes its key with setup-java, the same prefix actions/setup-java uses for the Maven caches that three other workflows here write. A prefix filter matched both, so the delete would have taken four unrelated entries with it. Select on the segment that tells them apart. Skip the Maven warm when the entry it would write is already there. What that entry holds is decided by the poms alone, so on a merge that changes none of them the run restored the key, spent up to forty five minutes building, and then skipped its save as an exact hit. A lookup only probe up front costs a checkout instead. Delete the Gradle entry as late as the job allows, after the Maven work rather than before it. From the delete until the save this ref has no Gradle home, and a gradle-tests run starting inside that window resolves cold and saves a copy of its own, which is the thing this is all meant to stop. It cannot move any later, since setup-gradle restores as it runs. Say that plainly in the concurrency note as well: cancelling costs the Maven warm freshness, but it can cost the Gradle warm availability, and that provider has no restore keys to soften it. Correct what the validation note credits. The writes come down because a pull request that edits none of the hashed files restores what main warmed and skips its save, not because of the gating, which was already in place while those copies accumulated. --- .github/workflows/validation.yml | 7 ++- .github/workflows/warm-caches.yml | 78 ++++++++++++++++++++----------- 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index 9c394876c55..8399766a723 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -381,8 +381,11 @@ jobs: # copy of it. Those copies alone held 7 of the 10 GB the repository is # allowed, so entries evicted each other within hours and even a second # run of the same pull request started cold. What keeps them down now is - # that this job runs at all only when the Gradle plugin changed, and that - # a merge queue branch, which nothing can read from, no longer writes. + # the key: a pull request that edits none of the files it hashes restores + # the entry main warmed on the primary key, and the provider skips its + # save on an exact hit, so only the ones that change a build script write + # at all. The gating below is not what does it, it was already there + # while those copies accumulated. - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 if: ${{ steps.decide.outputs.run == 'true' }} with: diff --git a/.github/workflows/warm-caches.yml b/.github/workflows/warm-caches.yml index 88678bb6606..def8fe99e0e 100644 --- a/.github/workflows/warm-caches.yml +++ b/.github/workflows/warm-caches.yml @@ -29,12 +29,16 @@ concurrency: # # Only the most recent state of a branch is worth caching, and queueing runs # instead would warm one superseded commit after another while the useful one - # waits behind them. Cancelling costs freshness, not availability: the - # previously saved entry stays in the store and the restore keys still match - # it, so a run that loses a race leaves every branch on a cache one merge - # behind rather than on none. Merges are far enough apart for the last run of - # a burst to finish anyway; over 18 days the median gap between pushes to main - # was 54 minutes. + # waits behind them. For warm-maven cancelling costs freshness, not + # availability: the previous entry stays in the store and the restore keys + # still match it, so a branch is left one merge behind rather than on nothing. + # For warm-gradle it can cost availability, since that job deletes before it + # rebuilds and there are no restore keys, so a run cancelled after that point + # leaves the ref with no Gradle home until the next merge. The delete sits as + # late in the job as it can to keep that window short. + # + # Merges are far enough apart for the last run of a burst to finish anyway; + # over 18 days the median gap between pushes to main was 54 minutes. group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true @@ -65,7 +69,19 @@ jobs: # happen here too for the key to match the one validation.yml computes. run: | ./scripts/computeMatrix.js set-version --version=999.99-SNAPSHOT + - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: probe + # What the entry holds is decided by the poms alone, so an entry already + # under this key has nothing to gain from being rebuilt: the save would + # be skipped as an exact hit and the build below would be an hour of a + # runner spent on nothing. Most merges do not touch a pom, so this is + # the common case. + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-warm-${{ hashFiles('**/pom.xml') }} + lookup-only: true - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + if: ${{ steps.probe.outputs.cache-hit != 'true' }} with: path: ~/.m2/repository # Deliberately not the key validation.yml computes, even though this @@ -78,6 +94,7 @@ jobs: key: ${{ runner.os }}-maven-warm-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-maven-warm- - name: Populate the local repository + if: ${{ steps.probe.outputs.cache-hit != 'true' }} # The same command the build job runs, so that what is cached is what a # validation run asks for. A real build, because it resolves what a real # build resolves: dependency:go-offline fails on this reactor over @@ -88,6 +105,7 @@ jobs: cmd="mvn install -B -ntp -DskipTests -pl \!flow-plugins/flow-gradle-plugin" eval $cmd -T 2C -q || eval $cmd - name: Drop the Flow artifacts before the cache is saved + if: ${{ steps.probe.outputs.cache-hit != 'true' }} # The step that saves the cache runs after every regular step, so # removing them here keeps them out of the entry every branch restores, # where they could satisfy a resolution that ought to fail for a module @@ -110,23 +128,6 @@ jobs: actions: write steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - - name: Forget the Gradle home entry for this ref - # setup-gradle saves whatever the Gradle home holds even when the job - # fails, its cache provider never overwrites a key it restored, and its - # key has no restore keys to fall back on. One interrupted run would - # otherwise freeze a half resolved home in place: every later run, - # successful ones included, restores it exactly and skips its own save. - # - # Deleting first is what makes this job self healing. It has to happen - # before setup-gradle restores, not after a failure: post steps run - # last, so a cleanup at the end would be overwritten by the save it is - # meant to undo. - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh cache list --ref "$GITHUB_REF" --key setup-java- --limit 100 \ - --json id --jq '.[].id' \ - | xargs -r -n1 gh cache delete - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.20.0' @@ -135,10 +136,6 @@ jobs: with: java-version: "${{ env.JAVA_VERSION }}" distribution: 'temurin' - - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - with: - # See the note in validation.yml: the default provider is proprietary. - cache-provider: basic - name: Set flow version to 999.99-SNAPSHOT # The Gradle build takes its version from the root pom, so the poms have # to match what is installed below for it to resolve flow-plugin-base. @@ -156,6 +153,33 @@ jobs: # what it is built from is all this job needs from the local repository. run: | mvn install -B -ntp -DskipTests -q -pl flow-plugins/flow-plugin-base -am + - name: Forget the Gradle home entry for this ref + # setup-gradle saves whatever the Gradle home holds even when the job + # fails, its cache provider never overwrites a key it restored, and its + # key has no restore keys to fall back on. One interrupted run would + # otherwise freeze a half resolved home in place: every later run, + # successful ones included, restores it exactly and skips its own save. + # + # Deleting first is what makes this job self healing. It has to happen + # before setup-gradle restores, not after a failure: post steps run + # last, so a cleanup at the end would be overwritten by the save it is + # meant to undo. It is as late as it can be for the same reason the + # setup below is: from here until the save this ref has no Gradle home + # entry, and a gradle-tests run starting inside that window resolves + # cold and saves a copy of its own. + # + # The prefix is shared with the Maven caches actions/setup-java writes, + # so the segment is what identifies these. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh cache list --ref "$GITHUB_REF" --key setup-java- --limit 100 \ + --json id,key --jq '.[] | select(.key | contains("-gradle-")) | .id' \ + | xargs -r -n1 gh cache delete + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + # See the note in validation.yml: the default provider is proprietary. + cache-provider: basic - name: Populate the Gradle home # Compiles every source set, so the compile classpaths of main, test and # functionalTest are resolved, without running the tests, which is the