|
| 1 | +"""Central ledger of every process-lifetime GFQL cache. |
| 2 | +
|
| 3 | +Each cache SETUP SITE registers itself here, adjacent to its own definition, as |
| 4 | +either CLEARABLE (keyed by caller input; ``gfql_clear_caches`` empties it) or a |
| 5 | +PROCESS SINGLETON (a function of the code, exempt, with the written reason). |
| 6 | +
|
| 7 | +Why a registry instead of a central list of names: the bug that motivated this |
| 8 | +file was ``gfql_clear_caches`` looking a clear target up BY NAME and silently |
| 9 | +doing nothing when the memo actually lived on a different object |
| 10 | +(``parse_cypher`` vs ``_parse_cypher_cached``). A registration hands over the |
| 11 | +bound ``cache_clear``/``dict.clear`` of the real object at definition time, so |
| 12 | +there is no later lookup to get wrong. Classification and exemption reasons |
| 13 | +live next to the cache they describe, and the coverage lock in |
| 14 | +``graphistry/tests/compute/gfql/test_clear_caches_covers_every_cache.py`` |
| 15 | +statically discovers every cache in the tree and fails when one is not |
| 16 | +registered -- registration is enforced, not optional. |
| 17 | +
|
| 18 | +A module that is never imported registers nothing, and that is correct: its |
| 19 | +cache object does not exist, so there is nothing to clear. |
| 20 | +
|
| 21 | +Threading: registration runs at import time under the import lock plus this |
| 22 | +module's own lock; ``clear_all`` snapshots under the lock and clears outside it. |
| 23 | +``lru_cache.cache_clear`` is internally locked; dict-style memos must make their |
| 24 | +hit paths recompute-safe against a clear racing a read (see the single-alias |
| 25 | +memo). ``importlib.reload`` of a cache host raises the registered-twice error on |
| 26 | +purpose: the reloaded module would leave a zombie cache this registry can no |
| 27 | +longer reach, and that deserves a loud failure, not a silent overwrite. |
| 28 | +""" |
| 29 | + |
| 30 | +from __future__ import annotations |
| 31 | + |
| 32 | +import threading |
| 33 | +from typing import Callable, Dict, NamedTuple, Optional, Protocol |
| 34 | + |
| 35 | + |
| 36 | +class _SupportsCacheClear(Protocol): |
| 37 | + """An ``@lru_cache``-decorated function (mypy's wrapper stub carries no __name__, |
| 38 | + so the name is read with getattr at runtime, where functools always sets it).""" |
| 39 | + |
| 40 | + def cache_clear(self) -> None: ... |
| 41 | + |
| 42 | + |
| 43 | +class _SupportsClear(Protocol): |
| 44 | + """A dict-like memo: anything with an argument-free ``clear``.""" |
| 45 | + |
| 46 | + def clear(self) -> None: ... |
| 47 | + |
| 48 | + |
| 49 | +class CacheEntry(NamedTuple): |
| 50 | + name: str |
| 51 | + clear: Optional[Callable[[], None]] # None => exempt process singleton |
| 52 | + reason: Optional[str] # None => clearable |
| 53 | + |
| 54 | + |
| 55 | +_REGISTRY: Dict[str, CacheEntry] = {} |
| 56 | +_LOCK = threading.Lock() |
| 57 | + |
| 58 | + |
| 59 | +def _register(entry: CacheEntry) -> None: |
| 60 | + with _LOCK: |
| 61 | + existing = _REGISTRY.get(entry.name) |
| 62 | + if existing is not None and existing != entry: |
| 63 | + raise ValueError(f"cache {entry.name!r} registered twice with different handles") |
| 64 | + _REGISTRY[entry.name] = entry |
| 65 | + |
| 66 | + |
| 67 | +def register_clearable(fn: _SupportsCacheClear, name: Optional[str] = None) -> None: |
| 68 | + """Register an ``@lru_cache`` function whose memo gfql_clear_caches must empty.""" |
| 69 | + clear = getattr(fn, "cache_clear", None) # runtime check: typing cannot stop a bare fn |
| 70 | + if clear is None: |
| 71 | + raise TypeError(f"{fn!r} has no cache_clear; register the decorated function itself") |
| 72 | + resolved = name if name is not None else str(getattr(fn, "__name__", repr(fn))) |
| 73 | + _register(CacheEntry(resolved, clear, None)) |
| 74 | + |
| 75 | + |
| 76 | +def register_clearable_dict(name: str, mapping: _SupportsClear) -> None: |
| 77 | + """Register a hand-rolled dict/OrderedDict memo for clearing.""" |
| 78 | + _register(CacheEntry(name, mapping.clear, None)) |
| 79 | + |
| 80 | + |
| 81 | +def register_clearable_callable(name: str, clear: Callable[[], None]) -> None: |
| 82 | + """Register a clear callable for a cache that needs its own locking discipline.""" |
| 83 | + _register(CacheEntry(name, clear, None)) |
| 84 | + |
| 85 | + |
| 86 | +def register_process_singleton(fn: _SupportsCacheClear, reason: str) -> None: |
| 87 | + """Exempt a maxsize=1, function-of-the-code cache, with the reason in writing.""" |
| 88 | + fn_name = str(getattr(fn, "__name__", repr(fn))) |
| 89 | + if len(reason.split()) < 6: |
| 90 | + raise ValueError(f"{fn_name}: exemption reason is too thin to be a reason") |
| 91 | + _register(CacheEntry(fn_name, None, reason)) |
| 92 | + |
| 93 | + |
| 94 | +def clear_all() -> None: |
| 95 | + """Empty every registered clearable cache. Raises if nothing is registered.""" |
| 96 | + with _LOCK: |
| 97 | + entries = list(_REGISTRY.values()) |
| 98 | + if not any(entry.clear is not None for entry in entries): |
| 99 | + raise RuntimeError( |
| 100 | + "no clearable GFQL cache is registered; the registry import wiring is broken" |
| 101 | + ) |
| 102 | + for entry in entries: |
| 103 | + if entry.clear is not None: |
| 104 | + entry.clear() |
| 105 | + |
| 106 | + |
| 107 | +def entries() -> Dict[str, CacheEntry]: |
| 108 | + """Snapshot for the coverage lock test.""" |
| 109 | + with _LOCK: |
| 110 | + return dict(_REGISTRY) |
0 commit comments