Files

675 lines
29 KiB
Python

"""Small, optional Redis cache for the homepage agent and skill catalogues.
MySQL remains the source of truth. Redis can use the dedicated
``api_read_cache`` connection, or reuse ``concurrency.redis`` when explicitly
enabled; failures and missing configuration fall through to the normal
MySQL-backed request path.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import secrets
import time
from typing import Any
from weakref import WeakKeyDictionary
from app.gateway.api_cache_catalog import API_CACHE_CATALOG, API_READ_CACHE_COLLECTIONS
logger = logging.getLogger(__name__)
_CACHE_SUFFIX = "api_read_cache:v1"
_DEFAULT_CACHE_TTL_SECONDS = 300
_MIN_CACHE_TTL_SECONDS = 1
_MAX_CACHE_TTL_SECONDS = 3600
_clients: dict[tuple[str, str, int, int], Any] = {}
_last_warning_at: dict[str, float] = {}
_VERSION_UNSET = object()
_api_read_cache_settings_cache: tuple[float, int | None, bool, int, dict[str, bool], bool, str] | None = None
_enabled_cache_ttl_seconds = 2.0
_save_locks: WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = WeakKeyDictionary()
_enabled_locks: dict[tuple[int, int], asyncio.Lock] = {}
_fill_events: dict[tuple[int, str], asyncio.Event] = {}
_fill_local_payloads: dict[tuple[int, str], tuple[float, str, bytes]] = {}
def _redis_settings(config: Any, *, enabled: bool | None = None) -> tuple[str, str | None, str] | None:
concurrency = getattr(config, "concurrency", None)
concurrency_redis = getattr(concurrency, "redis", None)
api_cache = getattr(config, "api_read_cache", None)
api_enabled = bool(getattr(api_cache, "enabled", False)) if enabled is None else enabled
concurrency_enabled = bool(getattr(concurrency_redis, "enabled", False))
if not api_enabled:
return None
url = str(getattr(api_cache, "url", None) or (getattr(concurrency_redis, "url", None) if concurrency_enabled else None) or "").strip()
if not url:
return None
namespace = str(getattr(api_cache, "namespace", "cmzs_deerflow:homepage") if api_enabled else f"{getattr(concurrency_redis, 'namespace', 'cmzs_deerflow:concurrency')}:homepage")
password = getattr(api_cache, "password", None)
if not getattr(api_cache, "url", None) and concurrency_enabled:
password = password or getattr(concurrency_redis, "password", None)
return url, password, namespace.strip().rstrip(":")
def _client(config: Any, *, enabled: bool | None = None, epoch: str | None = None) -> tuple[Any, str] | None:
settings = _redis_settings(config, enabled=enabled)
if settings is None:
return None
url, password, namespace = settings
api_cache = getattr(config, "api_read_cache", None)
redis_config = getattr(getattr(config, "concurrency", None), "redis", None)
timeout_config = api_cache if (enabled if enabled is not None else getattr(api_cache, "enabled", False)) else redis_config
connect_timeout_ms = int(getattr(timeout_config, "socket_connect_timeout_ms", 250) or 250)
socket_timeout_ms = int(getattr(timeout_config, "socket_timeout_ms", 750) or 750)
identity = (url, str(password or ""), connect_timeout_ms, socket_timeout_ms)
client = _clients.get(identity)
if client is None:
try:
from redis.asyncio import Redis
except ImportError:
_warn_once(namespace, "redis package is not installed")
return None
client = Redis.from_url(
url,
password=password,
socket_connect_timeout=max(0.001, connect_timeout_ms / 1000),
socket_timeout=max(0.001, socket_timeout_ms / 1000),
decode_responses=False,
# Homepage bursts commonly arrive in parallel (e.g. 70 users).
# 70 users can load several homepage APIs at once; keep enough
# headroom for the combined burst instead of queuing Redis reads.
max_connections=256,
)
_clients[identity] = client
return client, f"{namespace}:{_CACHE_SUFFIX}:epoch:{_cache_epoch(config) if epoch is None else epoch}"
def _cache_epoch(config: Any) -> str:
current = _api_read_cache_settings_cache
if current is not None and current[1] in (None, id(config)):
return current[6]
return "0"
def _scoped_version(epoch: str, generation: str) -> str:
# Initial deployments keep the original generation value; later saves use
# an opaque epoch marker so an in-flight old fill cannot enter a new cache.
return generation if epoch == "0" else f"{epoch}.{generation}"
async def get_api_read_cache_settings(
config: Any,
*,
force_refresh: bool = False,
) -> tuple[bool, int]:
"""Read the admin toggle and TTL from MySQL, refreshing at most every 2s.
The YAML flag is retained as the compatibility default for deployments that
have not yet created the settings row. Once an administrator saves the
setting, MySQL is authoritative across restarts and worker containers.
"""
global _api_read_cache_settings_cache
now = time.monotonic()
if not force_refresh and _api_read_cache_settings_cache is not None and now - _api_read_cache_settings_cache[0] < _enabled_cache_ttl_seconds and _api_read_cache_settings_cache[1] in (None, id(config)):
return _api_read_cache_settings_cache[2], _api_read_cache_settings_cache[3]
loop = asyncio.get_running_loop()
lock_key = (id(loop), id(config))
setting_lock = _enabled_locks.get(lock_key)
if setting_lock is None:
setting_lock = _enabled_locks[lock_key] = asyncio.Lock()
if len(_enabled_locks) > 64:
_enabled_locks.pop(next(iter(_enabled_locks)))
async with setting_lock:
now = time.monotonic()
if not force_refresh and _api_read_cache_settings_cache is not None and now - _api_read_cache_settings_cache[0] < _enabled_cache_ttl_seconds and _api_read_cache_settings_cache[1] in (None, id(config)):
return _api_read_cache_settings_cache[2], _api_read_cache_settings_cache[3]
configured_default = bool(getattr(getattr(config, "api_read_cache", None), "enabled", False))
enabled = configured_default
ttl_seconds = _cache_ttl_seconds(config)
collection_overrides: dict[str, bool] = {}
browser_status_enabled = True
cache_epoch = "0"
try:
from deerflow.persistence.api_read_cache_settings.model import ApiReadCacheSettingRow
from deerflow.persistence.engine import get_session_factory
session_factory = get_session_factory()
if session_factory is not None:
async with session_factory() as session:
row = await session.get(ApiReadCacheSettingRow, "default")
if row is not None:
enabled = bool(row.enabled)
ttl_seconds = _cache_ttl_seconds(
config,
getattr(row, "ttl_seconds", ttl_seconds),
)
collection_overrides = dict(getattr(row, "collection_overrides", None) or {})
browser_status_enabled = bool(getattr(row, "browser_status_enabled", True))
cache_epoch = str(getattr(row, "cache_epoch", None) or "0")
except Exception as exc:
_warn_once("api_read_cache_settings", f"setting read failed ({type(exc).__name__})")
if _api_read_cache_settings_cache is not None and _api_read_cache_settings_cache[1] in (None, id(config)):
enabled = _api_read_cache_settings_cache[2]
ttl_seconds = _api_read_cache_settings_cache[3]
collection_overrides = _api_read_cache_settings_cache[4]
browser_status_enabled = _api_read_cache_settings_cache[5]
cache_epoch = _api_read_cache_settings_cache[6]
if _api_read_cache_settings_cache is not None and _api_read_cache_settings_cache[0] > now and _api_read_cache_settings_cache[1] in (None, id(config)):
# An administrator saved while this database read was in flight.
# Do not replace the just-saved switch/epoch with its older row.
return _api_read_cache_settings_cache[2], _api_read_cache_settings_cache[3]
_api_read_cache_settings_cache = (now, id(config), enabled, ttl_seconds, collection_overrides, browser_status_enabled, cache_epoch)
return enabled, ttl_seconds
async def is_api_read_cache_enabled(config: Any, *, force_refresh: bool = False) -> bool:
return (await get_api_read_cache_settings(config, force_refresh=force_refresh))[0]
async def is_collection_cache_enabled(config: Any, collection: str) -> bool:
enabled, _ = await get_api_read_cache_settings(config)
return enabled and bool(_api_read_cache_settings_cache[4].get(collection, True))
async def is_browser_status_enabled(config: Any) -> bool:
await get_api_read_cache_settings(config)
return _api_read_cache_settings_cache[5]
async def get_api_cache_controls(config: Any) -> dict[str, Any]:
_, ttl = await get_api_read_cache_settings(config)
overrides = _api_read_cache_settings_cache[4]
return {
"browser_status_enabled": _api_read_cache_settings_cache[5],
"collections": [{"id": key, "label": label, "paths": paths, "scope": scope, "enabled": overrides.get(key, True), "ttl_seconds": ttl} for key, label, paths, scope in API_CACHE_CATALOG],
}
def set_api_read_cache_settings_local(
enabled: bool,
ttl_seconds: int,
*,
collection_overrides: dict[str, bool] | None = None,
browser_status_enabled: bool | None = None,
cache_epoch: str | None = None,
) -> None:
"""Make just-saved settings effective in this Gateway process immediately."""
global _api_read_cache_settings_cache
previous = _api_read_cache_settings_cache
_api_read_cache_settings_cache = (
time.monotonic(),
None,
bool(enabled),
_cache_ttl_seconds(None, ttl_seconds),
dict(collection_overrides if collection_overrides is not None else (previous[4] if previous else {})),
browser_status_enabled if browser_status_enabled is not None else (previous[5] if previous else True),
cache_epoch if cache_epoch is not None else (previous[6] if previous else "0"),
)
if previous is not None and previous[6] != _api_read_cache_settings_cache[6]:
# Wake old-generation waiters immediately; their Redis locks naturally
# expire and cannot block reads in the new persisted epoch.
for event in _fill_events.values():
event.set()
_fill_events.clear()
_fill_local_payloads.clear()
def set_api_read_cache_enabled_local(enabled: bool) -> None:
"""Backward-compatible local switch update that retains the cached TTL."""
ttl_seconds = _api_read_cache_settings_cache[3] if _api_read_cache_settings_cache is not None else _DEFAULT_CACHE_TTL_SECONDS
set_api_read_cache_settings_local(enabled, ttl_seconds)
async def save_api_read_cache_settings(
enabled: bool | None,
ttl_seconds: int | None = None,
*,
config: Any = None,
collection_overrides: dict[str, bool] | None = None,
browser_status_enabled: bool | None = None,
) -> tuple[bool, int]:
"""Serialize same-process patches; row locks protect concurrent DB workers."""
loop = asyncio.get_running_loop()
lock = _save_locks.setdefault(loop, asyncio.Lock())
async with lock:
return await _persist_api_read_cache_settings(
enabled,
ttl_seconds,
config=config,
collection_overrides=collection_overrides,
browser_status_enabled=browser_status_enabled,
)
async def _persist_api_read_cache_settings(
enabled: bool | None,
ttl_seconds: int | None,
*,
config: Any,
collection_overrides: dict[str, bool] | None,
browser_status_enabled: bool | None,
) -> tuple[bool, int]:
"""Persist the administrator toggle and optional TTL in the business DB."""
from deerflow.persistence.api_read_cache_settings.model import ApiReadCacheSettingRow
from deerflow.persistence.engine import get_session_factory
session_factory = get_session_factory()
if session_factory is None:
raise RuntimeError("MySQL persistence is required to save the Redis cache setting")
if collection_overrides is not None and set(collection_overrides) - set(API_READ_CACHE_COLLECTIONS):
raise ValueError("Unknown API cache collection")
from sqlalchemy.exc import IntegrityError
for attempt in range(2):
try:
async with session_factory() as session:
row = await session.get(ApiReadCacheSettingRow, "default", with_for_update=True)
if row is None:
saved_enabled = bool(getattr(getattr(config, "api_read_cache", None), "enabled", False)) if enabled is None else enabled
saved_ttl = _cache_ttl_seconds(config, ttl_seconds)
row = ApiReadCacheSettingRow(
key="default",
enabled=saved_enabled,
ttl_seconds=saved_ttl,
collection_overrides={},
browser_status_enabled=True,
)
session.add(row)
else:
if enabled is not None:
row.enabled = bool(enabled)
saved_ttl = _cache_ttl_seconds(
config,
ttl_seconds if ttl_seconds is not None else getattr(row, "ttl_seconds", None),
)
row.ttl_seconds = saved_ttl
saved_enabled = bool(row.enabled)
saved_overrides = {**(getattr(row, "collection_overrides", None) or {}), **(collection_overrides or {})}
saved_browser_status = browser_status_enabled if browser_status_enabled is not None else bool(getattr(row, "browser_status_enabled", True))
row.collection_overrides = saved_overrides
row.browser_status_enabled = saved_browser_status
saved_epoch = secrets.token_hex(16)
row.cache_epoch = saved_epoch
await session.commit()
break
except IntegrityError:
# Another worker may have inserted the singleton settings row.
# Reload it under the row lock and merge this patch exactly once.
if attempt:
raise
set_api_read_cache_settings_local(saved_enabled, saved_ttl, collection_overrides=saved_overrides, browser_status_enabled=saved_browser_status, cache_epoch=saved_epoch)
return saved_enabled, saved_ttl
async def save_api_read_cache_enabled(enabled: bool) -> None:
"""Backward-compatible switch update; preserves an existing TTL."""
await save_api_read_cache_settings(enabled)
def api_read_cache_redis_configured(config: Any) -> bool:
return _redis_settings(config, enabled=True) is not None
def _cache_ttl_seconds(config: Any, ttl_seconds: int | None = None) -> int:
value = int(ttl_seconds if ttl_seconds is not None else getattr(getattr(config, "api_read_cache", None), "ttl_seconds", _DEFAULT_CACHE_TTL_SECONDS) or _DEFAULT_CACHE_TTL_SECONDS)
return max(_MIN_CACHE_TTL_SECONDS, min(_MAX_CACHE_TTL_SECONDS, value))
def _redis_operation_timeout_seconds(config: Any) -> float:
cache = getattr(config, "api_read_cache", None)
connect_ms = int(getattr(cache, "socket_connect_timeout_ms", 250) or 250)
socket_ms = int(getattr(cache, "socket_timeout_ms", 750) or 750)
# Keep Redis optional and bounded, while allowing a private-network cache
# to absorb short bursts before falling back to MySQL.
return min(1.0, max(0.15, max(connect_ms, socket_ms) * 1.25 / 1000))
def _max_cache_value_bytes(config: Any) -> int:
value = int(getattr(getattr(config, "api_read_cache", None), "max_value_bytes", 512 * 1024) or 512 * 1024)
return max(1024, min(5 * 1024 * 1024, value))
def _warn_once(key: str, message: str) -> None:
now = time.monotonic()
if now - _last_warning_at.get(key, 0.0) >= 60:
_last_warning_at[key] = now
logger.warning("Homepage Redis cache unavailable; using MySQL: %s", message)
def _version_key(prefix: str, collection: str) -> str:
return f"{prefix}:{collection}:version"
def _item_key(prefix: str, collection: str, version: str, logical_key: str) -> str:
digest = hashlib.sha256(logical_key.encode("utf-8")).hexdigest()
return f"{prefix}:{collection}:v{version}:{digest}"
async def get_cached_json(
config: Any,
collection: str,
logical_key: str,
*,
version: str | None | object = _VERSION_UNSET,
) -> Any | None:
raw = await get_cached_json_payload(config, collection, logical_key, version=version)
if raw is None:
return None
try:
return json.loads(raw)
except Exception as exc:
_warn_once(f"{collection}:decode", f"cached JSON decode failed ({type(exc).__name__})")
return None
async def get_cached_json_payload(
config: Any,
collection: str,
logical_key: str,
*,
version: str | None | object = _VERSION_UNSET,
) -> bytes | None:
"""Return the stored JSON bytes so HTTP routes can bypass revalidation."""
if not await is_collection_cache_enabled(config, collection):
return None
client_info = _client(config, enabled=True)
if client_info is None:
return None
client, prefix = client_info
try:
if version is _VERSION_UNSET:
version = await get_cache_version(config, collection)
if version is None:
return None
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
raw = await client.get(_item_key(prefix, collection, str(version), logical_key))
if raw is None:
return None
return raw if isinstance(raw, bytes) else str(raw).encode("utf-8")
except Exception as exc: # Redis is an optimization; MySQL remains authoritative.
_warn_once(prefix, f"read failed ({type(exc).__name__})")
return None
async def get_cache_version(config: Any, collection: str) -> str | None:
"""Read the collection generation used to guard against stale writes."""
if not await is_collection_cache_enabled(config, collection):
return None
client_info = _client(config, enabled=True)
if client_info is None:
return None
client, prefix = client_info
epoch = _cache_epoch(config)
try:
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
version = await client.get(_version_key(prefix, collection))
generation = version.decode("ascii") if isinstance(version, bytes) else str(version or "0")
return _scoped_version(epoch, generation)
except Exception as exc:
_warn_once(prefix, f"version read failed ({type(exc).__name__})")
return None
async def wait_for_cached_json_or_claim_fill(
config: Any,
collection: str,
logical_key: str,
*,
version: str | None,
wait_ms: int = 1000,
) -> tuple[Any | None, str | None]:
"""Wait briefly for a concurrent miss to fill Redis, or claim the fill.
The returned token must be passed to :func:`release_cache_fill` in a
``finally`` block. This prevents a burst of identical requests from all
rebuilding the same large agent/skill response at once.
"""
return await _wait_for_cached_value_or_claim_fill(
config,
collection,
logical_key,
version=version,
wait_ms=wait_ms,
raw_payload=False,
)
async def wait_for_cached_json_payload_or_claim_fill(
config: Any,
collection: str,
logical_key: str,
*,
version: str | None,
wait_ms: int = 6000,
) -> tuple[bytes | None, str | None]:
"""Wait for an in-flight fill and return the cached HTTP JSON bytes."""
value, token = await _wait_for_cached_value_or_claim_fill(
config,
collection,
logical_key,
version=version,
wait_ms=wait_ms,
raw_payload=True,
)
return value, token
async def _wait_for_cached_value_or_claim_fill(
config: Any,
collection: str,
logical_key: str,
*,
version: str | None,
wait_ms: int,
raw_payload: bool,
) -> tuple[Any | bytes | None, str | None]:
if version is None or not await is_collection_cache_enabled(config, collection):
return None, None
client_info = _client(config, enabled=True)
if client_info is None:
return None, None
client, prefix = client_info
digest = hashlib.sha256(logical_key.encode("utf-8")).hexdigest()
lock_key = f"{prefix}:{collection}:fill:{digest}"
event_key = (id(asyncio.get_running_loop()), lock_key)
token = f"{_cache_epoch(config)}:{secrets.token_urlsafe(18)}"
try:
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
if await client.set(lock_key, token, ex=15, nx=True):
_fill_events[event_key] = asyncio.Event()
return None, token
except Exception as exc:
_warn_once(prefix, f"fill lock unavailable ({type(exc).__name__})")
return None, None
wait_seconds = max(0, min(wait_ms, 8000)) / 1000
local_event = _fill_events.get(event_key)
if local_event is not None:
try:
async with asyncio.timeout(wait_seconds):
await local_event.wait()
except TimeoutError:
pass
if not await is_collection_cache_enabled(config, collection) or await get_cache_version(config, collection) != version:
return None, None
local_payload = _fill_local_payloads.get(event_key)
if local_payload is not None:
expires_at, payload_version, payload = local_payload
if expires_at > time.monotonic() and payload_version == version:
if raw_payload:
return payload, None
try:
return json.loads(payload), None
except Exception as exc:
_warn_once(f"{collection}:decode", f"local fill JSON decode failed ({type(exc).__name__})")
else:
_fill_local_payloads.pop(event_key, None)
if raw_payload:
cached = await get_cached_json_payload(config, collection, logical_key, version=version)
else:
cached = await get_cached_json(config, collection, logical_key, version=version)
if cached is not None:
return cached, None
else:
# Cross-process waiters cannot share the local event. Check Redis at a
# low frequency instead of polling every 50-100ms under a cold burst.
deadline = time.monotonic() + wait_seconds
while time.monotonic() < deadline:
await asyncio.sleep(min(1.0, max(0.0, deadline - time.monotonic())))
if not await is_collection_cache_enabled(config, collection) or await get_cache_version(config, collection) != version:
return None, None
if raw_payload:
cached = await get_cached_json_payload(config, collection, logical_key, version=version)
else:
cached = await get_cached_json(config, collection, logical_key, version=version)
if cached is not None:
return cached, None
# The original filler may have failed. A single waiter gets one chance to
# take over; remaining requests fall through to MySQL if the cache is down.
if not await is_collection_cache_enabled(config, collection) or await get_cache_version(config, collection) != version:
return None, None
try:
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
if await client.set(lock_key, token, ex=15, nx=True):
_fill_events[event_key] = asyncio.Event()
return None, token
except Exception as exc:
_warn_once(prefix, f"fill lock retry failed ({type(exc).__name__})")
return None, None
async def release_cache_fill(config: Any, collection: str, logical_key: str, token: str | None) -> None:
"""Release a miss lock only when it is still owned by this request."""
if not token:
return
digest = hashlib.sha256(logical_key.encode("utf-8")).hexdigest()
owner_epoch, separator, _ = token.partition(":")
client_info = _client(config, enabled=True, epoch=owner_epoch if separator else None)
if client_info is not None:
client, prefix = client_info
lock_key = f"{prefix}:{collection}:fill:{digest}"
event = _fill_events.pop((id(asyncio.get_running_loop()), lock_key), None)
else:
lock_key = ""
event = None
if event is not None:
event.set()
if client_info is None:
return
try:
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
await client.eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
1,
lock_key,
token,
)
except Exception as exc:
_warn_once(prefix, f"fill lock release failed ({type(exc).__name__})")
def _json_default(value: Any) -> Any:
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
return model_dump(mode="json", by_alias=True)
isoformat = getattr(value, "isoformat", None)
if callable(isoformat):
return isoformat()
raise TypeError(f"Cannot cache {type(value).__name__}")
async def set_cached_json(
config: Any,
collection: str,
logical_key: str,
value: Any,
*,
expected_version: str | None | object = _VERSION_UNSET,
ttl_seconds: int | None = None,
) -> None:
enabled, configured_ttl = await get_api_read_cache_settings(config)
if not enabled or not await is_collection_cache_enabled(config, collection):
return
client_info = _client(config, enabled=True)
if client_info is None:
return
client, prefix = client_info
try:
if expected_version is _VERSION_UNSET:
expected_version = await get_cache_version(config, collection)
if expected_version is None:
return
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=_json_default).encode("utf-8")
if len(encoded) > _max_cache_value_bytes(config):
return
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
version = await client.get(_version_key(prefix, collection))
version_text = version.decode("ascii") if isinstance(version, bytes) else str(version or "0")
if _scoped_version(_cache_epoch(config), version_text) != expected_version:
return
await client.set(
_item_key(prefix, collection, expected_version, logical_key),
encoded,
ex=_cache_ttl_seconds(
config,
ttl_seconds if ttl_seconds is not None else configured_ttl,
),
)
loop_id = id(asyncio.get_running_loop())
fill_digest = hashlib.sha256(logical_key.encode("utf-8")).hexdigest()
fill_event_key = (loop_id, f"{prefix}:{collection}:fill:{fill_digest}")
if fill_event_key in _fill_events:
now = time.monotonic()
for key, (expires_at, _, _) in list(_fill_local_payloads.items()):
if expires_at <= now:
_fill_local_payloads.pop(key, None)
_fill_local_payloads[fill_event_key] = (now + 2.0, str(expected_version), encoded)
if len(_fill_local_payloads) > 128:
_fill_local_payloads.pop(next(iter(_fill_local_payloads)))
except Exception as exc:
_warn_once(prefix, f"write failed ({type(exc).__name__})")
async def invalidate_cached_collection(config: Any, collection: str, *, force: bool = False) -> None:
"""Bump a generation; old keys expire naturally, with no Redis key scan."""
if not force and not await is_api_read_cache_enabled(config):
return
# A forced invalidation is used after an admin edits sensitive words while
# Redis reads are disabled. The generation still advances so re-enabling
# the cache cannot resurrect that older value within its one-hour TTL.
client_info = _client(config, enabled=True)
if client_info is None:
return
client, prefix = client_info
try:
async with asyncio.timeout(_redis_operation_timeout_seconds(config)):
await client.incr(_version_key(prefix, collection))
except Exception as exc:
_warn_once(prefix, f"invalidation failed ({type(exc).__name__})")
async def close_api_read_cache() -> None:
clients = list(_clients.values())
_clients.clear()
for client in clients:
try:
await client.aclose()
except Exception:
logger.debug("Failed to close homepage Redis cache client", exc_info=True)