mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat(governance): unify durable event retention
This commit is contained in:
@@ -167,6 +167,11 @@ class ChainRuntimeConfig:
|
||||
data_cleanup_site_userdata_days: Any = 0
|
||||
data_cleanup_transfer_history_days: Any = 0
|
||||
data_cleanup_download_failure_days: Any = 0
|
||||
data_cleanup_subscribe_history_days: Any = 0
|
||||
data_cleanup_agent_chat_days: Any = 0
|
||||
data_cleanup_agent_task_run_days: Any = 0
|
||||
data_cleanup_outbox_completed_days: Any = 0
|
||||
data_cleanup_outbox_dead_days: Any = 0
|
||||
download_subtitle: bool = True
|
||||
lyrics_batch_timeout: int = 120
|
||||
music_metadata_to_simplified: bool = True
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable, ContextManager, Dict, Optional, Protocol
|
||||
|
||||
from app.application.configuration import get_chain_runtime_config_snapshot
|
||||
@@ -26,6 +26,11 @@ class CleanupPolicy:
|
||||
site_userdata_days: int
|
||||
transfer_history_days: int
|
||||
download_failure_days: int
|
||||
subscribe_history_days: int
|
||||
agent_chat_days: int
|
||||
agent_task_run_days: int
|
||||
outbox_completed_days: int
|
||||
outbox_dead_days: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -73,6 +78,26 @@ class CleanupRepository(Protocol):
|
||||
"""删除已经过期的下载失败冷却记录。"""
|
||||
...
|
||||
|
||||
def delete_subscribe_history(self, db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止时间的订阅历史。"""
|
||||
...
|
||||
|
||||
def delete_agent_chats(self, db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止时间且未被任务引用的 Agent 会话。"""
|
||||
...
|
||||
|
||||
def delete_agent_task_runs(self, db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止时间且不再承担恢复语义的 Agent 运行历史。"""
|
||||
...
|
||||
|
||||
def delete_outbox_completed(self, db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止时间的 Outbox 已完成记录。"""
|
||||
...
|
||||
|
||||
def delete_outbox_dead(self, db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止时间的 Outbox 死信记录。"""
|
||||
...
|
||||
|
||||
|
||||
class CleanupUnitOfWork(Protocol):
|
||||
"""数据维护每一批删除所需的最小事务能力。"""
|
||||
@@ -249,6 +274,29 @@ class DataCleanupService:
|
||||
policy.download_failure_days,
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
subscribe_history_cutoff = self._cutoff(
|
||||
started_at,
|
||||
policy.subscribe_history_days,
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
agent_chat_cutoff = self._cutoff(
|
||||
started_at,
|
||||
policy.agent_chat_days,
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
agent_task_run_cutoff = self._cutoff(
|
||||
started_at,
|
||||
policy.agent_task_run_days,
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
outbox_completed_cutoff = self._outbox_cutoff(
|
||||
started_at,
|
||||
policy.outbox_completed_days,
|
||||
)
|
||||
outbox_dead_cutoff = self._outbox_cutoff(
|
||||
started_at,
|
||||
policy.outbox_dead_days,
|
||||
)
|
||||
return [
|
||||
CleanupPlan(
|
||||
"message",
|
||||
@@ -296,6 +344,46 @@ class DataCleanupService:
|
||||
db, download_failure_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
CleanupPlan(
|
||||
"subscribehistory",
|
||||
policy.subscribe_history_days,
|
||||
subscribe_history_cutoff,
|
||||
lambda db: self._repository.delete_subscribe_history(
|
||||
db, subscribe_history_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
CleanupPlan(
|
||||
"agentchat",
|
||||
policy.agent_chat_days,
|
||||
agent_chat_cutoff,
|
||||
lambda db: self._repository.delete_agent_chats(
|
||||
db, agent_chat_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
CleanupPlan(
|
||||
"agenttaskrun",
|
||||
policy.agent_task_run_days,
|
||||
agent_task_run_cutoff,
|
||||
lambda db: self._repository.delete_agent_task_runs(
|
||||
db, agent_task_run_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
CleanupPlan(
|
||||
"outbox_completed",
|
||||
policy.outbox_completed_days,
|
||||
outbox_completed_cutoff,
|
||||
lambda db: self._repository.delete_outbox_completed(
|
||||
db, outbox_completed_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
CleanupPlan(
|
||||
"outbox_dead",
|
||||
policy.outbox_dead_days,
|
||||
outbox_dead_cutoff,
|
||||
lambda db: self._repository.delete_outbox_dead(
|
||||
db, outbox_dead_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def _cleanup_in_batches(
|
||||
@@ -326,6 +414,19 @@ class DataCleanupService:
|
||||
"""按兼容格式计算一个清理截止时间。"""
|
||||
return (started_at - timedelta(days=retention_days)).strftime(pattern)
|
||||
|
||||
@staticmethod
|
||||
def _outbox_cutoff(started_at: datetime, retention_days: int) -> str:
|
||||
"""按 Outbox 的 UTC ISO 格式生成可排序截止时间。"""
|
||||
aware_started_at = (
|
||||
started_at.astimezone()
|
||||
if started_at.tzinfo is None
|
||||
else started_at
|
||||
)
|
||||
return (
|
||||
aware_started_at.astimezone(timezone.utc)
|
||||
- timedelta(days=retention_days)
|
||||
).isoformat()
|
||||
|
||||
|
||||
def read_cleanup_policy() -> CleanupPolicy:
|
||||
"""读取并规范化当前数据清理配置,单次运行期间保持快照一致。"""
|
||||
@@ -337,6 +438,11 @@ def read_cleanup_policy() -> CleanupPolicy:
|
||||
site_userdata_days=_normalize_days(config.data_cleanup_site_userdata_days),
|
||||
transfer_history_days=_normalize_days(config.data_cleanup_transfer_history_days),
|
||||
download_failure_days=_normalize_days(config.data_cleanup_download_failure_days),
|
||||
subscribe_history_days=_normalize_days(config.data_cleanup_subscribe_history_days),
|
||||
agent_chat_days=_normalize_days(config.data_cleanup_agent_chat_days),
|
||||
agent_task_run_days=_normalize_days(config.data_cleanup_agent_task_run_days),
|
||||
outbox_completed_days=_normalize_days(config.data_cleanup_outbox_completed_days),
|
||||
outbox_dead_days=_normalize_days(config.data_cleanup_outbox_dead_days),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,18 +17,28 @@ T = TypeVar("T")
|
||||
SUBSCRIBE_ADDED_TOPIC = "subscribe.added"
|
||||
SUBSCRIBE_MODIFIED_TOPIC = "subscribe.modified"
|
||||
SUBSCRIBE_DELETED_TOPIC = "subscribe.deleted"
|
||||
SUBSCRIBE_COMPLETED_TOPIC = "subscribe.complete"
|
||||
DOWNLOAD_ADDED_TOPIC = "download.added"
|
||||
TRANSFER_COMPLETED_TOPIC = "transfer.completed"
|
||||
TRANSFER_FAILED_TOPIC = "transfer.failed"
|
||||
SUBTITLE_TRANSFER_COMPLETED_TOPIC = "transfer.subtitle.completed"
|
||||
SUBTITLE_TRANSFER_FAILED_TOPIC = "transfer.subtitle.failed"
|
||||
AUDIO_TRANSFER_COMPLETED_TOPIC = "transfer.audio.completed"
|
||||
AUDIO_TRANSFER_FAILED_TOPIC = "transfer.audio.failed"
|
||||
OUTBOX_LEASE_SECONDS = 60
|
||||
|
||||
DURABLE_EVENT_TOPICS: Mapping[EventType, str] = MappingProxyType({
|
||||
EventType.SubscribeAdded: SUBSCRIBE_ADDED_TOPIC,
|
||||
EventType.SubscribeModified: SUBSCRIBE_MODIFIED_TOPIC,
|
||||
EventType.SubscribeDeleted: SUBSCRIBE_DELETED_TOPIC,
|
||||
EventType.SubscribeComplete: SUBSCRIBE_COMPLETED_TOPIC,
|
||||
EventType.DownloadAdded: DOWNLOAD_ADDED_TOPIC,
|
||||
EventType.TransferComplete: TRANSFER_COMPLETED_TOPIC,
|
||||
EventType.TransferFailed: TRANSFER_FAILED_TOPIC,
|
||||
EventType.SubtitleTransferComplete: SUBTITLE_TRANSFER_COMPLETED_TOPIC,
|
||||
EventType.SubtitleTransferFailed: SUBTITLE_TRANSFER_FAILED_TOPIC,
|
||||
EventType.AudioTransferComplete: AUDIO_TRANSFER_COMPLETED_TOPIC,
|
||||
EventType.AudioTransferFailed: AUDIO_TRANSFER_FAILED_TOPIC,
|
||||
})
|
||||
|
||||
|
||||
@@ -96,7 +106,6 @@ class OutboxRepository(Protocol):
|
||||
) -> None:
|
||||
"""记录有限退避或 dead-letter 终态。"""
|
||||
|
||||
|
||||
class AsyncOutboxTransaction(Protocol):
|
||||
"""异步业务事务暂存并收口 durable intent 的最小端口。"""
|
||||
|
||||
@@ -236,7 +245,6 @@ class OutboxDispatcher:
|
||||
"""释放 dispatcher 工厂创建的短生命周期持久化资源。"""
|
||||
self._close()
|
||||
|
||||
|
||||
_configured_dispatcher: Callable[[], OutboxDispatcher] | None = None
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, Protocol
|
||||
from app.application.outbox import (
|
||||
OUTBOX_LEASE_SECONDS,
|
||||
OutboxIntent,
|
||||
SUBSCRIBE_COMPLETED_TOPIC,
|
||||
SyncOutboxTransaction,
|
||||
SyncUnitOfWork,
|
||||
)
|
||||
@@ -77,7 +78,7 @@ class CompleteSubscriptionCommand:
|
||||
self._outbox.stage(
|
||||
OutboxIntent(
|
||||
event_key=event_key,
|
||||
topic="subscribe.complete",
|
||||
topic=SUBSCRIBE_COMPLETED_TOPIC,
|
||||
payload=event_payload,
|
||||
),
|
||||
now,
|
||||
|
||||
+87
-91
@@ -41,7 +41,14 @@ from app.application.history import (
|
||||
is_skip_action,
|
||||
record_transfer_failure,
|
||||
)
|
||||
from app.application.outbox import TRANSFER_COMPLETED_TOPIC, TRANSFER_FAILED_TOPIC
|
||||
from app.application.outbox import (
|
||||
AUDIO_TRANSFER_COMPLETED_TOPIC,
|
||||
AUDIO_TRANSFER_FAILED_TOPIC,
|
||||
SUBTITLE_TRANSFER_COMPLETED_TOPIC,
|
||||
SUBTITLE_TRANSFER_FAILED_TOPIC,
|
||||
TRANSFER_COMPLETED_TOPIC,
|
||||
TRANSFER_FAILED_TOPIC,
|
||||
)
|
||||
from app.application.transfer import (
|
||||
FailedRetryScheduler,
|
||||
JobManager,
|
||||
@@ -146,6 +153,53 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
"transfer_history_id": history_id,
|
||||
}
|
||||
|
||||
def _durable_transfer_event(
|
||||
self,
|
||||
task: TransferTask,
|
||||
*,
|
||||
success: bool,
|
||||
) -> Optional[tuple[str, EventType]]:
|
||||
"""返回当前整理结果应持久化的 topic 与兼容事件类型。"""
|
||||
if success:
|
||||
if self._is_primary_media_file(task.fileitem, task.mediainfo):
|
||||
return TRANSFER_COMPLETED_TOPIC, EventType.TransferComplete
|
||||
if self._is_subtitle_file(task.fileitem):
|
||||
return (
|
||||
SUBTITLE_TRANSFER_COMPLETED_TOPIC,
|
||||
EventType.SubtitleTransferComplete,
|
||||
)
|
||||
if self._is_audio_file(task.fileitem):
|
||||
return AUDIO_TRANSFER_COMPLETED_TOPIC, EventType.AudioTransferComplete
|
||||
return None
|
||||
if self._is_media_file(task.fileitem):
|
||||
return TRANSFER_FAILED_TOPIC, EventType.TransferFailed
|
||||
if self._is_subtitle_file(task.fileitem):
|
||||
return SUBTITLE_TRANSFER_FAILED_TOPIC, EventType.SubtitleTransferFailed
|
||||
if self._is_audio_file(task.fileitem):
|
||||
return AUDIO_TRANSFER_FAILED_TOPIC, EventType.AudioTransferFailed
|
||||
return None
|
||||
|
||||
def _publish_transfer_result(
|
||||
self,
|
||||
event_type: EventType,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""显式分派整理结果,使运行契约可追踪每种事件的生产者。"""
|
||||
if event_type is EventType.TransferComplete:
|
||||
self.eventmanager.send_event(EventType.TransferComplete, payload)
|
||||
elif event_type is EventType.TransferFailed:
|
||||
self.eventmanager.send_event(EventType.TransferFailed, payload)
|
||||
elif event_type is EventType.SubtitleTransferComplete:
|
||||
self.eventmanager.send_event(EventType.SubtitleTransferComplete, payload)
|
||||
elif event_type is EventType.SubtitleTransferFailed:
|
||||
self.eventmanager.send_event(EventType.SubtitleTransferFailed, payload)
|
||||
elif event_type is EventType.AudioTransferComplete:
|
||||
self.eventmanager.send_event(EventType.AudioTransferComplete, payload)
|
||||
elif event_type is EventType.AudioTransferFailed:
|
||||
self.eventmanager.send_event(EventType.AudioTransferFailed, payload)
|
||||
else:
|
||||
raise ValueError(f"不支持的整理结果事件:{event_type}")
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""初始化文件整理处理链。"""
|
||||
super().__init__()
|
||||
@@ -479,14 +533,16 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
fileid=task.fileitem.fileid if task.fileitem else None,
|
||||
)
|
||||
|
||||
durable_event = self._durable_transfer_event(task, success=False)
|
||||
durable_transfer_failed = bool(
|
||||
getattr(self, "durable_event_writer", None)
|
||||
and self._is_media_file(task.fileitem)
|
||||
getattr(self, "durable_event_writer", None) and durable_event
|
||||
)
|
||||
if durable_transfer_failed:
|
||||
assert durable_event is not None
|
||||
topic, event_type = durable_event
|
||||
event_payload = self._transfer_result_payload(task, transferinfo)
|
||||
history = self.durable_event_writer.transfer_result(
|
||||
topic=TRANSFER_FAILED_TOPIC,
|
||||
topic=topic,
|
||||
stage_history=lambda writer: add_transfer_fail(
|
||||
fileitem=task.fileitem,
|
||||
mode=transferinfo.transfer_type if transferinfo else "",
|
||||
@@ -498,9 +554,8 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
transfer_history_oper=writer,
|
||||
),
|
||||
event_payload=event_payload,
|
||||
publish=lambda payload: self.eventmanager.send_event(
|
||||
EventType.TransferFailed,
|
||||
payload,
|
||||
publish=lambda payload: self._publish_transfer_result(
|
||||
event_type, payload
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -515,45 +570,15 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
transfer_history_oper=transferhis,
|
||||
)
|
||||
|
||||
# 整理失败事件
|
||||
if self._is_media_file(task.fileitem):
|
||||
if not durable_transfer_failed:
|
||||
# 显式旧测试上下文仍走原始发送;正式上下文由 outbox writer 发布。
|
||||
self.eventmanager.send_event(
|
||||
EventType.TransferFailed,
|
||||
self._transfer_result_payload(
|
||||
task,
|
||||
transferinfo,
|
||||
history.id if history else None,
|
||||
),
|
||||
)
|
||||
elif self._is_subtitle_file(task.fileitem):
|
||||
# 字幕整理失败事件
|
||||
self.eventmanager.send_event(
|
||||
EventType.SubtitleTransferFailed,
|
||||
{
|
||||
"fileitem": task.fileitem,
|
||||
"meta": task.meta,
|
||||
"mediainfo": task.mediainfo,
|
||||
"transferinfo": transferinfo,
|
||||
"downloader": task.downloader,
|
||||
"download_hash": task.download_hash,
|
||||
"transfer_history_id": history.id if history else None,
|
||||
},
|
||||
)
|
||||
elif self._is_audio_file(task.fileitem):
|
||||
# 音频文件整理失败事件
|
||||
self.eventmanager.send_event(
|
||||
EventType.AudioTransferFailed,
|
||||
{
|
||||
"fileitem": task.fileitem,
|
||||
"meta": task.meta,
|
||||
"mediainfo": task.mediainfo,
|
||||
"transferinfo": transferinfo,
|
||||
"downloader": task.downloader,
|
||||
"download_hash": task.download_hash,
|
||||
"transfer_history_id": history.id if history else None,
|
||||
},
|
||||
if durable_event and not durable_transfer_failed:
|
||||
# 显式旧测试上下文仍走原始发送;正式上下文由 outbox writer 发布。
|
||||
self._publish_transfer_result(
|
||||
durable_event[1],
|
||||
self._transfer_result_payload(
|
||||
task,
|
||||
transferinfo,
|
||||
history.id if history else None,
|
||||
),
|
||||
)
|
||||
|
||||
self.queue_failed_transfer_notification(
|
||||
@@ -592,14 +617,16 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
task.fileitem.storage if task.fileitem else None,
|
||||
)
|
||||
|
||||
durable_event = self._durable_transfer_event(task, success=True)
|
||||
durable_transfer_complete = bool(
|
||||
getattr(self, "durable_event_writer", None)
|
||||
and self._is_primary_media_file(task.fileitem, task.mediainfo)
|
||||
getattr(self, "durable_event_writer", None) and durable_event
|
||||
)
|
||||
if durable_transfer_complete:
|
||||
assert durable_event is not None
|
||||
topic, event_type = durable_event
|
||||
event_payload = self._transfer_result_payload(task, transferinfo)
|
||||
history = self.durable_event_writer.transfer_result(
|
||||
topic=TRANSFER_COMPLETED_TOPIC,
|
||||
topic=topic,
|
||||
stage_history=lambda writer: add_transfer_success(
|
||||
fileitem=task.fileitem,
|
||||
mode=transferinfo.transfer_type if transferinfo else "",
|
||||
@@ -611,9 +638,8 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
transfer_history_oper=writer,
|
||||
),
|
||||
event_payload=event_payload,
|
||||
publish=lambda payload: self.eventmanager.send_event(
|
||||
EventType.TransferComplete,
|
||||
payload,
|
||||
publish=lambda payload: self._publish_transfer_result(
|
||||
event_type, payload
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -628,45 +654,15 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
|
||||
transfer_history_oper=transferhis,
|
||||
)
|
||||
|
||||
# task整理完成事件
|
||||
if self._is_primary_media_file(task.fileitem, task.mediainfo):
|
||||
if not durable_transfer_complete:
|
||||
# 显式旧测试上下文仍走原始发送;正式上下文由 outbox writer 发布。
|
||||
self.eventmanager.send_event(
|
||||
EventType.TransferComplete,
|
||||
self._transfer_result_payload(
|
||||
task,
|
||||
transferinfo,
|
||||
history.id if history else None,
|
||||
),
|
||||
)
|
||||
elif self._is_subtitle_file(task.fileitem):
|
||||
# 字幕整理完成事件
|
||||
self.eventmanager.send_event(
|
||||
EventType.SubtitleTransferComplete,
|
||||
{
|
||||
"fileitem": task.fileitem,
|
||||
"meta": task.meta,
|
||||
"mediainfo": task.mediainfo,
|
||||
"transferinfo": transferinfo,
|
||||
"downloader": task.downloader,
|
||||
"download_hash": task.download_hash,
|
||||
"transfer_history_id": history.id if history else None,
|
||||
},
|
||||
)
|
||||
elif self._is_audio_file(task.fileitem):
|
||||
# 音频文件整理完成事件
|
||||
self.eventmanager.send_event(
|
||||
EventType.AudioTransferComplete,
|
||||
{
|
||||
"fileitem": task.fileitem,
|
||||
"meta": task.meta,
|
||||
"mediainfo": task.mediainfo,
|
||||
"transferinfo": transferinfo,
|
||||
"downloader": task.downloader,
|
||||
"download_hash": task.download_hash,
|
||||
"transfer_history_id": history.id if history else None,
|
||||
},
|
||||
if durable_event and not durable_transfer_complete:
|
||||
# 显式旧测试上下文仍走原始发送;正式上下文由 outbox writer 发布。
|
||||
self._publish_transfer_result(
|
||||
durable_event[1],
|
||||
self._transfer_result_payload(
|
||||
task,
|
||||
transferinfo,
|
||||
history.id if history else None,
|
||||
),
|
||||
)
|
||||
|
||||
# task登记转移成功文件清单
|
||||
|
||||
@@ -176,7 +176,6 @@ class SqlAlchemyOutboxRepository:
|
||||
)
|
||||
self._session.commit()
|
||||
|
||||
|
||||
class SqlAlchemyAsyncOutboxStager:
|
||||
"""只负责把 outbox 意图加入调用方异步事务。"""
|
||||
|
||||
|
||||
@@ -2,10 +2,18 @@
|
||||
|
||||
from typing import Any, Callable, ContextManager
|
||||
|
||||
from sqlalchemy import delete, exists, select
|
||||
|
||||
from app.db.base import execute_dml
|
||||
from app.db.models.agentchat import AgentChat
|
||||
from app.db.models.agenttask import AgentTask
|
||||
from app.db.models.agenttaskrun import AgentTaskRun
|
||||
from app.db.models.downloadfailure import DownloadFailure
|
||||
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
|
||||
from app.db.models.message import Message
|
||||
from app.db.models.outbox import OutboxMessage
|
||||
from app.db.models.siteuserdata import SiteUserData
|
||||
from app.db.models.subscribehistory import SubscribeHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.uow import SqlAlchemyUnitOfWork
|
||||
|
||||
@@ -67,3 +75,116 @@ class DatabaseCleanupRepository:
|
||||
before_time=cutoff,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_subscribe_history(db: Any, cutoff: str, limit: int) -> int:
|
||||
"""分批删除超过用户保留期的已完成订阅快照。"""
|
||||
return DatabaseCleanupRepository._delete_selected_ids(
|
||||
db=db,
|
||||
model=SubscribeHistory,
|
||||
condition=SubscribeHistory.date < cutoff,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_agent_chats(db: Any, cutoff: str, limit: int) -> int:
|
||||
"""清理旧会话,但保留仍被 Agent 定时任务引用的上下文。"""
|
||||
task_reference = exists(
|
||||
select(AgentTask.id).where(AgentTask.session_id == AgentChat.session_id)
|
||||
)
|
||||
return DatabaseCleanupRepository._delete_selected_ids(
|
||||
db=db,
|
||||
model=AgentChat,
|
||||
condition=(AgentChat.updated_at < cutoff) & ~task_reference,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_agent_task_runs(db: Any, cutoff: str, limit: int) -> int:
|
||||
"""清理旧终态运行,但保留运行中和父任务最后一次运行。"""
|
||||
latest_run_reference = exists(
|
||||
select(AgentTask.id).where(AgentTask.last_run_id == AgentTaskRun.run_id)
|
||||
)
|
||||
return DatabaseCleanupRepository._delete_selected_ids(
|
||||
db=db,
|
||||
model=AgentTaskRun,
|
||||
condition=(
|
||||
(AgentTaskRun.started_at < cutoff)
|
||||
& (AgentTaskRun.status != "running")
|
||||
& ~latest_run_reference
|
||||
),
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_outbox_completed(db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除过期 completed 记录,事务提交由维护用例统一控制。"""
|
||||
return DatabaseCleanupRepository._delete_outbox_status(
|
||||
db=db,
|
||||
status="completed",
|
||||
timestamp_column=OutboxMessage.completed_at,
|
||||
cutoff=cutoff,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_outbox_dead(db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除过期 dead-letter 记录,保留 pending/processing 恢复语义。"""
|
||||
return DatabaseCleanupRepository._delete_outbox_status(
|
||||
db=db,
|
||||
status="dead",
|
||||
timestamp_column=OutboxMessage.next_retry_at,
|
||||
cutoff=cutoff,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _delete_outbox_status(
|
||||
*,
|
||||
db: Any,
|
||||
status: str,
|
||||
timestamp_column: Any,
|
||||
cutoff: str,
|
||||
limit: int,
|
||||
) -> int:
|
||||
"""先锁定有限 ID 再删除,避免一次维护事务无界膨胀。"""
|
||||
message_ids = db.execute(
|
||||
select(OutboxMessage.id)
|
||||
.where(
|
||||
OutboxMessage.status == status,
|
||||
timestamp_column.is_not(None),
|
||||
timestamp_column < cutoff,
|
||||
)
|
||||
.order_by(OutboxMessage.id)
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
if not message_ids:
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
delete(OutboxMessage).where(OutboxMessage.id.in_(message_ids)),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _delete_selected_ids(
|
||||
*,
|
||||
db: Any,
|
||||
model: Any,
|
||||
condition: Any,
|
||||
limit: int,
|
||||
) -> int:
|
||||
"""按安全谓词锁定有限主键并暂存删除。"""
|
||||
record_ids = db.execute(
|
||||
select(model.id)
|
||||
.where(condition)
|
||||
.order_by(model.id)
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
if not record_ids:
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
delete(model).where(model.id.in_(record_ids)),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@@ -46,6 +46,7 @@ class AgentChat(Base):
|
||||
Index("ix_agentchat_session_user", "session_id", "user_id"),
|
||||
Index("ix_agentchat_user_updated", "user_id", "updated_at", "id"),
|
||||
Index("ix_agentchat_channel_updated", "channel", "updated_at", "id"),
|
||||
Index("ix_agentchat_updated_id", "updated_at", "id"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -37,6 +37,7 @@ class AgentTaskRun(Base):
|
||||
__table_args__ = (
|
||||
Index("ix_agenttaskrun_run_id", "run_id", unique=True),
|
||||
Index("ix_agenttaskrun_task_started", "task_id", "started_at", "id"),
|
||||
Index("ix_agenttaskrun_status_started_id", "status", "started_at", "id"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -102,6 +102,7 @@ class SubscribeHistory(Base):
|
||||
__table_args__ = (
|
||||
media_identity_constraint("subscribehistory"),
|
||||
Index('ix_subscribehistory_type_date', 'type', 'date'),
|
||||
Index('ix_subscribehistory_date_id', 'date', 'id'),
|
||||
Index('ix_subscribehistory_media_identity', 'media_source', 'media_id'),
|
||||
)
|
||||
|
||||
|
||||
@@ -218,6 +218,16 @@ class ConfigModel(BaseModel):
|
||||
DATA_CLEANUP_TRANSFER_HISTORY_DAYS: int = 365 * 3
|
||||
# 下载失败冷却记录保留天数,0为不清理
|
||||
DATA_CLEANUP_DOWNLOAD_FAILURE_DAYS: int = 7
|
||||
# 订阅完成历史保留天数,0为不清理
|
||||
DATA_CLEANUP_SUBSCRIBE_HISTORY_DAYS: int = 365 * 3
|
||||
# Agent 会话历史保留天数,0为不清理
|
||||
DATA_CLEANUP_AGENT_CHAT_DAYS: int = 180
|
||||
# Agent 定时任务运行历史保留天数,0为不清理
|
||||
DATA_CLEANUP_AGENT_TASK_RUN_DAYS: int = 180
|
||||
# Outbox 已完成记录保留天数,0为不清理
|
||||
DATA_CLEANUP_OUTBOX_COMPLETED_DAYS: int = 30
|
||||
# Outbox 死信记录保留天数,0为不清理
|
||||
DATA_CLEANUP_OUTBOX_DEAD_DAYS: int = 90
|
||||
|
||||
# ==================== 缓存配置 ====================
|
||||
# 缓存类型,支持 cachetools 和 redis,默认使用 cachetools
|
||||
|
||||
@@ -159,9 +159,14 @@ _DURABLE_REQUIRED = {
|
||||
EventType.SubscribeAdded,
|
||||
EventType.SubscribeModified,
|
||||
EventType.SubscribeDeleted,
|
||||
EventType.SubscribeComplete,
|
||||
EventType.DownloadAdded,
|
||||
EventType.TransferComplete,
|
||||
EventType.TransferFailed,
|
||||
EventType.SubtitleTransferComplete,
|
||||
EventType.SubtitleTransferFailed,
|
||||
EventType.AudioTransferComplete,
|
||||
EventType.AudioTransferFailed,
|
||||
}
|
||||
_TARGET_PLUGIN = {EventType.PluginAction, EventType.PluginTriggered}
|
||||
_HOST_ONLY = {EventType.SystemError, EventType.ConfigChanged, EventType.ModuleReload}
|
||||
|
||||
+6
-1
@@ -126,6 +126,12 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
"DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS",
|
||||
"DATA_CLEANUP_SITE_USERDATA_DAYS",
|
||||
"DATA_CLEANUP_TRANSFER_HISTORY_DAYS",
|
||||
"DATA_CLEANUP_DOWNLOAD_FAILURE_DAYS",
|
||||
"DATA_CLEANUP_SUBSCRIBE_HISTORY_DAYS",
|
||||
"DATA_CLEANUP_AGENT_CHAT_DAYS",
|
||||
"DATA_CLEANUP_AGENT_TASK_RUN_DAYS",
|
||||
"DATA_CLEANUP_OUTBOX_COMPLETED_DAYS",
|
||||
"DATA_CLEANUP_OUTBOX_DEAD_DAYS",
|
||||
"DB_BACKUP_ENABLE",
|
||||
"DB_BACKUP_CRON",
|
||||
"USAGE_STATISTIC_SHARE",
|
||||
@@ -591,7 +597,6 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
kwargs={"job_id": "outbox_dispatch"},
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# CookieCloud定时同步
|
||||
if (
|
||||
config.cookiecloud_interval
|
||||
|
||||
@@ -120,6 +120,11 @@ def build_chain_runtime_config(settings: Settings) -> ChainRuntimeConfig:
|
||||
data_cleanup_site_userdata_days=settings.DATA_CLEANUP_SITE_USERDATA_DAYS,
|
||||
data_cleanup_transfer_history_days=settings.DATA_CLEANUP_TRANSFER_HISTORY_DAYS,
|
||||
data_cleanup_download_failure_days=settings.DATA_CLEANUP_DOWNLOAD_FAILURE_DAYS,
|
||||
data_cleanup_subscribe_history_days=settings.DATA_CLEANUP_SUBSCRIBE_HISTORY_DAYS,
|
||||
data_cleanup_agent_chat_days=settings.DATA_CLEANUP_AGENT_CHAT_DAYS,
|
||||
data_cleanup_agent_task_run_days=settings.DATA_CLEANUP_AGENT_TASK_RUN_DAYS,
|
||||
data_cleanup_outbox_completed_days=settings.DATA_CLEANUP_OUTBOX_COMPLETED_DAYS,
|
||||
data_cleanup_outbox_dead_days=settings.DATA_CLEANUP_OUTBOX_DEAD_DAYS,
|
||||
download_subtitle=settings.DOWNLOAD_SUBTITLE,
|
||||
music_metadata_to_simplified=settings.MUSIC_METADATA_TO_SIMPLIFIED,
|
||||
recognize_plugin_first=settings.RECOGNIZE_PLUGIN_FIRST,
|
||||
|
||||
@@ -374,7 +374,9 @@ def _build_outbox_dispatcher() -> OutboxDispatcher:
|
||||
message.payload,
|
||||
),
|
||||
"subscribe.deleted.report": dispatch_subscribe_deleted_report,
|
||||
"subscribe.complete": lambda message: EventManager().send_event(
|
||||
durable_event_topic(
|
||||
EventType.SubscribeComplete
|
||||
): lambda message: EventManager().send_event(
|
||||
EventType.SubscribeComplete,
|
||||
message.payload,
|
||||
),
|
||||
@@ -398,6 +400,30 @@ def _build_outbox_dispatcher() -> OutboxDispatcher:
|
||||
EventType.TransferFailed,
|
||||
restore_transfer_result(message.payload),
|
||||
),
|
||||
durable_event_topic(
|
||||
EventType.SubtitleTransferComplete
|
||||
): lambda message: EventManager().send_event(
|
||||
EventType.SubtitleTransferComplete,
|
||||
restore_transfer_result(message.payload),
|
||||
),
|
||||
durable_event_topic(
|
||||
EventType.SubtitleTransferFailed
|
||||
): lambda message: EventManager().send_event(
|
||||
EventType.SubtitleTransferFailed,
|
||||
restore_transfer_result(message.payload),
|
||||
),
|
||||
durable_event_topic(
|
||||
EventType.AudioTransferComplete
|
||||
): lambda message: EventManager().send_event(
|
||||
EventType.AudioTransferComplete,
|
||||
restore_transfer_result(message.payload),
|
||||
),
|
||||
durable_event_topic(
|
||||
EventType.AudioTransferFailed
|
||||
): lambda message: EventManager().send_event(
|
||||
EventType.AudioTransferFailed,
|
||||
restore_transfer_result(message.payload),
|
||||
),
|
||||
}
|
||||
validate_durable_event_handlers(handlers)
|
||||
session = SessionFactory()
|
||||
|
||||
Reference in New Issue
Block a user