feat(governance): unify durable event retention

This commit is contained in:
jxxghp
2026-08-26 12:45:44 +08:00
parent f5dacf79c3
commit 362f606751
29 changed files with 948 additions and 121 deletions
+8 -2
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6767,
"edge_sha256": "659804b4d1c0f3ff4d96e8c9a059df0afb74c91f368a73188c740558114efa61",
"edge_count": 6773,
"edge_sha256": "3ba25180753ef7e15d9d08b7af25b66191306e8c0b465a27bed5613ed8d30c5d",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -3724,11 +3724,17 @@
"app.db.health -> app.db",
"app.db.health -> app.db.session",
"app.db.maintenance -> app.db",
"app.db.maintenance -> app.db.base",
"app.db.maintenance -> app.db.models",
"app.db.maintenance -> app.db.models.agentchat",
"app.db.maintenance -> app.db.models.agenttask",
"app.db.maintenance -> app.db.models.agenttaskrun",
"app.db.maintenance -> app.db.models.downloadfailure",
"app.db.maintenance -> app.db.models.downloadhistory",
"app.db.maintenance -> app.db.models.message",
"app.db.maintenance -> app.db.models.outbox",
"app.db.maintenance -> app.db.models.siteuserdata",
"app.db.maintenance -> app.db.models.subscribehistory",
"app.db.maintenance -> app.db.models.transferhistory",
"app.db.maintenance -> app.db.uow",
"app.db.models -> app.db",
+24 -8
View File
@@ -1514,7 +1514,7 @@
"visibility": "plugin_public"
},
"EventType.AudioTransferComplete": {
"delivery": "ephemeral",
"delivery": "durable_required",
"error_behavior": "notify",
"input_contract": "TransferResultContractData",
"legacy_reason": null,
@@ -1529,7 +1529,7 @@
"visibility": "plugin_public"
},
"EventType.AudioTransferFailed": {
"delivery": "ephemeral",
"delivery": "durable_required",
"error_behavior": "notify",
"input_contract": "TransferResultContractData",
"legacy_reason": null,
@@ -1799,7 +1799,7 @@
"visibility": "plugin_public"
},
"EventType.SubscribeComplete": {
"delivery": "ephemeral",
"delivery": "durable_required",
"error_behavior": "notify",
"input_contract": "SubscribeCompleteEventData",
"legacy_reason": null,
@@ -1844,7 +1844,7 @@
"visibility": "plugin_public"
},
"EventType.SubtitleTransferComplete": {
"delivery": "ephemeral",
"delivery": "durable_required",
"error_behavior": "notify",
"input_contract": "TransferResultContractData",
"legacy_reason": null,
@@ -1859,7 +1859,7 @@
"visibility": "plugin_public"
},
"EventType.SubtitleTransferFailed": {
"delivery": "ephemeral",
"delivery": "durable_required",
"error_behavior": "notify",
"input_contract": "TransferResultContractData",
"legacy_reason": null,
@@ -2253,6 +2253,10 @@
{
"caller": "app.chain.transfer",
"count": 1
},
{
"caller": "app.startup.initializers.modules",
"count": 1
}
]
},
@@ -2262,6 +2266,10 @@
{
"caller": "app.chain.transfer",
"count": 1
},
{
"caller": "app.startup.initializers.modules",
"count": 1
}
]
},
@@ -2521,6 +2529,10 @@
{
"caller": "app.chain.transfer",
"count": 1
},
{
"caller": "app.startup.initializers.modules",
"count": 1
}
]
},
@@ -2530,6 +2542,10 @@
{
"caller": "app.chain.transfer",
"count": 1
},
{
"caller": "app.startup.initializers.modules",
"count": 1
}
]
},
@@ -2555,7 +2571,7 @@
"producers": [
{
"caller": "app.chain.transfer",
"count": 2
"count": 1
},
{
"caller": "app.startup.initializers.modules",
@@ -2568,7 +2584,7 @@
"producers": [
{
"caller": "app.chain.transfer",
"count": 2
"count": 1
},
{
"caller": "app.startup.initializers.modules",
@@ -2609,7 +2625,7 @@
]
}
},
"producer_count": 78
"producer_count": 80
},
"module_method_specs": {
"anilist_credits": {
+12 -2
View File
@@ -9,7 +9,6 @@ from sqlalchemy.schema import CreateTable
from app.db.models.agenttask import AgentTask
from app.db.models.agenttaskrun import AgentTaskRun
MIGRATION = "database.versions.f4c8d2a7b1e6_3_0_6"
@@ -84,7 +83,18 @@ def test_agent_task_run_migration_accepts_fresh_current_schema(monkeypatch) -> N
assert {
column["name"] for column in inspector.get_columns("agenttaskrun")
} == {column.name for column in AgentTaskRun.__table__.columns}
assert len(inspector.get_indexes("agenttaskrun")) == 2
actual_indexes = {
index["name"]: (tuple(index["column_names"]), index["unique"])
for index in inspector.get_indexes("agenttaskrun")
}
expected_indexes = {
index.name: (
tuple(column.name for column in index.columns),
int(index.unique),
)
for index in AgentTaskRun.__table__.indexes
}
assert actual_indexes == expected_indexes
def test_agent_task_run_migration_matches_postgresql_identity() -> None:
+75
View File
@@ -0,0 +1,75 @@
"""历史表清理索引迁移测试。"""
import importlib
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
MIGRATION_MODULE = "database.versions.a6c8e2f4b1d3_3_0_11"
INDEXES = {
"subscribehistory": (
"ix_subscribehistory_date_id",
("date", "id"),
),
"agentchat": (
"ix_agentchat_updated_id",
("updated_at", "id"),
),
"agenttaskrun": (
"ix_agenttaskrun_status_started_id",
("status", "started_at", "id"),
),
}
def _index_columns(connection, table_name: str) -> dict[str, tuple[str, ...]]:
"""返回测试表的索引字段签名。"""
return {
index["name"]: tuple(index.get("column_names") or ())
for index in sa.inspect(connection).get_indexes(table_name)
}
def test_cleanup_index_migration_is_idempotent_and_reversible(monkeypatch) -> None:
"""升级可重复执行并创建准确索引,降级只移除新增索引。"""
engine = sa.create_engine("sqlite://")
metadata = sa.MetaData()
sa.Table(
"subscribehistory",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("date", sa.String()),
)
sa.Table(
"agentchat",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("updated_at", sa.String()),
)
sa.Table(
"agenttaskrun",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("status", sa.String()),
sa.Column("started_at", sa.String()),
)
with engine.begin() as connection:
metadata.create_all(connection)
migration = importlib.import_module(MIGRATION_MODULE)
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
migration.upgrade()
migration.upgrade()
for table_name, (index_name, columns) in INDEXES.items():
assert _index_columns(connection, table_name)[index_name] == columns
migration.downgrade()
for table_name, (index_name, _columns) in INDEXES.items():
assert index_name not in _index_columns(connection, table_name)
+156
View File
@@ -0,0 +1,156 @@
"""统一数据维护对追加型历史表的安全清理测试。"""
from datetime import datetime
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.maintenance import CleanupPolicy, DataCleanupService
from app.db.base import Base
from app.db.maintenance import DatabaseCleanupRepository
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.subscribehistory import SubscribeHistory
def _cleanup_policy() -> CleanupPolicy:
"""只启用本组新增历史表的 30 天保留期。"""
return CleanupPolicy(
enabled=True,
message_days=0,
download_history_days=0,
site_userdata_days=0,
transfer_history_days=0,
download_failure_days=0,
subscribe_history_days=30,
agent_chat_days=30,
agent_task_run_days=30,
outbox_completed_days=0,
outbox_dead_days=0,
)
def test_growth_table_cleanup_preserves_live_agent_recovery_state() -> None:
"""旧历史可回收,但任务引用会话、最后运行和运行中记录必须保留。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
old_time = "2026-06-01 12:00:00"
recent_time = "2026-08-20 12:00:00"
with factory() as session:
session.add_all([
SubscribeHistory(name="old-subscribe", date=old_time),
SubscribeHistory(name="recent-subscribe", date=recent_time),
AgentChat(
session_id="old-unreferenced",
title="old-unreferenced",
created_at=old_time,
updated_at=old_time,
),
AgentChat(
session_id="task-context",
title="task-context",
created_at=old_time,
updated_at=old_time,
),
AgentChat(
session_id="recent-chat",
title="recent-chat",
created_at=recent_time,
updated_at=recent_time,
),
])
task = AgentTask(
name="cleanup-protected-task",
content="test",
trigger_type="cron",
enabled=True,
user_id="1",
session_id="task-context",
last_status="success",
last_run_id="latest-run",
run_count=2,
created_at=old_time,
updated_at=old_time,
)
session.add(task)
session.flush()
session.add_all([
AgentTaskRun(
run_id="expired-run",
task_id=task.id,
trigger_source="scheduled",
name=task.name,
content=task.content,
trigger_type=task.trigger_type,
user_id=task.user_id,
session_id=task.session_id,
status="success",
started_at=old_time,
finished_at=old_time,
),
AgentTaskRun(
run_id="latest-run",
task_id=task.id,
trigger_source="scheduled",
name=task.name,
content=task.content,
trigger_type=task.trigger_type,
user_id=task.user_id,
session_id=task.session_id,
status="success",
started_at=old_time,
finished_at=old_time,
),
AgentTaskRun(
run_id="running-run",
task_id=task.id,
trigger_source="manual",
name=task.name,
content=task.content,
trigger_type=task.trigger_type,
user_id=task.user_id,
session_id=task.session_id,
status="running",
started_at=old_time,
),
AgentTaskRun(
run_id="recent-run",
task_id=task.id,
trigger_source="manual",
name=task.name,
content=task.content,
trigger_type=task.trigger_type,
user_id=task.user_id,
session_id=task.session_id,
status="failed",
started_at=recent_time,
finished_at=recent_time,
),
])
session.commit()
report = DataCleanupService(
repository=DatabaseCleanupRepository(session_factory=factory),
policy_reader=_cleanup_policy,
clock=lambda: datetime(2026, 8, 26, 12, 0, 0),
).execute(batch_size=1)
assert report["tables"]["subscribehistory"]["deleted"] == 1
assert report["tables"]["agentchat"]["deleted"] == 1
assert report["tables"]["agenttaskrun"]["deleted"] == 1
with factory() as session:
assert set(session.execute(select(SubscribeHistory.name)).scalars()) == {
"recent-subscribe"
}
assert set(session.execute(select(AgentChat.session_id)).scalars()) == {
"task-context",
"recent-chat",
}
assert set(session.execute(select(AgentTaskRun.run_id)).scalars()) == {
"latest-run",
"running-run",
"recent-run",
}
+31 -1
View File
@@ -70,6 +70,26 @@ class FakeCleanupRepository:
"""模拟下载失败记录删除。"""
return self._delete("downloadfailure")
def delete_subscribe_history(self, db, cutoff: str, limit: int) -> int:
"""模拟订阅历史删除。"""
return self._delete("subscribehistory")
def delete_agent_chats(self, db, cutoff: str, limit: int) -> int:
"""模拟 Agent 会话删除。"""
return self._delete("agentchat")
def delete_agent_task_runs(self, db, cutoff: str, limit: int) -> int:
"""模拟 Agent 运行历史删除。"""
return self._delete("agenttaskrun")
def delete_outbox_completed(self, db, cutoff: str, limit: int) -> int:
"""模拟 Outbox 已完成记录删除。"""
return self._delete("outbox_completed")
def delete_outbox_dead(self, db, cutoff: str, limit: int) -> int:
"""模拟 Outbox 死信记录删除。"""
return self._delete("outbox_dead")
def _policy(**overrides) -> CleanupPolicy:
"""构造所有表默认启用的测试策略。"""
@@ -80,6 +100,11 @@ def _policy(**overrides) -> CleanupPolicy:
"site_userdata_days": 1,
"transfer_history_days": 1,
"download_failure_days": 1,
"subscribe_history_days": 1,
"agent_chat_days": 1,
"agent_task_run_days": 1,
"outbox_completed_days": 1,
"outbox_dead_days": 1,
}
values.update(overrides)
return CleanupPolicy(**values)
@@ -112,6 +137,11 @@ def test_cleanup_service_owns_batching_report_and_progress() -> None:
"siteuserdata",
"transferhistory",
"downloadfailure",
"subscribehistory",
"agentchat",
"agenttaskrun",
"outbox_completed",
"outbox_dead",
]
assert progress.call_args.kwargs["value"] == 100
@@ -128,7 +158,7 @@ def test_cleanup_service_finishes_other_tables_before_raising_partial_failure()
with pytest.raises(RuntimeError, match="downloadhistory: boom"):
service.execute(batch_size=2)
assert repository.calls[-1] == "downloadfailure"
assert repository.calls[-1] == "outbox_dead"
assert repository.rollbacks == 1
+1
View File
@@ -89,6 +89,7 @@ def test_selected_user_side_effects_are_marked_durable_required() -> None:
}
assert set(DURABLE_EVENT_TOPICS) == durable_events
assert len(durable_events) == 11
assert len(set(DURABLE_EVENT_TOPICS.values())) == len(durable_events)
+96
View File
@@ -7,10 +7,12 @@ import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.maintenance import CleanupPolicy, DataCleanupService
from app.application.outbox import ClaimedOutboxMessage, OutboxDispatcher, OutboxIntent
from app.application.subscription.write import CreateSubscriptionCommand
from app.db.adapters.outbox import SqlAlchemyOutboxRepository
from app.db.base import Base
from app.db.maintenance import DatabaseCleanupRepository
from app.db.models.outbox import OutboxMessage
@@ -171,3 +173,97 @@ def test_sync_outbox_claim_is_exclusive_for_event_key() -> None:
assert message.status == "processing"
assert message.attempt == 1
assert message.lease_until == lease_until.isoformat()
def test_outbox_cleanup_removes_only_expired_terminal_history_in_batches() -> None:
"""清理只删除超过各自保留期的终态记录,并按批次持续收口。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
now = datetime(2026, 8, 26, tzinfo=timezone.utc)
def message(
event_key: str,
status: str,
*,
completed_at: datetime | None = None,
next_retry_at: datetime | None = None,
) -> OutboxMessage:
"""构造指定终态时间的最小 Outbox 测试记录。"""
return OutboxMessage(
event_key=event_key,
topic="test",
payload_version=1,
payload={},
status=status,
attempt=1,
next_retry_at=(next_retry_at or now).isoformat(),
created_at=(now - timedelta(days=120)).isoformat(),
completed_at=completed_at.isoformat() if completed_at else None,
)
with factory() as session:
session.add_all([
message(
"completed-expired-1",
"completed",
completed_at=now - timedelta(days=31),
),
message(
"completed-expired-2",
"completed",
completed_at=now - timedelta(days=40),
),
message(
"completed-boundary",
"completed",
completed_at=now - timedelta(days=30),
),
message(
"dead-expired",
"dead",
next_retry_at=now - timedelta(days=91),
),
message(
"dead-recent",
"dead",
next_retry_at=now - timedelta(days=20),
),
message("pending-old", "pending"),
message("processing-old", "processing"),
])
session.commit()
cleanup = DataCleanupService(
repository=DatabaseCleanupRepository(session_factory=factory),
policy_reader=lambda: CleanupPolicy(
enabled=True,
message_days=0,
download_history_days=0,
site_userdata_days=0,
transfer_history_days=0,
download_failure_days=0,
subscribe_history_days=0,
agent_chat_days=0,
agent_task_run_days=0,
outbox_completed_days=30,
outbox_dead_days=90,
),
clock=lambda: now,
)
report = cleanup.execute(batch_size=2)
assert report["tables"]["outbox_completed"]["deleted"] == 2
assert report["tables"]["outbox_dead"]["deleted"] == 1
assert report["total_deleted"] == 3
with factory() as session:
remaining = set(
session.execute(select(OutboxMessage.event_key)).scalars().all()
)
assert remaining == {
"completed-boundary",
"dead-recent",
"pending-old",
"processing-old",
}
+54 -1
View File
@@ -9,10 +9,17 @@ __is_overwrite_declined 用于识别这一场景,__default_callback 失败分
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from app.chain.transfer import TransferChain
from app.schemas.transfer import TransferInfo
from app.schemas.types import EventType
from tests.test_transfer_job_manager import FakeMedia, make_task, make_transfer_chain
from tests.test_transfer_job_manager import (
FakeMedia,
make_fileitem,
make_task,
make_transfer_chain,
)
def make_history_oper(history=None, success_history=None, raise_on_query: bool = False):
@@ -271,3 +278,49 @@ def test_default_callback_delegates_primary_failure_to_durable_writer():
event_type, event_payload = chain.eventmanager.send_event.call_args.args
assert event_type == EventType.TransferFailed
assert event_payload["idempotency_key"] == "transfer.failed:1:v1"
@pytest.mark.parametrize(
("path", "success", "expected_topic", "expected_event"),
[
(
"/downloads/demo.srt",
True,
"transfer.subtitle.completed",
EventType.SubtitleTransferComplete,
),
(
"/downloads/demo.srt",
False,
"transfer.subtitle.failed",
EventType.SubtitleTransferFailed,
),
(
"/downloads/demo.flac",
True,
"transfer.audio.completed",
EventType.AudioTransferComplete,
),
(
"/downloads/demo.flac",
False,
"transfer.audio.failed",
EventType.AudioTransferFailed,
),
],
)
def test_subtitle_and_audio_results_use_durable_topics(
path: str,
success: bool,
expected_topic: str,
expected_event: EventType,
) -> None:
"""字幕与音频结果必须和主要媒体结果共用 durable writer 分类。"""
chain = make_transfer_chain()
task = make_task(1)
task.fileitem = make_fileitem(path)
assert chain._durable_transfer_event(task, success=success) == (
expected_topic,
expected_event,
)