On this page

Task caching 🔗

Deder caches task outputs on disk so tasks whose inputs haven't changed don't re-run. This is separate from Scala incremental compilation: when compile does run it invokes Zinc, which does incremental compilation internally.

How caching behaves 🔗

Cached vs always-run tasks 🔗

Whether a task is cached or always runs is noted in the Task Reference — that page is the authoritative inventory.

To verify against source, grep server/src for CachedTaskBuilder (cached). Always-run tasks extend TaskImpl — this includes TaskBuilder-constructed tasks as well as ConfigValueTask (config reads) and SourceFileTask/SourceFilesTask (source-file tracking).

compile is a CachedTask, so it should skip when its inputs are unchanged — but see the "never content-hash a task's own outputs" anti-pattern below: it currently has self-referential dependencies that keep it from hitting. Even when it does run, Zinc skips unchanged sources internally.

Where cached data lives 🔗

Every cached task writes a metadata.json (plus any task-specific artifacts) under:

.deder/out/<module-id>/<task-name>/metadata.json

metadata.json stores the task's stored value, inputsHash, and outputHash.

Before a task re-executes, its previous metadata is removed. New metadata is written to a temporary file in the same directory, then published with an atomic move. A failed execution or metadata write leaves no reusable entry from that execution. Temporary files are removed on handled failures; an abrupt process termination can leave an unused temporary file. Artifact writes themselves are not transactional.

Task implementation versions 🔗

CachedTaskBuilder.withCacheVersion(n) sets a task-specific implementation version. The default, 0, preserves the original cache-key calculation and existing entries. Nonzero versions are included in the input hash; changing the version invalidates that task's stored result. Deder's release/build version is not part of this key.

Downstream invalidation continues to follow dependency output hashes. A task whose output identity includes its input hash, such as compile, propagates the version change through that identity; an unchanged value-based result can remain reusable downstream.

Complete cache restores 🔗

Cache reuse assumes all cached artifacts were copied or restored completely. Deder does not check artifact presence or contents on cache hits. Missing or manually deleted outputs require cleaning and rebuilding the affected tasks. No output manifests are maintained.

What invalidates a cached task 🔗

Clearing the cache 🔗

deder clean -m <module-id> removes .deder/out/<module>/ entirely (all cached artifacts and metadata for that module). Multiple -m flags clean multiple modules:

deder clean -m mymodule
deder clean -m mod1 -m mod2

deder clean -t <task-name> removes .deder/out/<module>/<task>/ for the specified task across all modules. Combine -m and -t to target a specific task on specific modules:

deder clean -t compile
deder clean -m mymodule -t test
deder clean -m mod% -t compile%

Wildcard patterns (%) are supported for both -m and -t flags.

How caching works 🔗

Two task kinds 🔗

The hash chain 🔗

SourceFileTask (CachedTask leaf) ──► outputHash = hash(file contents)
         │
         ▼
compileClasspath (CachedTask) ──────► inputsHash = hash(dep outputHashes)
         │                             outputHash = hash(result)
         ▼
compile (CachedTask) ───────────────► skips if its inputs are unchanged
                                       outputHash = hash(classes dir)
         │
         ▼
assembly (CachedTask) ──────────────► skips if compile's outputHash unchanged

Hashable[T] derivation 🔗

Explicit instances live in server/src/ba/sake/deder/Hashable.scala: Int, String, Boolean, os.Path, Option[T], Seq[T], Map[K,V]. A low-priority given derives Hashable[T] from JsonRW[T] by hashing the JSON string, so any config type is automatically hashable. Explicit instances take priority — os.Path hashes file/directory contents (recursively, with leaf names bound to child hashes) rather than the path string.

Anti-pattern: never content-hash a task's own outputs 🔗

Hashable[os.Path] / Hashable[DederPath] hash directory contents recursively, and a cache key is built from its dependencies' outputHashes. Therefore a task must never take a content-hashed dependency (dependsOn) on a directory that the task itself — or any task downstream of it — writes into.

This is not a task-graph cycle, so cycle detection will not catch it. The DAG stays acyclic (e.g. compile → compileClasspath → allClassesDirs → classes); the coupling is through the filesystem: compile writes the class dir that one of its own dependencies content-hashes. The consequences:

Rules:

  1. A task's cache key must be derived only from its true inputs (sources, compiler options, upstream module outputs) — never its own outputs.
  2. If a task needs an output path at execute time (e.g. the module's own classes dir on the compiler classpath, needed for javac annotation processing where generated Java sources feed Scala), derive the path in execute (ctx.out / os.up / "classes") — do not add a content-hashed dependsOn.
  3. Represent a produced directory's "version" by its producer's input hash (identity), not by re-hashing its bytes. Cheaper, and immune to non-deterministic compiler output.
  4. Build outputs live under .deder/out/<module>/<task>/. A content-hashed cache input pointing inside that tree (for the same or a downstream module) is the smell to watch for — consider it a bug.

Tracked offenders (2026-06-14): compileTask depends on semanticdbDirTask, and (via compileClasspathTask → allClassesDirsTask) on its own classesTask — both directories compile writes. Until they're removed from compile's cache key (derive the paths at execute time instead), compile re-runs Zinc and re-hashes its class tree on every no-op.

Known limitations 🔗

⬅️ Architecture Files layout ➡️