Repository navigation
fix: bound the decoder adapter caches - #191
Merged
Merged
Conversation
Each decoder cache now holds at most 4096 models and is emptied when an insert would go past that, so models created per request no longer pile up for the life of the decoder.
Older msgspec builds typing.Tuple[...] while inspecting a model, and typing's subscription LRU keeps the recent models alive, so the weakref check failed on the dependency floors.
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.
What changed
PydanticDecoderandMsgspecDecodereach keep a dict from model to adapter (decoders/_caching.py). Until now that dict never evicted and held every model strongly, so code that builds a model per request grew it, and kept the classes alive, for the life of the decoder.Each cache now holds at most
_MAX_ENTRIES = 4096entries. When an insert would go past the cap, the cache is cleared first and then the new entry is stored. That covers the class/union key path and the_lookup_by_reprfallback for unhashable models: both now insert through one_storehelper. The cap is a module constant, not public config.Reproduction (before the fix)
10,000 models decoded once each through one decoder, each model in its own process run:
gc.collect()pydantic.create_modelmsgspec.defstructAfter the fix, the same run ends with 1,808 entries in each cache (10,000 minus two full clears of 4,096), and the first model is collected (weakref is dead). Peak RSS growth falls to 51.5 MB (pydantic) and 17.3 MB (msgspec). The peak still includes a full cache of 4,096 entries.
Why clear-on-overflow and why 4096
Clearing keeps the cache-hit path exactly as it was: a hit runs the same
_key+dict.getas before, and the cap check only runs on a miss. An LRU would need bookkeeping on every hit, and the lookup runs on every decoded request. AWeakKeyDictionarywon't work, because the cached adapter holds the model strongly.An entry is one distinct type expression per decoder, so
User,list[User]andUser | Nonetake three slots. Even a client for a large API with a few hundred response models, plus their list and optional forms, stays well under 4,096, so a real app should never hit the clear. Measured on trivial one-field models, an entry costs about 9 KB for pydantic and 2.3 KB for msgspec, so a full cache tops out around 37 MB and 10 MB for such models. Apps above the cap pay a periodic rebuild. Apps that make models per request now have bounded memory and get their adapters rebuilt after each clear.Thread safety
Unchanged: concurrent first calls may build twice and the last write wins.
dict.get,len,clearand item assignment are each atomic on a dict, including under free threading, so a lookup racing a clear sees either the old entry for its own key or a miss and rebuilds. It never raises and never returns another model's adapter. Under a race the cache can briefly go past the cap by about the number of threads inserting at once. A newstresstest drives 16 threads through a cache with a tiny cap and checks that every lookup returns its own model's entry.Benchmarks
The cache-hit bytecode of
_lookupis unchanged; only the miss branch moved into_store. Interleaved A/B in one process (old and new_cachingmodules side by side, best of 9 runs of 500k calls), on a machine with a load average around 40:_lookuphit, class model_lookuphit,User | int | NoneThe differences go both ways from run to run and are noise. A full mocked
Client.get(..., response_model=User)measured 178 us before and 172 us after in back-to-back runs on the same loaded machine, also noise.Tests
tests/test_decoders_caching.pypatches the cap to 8. It checks that cap+1create_modelmodels leave the pydantic cache bounded with the first model garbage collected, and that cap+1defstructmodels leave the msgspec cache bounded with the first model no longer in it. It also checks that the repr-keyed path is bounded, that a model decodes after a clear, and the threaded race above. The three bound tests failed before the fix.The msgspec test checks only our cache, not a weakref. msgspec 0.19 (a dependency floor) runs
MsgpackDecoder(typing.Tuple[self.types])insidemsgspec.inspect.type_info, and typing caches subscriptions in anlru_cacheof 128, so the last 128 inspected models stay alive outside httpware (found withgc.get_referrersin a floors venv). msgspec 0.22 uses builtintuple[...], which typing does not cache, and the weakref check passes there. That retention is capped at 128, so it is not a leak.Docs
docs/decoders.mdgets a short section saying response models should be long-lived, and that models created per request get rebuilt once the cache fills.Closes #174