feat: add CacheService with Redis caching and graceful fallback
Implements Redis-backed caching with category-aware TTL management: - Immutable categories (artifact metadata, dependencies) cached forever - Mutable categories (index pages, upstream sources) use configurable TTL - Graceful fallback when Redis unavailable or disabled - Pattern-based invalidation for bulk cache clearing
This commit is contained in:
262
backend/app/cache_service.py
Normal file
262
backend/app/cache_service.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
Redis-backed caching service with category-aware TTL and invalidation.
|
||||
|
||||
Provides:
|
||||
- Immutable caching for artifact data (hermetic builds)
|
||||
- TTL-based caching for discovery data
|
||||
- Event-driven invalidation for config changes
|
||||
- Graceful fallback when Redis unavailable
|
||||
"""
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from .config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheCategory(Enum):
|
||||
"""
|
||||
Cache categories with different TTL and invalidation rules.
|
||||
|
||||
Immutable (cache forever):
|
||||
- ARTIFACT_METADATA: Artifact info by SHA256
|
||||
- ARTIFACT_DEPENDENCIES: Extracted deps by SHA256
|
||||
- DEPENDENCY_RESOLUTION: Resolution results by input hash
|
||||
|
||||
Mutable (TTL + event invalidation):
|
||||
- UPSTREAM_SOURCES: Upstream config, invalidate on DB change
|
||||
- PACKAGE_INDEX: PyPI/npm index pages, TTL only
|
||||
- PACKAGE_VERSIONS: Version listings, TTL only
|
||||
"""
|
||||
|
||||
# Immutable - cache forever (hermetic builds)
|
||||
ARTIFACT_METADATA = "artifact"
|
||||
ARTIFACT_DEPENDENCIES = "deps"
|
||||
DEPENDENCY_RESOLUTION = "resolve"
|
||||
|
||||
# Mutable - TTL + event invalidation
|
||||
UPSTREAM_SOURCES = "upstream"
|
||||
PACKAGE_INDEX = "index"
|
||||
PACKAGE_VERSIONS = "versions"
|
||||
|
||||
|
||||
def get_category_ttl(category: CacheCategory, settings: Settings) -> Optional[int]:
|
||||
"""
|
||||
Get TTL for a cache category.
|
||||
|
||||
Returns:
|
||||
TTL in seconds, or None for no expiry (immutable).
|
||||
"""
|
||||
ttl_map = {
|
||||
# Immutable - no TTL
|
||||
CacheCategory.ARTIFACT_METADATA: None,
|
||||
CacheCategory.ARTIFACT_DEPENDENCIES: None,
|
||||
CacheCategory.DEPENDENCY_RESOLUTION: None,
|
||||
# Mutable - configurable TTL
|
||||
CacheCategory.UPSTREAM_SOURCES: settings.cache_ttl_upstream,
|
||||
CacheCategory.PACKAGE_INDEX: settings.cache_ttl_index,
|
||||
CacheCategory.PACKAGE_VERSIONS: settings.cache_ttl_versions,
|
||||
}
|
||||
return ttl_map.get(category)
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""
|
||||
Redis-backed caching with category-aware TTL.
|
||||
|
||||
Key format: orchard:{category}:{protocol}:{identifier}
|
||||
Example: orchard:deps:pypi:abc123def456
|
||||
|
||||
When Redis is disabled or unavailable, operations gracefully
|
||||
return None/no-op to allow the application to function without caching.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
self._settings = settings
|
||||
self._enabled = settings.redis_enabled
|
||||
self._redis: Optional["redis.asyncio.Redis"] = None
|
||||
self._started = False
|
||||
|
||||
async def startup(self) -> None:
|
||||
"""Initialize Redis connection. Called by FastAPI lifespan."""
|
||||
if self._started:
|
||||
return
|
||||
|
||||
if not self._enabled:
|
||||
logger.info("CacheService disabled (redis_enabled=False)")
|
||||
self._started = True
|
||||
return
|
||||
|
||||
try:
|
||||
import redis.asyncio as redis
|
||||
|
||||
logger.info(
|
||||
f"Connecting to Redis at {self._settings.redis_host}:"
|
||||
f"{self._settings.redis_port}/{self._settings.redis_db}"
|
||||
)
|
||||
|
||||
self._redis = redis.Redis(
|
||||
host=self._settings.redis_host,
|
||||
port=self._settings.redis_port,
|
||||
db=self._settings.redis_db,
|
||||
password=self._settings.redis_password,
|
||||
decode_responses=False, # We handle bytes
|
||||
)
|
||||
|
||||
# Test connection
|
||||
await self._redis.ping()
|
||||
logger.info("CacheService connected to Redis")
|
||||
|
||||
except ImportError:
|
||||
logger.warning("redis package not installed, caching disabled")
|
||||
self._enabled = False
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis connection failed, caching disabled: {e}")
|
||||
self._enabled = False
|
||||
self._redis = None
|
||||
|
||||
self._started = True
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Close Redis connection. Called by FastAPI lifespan."""
|
||||
if not self._started:
|
||||
return
|
||||
|
||||
if self._redis:
|
||||
await self._redis.aclose()
|
||||
self._redis = None
|
||||
|
||||
self._started = False
|
||||
logger.info("CacheService shutdown complete")
|
||||
|
||||
@staticmethod
|
||||
def _make_key(category: CacheCategory, protocol: str, identifier: str) -> str:
|
||||
"""Build namespaced cache key."""
|
||||
return f"orchard:{category.value}:{protocol}:{identifier}"
|
||||
|
||||
async def get(
|
||||
self,
|
||||
category: CacheCategory,
|
||||
key: str,
|
||||
protocol: str = "default",
|
||||
) -> Optional[bytes]:
|
||||
"""
|
||||
Get cached value.
|
||||
|
||||
Args:
|
||||
category: Cache category for TTL rules
|
||||
key: Unique identifier within category
|
||||
protocol: Protocol namespace (pypi, npm, etc.)
|
||||
|
||||
Returns:
|
||||
Cached bytes or None if not found/disabled.
|
||||
"""
|
||||
if not self._enabled or not self._redis:
|
||||
return None
|
||||
|
||||
try:
|
||||
full_key = self._make_key(category, protocol, key)
|
||||
return await self._redis.get(full_key)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache get failed for {key}: {e}")
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
category: CacheCategory,
|
||||
key: str,
|
||||
value: bytes,
|
||||
protocol: str = "default",
|
||||
) -> None:
|
||||
"""
|
||||
Set cached value with category-appropriate TTL.
|
||||
|
||||
Args:
|
||||
category: Cache category for TTL rules
|
||||
key: Unique identifier within category
|
||||
value: Bytes to cache
|
||||
protocol: Protocol namespace (pypi, npm, etc.)
|
||||
"""
|
||||
if not self._enabled or not self._redis:
|
||||
return
|
||||
|
||||
try:
|
||||
full_key = self._make_key(category, protocol, key)
|
||||
ttl = get_category_ttl(category, self._settings)
|
||||
|
||||
if ttl is None:
|
||||
await self._redis.set(full_key, value)
|
||||
else:
|
||||
await self._redis.setex(full_key, ttl, value)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache set failed for {key}: {e}")
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
category: CacheCategory,
|
||||
key: str,
|
||||
protocol: str = "default",
|
||||
) -> None:
|
||||
"""Delete a specific cache entry."""
|
||||
if not self._enabled or not self._redis:
|
||||
return
|
||||
|
||||
try:
|
||||
full_key = self._make_key(category, protocol, key)
|
||||
await self._redis.delete(full_key)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache delete failed for {key}: {e}")
|
||||
|
||||
async def invalidate_pattern(
|
||||
self,
|
||||
category: CacheCategory,
|
||||
pattern: str = "*",
|
||||
protocol: str = "default",
|
||||
) -> int:
|
||||
"""
|
||||
Invalidate all entries matching pattern.
|
||||
|
||||
Args:
|
||||
category: Cache category
|
||||
pattern: Glob pattern for keys (default "*" = all in category)
|
||||
protocol: Protocol namespace
|
||||
|
||||
Returns:
|
||||
Number of keys deleted.
|
||||
"""
|
||||
if not self._enabled or not self._redis:
|
||||
return 0
|
||||
|
||||
try:
|
||||
full_pattern = self._make_key(category, protocol, pattern)
|
||||
keys = []
|
||||
async for key in self._redis.scan_iter(match=full_pattern):
|
||||
keys.append(key)
|
||||
|
||||
if keys:
|
||||
return await self._redis.delete(*keys)
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache invalidate failed for pattern {pattern}: {e}")
|
||||
return 0
|
||||
|
||||
async def ping(self) -> bool:
|
||||
"""Check if Redis is connected and responding."""
|
||||
if not self._enabled or not self._redis:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self._redis.ping()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Check if caching is enabled."""
|
||||
return self._enabled
|
||||
374
backend/tests/unit/test_cache_service.py
Normal file
374
backend/tests/unit/test_cache_service.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""Tests for CacheService."""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
|
||||
class TestCacheCategory:
|
||||
"""Tests for cache category enum."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_immutable_categories_have_no_ttl(self):
|
||||
"""Immutable categories should return None for TTL."""
|
||||
from app.cache_service import CacheCategory, get_category_ttl
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
|
||||
assert get_category_ttl(CacheCategory.ARTIFACT_METADATA, settings) is None
|
||||
assert get_category_ttl(CacheCategory.ARTIFACT_DEPENDENCIES, settings) is None
|
||||
assert get_category_ttl(CacheCategory.DEPENDENCY_RESOLUTION, settings) is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_mutable_categories_have_ttl(self):
|
||||
"""Mutable categories should return configured TTL."""
|
||||
from app.cache_service import CacheCategory, get_category_ttl
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
cache_ttl_index=300,
|
||||
cache_ttl_upstream=3600,
|
||||
)
|
||||
|
||||
assert get_category_ttl(CacheCategory.PACKAGE_INDEX, settings) == 300
|
||||
assert get_category_ttl(CacheCategory.UPSTREAM_SOURCES, settings) == 3600
|
||||
|
||||
|
||||
class TestCacheService:
|
||||
"""Tests for Redis cache service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_disabled_cache_returns_none(self):
|
||||
"""When Redis disabled, get() should return None."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=False)
|
||||
cache = CacheService(settings)
|
||||
await cache.startup()
|
||||
|
||||
result = await cache.get(CacheCategory.PACKAGE_INDEX, "test-key")
|
||||
|
||||
assert result is None
|
||||
await cache.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_disabled_cache_set_is_noop(self):
|
||||
"""When Redis disabled, set() should be a no-op."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=False)
|
||||
cache = CacheService(settings)
|
||||
await cache.startup()
|
||||
|
||||
# Should not raise
|
||||
await cache.set(CacheCategory.PACKAGE_INDEX, "test-key", b"test-value")
|
||||
|
||||
await cache.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_cache_key_namespacing(self):
|
||||
"""Cache keys should be properly namespaced."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
|
||||
key = CacheService._make_key(CacheCategory.PACKAGE_INDEX, "pypi", "numpy")
|
||||
|
||||
assert key == "orchard:index:pypi:numpy"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_ping_returns_false_when_disabled(self):
|
||||
"""ping() should return False when Redis is disabled."""
|
||||
from app.cache_service import CacheService
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=False)
|
||||
cache = CacheService(settings)
|
||||
await cache.startup()
|
||||
|
||||
result = await cache.ping()
|
||||
|
||||
assert result is False
|
||||
await cache.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_enabled_property(self):
|
||||
"""enabled property should reflect Redis state."""
|
||||
from app.cache_service import CacheService
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=False)
|
||||
cache = CacheService(settings)
|
||||
|
||||
assert cache.enabled is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_delete_is_noop_when_disabled(self):
|
||||
"""delete() should be a no-op when Redis is disabled."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=False)
|
||||
cache = CacheService(settings)
|
||||
await cache.startup()
|
||||
|
||||
# Should not raise
|
||||
await cache.delete(CacheCategory.PACKAGE_INDEX, "test-key")
|
||||
|
||||
await cache.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_invalidate_pattern_returns_zero_when_disabled(self):
|
||||
"""invalidate_pattern() should return 0 when Redis is disabled."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=False)
|
||||
cache = CacheService(settings)
|
||||
await cache.startup()
|
||||
|
||||
result = await cache.invalidate_pattern(CacheCategory.PACKAGE_INDEX)
|
||||
|
||||
assert result == 0
|
||||
await cache.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_startup_already_started(self):
|
||||
"""startup() should be idempotent."""
|
||||
from app.cache_service import CacheService
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=False)
|
||||
cache = CacheService(settings)
|
||||
await cache.startup()
|
||||
await cache.startup() # Should not raise
|
||||
|
||||
assert cache._started is True
|
||||
await cache.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_shutdown_not_started(self):
|
||||
"""shutdown() should handle not-started state."""
|
||||
from app.cache_service import CacheService
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=False)
|
||||
cache = CacheService(settings)
|
||||
|
||||
# Should not raise
|
||||
await cache.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_make_key_with_default_protocol(self):
|
||||
"""_make_key should work with default protocol."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
|
||||
key = CacheService._make_key(CacheCategory.ARTIFACT_METADATA, "default", "abc123")
|
||||
|
||||
assert key == "orchard:artifact:default:abc123"
|
||||
|
||||
|
||||
class TestCacheServiceWithMockedRedis:
|
||||
"""Tests for CacheService with mocked Redis client."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_get_returns_cached_value(self):
|
||||
"""get() should return cached value when available."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=True)
|
||||
cache = CacheService(settings)
|
||||
|
||||
# Mock the redis client
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get.return_value = b"cached-data"
|
||||
cache._redis = mock_redis
|
||||
cache._enabled = True
|
||||
cache._started = True
|
||||
|
||||
result = await cache.get(CacheCategory.PACKAGE_INDEX, "test-key", "pypi")
|
||||
|
||||
assert result == b"cached-data"
|
||||
mock_redis.get.assert_called_once_with("orchard:index:pypi:test-key")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_set_with_ttl(self):
|
||||
"""set() should use setex for mutable categories."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=True, cache_ttl_index=300)
|
||||
cache = CacheService(settings)
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
cache._redis = mock_redis
|
||||
cache._enabled = True
|
||||
cache._started = True
|
||||
|
||||
await cache.set(CacheCategory.PACKAGE_INDEX, "test-key", b"test-value", "pypi")
|
||||
|
||||
mock_redis.setex.assert_called_once_with(
|
||||
"orchard:index:pypi:test-key", 300, b"test-value"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_set_without_ttl(self):
|
||||
"""set() should use set (no expiry) for immutable categories."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=True)
|
||||
cache = CacheService(settings)
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
cache._redis = mock_redis
|
||||
cache._enabled = True
|
||||
cache._started = True
|
||||
|
||||
await cache.set(
|
||||
CacheCategory.ARTIFACT_METADATA, "abc123", b"metadata", "pypi"
|
||||
)
|
||||
|
||||
mock_redis.set.assert_called_once_with(
|
||||
"orchard:artifact:pypi:abc123", b"metadata"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_delete_calls_redis_delete(self):
|
||||
"""delete() should call Redis delete."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=True)
|
||||
cache = CacheService(settings)
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
cache._redis = mock_redis
|
||||
cache._enabled = True
|
||||
cache._started = True
|
||||
|
||||
await cache.delete(CacheCategory.PACKAGE_INDEX, "test-key", "pypi")
|
||||
|
||||
mock_redis.delete.assert_called_once_with("orchard:index:pypi:test-key")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_invalidate_pattern_deletes_matching_keys(self):
|
||||
"""invalidate_pattern() should delete all matching keys."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=True)
|
||||
cache = CacheService(settings)
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
|
||||
# Create an async generator for scan_iter
|
||||
async def mock_scan_iter(match=None):
|
||||
for key in [b"orchard:index:pypi:numpy", b"orchard:index:pypi:requests"]:
|
||||
yield key
|
||||
|
||||
mock_redis.scan_iter = mock_scan_iter
|
||||
mock_redis.delete.return_value = 2
|
||||
cache._redis = mock_redis
|
||||
cache._enabled = True
|
||||
cache._started = True
|
||||
|
||||
result = await cache.invalidate_pattern(CacheCategory.PACKAGE_INDEX, "*", "pypi")
|
||||
|
||||
assert result == 2
|
||||
mock_redis.delete.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_ping_returns_true_when_connected(self):
|
||||
"""ping() should return True when Redis responds."""
|
||||
from app.cache_service import CacheService
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=True)
|
||||
cache = CacheService(settings)
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.ping.return_value = True
|
||||
cache._redis = mock_redis
|
||||
cache._enabled = True
|
||||
cache._started = True
|
||||
|
||||
result = await cache.ping()
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_get_handles_exception(self):
|
||||
"""get() should return None and log warning on exception."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=True)
|
||||
cache = CacheService(settings)
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get.side_effect = Exception("Connection lost")
|
||||
cache._redis = mock_redis
|
||||
cache._enabled = True
|
||||
cache._started = True
|
||||
|
||||
result = await cache.get(CacheCategory.PACKAGE_INDEX, "test-key")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_set_handles_exception(self):
|
||||
"""set() should log warning on exception."""
|
||||
from app.cache_service import CacheService, CacheCategory
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=True, cache_ttl_index=300)
|
||||
cache = CacheService(settings)
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.setex.side_effect = Exception("Connection lost")
|
||||
cache._redis = mock_redis
|
||||
cache._enabled = True
|
||||
cache._started = True
|
||||
|
||||
# Should not raise
|
||||
await cache.set(CacheCategory.PACKAGE_INDEX, "test-key", b"value")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_ping_returns_false_on_exception(self):
|
||||
"""ping() should return False on exception."""
|
||||
from app.cache_service import CacheService
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(redis_enabled=True)
|
||||
cache = CacheService(settings)
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.ping.side_effect = Exception("Connection lost")
|
||||
cache._redis = mock_redis
|
||||
cache._enabled = True
|
||||
cache._started = True
|
||||
|
||||
result = await cache.ping()
|
||||
|
||||
assert result is False
|
||||
|
||||
Reference in New Issue
Block a user