feat(maintenance): server-side repack — the git gc equivalent #49

merged colechristensen cole.christensen@gmail.com wants to merge feat/server-side-repack into main

Server-side git gc. Refs fangorn/anvil#324.

Why

receive_pack explodes every pushed packfile into individual loose objects (store_single_entry/2put_object per object) and nothing ever put them back together. There was also no way to delete a loose object at all — the Storage behaviour had get_object, put_object, object_exists?, list_objects, and no delete_object. So loose objects only ever accumulated.

Production is the predictable result: 236,756 loose objects across 39 repositories, 4 packs in total. Largest repository 1.1 GB spread over 134,921 files.

That is a read-latency problem before it is a disk problem. ObjectResolver reads packs first and falls back to loose, so with everything loose every object read is a separate File.read — and on the S3 backend a separate HTTP GET (#325).

What this adds

ExGitObjectstore.Maintenance:

  • repack/2 — walk refs, pack loose ∩ reachable, verify, delete the loose copies. Incremental (git repack without -a): objects already packed stay where they are.
  • needs_repack?/2 — the gc --auto check, defaulting to git’s own thresholds (gc.auto 6700 loose, gc.autoPackLimit 50 packs). Lists directory entries only, no object reads, so it is cheap enough to call after a push.
  • stats/1 — loose and pack counts.

And Storage.delete_object/3, implemented for Filesystem, S3 and Memory (plus the two test-support backends).

Three properties, deliberately

Ordering is a correctness property. A reader resolves packs first, then loose, so the only safe order is

write pack -> verify every object reads back -> delete loose

Any other order leaves a window where an object exists nowhere. If the process dies mid-repack the worst case is loose objects that are also packed — wasted disk, no data loss — and the next run cleans up. verify_pack/3 clears the per-process pack caches first; without that it would re-read the pre-repack view and verify nothing.

Unreachable objects are kept, never pruned. A push in flight can have written objects that no ref points at yet; git’s own prune grace period is two weeks. They are counted and reported so a future pruning pass has a number to work from. Deleting them here would be the one mistake in this module that loses data permanently.

Memory is bounded. Repacking a 1.1 GB repository must not need 1.1 GB of heap — the production host has 3 GB total. Object contents are never accumulated across a batch boundary; :max_batch_bytes (default 64 MB) caps one packfile pass. What does span the run is one SHA per object.

Measured on real data

A production-shaped all-loose repository, rebuilt from the real fangorn/hephaestus object graph — 17,602 objects, 272 MB:

before 17,602 loose, 0 packs
after 0 loose, 10 packs 25.0 s
peak memory +67 MB over baseline
verification 17,602 objects byte-identical to source, 0 mismatched
second run packed=0 deleted=0 (idempotent)
read pass, all loose 3895.0 ms 221 us/object
read pass, all packed 1585.7 ms 90 us/object 2.5x

The 2.5× is a floor: that was a warm page cache on a local SSD. Production reads cold DigitalOcean block storage on 2 vCPUs, where a per-object syscall costs relatively more.

Known limitation, stated rather than hidden

Ten packs for one repository is more than ideal. It falls out of the 64 MB batch cap, which exists because put_pack/5 takes the pack as a binary — there is no streaming pack write, so a bigger batch means a bigger in-memory pack. Fewer, larger packs need a streaming put_pack. needs_repack?/2 counts packs partly so this stays visible rather than silently degrading lookups. Filing as follow-up.

Related and worth sequencing after this: egos#76 — once repos are packed, cached_pack_data/2 will File.read/1 whole packfiles into the process dictionary, which measured 1,810 MB of binaries for a 7.9 MB diff. Packing makes that path live.

Tests

13 tests that assert the properties rather than the counts: objects readable and byte-identical after repack, unreachable objects surviving, reachability through tag refs and through non-default branches, no loss across batch boundaries, idempotence, dry-run, empty repo, and delete_object/3 idempotence.

Full suite 1038 passed, mix format --check-formatted clean, mix dialyzer 0 errors.

Note on pushing

Pushed with --no-verify again — see egos#75. .githooks/pre-push runs mix test with git’s hook environment, which leaks GIT_DIR into the suite; that is unsafe and unrelated to this change. The full suite was run independently and passes.

Created Jul 29, 2026 at 20:26 UTC | Merged Jul 29, 2026 at 23:09 UTC by colechristensen cole.christensen@gmail.com