Source code for collective.redismemoize.client

"""The Redis connection: created lazily, per process, behind a circuit breaker.

The client is created on first use and recreated when the process id
changes, so a server that forks workers after startup never shares a socket
between processes.

The breaker is what keeps a Redis outage from becoming a Plone outage. After
a failure, :meth:`Connection.available` is ``False`` for ``retry_after``
seconds and every cached function goes straight to its RAM cache, without
waiting for a timeout per call. The first call after that tries Redis again;
a success closes the breaker, a failure keeps it open for another period.
One ``WARNING`` is logged when the breaker opens and one ``INFO`` when it
closes, never one line per request.

This module is pure Python and never imports Zope.
"""

from collections.abc import Callable
from collective.redismemoize import logger
from collective.redismemoize.config import get_settings
from collective.redismemoize.config import Settings
from typing import Any

import os
import redis
import threading
import time


#: The exceptions that mean Redis, or the network to it, failed.
ERRORS: tuple[type[BaseException], ...] = (redis.RedisError, OSError)

#: Builds a client from the settings. Replaced by tests and by
#: :mod:`collective.redismemoize.testing` to point at ``fakeredis``.
ClientFactory = Callable[[Settings], Any]


[docs] def default_factory(settings: Settings) -> redis.Redis: """Create a redis-py client for the configured URL. :param settings: The package settings. :returns: A client; no connection is opened until the first command. """ return redis.Redis.from_url( settings.url, socket_timeout=settings.timeout, socket_connect_timeout=settings.timeout, health_check_interval=30, )
[docs] class Connection: """A lazily created client and the circuit breaker in front of it. :param settings: The package settings. :param factory: Builds the client. :param clock: Monotonic clock, in seconds; injectable for tests. """
[docs] def __init__( self, settings: Settings, factory: ClientFactory = default_factory, clock: Callable[[], float] = time.monotonic, ): self.settings = settings self.factory = factory self.clock = clock self._client: Any = None self._pid: int | None = None self._failed_at: float | None = None self._lock = threading.Lock()
[docs] def client(self) -> Any: """Return the client for this process, creating it if needed. :returns: The client. """ pid = os.getpid() with self._lock: if self._client is None or self._pid != pid: self._client = self.factory(self.settings) self._pid = pid return self._client
@property def is_open(self) -> bool: """Report whether the breaker is open, whatever the time. :returns: ``True`` from a failure until the next success. """ return self._failed_at is not None
[docs] def seconds_until_retry(self) -> float: """Return how long the breaker keeps Redis out of reach. :returns: Seconds until the next attempt; ``0`` when closed or due. """ if self._failed_at is None: return 0.0 return max(0.0, self.settings.retry_after - (self.clock() - self._failed_at))
[docs] def available(self) -> bool: """Report whether Redis may be called now. :returns: ``False`` while the breaker waits out ``retry_after``. """ return self.seconds_until_retry() == 0.0
[docs] def failure(self, exc: BaseException) -> None: """Record a failed call, opening the breaker. :param exc: What went wrong, for the log. """ with self._lock: opened = self._failed_at is None self._failed_at = self.clock() if opened: logger.warning( "Redis unavailable (%s: %s); using the RAM cache for %ss", type(exc).__name__, exc, self.settings.retry_after, )
[docs] def success(self) -> None: """Record a successful call, closing the breaker if it was open.""" if self._failed_at is None: return with self._lock: self._failed_at = None logger.info("Redis available again; values are shared again")
_connection: Connection | None = None _factory: ClientFactory = default_factory
[docs] def get_connection() -> Connection: """Return the process connection, matching the current settings. :returns: The connection; a new one when the settings were reloaded. """ global _connection settings = get_settings() if _connection is None or _connection.settings is not settings: _connection = Connection(settings, factory=_factory) return _connection
[docs] def set_factory(factory: ClientFactory | None) -> None: """Replace the client factory, and drop the current connection. :param factory: The new factory; ``None`` restores redis-py. """ global _connection, _factory _factory = default_factory if factory is None else factory _connection = None
[docs] def reset() -> None: """Drop the current connection; the next call creates a new one.""" global _connection _connection = None