675 lines
29 KiB
Python
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)
|