kache 1.2.0

Compiler cache for Rust, C/C++ and CUDA, with S3, GCS, shared-filesystem and OCI remotes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
---
title: Configuration
description: Configure Kache with TOML files and environment variables
---

Kache resolves settings in this order:

1. environment variable
2. config file
3. [host config](#host-config)
4. built-in default

Some settings exist only as environment variables, among them `KACHE_DISABLED`, `KACHE_CONFIG`, `KACHE_HOST_CONFIG`, `KACHE_SOCKET_PATH`, `KACHE_TRUST_DOMAIN`, the logging and progress controls, and the S3 and OCI credentials. Where the tables below list one, its TOML key is `none`.

## Config file

Open the editor:

```bash
kache config
```

Or edit TOML directly. Kache chooses the first file in this order:

1. `KACHE_CONFIG`
2. the nearest `.kache.toml`, walking up from the current directory
3. `$XDG_CONFIG_HOME/kache/config.toml` or `~/.config/kache/config.toml`

Kache reads only that one file, plus the [host config](#host-config); a `.kache.toml` does not merge with your user config.

Each process walks up from its own current directory. Cargo compiles packages under the workspace root from the workspace root, but compiles registry and git dependencies, and runs their build scripts, from each package's source directory under `$CARGO_HOME`, so those compiles do not see your project's `.kache.toml`. A daemon that a build starts resolves its file from the directory of the compile that started it, which can be a dependency's source directory. To apply a setting to every compile and to the daemon, put it in your user config or the host config, or point `KACHE_CONFIG` at the file.

If the chosen file is not valid TOML, or a key has the wrong type, Kache ignores the whole file, and the host config with it, and runs on environment variables and defaults without a warning. Size keys (`local_max_size`, `event_log_max_size`, `prefetch_max_bytes`) take quoted strings such as `"50GiB"`.

The editor covers common fields and keeps the other settings Kache knows. Saving rewrites the whole file: comments and keys Kache does not recognize are dropped, and a file that does not parse is replaced. The editor asks before it first overwrites an existing file.

```toml title="~/.config/kache/config.toml"
[cache]
cache_executables = true

[cache.remote]
type = "s3"
bucket = "my-build-cache"
region = "us-east-1"
```

Use `type = "filesystem"` with `path = "/mnt/kache"` for a shared filesystem remote. Kache also supports `gcs` and `oci` remotes; see [Remote settings](#remote-settings).

### Host config

A machine owner can set keys for every build on the machine in `/etc/kache/config.toml`. Kache merges it with the chosen file key by key: a key the chosen file sets wins, and a key it leaves unset comes from the host file. Environment variables still win over both.

The host file reaches CI jobs too. kache-action points `KACHE_CONFIG` at a file of its own that only sets `[cache.remote]`, so a host file like this applies to every job on the runner:

```toml title="/etc/kache/config.toml"
[cache]
local_max_size = "200GiB"
```

- `[cache.remote]`, `[cache.planner]` and `[cache.volumes]` are replaced whole. If the chosen file declares one, none of the host file's keys for it apply.
- A remote set only through `KACHE_S3_*` variables also replaces the host remote, as long as the chosen file declares no `[cache.remote]`. The same goes for `KACHE_PLANNER_*` and `[cache.planner]`, so a host planner token is never sent to an endpoint the environment picked. A daemon started by a build, `kache daemon start` or `kache daemon restart` does not inherit the variables that select the remote, so it uses the host remote; Kache warns when it starts one that way.
- The host file never contributes `[workspace]` declarations. Those belong to a project's `.kache.toml`.
- The host file cannot set `ignore_env`. Kache ignores it with a warning, so the host file can never turn environment overrides off. Only the chosen file can; see [Pin file-backed settings](#pin-file-backed-settings).
- A host file that cannot be read or parsed is ignored with a warning. `kache doctor` reports it, and lists each host key with whatever overrides it.
- `KACHE_HOST_CONFIG` reads another path instead. An empty value turns the host layer off.
- `kache config` edits the chosen file, never the host file.

## Core settings

| Environment | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| `KACHE_CACHE_DIR` | `cache.local_store` | OS cache directory | Persistent store |
| `KACHE_TRUST_DOMAIN` | none | unset | Isolate the local store under `<cache dir>/<label>/`; a runtime dir left at its default moves with it. The label is one path component of up to 64 characters from `[A-Za-z0-9._-]`, and not `.` or `..`. Invalid labels are ignored with a warning. Do not set this on public or fork jobs. |
| `KACHE_RUNTIME_DIR` | `cache.runtime_dir` | local store | Socket, locks, logs, state, and session markers |
| `KACHE_READONLY_STORE` | `cache.readonly_store` | unset | Another store on this machine to read after the local one, never written. See [Read-only store](#read-only-store). |
| none | `cache.volumes` | unset | Volume-local store shards, e.g. `[cache.volumes] "D:" = "D:/kache-store"`. Builds on a mapped volume share inodes there instead of copying across mounts. Unmapped volumes use the main store. GC sweeps each shard with its own budget; see [Volume shards](#volume-shards). |
| `KACHE_SOCKET_PATH` | none | `<runtime_dir>/daemon.sock` | Absolute daemon endpoint override |
| `KACHE_MAX_SIZE` | `cache.local_max_size` | 5% of the cache disk, rounded to the nearest GiB, floored at 5GiB and capped at 100GiB; 50GiB when the disk size cannot be read | Registered blob bytes allowed before GC. An explicit size always wins. `none` is not allowed. |
| `KACHE_AUTO_GC` | `cache.auto_gc` | `true` | Spawn throttled background GC under size pressure |
| `KACHE_GC_MAX_AGE_HOURS` | `cache.gc_max_age_hours` | `0` | Automatic age retention; `0` disables it |
| `KACHE_GC_EVICT_SHARED` | `cache.gc_evict_shared` | `false` | Restore namespace-first GC for entries retained by build outputs |
| `KACHE_INDEX_AUTO_COMPACT` | `cache.index_auto_compact` | `true` | Let the daemon heal the blob index and compact `index.db` when the machine is quiet. See [Daemon index maintenance](#daemon-index-maintenance). |
| `KACHE_AUTO_CLEAN_ORPHANED_TARGETS` | `cache.auto_clean_orphaned_targets` | `true` | Let the daemon remove target directories whose workspace was deleted. See [Target directory cleanup](#target-directory-cleanup). |
| `KACHE_AUTO_SHARE_TARGET_FILES` | `cache.auto_share_target_files` | `true` | Let the daemon replace target files that are full copies of stored blobs with clones of those blobs. See [Target directory cleanup](#target-directory-cleanup). |
| `KACHE_AUTO_CLEAN_IDLE_TARGETS_DAYS` | `cache.auto_clean_idle_targets_days` | `0` | Let the daemon also remove target directories no build has used for this many days; `0` disables it |
| `KACHE_AUTO_RECOVER_MIN_FREE_BYTES` | `cache.auto_recover_min_free_bytes` | `10737418240` (10 GiB); `0` in CI | Below this many free bytes, builds evict eligible cache entries; `0` disables recovery. See [Target directory cleanup](#target-directory-cleanup). |
| `KACHE_AUTO_RECOVER_TARGETS` | `cache.auto_recover_targets` | `false` | Below the free-space floor, also remove idle target directories when the machine is quiet. `kache init` offers to turn it on. See [Target directory cleanup](#target-directory-cleanup). |
| `KACHE_TARGET_LIVENESS` | `cache.target_liveness` | `false` | Record successful Cargo build/check commands for exact-scope unit cleanup; see [Recorded-command cleanup](#recorded-command-cleanup). |
| `KACHE_AUTO_CLEAN_UNUSED_UNITS_DAYS` | `cache.auto_clean_unused_units_days` | `30` | Let the daemon remove, from target directories still in use, build units no build has used for this many days; `0` disables it. See [Unused build units](#unused-build-units). |
| `KACHE_SEED_NEW_TARGETS` | `cache.seed_new_targets` | `true` | Copy another checkout's registry build units into a target directory Cargo has not built yet. See [New checkouts](#new-checkouts). |
| `KACHE_MIN_STORE_COMPILE_MS` | `cache.min_store_compile_ms` | `0` | Do not store results whose compile took less than this many milliseconds; `0` stores all eligible results. Ignored for a result that publishes to a writable remote, because the upload reads the local entry |
| `KACHE_COMPRESSION_LEVEL` | `cache.compression_level` | `3` | Zstd level, clamped to 1 through 22 |
| none | `cache.event_log_max_size` | `64MiB` | Rotate the event log after this size. Rotation keeps the build in progress, up to half this size |
| none | `cache.event_log_keep_lines` | `1000` | Minimum lines retained when the event log rotates |
| `KACHE_DISABLED` | none | `false` | Pass all compiler work through when set to `1` or `true` |
| `KACHE_LOCAL_ONLY` | `cache.local_only` | `false` | Ignore all remote and planner configuration while keeping local caching |
| `KACHE_REMOTE_READONLY` | `cache.remote_readonly` | `false` | Allow remote reads but suppress remote writes. On GitHub Actions and GitLab CI, every job except a push to a protected branch is also forced read-only, and `=0` does not turn that off. A pull request job with `pull_request_prefix` set writes under that prefix instead, unless this setting is on or the remote is OCI. See [Who may write](/docs/remote-cache/ci#who-may-write). |
| `KACHE_SCHEDULER` | `cache.scheduler` | `true` | Pace real compiler processes across concurrent builds after a miss. Hits and passthroughs never wait. `0` or `false` turns it off. With the [test runner](#test-runner), it paces test binaries too. |
| `KACHE_SCHEDULER_MEMORY_PRESSURE` | `cache.scheduler_memory_pressure` | `true` | Under memory pressure, run compiles one after another. See [Limits](/docs/how-it-works/limits). |

On macOS the default store is `~/Library/Caches/kache`; on Linux it is `$XDG_CACHE_HOME/kache` or `~/.cache/kache`; on Windows it is under `%LOCALAPPDATA%`.

## Test runner

Several `cargo test` runs on one machine each start a test thread per CPU. `kache test-runner` makes test binaries wait for slots in the scheduler's pool instead, the same pool compiles use.

On Unix, `kache init` turns it on for every project unless you pass `--no-shell`. It adds this line to your shell startup file, with your machine's target in the name:

```bash
[ -n "${CARGO_TARGET_AARCH64_APPLE_DARWIN_RUNNER-}" ] || CARGO_TARGET_AARCH64_APPLE_DARWIN_RUNNER='kache test-runner'; export CARGO_TARGET_AARCH64_APPLE_DARWIN_RUNNER
```

It reaches every process started from a shell that reads that file, including coding agents launched from a terminal. A value you set earlier in the file is kept.

Cargo ranks the variable above a project's own `[target.<triple>]` or `[target.'cfg(..)']` runner, so the test runner looks that runner up and runs the test binary under it. A project that tests under `sudo -E` or an emulator keeps doing so. Cargo reads configuration from the directory it was started in, which a test binary is not told, so the lookup reads `.cargo/config.toml` upward from both the package directory and `$PWD`, plus the files they `include` and `$CARGO_HOME/config.toml`. `cfg` runners are matched against `rustc --print cfg` with the `--cfg` flags from `RUSTFLAGS`.

When the test runner cannot tell which runner Cargo would have used, the test fails with `cannot tell which runner Cargo would use` and the reason. That happens when:

- the package directory and `$PWD` configure different runners, as with `cargo test --manifest-path` run from outside the project;
- more than one `cfg` runner matches;
- rustc cannot report the target's cfg, or a `cfg` runner's expression cannot be read;
- `--cfg` flags come from `build.rustflags`, `target.<triple>.rustflags` or `CARGO_BUILD_RUSTFLAGS`, which the test runner does not evaluate.

Unset the variable in that shell to let Cargo choose.

To turn it on for one project instead, set it as Cargo's target runner in the project's `.cargo/config.toml`:

```toml title=".cargo/config.toml"
[target.'cfg(all())']
runner = ["kache", "test-runner"]
```

For a single run, pass the same setting to Cargo:

```bash
cargo test --config "target.'cfg(all())'.runner = ['kache', 'test-runner']"
```

Cargo rejects two `cfg` runners that both match a target, so if the project already sets one, replace it rather than adding a second. Do not put a `cfg(all())` runner in `$CARGO_HOME/config.toml`: every project with its own `cfg` runner, embedded projects using probe-rs among them, would then fail to load its configuration.

What a scheduled test binary does:

- It takes slots from the top of the pool. A quarter of the pool, at least one slot, is kept for compiles, so tests never lock a build out.
- With no thread count set, it takes every free test slot, at least one, and runs with `RUST_TEST_THREADS` set to the number it holds. The reserve stays free even when nothing else runs, so a lone `cargo test` gets fewer threads than without the runner: 12 on 16 CPUs, 3 on 4, 1 on 2. With `--test-threads` or `RUST_TEST_THREADS`, it takes that many slots and the setting is left alone. Under nextest's process-per-test mode, each test takes one slot.
- On Unix, Kache records the binary's peak memory. A binary that used more than 512 MiB takes at least one slot per 512 MiB on its next run. Under nextest's process-per-test mode it records each test's peak instead, so a heavy test does not raise the others.
- It waits up to 30 minutes for slots, then runs without them. Under nextest the wait is 2 minutes, because nextest counts it toward the test's timeouts.
- Builds and test binaries started inside a test count against that test's slots and do not wait for slots of their own. A nested `cargo build` can therefore run more compiles at once than its test holds slots.
- While tests hold slots, a compile that has waited asks for no more slots than the tests leave free, and never fewer than the reserve. A test that waits on a build therefore cannot block that build.
- Exit codes, signals, stdin and Ctrl-C behave as if the binary ran directly. The runner writes nothing to stdout.

The runner starts these directly, without waiting for slots: binaries Cargo does not name with a hash (`cargo run`, doctests), test listing (`--list`, which nextest uses), benchmarks (`--bench`), and every run when `KACHE_SCHEDULER=0` or `[cache] scheduler = false`.

A nested build finds its test's slots through `KACHE_TEST_LEASE`. A build that runs with a cleared environment cannot see it, so it waits for slots of its own, up to the same limit.

## GC and shared build outputs

`cache.local_max_size` measures blob bytes registered in Kache's store. A Cargo target directory can hardlink or block-clone those blobs. In that case the store name and the build output refer to the same disk blocks.

By default, GC keeps an entry when removing its last store name would reclaim zero bytes. This preserves a usable cache hit. It also means the registered store size can remain above `local_max_size` until the target output is removed. `kache gc` reports these entries as unreclaimable, with the bytes they hold, and separates store bytes removed from disk bytes reclaimed.

A size-driven sweep leaves those held bytes out of its budget. Before it removes anything, it finds the entries whose last blob a build output still holds, and it evicts until the rest of the store is back under the target. A store whose registered size is mostly held by target directories is therefore not evicted further: removing other entries would lose cache hits without bringing the held bytes down.

Automatic eviction has one trigger: a sweep starts when the store is above 110% of `local_max_size` and evicts down to 90%. Three things can ask for one: a build that has just stored an entry (checked at most every 5 minutes), the daemon after an upload, and the daemon's 6-hour sweep. `kache gc` evicts from 100%.

While a daemon is running, it does the sweeping. A build that finds the store over the trigger sends the daemon a hint and continues; it does not wait for the sweep, and it never starts a daemon for this. Hints that arrive during a sweep are dropped. With no daemon, or a daemon too old to accept the hint, the build starts a detached `kache gc`. That worker sweeps a second time two minutes later only if the first sweep had to skip entries a running build was using.

Every automatic sweep records the store size it ended at, whichever of the three started it. When an automatic sweep leaves the store over budget, the next automatic sweep waits 10 minutes. Each further sweep that ends over budget doubles the wait, up to 2 hours. The wait ends early when the store grows by more than 10% of `local_max_size`, and resets once a sweep brings the store back under budget. The wait applies to all three, so one sweep that cannot free enough holds the others back too. The 6-hour sweep still expires entries by `cache.gc_max_age_hours` and removes duplicates during a wait; only its size eviction is skipped. The state lives in `auto-gc-backoff.json` in the cache dir. `kache gc` ignores it.

`auto-gc-backoff.json` also keeps the bytes the last sweep found held by target directories. For 6 hours the trigger compares the store size minus those bytes with 110% of `local_max_size`, so a store over budget only on held bytes starts no sweep. Deleting a worktree frees what it held without changing the store size; after 6 hours the trigger uses the full size again, and the next sweep measures anew.

Automatic sweeps also keep entries downloaded from the remote in the last 6 hours, whether a build has used them yet or not. A CI job that downloads entries from the remote and then runs several cargo commands keeps them for the whole job; without this, an upload between two commands could evict what the next command needs. If those entries alone keep the store over budget, the sweep stops and backs off as described above. `kache gc` does not keep them.

A sweep pauses for 150 ms after every 50 ms of index writes, and after each removal that finds a build holding the index write lock, so the sweep does not hold up builds that are storing entries.

To reclaim the blocks, remove the retaining build output. `kache clean --dry-run` lists every target directory Kache has seen, how long it has been idle, and what deleting it frees. It also marks targets of deleted worktrees. For stale targets, preview the deletion first:

```bash
kache clean --stale 14d --dry-run
kache clean --stale 14d --yes
```

Targets of deleted worktrees are offered at any age. `kache clean --orphans --yes` removes only those, which suits a hook that runs after `git worktree remove`.

If enforcing the store namespace limit matters more than retaining hits, let GC evict shared entries:

```toml title="~/.config/kache/config.toml"
[cache]
gc_evict_shared = true
```

The equivalent environment setting is `KACHE_GC_EVICT_SHARED=1`. With this option, GC can drop an entry even though target files still hold all its blocks. The registered store size falls, but disk usage does not. A later build must compile or download that entry again. Explicit `purge` and repair operations remain unconditional regardless of this setting.

### Volume shards

Every GC driver sweeps each `[cache.volumes]` shard as well as the main store: `kache gc`, the daemon's 6-hour sweep, its sweep after an upload or a build's hint, and the detached auto-GC worker. Each shard is swept like the main store, with the same age, duplicate, and size passes.

Each shard has its own budget. An explicit `local_max_size` or `KACHE_MAX_SIZE` applies to each shard as it is. Without one, a shard's budget is 5% of the filesystem that holds it, floored at 5GiB and capped at 100GiB, the same rule as the main store.

A shard keeps its own `gc.lock`, `auto-gc-backoff.json`, `gc_stats.json` and `savings.json` in its store dir, so its trigger, backoff and held bytes do not mix with the main store's. `kache gc` prints one line per shard after the main store's.

GC skips a shard whose store dir has no `index.db`: a missing directory, an unmounted volume, or a shard no build has written to yet. It never creates a shard directory. A shard that fails to open or sweep is logged, and GC carries on with the others and the main store.

`kache stats` and `kache stats --full` include every configured store. Entry counts count each cache key once; stored bytes count every copy across stores. JSON includes per-store paths, sizes, budgets and errors. The `stats --json` filesystem `disk` and index measurements still describe the main store; use `stores` for shard measurements.

`kache list` and `kache explain` show the stores that hold each entry. `kache doctor` checks shard availability and accounting, and its link-layout probe uses the shard mapped to the current directory. `doctor --verify` checks every stored copy; `--repair` also repairs shards. Missing shards are reported without creating their directories.

`kache gc --json`, `kache gc --stale-schema` and emptying the cache with `kache clean --cache` still cover only the main store. Automatic prefetch still imports into the main store.

### Read-only store

Set `readonly_store` to read another store on the same machine after your own. Mount that store read-only, for example by giving a CI container the build host's store through a `:ro` volume:

```toml title="~/.config/kache/config.toml"
[cache]
readonly_store = "/kache-host"
```

The equivalent environment setting is `KACHE_READONLY_STORE=/kache-host`. The value is the directory that holds that store's `index.db`. An empty value turns it off.

- Rust and C/C++ lookups ask the local store first (a volume shard, then the main store), then the read-only store. NVCC currently uses its own local and remote stores.
- A hit there is restored as a private copy (a reflink where the filesystem supports it), never a hardlink into that store.
- Kache skips index writes, hit stamps and maintenance there. A damaged or legacy entry is a miss. A rejected hit compiles and caches in your own writable store.
- Its owner must keep the WAL side files available. A WAL-mode index without `index.db-wal` and `index.db-shm` is skipped with a warning. A read-only mount is required to prevent SQLite from creating side files if the owner removes them during an open. The connection alone cannot enforce that protection on a writable mount.
- With a read-only store set, a unit with no record in the local store is keyed before it compiles, so it can still hit there.

The read-only path is used as configured. `KACHE_TRUST_DOMAIN` isolates your writable store and runtime, but does not change this path. Mount a store that the job is allowed to read for its trust domain.

The local store is not designed for sharing across machines. To share between machines, use a [remote](#remote-settings).

### Daemon index maintenance

The daemon looks after `index.db` on its own. It checks 60 seconds after it starts, every 5 minutes after that, and once at shutdown. Each check runs two steps in order: it heals the blob index, then compacts the file. Both steps hold the index write lock while they work, so both wait for a quiet machine. A machine is quiet when no compile or test binary run through the [test runner](#test-runner) holds a scheduler slot, and the daemon has received no build request for 60 seconds. Stats polling and an open `kache monitor` do not count as build requests. Neither step overlaps a GC sweep.

Set `cache.index_auto_compact = false` or `KACHE_INDEX_AUTO_COMPACT=0` to turn both steps off. `kache doctor --repair` does the same work on demand either way.

#### Blob index heal

The index keeps two tables about blobs: one row per blob with its size and a reference count, and one row per entry and blob it uses. Both are derived from the entries themselves. They can drift apart: a blob row keeps a reference count that no entry accounts for. Such a blob still counts toward `max_size`, but eviction only removes blobs through entries, so GC cannot free it. A store with enough of them stays over its budget and keeps sweeping without getting under it.

Every check compares the two tables with one read-only query. When they disagree and the machine is quiet, the daemon rebuilds both tables from the `meta.json` of every committed entry, in one transaction. Blob files that no row references after that are deleted once they are an hour old, the same grace GC uses, so a store in the middle of a write is not disturbed. The compaction step that follows returns the freed pages to the disk.

The heal has no fallback for a machine that is never quiet. It reads every entry's metadata under the write lock, so its duration grows with the store. Drift costs disk space, never a wrong cache hit. At shutdown it runs only when the store has at most 25,000 entries. If another process is using the index when it starts, it stops at once and a later check retries.

The rebuild refuses to run if it cannot trust an entry: a `meta.json` that is missing or does not parse, or a blob that is missing or has the wrong size. It then changes nothing, logs one warning that names the entry, and waits 6 hours before it tries again. The wait is kept across daemon restarts, in `blob-heal.json` in the cache dir. To fix it, run `kache doctor --repair` when no build is running: it removes the corrupt entry and rebuilds the blob index.

#### Index compaction

Deleting rows from `index.db` frees pages inside the file but does not shrink it. After a large prune or a schema migration, most of the file can be free pages. The daemon compacts the index with a SQLite `VACUUM` once free pages reach 64 MiB and a quarter of the file.

A `VACUUM` blocks other index users while it runs, so the daemon runs one only in these cases:

- On a quiet machine, when the live part of the index is at most 8 GiB. If anything else is using the index at that moment, the attempt stops at once and a later check retries.
- At daemon shutdown, under the same permit rule, when the live part of the index is at most 1 GiB.
- On a machine that is never quiet: after the index has stayed over the threshold for 6 hours, and only when the live part is at most 1 GiB. Index reads continue during the rewrite. Index writes wait for it, which at that size is a few seconds, inside the wrappers' normal index timeout. The 6 hours are counted across daemon restarts, in `index-compact.json` in the cache dir.

Compaction needs free disk of about twice the live index size, not the file size: a 45 GB index with 1 GB live needs about 2 GB. Without that room the attempt is skipped and retried later. The free pages stay reusable in the meantime, so a skipped compaction costs disk space only.

A live index above 8 GiB is never compacted automatically, because the rewrite would hold the index for too long. The daemon logs that it skipped it; run `kache doctor --repair` when no build is running.

### Target directory cleanup

Builds through Kache record the target directories they use; `kache clean --dry-run` lists them. The daemon checks that list at most once an hour, when the machine is quiet, and removes:

- target directories whose workspace was deleted and that no build has used for a day, such as the target of a removed worktree built with its own `CARGO_TARGET_DIR`. The day keeps a worktree that is moved or recreated from losing its target. Set `cache.auto_clean_orphaned_targets = false` or `KACHE_AUTO_CLEAN_ORPHANED_TARGETS=0` to keep them.
- with `cache.auto_clean_idle_targets_days` set, target directories no build has used for that many days, whether or not their workspace still exists.

It applies the same checks as `kache clean --yes`. A directory is removed only while it is still the Cargo target directory that was recorded, never when it holds a `Cargo.toml` or `.git`, and never while a running Cargo command or test holds it. The daemon renames it aside and checks once more before deleting it, so a build that starts at that moment gets a new directory and does not wait for the deletion. A workspace counts as deleted only when looking it up finds nothing; a permission or network error does not count. The daemon log names each directory it removes and why.

Put Kache's `cargo` shim before the real Cargo on `PATH` to keep targets through `cargo test`, `cargo nextest`, and `cargo run`. The shim runs the real Cargo with the original arguments. The test runner also protects the target while a test binary runs. Cleanup skips targets while any such command is active. A command that starts while `kache clean` or the daemon's unit pruning is deleting files waits until that finishes, and says so on stderr.

The daemon also asks Git about the repositories of the targets it tracks, and about the other repositories in the folders their worktrees sit in, one folder out. Discovery never reads the home folder itself or the folders macOS asks permission for, and never runs a repository's `core.fsmonitor` program. For each repository it lists the worktrees, and in each worktree the directories Git ignores; every ignored `target/` that Cargo tagged is recorded as found through Git, including one whose crate was removed. A target that Git does not ignore is found only at the path of a workspace Kache has seen a build in. A custom target path is known only after Kache sees a build use it. The daemon does this at its first quiet moment once an hour has passed since the last time, including just after it starts, and spends at most 20 seconds on it; `kache targets` and `kache clean` only read what it found. By default the daemon neither watches nor removes a Git-discovered target, and `kache targets` shows its idle time as unknown.

When `cache.auto_clean_idle_targets_days` is set or `cache.auto_recover_targets` is on, the daemon watches discovered targets so those rules can judge them. No build through Kache has used one, so each hourly check marks the access times of its units' fingerprints, the same way [unused build units](#unused-build-units) are found, and the target counts as idle from then until a build reads or writes one; Cargo reads them even when it compiles nothing. Until the daemon has watched it, or on a filesystem that does not record reads or is not local, a discovered target has unknown idle time and is never removed. Discovered targets go whole: the daemon does not prune their units, does not apply the deleted-workspace rule to them, and never uses them to seed another checkout.

Free-space recovery keeps 10 GiB free by default by evicting cache entries. In CI, where a runner's disk lasts one job and the CI cache keeps entries for later jobs, it is off unless you set a floor. Set `cache.auto_recover_min_free_bytes` or `KACHE_AUTO_RECOVER_MIN_FREE_BYTES` to the number of bytes to keep free, or to `0` to turn recovery off.

Recovery removes target directories only when you also turn on `cache.auto_recover_targets`, because those are build outputs in your worktrees, some of which no build through Kache has used. `kache init` asks whether to turn it on.

```toml
[cache]
auto_recover_min_free_bytes = 21474836480 # keep 20 GiB free
auto_recover_targets = true               # remove idle targets too
```

With `cache.auto_gc` enabled, builds request background eviction from the main cache and configured volume stores, even below their cache size budgets. Recovery checks actual filesystem free space between candidates and stops at the floor or a five-second work deadline. Candidate scans and an in-flight removal can extend that deadline. Each store runs at most once every five minutes under its GC lock. Recent accesses, fresh remote imports, pending uploads and artifacts retained outside the store keep their normal eviction protections. Cleanup can add disk I/O and future cache misses; it cannot guarantee enough space if the remaining entries are protected.

With `cache.auto_recover_targets` on, when the machine is quiet, target cleanup first removes build units unused for a day. If the volume remains below the floor, it removes targets unused for at least a day, largest estimated physical reclaim first. Unused means no build has read or written one of the target's unit fingerprints since the daemon last marked them, so a build that compiles nothing still counts as use; a target holding units that the daemon has not marked yet is left for a later check. It rechecks target and Cargo locks before each removal and stops at the floor. `kache targets` shows the next target cleanup plan, and the `Volume` row of `kache stats` says whether recovery covers cache entries only or idle targets too. It currently requires Unix volume usage reporting.

With `cache.auto_share_target_files` on (the default), a quiet check that did not already sweep targets replaces a large target file with a clone of a stored blob when the file is a byte-for-byte copy of that blob. Kache remaps paths while it compiles, and the blob is those remapped bytes, so the match is a copy of an artifact Kache built: another worktree, or a target restored from the same store. The file keeps its path, mode and modification time, so Cargo still sees it as fresh. One check hashes at most one file, and it stops when a compile starts. Files on another volume are counted as cross-device and are not hashed. `kache targets share` reports the same comparison; `--apply` runs it now, whether or not the setting is on.

The cleanup follows the configuration of the daemon that serves the cache directory.

### New checkouts

The first build in a new checkout or worktree starts with an empty target directory. Kache fills it before Cargo decides what to build, so Cargo skips the dependencies another checkout already built instead of restoring each one from the cache.

Cargo's first call through `RUSTC_WRAPPER` for a target directory it has never built is a probe of the compiler, made before it checks any unit. When it sees that probe, the wrapper asks the daemon to copy the matching units from the most recently used target directory built by the same `rustc`, and waits up to 3 seconds. Cargo then builds whatever was not copied. The daemon log names the checkout the units came from.

What is copied:

- Units of registry and git packages in the new checkout's `Cargo.lock`. Workspace members and path dependencies are never copied: Cargo trusts their sources' modification times, so a copy from a checkout with different sources could pass as fresh.
- The host `debug` profile, the one `cargo build`, `check`, `test` and `clippy` use. Both Cargo layouts are handled, the per-unit directories of Cargo 1.100 and later and the shared `deps/` layout before it.
- Files are cloned where the filesystem supports it and copied otherwise, with their modification times. Each unit's fingerprint is put in place last.
- A relative symlink inside a unit's directory (`build/<pkg>/<hash>/`, or `build/<pkg>-<hash>/` and `.fingerprint/<pkg>-<hash>/` in the shared layout) is copied as a link when its target climbs no higher than that directory with leading `..` and then only descends. Files in the shared `deps/` directory are copied one at a time, and a symlink that is itself the copied file is not kept, so a link there such as `libfoo.so -> libfoo.so.1` keeps its unit from being copied.
- A build script's `OUT_DIR` that a hermetic run (`KACHE_BUILD_SCRIPT_HERMETIC=1`) left as a link to its shared directory in this cache is linked to the same directory, which then counts as in use by the new target. A unit holding any other symlink is not copied.

Kache follows `build.target-dir`, `build.build-dir`, `CARGO_TARGET_DIR`, `CARGO_BUILD_TARGET_DIR`, and `CARGO_BUILD_BUILD_DIR`. File paths follow Cargo's config precedence and relative-path rules. Build-directory templates support `{workspace-root}` and `{cargo-cache-home}`. Check targets named `check` and editor targets named `rust-analyzer` use a donor with the same directory name.

The Cargo shim and `kache cargo -- …` also pass `--target-dir` to the wrapper. They skip seeding for invocations with `--config`, `--manifest-path`, `-C`, or `-Z`. A compiler wrapper used without the Cargo shim cannot see those command-line options; use Cargo's configuration or environment variables to select a seedable directory.

A unit whose build script or output files name the donor checkout, other than its own `OUT_DIR`, is not copied. A Windows path also counts when written with forward slashes, as CMake writes it in `CMakeCache.txt`, or with doubled backslashes, as JSON and string literals write it, and its drive letter matches in either case. A build script whose output files add up to more than 256 MiB, or are not all read before the deadline, is not copied either. Nothing is copied while a build holds either profile's lock or when no daemon is running. Config includes, unknown build-directory templates, and unreadable or invalid configuration are left unseeded. Set `cache.seed_new_targets = false` or `KACHE_SEED_NEW_TARGETS=0` to turn seeding off.

When the cache already holds a checkout's dependencies, seeding copies the same units it would otherwise restore one compiler call at a time.

### Unused build units

A target directory in regular use still collects dead weight. A lockfile update, a feature change or a new toolchain moves the build on to new units and Cargo leaves the old ones behind. The same hourly check removes, from every tracked target directory that stays, the units no build used for `cache.auto_clean_unused_units_days` days (30 by default; `0` turns it off).

Cargo reads the fingerprint of every unit a build uses, even when nothing needs compiling, but a filesystem records only some reads. macOS and Linux's default `relatime` both record a read when the file's access time is older than its modification time. So the first check of a target directory sets each fingerprint's access time just below its modification time and notes the moment. Once the configured days have passed, every unit whose fingerprint was neither read nor written since is removed, and the rest are set up again for the next period. Cargo looks only at modification times, so this changes nothing it relies on.

Nothing is removed on a filesystem where reads never change an access time (`noatime`), or on a network filesystem, where Cargo does not lock its build directories. A profile a running build holds is skipped, and all of Cargo's locks in it are held while its units are removed. Each unit's fingerprint goes before its outputs, so a removal cut short leaves outputs Cargo rebuilds. A unit that is needed after all is restored from the cache or compiled again. The daemon log reports how many units and bytes each target directory lost.

### Recorded-command cleanup

Enable receipts for an active workspace, then preview its obsolete build units:

```bash
KACHE_TARGET_LIVENESS=1 kache cargo check
kache clean --units target --dry-run
kache clean --units target --yes
```

Persist the setting to capture future supported commands through `kache cargo` or the installed Cargo shim:

```toml
[cache]
target_liveness = true
```

With `target_liveness` enabled, automatic unused-unit cleanup and its disk-pressure pass use these receipts instead of access-time evidence. An unavailable receipt keeps the units; these passes do not fall back to access-time pruning. Whole-target deletion keeps its existing idle and deleted-workspace rules.

The receipt uses successful stable Cargo `build`/`check` JSON output, including artifacts Cargo marked Fresh. Cleanup keeps those live units and removes only known units outside that recorded command's scope. It does not depend on file access times. After cleaning an already-fresh workspace, repeating the same recorded Cargo command stays fresh. A different feature set, profile or command may need units that were removed.

Recording and validation scan workspace inputs, build-script package inputs and fingerprint/output inventories only when this mode is enabled. Source walks stop at 32,768 entries or 128 MiB; oversized inputs and split build directories retain their units.

Missing, changed or incomplete evidence keeps the units. Unsupported layouts, symlinks and held build locks also prevent removal. Other profiles and unknown units are kept. Run the supported Cargo command again to refresh a receipt after changing sources, manifests, configuration or the toolchain. Existing private Cargo files alone are not a complete build graph.

`kache clean --artifacts` is an alias for `--units`. A path selects an intact tracked target directory or its workspace; without a path, the command previews or cleans all live tracked targets. `--json` previews unless paired with `--yes`, and `--dry-run` always removes nothing. `kache targets --json` includes each target's unit-cleanup plan.

## Compiler policy

| Environment | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| `KACHE_CACHE_EXECUTABLES` | `cache.cache_executables` | Linux/macOS: `true`; Windows: `false` | Cache eligible Rust bins and test executables |
| `KACHE_CACHE_CC_LINKS` | `cache.cache_cc_links` | `false` | Cache C/C++ whole-program links and their sidecars. Off until path and reproducibility gates pass. |
| `KACHE_TRUST_CODEGEN_BACKENDS` | `cache.trust_codegen_backends` | `false` | Cache rustc compiles that load a codegen backend dylib (`-Zcodegen-backend=<path>`), keyed by the dylib's contents. The backend must be passed as a path, and libraries it loads at run time are not part of the key. Off because a backend can write files rustc does not report, and a cache hit would not restore them. |
| `KACHE_CLEAN_INCREMENTAL` | `cache.clean_incremental` | `true` | Remove tracked incremental directories during cleanup |
| `KACHE_ADAPTIVE_INCREMENTAL` | `cache.adaptive_incremental` | `true` | Give isolated incremental state to a workspace member or path dependency whose sources or dependencies changed since its last build in the same target, whether or not the command selects it. A pull or a branch switch counts. Kache skips the cache for up to eight builds of such a unit, each within 15 minutes of the last, and neither stores nor uploads them. A compile error keeps the state. See [Executables and incremental builds](/docs/getting-started/quick-start#executables-and-incremental-builds). |
| `KACHE_PRESERVE_INCREMENTAL` | `cache.preserve_incremental` | `false` | Force eligible Cargo units onto isolated incremental state |
| `KACHE_INCREMENTAL_CRATES` | `cache.incremental_crates` | `[]` | Force listed rustc crate names onto that incremental path, whether or not the command selects them |
| `KACHE_MODIFIED_INPUT_GUARD` | `cache.modified_input_guard` | `false` | Do not store a Rust result when a keyed input changed at or after build start, or just before it by the margin file times can trail the clock. A compile that ran before its key, as in [misses without a record](/docs/how-it-works/cache-key#misses-without-a-record), is not stored when a source it read changed then; this option extends the check to the other inputs a key hashes and to keys taken before the compile. A keyed input whose size, write time or inode differs after the compile from what the key read also leaves the result unstored, which catches a save between the key and the compiler's read. A rewrite that keeps all three is missed: a second save within one tick of a coarse file clock, or on Windows, which has no inode, a replacement with the same size and write time. C/C++ and CUDA compiles always apply this check to the files their key hashes. When the key comes before such a compile, a timestamp that is in the future when Kache reads the file counts as clock skew, not a change. |
| `KACHE_INPUT_PREDICTIONS` | `cache.input_predictions` | `true` | Remember each crate's source closure and derive the next key from it, skipping the dep-info pre-pass when every recorded input still checks out |
| `KACHE_VERIFY_INPUT_PREDICTIONS` | none | off | `sampled` or `always`: also run the dep-info pre-pass for a validated prediction and count disagreements in the report. The pre-pass result is the one used |
| `KACHE_BUILD_SCRIPT_HERMETIC` | `cache.build_script_hermetic` | `false` | On Unix, run cached build scripts with `OUT_DIR` in a shared directory named by their inputs, so a run is shared across target directories. See [Build scripts](/docs/how-it-works/build-scripts). |
| `KACHE_BUILD_SCRIPT_CACHE` | none | `true` on Linux and macOS | Cache build-script runs: restore `OUT_DIR` and the script's output instead of running it when its binary, Cargo environment and declared `rerun-if-*` inputs match a recorded run. See [Build scripts](/docs/how-it-works/build-scripts). |
| `KACHE_FALLBACK` | `cache.fallback` | none | Wrapper used for ordinary Kache passthroughs |
| none | `cache.exclude` | `[]` | Source-path globs that bypass Kache |
| none | `cache.bypass_crates` | `[]` | Exact rustc crate names that bypass Kache |
| none | `cache.bypass_argv` | `[]` | Argument substrings that bypass Kache |
| none | `cache.bypass_env` | `[]` | `NAME` or `NAME=VALUE` rules that bypass Kache |
| `KACHE_CC_EXTRA_ALLOWLIST_FLAGS` | `cc.extra_allowlist_flags` | `[]` | Exact C/C++ flag spellings accepted in addition to the built-in model |

Bypass rules can only reduce caching:

```toml title=".kache.toml"
[cache]
exclude = ["vendor/problematic/**"]
bypass_crates = ["generated_bindings"]
bypass_argv = ["--cfg", "uncacheable_mode"]
bypass_env = ["SPECIAL_BUILD=1"]
```

## Cache-key controls

| Environment | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| `KACHE_KEY_SALT` | `cache.key_salt` | none | Opaque value folded into every key |
| `KACHE_BASE_DIR` | none | none | One extra path prefix normalized as `<BASE_DIR>` |
| none | `paths.base_dirs` | `[]` | Additional absolute prefixes with distinct stable sentinels |
| `KACHE_PATH_ONLY_ENV_VARS` | `cache.path_only_env_vars` | `[]` | Path locator variables eligible for safe normalization |
| `KACHE_KEY_ENV_VARS` | `cache.key_env_vars` | `[]` | Environment read by proc macros but not reported by rustc |
| `KACHE_VERIFY_RESTORES` | none | `off` | Re-hash `sampled` or `always` before restoring |
| `KACHE_VERIFY` | none | `off` | Recompile each rustc cache hit and compare it to the restored files |

Use `KACHE_VERIFY` to check a cache-key change. Set it to `1` or `true` to recompile every rustc cache hit into a staging directory and diff those artifacts against the files just restored. Leave it unset (or `0` / `false`) in ordinary builds: each hit pays a full compile. There is no `kache build` command for this.

Path and debug-info bytes that differ only as embedded absolute paths are reported as `path-debug` and do not fail the build. Remaining byte differences are `content`. In both cases the restored files stay in place so the build continues; the wrapper records the class on the hit event as `verify_compare` (and in tracing when logging is on).

`KACHE_VERIFY_RESTORES` only re-hashes stored blobs before restore. It does not recompile.

Use a key salt when an unobserved toolchain component changes output:

```toml
[cache]
key_salt = "sysroot-2026-08"
```

Keep `paths.base_dirs` narrow. Normalizing unrelated roots to stable sentinels can make different inputs appear equal.

`path_only_env_vars` widens the built-in `OUT_DIR` rule to other variables
whose value only locates a generated file. A plain `VAR` entry still has to
pass Kache's checks: some source must show `env!("VAR")` (or `option_env!`)
inside an `include!`, `include_str!` or `include_bytes!` argument, and no
source may read the value anywhere else. Kache also needs dep-info to list a
file under the value, and it reads the proof only from `.rs` sources. When a
listed variable is not normalized, `KACHE_LOG=trace` prints which of those
conditions failed next to the `env_dep:` line.

A crate that includes generated code through another crate's macro, such as
`tonic::include_proto!` or `lalrpop_mod!`, never spells `env!("OUT_DIR")`
itself, so the check finds no proof and the absolute path stays in the key.
That crate then rebuilds in every checkout. Assert it per crate, using rustc's
crate name (underscores):

```toml
[cache]
path_only_env_vars = ["my_proto_crate:OUT_DIR"]
```

A `crate:VAR` entry skips the source checks for that crate and variable only,
so use it when you have read the crate and know it never bakes the value into
its artifacts. `CARGO_MANIFEST_DIR` is never accepted, in either form: rustc
embeds it in crate metadata, and every crate's sources live under it, so the
checks above would pass for a value that does change the artifact. Kache warns
when a list names it and ignores the entry.

Build scripts can also share runs when a linker wrapper lives in each
checkout. Set `KACHE_BASE_DIR` to the checkout root and opt the tool variable
into normalization:

```toml
[cache]
path_only_env_vars = ["RUSTC_LINKER"]
```

For build-script runs, plain `RUSTC_LINKER`, `RUSTC`, and `RUSTDOC` entries
normalize a tool path under `KACHE_BASE_DIR` and hash the tool file's contents.
Different tool bytes still produce different keys. Use this only when moving
the tool between identical checkouts does not change its behavior. A wrapper
that reads other files must declare those inputs with `cargo:rerun-if-changed`.
Other variables and crate-scoped entries keep their existing behavior.

Registry crates that bake an empty `OUT_DIR` need no entry. On Unix, Kache
compiles a registry proc macro whose `OUT_DIR` is empty, or a registry lib that
only proc macros link, against `<cache>/out-dirs/v1/d/<pkg>-<hash>/out`: an
empty directory with mode 0555 at the same path in every checkout. A lib is
shared only once a build on this machine has shown that nothing but proc macros
links it, so the first checkout still rebuilds it. A `crate:VAR` entry for
`OUT_DIR`, or for a variable whose value is under it, keeps that crate on its
own `OUT_DIR`. A macro that writes debug output into the shared directory fails
with a hint: run `cargo clean -p <crate>` and rebuild with
`KACHE_OUT_DIR_ALIAS=0`. The hint comes from rustc's error output, so a compile
that Kache passes straight to rustc fails with rustc's error alone. That covers
a `bypass_*` or `exclude` match and a compile on the `preserve_incremental`
lane.

`key_env_vars` accepts exact names and trailing-star prefix patterns:

```toml
[cache]
key_env_vars = ["BOLTFFI_*"]
```

Values are hashed exactly and are not written to event logs. A value that changes on every job, such as a job ID, gives every job its own keys.

## Hidden compile-time inputs

Some macros read files that rustc does not report. If the package's build script already declares them with `cargo:rerun-if-changed`, Kache keys them without a declaration of its own (see [Build scripts](/docs/how-it-works/build-scripts#declared-inputs-in-the-rust-key)). Otherwise put `kache.toml` without a leading dot next to the crate manifest:

```toml title="crates/db/kache.toml"
extra_inputs = [
  ".sqlx/**/*.json",
  "migrations/**/*.sql",
]
```

Matches are relative to that crate. Kache folds their paths and contents into its key and adds bounded watches to Cargo dep-info. Invalid or unreadable declarations fail closed.

For a workspace-root input used by a provider package and its direct consumers:

```toml title=".kache.toml"
[[workspace.extra_inputs]]
crates = ["query-macros"]
inputs = [".sqlx/**/*.json"]
propagate_to_dependents = true
```

Workspace rules are accepted only from the `.kache.toml` beside the active Cargo workspace manifest. After adding a declaration to a target Cargo already considers fresh, rebuild that package once.

## Remote settings

| Environment | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| none | `cache.remote.type` | inferred: `filesystem` when `path` is set, otherwise `s3` | `s3`, `gcs`, `filesystem`, or `oci` |
| `KACHE_S3_BUCKET` | `cache.remote.bucket` | none | S3 bucket |
| `KACHE_S3_ENDPOINT` | `cache.remote.endpoint` | AWS | Custom S3-compatible endpoint |
| `KACHE_S3_REGION` | `cache.remote.region` | `us-east-1` | Region |
| `KACHE_S3_PREFIX` | `cache.remote.prefix` | `artifacts` | Object prefix |
| `KACHE_S3_PROFILE` | `cache.remote.profile` | credential-chain default | AWS profile |
| `KACHE_S3_USER_AGENT` | `cache.remote.user_agent` | none | Custom HTTP User-Agent |
| none | `cache.remote.path` | none | Filesystem remote root |
| none | `cache.remote.atomic_write_dir` | `<path>/.kache-tmp` | Same-filesystem staging directory |
| `KACHE_PULL_REQUEST_PREFIX` | `cache.remote.pull_request_prefix` | none | In a pull request job (a GitHub `pull_request` event or a GitLab merge request pipeline), read this prefix after `prefix` and write only here. It must differ from `prefix`, and neither may contain the other. OCI remotes stay read-only. See [A prefix for pull requests](/docs/remote-cache/ci#a-prefix-for-pull-requests). |
| `KACHE_S3_CONCURRENCY` | `cache.s3_concurrency` | `16` | Concurrent operations for all remote backends |
| `KACHE_S3_POOL_IDLE_SECS` | `cache.s3_pool_idle_secs` | `300` | S3 connection pool idle time |
| `KACHE_REMOTE_RESTORE_TIMEOUT_SECS` | `cache.remote_restore_timeout_secs` | `300` | Daemon operation deadline; wrapper demand remains capped at three seconds |
| `KACHE_REMOTE_NEGATIVE_TTL_SECS` | `cache.remote_negative_ttl_secs` | `60` | Cache definitive remote 404 results |

Credentials resolve from explicit `KACHE_S3_ACCESS_KEY` and `KACHE_S3_SECRET_KEY`, standard AWS variables, then the AWS profile. Web identity, container credentials and instance credentials follow only when no profile is selected. A selected profile that cannot supply credentials makes S3 requests fail; see [Selected profile](/docs/remote-cache/s3-setup#selected-profile).

The `KACHE_S3_*` connection settings apply to S3. GCS and OCI require an explicit
`cache.remote.type` and take their connection settings from TOML:

| Backend | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| GCS | `cache.remote.bucket` | required | GCS bucket |
| GCS | `cache.remote.endpoint` | Google Cloud Storage | Custom endpoint; requests go unsigned |
| OCI | `cache.remote.repository` | required | Registry host and repository, without scheme, tag, or digest |
| OCI | `cache.remote.insecure` | `false` | Use HTTP for a local registry |
| All | `cache.remote.prefix` | `artifacts` | Cache object prefix |

GCS uses Application Default Credentials. OCI uses Docker credentials or
`KACHE_OCI_USERNAME` with `KACHE_OCI_PASSWORD`, or `KACHE_OCI_TOKEN` alone.
See [Remote cache](/docs/remote-cache/overview) for setup guides and credential details.

## Prefetch and planner

| Environment | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| `KACHE_PREFETCH_ENABLED` | `cache.prefetch_enabled` | `true` | Enable speculative manifest and planner prefetch |
| `KACHE_REMOTE_KEY_LISTING` | `cache.remote_key_listing` | `false` for S3, GCS, and OCI; `true` for a filesystem remote | List the remote's keys, for the fallback planner's key index and packed-prefetch catalogs; on S3 this needs `s3:ListBucket` |
| `KACHE_REMOTE_KEY_CACHE_REFRESH_SECS` | `cache.remote_key_cache_refresh_secs` | `60` | Refresh the remote key index while listing is on; `0` means initial load only |
| `KACHE_PREFETCH_MAX_KEYS` | `cache.prefetch_max_keys` | `2000` | Entries allowed per plan; `0` is unlimited |
| `KACHE_PREFETCH_MAX_BYTES` | `cache.prefetch_max_bytes` | `2GiB` | Compressed bytes allowed per plan; `0` is unlimited. In-flight downloads may finish. In TOML the value is a quoted size, such as `"4GiB"` or `"0"` |
| `KACHE_PREFETCH_DEADLINE_SECS` | `cache.prefetch_deadline_secs` | `300` | Time during which a plan may start downloads; `0` disables the deadline |
| `KACHE_PLANNER_ENDPOINT` | `cache.planner.endpoint` | none | Planner service URL |
| `KACHE_PLANNER_TIMEOUT_MS` | `cache.planner.timeout_ms` | `750` | Planner request timeout |
| `KACHE_PLANNER_TOKEN` | `cache.planner.token` | none | Bearer token |
| `KACHE_NAMESPACE` | none | none | Shard namespace used by manifest upload and prefetch |
| `KACHE_MIN_COMPILE_MS` | none | `1000` | Manifest prefetch skips entries whose recorded compile time is below this many milliseconds, because recompiling them costs less than downloading. Read by the daemon |

On a long-lived runner with a warm local store, speculative prefetch may be unnecessary:

```toml
[cache]
prefetch_enabled = false
```

Exact remote lookup and background upload continue to work.

### Planner authentication

A planner that requires authentication gets a bearer from the first of these that applies:

1. `KACHE_PLANNER_TOKEN` / `cache.planner.token`, when set.
2. A workload token. In GitHub Actions, Kache requests the job's OIDC ID token with the planner's base URL as its audience, so no other endpoint can replay it; the job needs `permissions: id-token: write`. Otherwise it sends a GitLab CI ID token from `KUNOBI_ID_TOKEN`, or a Kubernetes service-account token read from the file named by `KUNOBI_SA_TOKEN_FILE`. Kache does not choose the audience of those two, so set it to the planner's base URL where you mint them. The planner must trust the token; of these, `kache-service` accepts only GitHub Actions tokens, from the repository owners it lists.
3. Your Kunobi session. Run `kache login` once: it trusts this planner and stores a session that the daemon refreshes from then on. The session is kept per planner client, so it never replaces another Kunobi tool's login.

Kache sends a workload token and the session only to a planner reached over HTTPS (or on loopback), and the session only to a planner you logged in to. A planner URL picked up from a project's config therefore cannot collect your credentials. Use `kache login --device` on a machine without a browser, and `kache logout` to revoke it.

A build never prompts for a login. Without a usable bearer the planner answers 401 and Kache plans locally, the same as when the planner is unreachable. If the planner's login issuer changes after you first trusted it, `kache login` refuses; run `kache login --retrust` when the change is expected.

## Telemetry

Kache does not push metrics to a collector. It writes OTLP JSON files that Kartero imports from CI artifacts, the same way it imports nightly bench results.

The two metric families stay separate:

- `kache.bench.*`: one-shot bench gauges (duration, speedup, verdict) from `kache-scenario`
- `kache.cache.*`: cache counters (uploads, prefetch, store size) from `kache telemetry write`

```bash
kache telemetry write ./cache-otlp --scenario bench-firefox --phase warm
```

That writes `metrics.otlp.json` and `schema_version` in the directory. `--scenario` must match `kache.bench.project`. `--phase` distinguishes cold from warm: the bench stops the daemon between phases, so counters are per daemon lifetime. The rustc wrapper does not write these files. The planner service still exposes Prometheus `/metrics` for cluster scrape.

### Machine telemetry

On a host where several jobs share one cache, the payload reports `kache.cache.store.physical_size`: registered blob bytes, the figure GC compares with `kache.cache.store.max`. `kache.cache.store.size` is the sum of logical entry sizes and may differ. `kache.cache.store.unowned.size` is the part of the physical size held by blobs no entry maps to, which GC cannot free. `kache.cache.store.refcount_drift` counts the blobs whose reference count disagrees with the entries mapped to them, unowned ones included. Both are zero on a healthy store and return to zero after a [blob index heal](#blob-index-heal). It also reports `kache.cache.index.size` (`index.db` plus `-wal`), `kache.cache.index.wal.size`, `kache.cache.index.free.size` (the space `index.db` holds in free pages, which compaction returns to the disk), and `kache.cache.index.rowid_high_water` per table. Last-run GC gauges include duration, pinned and unreclaimable entries, bytes held by target directories (`kache.cache.gc.last_run.bytes_held`), failed evictions, stale WAL snapshot failures, and recently accessed entries skipped before reading their metadata. The shipper adds host identity. The index probe is read-only with a 25 ms lock timeout and omits unavailable figures. `kache stats --json` carries the index figures and GC summary as `index_bytes`, `index_wal_bytes`, `index_rowid_high_water` and `gc`.

`rowid_high_water` reports the largest rowid in each table. Tables written with `INSERT OR REPLACE` (`input_predictions`, `eviction_tombstones`) allocate new rowids on replacement, so this value can grow without adding rows. `file_hashes` has no rowid once GC has rebuilt it as a `WITHOUT ROWID` table, and then drops out of this series. C/C++ memos keep their IDs on update; their high-water value does not count refreshes. Use `kache.cache.index.size` to measure index bytes.

To keep per-build numbers at machine level, add `--record` to `kache stats --full`:

```bash
kache stats --format github --since 24h --record
```

After rendering the report as usual, it appends one JSON line to `telemetry/sessions.jsonl` in the cache dir. The line holds the report's summary and timing breakdown (hit overhead, key, dep-info pre-pass, restore, wait, prediction mismatches), plus load average, CPUs and index size at that moment. Use it at the end of a CI job: the report reads the runtime dir, which the job's cleanup deletes. The root is recorded as a hash, never as a path.

To record every report on a host without passing the flag, set `KACHE_RECORD_SESSIONS=1` or `[cache] record_sessions = true`. Recording is off by default: without the setting or `--record`, `kache stats --full` writes nothing.

Every GC driver (the daemon's sweep and its eviction after an upload, the auto-GC worker, and `kache gc`) records its run in `gc_stats.json`. With `record_sessions` on, each run also appends a version 2 line to `telemetry/gc-runs.jsonl` with the driver, outcome, duration, lock failures, stale WAL snapshot failures, and recently accessed entries skipped before metadata reads. `gc_stats.json` keeps only the latest run; the history is what a backend adds up. The bytes each run frees are also added to `savings.json`, the lifetime totals `kache stats` shows. Skip counts are attempts across policy passes, not unique entries.

`kache telemetry write` reads index rows and files locally; it sends nothing over the network.

## Daemon, events, and diagnostics

| Environment | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| `KACHE_DAEMON_IDLE_TIMEOUT` | `cache.daemon_idle_timeout_secs` | `0` | Stop after idle seconds; `0` keeps it running |
| `KACHE_DAEMON_PUBLISH` | `cache.daemon_publish` | `true` | Let a running daemon store completed C/C++ compiles. The wrapper stores locally when the daemon cannot accept the work. |
| `KACHE_HEARTBEAT_SECS` | `cache.heartbeat_secs` | `30` | Event heartbeat cadence for long miss compiles; `0` disables it |
| `KACHE_EXPLAIN_MISS` | `cache.explain_miss` | `false` | Record changed key groups on repeat misses |
| `KACHE_RECORD_SESSIONS` | `cache.record_sessions` | `false` | Make every `kache stats --full` append its session line, as `--record` does, and every GC run append to `telemetry/gc-runs.jsonl` |
| `KACHE_STORAGE_LAYOUT_ADVICE` | `cache.storage_layout_advice` | `true` | Warn when storage layout forces copies. CLI commands print the advice; compiler wrappers log it at `warn` (see `KACHE_LOG`) so it never lands in compiler output |
| `KACHE_WINDOWS_HARDLINK` | `cache.windows_hardlink` | `false` | Unsafe opt-in hardlink restores on non-CoW Windows volumes |
| `KACHE_DEFERRED_DISCOVERY` | `cache.deferred_discovery` | `true` | On a certain miss (no closure record, no remote), compile first and key from the dep-info rustc emits instead of running the dep-info pre-pass; a C compile with no recorded read set likewise compiles first and keys from the dependency file it writes. Set to `0` to keep the pre-pass and the preprocessor probe on every miss. |
| `KACHE_OUT_DIR_ALIAS` | `cache.out_dir_alias` | `true` | On Unix, compile a registry proc macro whose `OUT_DIR` is empty, or a registry lib that only proc macros link, against one shared read-only directory under `<cache>/out-dirs` instead of its own `OUT_DIR`, so the path it bakes in is the same in every checkout. A macro that writes debug output there fails with a hint; set to `0` to turn this off. |
| `KACHE_DEFERRED_DURABILITY` | `cache.deferred_durability` | `true` | Store a miss without an fsync and let a background worker flush it a moment later; a hit on an entry not yet flushed verifies its bytes first. Set to `0` to flush every entry inside the compile that stored it. |
| `KACHE_SHARED_HARDLINK_RESTORES` | `cache.shared_hardlink_restores` | `false` | Let every target directory hardlink the same store blob on filesystems without reflink. Only for single-use target directories (a CI job's tree): the restore-time mtime stamp on a shared inode re-dates the artifact in every other tree that links it. |
| `KACHE_PROGRESS` | none | off | `hits` or `verbose` compiler progress |
| `KACHE_LOG` | none | CLI: `kache=warn`; wrapper: off | Stderr tracing filter |
| `KACHE_LOG_FILE` | none | CLI/daemon: `kache=info`; wrapper: off | File tracing filter and wrapper opt-in. The file is `~/Library/Logs/kache/kache.log` on macOS and `kache.log` in the default store directory elsewhere, even when `KACHE_CACHE_DIR` or `cache.local_store` moves the store. A process that starts while it is over 5 MiB truncates it |
| `KACHE_EVENT_ROOT` | none | detected | Root attached to events for report filtering |
| `KACHE_PAGER` | none | platform pager | Pager command for `kache list` |
| `KACHE_MAX_DIAGNOSTICS_BYTES` | none | unset | Cap, in bytes, on the compiler diagnostics stored with an entry. Unset stores them in full, so a hit replays exactly what the compile printed. Above the cap they are truncated with a marker |

## Pin file-backed settings

Set `cache.ignore_env` in a controlled project config to ignore environment overrides for settings that also exist in TOML:

```toml
[cache]
ignore_env = true
```

It does not suppress `KACHE_DISABLED`, `KACHE_CONFIG`, `KACHE_HOST_CONFIG`, `KACHE_SOCKET_PATH`, `KACHE_TRUST_DOMAIN`, S3 and OCI credentials, logging, progress, or other environment-only controls. It does suppress `KACHE_PLANNER_TOKEN`, which has a TOML key. Kache warns when it ignores a set variable.

The daemon reads configuration at startup and watches the active config file and the host config. When either changes, the daemon drains and exits, and the next start loads the new settings. Other settings come from the environment the daemon started with. A daemon started by a build, `kache daemon start` or `kache daemon restart` drops the variables that select the remote (`KACHE_S3_BUCKET`, `KACHE_S3_ENDPOINT`, `KACHE_S3_REGION`, `KACHE_S3_PREFIX`, `KACHE_S3_PROFILE`, `KACHE_S3_USER_AGENT` and `KACHE_LOCAL_ONLY`), so set those in the config file, or run `kache daemon run` from that environment. An installed service uses its service definition, not your current shell. See [Updates and config changes](/docs/daemon/lifecycle#updates-and-config-changes).