Skip to content

Commit bdba1ee

Browse files
Korijnclaude
andauthored
Manage proxy_db lifetimes with reference counting instead of a gc hook (#177)
The proxy_db kept a strong reference to every wrapped target, keyed on id(target), and relied on a gc.callbacks hook (plus sys.getrefcount inspection in Proxy.__del__) to figure out when entries could be removed. Entries for targets that were still referenced elsewhere were only released on a full gen-2 collection, and not at all when the garbage collector was disabled. Invert the ownership so that plain reference counting does all cleanup: - The per-target entry is now a TargetDep: the Dep for the container itself, which also owns the target (keeping id-keying safe against id reuse), the per-key deps and the proxies that wrap it. - Every Proxy holds a strong reference to its TargetDep (__dep__), giving traps direct access to their deps without a db lookup. - Keydeps are KeyDep instances that hold their owning TargetDep, and watchers already hold the deps they depend on, so an entry stays alive for exactly as long as a proxy or a subscribed watcher needs its dep identity. - The registry only holds weakrefs and entries remove themselves through weakref callbacks. No gc hook, no refcount inspection, no Proxy.__del__, and no reference cycles: cleanup is deterministic and works with gc disabled. The hot paths also get faster, since traps reach their deps through a slot instead of a global-db dict lookup, and creating an entry no longer constructs WeakValueDictionaries. Claude-Session: https://claude.ai/code/session_01T4cFbKSCxjGabtJbB9e5H2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent aec3a98 commit bdba1ee

11 files changed

Lines changed: 293 additions & 246 deletions

File tree

bench/test_creation.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
"""
22
Benchmarks for the cost of creating proxies and watchers.
33
4-
Creating a proxy for a dict registers the target in the proxy_db and
5-
eagerly creates a Dep for every key, so proxy creation cost scales
6-
with the number of keys.
4+
Creating a proxy registers the target in the proxy_db; deps for the
5+
keys of a dict are created lazily when they are read or written.
76
"""
87

98
import gc
@@ -26,9 +25,8 @@ def noop():
2625
def test_proxy_creation_dict(benchmark, size):
2726
def setup():
2827
# Collect garbage in between rounds (outside of the measured
29-
# code) so that proxy_db entries for the previous rounds are
30-
# cleaned up deterministically instead of adding gc noise to
31-
# the measurement
28+
# code) so that garbage from the previous rounds doesn't add
29+
# gc noise to the measurement
3230
gc.collect()
3331
return ({f"key_{i}": i for i in range(size)},), {}
3432

docs/guide/gotchas.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
## Keep no references to raw data
44

5-
Observ keeps references to the object passed to `reactive()` in order to keep track of dependencies and proxies for that object. When the object that is passed into `reactive()` is not managed by other code, observ cleans up its references automatically when the proxy is destroyed. However, if there is another reference to the original object, observ will only release its own reference when the garbage collector runs and all other references to the object are gone.
5+
Observ keeps a reference to the object passed to `reactive()` in order to keep track of dependencies and proxies for that object. That reference is released automatically — through regular reference counting, without relying on the garbage collector — as soon as the last proxy for the object is destroyed and no watcher depends on it anymore.
66

7-
For this reason, the **best practice** is to keep **no references** to the raw data, and instead work with the reactive proxies **only**:
7+
Even so, the **best practice** is to keep **no references** to the raw data, and instead work with the reactive proxies **only**: mutations on the raw data itself bypass the proxies and are therefore not observable.
88

99
```python
1010
# Good: no reference to the raw dict survives

observ/dict_proxy.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from .proxy import TYPE_LOOKUP, Proxy
2-
from .proxy_db import proxy_db
32
from .traps import construct_methods_traps_dict, trap_map, trap_map_readonly
43

54
dict_traps = {
@@ -54,7 +53,10 @@ class DictProxyBase(Proxy[dict]):
5453
__slots__ = ()
5554

5655
def _orphaned_keydeps(self):
57-
return set(proxy_db.attrs(self)["keydep"].keys()) - set(self.__target__.keys())
56+
keydeps = self.__dep__.keydeps
57+
if keydeps is None:
58+
return set()
59+
return set(keydeps.keys()) - set(self.__target__.keys())
5860

5961

6062
def readonly_dict_proxy_init(self, target, shallow=False, **kwargs):

observ/proxy.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@ class Proxy(Generic[T]):
1313
"""
1414
Proxy for an object/target.
1515
16-
Instantiating a Proxy will add a reference to the global proxy_db and
17-
destroying a Proxy will remove that reference.
16+
All proxies that wrap the same target share a single TargetDep
17+
(stored as `__dep__`), which holds the reactive state for the
18+
target. The proxy's strong reference to it keeps that state (and
19+
the registry entry for the target) alive; once the last proxy and
20+
the last subscribed watcher are gone, it is cleaned up through
21+
regular reference counting.
1822
1923
Please use the `proxy` method to get a proxy for a certain object instead
2024
of directly creating one yourself. The `proxy` method will either create
@@ -24,23 +28,22 @@ class Proxy(Generic[T]):
2428
__hash__ = None
2529
# the slots have to be very unique since we also proxy objects
2630
# which may define the attributes with the same names
27-
__slots__ = ("__readonly__", "__shallow__", "__target__", "__weakref__")
31+
__slots__ = ("__dep__", "__readonly__", "__shallow__", "__target__", "__weakref__")
2832

2933
def __init__(self, target: T, readonly=False, shallow=False):
3034
self.__target__ = target
3135
self.__readonly__ = readonly
3236
self.__shallow__ = shallow
33-
proxy_db.reference(self)
37+
dep = proxy_db.target_dep(target)
38+
dep.register_proxy((readonly, shallow), self)
39+
self.__dep__ = dep
3440

3541
def __copy__(self) -> T:
3642
return copy(self.__target__)
3743

3844
def __deepcopy__(self, memo: dict) -> T:
3945
return deepcopy(self.__target__, memo)
4046

41-
def __del__(self):
42-
proxy_db.dereference(self)
43-
4447

4548
# Lookup dict for mapping a type (dict, list, set) to a tuple
4649
# of proxy types (writable, readonly) for that type. Keyed on the

observ/proxy_db.py

Lines changed: 146 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,172 @@
1-
import gc
2-
import sys
3-
from weakref import WeakValueDictionary
1+
"""
2+
Registry of the reactive state that observ keeps for each wrapped
3+
target object.
4+
5+
For every target that is wrapped by one or more proxies there is a
6+
single TargetDep: the Dep for the container as a whole, which also
7+
owns everything else observ needs to know about the target (the
8+
per-key deps and the proxies that wrap it).
9+
10+
Lifetimes are managed purely by reference counting:
11+
12+
- Every Proxy holds a strong reference to the TargetDep of its
13+
target (Proxy.__dep__).
14+
- Every Watcher that depends on a target holds a strong reference
15+
to its TargetDep, or to one of its KeyDeps which in turn hold
16+
their TargetDep, for as long as the dependency lasts.
17+
- The registry itself only holds weak references, so as soon as the
18+
last proxy and the last interested watcher are gone, the TargetDep
19+
(and with it observ's reference to the target) is destroyed and
20+
its entry is removed from the registry by a weakref callback.
21+
22+
The registry is keyed on id(target), because the plain containers
23+
(dict, list, set) do not support weak references and are not
24+
(reliably) hashable. This is safe against id reuse, because a
25+
TargetDep keeps its target alive: an id can only be recycled after
26+
the TargetDep for its previous target has already been destroyed
27+
and has removed itself from the registry.
28+
"""
29+
30+
from weakref import WeakValueDictionary, ref
431

532
from .dep import Dep
633

734

35+
class TargetDep(Dep):
36+
"""
37+
The Dep for a target container as a whole. There is at most one
38+
TargetDep per wrapped target, shared by all proxies that wrap it,
39+
so that watchers and mutations always meet on the same Dep no
40+
matter which proxy they go through.
41+
"""
42+
43+
__slots__ = ("keydeps", "proxies", "target")
44+
45+
def __init__(self, target):
46+
super().__init__()
47+
self.target = target
48+
# Per-key deps (dict targets only). Starts out as None and is
49+
# only materialized (as a WeakValueDictionary) when a key is
50+
# read with dependency tracking active, since constructing a
51+
# WeakValueDictionary is relatively expensive. The values are
52+
# kept alive by the watchers that depend on them: a keydep
53+
# without subscribers has nobody to notify, so it can be
54+
# recreated freely whenever the key is read again
55+
self.keydeps = None
56+
# Weakrefs to the proxies that wrap the target,
57+
# keyed on (readonly, shallow)
58+
self.proxies = {}
59+
60+
def keydep(self, key):
61+
"""
62+
Returns the dep for the given key, creating it if needed.
63+
Note that the keydeps mapping is weak: the returned dep stays
64+
registered only for as long as the caller (or a subscribed
65+
watcher) holds a reference to it.
66+
"""
67+
keydeps = self.keydeps
68+
if keydeps is None:
69+
keydeps = self.keydeps = WeakValueDictionary()
70+
else:
71+
keydep = keydeps.get(key)
72+
if keydep is not None:
73+
return keydep
74+
keydep = KeyDep(self)
75+
keydeps[key] = keydep
76+
return keydep
77+
78+
def register_proxy(self, config, proxy):
79+
"""
80+
Registers the proxy as the proxy that wraps the target with
81+
the given (readonly, shallow) configuration. There can be only
82+
one proxy per configuration.
83+
"""
84+
proxies = self.proxies
85+
existing = proxies.get(config)
86+
if existing is not None and existing() is not None:
87+
raise RuntimeError("Proxy with existing configuration already in db")
88+
89+
def remove(weak_proxy, proxies=proxies, config=config):
90+
if proxies.get(config) is weak_proxy:
91+
del proxies[config]
92+
93+
proxies[config] = ref(proxy, remove)
94+
95+
def get_proxy(self, config):
96+
"""
97+
Returns the proxy that wraps the target with the given
98+
(readonly, shallow) configuration, or None if there is none.
99+
"""
100+
weak_proxy = self.proxies.get(config)
101+
if weak_proxy is None:
102+
return None
103+
return weak_proxy()
104+
105+
106+
class KeyDep(Dep):
107+
"""
108+
The Dep for a single key of a target. It holds a strong reference
109+
to the TargetDep that owns it, so that a watcher that depends on
110+
just a key still keeps the target's registry entry (and thereby
111+
the identity of its deps) alive.
112+
"""
113+
114+
__slots__ = ("owner",)
115+
116+
def __init__(self, owner):
117+
super().__init__()
118+
self.owner = owner
119+
120+
8121
class ProxyDb:
9122
"""
10-
Collection of proxies, tracked by the id of the object that they wrap.
11-
Each time a Proxy is instantiated, it will register itself for the
12-
wrapped object. And when a Proxy is deleted, then it will unregister.
13-
When the last proxy that wraps an object is removed, it is uncertain
14-
what happens to the wrapped object, so in that case the object id is
15-
removed from the collection.
123+
Weak registry of TargetDeps, keyed on the id of the target object
124+
that they describe. Entries remove themselves when their TargetDep
125+
is destroyed.
16126
"""
17127

18128
__slots__ = ("db",)
19129

20130
def __init__(self):
131+
# id(target) -> weakref to the TargetDep for that target
21132
self.db = {}
22-
gc.callbacks.append(self.cleanup)
23133

24-
def cleanup(self, phase, info):
134+
def target_dep(self, target):
25135
"""
26-
Callback for garbage collector to cleanup the db for targets
27-
that have no other references outside of the db
136+
Returns the TargetDep for the given target, creating it (and
137+
registering it) if there is none yet.
28138
"""
29-
if phase != "stop":
30-
# Ref counts are only stable after collection
31-
return
32-
33-
if info["generation"] != 2:
34-
# Only cleanup on full collection. Python GC runs in C mostly,
35-
# and this callback runs in Python land, so we want to minimize
36-
# the overhead as much as possible. The full collection happens
37-
# rarely compared to the other generations:
38-
# - gen 0 triggers constantly
39-
# - gen 1 is less frequent but still too often
40-
# - gen 2 is the full collection which happens rarely
41-
return
42-
43-
if not (db := self.db):
44-
# Early exit: Nothing to cleanup
45-
# This happens when observ is imported but not used
46-
# For example via transient dependencies or unused codepaths
47-
# This if statement avoids creating an iterator just to find out
48-
# it is empty
49-
return
50-
51-
getrefcount = sys.getrefcount # Local lookup is faster
52-
53-
# First collect keys to delete so we don't have to modify db while iterating
54-
# Use a list comprehension so we don't have to call .append in a loop
55-
keys_to_delete = [
56-
key
57-
for key, value in db.items()
58-
# Refs:
59-
# - sys.getrefcount
60-
# - ref in db item
61-
# If 2: We are the last to hold a reference!
62-
if getrefcount(value["target"]) <= 2
63-
]
64-
65-
# Only enter this for loop if there is something to delete, this avoids
66-
# creating an iterator only to find out there is nothing to delete.
67-
if keys_to_delete:
68-
for key in keys_to_delete:
69-
del db[key]
70-
71-
def reference(self, proxy):
72-
"""
73-
Adds a reference to the collection for the wrapped object's id
74-
"""
75-
target = proxy.__target__
139+
db = self.db
76140
obj_id = id(target)
141+
weak_dep = db.get(obj_id)
142+
if weak_dep is not None:
143+
dep = weak_dep()
144+
if dep is not None:
145+
return dep
77146

78-
entry = self.db.get(obj_id)
79-
if entry is None:
80-
attrs = {}
81-
if isinstance(target, dict):
82-
attrs["dep"] = Dep()
83-
# keydeps are created lazily: when a key is read
84-
# (with dependency tracking active) or written
85-
attrs["keydep"] = {}
86-
elif isinstance(target, (list, set)):
87-
attrs["dep"] = Dep()
88-
entry = {
89-
"target": target,
90-
"attrs": attrs, # dep, keydep
91-
# keyed on tuple(readonly, shallow)
92-
"proxies": WeakValueDictionary(),
93-
}
94-
self.db[obj_id] = entry
95-
96-
# Use setdefault to put the proxy in the proxies dict. If there
97-
# was an existing value, it will return that instead. There shouldn't
98-
# be an existing value, so we can compare the objects to see if we
99-
# should raise an exception.
100-
# Seems to be a tiny bit faster than checking beforehand if
101-
# there is already an existing value in the proxies dict
102-
result = entry["proxies"].setdefault(
103-
(proxy.__readonly__, proxy.__shallow__), proxy
104-
)
105-
if result is not proxy:
106-
raise RuntimeError("Proxy with existing configuration already in db")
147+
dep = TargetDep(target)
107148

108-
def dereference(self, proxy):
109-
"""
110-
Removes a reference from the database for the given proxy
111-
"""
112-
obj_id = id(proxy.__target__)
113-
entry = self.db.get(obj_id)
114-
if entry is None:
115-
# When there are failing tests, it might happen that proxies
116-
# are garbage collected at a point where the proxy_db is already
117-
# cleared. That's why we need this check here.
118-
# See fixture [clear_proxy_db](/tests/conftest.py:clear_proxy_db)
119-
# for more info.
120-
return
121-
122-
# The given proxy is the last proxy in the WeakValueDictionary,
123-
# so now is a good moment to see if can remove clean the deps
124-
# for the target object
125-
if len(entry["proxies"]) == 1:
126-
ref_count = sys.getrefcount(entry["target"])
127-
# Ref count is still 3 here because of the reference
128-
# through proxy.__target__
129-
if ref_count <= 3:
130-
# We are the last to hold a reference!
131-
del self.db[obj_id]
132-
133-
def attrs(self, proxy):
134-
return self.db[id(proxy.__target__)]["attrs"]
149+
def remove(weak_dep, db=db, obj_id=obj_id):
150+
# Guard against the entry having been replaced in the
151+
# meantime, so a stale callback can't remove a live entry
152+
if db.get(obj_id) is weak_dep:
153+
del db[obj_id]
154+
155+
db[obj_id] = ref(dep, remove)
156+
return dep
135157

136158
def get_proxy(self, target, readonly=False, shallow=False):
137159
"""
138-
Returns a proxy from the collection for the given object and configuration.
139-
Will return None if there is no proxy for the object's id.
160+
Returns the proxy with the given configuration for the given
161+
target. Will return None if there is no such proxy.
140162
"""
141-
try:
142-
return self.db[id(target)]["proxies"].get((readonly, shallow))
143-
except KeyError:
163+
weak_dep = self.db.get(id(target))
164+
if weak_dep is None:
165+
return None
166+
dep = weak_dep()
167+
if dep is None:
144168
return None
169+
return dep.get_proxy((readonly, shallow))
145170

146171

147172
# Create a global proxy collection

0 commit comments

Comments
 (0)