feat(cleanup): 解耦 request_candidates 与 provider_api_keys 生命周期

- 移除 request_candidates.key_id 对 provider_api_keys 的外键约束(含迁移脚本)
- 删除 Key 时不再级联删除候选记录,改为独立按保留天数定时清理
- 新增 request_candidates_retention_days / request_candidates_cleanup_batch_size 配置项
- batch_delete_task 增加 lock_timeout 及超时自动降批重试机制
- cleanup_key_references 提取阶段化清理流程,移除 RequestCandidate 联动删除
- 前端 CleanupPolicySection 新增候选记录保留天数和清理批次配置

Closes #227

Co-authored-by: Entropy-Xu <entropy.xu@cloudhabitatsh.com>
This commit is contained in:
fawney19
2026-03-14 01:31:39 +08:00
parent bdfe4adc98
commit 776dd2f8ea
14 changed files with 516 additions and 70 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
# Edit at https://www.toptal.com/developers/gitignore?templates=python # Edit at https://www.toptal.com/developers/gitignore?templates=python
# AI Assistant Configuration # AI Assistant Configuration
.codex/
.claude/ .claude/
.serena/ .serena/
.gemini*/ .gemini*/

View File

@@ -0,0 +1,64 @@
"""decouple request_candidates.key_id foreign key from provider_api_keys lifecycle
Revision ID: b7c8d9e0f1a2
Revises: c1d2e3f4a5b6
Create Date: 2026-03-12 19:15:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "b7c8d9e0f1a2"
down_revision = "c1d2e3f4a5b6"
branch_labels = None
depends_on = None
def _fk_exists(constraint_name: str, table_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_constraint c "
"JOIN pg_class r ON c.conrelid = r.oid "
"JOIN pg_namespace n ON r.relnamespace = n.oid "
"WHERE c.conname = :name AND r.relname = :table "
" AND n.nspname = current_schema() AND c.contype = 'f'"
),
{"name": constraint_name, "table": table_name},
)
return result.scalar() is not None
def upgrade() -> None:
if _fk_exists("request_candidates_key_id_fkey", "request_candidates"):
op.drop_constraint(
"request_candidates_key_id_fkey", "request_candidates", type_="foreignkey"
)
def downgrade() -> None:
bind = op.get_bind()
bind.execute(
sa.text(
"UPDATE request_candidates rc "
"SET key_id = NULL "
"WHERE key_id IS NOT NULL "
" AND NOT EXISTS ("
" SELECT 1 FROM provider_api_keys pak WHERE pak.id = rc.key_id"
" )"
)
)
if not _fk_exists("request_candidates_key_id_fkey", "request_candidates"):
op.create_foreign_key(
"request_candidates_key_id_fkey",
"request_candidates",
"provider_api_keys",
["key_id"],
["id"],
ondelete="CASCADE",
)

View File

@@ -99,6 +99,8 @@
:log-retention-days="systemConfig.log_retention_days" :log-retention-days="systemConfig.log_retention_days"
:cleanup-batch-size="systemConfig.cleanup_batch_size" :cleanup-batch-size="systemConfig.cleanup_batch_size"
:audit-log-retention-days="systemConfig.audit_log_retention_days" :audit-log-retention-days="systemConfig.audit_log_retention_days"
:request-candidates-retention-days="systemConfig.request_candidates_retention_days"
:request-candidates-cleanup-batch-size="systemConfig.request_candidates_cleanup_batch_size"
:loading="cleanupConfigLoading" :loading="cleanupConfigLoading"
:has-changes="hasCleanupConfigChanges" :has-changes="hasCleanupConfigChanges"
@save="saveCleanupConfig" @save="saveCleanupConfig"
@@ -109,6 +111,8 @@
@update:log-retention-days="systemConfig.log_retention_days = $event" @update:log-retention-days="systemConfig.log_retention_days = $event"
@update:cleanup-batch-size="systemConfig.cleanup_batch_size = $event" @update:cleanup-batch-size="systemConfig.cleanup_batch_size = $event"
@update:audit-log-retention-days="systemConfig.audit_log_retention_days = $event" @update:audit-log-retention-days="systemConfig.audit_log_retention_days = $event"
@update:request-candidates-retention-days="systemConfig.request_candidates_retention_days = $event"
@update:request-candidates-cleanup-batch-size="systemConfig.request_candidates_cleanup_batch_size = $event"
/> />
<!-- 定时任务 --> <!-- 定时任务 -->

View File

@@ -152,6 +152,46 @@
超过后删除审计日志记录 超过后删除审计日志记录
</p> </p>
</div> </div>
<div>
<Label
for="request-candidates-retention-days"
class="block text-sm font-medium"
>
候选记录保留天数
</Label>
<Input
id="request-candidates-retention-days"
:model-value="requestCandidatesRetentionDays"
type="number"
placeholder="30"
class="mt-1"
@update:model-value="$emit('update:requestCandidatesRetentionDays', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
过期后按时间窗口批量清理 request_candidates 审计记录
</p>
</div>
<div>
<Label
for="request-candidates-cleanup-batch-size"
class="block text-sm font-medium"
>
候选记录清理批次
</Label>
<Input
id="request-candidates-cleanup-batch-size"
:model-value="requestCandidatesCleanupBatchSize"
type="number"
placeholder="5000"
class="mt-1"
@update:model-value="$emit('update:requestCandidatesCleanupBatchSize', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
独立控制候选记录大表清理节奏不再跟随 Key 删除联动
</p>
</div>
</div> </div>
<!-- 清理策略说明 --> <!-- 清理策略说明 -->
@@ -164,7 +204,7 @@
<p>2. <strong>压缩日志阶段</strong>: body 字段被压缩存储节省空间</p> <p>2. <strong>压缩日志阶段</strong>: body 字段被压缩存储节省空间</p>
<p>3. <strong>统计阶段</strong>: 仅保留 tokens成本等统计信息</p> <p>3. <strong>统计阶段</strong>: 仅保留 tokens成本等统计信息</p>
<p>4. <strong>归档删除</strong>: 超过保留期限后完全删除记录</p> <p>4. <strong>归档删除</strong>: 超过保留期限后完全删除记录</p>
<p>5. <strong>候选记录</strong>: 与详细记录同步清理请求候选记录request_candidates</p> <p>5. <strong>候选记录</strong>: 独立按保留天数清理 request_candidates 审计记录不再跟随 Key 删除联动</p>
<p>6. <strong>审计日志</strong>: 独立清理记录用户登录操作等安全事件</p> <p>6. <strong>审计日志</strong>: 独立清理记录用户登录操作等安全事件</p>
</div> </div>
</div> </div>
@@ -186,6 +226,8 @@ defineProps<{
logRetentionDays: number logRetentionDays: number
cleanupBatchSize: number cleanupBatchSize: number
auditLogRetentionDays: number auditLogRetentionDays: number
requestCandidatesRetentionDays: number
requestCandidatesCleanupBatchSize: number
loading: boolean loading: boolean
hasChanges: boolean hasChanges: boolean
}>() }>()
@@ -199,5 +241,7 @@ defineEmits<{
'update:logRetentionDays': [value: number] 'update:logRetentionDays': [value: number]
'update:cleanupBatchSize': [value: number] 'update:cleanupBatchSize': [value: number]
'update:auditLogRetentionDays': [value: number] 'update:auditLogRetentionDays': [value: number]
'update:requestCandidatesRetentionDays': [value: number]
'update:requestCandidatesCleanupBatchSize': [value: number]
}>() }>()
</script> </script>

View File

@@ -32,6 +32,8 @@ export interface SystemConfig {
log_retention_days: number log_retention_days: number
cleanup_batch_size: number cleanup_batch_size: number
audit_log_retention_days: number audit_log_retention_days: number
request_candidates_retention_days: number
request_candidates_cleanup_batch_size: number
// 定时任务 // 定时任务
enable_provider_checkin: boolean enable_provider_checkin: boolean
provider_checkin_time: string provider_checkin_time: string
@@ -66,6 +68,8 @@ const CONFIG_KEYS = [
'log_retention_days', 'log_retention_days',
'cleanup_batch_size', 'cleanup_batch_size',
'audit_log_retention_days', 'audit_log_retention_days',
'request_candidates_retention_days',
'request_candidates_cleanup_batch_size',
// 定时任务 // 定时任务
'enable_provider_checkin', 'enable_provider_checkin',
'provider_checkin_time', 'provider_checkin_time',
@@ -101,6 +105,8 @@ function createDefaultConfig(): SystemConfig {
log_retention_days: 365, log_retention_days: 365,
cleanup_batch_size: 1000, cleanup_batch_size: 1000,
audit_log_retention_days: 30, audit_log_retention_days: 30,
request_candidates_retention_days: 30,
request_candidates_cleanup_batch_size: 5000,
// 定时任务 // 定时任务
enable_provider_checkin: true, enable_provider_checkin: true,
provider_checkin_time: '01:05', provider_checkin_time: '01:05',
@@ -171,7 +177,11 @@ export function useSystemConfig() {
systemConfig.value.log_retention_days !== originalConfig.value.log_retention_days || systemConfig.value.log_retention_days !== originalConfig.value.log_retention_days ||
systemConfig.value.cleanup_batch_size !== originalConfig.value.cleanup_batch_size || systemConfig.value.cleanup_batch_size !== originalConfig.value.cleanup_batch_size ||
systemConfig.value.audit_log_retention_days !== systemConfig.value.audit_log_retention_days !==
originalConfig.value.audit_log_retention_days originalConfig.value.audit_log_retention_days ||
systemConfig.value.request_candidates_retention_days !==
originalConfig.value.request_candidates_retention_days ||
systemConfig.value.request_candidates_cleanup_batch_size !==
originalConfig.value.request_candidates_cleanup_batch_size
) )
}) })
@@ -421,6 +431,16 @@ export function useSystemConfig() {
value: systemConfig.value.audit_log_retention_days, value: systemConfig.value.audit_log_retention_days,
description: '审计日志保留天数', description: '审计日志保留天数',
}, },
{
key: 'request_candidates_retention_days',
value: systemConfig.value.request_candidates_retention_days,
description: '请求候选记录保留天数',
},
{
key: 'request_candidates_cleanup_batch_size',
value: systemConfig.value.request_candidates_cleanup_batch_size,
description: '请求候选记录每批次清理条数',
},
] ]
await Promise.all( await Promise.all(
@@ -438,6 +458,10 @@ export function useSystemConfig() {
originalConfig.value.cleanup_batch_size = systemConfig.value.cleanup_batch_size originalConfig.value.cleanup_batch_size = systemConfig.value.cleanup_batch_size
originalConfig.value.audit_log_retention_days = originalConfig.value.audit_log_retention_days =
systemConfig.value.audit_log_retention_days systemConfig.value.audit_log_retention_days
originalConfig.value.request_candidates_retention_days =
systemConfig.value.request_candidates_retention_days
originalConfig.value.request_candidates_cleanup_batch_size =
systemConfig.value.request_candidates_cleanup_batch_size
} }
success('请求记录清理配置已保存') success('请求记录清理配置已保存')
} catch (err) { } catch (err) {

View File

@@ -2481,12 +2481,7 @@ class RequestCandidate(Base):
nullable=True, nullable=True,
index=True, index=True,
) )
key_id = Column( key_id = Column(String(36), nullable=True, index=True, comment="Provider Key ID 快照")
String(36),
ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
# 状态信息 # 状态信息
status = Column( status = Column(
@@ -2533,7 +2528,6 @@ class RequestCandidate(Base):
api_key = relationship("ApiKey") api_key = relationship("ApiKey")
provider = relationship("Provider") provider = relationship("Provider")
endpoint = relationship("ProviderEndpoint") endpoint = relationship("ProviderEndpoint")
key = relationship("ProviderAPIKey")
# ==================== 统计数据模型 ==================== # ==================== 统计数据模型 ====================

View File

@@ -27,13 +27,7 @@ class CandidateRecorder:
if getattr(row, "provider", None) is not None: if getattr(row, "provider", None) is not None:
provider_name = getattr(row.provider, "name", None) provider_name = getattr(row.provider, "name", None)
key_name = None key_name = getattr(row, "api_key_name", None)
auth_type = None
priority = None
if getattr(row, "key", None) is not None:
key_name = getattr(row.key, "name", None)
auth_type = getattr(row.key, "auth_type", None)
priority = getattr(row.key, "priority", None)
result.append( result.append(
CandidateKey( CandidateKey(
@@ -44,8 +38,6 @@ class CandidateRecorder:
endpoint_id=str(row.endpoint_id) if row.endpoint_id else None, endpoint_id=str(row.endpoint_id) if row.endpoint_id else None,
key_id=str(row.key_id) if row.key_id else None, key_id=str(row.key_id) if row.key_id else None,
key_name=str(key_name) if key_name else None, key_name=str(key_name) if key_name else None,
auth_type=str(auth_type) if auth_type else None,
priority=int(priority) if priority is not None else None,
is_cached=bool(getattr(row, "is_cached", False)), is_cached=bool(getattr(row, "is_cached", False)),
status=str(getattr(row, "status", "") or "pending"), status=str(getattr(row, "status", "") or "pending"),
skip_reason=getattr(row, "skip_reason", None), skip_reason=getattr(row, "skip_reason", None),

View File

@@ -16,6 +16,7 @@ from concurrent.futures import Future
import redis.asyncio as aioredis import redis.asyncio as aioredis
from sqlalchemy import delete as sa_delete from sqlalchemy import delete as sa_delete
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session
from src.clients.redis_client import get_redis_client from src.clients.redis_client import get_redis_client
from src.core.logger import logger from src.core.logger import logger
@@ -36,9 +37,15 @@ _CLEANUP_BATCH_SIZE = 50
# 单个批次的数据库 statement 超时(秒) # 单个批次的数据库 statement 超时(秒)
_BATCH_STATEMENT_TIMEOUT_S = 30 _BATCH_STATEMENT_TIMEOUT_S = 30
# 单个批次的数据库锁等待超时(秒)
_BATCH_LOCK_TIMEOUT_S = 5
# 整个任务的最大执行时间(秒) # 整个任务的最大执行时间(秒)
_TASK_TIMEOUT_S = 600 _TASK_TIMEOUT_S = 600
# 发生超时/锁等待时降批到的最小 Key 数
_MIN_RETRY_BATCH_SIZE = 1
# Redis key 前缀 # Redis key 前缀
_REDIS_KEY_PREFIX = "batch_delete_task" _REDIS_KEY_PREFIX = "batch_delete_task"
@@ -50,6 +57,30 @@ def _task_key(task_id: str) -> str:
return f"{_REDIS_KEY_PREFIX}:{task_id}" return f"{_REDIS_KEY_PREFIX}:{task_id}"
def _apply_statement_timeouts(db: Session) -> None:
db.execute(text(f"SET LOCAL statement_timeout = '{_BATCH_STATEMENT_TIMEOUT_S * 1000}'"))
db.execute(text(f"SET LOCAL lock_timeout = '{_BATCH_LOCK_TIMEOUT_S * 1000}'"))
def _is_retryable_batch_error(exc: Exception) -> bool:
messages = [str(exc)]
orig = getattr(exc, "orig", None)
if orig is not None:
messages.append(str(orig))
text_blob = " ".join(messages).lower()
return any(
marker in text_blob
for marker in (
"querycanceled",
"statement timeout",
"canceling statement due to statement timeout",
"lock timeout",
"canceling statement due to lock timeout",
)
)
class BatchDeleteTaskInfo: class BatchDeleteTaskInfo:
"""任务状态数据对象(从 Redis 反序列化)。""" """任务状态数据对象(从 Redis 反序列化)。"""
@@ -177,6 +208,115 @@ async def get_batch_delete_task(task_id: str) -> BatchDeleteTaskInfo | None:
return await _load_task(task_id) return await _load_task(task_id)
def _delete_key_batch(
db: Session,
provider_id: str,
batch: list[str],
) -> int:
from src.models.database import ProviderAPIKey
phase = "cleanup_key_references"
def _set_phase(stage_name: str, _batch_size: int) -> None:
nonlocal phase
phase = stage_name
try:
_apply_statement_timeouts(db)
cleanup_key_references(
db,
batch,
batch_size=len(batch),
stage_callback=_set_phase,
)
phase = "provider_api_keys"
result = db.execute(
sa_delete(ProviderAPIKey).where(
ProviderAPIKey.provider_id == provider_id,
ProviderAPIKey.id.in_(batch),
)
)
rowcount = getattr(result, "rowcount", 0) or 0
db.commit()
return int(rowcount)
except Exception as exc:
setattr(exc, "_aether_batch_phase", phase)
raise
def _delete_key_batch_with_retry(
db: Session,
provider_id: str,
batch: list[str],
*,
batch_idx: int,
total_batches: int,
start_offset: int,
attempt: int = 1,
) -> int:
try:
return _delete_key_batch(db, provider_id, batch)
except Exception as exc:
try:
db.rollback()
except Exception:
pass
is_retryable = _is_retryable_batch_error(exc)
phase = getattr(exc, "_aether_batch_phase", "unknown")
can_split = len(batch) > _MIN_RETRY_BATCH_SIZE
if is_retryable and can_split:
split_at = max(len(batch) // 2, _MIN_RETRY_BATCH_SIZE)
left = batch[:split_at]
right = batch[split_at:]
logger.warning(
"[BATCH_DELETE] batch {}/{} retrying after timeout/lock (keys {}-{}, size={}, attempt={}, phase={}): split into {} + {}",
batch_idx,
total_batches,
start_offset,
start_offset + len(batch),
len(batch),
attempt,
phase,
len(left),
len(right),
)
deleted = _delete_key_batch_with_retry(
db,
provider_id,
left,
batch_idx=batch_idx,
total_batches=total_batches,
start_offset=start_offset,
attempt=attempt + 1,
)
if right:
deleted += _delete_key_batch_with_retry(
db,
provider_id,
right,
batch_idx=batch_idx,
total_batches=total_batches,
start_offset=start_offset + len(left),
attempt=attempt + 1,
)
return deleted
logger.warning(
"[BATCH_DELETE] batch {}/{} failed (keys {}-{} size={} attempt={} phase={} retryable={}): {}",
batch_idx,
total_batches,
start_offset,
start_offset + len(batch),
len(batch),
attempt,
phase,
is_retryable,
exc,
)
return 0
def _sync_delete( def _sync_delete(
provider_id: str, provider_id: str,
key_ids: list[str], key_ids: list[str],
@@ -188,7 +328,6 @@ def _sync_delete(
每个批次独立事务,单批失败跳过并继续。 每个批次独立事务,单批失败跳过并继续。
""" """
from src.database import create_session from src.database import create_session
from src.models.database import ProviderAPIKey
db = create_session() db = create_session()
try: try:
@@ -209,33 +348,14 @@ def _sync_delete(
batch = key_ids[i : i + _CLEANUP_BATCH_SIZE] batch = key_ids[i : i + _CLEANUP_BATCH_SIZE]
batch_idx += 1 batch_idx += 1
try: affected += _delete_key_batch_with_retry(
# 设置 statement_timeout防止单条 SQL 无限等锁 db,
timeout_ms = _BATCH_STATEMENT_TIMEOUT_S * 1000 provider_id,
db.execute(text(f"SET LOCAL statement_timeout = '{timeout_ms}'")) batch,
cleanup_key_references(db, batch) batch_idx=batch_idx,
result = db.execute( total_batches=total_batches,
sa_delete(ProviderAPIKey).where( start_offset=i,
ProviderAPIKey.provider_id == provider_id, )
ProviderAPIKey.id.in_(batch),
)
)
rowcount = getattr(result, "rowcount", 0) or 0
affected += int(rowcount)
db.commit()
except Exception as exc:
logger.warning(
"[BATCH_DELETE] batch {}/{} failed (keys {}-{}): {}",
batch_idx,
total_batches,
i,
i + len(batch),
exc,
)
try:
db.rollback()
except Exception:
pass
# 每个批次都上报一次进度 # 每个批次都上报一次进度
if progress_callback is not None: if progress_callback is not None:
progress_callback(affected) progress_callback(affected)

View File

@@ -4,6 +4,8 @@ Provider Key 写操作后的副作用处理。
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from sqlalchemy import delete as sa_delete from sqlalchemy import delete as sa_delete
from sqlalchemy import update as sa_update from sqlalchemy import update as sa_update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -12,7 +14,6 @@ from src.core.logger import logger
from src.models.database import ( from src.models.database import (
GeminiFileMapping, GeminiFileMapping,
ProviderAPIKey, ProviderAPIKey,
RequestCandidate,
Usage, Usage,
VideoTask, VideoTask,
) )
@@ -22,11 +23,34 @@ from src.services.cache.provider_cache import ProviderCacheService
_SQLITE_BATCH_SIZE = 900 _SQLITE_BATCH_SIZE = 900
_DEFAULT_BATCH_SIZE = 2000 _DEFAULT_BATCH_SIZE = 2000
_CLEANUP_STAGES = (
(
"gemini_file_mappings",
lambda batch: sa_delete(GeminiFileMapping).where(GeminiFileMapping.key_id.in_(batch)),
),
(
"usage",
lambda batch: sa_update(Usage)
.where(Usage.provider_api_key_id.in_(batch))
.values(provider_api_key_id=None),
),
(
"video_tasks",
lambda batch: sa_update(VideoTask).where(VideoTask.key_id.in_(batch)).values(key_id=None),
),
)
def cleanup_key_references(db: Session, key_ids: list[str]) -> None:
def cleanup_key_references(
db: Session,
key_ids: list[str],
*,
batch_size: int | None = None,
stage_callback: Callable[[str, int], None] | None = None,
) -> None:
"""在删除 ProviderAPIKey 前,先显式处理关联表引用,降低级联删除/置空成本。 """在删除 ProviderAPIKey 前,先显式处理关联表引用,降低级联删除/置空成本。
- request_candidates / gemini_file_mappings: 直接删除 - gemini_file_mappings: 直接删除
- usage / video_tasks: 先置空外键,保留快照与历史记录 - usage / video_tasks: 先置空外键,保留快照与历史记录
PostgreSQL 下直接按 key_id/provider_api_key_id 批量处理; PostgreSQL 下直接按 key_id/provider_api_key_id 批量处理;
@@ -34,16 +58,12 @@ def cleanup_key_references(db: Session, key_ids: list[str]) -> None:
""" """
if not key_ids: if not key_ids:
return return
batch_size = _resolve_batch_size(db) effective_batch_size = batch_size if batch_size is not None else _resolve_batch_size(db)
for batch in _iter_batches(key_ids, batch_size): for batch in iter_key_batches(key_ids, effective_batch_size):
db.execute(sa_delete(RequestCandidate).where(RequestCandidate.key_id.in_(batch))) for stage_name, statement_factory in _CLEANUP_STAGES:
db.execute(sa_delete(GeminiFileMapping).where(GeminiFileMapping.key_id.in_(batch))) if stage_callback is not None:
db.execute( stage_callback(stage_name, len(batch))
sa_update(Usage) db.execute(statement_factory(batch))
.where(Usage.provider_api_key_id.in_(batch))
.values(provider_api_key_id=None)
)
db.execute(sa_update(VideoTask).where(VideoTask.key_id.in_(batch)).values(key_id=None))
def _resolve_batch_size(db: Session) -> int: def _resolve_batch_size(db: Session) -> int:
@@ -57,10 +77,13 @@ def _resolve_batch_size(db: Session) -> int:
return _DEFAULT_BATCH_SIZE return _DEFAULT_BATCH_SIZE
def _iter_batches(items: list[str], batch_size: int) -> list[list[str]]: def iter_key_batches(items: list[str], batch_size: int) -> list[list[str]]:
"""将 key_ids 列表按 batch_size 拆分为子列表。"""
if not items:
return []
if batch_size <= 0: if batch_size <= 0:
return [items] return [list(items)]
return [items[i : i + batch_size] for i in range(0, len(items), batch_size)] return [list(items[i : i + batch_size]) for i in range(0, len(items), batch_size)]
async def run_update_key_side_effects( async def run_update_key_side_effects(

View File

@@ -144,6 +144,14 @@ class SystemConfigService:
"value": 1000, "value": 1000,
"description": "每批次清理的记录数,避免单次操作过大影响数据库性能", "description": "每批次清理的记录数,避免单次操作过大影响数据库性能",
}, },
"request_candidates_retention_days": {
"value": 30,
"description": "请求候选记录保留天数,超过此天数的 request_candidates 审计记录将被自动清理",
},
"request_candidates_cleanup_batch_size": {
"value": 5000,
"description": "请求候选记录每批次清理条数,使用独立批次控制大表删除压力",
},
"enable_provider_checkin": { "enable_provider_checkin": {
"value": True, "value": True,
"description": "是否启用 Provider 自动签到任务", "description": "是否启用 Provider 自动签到任务",

View File

@@ -786,13 +786,28 @@ class MaintenanceScheduler:
return 0 return 0
retention_days = max( retention_days = max(
SystemConfigService.get_config(db, "detail_log_retention_days", 7), SystemConfigService.get_config(
db,
"request_candidates_retention_days",
SystemConfigService.get_config(db, "detail_log_retention_days", 7),
),
3, 3,
) )
batch_size = SystemConfigService.get_config(db, "cleanup_batch_size", 1000) 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) cutoff_time = datetime.now(timezone.utc) - timedelta(days=retention_days)
logger.info(f"开始清理 {retention_days} 天前的请求候选记录...") logger.info(
"开始清理 {} 天前的请求候选记录batch_size={}",
retention_days,
batch_size,
)
except Exception as e: except Exception as e:
logger.exception(f"候选记录清理配置读取失败: {e}") logger.exception(f"候选记录清理配置读取失败: {e}")
return 0 return 0
@@ -806,6 +821,7 @@ class MaintenanceScheduler:
records_to_delete = ( records_to_delete = (
batch_db.query(RequestCandidate.id) batch_db.query(RequestCandidate.id)
.filter(RequestCandidate.created_at < cutoff_time) .filter(RequestCandidate.created_at < cutoff_time)
.order_by(RequestCandidate.created_at.asc(), RequestCandidate.id.asc())
.limit(batch_size) .limit(batch_size)
.all() .all()
) )

View File

@@ -122,10 +122,12 @@ def test_sync_delete_reports_progress_after_each_batch(
self.rowcounts = [2, 1] self.rowcounts = [2, 1]
self.commits = 0 self.commits = 0
self.closed = False self.closed = False
self.text_statements: list[str] = []
def execute(self, _statement: object) -> SimpleNamespace: def execute(self, _statement: object) -> SimpleNamespace:
# SET LOCAL statement_timeout 不消耗 rowcount # SET LOCAL statement_timeout / lock_timeout 不消耗 rowcount
if hasattr(_statement, "text"): if hasattr(_statement, "text"):
self.text_statements.append(_statement.text)
return SimpleNamespace(rowcount=0) return SimpleNamespace(rowcount=0)
return SimpleNamespace(rowcount=self.rowcounts.pop(0)) return SimpleNamespace(rowcount=self.rowcounts.pop(0))
@@ -146,7 +148,7 @@ def test_sync_delete_reports_progress_after_each_batch(
monkeypatch.setattr( monkeypatch.setattr(
taskmod, taskmod,
"cleanup_key_references", "cleanup_key_references",
lambda _db, batch: cleanup_batches.append(list(batch)), lambda _db, batch, **_kwargs: cleanup_batches.append(list(batch)),
) )
monkeypatch.setattr("src.database.create_session", lambda: session) monkeypatch.setattr("src.database.create_session", lambda: session)
monkeypatch.setattr( monkeypatch.setattr(
@@ -165,4 +167,85 @@ def test_sync_delete_reports_progress_after_each_batch(
assert progress_updates == [2, 3] assert progress_updates == [2, 3]
assert cleanup_batches == [["key-1", "key-2"], ["key-3"]] assert cleanup_batches == [["key-1", "key-2"], ["key-3"]]
assert session.commits == 2 assert session.commits == 2
assert session.text_statements == [
"SET LOCAL statement_timeout = '30000'",
"SET LOCAL lock_timeout = '5000'",
"SET LOCAL statement_timeout = '30000'",
"SET LOCAL lock_timeout = '5000'",
]
assert session.closed is True
def test_sync_delete_retries_with_smaller_batches_after_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _FakeColumn:
def __eq__(self, other: object) -> tuple[str, object]: # type: ignore[override]
return ("eq", other)
def in_(self, values: list[str]) -> tuple[str, tuple[str, ...]]:
return ("in", tuple(values))
class _FakeProviderAPIKey:
provider_id = _FakeColumn()
id = _FakeColumn()
class _FakeDeleteStatement:
def where(self, *_conditions: object) -> "_FakeDeleteStatement":
return self
class _FakeSession:
def __init__(self) -> None:
self.rowcounts = [2, 2]
self.commits = 0
self.rollbacks = 0
self.closed = False
def execute(self, _statement: object) -> SimpleNamespace:
if hasattr(_statement, "text"):
return SimpleNamespace(rowcount=0)
return SimpleNamespace(rowcount=self.rowcounts.pop(0))
def commit(self) -> None:
self.commits += 1
def rollback(self) -> None:
self.rollbacks += 1
def close(self) -> None:
self.closed = True
session = _FakeSession()
progress_updates: list[int] = []
cleanup_batches: list[list[str]] = []
def fake_cleanup(_db: object, batch: list[str], **_kwargs: object) -> None:
cleanup_batches.append(list(batch))
if len(batch) >= 4:
raise RuntimeError(
"(psycopg2.errors.QueryCanceled) canceling statement due to statement timeout"
)
monkeypatch.setattr(taskmod, "_CLEANUP_BATCH_SIZE", 4)
monkeypatch.setattr(taskmod, "_MIN_RETRY_BATCH_SIZE", 1)
monkeypatch.setattr(taskmod, "cleanup_key_references", fake_cleanup)
monkeypatch.setattr("src.database.create_session", lambda: session)
monkeypatch.setattr("src.models.database.ProviderAPIKey", _FakeProviderAPIKey)
monkeypatch.setattr(taskmod, "sa_delete", lambda _model: _FakeDeleteStatement())
affected = taskmod._sync_delete(
"provider-1",
["key-1", "key-2", "key-3", "key-4"],
progress_updates.append,
)
assert affected == 4
assert progress_updates == [4]
assert cleanup_batches == [
["key-1", "key-2", "key-3", "key-4"],
["key-1", "key-2"],
["key-3", "key-4"],
]
assert session.commits == 2
assert session.rollbacks == 1
assert session.closed is True assert session.closed is True

View File

@@ -287,13 +287,12 @@ def test_cleanup_key_references_preserves_usage_and_video_tasks(
side_effects_module.cleanup_key_references(cast(Any, db), ["key-1", "key-2"]) side_effects_module.cleanup_key_references(cast(Any, db), ["key-1", "key-2"])
assert [(stmt.kind, stmt.model.__name__) for stmt in db.statements] == [ assert [(stmt.kind, stmt.model.__name__) for stmt in db.statements] == [
("delete", "RequestCandidate"),
("delete", "GeminiFileMapping"), ("delete", "GeminiFileMapping"),
("update", "Usage"), ("update", "Usage"),
("update", "VideoTask"), ("update", "VideoTask"),
] ]
assert db.statements[2].values_dict == {"provider_api_key_id": None} assert db.statements[1].values_dict == {"provider_api_key_id": None}
assert db.statements[3].values_dict == {"key_id": None} assert db.statements[2].values_dict == {"key_id": None}
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import inspect import inspect
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest import pytest
@@ -41,3 +42,76 @@ async def test_maintenance_scheduler_start_skips_startup_task_when_disabled(
await scheduler.start() await scheduler.start()
assert created is False assert created is False
@pytest.mark.asyncio
async def test_candidate_cleanup_uses_dedicated_retention_and_batch_settings(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scheduler = MaintenanceScheduler()
class _FakeLoop:
async def run_in_executor(self, _executor, func): # type: ignore[no-untyped-def]
return func()
class _ConfigSession:
def close(self) -> None:
return None
class _BatchSession:
def __init__(self, ids: list[str]) -> None:
self.ids = ids
self.closed = False
self.committed = False
self.query_obj = MagicMock()
filtered = self.query_obj.filter.return_value
filtered.order_by.return_value.limit.return_value.all.return_value = [
SimpleNamespace(id=value) for value in ids
]
def query(self, _model): # type: ignore[no-untyped-def]
return self.query_obj
def execute(self, _statement): # type: ignore[no-untyped-def]
return SimpleNamespace(rowcount=len(self.ids))
def commit(self) -> None:
self.committed = True
def rollback(self) -> None:
raise AssertionError("rollback should not be called")
def close(self) -> None:
self.closed = True
config_session = _ConfigSession()
batch_one = _BatchSession(["candidate-1", "candidate-2"])
batch_two = _BatchSession([])
sessions = iter([config_session, batch_one, batch_two])
def fake_create_session(): # type: ignore[no-untyped-def]
return next(sessions)
config_values = {
"enable_auto_cleanup": True,
"request_candidates_retention_days": 21,
"request_candidates_cleanup_batch_size": 2,
}
monkeypatch.setattr(maintenance_scheduler_module, "create_session", fake_create_session)
monkeypatch.setattr(
maintenance_scheduler_module.SystemConfigService,
"get_config",
lambda _db, key, default=None: config_values.get(key, default),
)
monkeypatch.setattr(
maintenance_scheduler_module.asyncio, "get_running_loop", lambda: _FakeLoop()
)
await scheduler._perform_candidate_cleanup()
batch_one.query_obj.filter.return_value.order_by.return_value.limit.return_value.all.assert_called_once()
batch_one.query_obj.filter.return_value.order_by.return_value.limit.assert_called_once_with(2)
assert batch_one.committed is True
assert batch_one.closed is True
assert batch_two.closed is True