"""Public API: invalidation, status, and a decorator with its own TTL.
Nothing in this package calls :func:`invalidate_all` on its own. Call it when
the meaning of cached values changes for every instance at once, typically
after a deploy that changes what a cached function returns, or from an
upgrade step.
"""
from collections.abc import Callable
from collective.redismemoize.adapter import RedisCacheAdapter
from collective.redismemoize.client import ERRORS
from collective.redismemoize.client import get_connection
from collective.redismemoize.config import get_settings
from collective.redismemoize.routing import router
from plone.memoize import volatile
from plone.memoize.ram import global_cache
from plone.memoize.ram import store_in_cache
from typing import Any
from zope.component import queryUtility
from zope.ramcache.interfaces.ram import IRAMCache
import re
import redis
import time
#: How many keys one ``SCAN`` step asks for, and one ``UNLINK`` removes.
BATCH_SIZE = 500
def _escape(text: str) -> str:
"""Escape the glob characters Redis ``MATCH`` patterns understand.
:param text: Literal text.
:returns: The text, matching only itself.
"""
return re.sub(r"([*?\[\]\\])", r"\\\1", text)
def _unlink(pattern: str) -> int:
"""Remove every Redis key matching a pattern, in batches.
:param pattern: A ``MATCH`` pattern.
:returns: How many keys were removed; ``0`` when Redis is unreachable.
"""
connection = get_connection()
removed = 0
try:
client = connection.client()
batch: list[Any] = []
for key in client.scan_iter(match=pattern, count=BATCH_SIZE):
batch.append(key)
if len(batch) == BATCH_SIZE:
removed += client.unlink(*batch)
batch = []
if batch:
removed += client.unlink(*batch)
except ERRORS as exc:
connection.failure(exc)
return removed
connection.success()
return removed
[docs]
def ram_caches() -> list[Any]:
"""Return the RAM caches ``plone.memoize`` may have stored values in.
The chooser wraps the ``IRAMCache`` utility, while ``store_in_cache``
falls back to ``global_cache`` when no chooser is registered. They are
usually the same object, but not always: a test layer can register its own.
:returns: ``global_cache``, and the utility when it is a different cache.
"""
caches = [global_cache]
utility = queryUtility(IRAMCache)
if utility is not None and utility is not global_cache:
caches.append(utility)
return caches
[docs]
def invalidate(fun_name: str) -> int:
"""Drop every cached value of one function, in Redis and in RAM.
:param fun_name: Dotted name of the cached function, as
``module.function``.
:returns: How many Redis keys were removed.
"""
for cache in ram_caches():
cache.invalidate(fun_name)
settings = get_settings()
if not settings.active:
return 0
return _unlink(f"{_escape(settings.prefix)}:{_escape(fun_name)}:*")
[docs]
def invalidate_all() -> int:
"""Drop every cached value under the prefix, and the whole RAM cache.
:returns: How many Redis keys were removed.
"""
for cache in ram_caches():
cache.invalidateAll()
settings = get_settings()
if not settings.active:
return 0
return _unlink(f"{_escape(settings.prefix)}:*")
def _server() -> dict[str, Any]:
"""Ask the server for its latency and version.
:returns: ``latency_ms``, ``product``, ``version`` and ``error``.
"""
connection = get_connection()
info: dict[str, Any] = {
"latency_ms": None,
"product": None,
"version": None,
"error": None,
}
try:
client = connection.client()
started = time.perf_counter()
client.ping()
info["latency_ms"] = round((time.perf_counter() - started) * 1000, 3)
try:
server = client.info("server")
except redis.ResponseError:
# The server answered, but refuses INFO: some managed services
# rename or disable it. That is not an outage.
server = {}
except ERRORS as exc:
connection.failure(exc)
info["error"] = f"{type(exc).__name__}: {exc}"
return info
connection.success()
if "valkey_version" in server:
info["product"] = "valkey"
info["version"] = str(server["valkey_version"])
else:
info["product"] = "redis"
version = server.get("redis_version")
info["version"] = None if version is None else str(version)
return info
[docs]
def status() -> dict[str, Any]:
"""Describe the configuration, the breaker, the server and the routes.
This contacts Redis (a ``PING`` and an ``INFO server``) even while the
breaker is open, so it reports the server's current state.
:returns: A JSON-serializable mapping.
"""
settings = get_settings()
result: dict[str, Any] = {
"active": settings.active,
"settings": {
"url": settings.redacted_url,
"prefix": settings.prefix,
"ttl": settings.ttl,
"timeout": settings.timeout,
"retry_after": settings.retry_after,
"max_size": settings.max_size,
"include": list(settings.include),
"exclude": list(settings.exclude),
},
"breaker": None,
"server": None,
"functions": router.snapshot(),
}
if settings.active:
connection = get_connection()
result["server"] = _server()
result["breaker"] = {
"open": connection.is_open,
"seconds_until_retry": round(connection.seconds_until_retry(), 3),
}
return result
[docs]
def cache(get_key: Callable[..., Any], ttl: int | None = None) -> Callable:
"""Decorate a function like ``plone.memoize.ram.cache``, with its own TTL.
The value goes through the same chooser, serializer and routing as any
other cached function; ``ttl`` only matters when it ends up in Redis.
:param get_key: Builds the cache key, as for ``plone.memoize.ram.cache``.
:param ttl: Lifetime in seconds; the configured default when omitted.
:returns: The decorator.
:raises ValueError: When ``ttl`` is not a positive integer.
"""
if ttl is not None and (type(ttl) is not int or ttl < 1):
raise ValueError(f"ttl must be a positive integer, not {ttl!r}")
def get_cache(fun: Callable, *args: Any, **kwargs: Any) -> Any:
adapter = store_in_cache(fun, *args, **kwargs)
if ttl is not None and isinstance(adapter, RedisCacheAdapter):
adapter.ttl = ttl
return adapter
return volatile.cache(get_key, get_cache=get_cache)