Files
awoooi/apps/api/src/core/redis_client.py
OG T 6f049877fc fix(lint): ruff auto-fix + lewooogo-core src 加入 git
- Python: ruff --fix 修復 280 個 lint 錯誤
- lewooogo-core: src/ 目錄未追蹤,導致 CI eslint 失敗

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-23 23:51:37 +08:00

303 lines
8.2 KiB
Python

"""
Redis Client - AWOOOI 分散式狀態儲存
=====================================
Phase 6.1.1: Multi-Sig Redis 遷移
Features:
- 非同步連線池 (Connection Pool)
- Lifespan 管理 (啟動/關閉)
- 分散式鎖 (Distributed Lock)
- 環境變數驅動 (禁止硬編碼 IP)
統帥鐵律:
- 所有 Redis 操作必須使用此模組
- 禁止在其他地方直接建立 Redis 連線
"""
import asyncio
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import redis.asyncio as redis
import structlog
from src.core.config import settings
logger = structlog.get_logger(__name__)
# =============================================================================
# Connection Pool (API 用 - 短超時)
# =============================================================================
_redis_pool: redis.Redis | None = None
# =============================================================================
# Worker 專屬連線池 (長超時,用於 XREADGROUP 阻塞操作)
# =============================================================================
_worker_redis_pool: redis.Redis | None = None
async def init_redis_pool() -> redis.Redis:
"""
初始化 Redis 連線池
統帥鐵律: 連線池在 Lifespan 啟動時建立
"""
global _redis_pool
if _redis_pool is not None:
return _redis_pool
_redis_pool = redis.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
max_connections=20,
socket_timeout=5.0,
socket_connect_timeout=5.0,
)
# 測試連線
try:
await _redis_pool.ping()
logger.info(
"redis_pool_initialized",
url=settings.REDIS_URL.split("@")[-1], # 隱藏密碼
)
except redis.ConnectionError as e:
logger.error("redis_connection_failed", error=str(e))
raise
return _redis_pool
async def close_redis_pool() -> None:
"""
關閉 Redis 連線池
統帥鐵律: 連線池在 Lifespan 關閉時回收
"""
global _redis_pool
if _redis_pool is not None:
await _redis_pool.close()
_redis_pool = None
logger.info("redis_pool_closed")
def get_redis() -> redis.Redis:
"""
取得 Redis 連線 (API 用,短超時)
Raises:
RuntimeError: 若連線池未初始化
"""
if _redis_pool is None:
raise RuntimeError("Redis pool not initialized. Call init_redis_pool() first.")
return _redis_pool
# =============================================================================
# Worker 專屬連線池 (長超時)
# =============================================================================
async def init_worker_redis_pool() -> redis.Redis:
"""
初始化 Worker 專屬 Redis 連線池
統帥鐵律 2026-03-23:
- Worker 使用 XREADGROUP 阻塞操作,需要長超時
- 絕對禁止與 API 共用短超時連線池
- socket_timeout=None 表示無限等待
"""
global _worker_redis_pool
if _worker_redis_pool is not None:
return _worker_redis_pool
_worker_redis_pool = redis.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
max_connections=5, # Worker 不需要太多連線
socket_timeout=None, # 無限等待 (XREADGROUP 阻塞操作)
socket_connect_timeout=10.0, # 連線超時仍需設定
)
# 測試連線
try:
await _worker_redis_pool.ping()
logger.info(
"worker_redis_pool_initialized",
url=settings.REDIS_URL.split("@")[-1],
socket_timeout="None (unlimited)",
)
except redis.ConnectionError as e:
logger.error("worker_redis_connection_failed", error=str(e))
raise
return _worker_redis_pool
async def close_worker_redis_pool() -> None:
"""
關閉 Worker 專屬 Redis 連線池
"""
global _worker_redis_pool
if _worker_redis_pool is not None:
await _worker_redis_pool.close()
_worker_redis_pool = None
logger.info("worker_redis_pool_closed")
def get_worker_redis() -> redis.Redis:
"""
取得 Worker 專屬 Redis 連線 (長超時,用於 XREADGROUP)
Raises:
RuntimeError: 若連線池未初始化
"""
if _worker_redis_pool is None:
raise RuntimeError("Worker Redis pool not initialized. Call init_worker_redis_pool() first.")
return _worker_redis_pool
# =============================================================================
# Distributed Lock (分散式鎖)
# =============================================================================
class RedisLock:
"""
Redis 分散式鎖
防禦場景:
- 防止 Web + Telegram 同時簽核導致 Race Condition
- 防止 K8s Executor 被觸發兩次
使用方式:
async with RedisLock("approval:123:lock", timeout=10):
# Critical section
await execute_approval()
"""
def __init__(
self,
key: str,
timeout: int = 30,
blocking_timeout: float = 5.0,
):
"""
Args:
key: 鎖的 Redis Key
timeout: 鎖的自動過期時間 (秒)
blocking_timeout: 等待取得鎖的最大時間 (秒)
"""
self.key = f"lock:{key}"
self.timeout = timeout
self.blocking_timeout = blocking_timeout
self._lock_value: str | None = None
async def acquire(self) -> bool:
"""
嘗試取得鎖
Returns:
bool: 是否成功取得鎖
"""
import uuid
redis_client = get_redis()
self._lock_value = str(uuid.uuid4())
# 使用 SET NX EX 實現原子操作
acquired = await redis_client.set(
self.key,
self._lock_value,
nx=True, # Only set if not exists
ex=self.timeout, # Expire in timeout seconds
)
if acquired:
logger.debug("redis_lock_acquired", key=self.key)
return True
# 如果沒有立即取得,則等待
start_time = asyncio.get_event_loop().time()
while asyncio.get_event_loop().time() - start_time < self.blocking_timeout:
await asyncio.sleep(0.1)
acquired = await redis_client.set(
self.key,
self._lock_value,
nx=True,
ex=self.timeout,
)
if acquired:
logger.debug("redis_lock_acquired_after_wait", key=self.key)
return True
logger.warning("redis_lock_timeout", key=self.key)
return False
async def release(self) -> bool:
"""
釋放鎖
使用 Lua Script 確保只釋放自己持有的鎖 (防止誤刪)
Returns:
bool: 是否成功釋放
"""
if self._lock_value is None:
return False
redis_client = get_redis()
# Lua script: 只有當值匹配時才刪除 (原子操作)
lua_script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
result = await redis_client.eval(lua_script, 1, self.key, self._lock_value)
if result:
logger.debug("redis_lock_released", key=self.key)
return True
else:
logger.warning("redis_lock_release_failed", key=self.key)
return False
async def __aenter__(self) -> "RedisLock":
acquired = await self.acquire()
if not acquired:
raise RuntimeError(f"Failed to acquire lock: {self.key}")
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
await self.release()
# =============================================================================
# Context Manager
# =============================================================================
@asynccontextmanager
async def redis_context() -> AsyncGenerator[redis.Redis, None]:
"""
Redis 連線 Context Manager
用於需要獨立連線的場景
"""
client = get_redis()
try:
yield client
finally:
pass # 使用連線池,不需要關閉