skip to content

A custom cacheable task produces some real outputs, keeps an incremental scratch directory, and a separate task wipes that scratch directory on demand. Walk through which annotations you'd use where, and why.

level: seniorimportance: should knowfreq 18%

answer

  1. deliverables → @Output* (map if dynamic)
  2. scratch → @LocalState (not cached, cleaned on hit)
  3. wiper → @Destroys (parallel-safe scheduling)
  4. producer → @CacheableTask + @PathSensitive
  5. portability + restore + concurrency

basics

~10 s

Real artifacts → @OutputFiles/@OutputDirectories. The incremental scratch directory → @LocalState (tracked, not cached). The wipe task's deleted path → @Destroys so it never races the producer under --parallel. Inputs stay @Input/@InputFiles with path sensitivity.

solid answer

~40 s

Map each role to its annotation: the **deliverable artifacts** get `@OutputDirectory`/`@OutputFile` (or the plural `@OutputDirectories`/`@OutputFiles` with a `Map<String,File>` if dynamic) so they're fingerprinted and packed into the build cache. The **incremental scratch/analysis directory** gets `@LocalState`: Gradle tracks it for cleanup on cache restore but **never packs it into the cache**, keeping entries portable across machines. The producer is `@CacheableTask` with inputs declared `@Input`/`@InputFiles` plus `@PathSensitive` for cache-key stability. The **separate wipe task** declares the scratch directory with `@Destroys`, so the scheduler guarantees it never runs concurrently with — and is safely ordered against — the producer that reads/writes that path under `--parallel`. This separation keeps the cacheable task correct and portable while the destructive task stays race-free.

code

kotlin · 14 lines
kotlin
@CacheableTask
abstract class Gen : DefaultTask() {
    @get:InputFiles
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val sources: ConfigurableFileCollection
    @get:OutputDirectory abstract val out: DirectoryProperty
    @get:LocalState      abstract val scratch: DirectoryProperty
    @TaskAction fun run() { /* read sources + scratch, write out, update scratch */ }
}

abstract class WipeScratch : DefaultTask() {
    @get:Destroys abstract val scratch: DirectoryProperty
    @TaskAction fun wipe() = project.delete(scratch)
}

go deeper

for a junior

Identify that deliverables are outputs and the scratch is something special you don't cache.

for a middle

Correctly assign @Output*, @LocalState, and @Destroys to each role and justify the scratch exclusion.

for a senior

Integrate @CacheableTask + @PathSensitive and explain portability, cache-restore cleanup, and parallel scheduling together.

for a principal

Define plugin conventions covering all three concerns so a team's custom tasks are cache-portable and race-free at scale, including CI cache-hit strategy.

## The roles in play Three distinct kinds of files appear here, and each needs a different annotation because Gradle treats them differently for **caching**, **up-to-date checks**, and **scheduling**. ## 1. Real outputs → @OutputDirectory / @OutputFile (or plural) These are the artifacts consumers want. Annotating them as outputs means: - they're fingerprinted for up-to-date checks, - on a cacheable task they're **packed into the cache entry**, - downstream tasks can wire them as inputs. If the set is dynamic, use `@OutputFiles`/`@OutputDirectories` with a `Map<String,File>` so each output keeps a stable identity. ## 2. Incremental scratch → @LocalState The analysis/index directory speeds the *next local* run but is **machine-specific** and not a shippable artifact. `@LocalState`: - is **excluded from cache packing** (portable entries), and - is **deleted on a cache hit** so stale scratch never mixes with downloaded outputs. Using `@OutputDirectory` here would wrongly ship scratch into the cache; using `@Internal` would leave stale scratch after a cache restore. `@LocalState` is exactly the middle behavior. ## 3. The producer task itself → @CacheableTask + typed inputs ``` @CacheableTask abstract class Gen : DefaultTask() { @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val sources: ConfigurableFileCollection @get:OutputDirectory abstract val out: DirectoryProperty @get:LocalState abstract val scratch: DirectoryProperty @TaskAction fun run() { /* ... */ } } ``` `@PathSensitive(RELATIVE)` keeps the cache key stable when the project moves on disk — essential for cross-machine cache hits. ## 4. The wipe task → @Destroys A second task deletes the scratch directory on demand. Declaring it `@Destroys`: ``` abstract class WipeScratch : DefaultTask() { @get:Destroys abstract val scratch: DirectoryProperty @TaskAction fun wipe() = project.delete(scratch) } ``` lets Gradle's planner ensure the wiper is **mutually exclusive** with, and **safely ordered** against, the producer that reads/writes that same path — so a `--parallel` build can't delete scratch mid-generation. ## Why the separation matters - **Portability**: only real outputs travel in the cache; local scratch never does. - **Correctness on restore**: scratch is cleaned on cache hits. - **Concurrency safety**: the destructive task is scheduled, not racing. This is the canonical layering of the 'beyond-the-basics' output annotations: `@Output*` for deliverables, `@LocalState` for non-shareable intermediates, `@Destroys` for cleanup, all under a `@CacheableTask` producer with path-sensitive inputs.

  • Why not annotate the scratch directory as @OutputDirectory so it gets cached too?
    Scratch is machine-specific and not a deliverable; caching it would ship non-portable state to other machines and pollute cache entries. @LocalState keeps it out of the cache while still cleaning it on restore.
  • What breaks if the wipe task only uses mustRunAfter(gen) instead of @Destroys?
    mustRunAfter only orders those two tasks. @Destroys gives the scheduler the destroyed-path set so the wiper is kept exclusive of ANY task touching that path under --parallel, not just the one you named.
  • Why @PathSensitive(RELATIVE) on the inputs?
    It normalizes file paths to be relative, so the cache key doesn't change when the project is checked out at a different absolute path — enabling cache hits across machines/CI agents.

saying these in an interview costs you the question

  • Marking scratch as @OutputDirectory and shipping it in the cache.
  • Relying only on ordering rules for the destructive task under --parallel.
  • Forgetting @PathSensitive, which silently kills cross-machine cache hits.

context