Skip to content

[blob-store 2/5] Reference-aware GC for v2/ blobs + gzip the manifest - #7

Closed
chondl wants to merge 2 commits into
cph-bucket-first-servingfrom
blob-gc
Closed

[blob-store 2/5] Reference-aware GC for v2/ blobs + gzip the manifest#7
chondl wants to merge 2 commits into
cph-bucket-first-servingfrom
blob-gc

Conversation

@chondl

@chondl chondl commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Track 2 · blob-store stack — PR 2 of 5. Base: bucket-first-serving (#2); merge after #2. Stack order: #2 → #7 → #9 → #10 → #11. Two independent, low-risk cleanups (reference-aware GC + gzipped manifest). No cross-track conflicts.


Stacked on bucket-first-serving. Two independent changes to the blob publish
layer: a reference-aware garbage collector for the versioned v2/ blobs, and a
gzip content-encoding for manifest.json. One commit per change. No new tests on
this branch.

1. Reference-aware GC for v2/ blobs

Problem. Copy-on-write publishing uploads a blob to a new content-addressed
key v2/{path}.{hash12} only when its payload changes, and the manifest flips to
point at the new key. The superseded key is never referenced again but is never
deleted, so v2/ grows without bound. The PR description recommended a GCS
lifecycle rule, but a blind "delete objects older than N days" rule is unsafe: a
blob that has not changed in months is still current and still referenced by the
manifest, and age alone cannot distinguish it from a superseded version.

Design. gc_versioned_blobs() in src/google/storage.py:

  1. Read the current manifest.json; collect its referenced versioned keys.
  2. List every object under v2/.
  3. Keep an object if it is referenced by the current manifest or younger than
    a grace window (GC_GRACE_HOURS = 48, a constant).
  4. Delete the rest in batches (bucket.delete_blobs, 100 at a time).

It only ever lists and deletes under the v2/ prefix, so legacy unversioned
paths, hist/, and manifest.json are never touched. It is exposed as a GET on
the data router (/v3/data/gc_blobs, alongside the existing update_curr_year
etc.) so Cloud Scheduler can trigger it, with optional grace_hours and dry_run
query params. It is idempotent — a second run with no intervening publish finds
nothing new to delete — and it logs a one-line summary
(GC v2/: scanned … kept … deleted … freed … bytes).

Concurrency race and why the grace window is correct. A publish uploads new
v2/ objects before it writes the manifest that references them (manifest-last
atomicity). If GC ran concurrently with a publish and only subtracted the
current manifest's references, it could see a freshly uploaded object that the
about-to-be-written manifest will reference, judge it unreferenced, and delete it —
tearing the set the publish is assembling. The grace window closes this: any
object younger than 48h is kept regardless of reference state, so an in-flight
publish's new objects are always protected until well after its manifest lands.
The same window also protects objects referenced by a previous manifest that a
live client still holds in its 60s cache (plus CDN TTL) — 48h is far beyond any
realistic client/edge cache lifetime — so a client resolving an old manifest never
races a delete. GC is therefore safe to run concurrently with a publish and safe
to run on any schedule; it never needs to coordinate with the publisher.

Dry-run evidence (local rig, fake-gcs, full 2026 set). v2/ held 4,483
objects, of which the current manifest referenced 3,950.

  • grace_hours=48, dry_run=true → scanned 4,484, kept 4,484, deleted 0 — every
    object on the rig was created recently, so the grace window protected all of
    them, including a planted unreferenced probe. This is the young-object rule in
    action.
  • grace_hours=0, dry_run=true → scanned 4,484, kept 3,950, would delete 534
    (533 accumulated superseded versions + 1 planted probe), freeing 858,001 bytes;
    nothing actually deleted.
  • grace_hours=0, dry_run=false → deleted 534, freed 858,001 bytes; a 25-key
    sample of manifest-referenced objects all survived (25/25), and the planted
    probe was gone.

fake-gcs sets time_created to now for every object, so the grace window was
parameterized to 0 to exercise the delete path in the same run; production uses
the 48h default.

2. gzip manifest.json

Problem. manifest.json is ~169 KB of plain JSON and is refetched by every
client every 60s (max-age=60) — the dominant egress line item at scale.

Design. write_manifest() now stores the manifest gzip-compressed with
content_encoding='gzip'. GCS transcoding serves the compressed bytes to clients
that send Accept-Encoding: gzip (all browsers) and transparently decompresses
for those that do not, so no reader changes: the frontend's fetch(...).json()
and the backend's download_as_bytes() both receive plain JSON unchanged.

Measured. manifest plain JSON 169,138 bytes → gzipped 53,569 bytes (0.317×,
a 68% reduction) at 3,950 entries. Round-trip verified: read_manifest() returns
an identical blob map after the gzip write.

No reader change, verified through fake-gcs on the rig. curl of the gzipped
manifest:

  • with Accept-Encoding: gzipContent-Encoding: gzip, 53,569 bytes, gzip magic
    1f8b.
  • without → no Content-Encoding, 169,138 bytes, body starts { (decompressed).

fake-gcs reproduces real GCS transcoding, so the rig is sufficient here.

Notes

STYLE: no code comments, smallest reviewable diff, one commit per change.
Pure-logic tests for the GC keep/delete partition can follow on a stacked
*-tests branch if wanted, matching this stack's convention of keeping test
infrastructure off the feature branch.

@chondl
chondl force-pushed the blob-gc branch 2 times, most recently from 8baafe1 to 0338012 Compare July 10, 2026 20:27
@chondl chondl changed the title Reference-aware GC for v2/ blobs + gzip the manifest [blob-store 2/5] Reference-aware GC for v2/ blobs + gzip the manifest Jul 10, 2026
chondl added 2 commits July 10, 2026 14:46
List v2/ objects, subtract the current manifest's references and objects
younger than a 48h grace window, and delete the remainder in batches.
Exposed as a data-router endpoint (grace_hours and dry_run params) so a
scheduler can trigger it. Legacy paths, hist/, and manifest.json are never
touched.
manifest.json is refetched per client each minute and is the dominant egress
cost. Store it gzipped with Content-Encoding: gzip; GCS transcoding serves it
compressed to browsers (which send Accept-Encoding: gzip) and transparently
decompressed otherwise, so no reader change is needed.
@chondl

chondl commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #20 — reopened as #20 [02] on cph-blob-gc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant