diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 87a7833..a0aef59 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,8 @@ Unreleased X.Y.Z (DD-MM-YYYY) Added ----- - Freeze Multitons using their internal FrozenKey (:pr:`10`) +- Add ``Multiton.clear_cache`` to evict all cached instances, or only those + matching an instance type and/or a ``where(instance)`` predicate Changed ------- diff --git a/src/rarg_python_patterns/multiton/multiton.py b/src/rarg_python_patterns/multiton/multiton.py index 0e3458d..dfde948 100644 --- a/src/rarg_python_patterns/multiton/multiton.py +++ b/src/rarg_python_patterns/multiton/multiton.py @@ -7,6 +7,7 @@ import time import weakref from threading import RLock +from types import UnionType from typing import Any, Callable, ClassVar, Dict, Generic, List, Tuple, TypeVar from rarg_python_patterns.multiton.canonicalisation import ( @@ -49,11 +50,11 @@ class Multiton(Generic[T]): A ``ttl`` of ``math.inf`` (set via ``with_ttl(math.inf)`` or the ``with_infinite_ttl()`` shorthand) makes an entry eternal: it never - expires and is only removed by ``release()``. Eternal entries are never - pushed onto the heap (an ``inf`` deadline could never satisfy the sweep - condition anyway), so they cost nothing in heap space. The heap holds - only finite-TTL tuples and self-compacts to discard stale ones whenever - it grows much larger than the live cache. + expires and is only removed by ``release()`` or ``clear_cache()``. + Eternal entries are never pushed onto the heap (an ``inf`` deadline could + never satisfy the sweep condition anyway), so they cost nothing in heap + space. The heap holds only finite-TTL tuples and self-compacts to discard + stale ones whenever it grows much larger than the live cache. **Thread safety.** Cache hits acquire only a brief global lock and never block behind factory execution. Constructions are serialised per key: @@ -154,7 +155,8 @@ def with_ttl(self, ttl: float) -> Multiton[T]: Arguments: ttl: Time-to-live in seconds for the cached instance. ``math.inf`` - makes the entry eternal (never expires; only removed by ``release()``). + makes the entry eternal (never expires; only removed by ``release()`` + or ``clear_cache()``). See :meth:`with_args` for the TTL-reset and first-write semantics. """ return self.with_args(ttl=ttl) @@ -333,6 +335,56 @@ def release(self) -> None: with self._INSTANCE_LOCK: self._INSTANCE_CACHE.pop(self._key, None) + @classmethod + def clear_cache( + cls, + instance_type: type | tuple[type, ...] | UnionType | None = None, + *, + where: Callable[[Any], bool] | None = None, + ) -> int: + """Evict cached instances, optionally only those matching filters. + + An entry is evicted only if it matches every filter supplied. With no + filters the entire cache is cleared, including eternal (``math.inf`` + TTL) entries. + + Arguments: + instance_type: Match entries whose cached instance satisfies + ``isinstance(instance, instance_type)``: a type, a tuple of types or + a union such as ``int | str``. Subclass instances therefore match + too. Matching is on the object the factory returned, not on the + factory itself. + where: Match entries for which ``where(instance)`` is true, e.g. to + evict only instances tied to a particular resource. It is called + under the global cache lock, so it must be quick and must not + access any Multiton's ``instance``. + + Returns: + The number of entries evicted. + + Evicted keys are recreated on next access. Like ``release()``, this does + not wait for in-flight constructions: an entry being constructed when + ``clear_cache`` runs is published afterwards as usual. + """ + with cls._INSTANCE_LOCK: + if instance_type is None and where is None: + # Every heap entry is now stale, so drop them all at once + n = len(cls._INSTANCE_CACHE) + cls._INSTANCE_CACHE.clear() + cls._EXPIRY_HEAP.clear() + return n + + # Remaining heap entries are discarded as stale during the next purge + keys = [ + k + for k, (obj, *_) in cls._INSTANCE_CACHE.items() + if (instance_type is None or isinstance(obj, instance_type)) + and (where is None or where(obj)) + ] + for k in keys: + del cls._INSTANCE_CACHE[k] + return len(keys) + def __str__(self) -> str: return f"Multiton({self._factory})" diff --git a/tests/test_multiton.py b/tests/test_multiton.py index eabb643..a99fc39 100644 --- a/tests/test_multiton.py +++ b/tests/test_multiton.py @@ -573,3 +573,171 @@ class WeakMultiton(Multiton[Data]): # The parent is collectable while the child remains cached assert parent_ref() is None assert child_key in Multiton._INSTANCE_CACHE + + +class Base: + pass + + +class Derived(Base): + pass + + +class Other: + pass + + +def test_clear_cache_all(): + """clear_cache() evicts every entry, eternal ones included, and the heap""" + finite = Multiton(Base) + eternal = Multiton(Other).with_infinite_ttl() + old_finite, old_eternal = finite.instance, eternal.instance + + Multiton.clear_cache() + assert not Multiton._INSTANCE_CACHE + assert not Multiton._EXPIRY_HEAP + + assert finite.instance is not old_finite + assert eternal.instance is not old_eternal + + +def test_clear_cache_by_type(): + """Only entries whose instance matches the type are evicted""" + base, other = Multiton(Base), Multiton(Other) + old_base, old_other = base.instance, other.instance + + Multiton.clear_cache(Base) + + assert base.instance is not old_base + assert other.instance is old_other + + +def test_clear_cache_matches_subclasses(): + """isinstance semantics: clearing a base type also evicts subclasses""" + derived, other = Multiton(Derived), Multiton(Other) + old_derived, old_other = derived.instance, other.instance + + Multiton.clear_cache(Base) + + assert derived.instance is not old_derived + assert other.instance is old_other + + +@pytest.mark.parametrize("instance_type", [(Base, Other), Base | Other]) +def test_clear_cache_tuple_and_union(instance_type): + """Tuples of types and unions are both accepted""" + base, other, data = Multiton(Base), Multiton(Other), Multiton(Data, 1.0, 2.0) + old_base, old_other, old_data = base.instance, other.instance, data.instance + + Multiton.clear_cache(instance_type) + + assert base.instance is not old_base + assert other.instance is not old_other + assert data.instance is old_data + + +def test_clear_cache_no_match(): + """A type matching nothing leaves the cache untouched""" + m = Multiton(Base) + old = m.instance + + Multiton.clear_cache(Other) + + assert m.instance is old + + +def test_clear_cache_stale_heap_entry_ignored(): + """A recreated key survives expiry of the heap entry left by clear_cache""" + m = Multiton(Base).with_ttl(10.0) + now = 1000.0 + with patch("rarg_python_patterns.multiton.multiton.time.monotonic") as mono: + mono.return_value = now + m.instance + Multiton.clear_cache(Base) + assert len(Multiton._EXPIRY_HEAP) == 1 # stale entry left behind + + mono.return_value = now + 5.0 + recreated = m.instance + + # The stale entry's deadline passes, but the recreated entry is live + mono.return_value = now + 12.0 + assert m.instance is recreated + + +def test_clear_cache_returns_count(): + """clear_cache reports how many entries it evicted""" + Multiton(Base).instance + Multiton(Other).instance + Multiton(Data, 1.0, 2.0).instance + + assert Multiton.clear_cache(Base | Other) == 2 + assert Multiton.clear_cache(Base) == 0 + assert Multiton.clear_cache() == 1 + + +def test_clear_cache_where(): + """where filters on the instance; combined with instance_type it is an AND""" + d1, d2 = Multiton(Data, 1.0, 2.0), Multiton(Data, 3.0, 4.0) + other = Multiton(Other) + old_d1, old_d2, old_other = d1.instance, d2.instance, other.instance + + def first_arg_is_one(obj): + return isinstance(obj, Data) and obj.a == 1.0 + + assert Multiton.clear_cache(Other, where=first_arg_is_one) == 0 + assert Multiton.clear_cache(where=first_arg_is_one) == 1 + + assert d1.instance is not old_d1 + assert d2.instance is old_d2 + assert other.instance is old_other + + +class _Table: + def __init__(self, name): + self.name = name + + +class _Structure: + def __init__(self, table, subtables): + self.table = table.instance.name + self.subtables = {k: v.instance.name for k, v in subtables.items()} + + +def test_clear_cache_where_per_dataset(): + """Evict one dataset's tables while keeping its structure and other datasets. + + Mirrors how xarray-ms keys its main table, subtables and structure. + """ + calls = {"table": 0, "structure": 0} + + def open_table(name): + calls["table"] += 1 + return _Table(name) + + def build_structure(table, subtables): + calls["structure"] += 1 + return _Structure(table, subtables) + + def make(ms): + table = Multiton(open_table, ms) + subtables = {"ANT": Multiton(open_table, f"{ms}::ANT")} + return table, subtables, Multiton(build_structure, table, subtables) + + a_table, a_subtables, a_structure = make("a.ms") + b_table, b_subtables, b_structure = make("b.ms") + a_structure.instance, b_structure.instance + old_a, old_b = a_table.instance, b_table.instance + assert calls == {"table": 4, "structure": 2} + + def a_tables(obj): + return isinstance(obj, _Table) and obj.name.partition("::")[0] == "a.ms" + + assert Multiton.clear_cache(where=a_tables) == 2 + + # Structures survive; only a.ms's tables reopen + a_structure.instance, b_structure.instance + assert calls == {"table": 4, "structure": 2} + assert a_table.instance is not old_a + assert a_subtables["ANT"].instance.name == "a.ms::ANT" + assert b_table.instance is old_b + assert calls == {"table": 6, "structure": 2}