MCPcopy Index your code
hub / github.com/Mic92/fast-nix-gc

github.com/Mic92/fast-nix-gc @main

Chat with this repo
repository ↗ · DeepWiki ↗ · + Follow
362 symbols 1,129 edges 24 files 92 documented · 25% updated 3d ago★ 611 open issues
What it actually does AI analysis from the code graph — generated when you open this
loading…
README

fast-nix-gc

Faster nix-collect-garbage and nix-store --optimise. See real-world timings for a rough idea of how much faster.

fast-nix-gc

The stock GC issues one SQLite query per store path while traversing the reference graph. With ~100K paths this means ~100K B-tree seeks and a lot of statement-cache churn. fast-nix-gc instead reads ValidPaths and Refs once into a CSR adjacency list and runs the liveness BFS over integer node ids. On a real store with ~30K dead paths this brings the dry-run from ~20s down to ~1s. Disk deletion and .links cleanup are parallelized with rayon.

fast-nix-gc [OPTIONS]

  -d, --delete-old              Remove old profile generations
      --delete-older-than SPEC  Delete generations older than SPEC (e.g. 30d, 4h)
      --dry-run                 Show what would be done
      --ensure-free SIZE        Free until SIZE is available (e.g. 50G or 20%)
      --keep-recent SPEC        Keep paths registered within SPEC (e.g. 1d)
      --no-vacuum               Skip the database VACUUM after deletion
      --chunk-size N            Dead paths per delete transaction [default: 65536]
      --keep-outputs BOOL       Override the keep-outputs nix.conf setting
      --keep-derivations BOOL   Override the keep-derivations nix.conf setting
      --gc-roots-dir PATH       Extra directory to scan for GC roots (repeatable)
      --store-dir PATH          Nix store directory [default: /nix/store]
      --state-dir PATH          Nix state directory [default: /nix/var/nix]

fast-nix-optimise

Hardlink-based store dedup, on-disk compatible with nix-store --optimise: It uses the same .links/ layout and the same NAR-SHA-256 filenames Hashing and linking run as concurrent tokio tasks. An already deduped store is skipped via d_ino from readdir without rehashing. We measured ~2x faster than upstream on a warm store.

fast-nix-optimise [OPTIONS]

      --dry-run             Show what would be done
      --min-size BYTES      Skip files smaller than BYTES
  -j, --jobs N              Concurrency [default: num CPUs]
      --store-dir PATH      Nix store directory [default: /nix/store]
      --state-dir PATH      Nix state directory [default: /nix/var/nix]

Both tools take a shared gc.lock so they don't race each other or Nix. While fast-nix-gc deletes, it serves the GC roots socket (state/gc-socket/socket same as nix-store --gc). A concurrent nix builds register temp roots without blocking on the lock.

NixOS module

Replaces nix.gc and nix.optimise:

{
  inputs.fast-nix-gc.url = "github:Mic92/fast-nix-gc";

  outputs = { nixpkgs, fast-nix-gc, ... }: {
    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
      modules = [
        fast-nix-gc.nixosModules.default
        {
          services.fast-nix-gc = {
            enable = true;
            automatic = true;
            dates = "weekly";
            deleteOlderThan = "30d";
            ensureFree = "50G";
            keepRecent = "1d";
          };
          services.fast-nix-optimise = {
            enable = true;
            automatic = true;
            dates = "weekly";
          };
        }
      ];
    };
  };
}

services.fast-nix-gc options:

Option Default Description
enable false Enable the systemd service
automatic false Run on a schedule via systemd timer
dates "03:15" When to run (systemd.time(7) calendar event)
randomizedDelaySec "0" Random delay before each run
persistent true Run on next boot if a scheduled run was missed
deleteOlderThan null Remove profile generations older than e.g. "30d"
ensureFree null Stop once this much disk is free, e.g. "50G" or "20%" of the store's filesystem
keepRecent null Pin paths registered within e.g. "1d"
noVacuum false Skip the post-GC database VACUUM (see below)
chunkSize null Dead paths per delete transaction (default 65536)
gcRootsDirs [ ] Extra directories to scan for GC roots, e.g. [ "/mnt/extra-roots" ]
package this flake's package Override the binary
extraArgs [ ] Extra CLI arguments

