Phase 1 Database Implementation (DB-001 through DB-012): Models: - User: OAuth support (Google/Discord), premium subscriptions - Collection: Card ownership with CardSource enum - Deck: JSONB cards/energy_cards, validation state - CampaignProgress: One-to-one with User, medals/NPCs as JSONB - ActiveGame: In-progress games with GameType enum - GameHistory: Completed games with EndReason enum, replay data Infrastructure: - Alembic migrations with sync psycopg2 (avoids async issues) - Docker Compose for Postgres (5433) and Redis (6380) - App config with Pydantic settings - Redis client helper Test Infrastructure: - 68 database tests (47 model + 21 relationship) - Async factory pattern for test data creation - Sync TRUNCATE cleanup (solves pytest-asyncio event loop mismatch) - Uses dev containers instead of testcontainers for reliability Key technical decisions: - passive_deletes=True for ON DELETE SET NULL relationships - NullPool for test sessions (no connection reuse) - expire_on_commit=False with manual expire() for relationship tests
255 lines
6.4 KiB
Python
255 lines
6.4 KiB
Python
"""Redis connection utilities for Mantimon TCG.
|
|
|
|
This module provides async Redis connection management with:
|
|
- Connection pooling
|
|
- JSON serialization helpers
|
|
- Proper cleanup on shutdown
|
|
|
|
Usage:
|
|
from app.db.redis import get_redis, init_redis, close_redis
|
|
|
|
# Initialize on startup
|
|
await init_redis()
|
|
|
|
# Use Redis
|
|
async with get_redis() as redis:
|
|
await redis.set("key", "value")
|
|
value = await redis.get("key")
|
|
|
|
# Close on shutdown
|
|
await close_redis()
|
|
|
|
Key Patterns:
|
|
- game:{game_id} - Active game state (JSON)
|
|
- session:{session_id} - User session data
|
|
- matchmaking:queue - Matchmaking queue
|
|
"""
|
|
|
|
import json
|
|
from collections.abc import AsyncGenerator
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any
|
|
|
|
from redis.asyncio import ConnectionPool, Redis
|
|
|
|
from app.config import settings
|
|
|
|
# Global Redis pool (initialized by init_redis)
|
|
_pool: ConnectionPool | None = None
|
|
|
|
|
|
async def init_redis() -> ConnectionPool:
|
|
"""Initialize the Redis connection pool.
|
|
|
|
Creates a connection pool configured based on application settings.
|
|
Should be called once during application startup.
|
|
|
|
Returns:
|
|
The initialized ConnectionPool instance.
|
|
|
|
Example:
|
|
@app.on_event("startup")
|
|
async def startup():
|
|
await init_redis()
|
|
"""
|
|
global _pool
|
|
|
|
if _pool is not None:
|
|
return _pool
|
|
|
|
_pool = ConnectionPool.from_url(
|
|
str(settings.redis_url),
|
|
max_connections=settings.redis_max_connections,
|
|
decode_responses=True, # Return strings instead of bytes
|
|
)
|
|
|
|
return _pool
|
|
|
|
|
|
async def close_redis() -> None:
|
|
"""Close Redis connections and dispose of the pool.
|
|
|
|
Should be called during application shutdown.
|
|
|
|
Example:
|
|
@app.on_event("shutdown")
|
|
async def shutdown():
|
|
await close_redis()
|
|
"""
|
|
global _pool
|
|
|
|
if _pool is not None:
|
|
await _pool.disconnect()
|
|
_pool = None
|
|
|
|
|
|
def get_pool() -> ConnectionPool:
|
|
"""Get the Redis connection pool.
|
|
|
|
Returns:
|
|
The initialized ConnectionPool instance.
|
|
|
|
Raises:
|
|
RuntimeError: If init_redis() has not been called.
|
|
"""
|
|
if _pool is None:
|
|
raise RuntimeError("Redis pool not initialized. Call init_redis() first.")
|
|
return _pool
|
|
|
|
|
|
@asynccontextmanager
|
|
async def get_redis() -> AsyncGenerator[Redis, None]:
|
|
"""Get a Redis client from the connection pool.
|
|
|
|
Provides a Redis client as an async context manager that
|
|
automatically returns the connection to the pool.
|
|
|
|
Yields:
|
|
A Redis client instance.
|
|
|
|
Raises:
|
|
RuntimeError: If init_redis() has not been called.
|
|
|
|
Example:
|
|
async with get_redis() as redis:
|
|
await redis.set("key", "value")
|
|
value = await redis.get("key")
|
|
"""
|
|
pool = get_pool()
|
|
client = Redis(connection_pool=pool)
|
|
try:
|
|
yield client
|
|
finally:
|
|
await client.aclose()
|
|
|
|
|
|
class RedisHelper:
|
|
"""Helper class for common Redis operations with JSON support.
|
|
|
|
Provides convenience methods for storing and retrieving JSON data,
|
|
with proper serialization and error handling.
|
|
|
|
Example:
|
|
helper = RedisHelper()
|
|
|
|
# Store JSON
|
|
await helper.set_json("game:123", {"state": "active"})
|
|
|
|
# Retrieve JSON
|
|
data = await helper.get_json("game:123")
|
|
|
|
# Delete
|
|
await helper.delete("game:123")
|
|
"""
|
|
|
|
async def get_json(self, key: str) -> dict[str, Any] | None:
|
|
"""Get and deserialize a JSON value from Redis.
|
|
|
|
Args:
|
|
key: Redis key to retrieve.
|
|
|
|
Returns:
|
|
Deserialized JSON data, or None if key doesn't exist.
|
|
"""
|
|
async with get_redis() as client:
|
|
value = await client.get(key)
|
|
if value is None:
|
|
return None
|
|
return json.loads(value)
|
|
|
|
async def set_json(
|
|
self,
|
|
key: str,
|
|
value: dict[str, Any],
|
|
expire_seconds: int | None = None,
|
|
) -> None:
|
|
"""Serialize and store a JSON value in Redis.
|
|
|
|
Args:
|
|
key: Redis key to store.
|
|
value: Dictionary to serialize and store.
|
|
expire_seconds: Optional TTL in seconds.
|
|
"""
|
|
async with get_redis() as client:
|
|
json_str = json.dumps(value, default=str)
|
|
if expire_seconds:
|
|
await client.setex(key, expire_seconds, json_str)
|
|
else:
|
|
await client.set(key, json_str)
|
|
|
|
async def delete(self, key: str) -> bool:
|
|
"""Delete a key from Redis.
|
|
|
|
Args:
|
|
key: Redis key to delete.
|
|
|
|
Returns:
|
|
True if the key was deleted, False if it didn't exist.
|
|
"""
|
|
async with get_redis() as client:
|
|
result = await client.delete(key)
|
|
return result > 0
|
|
|
|
async def exists(self, key: str) -> bool:
|
|
"""Check if a key exists in Redis.
|
|
|
|
Args:
|
|
key: Redis key to check.
|
|
|
|
Returns:
|
|
True if the key exists, False otherwise.
|
|
"""
|
|
async with get_redis() as client:
|
|
result = await client.exists(key)
|
|
return result > 0
|
|
|
|
async def get_keys(self, pattern: str) -> list[str]:
|
|
"""Get all keys matching a pattern.
|
|
|
|
Args:
|
|
pattern: Redis key pattern (e.g., "game:*").
|
|
|
|
Returns:
|
|
List of matching keys.
|
|
|
|
Note:
|
|
Use sparingly in production as KEYS can be slow
|
|
on large datasets. Consider SCAN for production use.
|
|
"""
|
|
async with get_redis() as client:
|
|
keys = await client.keys(pattern)
|
|
return list(keys)
|
|
|
|
async def set_with_ttl(
|
|
self,
|
|
key: str,
|
|
value: str,
|
|
ttl_seconds: int,
|
|
) -> None:
|
|
"""Set a string value with TTL.
|
|
|
|
Args:
|
|
key: Redis key to store.
|
|
value: String value to store.
|
|
ttl_seconds: Time-to-live in seconds.
|
|
"""
|
|
async with get_redis() as client:
|
|
await client.setex(key, ttl_seconds, value)
|
|
|
|
async def increment(self, key: str, amount: int = 1) -> int:
|
|
"""Increment a counter.
|
|
|
|
Args:
|
|
key: Redis key for the counter.
|
|
amount: Amount to increment by (default 1).
|
|
|
|
Returns:
|
|
The new counter value.
|
|
"""
|
|
async with get_redis() as client:
|
|
return await client.incrby(key, amount)
|
|
|
|
|
|
# Global helper instance
|
|
redis_helper = RedisHelper()
|