Files
Aether/_deprecated_py_src/services/system/maintenance_scheduler.py
fawney19 1d9c77522a refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
2026-04-03 16:26:16 +08:00

1479 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
系统维护定时任务调度器
包含以下任务:
- 统计聚合:每天凌晨聚合前一天的统计数据
- Provider 签到:每天凌晨执行所有已配置 Provider 的签到
- 使用记录清理:分级清理策略(压缩、清空、删除)
- 审计日志清理:定期清理过期的审计日志
- 连接池监控:定期检查数据库连接池状态
- Pending 状态清理:清理异常的 Pending 状态记录
- Gemini 文件映射清理:清理过期的 Gemini 文件→Key 映射
- 请求候选记录清理:定期清理过期的 request_candidates 记录
- 数据库表维护:定期 VACUUM ANALYZE 防止表和索引膨胀
使用 APScheduler 进行任务调度,支持时区配置。
"""
from __future__ import annotations
import asyncio
import os
from datetime import date, datetime, timedelta, timezone
from typing import Any
from sqlalchemy import delete, literal_column, text
from src.clients.http_client import HTTPClientPool
from src.config.settings import config
from src.core.logger import logger
from src.database import create_session
from src.models.database import AuditLog, Provider, RequestCandidate, Usage
from src.services.provider_ops.service import ProviderOpsService
from src.services.system.config import SystemConfigService
from src.services.system.scheduler import get_scheduler
from src.services.system.stats_aggregator import StatsAggregatorService
from src.services.user.apikey import ApiKeyService
from src.services.wallet import WalletDailyUsageLedgerService
from src.utils.compression import compress_json
class MaintenanceScheduler:
"""系统维护任务调度器"""
# 签到任务的 job_id
CHECKIN_JOB_ID = "provider_checkin"
def __init__(self) -> None:
self.running = False
self._interval_tasks = []
self._stats_aggregation_lock = asyncio.Lock()
self._wallet_daily_usage_lock = asyncio.Lock()
self._startup_task: asyncio.Task[Any] | None = None
self._registered_job_ids: list[str] = []
@staticmethod
def _get_http_client_idle_cleanup_interval_minutes() -> int:
"""获取 HTTP 客户端空闲清理调度间隔(分钟)。"""
raw = os.getenv("HTTP_CLIENT_IDLE_CLEANUP_INTERVAL_MINUTES", "5")
try:
minutes = int(raw)
except ValueError:
logger.warning(
"环境变量 HTTP_CLIENT_IDLE_CLEANUP_INTERVAL_MINUTES 非法: {}, 使用默认值 5",
raw,
)
return 5
return max(1, minutes)
@staticmethod
def _has_python_startup_tasks() -> bool:
return bool(
config.pending_cleanup_python_enabled or config.antigravity_ua_refresh_python_enabled
)
def _get_checkin_time(self) -> tuple[int, int]:
"""获取签到任务的执行时间
Returns:
(hour, minute) 元组
"""
db = create_session()
try:
time_str = SystemConfigService.get_config(db, "provider_checkin_time", "01:05")
return self._parse_time_string(time_str)
finally:
db.close()
@staticmethod
def _parse_time_string(time_str: str) -> tuple[int, int]:
"""解析时间字符串为 (hour, minute) 元组
Args:
time_str: HH:MM 格式的时间字符串
Returns:
(hour, minute) 元组,解析失败返回默认值 (1, 5)
"""
try:
if not time_str or ":" not in time_str:
return (1, 5)
parts = time_str.split(":")
hour = int(parts[0])
minute = int(parts[1])
# 验证范围
if 0 <= hour <= 23 and 0 <= minute <= 59:
return (hour, minute)
return (1, 5)
except (ValueError, IndexError):
return (1, 5)
def update_checkin_time(self, time_str: str) -> bool:
"""更新签到任务的执行时间
Args:
time_str: HH:MM 格式的时间字符串
Returns:
是否成功更新
"""
hour, minute = self._parse_time_string(time_str)
scheduler = get_scheduler()
success = scheduler.reschedule_cron_job(
self.CHECKIN_JOB_ID,
hour=hour,
minute=minute,
)
if success:
logger.info(f"Provider 签到任务时间已更新为: {hour:02d}:{minute:02d}")
return success
def get_checkin_job_info(self) -> dict | None:
"""获取签到任务的信息
Returns:
任务信息字典
"""
scheduler = get_scheduler()
return scheduler.get_job_info(self.CHECKIN_JOB_ID)
async def start(self) -> Any:
"""启动调度器"""
if self.running:
logger.warning("Maintenance scheduler already running")
return
self.running = True
self._registered_job_ids.clear()
logger.info("系统维护调度器已启动")
scheduler = get_scheduler()
def _add_cron(job_id: str, **kwargs: Any) -> None:
scheduler.add_cron_job(job_id=job_id, **kwargs)
self._registered_job_ids.append(job_id)
def _add_interval(job_id: str, **kwargs: Any) -> None:
scheduler.add_interval_job(job_id=job_id, **kwargs)
self._registered_job_ids.append(job_id)
# 注册定时任务
if config.stats_aggregation_python_enabled:
# 统计聚合任务 - UTC 00:05 执行
_add_cron(
"stats_aggregation",
func=self._scheduled_stats_aggregation,
hour=0,
minute=5,
name="统计数据聚合",
timezone="UTC",
)
else:
logger.info("每日统计聚合已切到 Rust gatewayPython 调度器跳过注册")
if config.stats_hourly_aggregation_python_enabled:
# 小时统计聚合任务 - 每小时 05 分执行UTC
_add_cron(
"stats_hourly_aggregation",
func=self._scheduled_hourly_stats_aggregation,
hour="*",
minute=5,
name="统计小时数据聚合",
timezone="UTC",
)
else:
logger.info("小时统计聚合已切到 Rust gatewayPython 调度器跳过注册")
if config.wallet_daily_usage_aggregation_python_enabled:
_add_cron(
"wallet_daily_usage_aggregation",
func=self._scheduled_wallet_daily_usage_aggregation,
hour=0,
minute=10,
name="钱包每日消费汇总",
)
else:
logger.info("钱包每日消费汇总已切到 Rust gatewayPython 调度器跳过注册")
if config.usage_cleanup_python_enabled:
# 清理任务 - 凌晨 3 点执行
_add_cron(
"usage_cleanup",
func=self._scheduled_cleanup,
hour=3,
minute=0,
name="使用记录清理",
)
else:
logger.info("使用记录清理已切到 Rust gatewayPython 调度器跳过注册")
if config.pool_monitor_python_enabled:
# 连接池监控 - 每 5 分钟
_add_interval(
"pool_monitor",
func=self._scheduled_monitor,
minutes=5,
name="连接池监控",
)
else:
logger.info("连接池监控已切到 Rust gatewayPython 调度器跳过注册")
if config.http_client_idle_cleanup_python_enabled:
# HTTP 代理/Tunnel 客户端空闲清理 - 默认每 5 分钟
_add_interval(
"http_client_idle_cleanup",
func=self._scheduled_http_client_idle_cleanup,
minutes=self._get_http_client_idle_cleanup_interval_minutes(),
name="HTTP客户端空闲清理",
)
else:
logger.info("HTTP 客户端空闲清理默认跳过 Python 调度注册")
if config.pending_cleanup_python_enabled:
# Pending 状态清理 - 每 5 分钟
_add_interval(
"pending_cleanup",
func=self._scheduled_pending_cleanup,
minutes=5,
name="Pending状态清理",
)
else:
logger.info("Pending 状态清理已切到 Rust gatewayPython 调度器跳过注册")
if config.audit_cleanup_python_enabled:
# 审计日志清理 - 凌晨 4 点执行
_add_cron(
"audit_cleanup",
func=self._scheduled_audit_cleanup,
hour=4,
minute=0,
name="审计日志清理",
)
else:
logger.info("审计日志清理已切到 Rust gatewayPython 调度器跳过注册")
if config.gemini_file_mapping_cleanup_python_enabled:
# Gemini 文件映射清理 - 每小时执行
_add_interval(
"gemini_file_mapping_cleanup",
func=self._scheduled_gemini_file_mapping_cleanup,
hours=1,
name="Gemini文件映射清理",
)
else:
logger.info("Gemini 文件映射清理已切到 Rust gatewayPython 调度器跳过注册")
if config.request_candidate_cleanup_python_enabled:
# 请求候选记录清理 - 凌晨 3:30 执行
_add_cron(
"candidate_cleanup",
func=self._scheduled_candidate_cleanup,
hour=3,
minute=30,
name="请求候选记录清理",
)
else:
logger.info("请求候选记录清理已切到 Rust gatewayPython 调度器跳过注册")
if config.db_maintenance_python_enabled:
# 数据库表维护 - 每周日凌晨 5 点执行 VACUUM ANALYZE
_add_cron(
"db_maintenance",
func=self._scheduled_db_maintenance,
day_of_week="sun",
hour=5,
minute=0,
name="数据库表维护",
)
else:
logger.info("数据库维护已切到 Rust gatewayPython 调度器跳过注册")
if config.antigravity_ua_refresh_python_enabled:
# Antigravity User-Agent 版本刷新 - 每 6 小时
_add_interval(
"antigravity_ua_refresh",
func=self._scheduled_antigravity_ua_refresh,
hours=6,
name="Antigravity UA版本刷新",
)
else:
logger.info("Antigravity UA 版本刷新默认跳过 Python 调度注册")
if config.provider_checkin_python_enabled:
# Provider 签到任务 - 根据配置时间执行
checkin_hour, checkin_minute = self._get_checkin_time()
_add_cron(
self.CHECKIN_JOB_ID,
func=self._scheduled_provider_checkin,
hour=checkin_hour,
minute=checkin_minute,
name="Provider签到",
)
else:
logger.info("Provider 签到已切到 Rust gatewayPython 调度器跳过注册")
# 启动时执行一次初始化任务
if config.maintenance_startup_tasks_enabled and self._has_python_startup_tasks():
from src.utils.async_utils import safe_create_task
self._startup_task = safe_create_task(self._run_startup_tasks())
elif config.maintenance_startup_tasks_enabled:
self._startup_task = None
logger.info("维护调度器启动任务已无剩余 Python owner跳过创建")
else:
self._startup_task = None
logger.info("维护调度器启动任务已禁用MAINTENANCE_STARTUP_TASKS_ENABLED=false")
async def _run_startup_tasks(self) -> None:
"""启动时执行的初始化任务"""
# 延迟执行等待系统完全启动Redis 连接、其他后台任务稳定)
# 增加延迟时间避免与 UsageQueueConsumer 等后台任务竞争数据库连接
await asyncio.sleep(10)
if config.antigravity_ua_refresh_python_enabled:
# 刷新 Antigravity User-Agent 版本号(不阻塞其他启动任务)
try:
from src.services.provider.adapters.antigravity.client import refresh_user_agent
await refresh_user_agent()
except Exception as e:
logger.debug("启动时刷新 Antigravity UA 版本失败(不影响运行): {}", e)
else:
logger.info("启动时 Antigravity UA 刷新默认跳过")
if config.pending_cleanup_python_enabled:
try:
logger.info("启动时清理残留的 pending/streaming 请求...")
await self._perform_pending_cleanup()
except Exception as e:
logger.exception(f"启动时 pending 清理执行出错: {e}")
else:
logger.info("启动时 pending 清理已切到 Rust gatewayPython 宿主跳过执行")
async def stop(self) -> Any:
"""停止调度器"""
if not self.running:
return
self.running = False
if self._startup_task and not self._startup_task.done():
self._startup_task.cancel()
try:
await self._startup_task
except asyncio.CancelledError:
pass
self._startup_task = None
scheduler = get_scheduler()
for job_id in self._registered_job_ids:
scheduler.remove_job(job_id)
self._registered_job_ids.clear()
logger.info("系统维护调度器已停止")
# ========== 任务函数APScheduler 直接调用异步函数) ==========
async def _scheduled_stats_aggregation(self, backfill: bool = False) -> None:
"""统计聚合任务(定时调用)"""
if not self.running:
return
await self._perform_stats_aggregation(backfill=backfill)
async def _scheduled_wallet_daily_usage_aggregation(self) -> None:
"""钱包每日消费汇总任务(定时调用)"""
if not self.running:
return
await self._perform_wallet_daily_usage_aggregation()
async def _scheduled_hourly_stats_aggregation(self) -> None:
"""小时统计聚合任务(定时调用)"""
if not self.running:
return
await self._perform_hourly_stats_aggregation()
async def _scheduled_cleanup(self) -> None:
"""清理任务(定时调用)"""
if not self.running:
return
await self._perform_cleanup()
async def _scheduled_monitor(self) -> None:
"""监控任务(定时调用)"""
if not self.running:
return
try:
from src.database import log_pool_status
log_pool_status()
except Exception as e:
logger.exception("连接池监控任务出错: {}", e)
async def _scheduled_http_client_idle_cleanup(self) -> None:
"""HTTP 客户端空闲清理任务(定时调用)。"""
if not self.running:
return
try:
stats = await HTTPClientPool.cleanup_idle_clients()
if stats.get("proxy_closed", 0) or stats.get("tunnel_closed", 0):
logger.info(
"HTTP 客户端空闲清理释放连接: proxy={}, tunnel={}",
stats.get("proxy_closed", 0),
stats.get("tunnel_closed", 0),
)
except Exception as e:
logger.exception("HTTP 客户端空闲清理任务出错: {}", e)
async def _scheduled_pending_cleanup(self) -> None:
"""Pending 清理任务(定时调用)"""
if not self.running:
return
await self._perform_pending_cleanup()
async def _scheduled_audit_cleanup(self) -> None:
"""审计日志清理任务(定时调用)"""
if not self.running:
return
await self._perform_audit_cleanup()
async def _scheduled_candidate_cleanup(self) -> None:
"""请求候选记录清理任务(定时调用)"""
if not self.running:
return
await self._perform_candidate_cleanup()
async def _scheduled_db_maintenance(self) -> None:
"""数据库表维护任务(定时调用)"""
if not self.running:
return
await self._perform_db_maintenance()
async def _scheduled_gemini_file_mapping_cleanup(self) -> None:
"""Gemini 文件映射清理任务(定时调用)"""
if not self.running:
return
await self._perform_gemini_file_mapping_cleanup()
async def _scheduled_antigravity_ua_refresh(self) -> None:
"""Antigravity User-Agent 版本刷新(定时调用)"""
if not self.running:
return
try:
from src.services.provider.adapters.antigravity.client import refresh_user_agent
await refresh_user_agent()
except Exception as e:
logger.debug("定时刷新 Antigravity UA 版本失败: {}", e)
async def _scheduled_provider_checkin(self) -> None:
"""Provider 签到任务(定时调用)"""
if not self.running:
return
await self._perform_provider_checkin()
# ========== 实际任务实现 ==========
async def _perform_stats_aggregation(self, backfill: bool = False) -> None:
"""执行统计聚合任务
Args:
backfill: 是否回填历史数据(启动时检查缺失的日期)
"""
if self._stats_aggregation_lock.locked():
logger.info("统计聚合任务正在运行,跳过本次触发")
return
async with self._stats_aggregation_lock:
db = create_session()
try:
# 检查是否启用统计聚合
if not SystemConfigService.get_config(db, "enable_stats_aggregation", True):
logger.info("统计聚合已禁用,跳过聚合任务")
return
logger.info("开始执行统计数据聚合...")
from src.models.database import StatsDaily
from src.models.database import User as DBUser
# 使用 UTC 日期,定时任务在 UTC 00:05 触发,聚合 UTC 昨天
now_utc = datetime.now(timezone.utc)
today_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
if backfill:
# 启动时检查并回填缺失的日期
from src.models.database import StatsSummary
summary = db.query(StatsSummary).first()
if not summary:
# 首次运行,回填所有历史数据
logger.info("检测到首次运行,开始回填历史统计数据...")
days_to_backfill = SystemConfigService.get_config(
db, "stats_backfill_days", 365
)
count = StatsAggregatorService.backfill_historical_data(
db, days=days_to_backfill
)
logger.info(f"历史数据回填完成,共 {count}")
return
# 非首次运行,检查最近是否有缺失的日期需要回填
from src.models.database import StatsDailyModel, StatsDailyProvider
yesterday_utc_date = today_utc.date() - timedelta(days=1)
max_backfill_days: int = (
SystemConfigService.get_config(db, "max_stats_backfill_days", 30) or 30
)
# 计算回填检查的起始日期
check_start_date = yesterday_utc_date - timedelta(days=max_backfill_days - 1)
check_start_dt = datetime.combine(
check_start_date, datetime.min.time(), tzinfo=timezone.utc
)
# 单次查询获取三张统计表中已有数据的日期UNION ALL 合并)
existing_daily_dates: set[date] = set()
existing_model_dates: set[date] = set()
existing_provider_dates: set[date] = set()
q_daily = db.query(
StatsDaily.date.label("dt"),
literal_column("'daily'").label("src"),
).filter(StatsDaily.date >= check_start_dt)
q_model = (
db.query(
StatsDailyModel.date.label("dt"),
literal_column("'model'").label("src"),
)
.filter(StatsDailyModel.date >= check_start_dt)
.distinct()
)
q_provider = (
db.query(
StatsDailyProvider.date.label("dt"),
literal_column("'provider'").label("src"),
)
.filter(StatsDailyProvider.date >= check_start_dt)
.distinct()
)
combined = q_daily.union_all(q_model).union_all(q_provider).all()
for stat_date, src in combined:
if stat_date.tzinfo is None:
stat_date = stat_date.replace(tzinfo=timezone.utc)
d = stat_date.date()
if src == "daily":
existing_daily_dates.add(d)
elif src == "model":
existing_model_dates.add(d)
else:
existing_provider_dates.add(d)
# 找出需要回填的日期
all_dates = set()
current = check_start_date
while current <= yesterday_utc_date:
all_dates.add(current)
current += timedelta(days=1)
# 需要回填 StatsDaily 的日期
missing_daily_dates = all_dates - existing_daily_dates
# 需要回填 StatsDailyModel 的日期
missing_model_dates = all_dates - existing_model_dates
# 需要回填 StatsDailyProvider 的日期
missing_provider_dates = all_dates - existing_provider_dates
# 合并所有需要处理的日期
dates_to_process = (
missing_daily_dates | missing_model_dates | missing_provider_dates
)
if dates_to_process:
sorted_dates = sorted(dates_to_process)
logger.info(
f"检测到 {len(dates_to_process)} 天的统计数据需要回填 "
f"(StatsDaily 缺失 {len(missing_daily_dates)} 天, "
f"StatsDailyModel 缺失 {len(missing_model_dates)} 天, "
f"StatsDailyProvider 缺失 {len(missing_provider_dates)} 天)"
)
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
user_ids = [user_id for (user_id,) in users]
failed_dates = 0
for current_date in sorted_dates:
try:
current_date_utc = datetime.combine(
current_date, datetime.min.time(), tzinfo=timezone.utc
)
StatsAggregatorService.aggregate_daily_stats_bundle(
db, current_date_utc, user_ids=user_ids
)
db.expunge_all()
except Exception as e:
failed_dates += 1
logger.warning(f"回填日期 {current_date} 失败: {e}")
try:
db.rollback()
except Exception as rollback_err:
logger.error(f"回滚失败: {rollback_err}")
StatsAggregatorService.update_summary(db)
if failed_dates > 0:
logger.warning(
f"回填完成,共处理 {len(dates_to_process)} 天,"
f"失败: {failed_dates}"
)
else:
logger.info(f"缺失数据回填完成,共处理 {len(dates_to_process)}")
else:
logger.info("统计数据已是最新,无需回填")
return
# 定时任务:聚合昨天 (UTC) 的数据
yesterday_utc = today_utc - timedelta(days=1)
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
user_ids = [user_id for (user_id,) in users]
StatsAggregatorService.aggregate_daily_stats_bundle(
db, yesterday_utc, user_ids=user_ids
)
StatsAggregatorService.update_summary(db)
logger.info("统计数据聚合完成")
except Exception as e:
logger.exception(f"统计聚合任务执行失败: {e}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()
async def _perform_wallet_daily_usage_aggregation(self) -> None:
if self._wallet_daily_usage_lock.locked():
logger.info("钱包每日消费汇总任务正在运行,跳过本次触发")
return
async with self._wallet_daily_usage_lock:
def _do() -> None:
db = create_session()
try:
logger.info("开始执行钱包每日消费汇总...")
billing_today = WalletDailyUsageLedgerService.get_today_billing_date()
billing_yesterday = billing_today - timedelta(days=1)
affected = WalletDailyUsageLedgerService.aggregate_day(db, billing_yesterday)
logger.info(
"钱包每日消费汇总完成: date={}, wallets={}",
billing_yesterday.isoformat(),
affected,
)
except Exception as e:
logger.exception("钱包每日消费汇总任务执行失败: {}", e)
try:
db.rollback()
except Exception:
pass
finally:
db.close()
await asyncio.to_thread(_do)
async def _perform_hourly_stats_aggregation(self) -> None:
"""执行小时统计聚合任务"""
def _do() -> None:
db = create_session()
try:
if not SystemConfigService.get_config(db, "enable_stats_aggregation", True):
logger.info("统计聚合已禁用,跳过小时聚合任务")
return
now_utc = datetime.now(timezone.utc)
last_hour = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
StatsAggregatorService.aggregate_hourly_stats_bundle(db, last_hour)
logger.info("小时统计聚合完成: {}", last_hour.isoformat())
except Exception as e:
logger.exception("小时统计聚合任务执行失败: {}", e)
try:
db.rollback()
except Exception:
pass
finally:
db.close()
await asyncio.to_thread(_do)
async def _perform_pending_cleanup(self) -> None:
"""执行 pending 状态清理"""
def _do_pending_cleanup() -> int:
db = create_session()
try:
from src.services.usage.service import UsageService
timeout_minutes = SystemConfigService.get_config(
db, "pending_request_timeout_minutes", 10
)
# pending 清理涉及 candidate 表关联查询,限制批次大小以控制内存
batch_size = min(
max(SystemConfigService.get_config(db, "cleanup_batch_size", 1000), 1),
200,
)
return UsageService.cleanup_stale_pending_requests(
db,
timeout_minutes=timeout_minutes,
batch_size=batch_size,
)
except Exception as e:
logger.exception(f"清理 pending 请求失败: {e}")
db.rollback()
return 0
finally:
db.close()
loop = asyncio.get_running_loop()
cleaned_count = await loop.run_in_executor(None, _do_pending_cleanup)
if cleaned_count > 0:
logger.info(f"清理了 {cleaned_count} 条超时的 pending/streaming 请求")
async def _perform_audit_cleanup(self) -> None:
"""执行审计日志清理任务"""
def _do_audit_cleanup() -> int:
db = create_session()
try:
if not SystemConfigService.get_config(db, "enable_auto_cleanup", True):
logger.info("自动清理已禁用,跳过审计日志清理")
return 0
audit_retention_days = max(
SystemConfigService.get_config(db, "audit_log_retention_days", 30),
7,
)
batch_size = SystemConfigService.get_config(db, "cleanup_batch_size", 1000)
cutoff_time = datetime.now(timezone.utc) - timedelta(days=audit_retention_days)
logger.info(f"开始清理 {audit_retention_days} 天前的审计日志...")
total_deleted = 0
while True:
batch_db = create_session()
try:
records_to_delete = (
batch_db.query(AuditLog.id)
.filter(AuditLog.created_at < cutoff_time)
.limit(batch_size)
.all()
)
if not records_to_delete:
break
record_ids = [r.id for r in records_to_delete]
result = batch_db.execute(
delete(AuditLog)
.where(AuditLog.id.in_(record_ids))
.execution_options(synchronize_session=False)
)
rows_deleted = result.rowcount
batch_db.commit()
total_deleted += rows_deleted
logger.debug(f"已删除 {rows_deleted} 条审计日志,累计 {total_deleted}")
except Exception as e:
logger.exception(f"删除审计日志批次失败: {e}")
try:
batch_db.rollback()
except Exception:
pass
break
finally:
batch_db.close()
return total_deleted
except Exception as e:
logger.exception(f"审计日志清理失败: {e}")
return 0
finally:
db.close()
loop = asyncio.get_running_loop()
total_deleted = await loop.run_in_executor(None, _do_audit_cleanup)
if total_deleted > 0:
logger.info(f"审计日志清理完成,共删除 {total_deleted} 条记录")
else:
logger.info("无需清理的审计日志")
async def _perform_gemini_file_mapping_cleanup(self) -> None:
"""清理过期的 Gemini 文件映射记录"""
def _do() -> None:
db = create_session()
try:
from src.services.gemini_files_mapping import cleanup_expired_mappings
deleted_count = cleanup_expired_mappings(db)
if deleted_count > 0:
logger.info(f"清理了 {deleted_count} 条过期的 Gemini 文件映射")
except Exception as e:
logger.exception(f"Gemini 文件映射清理失败: {e}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()
await asyncio.to_thread(_do)
async def _perform_provider_checkin(self) -> None:
"""执行 Provider 签到任务
遍历所有已配置 provider_ops 的 Provider触发签到。
签到会在余额查询时一起执行(先签到再查询余额)。
"""
def _load_provider_ids() -> list[str]:
db = create_session()
try:
if not SystemConfigService.get_config(db, "enable_provider_checkin", True):
return []
providers = (
db.query(Provider.id, Provider.config)
.filter(Provider.is_active.is_(True))
.all()
)
return [p.id for p in providers if p.config and p.config.get("provider_ops")]
finally:
db.close()
try:
provider_ids = await asyncio.to_thread(_load_provider_ids)
if not provider_ids:
logger.info("无已配置的 Provider跳过签到任务")
return
logger.info(f"开始执行 Provider 签到,共 {len(provider_ids)} 个...")
# 使用信号量限制并发,避免同时发起过多请求
concurrency = 3 # 签到任务并发数
semaphore = asyncio.Semaphore(concurrency)
async def _checkin_provider(provider_id: str) -> tuple[str, bool, str]:
"""执行单个 Provider 的签到"""
async with semaphore:
task_db = create_session()
try:
service = ProviderOpsService(task_db)
# 触发余额查询(会先执行签到)
result = await service.query_balance(provider_id)
# 检查签到结果
checkin_success = None
checkin_message = ""
if result.data and hasattr(result.data, "extra") and result.data.extra:
checkin_success = result.data.extra.get("checkin_success")
checkin_message = result.data.extra.get("checkin_message", "")
if checkin_success is True:
return provider_id, True, checkin_message
elif checkin_success is False:
return provider_id, False, checkin_message
else:
# None 表示未执行签到(可能没配置 Cookie
return provider_id, False, "未执行签到"
except Exception as e:
logger.warning(f"Provider {provider_id} 签到失败: {e}")
return provider_id, False, str(e)
finally:
try:
task_db.close()
except Exception:
pass
# 并行执行签到
tasks = [_checkin_provider(pid) for pid in provider_ids]
results = await asyncio.gather(*tasks)
# 统计结果
success_count = sum(1 for _, success, _ in results if success)
logger.info(f"Provider 签到完成: {success_count}/{len(provider_ids)} 成功")
# 记录详细结果
for provider_id, success, message in results:
if success:
logger.debug(f" - {provider_id}: 签到成功 - {message}")
elif message != "未执行签到":
logger.debug(f" - {provider_id}: 签到失败 - {message}")
except Exception as e:
logger.exception(f"Provider 签到任务执行失败: {e}")
async def _perform_candidate_cleanup(self) -> None:
"""清理过期的 request_candidates 记录"""
def _do_candidate_cleanup() -> int:
db = create_session()
try:
if not SystemConfigService.get_config(db, "enable_auto_cleanup", True):
logger.info("自动清理已禁用,跳过候选记录清理")
return 0
retention_days = max(
SystemConfigService.get_config(
db,
"request_candidates_retention_days",
SystemConfigService.get_config(db, "detail_log_retention_days", 7),
),
3,
)
batch_size = max(
SystemConfigService.get_config(
db,
"request_candidates_cleanup_batch_size",
SystemConfigService.get_config(db, "cleanup_batch_size", 1000),
),
1,
)
cutoff_time = datetime.now(timezone.utc) - timedelta(days=retention_days)
logger.info(
"开始清理 {} 天前的请求候选记录batch_size={}",
retention_days,
batch_size,
)
except Exception as e:
logger.exception(f"候选记录清理配置读取失败: {e}")
return 0
finally:
db.close()
total_deleted = 0
while True:
batch_db = create_session()
try:
records_to_delete = (
batch_db.query(RequestCandidate.id)
.filter(RequestCandidate.created_at < cutoff_time)
.order_by(RequestCandidate.created_at.asc(), RequestCandidate.id.asc())
.limit(batch_size)
.all()
)
if not records_to_delete:
break
record_ids = [r.id for r in records_to_delete]
result = batch_db.execute(
delete(RequestCandidate)
.where(RequestCandidate.id.in_(record_ids))
.execution_options(synchronize_session=False)
)
rows_deleted = result.rowcount
batch_db.commit()
total_deleted += rows_deleted
logger.debug(f"已删除 {rows_deleted} 条候选记录,累计 {total_deleted}")
except Exception as e:
logger.exception(f"删除候选记录批次失败: {e}")
try:
batch_db.rollback()
except Exception:
pass
break
finally:
batch_db.close()
return total_deleted
loop = asyncio.get_running_loop()
total_deleted = await loop.run_in_executor(None, _do_candidate_cleanup)
if total_deleted > 0:
logger.info(f"请求候选记录清理完成,共删除 {total_deleted} 条记录")
else:
logger.info("无需清理的候选记录")
async def _perform_db_maintenance(self) -> None:
"""执行数据库表维护VACUUM ANALYZE
对大表执行 VACUUM ANALYZE防止表和索引膨胀。
VACUUM 不能在事务内执行,需要使用 autocommit 连接。
使用线程池执行,避免阻塞事件循环。
"""
db = create_session()
try:
if not SystemConfigService.get_config(db, "enable_db_maintenance", True):
logger.info("数据库维护已禁用,跳过")
return
except Exception as e:
logger.exception(f"读取数据库维护配置失败: {e}")
return
finally:
db.close()
tables = ["usage", "request_candidates", "audit_logs"]
logger.info(f"开始数据库表维护VACUUM ANALYZE目标表: {', '.join(tables)}")
from src.database.database import _ensure_engine
engine = _ensure_engine()
def _vacuum_table(table_name: str) -> tuple[str, bool, str]:
"""在线程池中执行 VACUUM ANALYZE同步阻塞操作"""
try:
with engine.connect() as raw_conn:
conn = raw_conn.execution_options(isolation_level="AUTOCOMMIT")
conn.execute(text(f"VACUUM ANALYZE {table_name}"))
return table_name, True, ""
except Exception as e:
return table_name, False, str(e)
loop = asyncio.get_running_loop()
for table in tables:
table_name, success, error = await loop.run_in_executor(None, _vacuum_table, table)
if success:
logger.info(f"VACUUM ANALYZE {table_name} 完成")
else:
logger.warning(f"VACUUM ANALYZE {table_name} 失败: {error}")
logger.info("数据库表维护完成")
async def _perform_cleanup(self) -> None:
"""执行清理任务(在线程池中运行,避免阻塞事件循环)"""
def _do_cleanup() -> None:
db = create_session()
try:
if not SystemConfigService.get_config(db, "enable_auto_cleanup", True):
logger.info("自动清理已禁用,跳过清理任务")
return
logger.info("开始执行使用记录分级清理...")
detail_retention = SystemConfigService.get_config(
db, "detail_log_retention_days", 7
)
compressed_retention = SystemConfigService.get_config(
db, "compressed_log_retention_days", 30
)
header_retention = SystemConfigService.get_config(db, "header_retention_days", 90)
log_retention = SystemConfigService.get_config(db, "log_retention_days", 365)
batch_size = SystemConfigService.get_config(db, "cleanup_batch_size", 1000)
auto_delete = SystemConfigService.get_config(db, "auto_delete_expired_keys", False)
except Exception as e:
logger.exception(f"清理任务配置读取失败: {e}")
return
finally:
db.close()
try:
now = datetime.now(timezone.utc)
detail_cutoff = now - timedelta(days=detail_retention)
compressed_cutoff = now - timedelta(days=compressed_retention)
header_cutoff = now - timedelta(days=header_retention)
log_cutoff = now - timedelta(days=log_retention)
# 先删最老的整行,再按窗口处理剩余记录,避免同一行在一轮里被重复改写。
records_deleted = self._delete_old_records(log_cutoff, batch_size)
header_cleaned = self._cleanup_header_fields(
header_cutoff, batch_size, newer_than=log_cutoff
)
body_cleaned = self._cleanup_stale_body_fields(
compressed_cutoff, batch_size, newer_than=log_cutoff
)
# 仅压缩 7-30 天窗口内的 body更老记录直接清空 body不再先压缩再清理。
body_compressed = self._cleanup_body_fields(
detail_cutoff, batch_size, newer_than=compressed_cutoff
)
# 5. 清理过期的API Keys
keys_db = create_session()
try:
keys_cleaned = ApiKeyService.cleanup_expired_keys(
keys_db, auto_delete=auto_delete
)
except Exception as e:
logger.exception(f"清理过期 Keys 失败: {e}")
keys_cleaned = 0
finally:
keys_db.close()
logger.info(
f"清理完成: 压缩 {body_compressed} 条, "
f"清理body {body_cleaned} 条, "
f"清理header {header_cleaned} 条, "
f"删除记录 {records_deleted} 条, "
f"清理过期Keys {keys_cleaned}"
)
except Exception as e:
logger.exception(f"清理任务执行失败: {e}")
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _do_cleanup)
def _cleanup_body_fields(
self,
cutoff_time: datetime,
batch_size: int,
*,
newer_than: datetime | None = None,
) -> int:
"""压缩 request_body 和 response_body 字段到压缩字段
仅处理指定时间窗口内仍保留原始 body 的记录,避免对更老记录重复写放大。
"""
from sqlalchemy import null, update
total_compressed = 0
no_progress_count = 0
memory_safe_batch_size = max(1, min(batch_size, 25))
if newer_than is not None and newer_than >= cutoff_time:
logger.warning(
"压缩 body 字段跳过: 无效时间窗口 newer_than={} cutoff_time={}",
newer_than,
cutoff_time,
)
return 0
while True:
batch_db = create_session()
try:
query = batch_db.query(
Usage.id,
Usage.request_body,
Usage.response_body,
Usage.provider_request_body,
Usage.client_response_body,
).filter(Usage.created_at < cutoff_time)
if newer_than is not None:
query = query.filter(Usage.created_at >= newer_than)
records = (
query.filter(
(Usage.request_body.isnot(None))
| (Usage.response_body.isnot(None))
| (Usage.provider_request_body.isnot(None))
| (Usage.client_response_body.isnot(None))
)
.order_by(Usage.created_at.asc(), Usage.id.asc())
.limit(memory_safe_batch_size)
.all()
)
if not records:
break
batch_success = 0
batch_progress = False
for record in records:
result = batch_db.execute(
update(Usage)
.where(Usage.id == record.id)
.values(
request_body=null(),
response_body=null(),
provider_request_body=null(),
client_response_body=null(),
request_body_compressed=(
compress_json(record.request_body) if record.request_body else None
),
response_body_compressed=(
compress_json(record.response_body)
if record.response_body
else None
),
provider_request_body_compressed=(
compress_json(record.provider_request_body)
if record.provider_request_body
else None
),
client_response_body_compressed=(
compress_json(record.client_response_body)
if record.client_response_body
else None
),
)
.execution_options(synchronize_session=False)
)
if result.rowcount > 0:
batch_success += 1
batch_progress = True
batch_db.commit()
except Exception as e:
logger.warning("压缩 body 批次失败: {}", e)
try:
batch_db.rollback()
except Exception:
pass
break
finally:
batch_db.close()
if not batch_progress:
no_progress_count += 1
if no_progress_count >= 3:
logger.error(
f"压缩 body 字段连续 {no_progress_count} 批无进展," "终止循环以避免死循环"
)
break
else:
no_progress_count = 0
total_compressed += batch_success
if batch_success > 0:
logger.debug(
f"已压缩 {batch_success} 条记录的 body 字段,累计 {total_compressed}"
)
return total_compressed
def _cleanup_stale_body_fields(
self,
cutoff_time: datetime,
batch_size: int,
*,
newer_than: datetime | None = None,
) -> int:
"""清理已超过压缩保留期的 body 字段
直接清空 raw/compressed body避免更老记录先压缩再马上被清掉。
"""
from sqlalchemy import null, update
total_cleaned = 0
if newer_than is not None and newer_than >= cutoff_time:
logger.warning(
"清理 body 字段跳过: 无效时间窗口 newer_than={} cutoff_time={}",
newer_than,
cutoff_time,
)
return 0
while True:
batch_db = create_session()
try:
query = batch_db.query(Usage.id).filter(Usage.created_at < cutoff_time)
if newer_than is not None:
query = query.filter(Usage.created_at >= newer_than)
records_to_clean = (
query.filter(
(Usage.request_body.isnot(None))
| (Usage.response_body.isnot(None))
| (Usage.provider_request_body.isnot(None))
| (Usage.client_response_body.isnot(None))
| (Usage.request_body_compressed.isnot(None))
| (Usage.response_body_compressed.isnot(None))
| (Usage.provider_request_body_compressed.isnot(None))
| (Usage.client_response_body_compressed.isnot(None))
)
.order_by(Usage.created_at.asc(), Usage.id.asc())
.limit(batch_size)
.all()
)
if not records_to_clean:
break
record_ids = [r.id for r in records_to_clean]
result = batch_db.execute(
update(Usage)
.where(Usage.id.in_(record_ids))
.values(
request_body=null(),
response_body=null(),
provider_request_body=null(),
client_response_body=null(),
request_body_compressed=null(),
response_body_compressed=null(),
provider_request_body_compressed=null(),
client_response_body_compressed=null(),
)
)
rows_updated = result.rowcount
batch_db.commit()
if rows_updated == 0:
logger.warning("清理 body 字段: rowcount=0可能存在问题")
break
total_cleaned += rows_updated
logger.debug(f"已清理 {rows_updated} 条记录的 body 字段,累计 {total_cleaned}")
except Exception as e:
logger.exception(f"清理 body 字段失败: {e}")
try:
batch_db.rollback()
except Exception:
pass
break
finally:
batch_db.close()
return total_cleaned
def _cleanup_header_fields(
self,
cutoff_time: datetime,
batch_size: int,
*,
newer_than: datetime | None = None,
) -> int:
"""清理 request_headers, response_headers 和 provider_request_headers 字段
每批使用短生命周期 session同步方法在线程池中调用
"""
from sqlalchemy import null, update
total_cleaned = 0
if newer_than is not None and newer_than >= cutoff_time:
logger.warning(
"清理 header 字段跳过: 无效时间窗口 newer_than={} cutoff_time={}",
newer_than,
cutoff_time,
)
return 0
while True:
batch_db = create_session()
try:
query = batch_db.query(Usage.id).filter(Usage.created_at < cutoff_time)
if newer_than is not None:
query = query.filter(Usage.created_at >= newer_than)
records_to_clean = (
query.filter(
(Usage.request_headers.isnot(None))
| (Usage.response_headers.isnot(None))
| (Usage.provider_request_headers.isnot(None))
| (Usage.client_response_headers.isnot(None))
)
.order_by(Usage.created_at.asc(), Usage.id.asc())
.limit(batch_size)
.all()
)
if not records_to_clean:
break
record_ids = [r.id for r in records_to_clean]
result = batch_db.execute(
update(Usage)
.where(Usage.id.in_(record_ids))
.values(
request_headers=null(),
response_headers=null(),
provider_request_headers=null(),
client_response_headers=null(),
)
)
rows_updated = result.rowcount
batch_db.commit()
if rows_updated == 0:
logger.warning("清理 header 字段: rowcount=0可能存在问题")
break
total_cleaned += rows_updated
logger.debug(f"已清理 {rows_updated} 条记录的 header 字段,累计 {total_cleaned}")
except Exception as e:
logger.exception(f"清理 header 字段失败: {e}")
try:
batch_db.rollback()
except Exception:
pass
break
finally:
batch_db.close()
return total_cleaned
def _delete_old_records(self, cutoff_time: datetime, batch_size: int) -> int:
"""删除过期的完整记录(同步方法,在线程池中调用)"""
total_deleted = 0
while True:
batch_db = create_session()
try:
records_to_delete = (
batch_db.query(Usage.id)
.filter(Usage.created_at < cutoff_time)
.order_by(Usage.created_at.asc(), Usage.id.asc())
.limit(batch_size)
.all()
)
if not records_to_delete:
break
record_ids = [r.id for r in records_to_delete]
result = batch_db.execute(
delete(Usage)
.where(Usage.id.in_(record_ids))
.execution_options(synchronize_session=False)
)
rows_deleted = result.rowcount
batch_db.commit()
total_deleted += rows_deleted
logger.debug(f"已删除 {rows_deleted} 条过期记录,累计 {total_deleted}")
except Exception as e:
logger.exception(f"删除过期记录失败: {e}")
try:
batch_db.rollback()
except Exception:
pass
break
finally:
batch_db.close()
return total_deleted
def should_start_python_maintenance_scheduler() -> bool:
return bool(
config.audit_cleanup_python_enabled
or config.antigravity_ua_refresh_python_enabled
or config.db_maintenance_python_enabled
or config.gemini_file_mapping_cleanup_python_enabled
or config.http_client_idle_cleanup_python_enabled
or config.pending_cleanup_python_enabled
or config.pool_monitor_python_enabled
or config.provider_checkin_python_enabled
or config.request_candidate_cleanup_python_enabled
or config.stats_aggregation_python_enabled
or config.stats_hourly_aggregation_python_enabled
or config.usage_cleanup_python_enabled
or config.wallet_daily_usage_aggregation_python_enabled
)
# 全局单例
_maintenance_scheduler = None
def get_maintenance_scheduler() -> MaintenanceScheduler:
"""获取维护调度器单例"""
global _maintenance_scheduler
if _maintenance_scheduler is None:
_maintenance_scheduler = MaintenanceScheduler()
return _maintenance_scheduler