mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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*/
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 定时任务 -->
|
<!-- 定时任务 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 统计数据模型 ====================
|
# ==================== 统计数据模型 ====================
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 自动签到任务",
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user