Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f848c4878 | ||
|
|
7297e6b9f1 | ||
|
|
2552bfd1f9 |
@@ -5,6 +5,40 @@ All notable changes to Library Desk will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.4.2] - 2025-12-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Volatile Cache System** - Ephemeral data storage with TTL
|
||||||
|
- `GET /volatile/{namespace}/{key}` - Retrieve cached record
|
||||||
|
- `POST /volatile/{namespace}/{key}` - Store/update record with TTL
|
||||||
|
- `DELETE /volatile/{namespace}/{key}` - Remove record
|
||||||
|
- `GET /volatile/{namespace}` - List keys in namespace
|
||||||
|
- `DELETE /volatile/{namespace}` - Clear all records in namespace
|
||||||
|
- `GET /volatile/stats` - Cache statistics by namespace
|
||||||
|
- `GET /volatile/scheduled` - Records needing refresh (for scheduler)
|
||||||
|
- `GET /volatile/namespaces` - List available namespaces with default TTLs
|
||||||
|
- **Volatile Namespaces** - Predefined categories with appropriate TTLs:
|
||||||
|
- `weather` (30min) - Weather conditions and forecasts
|
||||||
|
- `news` (1hr) - Headlines and breaking news
|
||||||
|
- `financial` (5min) - Stock prices, exchange rates
|
||||||
|
- `transit` (5min) - Train/bus schedules, delays
|
||||||
|
- `traffic` (10min) - Commute times, road conditions
|
||||||
|
- `air_quality` (1hr) - Pollution, pollen counts
|
||||||
|
- `sports` (1min) - Live scores, matches
|
||||||
|
- `social` (10min) - Social notifications
|
||||||
|
- `system` (1min) - Service health status
|
||||||
|
- `context` (1hr) - Session state
|
||||||
|
- `custom` (1hr) - User-defined data
|
||||||
|
- **Refresh Schedule Support** - Optional cron expressions for scheduler integration
|
||||||
|
|
||||||
|
## [1.4.1] - 2025-12-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Wiki.js API token now optional - GraphQL API works without authentication
|
||||||
|
- Container startup failure when `WIKI_GRAPHQL_API` env var not set
|
||||||
|
|
||||||
## [1.4.0] - 2025-12-24
|
## [1.4.0] - 2025-12-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "library-desk"
|
name = "library-desk"
|
||||||
version = "1.4.0"
|
version = "1.4.2"
|
||||||
description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation"
|
description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -35,16 +35,19 @@ class WikiJSClient:
|
|||||||
self.graphql_url = f"{self.base_url}/graphql"
|
self.graphql_url = f"{self.base_url}/graphql"
|
||||||
self.api_token = api_token
|
self.api_token = api_token
|
||||||
self.client = httpx.AsyncClient(timeout=30.0)
|
self.client = httpx.AsyncClient(timeout=30.0)
|
||||||
logger.info(f"Initialized Wiki.js client: {base_url} (using API token)")
|
auth_mode = "with API token" if api_token else "without auth (open API)"
|
||||||
|
logger.info(f"Initialized Wiki.js client: {base_url} ({auth_mode})")
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""Close HTTP client"""
|
"""Close HTTP client"""
|
||||||
await self.client.aclose()
|
await self.client.aclose()
|
||||||
|
|
||||||
def _ensure_authenticated(self):
|
def _get_headers(self) -> Dict[str, str]:
|
||||||
"""Verify API token is configured."""
|
"""Get request headers, optionally including auth token."""
|
||||||
if not self.api_token:
|
headers = {"Content-Type": "application/json"}
|
||||||
raise Exception("Wiki.js API token not configured")
|
if self.api_token:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
async def _execute_query(
|
async def _execute_query(
|
||||||
self,
|
self,
|
||||||
@@ -64,18 +67,12 @@ class WikiJSClient:
|
|||||||
Raises:
|
Raises:
|
||||||
Exception: If query fails or returns errors
|
Exception: If query fails or returns errors
|
||||||
"""
|
"""
|
||||||
# Ensure API token is configured
|
|
||||||
self._ensure_authenticated()
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"query": query,
|
"query": query,
|
||||||
"variables": variables or {}
|
"variables": variables or {}
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = {
|
headers = self._get_headers()
|
||||||
"Authorization": f"Bearer {self.api_token}",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await self.client.post(
|
response = await self.client.post(
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Wiki.js Configuration
|
# Wiki.js Configuration
|
||||||
wikijs_url: str = Field(default="http://wiki:3000", description="Wiki.js URL")
|
wikijs_url: str = Field(default="http://wiki:3000", description="Wiki.js URL")
|
||||||
wiki_graphql_api: str = Field(..., description="Wiki.js GraphQL API token (JWT)")
|
wiki_graphql_api: str = Field(default="", description="Wiki.js GraphQL API token (optional - API may be open)")
|
||||||
# Legacy auth fields - kept for backwards compatibility but deprecated
|
# Legacy auth fields - kept for backwards compatibility but deprecated
|
||||||
wikijs_username: str = Field(default="", description="Wiki.js username (deprecated, use wiki_graphql_api)")
|
wikijs_username: str = Field(default="", description="Wiki.js username (deprecated, use wiki_graphql_api)")
|
||||||
wikijs_password: str = Field(default="", description="Wiki.js password (deprecated, use wiki_graphql_api)")
|
wikijs_password: str = Field(default="", description="Wiki.js password (deprecated, use wiki_graphql_api)")
|
||||||
|
|||||||
+2
-1
@@ -51,7 +51,7 @@ app.add_middleware(
|
|||||||
from src.routers import (
|
from src.routers import (
|
||||||
wiki, tools, graph, vector, hybrid_rag, consolidation,
|
wiki, tools, graph, vector, hybrid_rag, consolidation,
|
||||||
ingestion, entity_linking, webhooks, rag_search, content,
|
ingestion, entity_linking, webhooks, rag_search, content,
|
||||||
maintenance
|
maintenance, volatile
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(wiki.router)
|
app.include_router(wiki.router)
|
||||||
@@ -66,6 +66,7 @@ app.include_router(webhooks.router)
|
|||||||
app.include_router(rag_search.router)
|
app.include_router(rag_search.router)
|
||||||
app.include_router(content.router)
|
app.include_router(content.router)
|
||||||
app.include_router(maintenance.router)
|
app.include_router(maintenance.router)
|
||||||
|
app.include_router(volatile.router)
|
||||||
|
|
||||||
# Mount static files directory for Wiki.js integration scripts
|
# Mount static files directory for Wiki.js integration scripts
|
||||||
static_dir = Path(__file__).parent.parent / "static"
|
static_dir = Path(__file__).parent.parent / "static"
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
Volatile memory models for Library Desk.
|
||||||
|
|
||||||
|
Provides models for ephemeral cached data with TTL - weather, news, financial data,
|
||||||
|
transit schedules, and other time-sensitive external information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class VolatileNamespace(str, Enum):
|
||||||
|
"""
|
||||||
|
Predefined namespaces for volatile data.
|
||||||
|
|
||||||
|
Each namespace can have different default TTLs and refresh schedules.
|
||||||
|
"""
|
||||||
|
# Real-time external data
|
||||||
|
WEATHER = "weather" # Current conditions, forecasts
|
||||||
|
NEWS = "news" # Headlines, breaking news
|
||||||
|
FINANCIAL = "financial" # Stock prices, exchange rates, crypto
|
||||||
|
TRANSIT = "transit" # Train/bus schedules, delays, disruptions
|
||||||
|
TRAFFIC = "traffic" # Commute times, road conditions
|
||||||
|
AIR_QUALITY = "air_quality" # Pollution levels, pollen counts
|
||||||
|
SPORTS = "sports" # Live scores, upcoming matches
|
||||||
|
|
||||||
|
# System/integration data
|
||||||
|
SOCIAL = "social" # Social media mentions, notifications
|
||||||
|
SYSTEM = "system" # Service health, infrastructure status
|
||||||
|
|
||||||
|
# Ephemeral context
|
||||||
|
CONTEXT = "context" # Conversation context, session state
|
||||||
|
CUSTOM = "custom" # User-defined volatile data
|
||||||
|
|
||||||
|
|
||||||
|
# Default TTLs per namespace (in seconds)
|
||||||
|
NAMESPACE_DEFAULT_TTL: Dict[str, int] = {
|
||||||
|
VolatileNamespace.WEATHER: 1800, # 30 min - weather changes slowly
|
||||||
|
VolatileNamespace.NEWS: 3600, # 1 hour - news cycles
|
||||||
|
VolatileNamespace.FINANCIAL: 300, # 5 min - markets move fast
|
||||||
|
VolatileNamespace.TRANSIT: 300, # 5 min - schedules update frequently
|
||||||
|
VolatileNamespace.TRAFFIC: 600, # 10 min - traffic patterns
|
||||||
|
VolatileNamespace.AIR_QUALITY: 3600, # 1 hour - air quality stable
|
||||||
|
VolatileNamespace.SPORTS: 60, # 1 min - live scores
|
||||||
|
VolatileNamespace.SOCIAL: 600, # 10 min - social notifications
|
||||||
|
VolatileNamespace.SYSTEM: 60, # 1 min - system health
|
||||||
|
VolatileNamespace.CONTEXT: 3600, # 1 hour - session context
|
||||||
|
VolatileNamespace.CUSTOM: 3600, # 1 hour - default for custom
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VolatileRecord(BaseModel):
|
||||||
|
"""
|
||||||
|
A volatile cache record with TTL.
|
||||||
|
|
||||||
|
Volatile records are ephemeral data stored in Redis with automatic expiration.
|
||||||
|
Used for weather, news, financial data, and other time-sensitive information.
|
||||||
|
"""
|
||||||
|
key: str = Field(..., description="Record key (e.g., 'rotterdam', 'nos-headlines')")
|
||||||
|
namespace: str = Field(..., description="Namespace (e.g., 'weather', 'news', 'financial')")
|
||||||
|
data: Dict[str, Any] = Field(..., description="Actual content/payload")
|
||||||
|
source: Optional[str] = Field(None, description="Origin API/service (e.g., 'openweathermap', 'nos.nl')")
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow, description="When record was created")
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow, description="When record was last updated")
|
||||||
|
ttl: int = Field(..., ge=60, le=604800, description="Time-to-live in seconds (max 7 days)")
|
||||||
|
refresh_schedule: Optional[str] = Field(None, description="Cron expression for scheduled refresh")
|
||||||
|
user: str = Field(..., description="User identifier for multi-tenancy")
|
||||||
|
|
||||||
|
|
||||||
|
class VolatileRecordCreate(BaseModel):
|
||||||
|
"""Request model for creating/updating a volatile record."""
|
||||||
|
data: Dict[str, Any] = Field(..., description="Content to store")
|
||||||
|
source: Optional[str] = Field(None, description="Origin API/service")
|
||||||
|
ttl: Optional[int] = Field(None, ge=60, le=604800, description="TTL in seconds (uses namespace default if not set)")
|
||||||
|
refresh_schedule: Optional[str] = Field(None, description="Cron expression for scheduled refresh")
|
||||||
|
|
||||||
|
|
||||||
|
class VolatileRecordResponse(BaseModel):
|
||||||
|
"""Response model for a volatile record."""
|
||||||
|
key: str = Field(..., description="Record key")
|
||||||
|
namespace: str = Field(..., description="Namespace")
|
||||||
|
data: Dict[str, Any] = Field(..., description="Stored content")
|
||||||
|
source: Optional[str] = Field(None, description="Origin API/service")
|
||||||
|
created_at: datetime = Field(..., description="Creation timestamp")
|
||||||
|
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||||
|
ttl: int = Field(..., description="TTL in seconds")
|
||||||
|
ttl_remaining: int = Field(..., description="Seconds until expiration")
|
||||||
|
refresh_schedule: Optional[str] = Field(None, description="Cron expression if scheduled")
|
||||||
|
user: str = Field(..., description="User identifier")
|
||||||
|
|
||||||
|
|
||||||
|
class VolatileListResponse(BaseModel):
|
||||||
|
"""Response model for listing volatile records."""
|
||||||
|
namespace: str = Field(..., description="Namespace queried")
|
||||||
|
keys: List[str] = Field(..., description="List of keys in namespace")
|
||||||
|
count: int = Field(..., description="Number of keys")
|
||||||
|
user: str = Field(..., description="User identifier")
|
||||||
|
|
||||||
|
|
||||||
|
class VolatileScheduledResponse(BaseModel):
|
||||||
|
"""Response model for records needing refresh."""
|
||||||
|
records: List[VolatileRecordResponse] = Field(..., description="Records with refresh schedules")
|
||||||
|
count: int = Field(..., description="Number of scheduled records")
|
||||||
|
user: str = Field(..., description="User identifier")
|
||||||
|
|
||||||
|
|
||||||
|
class VolatileStatsResponse(BaseModel):
|
||||||
|
"""Response model for volatile cache statistics."""
|
||||||
|
total_records: int = Field(..., description="Total volatile records for user")
|
||||||
|
by_namespace: Dict[str, int] = Field(..., description="Record count per namespace")
|
||||||
|
scheduled_count: int = Field(..., description="Records with refresh schedules")
|
||||||
|
total_memory_bytes: Optional[int] = Field(None, description="Approximate memory usage")
|
||||||
|
user: str = Field(..., description="User identifier")
|
||||||
|
|
||||||
|
|
||||||
|
class VolatileDeleteResponse(BaseModel):
|
||||||
|
"""Response model for delete operation."""
|
||||||
|
key: str = Field(..., description="Deleted key")
|
||||||
|
namespace: str = Field(..., description="Namespace")
|
||||||
|
deleted: bool = Field(..., description="Whether record was found and deleted")
|
||||||
|
user: str = Field(..., description="User identifier")
|
||||||
|
|
||||||
|
|
||||||
|
class VolatileBulkDeleteResponse(BaseModel):
|
||||||
|
"""Response model for bulk delete operations."""
|
||||||
|
namespace: Optional[str] = Field(None, description="Namespace if namespace-wide delete")
|
||||||
|
deleted_count: int = Field(..., description="Number of records deleted")
|
||||||
|
user: str = Field(..., description="User identifier")
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""
|
||||||
|
Volatile cache router for Library Desk API.
|
||||||
|
|
||||||
|
Endpoints for ephemeral cached data with TTL - weather, news, financial, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from src.models.volatile import (
|
||||||
|
VolatileRecordCreate,
|
||||||
|
VolatileRecordResponse,
|
||||||
|
VolatileListResponse,
|
||||||
|
VolatileScheduledResponse,
|
||||||
|
VolatileStatsResponse,
|
||||||
|
VolatileDeleteResponse,
|
||||||
|
VolatileBulkDeleteResponse,
|
||||||
|
VolatileNamespace,
|
||||||
|
)
|
||||||
|
from src.services.volatile_service import VolatileCacheService
|
||||||
|
from src.core.dependencies import verify_api_key, RedisDep
|
||||||
|
from src.core.multi_tenancy import DEFAULT_USER
|
||||||
|
from src.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/volatile", tags=["Volatile Cache"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_volatile_service(redis: RedisDep) -> VolatileCacheService:
|
||||||
|
"""Get volatile cache service instance."""
|
||||||
|
settings = get_settings()
|
||||||
|
return VolatileCacheService(redis_client=redis, settings=settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=VolatileStatsResponse)
|
||||||
|
async def get_stats(
|
||||||
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||||
|
redis: RedisDep = None,
|
||||||
|
api_key: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get volatile cache statistics.
|
||||||
|
|
||||||
|
Returns counts of records by namespace and scheduled refresh info.
|
||||||
|
"""
|
||||||
|
service = get_volatile_service(redis)
|
||||||
|
stats = await service.get_stats(user)
|
||||||
|
|
||||||
|
return VolatileStatsResponse(
|
||||||
|
total_records=stats["total_records"],
|
||||||
|
by_namespace=stats["by_namespace"],
|
||||||
|
scheduled_count=stats["scheduled_count"],
|
||||||
|
total_memory_bytes=stats.get("total_memory_bytes"),
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scheduled", response_model=VolatileScheduledResponse)
|
||||||
|
async def get_scheduled(
|
||||||
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||||
|
redis: RedisDep = None,
|
||||||
|
api_key: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get records with refresh schedules.
|
||||||
|
|
||||||
|
Used by scheduler to determine what volatile data needs refreshing.
|
||||||
|
Returns all records that have a refresh_schedule cron expression set.
|
||||||
|
"""
|
||||||
|
service = get_volatile_service(redis)
|
||||||
|
records = await service.get_scheduled(user)
|
||||||
|
|
||||||
|
return VolatileScheduledResponse(
|
||||||
|
records=records,
|
||||||
|
count=len(records),
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/namespaces")
|
||||||
|
async def list_namespaces(
|
||||||
|
api_key: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List available namespaces and their default TTLs.
|
||||||
|
|
||||||
|
Returns predefined namespaces with their default TTL values.
|
||||||
|
Custom namespaces can also be used with the default TTL.
|
||||||
|
"""
|
||||||
|
from src.models.volatile import NAMESPACE_DEFAULT_TTL
|
||||||
|
|
||||||
|
return {
|
||||||
|
"namespaces": [
|
||||||
|
{
|
||||||
|
"name": ns.value,
|
||||||
|
"default_ttl": NAMESPACE_DEFAULT_TTL.get(ns, 3600),
|
||||||
|
"description": _get_namespace_description(ns),
|
||||||
|
}
|
||||||
|
for ns in VolatileNamespace
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_namespace_description(ns: VolatileNamespace) -> str:
|
||||||
|
"""Get human-readable description for namespace."""
|
||||||
|
descriptions = {
|
||||||
|
VolatileNamespace.WEATHER: "Weather conditions and forecasts",
|
||||||
|
VolatileNamespace.NEWS: "Headlines and breaking news",
|
||||||
|
VolatileNamespace.FINANCIAL: "Stock prices, exchange rates, crypto",
|
||||||
|
VolatileNamespace.TRANSIT: "Train/bus schedules, delays",
|
||||||
|
VolatileNamespace.TRAFFIC: "Commute times, road conditions",
|
||||||
|
VolatileNamespace.AIR_QUALITY: "Pollution levels, pollen counts",
|
||||||
|
VolatileNamespace.SPORTS: "Live scores, upcoming matches",
|
||||||
|
VolatileNamespace.SOCIAL: "Social media mentions, notifications",
|
||||||
|
VolatileNamespace.SYSTEM: "Service health, infrastructure status",
|
||||||
|
VolatileNamespace.CONTEXT: "Conversation context, session state",
|
||||||
|
VolatileNamespace.CUSTOM: "User-defined volatile data",
|
||||||
|
}
|
||||||
|
return descriptions.get(ns, "Custom namespace")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{namespace}", response_model=VolatileListResponse)
|
||||||
|
async def list_keys(
|
||||||
|
namespace: str,
|
||||||
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||||
|
redis: RedisDep = None,
|
||||||
|
api_key: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all keys in a namespace.
|
||||||
|
|
||||||
|
Returns the list of keys stored in the specified namespace.
|
||||||
|
"""
|
||||||
|
service = get_volatile_service(redis)
|
||||||
|
keys = await service.list_namespace(user, namespace)
|
||||||
|
|
||||||
|
return VolatileListResponse(
|
||||||
|
namespace=namespace,
|
||||||
|
keys=keys,
|
||||||
|
count=len(keys),
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{namespace}", response_model=VolatileBulkDeleteResponse)
|
||||||
|
async def delete_namespace(
|
||||||
|
namespace: str,
|
||||||
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||||
|
redis: RedisDep = None,
|
||||||
|
api_key: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete all records in a namespace.
|
||||||
|
|
||||||
|
Removes all volatile data for the specified namespace.
|
||||||
|
"""
|
||||||
|
service = get_volatile_service(redis)
|
||||||
|
deleted = await service.delete_namespace(user, namespace)
|
||||||
|
|
||||||
|
return VolatileBulkDeleteResponse(
|
||||||
|
namespace=namespace,
|
||||||
|
deleted_count=deleted,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{namespace}/{key}", response_model=VolatileRecordResponse)
|
||||||
|
async def get_record(
|
||||||
|
namespace: str,
|
||||||
|
key: str,
|
||||||
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||||
|
redis: RedisDep = None,
|
||||||
|
api_key: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a volatile record.
|
||||||
|
|
||||||
|
Returns the record if it exists and has not expired.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
GET /volatile/weather/rotterdam?user=jpmschweitzer
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
service = get_volatile_service(redis)
|
||||||
|
record = await service.get(user, namespace, key)
|
||||||
|
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Record '{key}' not found in namespace '{namespace}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{namespace}/{key}", response_model=VolatileRecordResponse)
|
||||||
|
async def set_record(
|
||||||
|
namespace: str,
|
||||||
|
key: str,
|
||||||
|
request: VolatileRecordCreate,
|
||||||
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||||
|
redis: RedisDep = None,
|
||||||
|
api_key: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Store or update a volatile record.
|
||||||
|
|
||||||
|
Creates or updates a record with the specified TTL.
|
||||||
|
If TTL is not provided, the namespace default is used.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"temperature": 18,
|
||||||
|
"conditions": "Partly cloudy",
|
||||||
|
"humidity": 65
|
||||||
|
},
|
||||||
|
"source": "openweathermap",
|
||||||
|
"ttl": 1800,
|
||||||
|
"refresh_schedule": "0 * * * *"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Refresh Schedule:**
|
||||||
|
Optional cron expression for automatic refresh. The scheduler
|
||||||
|
will query `/volatile/scheduled` and trigger refreshes.
|
||||||
|
"""
|
||||||
|
service = get_volatile_service(redis)
|
||||||
|
|
||||||
|
try:
|
||||||
|
record = await service.set(
|
||||||
|
user=user,
|
||||||
|
namespace=namespace,
|
||||||
|
key=key,
|
||||||
|
data=request.data,
|
||||||
|
source=request.source,
|
||||||
|
ttl=request.ttl,
|
||||||
|
refresh_schedule=request.refresh_schedule,
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to store volatile record: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to store record")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{namespace}/{key}", response_model=VolatileDeleteResponse)
|
||||||
|
async def delete_record(
|
||||||
|
namespace: str,
|
||||||
|
key: str,
|
||||||
|
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||||
|
redis: RedisDep = None,
|
||||||
|
api_key: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a volatile record.
|
||||||
|
|
||||||
|
Removes the record from the cache.
|
||||||
|
"""
|
||||||
|
service = get_volatile_service(redis)
|
||||||
|
deleted = await service.delete(user, namespace, key)
|
||||||
|
|
||||||
|
return VolatileDeleteResponse(
|
||||||
|
key=key,
|
||||||
|
namespace=namespace,
|
||||||
|
deleted=deleted,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
"""
|
||||||
|
Volatile Cache service for Library Desk.
|
||||||
|
|
||||||
|
Provides ephemeral data storage with TTL for time-sensitive information:
|
||||||
|
- Weather, news, financial data
|
||||||
|
- Transit schedules, traffic conditions
|
||||||
|
- System status, social notifications
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from src.config import Settings
|
||||||
|
from src.models.volatile import (
|
||||||
|
VolatileRecord,
|
||||||
|
VolatileRecordResponse,
|
||||||
|
VolatileNamespace,
|
||||||
|
NAMESPACE_DEFAULT_TTL,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VolatileCacheService:
|
||||||
|
"""
|
||||||
|
Service for volatile data with TTL.
|
||||||
|
|
||||||
|
Stores ephemeral data in Redis with automatic expiration.
|
||||||
|
Supports multiple namespaces with configurable TTLs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Redis key prefix for volatile data
|
||||||
|
KEY_PREFIX = "volatile"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
redis_client: aioredis.Redis,
|
||||||
|
settings: Settings
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize volatile cache service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
redis_client: Async Redis client
|
||||||
|
settings: Application settings
|
||||||
|
"""
|
||||||
|
self.redis = redis_client
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
logger.info("Initialized VolatileCacheService")
|
||||||
|
|
||||||
|
def _build_key(self, user: str, namespace: str, key: str) -> str:
|
||||||
|
"""
|
||||||
|
Build Redis key for volatile record.
|
||||||
|
|
||||||
|
Pattern: {user}:volatile:{namespace}:{key_hash}
|
||||||
|
Uses hash to ensure safe key characters and consistent length.
|
||||||
|
"""
|
||||||
|
key_hash = hashlib.md5(key.encode()).hexdigest()[:12]
|
||||||
|
return f"{user}:{self.KEY_PREFIX}:{namespace}:{key_hash}"
|
||||||
|
|
||||||
|
def _build_pattern(self, user: str, namespace: Optional[str] = None) -> str:
|
||||||
|
"""Build pattern for key scanning."""
|
||||||
|
if namespace:
|
||||||
|
return f"{user}:{self.KEY_PREFIX}:{namespace}:*"
|
||||||
|
return f"{user}:{self.KEY_PREFIX}:*"
|
||||||
|
|
||||||
|
def _get_default_ttl(self, namespace: str) -> int:
|
||||||
|
"""Get default TTL for a namespace."""
|
||||||
|
try:
|
||||||
|
ns = VolatileNamespace(namespace)
|
||||||
|
return NAMESPACE_DEFAULT_TTL.get(ns, self.settings.volatile_default_ttl)
|
||||||
|
except ValueError:
|
||||||
|
return self.settings.volatile_default_ttl
|
||||||
|
|
||||||
|
def _serialize_record(self, record: VolatileRecord) -> str:
|
||||||
|
"""Serialize record to JSON for storage."""
|
||||||
|
return json.dumps({
|
||||||
|
"key": record.key,
|
||||||
|
"namespace": record.namespace,
|
||||||
|
"data": record.data,
|
||||||
|
"source": record.source,
|
||||||
|
"created_at": record.created_at.isoformat(),
|
||||||
|
"updated_at": record.updated_at.isoformat(),
|
||||||
|
"ttl": record.ttl,
|
||||||
|
"refresh_schedule": record.refresh_schedule,
|
||||||
|
"user": record.user,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _deserialize_record(self, data: str) -> VolatileRecord:
|
||||||
|
"""Deserialize record from JSON."""
|
||||||
|
obj = json.loads(data)
|
||||||
|
return VolatileRecord(
|
||||||
|
key=obj["key"],
|
||||||
|
namespace=obj["namespace"],
|
||||||
|
data=obj["data"],
|
||||||
|
source=obj.get("source"),
|
||||||
|
created_at=datetime.fromisoformat(obj["created_at"]),
|
||||||
|
updated_at=datetime.fromisoformat(obj["updated_at"]),
|
||||||
|
ttl=obj["ttl"],
|
||||||
|
refresh_schedule=obj.get("refresh_schedule"),
|
||||||
|
user=obj["user"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
namespace: str,
|
||||||
|
key: str
|
||||||
|
) -> Optional[VolatileRecordResponse]:
|
||||||
|
"""
|
||||||
|
Get a volatile record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
namespace: Data namespace
|
||||||
|
key: Record key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Record if found and not expired, None otherwise
|
||||||
|
"""
|
||||||
|
redis_key = self._build_key(user, namespace, key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await self.redis.get(redis_key)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
record = self._deserialize_record(data)
|
||||||
|
|
||||||
|
# Get TTL remaining
|
||||||
|
ttl_remaining = await self.redis.ttl(redis_key)
|
||||||
|
if ttl_remaining < 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return VolatileRecordResponse(
|
||||||
|
key=record.key,
|
||||||
|
namespace=record.namespace,
|
||||||
|
data=record.data,
|
||||||
|
source=record.source,
|
||||||
|
created_at=record.created_at,
|
||||||
|
updated_at=record.updated_at,
|
||||||
|
ttl=record.ttl,
|
||||||
|
ttl_remaining=max(0, ttl_remaining),
|
||||||
|
refresh_schedule=record.refresh_schedule,
|
||||||
|
user=record.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get volatile record {redis_key}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
namespace: str,
|
||||||
|
key: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
source: Optional[str] = None,
|
||||||
|
ttl: Optional[int] = None,
|
||||||
|
refresh_schedule: Optional[str] = None
|
||||||
|
) -> VolatileRecordResponse:
|
||||||
|
"""
|
||||||
|
Store or update a volatile record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
namespace: Data namespace
|
||||||
|
key: Record key
|
||||||
|
data: Content to store
|
||||||
|
source: Origin API/service
|
||||||
|
ttl: TTL in seconds (uses namespace default if not set)
|
||||||
|
refresh_schedule: Optional cron expression for refresh
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The stored record
|
||||||
|
"""
|
||||||
|
redis_key = self._build_key(user, namespace, key)
|
||||||
|
|
||||||
|
# Use provided TTL or namespace default
|
||||||
|
effective_ttl = ttl if ttl is not None else self._get_default_ttl(namespace)
|
||||||
|
|
||||||
|
# Check if record exists (for created_at)
|
||||||
|
existing = await self.get(user, namespace, key)
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
record = VolatileRecord(
|
||||||
|
key=key,
|
||||||
|
namespace=namespace,
|
||||||
|
data=data,
|
||||||
|
source=source,
|
||||||
|
created_at=existing.created_at if existing else now,
|
||||||
|
updated_at=now,
|
||||||
|
ttl=effective_ttl,
|
||||||
|
refresh_schedule=refresh_schedule,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
serialized = self._serialize_record(record)
|
||||||
|
await self.redis.setex(redis_key, effective_ttl, serialized)
|
||||||
|
|
||||||
|
logger.debug(f"Stored volatile record {redis_key} with TTL {effective_ttl}s")
|
||||||
|
|
||||||
|
return VolatileRecordResponse(
|
||||||
|
key=record.key,
|
||||||
|
namespace=record.namespace,
|
||||||
|
data=record.data,
|
||||||
|
source=record.source,
|
||||||
|
created_at=record.created_at,
|
||||||
|
updated_at=record.updated_at,
|
||||||
|
ttl=record.ttl,
|
||||||
|
ttl_remaining=effective_ttl,
|
||||||
|
refresh_schedule=record.refresh_schedule,
|
||||||
|
user=record.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to store volatile record {redis_key}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def delete(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
namespace: str,
|
||||||
|
key: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a volatile record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
namespace: Data namespace
|
||||||
|
key: Record key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if record was deleted, False if not found
|
||||||
|
"""
|
||||||
|
redis_key = self._build_key(user, namespace, key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted = await self.redis.delete(redis_key)
|
||||||
|
if deleted:
|
||||||
|
logger.debug(f"Deleted volatile record {redis_key}")
|
||||||
|
return deleted > 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete volatile record {redis_key}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def list_namespace(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
namespace: str
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
List all keys in a namespace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
namespace: Data namespace
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of keys (original keys, not Redis keys)
|
||||||
|
"""
|
||||||
|
pattern = self._build_pattern(user, namespace)
|
||||||
|
|
||||||
|
try:
|
||||||
|
keys = []
|
||||||
|
async for redis_key in self.redis.scan_iter(match=pattern):
|
||||||
|
# Get the record to retrieve original key
|
||||||
|
data = await self.redis.get(redis_key)
|
||||||
|
if data:
|
||||||
|
record = self._deserialize_record(data)
|
||||||
|
keys.append(record.key)
|
||||||
|
|
||||||
|
return keys
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list namespace {namespace}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_scheduled(
|
||||||
|
self,
|
||||||
|
user: str
|
||||||
|
) -> List[VolatileRecordResponse]:
|
||||||
|
"""
|
||||||
|
Get all records with refresh schedules.
|
||||||
|
|
||||||
|
Used by scheduler to determine what needs refreshing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of records with refresh_schedule set
|
||||||
|
"""
|
||||||
|
pattern = self._build_pattern(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheduled = []
|
||||||
|
async for redis_key in self.redis.scan_iter(match=pattern):
|
||||||
|
data = await self.redis.get(redis_key)
|
||||||
|
if data:
|
||||||
|
record = self._deserialize_record(data)
|
||||||
|
if record.refresh_schedule:
|
||||||
|
ttl_remaining = await self.redis.ttl(redis_key)
|
||||||
|
scheduled.append(VolatileRecordResponse(
|
||||||
|
key=record.key,
|
||||||
|
namespace=record.namespace,
|
||||||
|
data=record.data,
|
||||||
|
source=record.source,
|
||||||
|
created_at=record.created_at,
|
||||||
|
updated_at=record.updated_at,
|
||||||
|
ttl=record.ttl,
|
||||||
|
ttl_remaining=max(0, ttl_remaining),
|
||||||
|
refresh_schedule=record.refresh_schedule,
|
||||||
|
user=record.user,
|
||||||
|
))
|
||||||
|
|
||||||
|
return scheduled
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get scheduled records: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_stats(
|
||||||
|
self,
|
||||||
|
user: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get cache statistics for user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Statistics dict
|
||||||
|
"""
|
||||||
|
pattern = self._build_pattern(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
by_namespace: Dict[str, int] = {}
|
||||||
|
total = 0
|
||||||
|
scheduled = 0
|
||||||
|
|
||||||
|
async for redis_key in self.redis.scan_iter(match=pattern):
|
||||||
|
data = await self.redis.get(redis_key)
|
||||||
|
if data:
|
||||||
|
record = self._deserialize_record(data)
|
||||||
|
total += 1
|
||||||
|
by_namespace[record.namespace] = by_namespace.get(record.namespace, 0) + 1
|
||||||
|
if record.refresh_schedule:
|
||||||
|
scheduled += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_records": total,
|
||||||
|
"by_namespace": by_namespace,
|
||||||
|
"scheduled_count": scheduled,
|
||||||
|
"total_memory_bytes": None, # Could implement with DEBUG MEMORY
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get stats: {e}")
|
||||||
|
return {
|
||||||
|
"total_records": 0,
|
||||||
|
"by_namespace": {},
|
||||||
|
"scheduled_count": 0,
|
||||||
|
"total_memory_bytes": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def delete_namespace(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
namespace: str
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Delete all records in a namespace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
namespace: Data namespace
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of records deleted
|
||||||
|
"""
|
||||||
|
pattern = self._build_pattern(user, namespace)
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted = 0
|
||||||
|
async for redis_key in self.redis.scan_iter(match=pattern):
|
||||||
|
await self.redis.delete(redis_key)
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
logger.info(f"Deleted {deleted} records from namespace {namespace}")
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete namespace {namespace}: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def delete_all(
|
||||||
|
self,
|
||||||
|
user: str
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Delete all volatile records for user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of records deleted
|
||||||
|
"""
|
||||||
|
pattern = self._build_pattern(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted = 0
|
||||||
|
async for redis_key in self.redis.scan_iter(match=pattern):
|
||||||
|
await self.redis.delete(redis_key)
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
logger.info(f"Deleted all {deleted} volatile records for user {user}")
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete all records: {e}")
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""
|
||||||
|
Tests for volatile cache router and service.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Volatile record CRUD operations
|
||||||
|
- Namespace listing and management
|
||||||
|
- Scheduled record retrieval
|
||||||
|
- TTL behavior
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from src.models.volatile import (
|
||||||
|
VolatileRecord,
|
||||||
|
VolatileRecordCreate,
|
||||||
|
VolatileRecordResponse,
|
||||||
|
VolatileListResponse,
|
||||||
|
VolatileScheduledResponse,
|
||||||
|
VolatileStatsResponse,
|
||||||
|
VolatileDeleteResponse,
|
||||||
|
VolatileBulkDeleteResponse,
|
||||||
|
VolatileNamespace,
|
||||||
|
NAMESPACE_DEFAULT_TTL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolatileModels:
|
||||||
|
"""Test volatile data models."""
|
||||||
|
|
||||||
|
def test_volatile_record_creation(self):
|
||||||
|
"""Test VolatileRecord model creation."""
|
||||||
|
record = VolatileRecord(
|
||||||
|
key="rotterdam",
|
||||||
|
namespace="weather",
|
||||||
|
data={"temperature": 18, "conditions": "Cloudy"},
|
||||||
|
source="openweathermap",
|
||||||
|
ttl=1800,
|
||||||
|
user="jpmschweitzer",
|
||||||
|
)
|
||||||
|
assert record.key == "rotterdam"
|
||||||
|
assert record.namespace == "weather"
|
||||||
|
assert record.data["temperature"] == 18
|
||||||
|
assert record.ttl == 1800
|
||||||
|
assert record.refresh_schedule is None
|
||||||
|
|
||||||
|
def test_volatile_record_with_schedule(self):
|
||||||
|
"""Test VolatileRecord with refresh schedule."""
|
||||||
|
record = VolatileRecord(
|
||||||
|
key="nos-headlines",
|
||||||
|
namespace="news",
|
||||||
|
data={"headlines": ["Test headline"]},
|
||||||
|
source="nos.nl",
|
||||||
|
ttl=3600,
|
||||||
|
refresh_schedule="0 * * * *",
|
||||||
|
user="jpmschweitzer",
|
||||||
|
)
|
||||||
|
assert record.refresh_schedule == "0 * * * *"
|
||||||
|
|
||||||
|
def test_volatile_record_create(self):
|
||||||
|
"""Test VolatileRecordCreate model."""
|
||||||
|
create = VolatileRecordCreate(
|
||||||
|
data={"price": 150.50, "change": 2.3},
|
||||||
|
source="alpha_vantage",
|
||||||
|
ttl=300,
|
||||||
|
)
|
||||||
|
assert create.data["price"] == 150.50
|
||||||
|
assert create.ttl == 300
|
||||||
|
|
||||||
|
def test_volatile_record_response(self):
|
||||||
|
"""Test VolatileRecordResponse model."""
|
||||||
|
response = VolatileRecordResponse(
|
||||||
|
key="rotterdam",
|
||||||
|
namespace="weather",
|
||||||
|
data={"temperature": 18},
|
||||||
|
source="openweathermap",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
ttl=1800,
|
||||||
|
ttl_remaining=1500,
|
||||||
|
user="jpmschweitzer",
|
||||||
|
)
|
||||||
|
assert response.ttl_remaining == 1500
|
||||||
|
assert response.ttl == 1800
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolatileNamespaces:
|
||||||
|
"""Test volatile namespaces and defaults."""
|
||||||
|
|
||||||
|
def test_all_namespaces_have_default_ttl(self):
|
||||||
|
"""Verify all namespaces have default TTLs defined."""
|
||||||
|
for ns in VolatileNamespace:
|
||||||
|
assert ns in NAMESPACE_DEFAULT_TTL, f"Missing TTL for {ns}"
|
||||||
|
assert NAMESPACE_DEFAULT_TTL[ns] > 0
|
||||||
|
|
||||||
|
def test_weather_default_ttl(self):
|
||||||
|
"""Test weather namespace default TTL."""
|
||||||
|
assert NAMESPACE_DEFAULT_TTL[VolatileNamespace.WEATHER] == 1800 # 30 min
|
||||||
|
|
||||||
|
def test_financial_default_ttl(self):
|
||||||
|
"""Test financial namespace default TTL."""
|
||||||
|
assert NAMESPACE_DEFAULT_TTL[VolatileNamespace.FINANCIAL] == 300 # 5 min
|
||||||
|
|
||||||
|
def test_sports_default_ttl(self):
|
||||||
|
"""Test sports namespace default TTL (fast updates)."""
|
||||||
|
assert NAMESPACE_DEFAULT_TTL[VolatileNamespace.SPORTS] == 60 # 1 min
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolatileListResponse:
|
||||||
|
"""Test list response models."""
|
||||||
|
|
||||||
|
def test_list_response(self):
|
||||||
|
"""Test VolatileListResponse model."""
|
||||||
|
response = VolatileListResponse(
|
||||||
|
namespace="weather",
|
||||||
|
keys=["rotterdam", "amsterdam", "utrecht"],
|
||||||
|
count=3,
|
||||||
|
user="jpmschweitzer",
|
||||||
|
)
|
||||||
|
assert response.count == 3
|
||||||
|
assert "rotterdam" in response.keys
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolatileScheduledResponse:
|
||||||
|
"""Test scheduled records response."""
|
||||||
|
|
||||||
|
def test_scheduled_response_empty(self):
|
||||||
|
"""Test empty scheduled response."""
|
||||||
|
response = VolatileScheduledResponse(
|
||||||
|
records=[],
|
||||||
|
count=0,
|
||||||
|
user="jpmschweitzer",
|
||||||
|
)
|
||||||
|
assert response.count == 0
|
||||||
|
assert response.records == []
|
||||||
|
|
||||||
|
def test_scheduled_response_with_records(self):
|
||||||
|
"""Test scheduled response with records."""
|
||||||
|
record = VolatileRecordResponse(
|
||||||
|
key="nos-headlines",
|
||||||
|
namespace="news",
|
||||||
|
data={"headlines": []},
|
||||||
|
source="nos.nl",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
ttl=3600,
|
||||||
|
ttl_remaining=3000,
|
||||||
|
refresh_schedule="0 */6 * * *",
|
||||||
|
user="jpmschweitzer",
|
||||||
|
)
|
||||||
|
response = VolatileScheduledResponse(
|
||||||
|
records=[record],
|
||||||
|
count=1,
|
||||||
|
user="jpmschweitzer",
|
||||||
|
)
|
||||||
|
assert response.count == 1
|
||||||
|
assert response.records[0].refresh_schedule == "0 */6 * * *"
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolatileStatsResponse:
|
||||||
|
"""Test stats response model."""
|
||||||
|
|
||||||
|
def test_stats_response(self):
|
||||||
|
"""Test VolatileStatsResponse model."""
|
||||||
|
response = VolatileStatsResponse(
|
||||||
|
total_records=15,
|
||||||
|
by_namespace={"weather": 3, "news": 5, "financial": 7},
|
||||||
|
scheduled_count=2,
|
||||||
|
total_memory_bytes=None,
|
||||||
|
user="jpmschweitzer",
|
||||||
|
)
|
||||||
|
assert response.total_records == 15
|
||||||
|
assert response.by_namespace["weather"] == 3
|
||||||
|
assert response.scheduled_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolatileDeleteResponses:
|
||||||
|
"""Test delete response models."""
|
||||||
|
|
||||||
|
def test_delete_response(self):
|
||||||
|
"""Test VolatileDeleteResponse model."""
|
||||||
|
response = VolatileDeleteResponse(
|
||||||
|
key="rotterdam",
|
||||||
|
namespace="weather",
|
||||||
|
deleted=True,
|
||||||
|
user="jpmschweitzer",
|
||||||
|
)
|
||||||
|
assert response.deleted is True
|
||||||
|
|
||||||
|
def test_delete_not_found(self):
|
||||||
|
"""Test delete response when record not found."""
|
||||||
|
response = VolatileDeleteResponse(
|
||||||
|
key="nonexistent",
|
||||||
|
namespace="weather",
|
||||||
|
deleted=False,
|
||||||
|
user="jpmschweitzer",
|
||||||
|
)
|
||||||
|
assert response.deleted is False
|
||||||
|
|
||||||
|
def test_bulk_delete_response(self):
|
||||||
|
"""Test VolatileBulkDeleteResponse model."""
|
||||||
|
response = VolatileBulkDeleteResponse(
|
||||||
|
namespace="weather",
|
||||||
|
deleted_count=5,
|
||||||
|
user="jpmschweitzer",
|
||||||
|
)
|
||||||
|
assert response.deleted_count == 5
|
||||||
|
assert response.namespace == "weather"
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolatileService:
|
||||||
|
"""Test VolatileCacheService functionality."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_redis(self):
|
||||||
|
"""Create mock Redis client."""
|
||||||
|
redis = AsyncMock()
|
||||||
|
redis.get = AsyncMock(return_value=None)
|
||||||
|
redis.setex = AsyncMock()
|
||||||
|
redis.delete = AsyncMock(return_value=1)
|
||||||
|
redis.ttl = AsyncMock(return_value=1500)
|
||||||
|
redis.scan_iter = MagicMock(return_value=iter([]))
|
||||||
|
return redis
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings(self):
|
||||||
|
"""Create mock settings."""
|
||||||
|
settings = MagicMock()
|
||||||
|
settings.volatile_default_ttl = 3600
|
||||||
|
return settings
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def volatile_service(self, mock_redis, mock_settings):
|
||||||
|
"""Create VolatileCacheService with mocks."""
|
||||||
|
from src.services.volatile_service import VolatileCacheService
|
||||||
|
return VolatileCacheService(
|
||||||
|
redis_client=mock_redis,
|
||||||
|
settings=mock_settings
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_build_key(self, volatile_service):
|
||||||
|
"""Test Redis key building."""
|
||||||
|
key = volatile_service._build_key("jpmschweitzer", "weather", "rotterdam")
|
||||||
|
assert key.startswith("jpmschweitzer:volatile:weather:")
|
||||||
|
assert len(key) > 30 # Has hash suffix
|
||||||
|
|
||||||
|
def test_build_pattern(self, volatile_service):
|
||||||
|
"""Test pattern building."""
|
||||||
|
pattern = volatile_service._build_pattern("jpmschweitzer", "weather")
|
||||||
|
assert pattern == "jpmschweitzer:volatile:weather:*"
|
||||||
|
|
||||||
|
def test_build_pattern_all(self, volatile_service):
|
||||||
|
"""Test pattern building for all namespaces."""
|
||||||
|
pattern = volatile_service._build_pattern("jpmschweitzer")
|
||||||
|
assert pattern == "jpmschweitzer:volatile:*"
|
||||||
|
|
||||||
|
def test_get_default_ttl_known_namespace(self, volatile_service):
|
||||||
|
"""Test default TTL for known namespace."""
|
||||||
|
ttl = volatile_service._get_default_ttl("weather")
|
||||||
|
assert ttl == 1800 # Weather namespace default
|
||||||
|
|
||||||
|
def test_get_default_ttl_unknown_namespace(self, volatile_service):
|
||||||
|
"""Test default TTL for unknown namespace."""
|
||||||
|
ttl = volatile_service._get_default_ttl("unknown_namespace")
|
||||||
|
assert ttl == 3600 # Falls back to settings default
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_not_found(self, volatile_service, mock_redis):
|
||||||
|
"""Test get when record not found."""
|
||||||
|
mock_redis.get.return_value = None
|
||||||
|
result = await volatile_service.get("jpmschweitzer", "weather", "rotterdam")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_success(self, volatile_service, mock_redis):
|
||||||
|
"""Test successful delete."""
|
||||||
|
mock_redis.delete.return_value = 1
|
||||||
|
result = await volatile_service.delete("jpmschweitzer", "weather", "rotterdam")
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_not_found(self, volatile_service, mock_redis):
|
||||||
|
"""Test delete when record not found."""
|
||||||
|
mock_redis.delete.return_value = 0
|
||||||
|
result = await volatile_service.delete("jpmschweitzer", "weather", "nonexistent")
|
||||||
|
assert result is False
|
||||||
Reference in New Issue
Block a user