How do collection properties behave as task @Input snapshots and around finalization, and what mistakes break up-to-date / configuration-cache correctness?
answer
- resolved & snapshotted at fingerprint time
- finalizeValueOnRead locks first read
- mutate-after-finalize => IllegalStateException
- Set snapshot order-insensitive; List order matters
- CC: serializable, no Project/Task capture
basics
~10 sAs @Input, a collection property's resolved value is snapshotted for up-to-date checks. Resolution is deferred until just before execution and then finalized, so changes (and contributed providers) are captured. Mutating after finalization throws.
solid answer
~50 sWhen a ListProperty/SetProperty/MapProperty is annotated @Input (or @InputFiles for files), Gradle resolves and snapshots its value when computing the task's input fingerprint — after configuration, before the action runs. Because the value is lazy, providers contributed via add/addAll/put are resolved at that moment, capturing late-bound values and any implicit task dependencies. After resolution Gradle finalizes the property; further add/put/set calls throw IllegalStateException. This is what makes up-to-date checks honest: the fingerprint reflects the actual resolved collection, so changing a contributed value busts the cache. The classic mistakes: (1) reading get() too early in configuration, freezing a stale value; (2) using mutable non-tracked state (a plain List captured in a closure) so changes don't bust the cache; (3) putting non-serializable or non-deterministic content in the collection, breaking the configuration cache; (4) ordering assumptions on SetProperty, whose snapshot is order-insensitive.
code
kotlin · 11 linesabstract class HashInputsTask : DefaultTask() {
@get:Input abstract val files: ListProperty<String>
init { files.finalizeValueOnRead() }
@TaskAction fun run() {
val resolved = files.get() // finalized: snapshot taken here
logger.lifecycle("inputs=$resolved")
// files.add("late") // would throw IllegalStateException
}
}go deeper
Know that as @Input the property feeds up-to-date checks and is read at execution.
Explain deferred resolution at fingerprint time and that contributed providers are captured then.
Cover finalizeValueOnRead/finalizeValue/disallowChanges, mutate-after-finalize errors, and Set order-insensitivity.
Set conventions ensuring lazy inputs stay configuration-cache compatible (no Project/Task capture, serializable contributors) across plugins.
## Lifecycle of a collection input 1. **Configuration** — you wire the property with `add`/`addAll`/`put`, possibly pointing at other providers. 2. **Finalize-on-read for inputs** — before the task action runs, Gradle queries the property to fingerprint the input. With `finalizeValueOnRead()` (used by managed task inputs), the value is computed *and locked* at first read. 3. **Snapshot** — the resolved `List`/`Set`/`Map` is hashed into the task's input state. `SetProperty` snapshots are order-insensitive; `ListProperty` order matters. 4. **Execution** — the action reads the now-finalized value. Mutating the property here throws `IllegalStateException`. ## Why this gives correct up-to-date checks Because resolution is deferred to fingerprint time, a contributed `Provider` that points at another task's output is captured at its real value, and the consuming task automatically depends on the producer. Change any contributed element and the fingerprint changes, so the task re-runs. Eager `List`s captured in closures bypass this and silently break incrementality. ## Configuration cache implications The configuration cache serializes the task graph, including the *recipe* for lazy properties. For this to work: - Contributed values/providers must be serializable and side-effect-free. - Avoid capturing `Project`, `Task`, or other non-CC-compatible references inside provider lambdas feeding the collection. - Prefer `providers.*` (gradleProperty, environmentVariable) over reading mutable ambient state. ## Finalization controls - `finalizeValue()` — eagerly resolve and lock now. - `finalizeValueOnRead()` — lock on first read (default for managed task inputs). - `disallowChanges()` — forbid further mutation without resolving yet. ```kotlin abstract class ManifestTask : DefaultTask() { @get:Input abstract val entries: MapProperty<String, String> @TaskAction fun run() { // entries is finalized here; entries.put(...) would throw entries.get().forEach { (k, v) -> logger.lifecycle("$k=$v") } } } ``` ## Common breakages | Mistake | Effect | |---|---| | `entries.get()` during configuration | Freezes a stale/empty value before contributors run | | Backing the property with an external mutable `List` | Changes don't bust up-to-date checks | | Non-serializable lambda feeding `addAll` | Configuration-cache failure | | Assuming order from a `SetProperty` | De-dup + order-insensitive snapshot surprises |
- Why does reading get() during configuration hurt incrementality?It resolves the property before later add/addAll contributors run, freezing a stale value and detaching it from the lazy graph, so subsequent changes don't reflect in the input fingerprint.
- What does finalizeValueOnRead() do for a task input?It defers resolution to the first read and then locks the value, so the snapshot reflects all configuration-time contributions while preventing later accidental mutation.
- Two builds produce the same SetProperty elements in different add order. Same up-to-date result?Yes — a SetProperty snapshot is de-duplicated and order-insensitive, so element identity, not insertion order, drives the fingerprint.
saying these in an interview costs you the question
- Saying the value is snapshotted at configuration time (it's at fingerprint/first-read time).
- Claiming you can keep add()-ing inside the task action.
- Backing a lazy input with an external mutable collection and expecting correct up-to-date checks.