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.
answer
- deliverables → @Output* (map if dynamic)
- scratch → @LocalState (not cached, cleaned on hit)
- wiper → @Destroys (parallel-safe scheduling)
- producer → @CacheableTask + @PathSensitive
- portability + restore + concurrency
basics
~10 sReal 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 sMap 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@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
Identify that deliverables are outputs and the scratch is something special you don't cache.
Correctly assign @Output*, @LocalState, and @Destroys to each role and justify the scratch exclusion.
Integrate @CacheableTask + @PathSensitive and explain portability, cache-restore cleanup, and parallel scheduling together.
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.