Repository navigation
Preserve bound class in shared classmethod LRU caches - #365
parijat1222q wants to merge 1 commit into
Conversation
|
Thanks for the report and the detailed reproducer. The bug is real: the I don't want to take this fix as it stands though, because of the way it @wrapt.decorator
def audit(wrapped, instance, args, kwargs):
log.append((instance, args))
return wrapped(*args, **kwargs)
class Base:
@wrapt.lru_cache
@audit
@classmethod
def identify(cls, value):
return cls.__name__, value
class Child(Base):
passOn current I have a different approach in mind which keeps the single shared cache, |
|
Thanks for explaining the stacked-decorator case. Reaching through |
|
A follow up on where I've got to with this. I first tried a variant of your approach which kept the single shared class Base:
@wrapt.lru_cache
@classmethod
def method(cls, value):
return value
Child = type("Child", (Base,), {})
Child.method(1)
ref = weakref.ref(Child)
del Child
gc.collect()
ref() is not None # True, Child is pinned by Base's cacheWith What I'm currently testing instead treats class methods the same way as class Base:
@wrapt.lru_cache(maxsize=32)
@classmethod
def method(cls, value):
return cls, value
class Child(Base):
pass
Base.method(1) # (Base, 1)
Child.method(1) # (Child, 1)
Child.method(2) # (Child, 2)
Child().method(1) # (Child, 1), cache hit
Base.method.cache_info()
# CacheInfo(hits=0, misses=1, maxsize=32, currsize=1)
Child.method.cache_info()
# CacheInfo(hits=1, misses=2, maxsize=32, currsize=2)
Child.method.cache_clear() # clears only Child's cacheWhat this means in practice:
The lookup has to read the class's own The cost is that the documentation currently promises a single shared |
|
I tried a small weak-class-key prototype with a shared cache. It preserves the shared budget and calls through the decorator chain, but only solves the direct key reference: a cached result such as |
|
A possible middle ground is class-owned entries with a shared, weakly held LRU order. Each class keeps its strong keys/results, while one coordinator manages the global maxsize, statistics and clearing. It must use each class's own This runnable example shows the ownership part only, not a complete cache: import gc
import weakref
from collections import OrderedDict
class Entry:
def __init__(self, value):
self.value = value
lru = OrderedDict() # order only; entries are weakly held
class Base:
pass
Child = type('Child', (Base,), {})
entry = Entry((Child, 1))
Child._entries = {1: entry}
key = object()
lru[key] = weakref.ref(entry, lambda ref: lru.pop(key, None))
ref = weakref.ref(Child)
del entry, Child
gc.collect()
assert ref() is None
assert not lruIn the fuller prototype, hits move an entry's token to the end of the shared order; eviction removes the oldest entry from its owner's map. The coordinator holds only weak entry references, and entries hold weak owner references. Misses call through the full bound wrapper, not Focused tests preserve one shared budget, result identity and the decorator chain, while allowing otherwise-unreachable subclasses to collect even when their own results refer back to them. Another live class caching Child, or a caller retaining a class-containing result, still legitimately retains it. The cost is a custom LRU and extra bookkeeping. This is a proof of concept, not a production-ready patch: full wrapt integration, reentrant keys and broader concurrency testing remain open. The simpler per-class design may still be the better tradeoff. |
|
Having thought about this more, I've decided the per class cache is the Consider what Python already does when a class method stores anything class A:
@classmethod
def f(cls, v):
cls.k = v
class B(A):
pass
A.f(1)
B.f(2)
print(vars(A)["k"]) # 1
print(vars(B)["k"]) # 2The value written by The same conclusion comes from treating a class method as what it is: It also lines up with correctness. If The visible consequence is that |
|
Would you like me to update this PR to the per-class cache design, or are you already handling the implementation? |
|
I already have it implemented. Had just been thinking it through, plus had to go out and do some running around. |
|
Is on develop now so you can see how that works for you. |
|
Thanks, I tested the develop change locally on Python 3.14. The lru_cache tests pass with and without the C extension, including the inherited-class regression. Thanks for keeping the test and crediting the report. |
Fixes #364
With
@wrapt.lru_cacheabove@classmethod, the shared cache currently wraps the first bound method. Later calls through a subclass therefore receive the first caller's class, even for arguments not already cached.Cache the unbound function and pass the bound class as part of the cache key. This keeps the documented single shared cache and maxsize budget, while preserving
clsfor each caller. Add a regression covering both initial call orders, inherited instance access, fresh arguments, shared statistics and cross-class cache clearing, plus a change note. This change is limited to the documented decorator order.Validation:
Justfiletest-versionrecipes: Python 3.9-3.15 plus 3.14t/3.15t, each with extensions absent, enabled and disabled at runtime. Python 3.14's three runs report 1417 passed / 2 skipped, 1419 passed, and 1417 passed / 2 skipped.just test-mypypassed for all seven target versions with the pinned mypy versions. Black on both changed Python files andgit diff --checkpassed.These are local results. Hosted CI and maintainer review are not claimed.