mantimon-tcg/backend/app/db/redis.py
Cal Corum 50684a1b11 Add database infrastructure with SQLAlchemy models and test suite
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
2026-01-27 10:17:30 -06:00

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()