services.fast-nix-optimise options:

Option Default Description
enable false Enable the systemd service
automatic false Run on a schedule via systemd timer
dates "04:15" When to run; ordered after fast-nix-gc.service when both run
randomizedDelaySec "0" Random delay before each run
persistent true Run on next boot if a scheduled run was missed
minSize null Skip files smaller than this many bytes
jobs null Concurrency, defaults to the CPU count
package services.fast-nix-gc.package Override the binary
extraArgs [ ] Extra CLI arguments

Without flakes, import nix/module.nix directly.

nix-darwin

darwinModules.default exposes the same services.fast-nix-gc and services.fast-nix-optimise options, run as launchd daemons. Scheduling uses launchd's startCalendarInterval (a list of launchd.plist(5) StartCalendarInterval entries) instead of dates:

fast-nix-gc.darwinModules.default
{
  services.fast-nix-gc = {
    enable = true;
    automatic = true;
    startCalendarInterval = [ { Hour = 3; Minute = 15; } ];
    deleteOlderThan = "30d";
    ensureFree = "50G";
  };
}

When to use --no-vacuum / noVacuum

After deleting dead paths, fast-nix-gc runs VACUUM when at least a quarter of the database is free pages. In WAL mode this rewrites the entire database through the write-ahead log. SQLite cannot truncate the resulting database-sized db.sqlite-wal while any connection holds a read snapshot. This is the reason Nix disabled GC vacuuming in commit 8299aaf.

On builders that are never idle, enable --no-vacuum. The free pages stay in db.sqlite and are reused for new registrations. A later GC on a quiet system reclaims the space.

Tuning --chunk-size / chunkSize

Dead paths are invalidated in batches, one SQLite transaction per batch, with the write-ahead log truncated after each. The batch size trades disk headroom against checkpoint overhead:

  • Smaller keeps each transaction's db.sqlite-wal small. A single transaction over the whole dead set grows the WAL until commit. On a full disk that aborts with SQLITE_FULL and frees nothing. Chunking bounds the WAL and reclaims space incrementally.
  • Larger means fewer transactions and fewer checkpoint fsyncs, so deletion runs faster, at the cost of a larger transient WAL.

As a rule of thumb, deleting a path dirties scattered B-tree pages across the references table and its indexes, costing very roughly 10 KiB of WAL per dead path on a large, cold store, so a batch's WAL is about chunk-size × 10 KiB. The default of 65536 keeps it near 640 MiB, safe on all but a nearly full disk. With tens of GiB free, --chunk-size 262144 (~2.5 GiB WAL) cuts the checkpoint count ~4x. Only higher when you have enough free disk space, since the WAL grows linearly with the batch size.

The truncation after each batch can only reclaim WAL frames older than the oldest live read snapshot. A long-running reader i.e. nix-daemon pins those frames, so the WAL keeps growing across batches regardless of --chunk-size, potentially until the disk fills. For a large GC on a tight disk stop nix-daemon (and any other store reader) first, or keep the chunk size small enough that even an unreclaimed WAL fits the free space.

Building

nix build

or nix develop -c cargo build --release. Without flakes: nix-build (uses default.nix).

Testing

cargo test       # against a synthetic store in a tempdir
cargo bench      # throughput across several synthetic store sizes

Two fuzzers back the behavioral claims below:

# differential: random store graphs, roots, configs and corruption
# vs nix-store --gc, deterministic per seed
cargo run --release --bin fuzz-nix-diff -- --iterations 20

# graph logic vs a reference model, no Nix (stable Rust)
cargo fuzz run -s none --fuzz-dir fuzz gc_graph

Behavior

Roots are gathered from gcroots/, profiles/, temproots/, and running processes (/proc on Linux; libproc syscalls on macOS instead of shelling out to lsof). Stale temp-root files and dangling auto-roots are removed. tmp-* build dirs are skipped if a builder still holds the lock.

keep-derivations and keep-outputs are honored with the same edge semantics as nix-store --gc: an alive output keeps its derivation (keep-derivations), an alive derivation keeps its outputs (keep-outputs). This includes content-addressed / dynamic derivations: drv↔output mappings are read from ValidPaths.deriver, DerivationOutputs, and the BuildTraceV3 table (Nix ≥2.35).

