Gazelle: ASPECT_GAZELLE_CACHE Internals

2026/02/23

BUILDbazelcaching
Series: Gazelle (6 posts)
  1. Gazelle: How import yaml Becomes @pypi//pyyaml
  2. Gazelle: Python Manifest and modules_mapping
  3. Gazelle: ASPECT_GAZELLE_CACHE Internals
  4. Gazelle: Python in a Monorepo, the Invisible Machinery
  5. Gazelle: When It Can’t Help, Hand-Writing BUILD Files
  6. Gazelle: Reading the Source Code

Gazelle in a large monorepo is slow. Like, 30-seconds-of-staring-at-your-terminal slow. Set one environment variable and it drops to 3 seconds. That variable is ASPECT_GAZELLE_CACHE, and today I dug into what it actually does under the hood.

The one-liner that saves 27 seconds

In your .bazelrc:

common --repo_env=ASPECT_GAZELLE_CACHE=.cache/aspect-gazelle.cache

That’s it. Gazelle now persists a file-based cache between runs. Unchanged files get skipped entirely — no re-parsing, no re-resolving. The parser is the expensive part, so skipping it is where the time savings come from.

Where the code lives (not where you’d expect)

I spent a while grepping through rules_py looking for cache logic. It’s not there.

The cache lives in aspect-build/aspect-gazelle — the pre-compiled gazelle binary that Aspect distributes. Key files:

File Role
common/cache/configurer.go Plugs into Gazelle’s config system
common/cache/disk.go Gob-encoded disk persistence
runner/pkg/watchman/cache.go Watchman-aware cache for watch mode

Cache key: content + version

The cache key for each file is:

MD5(gitCommit + fileContent)

Two things invalidate a cache entry:

  1. File content changes — you edited the file, hash changes, entry invalidated
  2. Gazelle version changes — the gitCommit of the gazelle binary itself is baked into the key, so upgrading gazelle blows the whole cache

This is clever. You don’t need a separate “cache version” mechanism or manual invalidation. If gazelle’s parser changes behavior in a new release, every cached entry becomes a miss automatically.

The lifecycle

On startup, the binary reads the gob-encoded cache file from disk into memory. Then during the run, every time gazelle needs to process a source file, it calls LoadOrStoreFile():

LoadOrStoreFile(path, contentHash, loaderFunc)
  → cache hit?  return stored result, skip parser
  → cache miss? call loaderFunc (the actual parser), store result

On shutdown, Persist() writes the updated map back to disk. One read, one write, everything else is in-memory lookups.

How it plugs in

This is the part I found most interesting architecturally. The cache isn’t bolted on as an afterthought — it’s wired in as a Gazelle config.Configurer, the same extension point that languages use.

The flow:

  1. CheckFlags() — reads ASPECT_GAZELLE_CACHE env var, creates the cache instance
  2. During generation — language extensions call cache.Get(config) to access the cache, use LoadOrStoreFile() for each source file
  3. DoneGeneratingRules() — calls Persist() to flush to disk

Using the Configurer interface means the cache participates in gazelle’s normal lifecycle without any special-case plumbing. It’s just another configurer, like a language extension.

Watchman mode: even smarter

In watch mode (when you have a watchman daemon running), the cache gets an upgrade. Instead of hashing file contents to detect changes, it uses watchman’s clock specs — essentially “what changed since the last time I asked?”

This means:

The watchman-aware cache (runner/pkg/watchman/cache.go) wraps the base cache and adds this clock-based invalidation. In a monorepo with thousands of directories, not re-walking untouched subtrees is a huge win on top of not re-parsing unchanged files.

The takeaway

ASPECT_GAZELLE_CACHE is a “don’t re-parse unchanged files” optimization. The implementation is clean — content-addressed keys with version pinning, gob encoding for fast serialization, and the Configurer interface for zero-friction integration. If you’re using Aspect’s gazelle binary and haven’t set this, you’re leaving a 10x speedup on the table.