Manage proxy_db lifetimes with reference counting instead of a gc hook - #177
Conversation
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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4cFbKSCxjGabtJbB9e5H2
d0eda4f to
8363fbb
Compare
Heads-up: benchmark gate regression on
|
|
I propose to merge and let this |
|
I agree! 💯 |
First stable release. Since v0.19.0, observ has had a broad performance overhaul, gained full type-hint coverage and a new trigger_ref API, and picked up a documentation site. New features - trigger_ref(): force-notify the watchers of a proxy, mirroring Vue's triggerRef (#188, closes #123). - Fully typed: modern type hints throughout, a py.typed marker, and ty type-checking enforced in CI (#185, #184, closes #114). Performance - Reworked deep-watch traversal: plain leaf values are filtered out of the traverse stack at push time and raw targets are traversed directly. Traversing large flat/shallow structures is up to ~85% faster (#190). - Lower per-operation trap overhead and proxies constructed with positional flags: reactive reads/writes and proxy creation are ~35-50% faster (#192, #193). - proxy_db lifetimes are managed by reference counting instead of a gc hook, eliminating reference cycles and making cleanup deterministic (#177). - Scheduler and dependency-bookkeeping micro-optimizations: in-place bisect insertion (#191), single-read/write flush counting (#196), a set difference in cleanup_deps (#194), and reading the arg count off the code object in weak() (#195). Correctness - Guarantees, with tests, that observ creates no reference cycles (#189). Documentation - New MkDocs site published to GitHub Pages (#176), with an Internals section (#179) and a rewritten README (#180). Tooling - Modernized CI workflows (#182) and a more robust benchmark guard (#181, #187).
Motivation
observ's whole cleanup story hinged on a garbage-collector hook:
proxy_dbheld a strong reference to every wrapped target, keyed onid(target), and agc.callbackshook combined withsys.getrefcount()inspection (in the hook and inProxy.__del__) decided when entries could be dropped. Consequences:gc.disable()(not uncommon in latency-sensitive apps), those entries leaked forever.Design
Invert the ownership so that plain reference counting does all cleanup, and the registry never needs to be scanned:
TargetDep— theDepfor the container itself, which also owns the target, the per-key deps, and (weakly) the proxies that wrap it. Owning the target is what keepsid(target)keying safe: an id can only be recycled after itsTargetDepis gone and has removed itself from the registry.Proxyholds a strong reference to itsTargetDep(__dep__).KeyDepinstances that hold their owningTargetDep. Watchers already hold strong references to the deps they depend on, so an entry stays alive for exactly as long as a proxy exists or a subscribed watcher needs its dep identity — the transient-proxy notification path keeps working (covered by a new test).proxy_db.dbmapsid(target)→ weakref to theTargetDep; entries remove themselves via weakref callbacks (with an identity guard against stale callbacks).No gc hook, no refcount inspection, no
Proxy.__del__, and deliberately no reference cycles amongTargetDep/KeyDep/proxies — cleanup is deterministic and immediate, and works with the garbage collector fully disabled. Keydeps become weakly registered: a keydep without subscribers has nobody to notify, so it can be recreated freely on the next tracked read (this also removes the vestigial keydep creation in the dict write trap).Performance
Traps now reach their deps through a slot (
self.__dep__) instead of a global-dbid()dict lookup, entry creation no longer constructsWeakValueDictionarys (keydeps are materialized lazily, proxies are a plain dict of weakrefs), and the gen-2 gc callback overhead is gone entirely. Spot check (min of 5 timeit repeats, Python 3.11):reactive()creationBehavioral changes
proxy_db.attrs(proxy)is replaced by directproxy.__dep__/proxy.__dep__.keydepsaccess.clear_proxy_dbconftest fixture is no longer needed and was removed.gc.disable()leak zero registry entries, andgc.callbacksstays empty.Full test suite passes (including qt group, run offscreen), plus ruff check/format.
🤖 Generated with Claude Code
https://claude.ai/code/session_01T4cFbKSCxjGabtJbB9e5H2
Generated by Claude Code