This commit is contained in:
2026-09-16 17:55:04 +08:00
parent 3f120417d1
commit 4537faf2c4
39 changed files with 986 additions and 423 deletions
+96 -138
View File
@@ -1,11 +1,14 @@
# services/redis_task_manager.py
"""Redis 任务管理器 - 替代内存字典,支持 TTL 自动清理。
"""Redis 任务管理器 - 任务状态热缓存(D7:不再有进程内存回退)。
存储格式:Redis Hash(field -> JSON 字符串)。
- update_task 走 HSET 字段级原子更新,消除旧 get->merge->set 三步竞态
(后台处理流程与导出端点并发写同一任务时丢更新);
- 进度 tick 只重写变化字段,不再全量重写整个任务 blob;
- 兼容读旧 string 格式(升级前写入的在途任务),新写入一律 Hash。
- 兼容读旧 string 格式(升级前写入的在途任务),新写入一律 Hash;
- **PG 是任务状态单一事实源**:Redis 不可用时本管理器不再降级进程内 dict
(多副本下各进程内存互相不可见,造成同一任务不同副本读到不同状态),
而是 no-op / 返回 None——状态查询路径(TaskQueryService)自然落到 PG。
"""
import json
@@ -101,24 +104,6 @@ class RedisTaskManager:
raise RuntimeError("Redis 未连接,无法直接访问 redis_client")
return self._redis
# ---- 内存回退 ----
_fallback_tasks: Dict[str, Dict[str, Any]] = {}
def _fallback_set(self, task_id: str, data: Dict[str, Any]):
self._fallback_tasks[task_id] = data
def _fallback_get(self, task_id: str) -> Optional[Dict[str, Any]]:
return self._fallback_tasks.get(task_id)
def _fallback_delete(self, task_id: str):
self._fallback_tasks.pop(task_id, None)
def _fallback_all(self) -> Dict[str, Dict[str, Any]]:
return dict(self._fallback_tasks)
def _fallback_count(self) -> int:
return len(self._fallback_tasks)
# ---- 内部工具 ----
def _key(self, task_id: str) -> str:
@@ -161,143 +146,116 @@ class RedisTaskManager:
# ---- 公共接口 ----
async def set_task(self, task_id: str, data: Dict[str, Any], ttl: Optional[int] = None):
"""整包写入任务数据(Hash,覆盖旧值,含旧 string 格式清理)"""
"""整包写入任务数据(Hash,覆盖旧值,含旧 string 格式清理)。
Redis 不可用时 no-op:任务状态事实源在 PG,缓存缺失不影响正确性。
"""
if not self.is_connected:
return
effective_ttl = ttl or self._ttl
mapping = self._dump_mapping(data)
if self.is_connected:
try:
key = self._key(task_id)
# DEL 先清掉可能存在的旧 string/Hash,保证覆盖语义
pipe = self._redis.pipeline()
pipe.delete(key)
pipe.hset(key, mapping=mapping)
pipe.expire(key, effective_ttl)
await pipe.execute()
return
except Exception as e:
logger.warning(f"Redis 写入失败,回退到内存: {e}")
self._fallback_set(task_id, self._make_serializable(data))
try:
key = self._key(task_id)
# DEL 先清掉可能存在的旧 string/Hash,保证覆盖语义
pipe = self._redis.pipeline()
pipe.delete(key)
pipe.hset(key, mapping=mapping)
pipe.expire(key, effective_ttl)
await pipe.execute()
except Exception as e:
logger.warning(f"Redis 写入失败(任务状态以 PG 为准): task={task_id}, {e}")
async def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
"""获取任务数据(Hash / 旧 string 兼容)"""
if self.is_connected:
try:
return await self._load_any(self._key(task_id))
except Exception as e:
logger.warning(f"Redis 读取失败,回退到内存: {e}")
"""获取任务数据(Hash / 旧 string 兼容)。
return self._fallback_get(task_id)
Redis 不可用 / 未命中返回 None,调用方落到 PG 路径。
"""
if not self.is_connected:
return None
try:
return await self._load_any(self._key(task_id))
except Exception as e:
logger.warning(f"Redis 读取失败(任务状态以 PG 为准): task={task_id}, {e}")
return None
async def update_task(self, task_id: str, updates: Dict[str, Any]):
"""字段级原子更新(HSET),无读改写竞态。
兼容旧 string 格式:先迁移为 Hash 再更新。
Redis 不可用时 no-op(状态事实源在 PG)。
"""
mapping = self._dump_mapping(updates)
if self.is_connected:
try:
key = self._key(task_id)
key_type = await self._redis.type(key)
if key_type == "none":
logger.warning(f"任务 {task_id} 不存在,无法更新")
return
if key_type == "string":
# 旧格式迁移:string -> Hash
legacy = await self._redis.get(key)
try:
base = json.loads(legacy) if legacy else {}
except json.JSONDecodeError:
base = {}
base.update(mapping)
pipe = self._redis.pipeline()
pipe.delete(key)
pipe.hset(key, mapping=self._dump_mapping(base))
pipe.expire(key, self._ttl)
await pipe.execute()
return
await self._redis.hset(key, mapping=mapping)
await self._redis.expire(key, self._ttl)
return
except Exception as e:
logger.warning(f"Redis 更新失败,回退到内存: {e}")
# 内存回退保持读改写语义(单进程内存无并发竞态)
current = self._fallback_get(task_id)
if current is None:
logger.warning(f"任务 {task_id} 不存在,无法更新")
if not self.is_connected:
return
current.update(self._make_serializable(updates))
self._fallback_set(task_id, current)
mapping = self._dump_mapping(updates)
try:
key = self._key(task_id)
key_type = await self._redis.type(key)
if key_type == "none":
logger.warning(f"任务 {task_id} 不存在,无法更新")
return
if key_type == "string":
# 旧格式迁移:string -> Hash
legacy = await self._redis.get(key)
try:
base = json.loads(legacy) if legacy else {}
except json.JSONDecodeError:
base = {}
base.update(mapping)
pipe = self._redis.pipeline()
pipe.delete(key)
pipe.hset(key, mapping=self._dump_mapping(base))
pipe.expire(key, self._ttl)
await pipe.execute()
return
await self._redis.hset(key, mapping=mapping)
await self._redis.expire(key, self._ttl)
except Exception as e:
logger.warning(f"Redis 更新失败(任务状态以 PG 为准): task={task_id}, {e}")
async def delete_task(self, task_id: str):
"""删除任务(DEL 对 Hash/string 均有效)"""
if self.is_connected:
try:
await self._redis.delete(self._key(task_id))
return
except Exception as e:
logger.warning(f"Redis 删除失败,回退到内存: {e}")
self._fallback_delete(task_id)
"""删除任务(DEL 对 Hash/string 均有效);Redis 不可用时 no-op"""
if not self.is_connected:
return
try:
await self._redis.delete(self._key(task_id))
except Exception as e:
logger.warning(f"Redis 删除失败: task={task_id}, {e}")
async def get_all_tasks(self) -> Dict[str, Dict[str, Any]]:
"""获取所有任务"""
if self.is_connected:
try:
pattern = f"{self._prefix}*"
result = {}
async for key in self._redis.scan_iter(match=pattern):
task_id = key.replace(self._prefix, "")
task = await self._load_any(key)
if task:
result[task_id] = task
return result
except Exception as e:
logger.warning(f"Redis 扫描失败,回退到内存: {e}")
return self._fallback_all()
"""获取所有任务;Redis 不可用时返回空 dict(调用方需容忍)"""
if not self.is_connected:
return {}
try:
pattern = f"{self._prefix}*"
result = {}
async for key in self._redis.scan_iter(match=pattern):
task_id = key.replace(self._prefix, "")
task = await self._load_any(key)
if task:
result[task_id] = task
return result
except Exception as e:
logger.warning(f"Redis 扫描失败: {e}")
return {}
async def get_task_count(self) -> int:
"""获取任务总数"""
if self.is_connected:
try:
pattern = f"{self._prefix}*"
count = 0
async for _ in self._redis.scan_iter(match=pattern):
count += 1
return count
except Exception as e:
logger.warning(f"Redis 计数失败,回退到内存: {e}")
return self._fallback_count()
async def cleanup_old_tasks(self, max_age_seconds: int = 86400 * 7):
"""清理过期任务(Redis 由 TTL 自动管理,内存回退需手动清理)"""
now = datetime.now()
to_delete = []
for task_id, task in self._fallback_tasks.items():
completed_at = task.get("completed_at")
if completed_at:
try:
completed_dt = datetime.fromisoformat(completed_at)
if (now - completed_dt).total_seconds() > max_age_seconds:
to_delete.append(task_id)
except (ValueError, TypeError):
pass
for task_id in to_delete:
del self._fallback_tasks[task_id]
if to_delete:
logger.info(f"清理了 {len(to_delete)} 个过期内存任务")
"""获取任务总数;Redis 不可用时返回 0(调用方需容忍)"""
if not self.is_connected:
return 0
try:
pattern = f"{self._prefix}*"
count = 0
async for _ in self._redis.scan_iter(match=pattern):
count += 1
return count
except Exception as e:
logger.warning(f"Redis 计数失败: {e}")
return 0
# ---- 工具方法 ----