Files
LitePan/core/cache_retention_manager.py
T
2026-05-26 09:10:51 +08:00

835 lines
33 KiB
Python
Executable File

"""缓存保持任务的调度器:按 refresh_interval 周期性刷新指定目录,保证 list_files 缓存不过期。"""
import asyncio
import time
from typing import Dict, List, Optional, Set
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass
from enum import Enum
from core.log_manager import get_writer, LogModule
from core.driver_base import _extra_api_delay
from core.driver_service import get_account_driver
from database.db import db
class TaskStatus(Enum):
RUNNING = "running"
PAUSED = "paused"
ERROR = "error"
WARNING = "warning"
@dataclass
class CacheRetentionTask:
config_id: int
account_id: int
parent_id: str
path: str
recursive: bool
api_interval: int # 毫秒
refresh_interval: int # 分钟
status: TaskStatus
file_count: int = 0
last_refresh: Optional[datetime] = None
last_refresh_status: Optional[str] = None
error_message: Optional[str] = None
next_run_time: Optional[datetime] = None
# None / 'user' / 'account_disabled' / 'auth_failure'
paused_reason: Optional[str] = None
time_window_enabled: bool = False
time_start: str = "00:00"
time_end: str = "00:00"
scanned_dirs: int = 0
scanned_files: int = 0
started_at: Optional[datetime] = None
last_duration_ms: int = 0
class CacheRetentionManager:
def __init__(self):
self._tasks: Dict[int, CacheRetentionTask] = {}
# 仅装真正正在执行的任务,RUNNING 状态但未被调度的不算
self._running_tasks: Set[int] = set()
self._running_task_futures: Dict[int, asyncio.Task] = {}
self._scheduler_task: Optional[asyncio.Task] = None
self._logger = get_writer(LogModule.CACHE)
self._is_running = False
self._global_cache_enabled = True
self._startup_ready_at: float = 0.0
self._startup_delay_seconds: int = 100
async def initialize(self):
try:
self._global_cache_enabled = await self._is_cache_globally_enabled()
configs = await db.get_cache_retention_configs()
for config in configs:
task = CacheRetentionTask(
config_id=config['id'],
account_id=config['account_id'],
parent_id=config['parent_id'],
path=config['path'],
recursive=config['recursive'],
api_interval=config['api_interval'],
refresh_interval=config['refresh_interval'],
status=TaskStatus(config['status']),
file_count=config.get('file_count', 0),
last_refresh=datetime.fromisoformat(config['last_refresh'].replace('Z', '+00:00')) if config.get('last_refresh') else None,
last_refresh_status=config.get('last_refresh_status'),
paused_reason=config.get('paused_reason'),
time_window_enabled=bool(config.get('time_window_enabled') or False),
time_start=str(config.get('time_start') or '00:00'),
time_end=str(config.get('time_end') or '00:00'),
)
if task.status == TaskStatus.RUNNING and task.last_refresh:
task.next_run_time = task.last_refresh + timedelta(minutes=task.refresh_interval)
elif task.status == TaskStatus.RUNNING:
task.next_run_time = datetime.now()
else:
task.next_run_time = None
self._tasks[config['id']] = task
self._logger.debug(f"📦 缓存保持管理器初始化完成,加载了 {len(self._tasks)} 个任务")
except Exception as e:
self._logger.error(f"初始化缓存保持管理器失败: {e}")
raise
@property
def startup_remaining(self) -> int:
return max(0, int(self._startup_ready_at - time.time()))
async def start(self):
if self._is_running:
return
self._is_running = True
self._startup_ready_at = time.time() + self._startup_delay_seconds
self._logger.info(f"缓存保持调度器将在 {self._startup_delay_seconds}s 后开始执行任务")
self._scheduler_task = asyncio.create_task(self._scheduler_loop())
async def stop(self):
if not self._is_running:
return
self._is_running = False
if self._scheduler_task:
self._scheduler_task.cancel()
try:
await self._scheduler_task
except asyncio.CancelledError:
pass
for future in list(self._running_task_futures.values()):
if not future.done():
future.cancel()
if self._running_task_futures:
await asyncio.gather(*self._running_task_futures.values(), return_exceptions=True)
self._running_task_futures.clear()
self._logger.debug("缓存保持管理器已停止")
@staticmethod
def _is_in_time_window(task: CacheRetentionTask) -> bool:
if not task.time_window_enabled:
return True
try:
now = datetime.now().time()
start_h, start_m = map(int, task.time_start.split(":"))
end_h, end_m = map(int, task.time_end.split(":"))
start_t = datetime.now().replace(hour=start_h, minute=start_m, second=0, microsecond=0).time()
end_t = datetime.now().replace(hour=end_h, minute=end_m, second=0, microsecond=0).time()
if start_t <= end_t:
return start_t <= now <= end_t
else:
return now >= start_t or now <= end_t
except (ValueError, AttributeError):
return True
async def _scheduler_loop(self):
while self._is_running:
try:
remaining = self.startup_remaining
if remaining > 0:
await asyncio.sleep(1)
continue
if not await self._is_cache_globally_enabled():
await asyncio.sleep(5)
continue
now = datetime.now()
# 获取 STRM 正在使用的账号,避免同账号同时打 API
strm_busy_account_ids: Set[int] = set()
try:
from core.strm_sync_manager import strm_sync_manager
strm_busy_account_ids = strm_sync_manager.get_running_account_ids()
except Exception:
pass
tasks_to_run = []
for task_id, task in self._tasks.items():
if (task.status == TaskStatus.RUNNING and
task.next_run_time and
task.next_run_time <= now):
if task_id in self._running_tasks:
continue
if not self._is_in_time_window(task):
continue
if task.account_id in strm_busy_account_ids:
continue
tasks_to_run.append(task_id)
# 单轮只触发一个到期任务,避免多任务同时打爆同账号 API
if tasks_to_run:
task_id = tasks_to_run[0]
if task_id not in self._running_tasks:
self._running_tasks.add(task_id)
self._tasks[task_id].next_run_time = None
future = asyncio.create_task(self._execute_task(task_id))
self._running_task_futures[task_id] = future
await asyncio.sleep(5)
except asyncio.CancelledError:
break
except Exception as e:
self._logger.error(f"调度器循环出错: {e}")
await asyncio.sleep(5)
async def _is_cache_globally_enabled(self) -> bool:
try:
from config import config_manager
cache_enabled = await config_manager.get_async('cache_enabled')
return cache_enabled if cache_enabled is not None else True
except Exception as e:
self._logger.error(f"检查全局缓存开关失败: {e}")
return True
@staticmethod
def _looks_like_auth_error(error_msg: str) -> bool:
text = str(error_msg or "").lower()
auth_error_keywords = [
'认证', 'token', 'access_token', 'refresh_token', 'token invalid', 'invalid token', 'expired',
'401', '403', 'cookie', '登录', '权限', 'permission', 'unauthorized',
'unauthenticated', 'forbidden', 'not authorized'
]
return any(keyword in text for keyword in auth_error_keywords)
async def on_global_cache_changed(self, enabled: bool):
try:
old_state = self._global_cache_enabled
self._global_cache_enabled = enabled
if old_state != enabled:
if enabled:
await self._resume_all_tasks()
else:
await self._pause_all_tasks()
except Exception as e:
self._logger.error(f"处理全局缓存变更失败: {e}")
async def _pause_all_tasks(self):
try:
paused_count = 0
for task_id, task in self._tasks.items():
if task.status == TaskStatus.RUNNING:
await self._pause_task(task, persist_status="paused")
paused_count += 1
if paused_count > 0:
self._logger.debug(f"已暂停 {paused_count} 个缓存保持任务")
except Exception as e:
self._logger.error(f"暂停所有任务失败: {e}")
async def _resume_all_tasks(self):
try:
resumed_count = 0
for task_id, task in self._tasks.items():
if task.status == TaskStatus.PAUSED:
# 账号 TTL=0 表示该账号不缓存,这种任务要保持暂停
if not await self._should_pause_task_due_to_ttl(task):
self._resume_task(task)
await db.update_cache_retention_config(task_id, status="running", paused_reason=None)
resumed_count += 1
if resumed_count > 0:
self._logger.debug(f"已恢复 {resumed_count} 个缓存保持任务")
except Exception as e:
self._logger.error(f"恢复所有任务失败: {e}")
async def _execute_task(self, task_id: int):
task = self._tasks.get(task_id)
if not task:
self._logger.error(f"任务 {task_id} 不存在")
return
# 抢到执行权后再确认一次状态:调度期间可能已被其它路径改成 PAUSED
if task.status != TaskStatus.RUNNING:
return
# 初始化进度字段
task.scanned_dirs = 0
task.scanned_files = 0
task.started_at = datetime.now(timezone.utc)
try:
account = await db.get_account(task.account_id)
if not account:
await self._pause_task(
task,
refresh_status="error",
error_message="关联的账号已删除",
persist_status="paused",
persist_error_message="关联的账号已删除",
paused_reason="account_disabled"
)
return
if not account.get('is_active', True):
await self._pause_task(
task,
refresh_status="error",
error_message="关联的账号已禁用",
persist_status="paused",
persist_error_message="关联的账号已禁用",
paused_reason="account_disabled"
)
return
account_config = account.get('config', {})
auth_status = account_config.get('auth_status', 'active')
if auth_status == 'failed':
await self._pause_task(
task,
refresh_status="error",
error_message="关联的账号认证已失效",
persist_status="paused",
persist_error_message="关联的账号认证已失效,请重新配置账号认证信息",
paused_reason="auth_failure"
)
return
if await self._should_pause_task_due_to_ttl(task):
await self._pause_task_due_to_ttl(task)
return
file_count = await self._refresh_cache(task)
# 扫描完成即视为成功(空目录/仅有子目录也是正常结果)
task.last_refresh_status = "success"
task.error_message = None
task.file_count = file_count
task.last_refresh = datetime.now()
task.last_duration_ms = max(int((datetime.now(timezone.utc) - task.started_at).total_seconds() * 1000) if task.started_at else 0, 0)
task.next_run_time = datetime.now() + timedelta(minutes=task.refresh_interval)
await db.update_cache_retention_config(
task_id,
file_count=file_count,
last_refresh=task.last_refresh.isoformat(),
last_refresh_status=task.last_refresh_status,
last_duration_ms=task.last_duration_ms,
error_message=None
)
except asyncio.CancelledError:
pass
except Exception as e:
error_msg = str(e)
self._logger.error(f"缓存保持任务 {task_id} 执行失败: {error_msg}")
if self._looks_like_auth_error(error_msg):
await self._pause_task(
task,
refresh_status="error",
error_message=f"认证错误: {error_msg}",
persist_status="paused",
persist_error_message=f"认证错误,请检查账号配置: {error_msg[:100]}",
paused_reason="auth_failure"
)
else:
task.last_refresh = datetime.now()
task.last_refresh_status = "error"
task.error_message = error_msg
task.next_run_time = datetime.now() + timedelta(minutes=task.refresh_interval)
await db.update_cache_retention_config(
task_id,
last_refresh=task.last_refresh.isoformat(),
last_refresh_status="error",
error_message=error_msg[:200]
)
finally:
self._running_tasks.discard(task_id)
if task_id in self._running_task_futures:
del self._running_task_futures[task_id]
t = self._tasks.get(task_id)
if t:
t.started_at = None
async def _should_pause_task_due_to_ttl(self, task: CacheRetentionTask) -> bool:
try:
account = await db.get_account(task.account_id)
if not account:
return False
account_config = account.get('config', {})
cache_ttl = account_config.get('cache_ttl')
# cache_ttl=0 表示该账号显式关闭缓存
if cache_ttl == 0:
return True
return False
except Exception as e:
self._logger.error(f"检查任务TTL设置失败: {e}")
return False
async def _pause_task_due_to_ttl(self, task: CacheRetentionTask):
try:
self._logger.warning(f"任务 {task.config_id} 的账号缓存已禁用(TTL=0),自动暂停任务")
await self._pause_task(task, persist_status="paused")
except Exception as e:
self._logger.error(f"暂停任务失败: {e}")
async def _refresh_cache(self, task: CacheRetentionTask) -> int:
driver = await get_account_driver(task.account_id)
if not driver:
raise RuntimeError("驱动实例不可用,可能是账号认证失效或账号已禁用")
token = _extra_api_delay.set(task.api_interval)
try:
if task.recursive:
return await self._refresh_cache_recursive(driver, task.parent_id, task=task)
else:
return await self._refresh_cache_single(driver, task.parent_id, task=task)
finally:
_extra_api_delay.reset(token)
async def _refresh_cache_single(self, driver, parent_id: str, task=None) -> int:
try:
files = await driver.list_files(parent_id)
files_only = [f for f in files if not getattr(f, 'is_dir', True)]
if task:
task.scanned_dirs = 1
task.scanned_files = len(files_only)
return len(files_only)
except Exception as e:
self._logger.error(f"单层缓存刷新失败: {e}")
raise
async def _refresh_cache_recursive(self, driver, parent_id: str, task=None) -> int:
total_files = 0
total_dirs = 0
try:
queue = [parent_id]
processed_dirs = set()
while queue:
current_dir = queue.pop(0)
if current_dir in processed_dirs:
continue
processed_dirs.add(current_dir)
try:
files = await driver.list_files(current_dir)
files_only = [f for f in files if not getattr(f, 'is_dir', True)]
dirs_only = [f for f in files if getattr(f, 'is_dir', False)]
total_files += len(files_only)
total_dirs += len(dirs_only)
if task:
task.scanned_dirs = len(processed_dirs)
task.scanned_files = total_files
for file in dirs_only:
dir_id = getattr(file, 'id', '')
if dir_id:
queue.append(dir_id)
except Exception as e:
if self._looks_like_auth_error(str(e)):
self._logger.error(f"递归缓存刷新遇到认证错误,停止任务: {e}")
raise
self._logger.warning(f"递归缓存刷新时目录 {current_dir} 失败: {e}")
continue
self._logger.debug(f"递归缓存刷新完成,总共处理了 {len(processed_dirs)} 个目录,{total_files} 个文件,{total_dirs} 个子目录")
return total_files
except Exception as e:
self._logger.error(f"递归缓存刷新失败: {e}")
raise
async def _pause_task(
self,
task: CacheRetentionTask,
*,
refresh_status: Optional[str] = None,
error_message: Optional[str] = None,
persist_status: str = "paused",
persist_error_message: Optional[str] = None,
paused_reason: Optional[str] = "user",
):
"""同步更新内存状态与数据库。paused_reason: user / account_disabled / auth_failure。"""
task.status = TaskStatus.PAUSED
task.next_run_time = None
task.paused_reason = paused_reason
if refresh_status is not None:
task.last_refresh_status = refresh_status
if error_message is not None:
task.error_message = error_message
self._running_tasks.discard(task.config_id)
await db.update_cache_retention_config(
task.config_id,
status=persist_status,
paused_reason=paused_reason,
last_refresh_status=refresh_status,
error_message=persist_error_message if persist_error_message is not None else error_message
)
def _resume_task(self, task: CacheRetentionTask):
task.status = TaskStatus.RUNNING
task.paused_reason = None
task.next_run_time = datetime.now()
async def pause_tasks_by_account(
self,
account_id: int,
reason: str,
message: Optional[str] = None,
) -> int:
"""账号禁用/认证失效时批量暂停该账号的任务,只对 RUNNING 生效,返回实际暂停数。"""
if reason not in ("account_disabled", "auth_failure"):
self._logger.warning(f"pause_tasks_by_account 收到未知 reason: {reason}")
return 0
paused = 0
try:
for task in list(self._tasks.values()):
if task.account_id != account_id:
continue
if task.status != TaskStatus.RUNNING:
continue
future = self._running_task_futures.get(task.config_id)
if future and not future.done():
future.cancel()
await self._pause_task(
task,
refresh_status="error",
error_message=message,
persist_status="paused",
persist_error_message=message,
paused_reason=reason,
)
paused += 1
if paused > 0:
self._logger.info(
f"账号 {account_id} 状态变化({reason}),已暂停 {paused} 个缓存保持任务"
)
except Exception as e:
self._logger.error(f"按账号暂停缓存保持任务失败: {e}")
return paused
async def resume_tasks_by_account(self, account_id: int) -> int:
"""账号恢复可用时只恢复 account_disabled/auth_failure 的任务,不触碰用户手动暂停的。"""
resumed = 0
try:
for task in list(self._tasks.values()):
if task.account_id != account_id:
continue
if task.status != TaskStatus.PAUSED:
continue
if task.paused_reason not in ("account_disabled", "auth_failure"):
continue
if await self._should_pause_task_due_to_ttl(task):
continue
self._resume_task(task)
# 同步清掉遗留的错误标记,避免前端"红色三角"残留
task.last_refresh_status = None
task.error_message = None
await db.update_cache_retention_config(
task.config_id,
status="running",
paused_reason=None,
last_refresh_status=None,
error_message=None,
)
resumed += 1
if resumed > 0:
self._logger.info(
f"账号 {account_id} 恢复可用,已自动恢复 {resumed} 个缓存保持任务"
)
except Exception as e:
self._logger.error(f"按账号恢复缓存保持任务失败: {e}")
return resumed
async def add_task(self, config_id: int, **config_data) -> bool:
try:
task = CacheRetentionTask(
config_id=config_id,
account_id=config_data['account_id'],
parent_id=config_data['parent_id'],
path=config_data['path'],
recursive=config_data['recursive'],
api_interval=config_data['api_interval'],
refresh_interval=config_data['refresh_interval'],
status=TaskStatus.RUNNING,
next_run_time=datetime.now(),
time_window_enabled=bool(config_data.get('time_window_enabled') or False),
time_start=str(config_data.get('time_start') or '00:00'),
time_end=str(config_data.get('time_end') or '00:00'),
)
self._tasks[config_id] = task
self._logger.debug(f"添加缓存保持任务 {config_id}: {task.path}")
return True
except Exception as e:
self._logger.error(f"添加任务失败: {e}")
return False
async def update_task(self, config_id: int, **config_data) -> bool:
try:
task = self._tasks.get(config_id)
if not task:
return False
task.account_id = config_data['account_id']
task.parent_id = config_data['parent_id']
task.path = config_data['path']
task.recursive = config_data['recursive']
task.api_interval = config_data['api_interval']
task.refresh_interval = config_data['refresh_interval']
task.time_window_enabled = bool(config_data.get('time_window_enabled') or False)
task.time_start = str(config_data.get('time_start') or '00:00')
task.time_end = str(config_data.get('time_end') or '00:00')
if task.status == TaskStatus.RUNNING:
if task.last_refresh:
task.next_run_time = task.last_refresh + timedelta(minutes=task.refresh_interval)
else:
task.next_run_time = datetime.now()
self._logger.debug(f"更新缓存保持任务 {config_id}: {task.path}")
return True
except Exception as e:
self._logger.error(f"更新任务失败: {e}")
return False
async def remove_task(self, config_id: int, clear_cache: bool = False) -> bool:
try:
task = self._tasks.get(config_id)
if not task:
return False
if clear_cache:
await self._clear_task_cache(task)
if config_id in self._running_tasks:
self._running_tasks.discard(config_id)
del self._tasks[config_id]
self._logger.debug(f"移除缓存保持任务 {config_id}" + (",已清理缓存" if clear_cache else ""))
return True
except Exception as e:
self._logger.error(f"移除任务失败: {e}")
return False
async def remove_configs_by_account(self, account_id: int) -> int:
try:
config_ids = [task.config_id for task in self._tasks.values() if task.account_id == account_id]
if not config_ids:
return 0
removed_count = 0
for config_id in config_ids:
if await self.remove_task(config_id, clear_cache=False):
removed_count += 1
from database.db import db
deleted = await db.delete_cache_retention_configs_by_account(account_id)
self._logger.debug(f"已删除账号 {account_id} 的 {removed_count} 个缓存保持配置")
return removed_count
except Exception as e:
self._logger.error(f"删除账号缓存保持配置失败: {e}")
return 0
async def _clear_task_cache(self, task: CacheRetentionTask):
try:
from core.dependency_container import get_cache_cleaner
cache_cleaner = get_cache_cleaner()
if not cache_cleaner:
return
await cache_cleaner._clear_directory_cache(str(task.account_id), task.parent_id)
self._logger.debug(f"已清理任务 {task.config_id} 的根目录缓存: {task.parent_id}")
# 递归任务的子目录 ID 在这里无法获知,子目录缓存只能依赖 TTL 自然过期
if task.recursive:
self._logger.debug(f"任务 {task.config_id} 为递归任务,建议手动清理子目录缓存")
except Exception as e:
self._logger.error(f"清理任务缓存失败: {e}")
async def toggle_task(self, config_id: int) -> bool:
try:
task = self._tasks.get(config_id)
if not task:
return False
if task.status == TaskStatus.RUNNING:
await self._pause_task(task, persist_status="paused")
else:
self._resume_task(task)
await db.update_cache_retention_config(config_id, status="running")
return True
except Exception as e:
self._logger.error(f"切换任务状态失败: {e}")
return False
async def refresh_task_now(self, config_id: int) -> str:
"""返回 'running' | 'already_running' | 'blocked_by_strm' | 'missing'"""
try:
task = self._tasks.get(config_id)
if not task:
return "missing"
if config_id in self._running_tasks:
return "already_running"
try:
from core.strm_sync_manager import strm_sync_manager
if task.account_id in strm_sync_manager.get_running_account_ids():
return "blocked_by_strm"
except Exception:
pass
self._running_tasks.add(config_id)
asyncio.create_task(self._execute_task(config_id))
return "running"
except Exception as e:
self._logger.error(f"立即刷新任务失败: {e}")
return False
async def force_stop_task(self, config_id: int) -> bool:
"""强制停止正在执行的任务,不改变任务状态,不影响下次调度。"""
try:
task = self._tasks.get(config_id)
if not task:
return False
future = self._running_task_futures.get(config_id)
if future and not future.done():
future.cancel()
self._running_tasks.discard(config_id)
self._running_task_futures.pop(config_id, None)
if task.next_run_time is None:
task.next_run_time = datetime.now()
self._logger.info(f"强制停止缓存保持任务 {config_id}(下次调度不受影响)")
return True
except Exception as e:
self._logger.error(f"强制停止缓存保持任务失败: {e}")
return False
async def refresh_all_tasks(self) -> int:
try:
running_tasks = [task_id for task_id, task in self._tasks.items()
if task.status == TaskStatus.RUNNING]
if not running_tasks:
return 0
for task_id in running_tasks:
if task_id not in self._running_tasks:
self._running_tasks.add(task_id)
asyncio.create_task(self._execute_task(task_id))
self._logger.debug(f"已触发 {len(running_tasks)} 个运行中任务")
except Exception as e:
self._logger.error(f"执行所有任务失败: {e}")
return 0
def get_task_status(self, config_id: int) -> Optional[Dict]:
task = self._tasks.get(config_id)
if not task:
return None
current_duration_ms = 0
if task.started_at and config_id in self._running_tasks:
current_duration_ms = max(
int((datetime.now(timezone.utc) - task.started_at).total_seconds() * 1000),
0,
)
return {
'config_id': task.config_id,
'status': task.status.value,
'file_count': task.file_count,
'last_refresh': task.last_refresh.isoformat() if task.last_refresh else None,
'last_refresh_status': task.last_refresh_status,
'next_run_time': task.next_run_time.isoformat() if task.next_run_time else None,
'error_message': task.error_message,
'scanned_dirs': task.scanned_dirs,
'scanned_files': task.scanned_files,
'started_at': task.started_at.isoformat() if task.started_at else None,
'last_duration_ms': task.last_duration_ms,
'current_duration_ms': current_duration_ms or task.last_duration_ms,
}
def get_all_tasks_status(self) -> List[Dict]:
return [self.get_task_status(task_id) for task_id in self._tasks.keys()]
def get_running_account_ids(self) -> Set[int]:
"""返回当前正在执行任务的账号 ID 集合,供 STRM 管理器跨管理器互斥。"""
account_ids: Set[int] = set()
for task_id in self._running_tasks:
task = self._tasks.get(task_id)
if task:
account_ids.add(task.account_id)
return account_ids
cache_retention_manager = CacheRetentionManager()