feat: private-content caching opt-in with fleet-wide delete purge - #155
Conversation
download_cache_private (default off) admits private uploads to the download cache — read-through and seeding, keyed on the serve path's DataMap derivation. Deletes now propagate to every instance: the delete appends the upload's cache keys to cache_purge_log before any row is removed, each instance's 1-minute sweep tick consumes the tail and never advances past a failed unlink, and boot reconciliation validates the whole cache directory against live rows (uploads.cache_key, stamped at terminal statuses and backfilled at writer boot) for instances that were down past log retention. Purge window: synchronous on the handling instance, ~one tick everywhere else. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Boot reconciliation (correctly) purged the sweep tests' fixtures as orphans before the eviction assertions ran — the entries stood in for legitimately cached live content and now have matching rows, which is also what production looks like. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Panel review — changes requestedReviewed head: Consensus: request changes. Five of six substantive seats classified the private-cache lifecycle gaps below as merge blockers; one seat dissented on severity but independently found the opt-out retention gap. The ordinary implementation and tests are clean, but the advertised whole-fleet/durable purge contract is not yet true under failure and boot interleavings. 1. Boot reconciliation can permanently skip a concurrent delete
A valid ordering is:
The remote instance retains deleted private plaintext until another restart or incidental eviction. I reproduced this ordering deterministically; the current tests cover boot-then-delete, not log-insert → reconcile → row-delete. Required invariant/fix: make purge-log insertion, related-row cleanup and upload-row deletion one database transaction. Other connections must see either row-live/no-log or row-gone/log-present. Add a deterministic regression test around that visibility boundary. 2. Seven-day pruning can erase an affected instance's only retry recordOn I reproduced this deterministically:
The plaintext and cache entry remain because the retry record is gone. Later deletes behind the failed key can also be delayed on that instance. Required invariant/fix: pruning must not destroy unconfirmed erasure work. Viable shapes include durable per-instance acknowledgements, retaining the log until safe, or a periodic full liveness reconciliation on every instance as a guaranteed backstop. Also prevent one failed key from indefinitely starving later purges. Add tests for failed unlink → retention expiry/prune → recovery, and failed first entry followed by later deletions. 3. Turning private caching off does not remove existing private plaintext
For an explicit private-data opt-in, “Disabled” should not silently mean “new caching disabled, old plaintext retained indefinitely.” Either implement true→false fleet purge plus policy-aware boot reconciliation, or explicitly redesign/document the control as admission-only with a required drain workflow. The former matches the rest of this PR's security contract. Required tests: true→false purges online instances; an instance offline past log retention purges private entries at boot; no-op true→true/false→false updates do not create spurious work. Non-blocking inherited issue
What is sound
Verification
No approval or merge action was taken; this is an advisory panel review. |
Finding 1: the purge-log append and upload-row delete now commit in one transaction — no interleaving lets a reconciling instance record a log high-water mark, still see the live row, and miss the delete between. A refused delete rolls its log rows back (regression-tested). Finding 2: the full liveness reconciliation now re-runs every 12 hours (and on demand) as the guaranteed backstop, so a purge whose log row was pruned while its unlink was stuck is re-derived from live rows and retried until the bytes are gone; a stuck key no longer delays later purges — every entry is attempted per tick, only the high-water mark waits for the contiguous prefix. Both panel reproductions are now regression tests. Finding 3: reconciliation is policy-aware — turning download_cache_private off purges already-cached private plaintext (next tick online, boot reconciliation for instances that were down) instead of stranding it; no-op setting updates do no spurious work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks — all three blockers are fixed in 1. Delete atomicity: 2. Prune vs unconfirmed work + starvation: took the periodic-reconciliation shape. The full liveness reconciliation now re-runs every 12 hours (well inside the 7-day retention) and stays dirty — re-running every tick — while any of its unlinks fail, so erasure work never depends on the log row surviving. Your exact reproduction (stuck unlink → watermark stalls → prune → recovery) is 3. Opt-out removes existing plaintext: reconciliation is policy-aware ( The inherited Full |
Completes the panel's no-op-update matrix — false-to-false was already asserted; both are the same non-transition branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-review — changes since
|
|
Thanks — noted on the reconcile cost, with one precision: the periodic reconciliation is not a filesystem walk. It snapshots the store's in-memory index ( |
What
Private uploads can now be cached — behind an explicit operator opt-in — and deletes reach every instance's cache, not just the one that handled the API call. This extends the "bytes already on disk → serve from disk" story to private content, which was excluded until deletion could mean something fleet-wide.
Two halves:
download_cache_private(default off, admin-UI toggle): private downloads promote read-through and private uploads seed, exactly like public — keyed on the serve path's derivation (the local DataMap). Off, behavior is byte-identical to today. Authorization is untouched either way: the cache is consulted only after the row/owner/visibility checks.uploads.cache_key— indexed serve-path key stamped at every terminal store status; Go-side backfill for pre-existing rows at writer boot (the digest can't be computed in SQL).cache_purge_log— the delete appends the upload's cache keys (both derivations) before any row is removed, so a failure later can at worst cause a spurious purge and re-warm, never a missed purge.cache_keyindex before consuming the log — covering deletes that happened while it was down, even beyond log retention. The log high-water mark is read before the cache snapshot, so nothing falls between reconciliation and the tail.The purge-window contract (documented in the deployment guide): the instance handling a delete purges synchronously and fails the API rather than report a deletion with plaintext still readable (#154); every other instance purges within ~one sweep tick (60s). Replica lag would add to that window if read-replicas are ever introduced — called out in the docs.
Notes for review
Drop's error contract is what makes the no-advance-past-failure loop sound, and the promote-site resurrection guards already close promote-vs-delete races on every instance against the shared DB.Dropon non-live keys: the only concurrent promotion of a non-live key is a resurrection, which the promote-site guard is already unwinding.MarkPublished(private→public flip) keepsdata_map, and the serve path prefers it — so the stampedcache_keyis stable across publishing.Tests
All
-race, full suite green; frontend builds.cache_key(both derivations); delete appends both derivations to the purge log exactly once; legacy-row backfill is correct and idempotent; prune and liveness lookups.seedDownloadCachegates.🤖 Generated with Claude Code