Skip to content

Cache parsed-and-filtered get() results per session (mitigates OOM on repeated non-templated bulk reads) - #1

Open
brettelliot wants to merge 1 commit into
RWLab:mainfrom
brettelliot:frame-cache-for-nontemplated-bulk
Open

brettelliot wants to merge 1 commit into
RWLab:mainfrom
brettelliot:frame-cache-for-nontemplated-bulk

Conversation

@brettelliot

Copy link
Copy Markdown

Context

We hit a real production OOM backfilling liquid_universe_prices for RW's own full ~2,136-ticker equities universe (2015–present) — a Celery worker died at both a 2GB and then a 4GB memory limit.

Root cause: liquid_universe_prices (unlike ib_shortstock) isn't Template.YEAR-partitioned, so Session._read_object re-reads and re-filters the entire on-disk export via formats.read_frame on every get() call, no matter how narrow start/end are. Our own workaround was to slice our own ingestion loop by calendar year to bound our memory — but that just multiplied the number of full-export reads within one long-running process (~36 of them), and since CPython/pandas don't reliably return freed memory to the OS between them, RSS ratcheted upward across the run rather than staying flat, eventually crossing any fixed ceiling we gave it.

We flagged this in Discord and were invited to open a PR, so here it is.

What this PR does

Adds a small per-AsyncSession LRU cache of _read_object's own parsed-and-filtered results (ClientConfig.frame_cache_max_entries / RWPYTOOLS_FRAME_CACHE_MAX_ENTRIES, default 8, 0 disables). A caller that repeats an identical request — retrying after a partial failure, or iterating windows itself to bound its own memory, both very real patterns against a large non-templated bulk dataset — gets the identical result back instead of re-reading and re-filtering the export from disk.

Keyed by the export's own on-disk identity (path, size — the same pair BulkClient.fetch_one already re-validates a cache entry against) plus the request shape (date_column, start, end, symbol_column, symbols), so a refreshed export or a genuinely different request always misses. Returns a shallow copy on a hit so no caller can mutate the shared cached object.

What this does not fix, to be upfront about scope: a first-time read of a large non-templated export, or two calls for genuinely different windows within one sweep (exactly our own year-by-year loop's shape), still each pay the full-export cost once. The fix that would actually help every caller and every window — not just exact repeats — is Template.YEAR-partitioning liquid_universe_prices itself, the same way ib_shortstock already is. That's a data-publishing decision on your side, not something a client-side PR can do, so it's a separate ask, not part of this PR.

Testing

  • 3 new tests in test_session.py: cache hit (spies on formats.read_frame, asserts it's called once across two identical get() calls, and that results match), correct discrimination by request shape (different symbols still reads fresh), and frame_cache_max_entries=0 fully disabling the cache.
  • Full existing suite: 232 passed (229 existing + 3 new).
  • ruff check, ruff format --check, mypy all clean on the changed files. (Two pre-existing lint findings elsewhere in the repo, unrelated to this change, confirmed present on main before this branch — left untouched.)
  • Also added a README Configuration table row and a short Cache section note distinguishing this from the existing on-disk FilesystemCache.

Happy to adjust the default LRU size, the cache-key shape, or split this into a smaller PR if that's easier to review — and equally happy to hear if Template.YEAR for liquid_universe_prices is already on your radar, in which case this PR might not be worth carrying long-term.

For a bulk dataset without Template.YEAR (a single export spanning its
whole date range, e.g. liquid_universe_prices), Session._read_object
re-reads and re-filters the *entire* on-disk export via
formats.read_frame on every single get() call, regardless of how narrow
start/end are -- there's no server-side or file-level way to narrow
what's read for a non-templated export.

A caller that repeats an identical request -- retrying an interrupted
job after a partial failure, or iterating windows itself specifically
to bound its own memory (a common pattern for a large equities
universe) -- pays that full-export re-read/re-filter cost again on
every repeat, in the same process, for a result that hasn't changed.
Observed in production: a ~2,136-ticker, 2015-present backfill against
liquid_universe_prices OOM'd a worker process at both 2GB and 4GB,
because dozens of sequential full-export reads across one long-running
process ratchet up RSS (CPython/pandas don't reliably hand freed
memory back to the OS between them) faster than any fixed ceiling.

Adds a small per-AsyncSession LRU (ClientConfig.frame_cache_max_entries
/ RWPYTOOLS_FRAME_CACHE_MAX_ENTRIES, default 8, 0 disables) that returns
an identical prior _read_object result instead of re-reading. Keyed by
the export's own on-disk identity (path, size -- the same pair
BulkClient.fetch_one already re-validates a cache entry against) plus
the request shape (date_column, start, end, symbol_column, symbols), so
a refreshed export or a genuinely different request always misses.
Returns a shallow copy on a hit so no caller can mutate the cached
object in place.

This doesn't help a first-time read of a large non-templated export,
or two calls for genuinely different windows within one sweep (e.g. a
caller doing exactly what liquid_universe_prices's own missing
Template.YEAR partitioning would otherwise avoid) -- only exact repeats
within one process. Template.YEAR partitioning of that dataset (already
used for ib_shortstock) remains the fix that would help every caller
and every window, not just repeats; this is a client-side mitigation
for the case that's actionable from here.

Tests: three new cases in test_session.py covering the cache hit
(read_frame called once across two identical calls, matching results),
correct discrimination by request shape (different symbols still
reads fresh), and frame_cache_max_entries=0 fully disabling it. Full
suite (232 tests), ruff, ruff format, and mypy all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019jZsSRwcRCyC9T8EMcPPUx

This branch has not been deployed

No deployments
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