feat: cache size management and LRU eviction (closes #51) #55
Reference in New Issue
Block a user
Delete Branch "feature/cache-size-eviction"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements #51 per the issue DoD and the owner direction comment (issuecomment-44068).
Behavior
Config:
cache_max_bytes(integrates with the #52/#53 validation framework)getInt64/int64Valgetter in the existing strict-loader pattern; the key is registered in the known-keys list. A SET but invalid value — negative, float, null, non-numeric string, boolean, list — aborts startup with exit 1 naming the key and the offending value.cache_max_bytes: 0is a valid value that disables the disk cache entirely.state_dirvalidation, the default resolves tomax(75% of free bytes on the filesystem containing <state_dir>/cache/, 500 MiB). The cache directory is created first and statfs runs on that actual path, so the measurement hits the right filesystem. The probe is injectable (FreeSpaceProbeFunc) so tests do not depend on the host disk. The effective limit (and disabled state) is logged at startup.Size accounting (no directory scans on the hot path)
002adds avariant_contenttable — processed variants were previously untracked anywhere — and alast_accessed_atcolumn onsource_content, both indexed. Total usage is two SUM queries.Eviction policy: global LRU across both content classes
variant_contentandsource_content(batched, 100 per class per pass, merged oldest-first byCOALESCE(last_accessed_at, fetched_at)), evicted until usage is at or below the limit.source_metadatarows referencing it plus itssource_contentrow in a single transaction BEFORE the file is unlinked. A blob referenced by multiple source paths is only ever removed together with all of its references, and DB rows never point at deleted files (the crash window leaves at worst an orphaned file, which reconciliation sweeps). The JSON metadata sidecars for removed rows are deleted as well.Triggers, off the request path
.metasidecar), drops accounting rows whose files are missing, removes source blob files the DB does not know (unreachable, since lookups go throughsource_metadata), removes rows whose files are gone, and sweeps.tmp-*files older than an hour.cache_max_bytes: 0disables the disk cacheStoreSource/StoreVariantare no-ops, no evictor runs; every request fetches and processes uncached. Verified end-to-end (below).Notes for review
imgcache.CacheConfiglayer, disabling is an explicitDisableDiskCacheflag rather thanMaxBytes == 0, because existing test fixtures constructCacheConfigwithoutMaxBytesand rely on the legacy "no limit" behavior; per repo rules those tests were not touched. The config layer mapscache_max_bytes: 0to the flag inhandlers.MaxBytes == 0at that layer means "no limit enforced" and is unreachable from production config (the computed default is always at least 500 MiB).newEvictionTestCache) to passDisableDiskCache: maxBytes == 0, mirroring the production mapping, when the flag design emerged. Assertions were not touched; no pre-existing tests were modified..meta, metadata JSON) are not counted in usage; they are bounded by entry counts and small (tens of bytes to ~1 KiB per entry) while content bytes dominate. Documented here for transparency.Cache.Statsreads the never-populatedoutput_content/request_cachetables, soTotalItems/TotalSizeBytesare always 0. Out of scope here; filing as a separate issue.Verification
3963ec3adds the failing tests first (18 new tests covering strict parsing, default computation with injected probe including floor and 75% branches, explicit-no-floor, zero-disables, size accounting, dedup accounting, LRU order, multi-reference blob eviction with the no-dangling-references invariant, under-limit no-op, write-pressure trigger, periodic trigger, reconciliation); implementation follows in8cb09b6/bdd86a4until green.make checkgreen (all tests, lint 0 issues, fmt-check) at HEAD.docker build --target lint .green (golangci-lint v2.10.1).computed default cache size limit from free spaceandeffective cache size limit(75% of the test host's free space);cache_max_bytes: banana: exit 1 withconfig key "cache_max_bytes": value "banana" is not an integer;cache_max_bytes: 0:cache_disabled=truelogged, two identical requests both fetch upstream (2 upstream fetches logged), 200image/jpegresponses, nocache/directory created, onlystate.sqlite3in the state dir;variant_contentandsource_contentrows match the on-disk file sizes.Built and verified as described in the PR body. Summary of what was done and how it was checked:
Commits (branch
feature/cache-size-evictionfrommainat61f42e6, headc1ec038):3963ec3— red phase: 18 failing tests (config parsing/default/floor rules, size accounting, LRU eviction, multi-reference blob safety, zero-disables, write-pressure and periodic triggers, reconciliation) plus minimal API skeletons so the tree compiles and lints; verified at that commit that ONLY the new tests failed.8cb09b6—cache_max_bytesconfig key: strictgetInt64getter, known-keys registration, non-negative validation, statfs-derived default with injectable probe, effective-limit logging.bdd86a4— migration 002 (variant_content+source_content.last_accessed_at), usage accounting, global-LRU background evictor with write-pressure and periodic triggers, transactional reference-safe source blob eviction, startup reconciliation,DisableDiskCachemode, handlers wiring (start on OnStart, stop on OnStop).c1ec038— docs (config.example.yml, README key list) andTODO.mdWorkflow bookkeeping (P1 blocked networks promoted to Next Step).Verification:
make checkgreen at HEAD (all tests, golangci-lint 0 issues, fmt-check);docker build --target lint .green against the pinned CI golangci-lint v2.10.1; end-to-end runs of the builtpixadconfirming the computed default is logged, an invalidcache_max_bytesexits 1 naming key and value,cache_max_bytes: 0serves every request uncached with no cache directory created, and the enabled path serves the second request from cache with accounting rows matching on-disk sizes.Discovered issue filed separately: #56 (
Cache.Statsreads the never-populatedoutput_content/request_cachetables).Verdict: FAIL (needs-rework)
Independent review of PR #55 against issue #51 DoD, owner direction (issuecomment-44068), implementer plan (issuecomment-44081), and REPO_POLICIES.md.
make checkwas run green in a local worktree at HEAD (c1ec038), plusgo test -race ./...(clean, no races detected) anddocker build --target lint .against the pinned golangci-lint v2.10.1 (clean). CI status onc1ec038is green and the branch is mergeable against currentmain(61f42e6). None of that is in question — the fail is a policy violation plus unaddressed correctness gaps in the eviction design.Blocking: iron-rule violation — migration numbering
internal/database/schema/002_cache_eviction.sqlis a new file. REPO_POLICIES.md states explicitly:> Pre-1.0.0: never add additional migration files (002, 003, etc.). There is no installed base to migrate. Edit 001_schema.sql directly.
TODO.md's own Status line confirms
pre-1.0. No git tags exist.git log --oneline --diff-filter=A -- 'internal/database/schema/*.sql'shows only000.sqland001_initial_schema.sqlexisted before this PR; commitbdd86a4is the first to add a002_*.sqlfile. The issue's implementer-plan comment (issuecomment-44081) proposed "Migration 002" on its own initiative — that is not an owner override of REPO_POLICIES.md (the review brief's supersession rule applies to issue #51's own DoD/body, not to the separate, written repo policy doc). Per the review brief, a change that violates an iron rule fails regardless of quality.Fix: fold the
variant_contenttable andsource_content.last_accessed_atcolumn directly into001_initial_schema.sqland drop002_cache_eviction.sql; there is no installed base to preserve.Correctness gaps (should fix, not individually fatal, but undisclosed)
evictSourceBlobTOCTOU window vs. concurrent content-addressed dedup (internal/imgcache/eviction.go:294-334).referencesis read once (line 295) before the transaction. The transaction (lines 300-319) deletessource_metadata/source_contentrows by a freshWHERE content_hash = ?query, so a new reference added before the transaction runs is safely swept too. But there is a real gap aftertx.Commit()(line 317) and beforec.srcContent.Delete(contentHash)(line 329):ContentStorage.Store()(storage.go:56-112) decides "already stored" purely fromos.Staton the content file, which still exists in this window. A concurrentStoreSourcecall for a different source path whose content happens to hash to the same value (genuine SHA-256 dedup, not a contrived case) will find the file present, skip rewriting it, andINSERT ... ON CONFLICT DO NOTHINGintosource_content— succeeding as a fresh row, since eviction's row was already deleted — and insert a freshsource_metadatarow referencing that hash. Eviction then unlinks the file at line 329, leaving that fresh row pointing at a deleted file: a transient violation of "rows never point at deleted files." It self-heals (LookupSource'ssrcContent.Existscheck at cache.go:324 reports a miss, and the next startup'sreconcileSourceRowscleans it up), but it is a real, untested gap in the exact invariant the DoD calls "the trickiest DoD case." None of the eviction tests exercise a write racing the unlink step.One-time-only reconciliation + best-effort accounting insert = unbounded drift risk for long-running processes.
evictionLoop(eviction.go:416-440) callsreconcileAccountingexactly once, before entering the ticker/pressure loop — it is never re-run periodically.StoreVariant(cache.go:269-295) makes itsvariant_contentinsert best-effort: on failure it only warns (line 288) and still returns success. If that insert fails during the life of a long-running process (e.g.SQLITE_BUSYunder concurrent writer contention —internal/database/database.goconfigures nobusy_timeoutand noSetMaxOpenConns, so contention between the evictor's deletes and concurrentStoreVariant/StoreSourceinserts is not guarded against), the variant file lands on disk untracked and stays untracked — invisible toUsageBytes/EvictToLimit— until the next process restart. That is a live channel for the disk to grow pastcache_max_byteswithout eviction ever noticing, which is the exact DoS class issue #51 exists to close. This risk is not mentioned in the PR's disclosed-deviations list (which only discloses that the insert is best-effort, not that recovery is restart-gated) and is not covered by any test that simulates a failed accounting insert during steady-state operation.Startup reconciliation races against request serving.
StartEviction(eviction.go:391-399) is invoked from theOnStarthook (handlers.go:88) and launches the reconciliation walk in a background goroutine without blocking; nothing prevents the HTTP listener from accepting requests whilereconcileSourceFiles/removeUntrackedSourceFile(eviction.go:617-667) are still walking.ContentStorage.Store()renames a new blob into place before itssource_contentrow is inserted (storage.go:56-112thencache.go:214-221). A source fetch completing in that specific window at cold start can have its just-written blob file treated as "untracked" and deleted by reconciliation before the DB insert lands. Self-healing (the request itself already has the bytes in memory), but it is a silent cache-write loss that isn't exercised by any test and isn't discussed as a known limitation.Minor race, not a safety issue:
evictVariant(eviction.go:267-279) can select a variant as an LRU candidate and then delete it after a concurrentStoreVarianthas just refreshed that same key (fresh timestamp, fresh bytes) — the freshly-written entry is destroyed rather than kept. No dangling reference results (row and file are deleted together), just wasted work / a reduced hit rate under churn. Noted for completeness, not required to fix.Process/tooling note (pre-existing, not this PR's fault, but relevant to the concurrency claims)
script/test(unchanged by this PR) runsgo test -timeout 30s -v ./...with no-race, somake check/CI never actually exercises the race detector over this PR's new goroutine + channel + shared-DB machinery. I ranCGO_ENABLED=1 go test -timeout 60s -race ./...manually in the PR worktree and it was clean, so I have no evidence of an actual data race, but the review brief's requirement ("confirm make test already does this") does not hold — flagging since a concurrency-heavy PR is exactly where that gap matters most.What's solid (no changes needed)
getInt64/cachesize.gostrict parsing correctly rejects negative, float, null, bare-key, non-numeric string, boolean, and list values, each with its own test (internal/config/cache_max_bytes_test.go) asserting both key and offending value appear in the error. Explicit values bypass the floor (tested); the 75%/500 MiB floor computation is correct integer arithmetic (divide-then-multiply, overflow-clamped) and both branches (75% dominant, floor dominant, including a "just below threshold" boundary case) are tested via the injectable probe.cache_max_bytes: 0is proven to create zero cache directories and write zero files (TestZeroMaxBytesDisablesDiskCache).TestEvictToLimitEvictsLeastRecentlyUsedFirstbackdates four distinct timestamps and asserts the specific victim and specific survivors, not just aggregate byte counts.TestEvictionRemovesMultiReferencedBlobTogetherWithAllReferencespopulates an actual 2-reference blob, forces real eviction via a real byte limit, and asserts bothsource_metadatarows and both sidecar files are gone together with the row and file, plusassertNoDanglingReferences. Write-pressure and periodic-trigger tests start the real goroutine and poll for the on-disk/DB effect, not just that a function returned without error. Reconciliation adoption/drop is exercised end-to-end (TestStartEvictionReconcilesAccountingWithDisk).make checkleaves the tree clean (git statusempty after); commit hygiene is correct (only the finishing commitc1ec038carries(closes #51), TDD red/green ordering is real pergit show --staton each commit); no Claude/Anthropic references or attribution trailers anywhere in the log or diff.DisableDiskCacheflag vs.MaxBytes==0, negative cache staying active when disk cache disabled, best-effort variant insert in isolation) are reasonable and adequately flagged for owner sign-off; no objection to any of them individually.Required for merge-ready
001_initial_schema.sql; delete the 002 file (policy violation, blocking).Cache.Statsfinding).Manager note: independent adversarial review came back FAIL (see the review comment above for full detail). Blocking issue:
internal/database/schema/002_cache_eviction.sqlviolates the pre-1.0 rule in REPO_POLICIES.md that forbids adding numbered migrations beyond001before a tagged release — the new table/column must be folded into001_initial_schema.sqlinstead. Additional non-blocking-but-real correctness gaps: a TOCTOU window inevictSourceBlobbetween the row-delete transaction and the file unlink, one-shot (not periodic) reconciliation letting best-effort-failed accounting inserts accumulate untracked disk usage indefinitely on a long-running process, andscript/testnot running with-raceso CI never exercises the race detector over this PR's new concurrency.Dispatching a rework pass against these findings, then a fresh independent reviewer.
Rework against the FAIL review (issuecomment-45064) and the manager note (issuecomment-45069). New commits on
feature/cache-size-eviction, headbdae9cb(previous headc1ec038):1. Migration-numbering policy violation (blocking) — fixed.
internal/database/schema/002_cache_eviction.sqlis deleted; thevariant_contenttable andsource_content.last_accessed_atcolumn (with their indexes) are folded directly into001_initial_schema.sql(commit6b0870d). No installed base existed to preserve. The migration runner (internal/database/database.go) is generic over whatever*.sqlfiles exist inschema/, so it needed no changes;TestApplyMigrations_CreatesSchemaAndTables(asserts >= 2 migrations, versions 0 and 1) still passes unmodified. Also removed the now-stale "migration 002" wording fromTODO.md(314ccbc).2. TOCTOU window in
evictSourceBlob(commit + unlink vs. concurrent dedup store) — fixed.Added
contentLock, a per-key mutex (internal/imgcache/contentlock.go, tests incontentlock_test.go: same-key exclusion, independence across distinct keys, and that the entry map doesn't grow unbounded).StoreSourcenow hashes content itself up front and holds that hash's lock across the whole store (content write + accounting inserts);evictSourceBlobholds the same lock across its whole operation (row-deletion transaction through file unlink). The two can no longer interleave: either fully completes before the other starts.ContentStoragegainedStoreHashedfor the pre-hashed path;Storewas refactored to share the write-if-absent logic with it, no change to its existing signature or behavior (internal/imgcache/storage.go).Evidence:
TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent(internal/imgcache/eviction_test.go) pauses eviction — via a test-only hook fired after the delete transaction commits and before the unlink — at exactly the window the review flagged, then races a concurrentStoreSourcefor a different path with identical content bytes against it. Confirmed red against the pre-fix code (err=<nil>, store completed instead of blocking; commit90b2f6f), green after the fix (9197b63): the store blocks for the full pause, then completes once eviction releases the hash,assertNoDanglingReferencespasses, and the re-stored blob is confirmed present (row + file both exist).3. One-shot reconciliation + best-effort insert = unbounded drift risk — fixed.
evictionLoopnow runs a reconciliation pass on every periodic ticker tick, not just once at startup (internal/imgcache/eviction.go, commite7964fe). Reconciliation walks the cache directories, so it deliberately only runs on the ticker (not on every write-pressure wakeup) to stay off the per-store hot path; it reuses the same interval eviction itself uses, documented inline — the simplest choice that still bounds unaccounted drift to one eviction interval regardless of process uptime.Evidence:
TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartupstarts the evictor, lets startup reconciliation settle on an empty cache, then writes a variant file directly to disk (bypassingStoreVariant, i.e. simulating its accounting insert having failed while the file write succeeded) and asserts a later pass adopts it. Confirmed red against one-shot reconciliation (41347a7: usage stayed 0, adoption never happened), green after the fix (e7964fe).4. Race detector coverage — fixed.
script/testnow runsCGO_ENABLED=1 go test -timeout 30s -race -v ./...(commitbdae9cb), scoped to exactly that flag addition as directed — no rewrite of the conditional-verbose-rerun pattern.go test -race ./...across the full module completes in ~6-7s, well inside the 30s timeout, and is clean: no races reported, including around the newcontentLock, the evictor goroutine, the write-pressure channel, and the concurrency test above.Verification:
make check(test, lint, fmt-check) is green atbdae9cb;go test -race ./...clean;git statusclean after. All new tests follow red-then-green per repo TDD rules (each fix's test committed failing before its implementation commit). No existing test assertions were modified.Label/assignee left as-is for the next independent review pass.
Manager note: rework pass complete (HEAD
bdae9cb), see the comment above for the per-finding evidence. Migration002folded back into001_initial_schema.sql, a per-content-hash lock now serializesStoreSourceagainstevictSourceBlobto close the TOCTOU window (new regression test), reconciliation now also runs on every periodic tick rather than once at startup (new regression test), andscript/testnow runs with-race.make checkgreen andgo test -race ./...clean.Labeling back to
needs-reviewfor a fresh independent reviewer.Verdict: PASS
Independent re-review of PR #55 at head
bdae9cb(second round, after the FAIL atc1ec038in comment #45064). Checked outbdae9cbin a clean worktree; verified all four rework claims independently against the actual code and by running the tests myself, and re-verified the original review scope rather than rubber-stamping it.1. Migration numbering — fixed, verified
git log --all --oneline -- 'internal/database/schema/*.sql'on this branch shows only000.sqland001_initial_schema.sqlever existed after commit6b0870d("fix: fold cache eviction schema into 001_initial_schema.sql"); no002_*.sqlfile exists in the tree or in branch history after that commit.internal/database/schema/001_initial_schema.sqlcontains thevariant_contenttable (lines 48-56) andsource_content.last_accessed_at(line 13) plus both new indexes, folded directly into the file.grep -rn "002" internal/database/ TODO.md README.md config.example.ymlturns up nothing but an unrelated illustrative comment indatabase.go:46("e.g. "001", "002"") describing the general filename-parsing scheme, not a real migration reference.TestApplyMigrations_CreatesSchemaAndTablesstill passes.TODO.md's stale "migration 002" wording was also removed (commit314ccbc). Matches REPO_POLICIES.md's pre-1.0 rule exactly.2. TOCTOU fix (
contentlock.go) — fixed, verifiedRead
internal/imgcache/contentlock.goin full: a reference-counted per-key mutex (entries map[string]*contentLockEntry, count protected by an outersync.Mutex, entry removed from the map only when its holder/waiter count reaches zero). This is a correct, standard keyed-mutex implementation — not global (proven byTestContentLockAllowsDifferentKeys, which requires all 20 goroutines on distinct keys to reach a rendezvous point simultaneously, timing out after 2s if they were serialized on one lock), doesn't leak entries (TestContentLockRemovesEntryAfterUnlock), and correctly excludes same-key holders (TestContentLockExcludesSameKey, asserts max concurrent holders == 1 across 20 goroutines).Call sites:
StoreSource(cache.go:210-304) hashes content itself before acquiring the lock (so the hash is known up front), then doesunlock := c.contentLocks.Lock(string(contentHash)); defer unlock()at line 243-244 covering content write, both DB inserts, and the metadata sidecar write — released viadeferon every return path including errors.evictSourceBlob(eviction.go:302-349) does the same at line 303-304, covering the references query, the full delete transaction (commit at line 328), and the file/sidecar unlinks (through line 346) — again viadefer, so no leak on any error return. The lock genuinely spans transaction-commit-through-unlink, closing exactly the window the first review flagged.Regression test
TestEvictSourceBlobExcludesConcurrentStoreOfIdenticalContent(eviction_test.go:744-838) uses a test-only hook fired after the delete transaction commits and before the unlink, pauses eviction there, and races a concurrentStoreSourceof identical content against it. It asserts (with a 200ms timeout) that the concurrent store does not complete while eviction holds the hash — this would fail immediately against a reverted/no-op lock, since the store would have nothing blocking it and would return well within 200ms. This is a real, race-forcing test, not a rubber-stamp assertion. RanCGO_ENABLED=1 go test -timeout 30s -race ./...myself: clean, no races, all packages pass (internal/imgcachein 3.86s).3. Periodic reconciliation fix — fixed, verified
evictionLoop(eviction.go:432-463) now callsc.runReconciliationPass(ctx)both once before entering the loop (startup) and again inside theselect'scase <-ticker.Cbranch (line 447-457) on every subsequent tick — confirmed by reading the code directly, not taking the PR body's word for it.TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(eviction_test.go:680-734) starts the evictor with a 100ms interval, sleeps 3 intervals to let startup reconciliation settle on an empty cache, then writes an untracked variant file directly to disk (bypassingStoreVariant), and polls for it to be adopted (UsageBytesreaching 900, one accounting row appearing) within a 5s deadline. Because the file is introduced strictly after startup reconciliation already ran and settled, this genuinely exercises the periodic path, not a relabeled startup test.On the design question raised: reconciliation is deliberately kept off the write-pressure trigger (only runs on the ticker, not on every store notification), staying off the per-store hot path, and is documented inline as such (
eviction.go:448-456). It does still do a realfilepath.WalkDirplus a stat-per-tracked-row pass (reconcileVariantRows) on every 5-minute tick now rather than once — a legitimate, disclosed-in-comments tradeoff (bounds accounting drift to one interval regardless of uptime) rather than a defect, but worth naming as a residual, non-blocking consideration: for very large caches this adds recurring directory-walk and per-row stat I/O to a background goroutine every interval, and becauseevictionLoop'sselectonly observesevictionStopbetween passes (not during one), a reconciliation pass in flight whenOnStopfires will make graceful shutdown wait for that pass to finish —StopEvictionblocks on<-c.evictionDonewith no timeout. Not a correctness bug and not something the DoD requires fixing, but flagging for awareness since it's a new-to-this-PR periodic cost, not a one-time startup cost.4. Race detector coverage — fixed, verified
git diff 61f42e6..bdae9cb -- script/testshows exactly the intended one-line change:go test -timeout 30s -v ./...→go test -timeout 30s -race -v ./.... Ran it myself (CGO_ENABLED=1 go test -timeout 30s -race ./..., full module): clean in ~7s, all packages pass, no races reported, includinginternal/imgcache(which carries all the new lock/goroutine/channel code) andinternal/config.make checkalso green end-to-end (~26s wall, includes lint + fmt-check).Re-verification of original scope (not just re-trusting the first PASS items)
getInt64/cachesize.go(internal/config/config.go:539-587,cachesize.go): strict parsing rejects negative (viavalidate(),config.go:319-323), non-integer float, null, non-numeric string, and any other type (bool, list) falls through to thedefault:case and errors — confirmed by reading the switch directly, consistent in structure and idiom with the existinggetInt/getBoolstrict getters.cache_max_bytes: 0is accepted (only< 0is rejected) and disables the cache end-to-end (TestZeroMaxBytesDisablesDiskCacheasserts zero DB rows, zero files, nocache/directory created at all — real filesystem walk, not just a flag check).ComputeDefaultCacheMaxBytes(cachesize.go:55-74) divides before multiplying (overflow-safe) and clamps tomath.MaxInt64; floor applies only to the computed default (resolveCacheMaxBytes, only entered when!cacheMaxBytesExplicit), never to explicit values (TestCacheMaxBytesExplicitValueUsedWithoutFloor,TestResolveCacheMaxBytesDoesNotOverrideExplicitValue).TestEvictionRemovesMultiReferencedBlobTogetherWithAllReferences,eviction_test.go:340-415): real 2-reference blob, real eviction via a real byte limit, asserts the blob is gone from disk, fromsource_content, and from bothsource_metadatarows, both JSON sidecars removed, the untouched (more-recently-used) blob and its single reference survive intact, plusassertNoDanglingReferences. Not vacuous.make check(test + lint + fmt-check via the Makefile/script/entrypoints only) is green atbdae9cb; localgolangci-lint versionis2.10.1, matching the Dockerfile-pinned lint image exactly (golangci/golangci-lint:v2.10.1-alpine), so the 0-issues result is meaningful against the same version CI uses..golangci.ymluntouched.git statusclean aftermake check(no files modified).bdae9cb:check / checkstatus issuccess. PR reportsmergeable: true;origin/mainis still at61f42e6, the PR's own base, so there is no drift to reconcile — trivially mergeable.(closes #51)is present onc1ec038, the original finishing commit that remains part of this PR's history; the repo's default merge style is squash, so the eventual merge commit message will derive from the PR title, which also ends(closes #51). TDD ordering for all three rework fixes is real red-then-green (test: add failing test for Xcommits precede theirfix:commits, each independently confirmed failing/passing per the rework report and consistent with what's in the diff).README.mdandconfig.example.ymlboth documentcache_max_bytesaccurately;TODO.mdWorkflow bookkeeping is correct (P1 blocked-networks promoted to Next Step, Completed Steps entry added, stale migration wording removed), and formatted per repo conventions.script/test) is directly attributable to this issue's DoD.Non-blocking observations (not required for merge)
StopEviction/graceful shutdown if a pass is in flight whenOnStopfires (no timeout on the wait). Worth a follow-up issue if it ever shows up in practice, not a blocker here.evictVariantcan destroy a variant a concurrentStoreVariantjust refreshed) remains unaddressed, but was explicitly called out by the previous review as non-blocking ("just wasted work / reduced hit rate under churn, no dangling reference"), and that assessment still holds — not part of the required fix set.No blocking findings. This PR may be labeled
merge-readyand assigned to sneak.Manager note: independent second-round review PASS (see comment above) — all four round-1 findings independently re-verified as genuinely fixed (migration folded back into
001_initial_schema.sql, TOCTOU closed via a real per-content-hash lock with a race that was reasoned to actually catch the original bug, reconciliation now runs every periodic tick,-raceenabled inscript/testand clean). Original scope re-checked and still holds; CI green onbdae9cb.Labeling
merge-readyand assigningsneakto merge (protectedmain).Manager note: this PR stays
merge-readyand assigned tosneak— no action needed here — but it should be merged BEFORE #54.Re-verified independently just now:
feature/cache-size-evictionatbdae9cbstill merges cleanly onto currentmain(61f42e6, unchanged), and CIcheck / check (push)issuccesson that head. Nothing about this PR has regressed.The reason for the ordering note is a collision between this PR and #54 that neither PR's isolated review could have caught. Both branch from
61f42e6, both merge cleanly ontomainindividually, but they conflict with each other. Verified by actually performing the merge in a temp worktree:Additionally, this branch still carries the pre-canonical
.golangci.yml(sha2567b38c4ef3c8cf1f3be006f0f8c980169c9f26a6361bfada32efeb00d8056eb9d, same asmain) and the oldgolangci/golangci-lint:v2.10.1-alpineDockerfile pin, so the roughly 2,600 lines added here —internal/imgcache/eviction.go,internal/imgcache/contentlock.go,internal/config/cachesize.goand their tests — have never been linted under the canonical v2.12.2 config that #54 introduces.I am deliberately not asking this PR to absorb that work. Bringing newly-landed code into canonical-config conformance is #54's entire purpose, and #54 has already done exactly that once for #53's config-validation code after it landed. Forcing a large mechanical lint refactor onto this PR instead would reopen review surface on a correctness-sensitive concurrency change that has already passed adversarial review — the wrong risk trade.
So: merge this first. #54 has been moved to
needs-rebaseand reassigned toclawbot; once this lands, a rework pass will rebase #54 onto the newmain, run the canonical-config lint pass over the eviction code, and go back through a fresh independent review.One consequence worth stating plainly for the record: the lint conformance findings on this PR's new code are being deferred to #54 rather than waived. They will be fixed there, under an independent review, before #54 merges.
what is the periodic reconciliation for, and what is the adoption thing? do we need this?
Answering @sneak's question (issuecomment-45166). Read the code on the branch to answer this rather than quoting the PR body.
What reconciliation is for
Size accounting in this PR is a derived view:
UsageBytesis twoSUMqueries overvariant_contentandsource_content. Eviction only ever sees bytes that have a row. So the whole scheme depends on "every file on disk has an accounting row, and every row has a file" — and there is no way to make a filesystem write and a SQLite insert atomic with each other. Reconciliation is the repair pass for the gap. Concretely it fixes four kinds of drift:.tmp-*leftovers from crashed writes.What "adoption" is
adoptVariantFile(eviction.go) is the fix for case 1, for variants specifically. It walks the variant directory, and for a file with novariant_contentrow it inserts one, takingsize_bytesfromstat,created_at/last_accessed_atfrom the file mtime, andcontent_typefrom the.metasidecar (falling back toapplication/octet-stream). "Adopt" = start counting this orphan file as cache usage, which also makes it eligible for eviction. Before adoption it is a file nothing will ever delete and nothing counts.It matters for variants and not sources because the two store paths differ:
StoreSourcetreats itssource_contentinsert as fatal (returns the error, store fails), whileStoreVariant's insert is deliberately best-effort — on failure it logs a warning and still returns success, leaving a file with no row.Do we need it?
Three separable pieces, and the honest answer differs for each:
StoreVariant's best-effort insert (avoidable).StoreVariant's best-effort insert. That was round 1's finding: with startup-only reconciliation, an insert that fails at hour 3 of a 30-day process stays unaccounted for 30 days. Making the pass periodic bounds that to one interval. It is a patch over the best-effort insert, not an independently motivated feature.And I want to flag a cost the reviewer only partly named.
adoptVariantFileissues oneSELECT COUNT(*) FROM variant_content WHERE cache_key = ?per file, andreconcileVariantRowsstats every tracked row — on every tick, now, not once. On a cache sized at the default (75% of free space, easily hundreds of GB, millions of variants) that is millions of SQLite queries plus a fullWalkDirevery 5 minutes, against a proxy whose README targets 1k-5k req/s. It is off the request path and shutdown-safe-ish, butStopEvictionwaits on an in-flight pass with no timeout, so it can also drag out graceful shutdown. At small cache sizes this is free; at the sizes the default config picks it is not.Options
A. Merge #55 as-is, file the simplification as a follow-up. Current behavior is correct — two adversarial reviews confirmed it — just wasteful at scale. P0 disk-fill fix lands now, and #54 unblocks (it is currently blocked behind this PR).
B. Rework #55 now: make
StoreVariant's accounting insert authoritative, return reconciliation to startup-only. If the insert fails, the store fails and the file is removed — the request still succeeds, it just does not get cached. Then a file-without-row can only come from a crash, which is by definition followed by a startup, so the startup pass is sufficient and the periodic walk disappears entirely. Cleanest invariant and least background I/O. Cost: reopens a P0 that has passed review, needs rework plus a fresh review round, and keeps #54 blocked longer.C. Keep periodic but decouple and lengthen it (e.g. hourly instead of reusing the 5-minute eviction interval). Small diff, cuts the recurring cost ~12x, but keeps the walk and keeps papering over the best-effort insert.
Recommendation: A. The periodic pass is a real inefficiency but not a correctness bug, and #55 is the P0 that also gates #54. I would merge it now and take option B's change as its own commit-sized unit under the 1.0.0 milestone — "make variant accounting authoritative, return reconciliation to startup-only" — where it gets a focused review instead of being bolted onto a concurrency-sensitive PR that already passed.
If you would rather have B before this merges, say so and I will dispatch the rework immediately. Leaving this
merge-readyand assigned to you in the meantime.clawbot referenced this pull request2026-08-09 16:10:39 +02:00