The store is remounted read-write on NixOS where it's bind-mounted read-only.

Database vacuum

SQLite never shrinks db.sqlite on its own, so after deletion the GC runs VACUUM when at least 25% of the file is free pages, still under the exclusive gc.lock. VACUUM is atomic: if it fails (e.g. out of disk for the temp copy) the database stays valid and the next GC retries. On a real-world 648 MB database that was 63% free pages, vacuuming shrank it to 216 MB and made the cold-cache graph load 4x faster (whole dry-run: 2.4x).

Corrupted stores

Disk and database can disagree after crashes or tampering. The GC repairs both directions: store entries without a database row are deleted, and rows whose disk entry is missing are removed once the path is garbage. Nix keeps a .drv row forever if its file is gone from disk, and if that path is reachable from a gcroot, nix-store --gc aborts entirely until the store is repaired by hand. fast-nix-gc decides liveness from the database alone and handles both.

Known limitation: keep-outputs with ca-derivations

On Nix without the BuildTraceV3 table (older than 2.35), a derivation depending on a floating content-addressed output has deferred output paths: nothing in the database links it to its outputs until Nix re-parses the .drv at GC time, which fast-nix-gc doesn't do. With keep-outputs = true such a rooted derivation therefore does not keep its outputs alive. This only loses cache, the outputs stay rebuildable. Nix itself has no consistent behavior here: --print-dead, the real GC and repeat runs all disagree, since the re-parsing happens during the GC walk and is order-dependent. Revisit once upstream defines stable semantics.

The GC takes the same gc.lock Nix does, so it won't race with nix-build or another GC. If interrupted mid-run the DB stays conservative: paths gone from the DB but still on disk are picked up as unknown entries by the next GC.

FAQ

Could this have been implemented in upstream Nix?

Yes, but C++ and concurrency is a scary combination, which is why I went with this implementation first.

Real-world timings

This is not a scientific benchmark, just journalctl data from a few of my machines after switching the daily GC timer from nix-gc.service to fast-nix-gc.service. Workloads differ between runs, sample sizes are small, and hardware varies. Take it as a rough indication of scale, not a controlled measurement.

Host Store profile nix-gc wall clock fast-nix-gc wall clock Rough speedup
Laptop (large store, daily dev churn) ~100k+ paths 1m25s – 19m, median ≈ 7m 5s – 20s ~25–60×
Build server (huge store, CI churn) very large 19m – 30m, avg ≈ 22m 7s – 17s ~80–180×
Small VPS (tiny store) small 4s – 23s 15s ~1–1.5×

The speedup grows with store size: stock nix-gc pays a large fixed cost walking the whole live closure even when there's almost nothing to delete (near-idle runs still took minutes on the bigger machines), while fast-nix-gc stays in the single-digit-seconds range. On a tiny store the overhead is negligible either way and the two are roughly comparable.

Core symbols most depended-on inside this repo

path
called by 108
difftest/src/main.rs
len
called by 73
crates/common/src/db.rs
symlink
called by 30
difftest/src/main.rs
add_path
called by 25
crates/common/src/db.rs
setup
called by 15
crates/common/src/db.rs
unlock
called by 14
crates/optimise/src/optimise.rs
open
called by 14
crates/common/src/db.rs
is_empty
called by 14
crates/common/src/db.rs

Shape

Function 256
Method 77
Class 26
Enum 3

Languages

Rust97%
Python3%

Modules by API surface

crates/gc/tests/integration.rs64 symbols
crates/common/src/db.rs43 symbols
crates/optimise/src/optimise.rs38 symbols
crates/gc/src/roots.rs35 symbols
difftest/src/main.rs33 symbols
crates/gc/src/gc_socket.rs26 symbols
crates/gc/src/profiles.rs24 symbols
crates/gc/src/main.rs13 symbols
crates/gc/src/gc.rs12 symbols
fuzz/fuzz_targets/gc_graph.rs9 symbols
scripts/compare-dry-run.py8 symbols
crates/common/src/temp_roots.rs8 symbols

For agents

$ claude mcp add fast-nix-gc \
  -- python -m otcore.mcp_server <graph>

⬇ download graph artifact