Skip to content

fix(passthrough): bound the response cache by total bytes - #8

Merged
unseensnick merged 2 commits into
mainfrom
loop/7-bound-passthrough-cache-bytes
Aug 19, 2026
Merged

fix(passthrough): bound the response cache by total bytes#8
unseensnick merged 2 commits into
mainfrom
loop/7-bound-passthrough-cache-bytes

Conversation

@unseensnick

Copy link
Copy Markdown
Owner

What changed and why

The passthrough response cache expired entries on age but never limited how much it held. A client working through many pages inside one TTL window could pin every body it fetched at the same time, and non-HTML documents are kept as decoded bytes, so a few large ones cost far more than pages do.

It now holds at most PASSTHROUGH_CACHE_MAX_BYTES (256 MB by default), evicting the entries closest to expiry first. A body over a quarter of the ceiling is served but never cached, since admitting it would evict most of the cache for one document.

Storing moved out of the request handler into _cache_store so the byte accounting has one owner and can be tested without a browser. That is what makes gate A able to fail here at all: there were no passthrough tests before this.

Provenance

Filed by /audit-scan on the resources dimension. The finding survived a refutation attempt: the comment at passthrough.py:216-218 shows growth was already reasoned about there, but what it fixes is different. Without that prune an entry was skipped once expired and never removed, pinning bodies for the process lifetime. That is fixed. What was left unbounded is how much can accumulate inside one TTL window.

Scope covered

Every site the issue listed, re-checked with grep -rn '_cache\b' src/ --include=*.py:

  • src/passthrough.py:48_cache declared with no bound; now paired with a _cache_bytes running total
  • src/passthrough.py:213-221 — the unconditional insert; now _cache_store with eviction
  • src/passthrough.py:112 — the PDF path that produces decoded bytes; covered by measuring bytes rather than entries
  • src/config.py:211passthrough_cache_ttl(); passthrough_cache_max_bytes() added beside it

One site the issue did not list, found while implementing: the completion log said , cached based on whether the body was eligible, which the byte cap can now falsify. It reports what actually happened.

Gate A, browser-free suite

151 tests, OK. 138 before, plus 13 new covering eviction order, the per-body ceiling at its exact boundary (250 stored, 251 refused against a 1000 cap), the running total across replaces and expiries, and the zero-cap escape hatch.

Gate B, live solve tally against a same-window baseline

Two containers built and run interleaved, trial for trial: the change on 8291 and origin/main on 8391.

Arm Engine Trials Solved Times Mean
change chrome 3 3/3 13.4, 11.5, 11.5 12.1s
baseline chrome 3 3/3 11.8, 11.6, 11.5 11.6s
change stealth 2 2/2 5.9, 6.1 6.0s
baseline stealth 2 2/2 6.1, 5.0 5.6s

Verdict: within noise, pass. The 0.5s gap sits inside the baseline's own spread (5.0 to 6.1 on stealth). Nothing in this change touches the solve path, so gate B is a no-regression check rather than a proof of the feature.

Gate C, the passthrough end to end

Run against a container with the passthrough enabled and a deliberately small cap, so eviction is reachable in a short run. Five distinct cache keys, each a real solve returning 8108 bytes, against a 33000-byte cap:

GET /?k=1 <- 200 in 11.3s (8108 bytes, cached)
GET /?k=2 <- 200 in 11.2s (8108 bytes, cached)
GET /?k=3 <- 200 in 11.2s (8108 bytes, cached)
GET /?k=4 <- 200 in 11.2s (8108 bytes, cached)
GET /?k=5 <- 200 in 11.2s (8108 bytes, cached)
GET /?k=1 <- 200 in 11.1s (8108 bytes, cached)   # re-solved: it was evicted
GET /?k=5 <- cache hit                            # newest survived

Five entries at 8108 exceed the cap at the fifth, the oldest was evicted, and the newest still served from cache. An earlier run at a 40000-byte cap also confirmed the per-body ceiling on real pages: bodies of 46 to 91 KB were served and refused by the cache, with only the 8 KB page admitted.

What was not covered

  • No Prowlarr instance was stood up. This change is confined to Solverr's own passthrough, which was driven directly with the traffic shape an indexer produces, including real solves, eviction, and cache hits. Worth flagging that /loop-work's gate C as written describes a byparr-proxy in front of /v1, which is a different code path and would not have exercised this change at all. The skill needs that distinction; tracked separately.
  • Concurrent eviction was not exercised. All eviction happens under _lock, and the tests are single-threaded.
  • The 256 MB default was not run to capacity. Every live cap here was small on purpose so eviction would fire inside a short run.

Review notes

Self-reviewed rather than run through /pr-review, because this session carries a standing instruction not to spawn subagents unasked. One issue found and fixed in ffa40fc: the new startup line divided the cap into whole megabytes, so any ceiling under 1 MB logged as max 0 MB, which reads as caching being off rather than tight.

One thing deliberately left: eviction sorts the key set on each store when over cap. At the default cap that is a few thousand keys, so roughly ten milliseconds against a solve that takes five to thirteen seconds. Not worth a heap or an ordered structure for 0.1% of the request.

Closes #7

The cache expired entries on age but never limited how much it held, so a
client working through many pages inside one TTL window could pin every
body it fetched at the same time. Non-HTML documents are kept as decoded
bytes, so a few large ones cost far more than pages do.

- Holds at most PASSTHROUGH_CACHE_MAX_BYTES (256 MB by default), evicting
  the entries closest to expiry first so eviction takes what was going to
  go anyway.
- A body over a quarter of the ceiling is served but never cached, since
  admitting it would evict most of the cache for one document.
- The completion log now reports whether the body was actually cached
  rather than whether it was eligible, which the byte cap can now change.
- Adds the first browser-free tests for the passthrough, covering
  eviction order, the per-body ceiling, and the running total staying
  correct across replaces and expiries.

Storing moved out of the request handler into _cache_store so the byte
accounting has one owner and can be tested without a browser.

Closes #7
The startup line divided the cap into whole megabytes, so any ceiling
under 1 MB logged as "max 0 MB", which reads as caching being off rather
than tight. Caught by a live run that set a deliberately small cap.

Small caps now report in bytes.
@unseensnick
unseensnick marked this pull request as ready for review August 19, 2026 07:12
@unseensnick
unseensnick merged commit 8834f1b into main Aug 19, 2026
1 check passed
@unseensnick
unseensnick deleted the loop/7-bound-passthrough-cache-bytes branch August 19, 2026 07:13
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.

audit(passthrough): the response cache bounds by age but not by size

1 participant