Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------
Expand Down
64 changes: 58 additions & 6 deletions src/rarg_python_patterns/multiton/multiton.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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})"

Expand Down
168 changes: 168 additions & 0 deletions tests/test_multiton.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Loading