8.4. Caching

Mark any labeled call site cache: true in the program YAML and its result is memoized to disk. The next call with equivalent inputs against unchanged source code is served from the cache rather than recomputed.

The freshness check is content-based, not time-based. Morloc has no notion of "the source file is newer than the cache entry, so re-run." Editing a comment in an unrelated function will not invalidate any cache. Copying the program to a new path will not either. Two builds on two machines that emit byte-identical pool sources share the same cache namespace. This is a deliberate departure from make, snakemake, nextflow, and similar tools that use mtime as a freshness signal and routinely re-run the world after a git checkout or a clock skew.

The build parameters passed with -X (see the build parameters section) also participate in the key, since they can change the compiled output without changing the pool source — switching a Futhark backend or adding a compiler flag is treated as a distinct build rather than a cache hit.

8.4.1. Declaring a cached call

labeled-groups:
  expensive_step: { cache: true }
foo xs = expensive_step@slowfn xs

Every call into expensive_step@slowfn is memoized under the expensive_step cache label. The same group config also controls per-step logging (log: true); the two flags are independent and may be combined.

8.4.2. What goes in the hash

A cached entry is keyed by:

call_key = xxh64(pool_source_fingerprint, midx, arg_content_hashes ...)

where:

  • pool_source_fingerprint is xxh64 of the rendered pool source text, seed-chained over the contents of any files listed under hash-include: in the program YAML. Editing the body of the cached function, of any function it calls, of any imported module that compiled into the same pool, or of any declared external data file, all shift this fingerprint.

  • midx is the compiler-assigned manifold id, deterministic per build.

  • arg_content_hashes are content-aware hashes of each argument’s value, walked through its msgpack schema. Two structurally equal inputs hash the same regardless of how their packet stored the data (inline bytes, shared-memory pointer, or temp file), and pointer bits are never themselves hashed.

The freshness test is therefore: same code + same code dependencies + same input values → cache hit. Anything else → miss.

8.4.3. Storage layout

The cache lives under one of these directories, in resolution order:

Source Notes

MORLOC_CACHE_BASE env var

Explicit override. Useful for Docker bind mounts and shared filesystems where SLURM workers need access to the same cache.

$XDG_CACHE_HOME/morloc/cache

If XDG_CACHE_HOME is set.

~/.cache/morloc/cache

Default.

Inside, two file types coexist:

 . ~/.cache/morloc/cache/
 │
 ├── expensive_step/
 │   ├── 3f4a91d62b08e7d2.packet
 │   └── b772aa1c0e4ef801.packet
 ├── another_label/
 │   └── ...
 └── data/
     ├── ef46db3751d8e999.dat
     └── ...
  • <label>/<call_key>.packet is a small morloc data packet (PACKET_TYPE_DATA, source=FILE, format=MSGPACK) that points to a dat file under data/. One entry per distinct cached call.

  • data/<data_hash>.dat holds the cached value as schema-agnostic msgpack bytes. The filename is xxh64 of those bytes, so equal return values consolidate onto one on-disk copy regardless of which label cached them or which pool language produced them.

The content-addressed split gives natural deduplication: a Python pool and an R pool that both cache the same dataset (say, a genome pulled from an external database) write the data once. A source edit that produces the same return value writes a new .packet pointer but reuses the existing .dat.

8.4.4. Reading and writing

On lookup, the runtime reads the .packet file and returns its bytes; the language pool’s get_value follows the FILE-source pointer through to the dat file. The on-disk format is opaque to the language pool — it sees only bytes that round-trip through the standard packet API, so future format changes (inline-small optimization, compression, alternative backends) require no recompile of pool code.

On store, the runtime materializes the input packet to msgpack (dereferencing any shared-memory or file-backed payload), hashes those bytes to choose the dat filename, skips the write if an identical dat already exists, and finally writes the small pointer packet under the label dir. Both writes go through an atomic tmp
rename, so a partial file is never observed by another reader and concurrent writers to the same key cannot corrupt the on-disk state.

A cached call whose foreign body raises is not memoized — the cache wrap only stores on the happy path. A re-run with the same inputs fails the same way rather than silently serving a stale entry.