Repository navigation
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
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
We hit a real production OOM backfilling
liquid_universe_pricesfor 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(unlikeib_shortstock) isn'tTemplate.YEAR-partitioned, soSession._read_objectre-reads and re-filters the entire on-disk export viaformats.read_frameon everyget()call, no matter how narrowstart/endare. 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-
AsyncSessionLRU cache of_read_object's own parsed-and-filtered results (ClientConfig.frame_cache_max_entries/RWPYTOOLS_FRAME_CACHE_MAX_ENTRIES, default 8,0disables). 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 pairBulkClient.fetch_onealready 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-partitioningliquid_universe_pricesitself, the same wayib_shortstockalready 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
test_session.py: cache hit (spies onformats.read_frame, asserts it's called once across two identicalget()calls, and that results match), correct discrimination by request shape (differentsymbolsstill reads fresh), andframe_cache_max_entries=0fully disabling the cache.232 passed(229 existing + 3 new).ruff check,ruff format --check,mypyall clean on the changed files. (Two pre-existing lint findings elsewhere in the repo, unrelated to this change, confirmed present onmainbefore this branch — left untouched.)Configurationtable row and a shortCachesection note distinguishing this from the existing on-diskFilesystemCache.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.YEARforliquid_universe_pricesis already on your radar, in which case this PR might not be worth carrying long-term.