Skip to content

fix(cache): clamp the background drain budget to the cache limit#443

Draft
sonhmai wants to merge 2 commits into
mainfrom
fix/drain-budget-clamp
Draft

fix(cache): clamp the background drain budget to the cache limit#443
sonhmai wants to merge 2 commits into
mainfrom
fix/drain-budget-clamp

Conversation

@sonhmai

@sonhmai sonhmai commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

max_drain_bytes defaults to None, which the background drain treated as unbounded. A partially consumed streamed read (head -c 100 of a multi-GB S3 object) spawned _background_drain, which kept downloading and buffering the entire remainder in process memory. The oversized add then evicted every other entry and finally the new entry itself, so the full download bought nothing and wiped the cache.

Draining more than cache_limit bytes can never produce a cache hit: eviction runs right after the add and an entry larger than the limit is always dropped. Unbounded draining is therefore pure waste and there is no reason to keep it as an option.

Change

  • FileCacheMixin.drain_budget (TS drainBudget(cache)) resolves the effective budget: min(max_drain_bytes or cache_limit, cache_limit).
  • _background_drain / backgroundDrain always use drain_bounded; the unbounded drain() branch is gone. Exceeding the budget logs and skips the fill, and the next read fetches fresh from the resource.
  • Config surface is unchanged. None now means "default to the cache limit" instead of "unbounded"; values above the limit are clamped.
  • Snapshot state still round-trips the raw configured max_drain_bytes, so old snapshots with None load with the new bounded semantics.

Tests

  • Python: drain_budget default/keep/clamp cases; default budget stops a stream larger than cache_limit and still caches smaller ones; clamp applies when max_drain_bytes exceeds the limit.
  • TypeScript: same three drainBudget cases plus the mirrored drain behavior tests.
  • Ran targeted pytest (cache/file, workspace snapshot) and vitest (packages/core/src/cache/file), prettier and eslint on the touched files, and the Python pre-commit hooks.

max_drain_bytes=None previously meant an unbounded it.drain(): a
partially consumed stream (head of a multi-GB object) kept downloading
and buffering the whole remainder in process memory, then the oversized
add evicted every other entry and finally itself, wiping the cache.

Draining past cache_limit can never produce a cache hit, so the budget
now resolves via FileCacheMixin.drain_budget (TS drainBudget):
min(max_drain_bytes or cache_limit, cache_limit). None keeps meaning
"default", now bounded; the unbounded path is gone. Snapshot state
still round-trips the raw configured value.
@sonhmai sonhmai requested a review from zechengz as a code owner July 7, 2026 08:20
@sonhmai sonhmai marked this pull request as draft July 7, 2026 08:21
Review findings on the drain clamp: drain_bounded returned early
without closing the underlying iterator, so streaming backends kept
their HTTP response open until GC, and this path is now the default.
drain_bounded (TS drainBounded) closes the source on budget exceed;
CachableAsyncIterator grows an aclose() delegating to the source.

Also drop the console.info on the TS skip path: budget exceed is a
normal event now and core has no logger, so it polluted stdout. The
Python side keeps logger.info, which is silent unless configured.
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