skip to content

A task you didn't write (e.g. a third-party plugin's task or `test`) keeps missing the remote cache despite no real change. How do you diagnose and fix it via normalization/path sensitivity from the consumer side?

level: principalimportance: should knowfreq 22%

answer

  1. -Dorg.gradle.caching.debug=true / build scan inputs diff
  2. classify: absolute path / volatile jar content / empty dirs
  3. global normalization.runtimeClasspath fixes consumers you don't own
  4. validate from a 2nd checkout path
  5. convention plugin + hit-rate CI gate

basics

~20 s

Use build-cache debug logging or the build scan to compare input fingerprints between two runs, find which input changed (often an absolute path or a build-stamped file in a jar), then stabilize it with runtime classpath normalization or by adjusting the input's path sensitivity.

solid answer

~50 s

Start by confirming it's a fingerprint difference, not a non-cacheable task: enable `-Dorg.gradle.caching.debug=true` (or read the build scan's task inputs comparison) to see, per input, which fingerprint changed between a cache-populating run and a missing run. Common culprits: (1) an **absolute path** entering the key because an input uses ABSOLUTE sensitivity or an absolute path is passed as a plain input — fix by making it RELATIVE/relative-to-root or wrapping it so the path is normalized; (2) a **volatile file inside a jar** (build-info/git.properties, manifest Built-By) — fix with `normalization.runtimeClasspath { ignore(...) / properties{ ignoreProperty } / metaInf{ ignoreAttribute } }`; (3) **empty directories** differing between CI and local — fix by ensuring inputs use `@IgnoreEmptyDirectories`. Since you don't own the task, you apply project-level normalization (global) and configure the plugin's inputs via its DSL where exposed. Validate by re-running on a clean checkout at a different path and confirming a hit.

code

bash · 4 lines
bash
# Populate cache, then prove a hit from a different checkout path
./gradlew test --build-cache -Dorg.gradle.caching.debug=true
# move/clone repo to a different absolute path, then:
./gradlew test --build-cache   # expect: Task :test FROM-CACHE

go deeper

for a junior

Know that a build scan / caching debug log shows which input changed and that some misses come from timestamps inside jars.

for a middle

Diagnose via caching.debug, identify the changed input, and apply the matching fix (normalization or path sensitivity).

for a senior

Systematically classify the three causes, apply project-level normalization for tasks you don't own, and validate from a second checkout.

for a principal

Drive an org-level remediation: a convention plugin applying standard normalization + pre-annotated task types, plus a CI hit-rate gate and governance against unsafe relaxations.

## Step 1 — confirm the failure mode A cache miss has two broad causes: the task is **not cacheable** (no `@CacheableTask`/no declared outputs) or it **is cacheable but its input fingerprint differs**. Path-sensitivity/normalization only helps the second. Check the build scan's *Build cache* and *Task inputs* sections, or run with `-Dorg.gradle.caching.debug=true`, which logs each input's individual hash. Re-run twice (ideally on two checkout paths/machines) and diff the per-input hashes to find the offending input. ## Step 2 — classify the offending input Three recurring causes, all fixable from the consumer side: ### a) Absolute path leaks into the key Symptom: the same content yields different keys on CI vs. local, or after moving the checkout. The input is fingerprinted with ABSOLUTE sensitivity (or an absolute path string was passed as a plain `@Input`). For an input you configure, switch to a relative declaration; for plugin tasks, prefer passing values via `layout`/`Provider` so paths are project-relative, and avoid feeding absolute `File` paths as scalar inputs. ### b) Volatile content inside an artifact Symptom: a runtime-classpath consumer (e.g. `test`) misses whenever an upstream jar is rebuilt, because the jar embeds a timestamp/commit. Fix globally: ```kotlin normalization { runtimeClasspath { properties("META-INF/build-info.properties") { ignoreProperty("build.time") } metaInf { ignoreAttribute("Built-By"); ignoreAttribute("Build-Jdk") } } } ``` This applies to **every** runtime classpath in the project, so you don't need to own the consuming task. ### c) Empty-directory noise Symptom: CI (clean Git checkout, no empty dirs) misses what local populated, or vice versa. If you control the input wiring, add `@IgnoreEmptyDirectories`; if it's a plugin's input, see whether the plugin exposes the input as a file collection you can reconfigure, or stabilize the working tree. ## Step 3 — validate the fix Reproduce the original miss deterministically: populate the cache from one checkout path, then build from a **second checkout at a different absolute path** (and ideally a different machine/container) with `--build-cache`. A `FROM-CACHE` outcome confirms the fingerprint is now stable. Add an assertion or a periodic cache-hit-rate report so regressions surface. ## Governance angle At org scale, low hit rates usually trace to a handful of recurring anti-patterns: stamped build metadata without normalization, absolute paths in inputs, and environment-specific empty dirs. The durable fix is a **shared convention plugin** that applies the standard `normalization` block and provides task types pre-annotated with RELATIVE + `@IgnoreEmptyDirectories`, plus a CI gate that reports hit rate so regressions are caught early. ## Distinguishing the tools - `@PathSensitive` → path portion of arbitrary file inputs. - `normalization.runtimeClasspath` → entry/content portion of classpath inputs. - `@IgnoreEmptyDirectories` → empty-dir participation. Diagnosis tells you which knob applies; never relax a knob past what's correct for the task just to force a hit.

  • The task isn't cacheable at all — does normalization help?
    No. Normalization and path sensitivity only stabilize input fingerprints for already-cacheable tasks. If the task lacks @CacheableTask or declared outputs, you must make it cacheable first (or it's intentionally non-cacheable).
  • How do you fix this for a plugin task whose inputs you can't annotate?
    Use project-level normalization.runtimeClasspath (it applies to all runtime classpaths regardless of who declared them), reconfigure inputs through the plugin's DSL where exposed, and avoid feeding absolute paths into its scalar inputs.
  • How do you prove the fix worked?
    Populate the cache from one checkout, then build --build-cache from a second checkout at a different absolute path/machine and confirm the task reports FROM-CACHE; track hit rate over time to catch regressions.

saying these in an interview costs you the question

  • Disabling the cache or marking inputs @Internal to 'fix' misses — that hides real changes and is a correctness bug.
  • Assuming all misses are fingerprint issues without checking the task is even cacheable.
  • Relaxing path sensitivity past what's correct just to force a hit.

context