refactor: make subscription lifecycle events durable

This commit is contained in:
jxxghp
2026-08-22 07:43:41 +08:00
parent 8f94fd620d
commit c5de1c7b1b
27 changed files with 1106 additions and 230 deletions
+20 -27
View File
@@ -6,11 +6,15 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.runtime.events import eventmanager
from app.application.agentdata import SubscribePort as SubscribeOper
from app.adapters.external.server import MoviePilotServerHelper
from app.application.subscription.delete import (
SubscribeDeletionActor,
get_delete_subscribe_scope,
)
from app.application.subscription.mutation import (
SubscriptionActor,
get_subscription_mutation_scope,
)
from app.runtime.log import logger
from app.schemas.types import EventType
class DeleteSubscribeInput(BaseModel):
@@ -45,32 +49,21 @@ class DeleteSubscribeTool(MoviePilotTool):
logger.info(f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}")
try:
subscribe_oper = SubscribeOper()
# 获取订阅信息
subscribe = await subscribe_oper.async_get(subscribe_id)
async with get_subscription_mutation_scope() as mutation:
subscribe = await mutation.get_accessible(
subscribe_id,
SubscriptionActor(name="agent", is_superuser=True),
)
if not subscribe:
return f"订阅 ID {subscribe_id} 不存在"
# 在删除之前获取订阅信息(用于事件)
subscribe_info = subscribe.to_dict()
await subscribe_oper.async_delete(subscribe_id)
# 分享订阅统计刷新本身已异步化,这里只需要在删除后触发即可。
MoviePilotServerHelper.sub_done_async(
{
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,
"music_type": subscribe.music_type,
"total_tracks": subscribe.total_tracks,
"season": subscribe.season,
}
)
# 发送事件
await eventmanager.async_send_event(
EventType.SubscribeDeleted,
{"subscribe_id": subscribe_id, "subscribe_info": subscribe_info},
)
async with get_delete_subscribe_scope() as command:
deleted = await command.execute(
subscribe_id,
SubscribeDeletionActor(username="agent", is_superuser=True),
)
if not deleted:
return f"订阅 ID {subscribe_id} 不存在"
return f"成功删除订阅:{subscribe.name} ({subscribe.year})"
except Exception as e:
+39 -43
View File
@@ -7,11 +7,12 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.runtime.events import eventmanager
from app.application.agentdata import SubscribePort as SubscribeOper
from app.application.subscription.mutation import (
SubscriptionActor,
get_subscription_mutation_scope,
)
from app.runtime.log import logger
from app.schemas.event import SubscribeModifiedEventData
from app.schemas.types import EventType, media_type_to_agent
from app.schemas.types import media_type_to_agent
class UpdateSubscribeInput(BaseModel):
@@ -172,8 +173,9 @@ class UpdateSubscribeTool(MoviePilotTool):
logger.info(f"执行工具: {self.name}, 参数: subscribe_id={subscribe_id}")
try:
subscribe_oper = SubscribeOper()
subscribe = await subscribe_oper.async_get(subscribe_id)
actor = SubscriptionActor(name="agent", is_superuser=True)
async with get_subscription_mutation_scope() as mutation:
subscribe = await mutation.get_accessible(subscribe_id, actor)
if not subscribe:
return json.dumps(
{"success": False, "message": f"订阅不存在: {subscribe_id}"},
@@ -206,9 +208,6 @@ class UpdateSubscribeTool(MoviePilotTool):
ensure_ascii=False,
)
# 保存旧数据用于事件
old_subscribe_dict = subscribe.to_dict()
# 构建更新字典
subscribe_dict = {}
@@ -306,24 +305,21 @@ class UpdateSubscribeTool(MoviePilotTool):
ensure_ascii=False,
)
# 更新订阅
await subscribe_oper.async_update(subscribe_id, subscribe_dict)
# 重新获取更新后的订阅数据
updated_subscribe = await subscribe_oper.async_get(subscribe_id)
# 发送订阅调整事件
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subscribe_id,
old_subscribe_info=old_subscribe_dict,
subscribe_info=updated_subscribe.to_dict()
if updated_subscribe
else {},
# Agent 工具没有 FastAPI 请求会话,由组合根提供一次独占事务作用域;
# 更新和 durable intent 必须共享同一 AsyncSession。
async with get_subscription_mutation_scope() as mutation:
change = await mutation.update(
subscribe_id,
subscribe_dict,
actor,
scene="agent_update",
).to_dict(),
)
)
if not change:
return json.dumps(
{"success": False, "message": f"订阅不存在: {subscribe_id}"},
ensure_ascii=False,
)
updated_subscribe = change.new
# 构建返回结果
result = {
@@ -335,23 +331,23 @@ class UpdateSubscribeTool(MoviePilotTool):
if updated_subscribe:
result["subscribe"] = {
"id": updated_subscribe.id,
"name": updated_subscribe.name,
"year": updated_subscribe.year,
"type": media_type_to_agent(updated_subscribe.type),
"music_type": updated_subscribe.music_type,
"total_tracks": updated_subscribe.total_tracks,
"media_source": updated_subscribe.media_source,
"media_id": updated_subscribe.media_id,
"season": updated_subscribe.season,
"state": updated_subscribe.state,
"total_episode": updated_subscribe.total_episode,
"manual_total_episode": updated_subscribe.manual_total_episode,
"lack_episode": updated_subscribe.lack_episode,
"start_episode": updated_subscribe.start_episode,
"quality": updated_subscribe.quality,
"resolution": updated_subscribe.resolution,
"effect": updated_subscribe.effect,
"id": updated_subscribe.get("id"),
"name": updated_subscribe.get("name"),
"year": updated_subscribe.get("year"),
"type": media_type_to_agent(updated_subscribe.get("type")),
"music_type": updated_subscribe.get("music_type"),
"total_tracks": updated_subscribe.get("total_tracks"),
"media_source": updated_subscribe.get("media_source"),
"media_id": updated_subscribe.get("media_id"),
"season": updated_subscribe.get("season"),
"state": updated_subscribe.get("state"),
"total_episode": updated_subscribe.get("total_episode"),
"manual_total_episode": updated_subscribe.get("manual_total_episode"),
"lack_episode": updated_subscribe.get("lack_episode"),
"start_episode": updated_subscribe.get("start_episode"),
"quality": updated_subscribe.get("quality"),
"resolution": updated_subscribe.get("resolution"),
"effect": updated_subscribe.get("effect"),
}
return json.dumps(result, ensure_ascii=False, indent=2)
+63 -1
View File
@@ -6,7 +6,18 @@ from typing import cast
from fastapi import Depends, Request
from app.application.messaging.chat import AsyncAgentChatRepository, AsyncUnitOfWork
from app.startup.context import AgentChatRuntime, HostRuntime
from app.application.outbox import AsyncOutboxTransaction
from app.application.subscription.delete import SubscribeDeletionRepository
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
from app.application.subscription.mutation import (
SubscriptionHistoryMutationRepository,
SubscriptionMutationRepository,
)
from app.startup.context import (
AgentChatRuntime,
HostRuntime,
SubscriptionRuntime,
)
def get_host_runtime(request: Request) -> HostRuntime:
@@ -46,3 +57,54 @@ def get_agent_chat_transaction(
) -> AsyncUnitOfWork:
"""构造绑定当前请求会话的 Agent 会话事务端口。"""
return cast(AsyncUnitOfWork, runtime.transaction(session))
def get_subscription_runtime(
runtime: HostRuntime = Depends(get_host_runtime),
) -> SubscriptionRuntime:
"""从完整宿主运行时收窄到订阅写事务能力。"""
return runtime.subscription
async def get_subscription_session(
runtime: SubscriptionRuntime = Depends(get_subscription_runtime),
) -> AsyncGenerator[object, None]:
"""从订阅运行时生成请求独占的异步会话。"""
async for session in runtime.async_session():
yield session
def get_subscription_repository(
session: object = Depends(get_subscription_session),
runtime: SubscriptionRuntime = Depends(get_subscription_runtime),
) -> (
SubscriptionMutationRepository
| SubscribeDeletionRepository
| SubscribeIdentityDeletionRepository
):
"""构造绑定当前请求会话的订阅仓储。"""
return runtime.repository(session)
def get_subscription_history_repository(
session: object = Depends(get_subscription_session),
runtime: SubscriptionRuntime = Depends(get_subscription_runtime),
) -> SubscriptionHistoryMutationRepository:
"""构造绑定当前请求会话的订阅历史仓储。"""
return runtime.history_repository(session)
def get_subscription_transaction(
session: object = Depends(get_subscription_session),
runtime: SubscriptionRuntime = Depends(get_subscription_runtime),
) -> AsyncUnitOfWork:
"""构造绑定当前订阅请求会话的异步事务端口。"""
return cast(AsyncUnitOfWork, runtime.transaction(session))
def get_subscription_outbox(
session: object = Depends(get_subscription_session),
runtime: SubscriptionRuntime = Depends(get_subscription_runtime),
) -> AsyncOutboxTransaction:
"""构造与订阅写入共享请求会话的 outbox 端口。"""
return runtime.outbox(session)
+52 -18
View File
@@ -1,17 +1,36 @@
"""订阅领域的请求级 command/query 依赖。"""
from typing import Any, cast
from fastapi import BackgroundTasks, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.adapters.external.server import MoviePilotServerHelper
from app.api.context import (
get_subscription_history_repository,
get_subscription_outbox,
get_subscription_repository,
get_subscription_transaction,
)
from app.api.data import get_async_db, get_db
from app.api.dependencies.data import repository, transaction
from app.api.dependencies.data import repository
from app.application.outbox import AsyncOutboxTransaction
from app.application.scheduling import Scheduler
from app.application.servarr import ServarrSubscriptionService
from app.application.subscription.delete import DeleteSubscribeCommand
from app.application.subscription.delete import (
AsyncUnitOfWork as DeleteUnitOfWork,
DeleteSubscribeCommand,
SubscribeDeletionRepository,
)
from app.application.subscription.identity import DeleteSubscriptionsByIdentityCommand
from app.application.subscription.mutation import SubscriptionMutationService
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
from app.application.subscription.mutation import (
AsyncUnitOfWork as MutationUnitOfWork,
SubscriptionHistoryMutationRepository,
SubscriptionMutationRepository,
SubscriptionMutationService,
)
from app.application.subscription.query import SubscriptionQueryService
from app.application.subscription.search import SearchSubscriptionsCommand
from app.runtime.events import eventmanager
@@ -20,25 +39,29 @@ from app.schemas.types import EventType
async def _publish_subscribe_deleted(
subscribe_id: int,
subscribe_info: dict,
payload: dict[str, Any],
) -> None:
"""通过宿主事件总线发布已提交的订阅删除事件。"""
await eventmanager.async_send_event(
EventType.SubscribeDeleted,
{"subscribe_id": subscribe_id, "subscribe_info": subscribe_info},
)
await eventmanager.async_send_event(EventType.SubscribeDeleted, payload)
async def _publish_subscribe_modified(payload: dict[str, Any]) -> None:
"""通过宿主事件总线发布已提交的订阅修改事件。"""
await eventmanager.async_send_event(EventType.SubscribeModified, payload)
def get_delete_subscribe_command(
db: AsyncSession = Depends(get_async_db),
repository_port: object = Depends(get_subscription_repository),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxTransaction = Depends(get_subscription_outbox),
) -> DeleteSubscribeCommand:
"""组装请求级订阅删除用例及其具体适配器。"""
return DeleteSubscribeCommand(
repository=repository("subscribe", db),
unit_of_work=transaction("async", db),
repository=cast(SubscribeDeletionRepository, repository_port),
unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
publish_deleted=_publish_subscribe_deleted,
report_deleted=MoviePilotServerHelper.sub_done_async,
outbox=outbox,
)
@@ -54,14 +77,17 @@ def _log_subscribe_deleted_event_error(
def get_delete_subscriptions_by_identity_command(
db: AsyncSession = Depends(get_async_db),
repository_port: object = Depends(get_subscription_repository),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxTransaction = Depends(get_subscription_outbox),
) -> DeleteSubscriptionsByIdentityCommand:
"""组装请求级按媒体身份删除订阅用例。"""
return DeleteSubscriptionsByIdentityCommand(
repository=repository("subscribe", db),
unit_of_work=transaction("async", db),
repository=cast(SubscribeIdentityDeletionRepository, repository_port),
unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
publish_deleted=_publish_subscribe_deleted,
handle_event_error=_log_subscribe_deleted_event_error,
outbox=outbox,
)
@@ -98,12 +124,20 @@ def get_subscription_query_service(
def get_subscription_mutation_service(
db: AsyncSession = Depends(get_async_db),
repository_port: object = Depends(get_subscription_repository),
history_repository: SubscriptionHistoryMutationRepository = Depends(
get_subscription_history_repository
),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxTransaction = Depends(get_subscription_outbox),
) -> SubscriptionMutationService:
"""组装异步订阅写服务。"""
return SubscriptionMutationService(
repository=repository("subscribe", db),
history_repository=repository("subscribe_history", db),
repository=cast(SubscriptionMutationRepository, repository_port),
history_repository=history_repository,
unit_of_work=cast(MutationUnitOfWork, unit_of_work),
outbox=outbox,
publish_modified=_publish_subscribe_modified,
)
+31 -30
View File
@@ -14,8 +14,8 @@ from app.schemas.workflow import Subscribe as _SchemaSubscribe
from app.api.response import ResponseAPIRouter
from app.chain.subscribe import SubscribeChain
from app.runtime.config import settings
from app.domain.context import MediaInfo
from app.runtime.events import eventmanager
from app.domain.context import MediaInfo
from app.domain.metainfo import MetaInfo
from app.adapters.web.security.access import verify_token, verify_apitoken
from app.application.subscription.delete import (
@@ -294,16 +294,16 @@ async def update_subscribe(
)
if not change:
return _SchemaResponse(success=False, message="订阅不存在")
# 发送订阅调整事件
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subscribe_in.id,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="update",
).to_dict(),
)
if not change.event_published:
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subscribe_in.id,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="update",
).to_dict(),
)
return _SchemaResponse(success=True)
@@ -327,16 +327,16 @@ async def update_subscribe_status(
change = await mutation.update_status(subid, state, actor)
if not change:
return _SchemaResponse(success=False, message="订阅不存在")
# 发送订阅调整事件
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subid,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="status",
).to_dict(),
)
if not change.event_published:
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subid,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="status",
).to_dict(),
)
return _SchemaResponse(success=True)
@@ -388,15 +388,16 @@ async def reset_subscribes(
)
change = await mutation.reset(subid, actor)
if change:
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subid,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="reset",
).to_dict(),
)
if not change.event_published:
await eventmanager.async_send_event(
EventType.SubscribeModified,
SubscribeModifiedEventData(
subscribe_id=subid,
old_subscribe_info=change.old,
subscribe_info=change.new,
scene="reset",
).to_dict(),
)
return _SchemaResponse(success=True)
return _SchemaResponse(success=False, message="订阅不存在")
+14
View File
@@ -53,6 +53,20 @@ class OutboxRepository(Protocol):
"""记录有限退避或 dead-letter 终态。"""
class AsyncOutboxTransaction(Protocol):
"""异步业务事务暂存并收口 durable intent 的最小端口。"""
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
"""把 intent 加入调用方当前事务,但不自行提交。"""
async def complete_by_event_key(
self,
event_key: str,
completed_at: datetime,
) -> None:
"""即时投递成功后按稳定幂等键标记 intent 完成。"""
class OutboxDispatcher:
"""认领并派发 outbox,按 event key 依赖 handler 幂等。"""
+67 -14
View File
@@ -1,7 +1,13 @@
"""订阅删除应用用例及其依赖端口。"""
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from typing import Awaitable, Callable, Mapping, Protocol
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, Mapping, Protocol, cast
from uuid import uuid4
from app.application.outbox import AsyncOutboxTransaction, OutboxIntent
from app.schemas.event import SubscribeDeletedEventData
@dataclass(frozen=True)
@@ -48,10 +54,7 @@ class AsyncUnitOfWork(Protocol):
...
SubscribeDeletedPublisher = Callable[
[int, Mapping[str, object]],
Awaitable[None],
]
SubscribeDeletedPublisher = Callable[[dict[str, Any]], Awaitable[None]]
SubscribeDeletedReporter = Callable[[Mapping[str, object]], object]
@@ -64,12 +67,14 @@ class DeleteSubscribeCommand:
unit_of_work: AsyncUnitOfWork,
publish_deleted: SubscribeDeletedPublisher,
report_deleted: SubscribeDeletedReporter,
outbox: AsyncOutboxTransaction | None = None,
) -> None:
"""注入数据访问、事务与提交后副作用端口。"""
self._repository = repository
self._unit_of_work = unit_of_work
self._publish_deleted = publish_deleted
self._report_deleted = report_deleted
self._outbox = outbox
async def execute(
self,
@@ -85,23 +90,38 @@ class DeleteSubscribeCommand:
candidate = await self._repository.get_candidate(subscribe_id)
if not self._can_delete(candidate, actor):
return False
assert candidate is not None
await self._repository.stage_delete(subscribe_id)
event_payload = build_subscribe_deleted_payload(
subscribe_id,
candidate.event_payload,
)
event_key = event_payload["idempotency_key"]
try:
if self._outbox:
await self._outbox.stage(
OutboxIntent(
event_key=event_key,
topic="subscribe.deleted",
payload=event_payload,
),
datetime.now(timezone.utc),
)
await self._unit_of_work.commit()
except Exception:
await self._unit_of_work.rollback()
raise
event_payload = dict(candidate.event_payload)
await self._publish_deleted(subscribe_id, event_payload)
self._report_deleted(
{
"media_source": event_payload.get("media_source"),
"media_id": event_payload.get("media_id"),
"season": event_payload.get("season"),
}
)
await self._publish_deleted(event_payload)
if self._outbox:
await self._outbox.complete_by_event_key(
event_key,
datetime.now(timezone.utc),
)
# 上报适配器会自行白名单过滤公开字段;传完整删除前快照可保留音乐实体维度,
# 避免 Agent 与 API 入口收敛后丢失 music_type / total_tracks。
self._report_deleted(dict(candidate.event_payload))
return True
@staticmethod
@@ -115,3 +135,36 @@ class DeleteSubscribeCommand:
if actor.is_superuser:
return True
return bool(candidate.username) and candidate.username == actor.username
def build_subscribe_deleted_payload(
subscribe_id: int,
subscribe_info: Mapping[str, object],
) -> dict[str, Any]:
"""构造兼容旧字段并携带幂等键的订阅删除事件快照。"""
event_key = f"subscribe.deleted:{subscribe_id}:{uuid4().hex}:v1"
return cast(
dict[str, Any],
SubscribeDeletedEventData(
subscribe_id=subscribe_id,
subscribe_info=dict(subscribe_info),
idempotency_key=event_key,
).model_dump(mode="json"),
)
DeleteSubscribeScope = Callable[[], AbstractAsyncContextManager[DeleteSubscribeCommand]]
_configured_delete_scope: DeleteSubscribeScope | None = None
def configure_delete_subscribe_scope(provider: DeleteSubscribeScope) -> None:
"""由启动组合根登记非 HTTP 入口使用的订阅删除事务作用域。"""
global _configured_delete_scope
_configured_delete_scope = provider
def get_delete_subscribe_scope() -> AbstractAsyncContextManager[DeleteSubscribeCommand]:
"""返回一次独占会话的订阅删除命令作用域。"""
if _configured_delete_scope is None:
raise RuntimeError("订阅删除事务作用域尚未配置")
return _configured_delete_scope()
+32 -8
View File
@@ -1,12 +1,15 @@
"""按媒体身份批量删除订阅的应用用例。"""
from typing import Callable, Protocol
from datetime import datetime, timezone
from typing import Any, Callable, Protocol
from app.application.outbox import AsyncOutboxTransaction, OutboxIntent
from app.application.subscription.delete import (
AsyncUnitOfWork,
SubscribeDeletedPublisher,
SubscribeDeletionActor,
SubscribeDeletionCandidate,
build_subscribe_deleted_payload,
)
from app.schemas.types import MediaSource
@@ -24,8 +27,8 @@ class SubscribeIdentityDeletionRepository(Protocol):
"""读取匹配媒体身份的去重订阅快照。"""
...
async def delete(self, subscribe_id: int) -> None:
"""把指定订阅登记为待删除。"""
async def stage_delete(self, subscribe_id: int) -> None:
"""把指定订阅登记为待删除,但不自行提交事务"""
...
@@ -41,12 +44,14 @@ class DeleteSubscriptionsByIdentityCommand:
unit_of_work: AsyncUnitOfWork,
publish_deleted: SubscribeDeletedPublisher,
handle_event_error: SubscribeDeletionEventErrorHandler,
outbox: AsyncOutboxTransaction | None = None,
) -> None:
"""注入数据访问、事务、事件和事件错误处理端口。"""
self._repository = repository
self._unit_of_work = unit_of_work
self._publish_deleted = publish_deleted
self._handle_event_error = handle_event_error
self._outbox = outbox
async def execute(
self,
@@ -68,21 +73,40 @@ class DeleteSubscriptionsByIdentityCommand:
for candidate in candidates
if self._can_delete(candidate, actor)
]
events: list[tuple[SubscribeDeletionCandidate, dict[str, Any]]] = []
for candidate in deletions:
await self._repository.stage_delete(candidate.subscribe_id)
event_payload = build_subscribe_deleted_payload(
candidate.subscribe_id,
candidate.event_payload,
)
events.append((candidate, event_payload))
try:
if self._outbox:
now = datetime.now(timezone.utc)
for _, event_payload in events:
await self._outbox.stage(
OutboxIntent(
event_key=event_payload["idempotency_key"],
topic="subscribe.deleted",
payload=event_payload,
),
now,
)
await self._unit_of_work.commit()
except Exception:
await self._unit_of_work.rollback()
raise
for candidate in deletions:
for candidate, event_payload in events:
try:
await self._publish_deleted(
candidate.subscribe_id,
dict(candidate.event_payload),
)
await self._publish_deleted(event_payload)
if self._outbox:
await self._outbox.complete_by_event_key(
event_payload["idempotency_key"],
datetime.now(timezone.utc),
)
except Exception as error:
self._handle_event_error(candidate.subscribe_id, error)
return len(deletions)
+118 -8
View File
@@ -1,7 +1,14 @@
"""订阅写操作用例及其数据端口。"""
from collections.abc import Awaitable, Callable
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Protocol
from uuid import uuid4
from app.application.outbox import AsyncOutboxTransaction, OutboxIntent
from app.schemas.event import SubscribeModifiedEventData
class SubscriptionMutationRepository(Protocol):
@@ -13,6 +20,13 @@ class SubscriptionMutationRepository(Protocol):
async def async_update(self, subscribe_id: int, payload: dict[str, Any]) -> Any | None:
"""更新订阅。"""
async def async_stage_update(
self,
subscribe_id: int,
payload: dict[str, Any],
) -> Any | None:
"""在调用方事务中暂存更新但不提交。"""
def get(self, subscribe_id: int) -> Any | None:
"""同步按 ID 获取订阅。"""
@@ -27,6 +41,19 @@ class SubscriptionHistoryMutationRepository(Protocol):
"""删除订阅历史。"""
class AsyncUnitOfWork(Protocol):
"""订阅修改用例使用的异步事务端口。"""
async def commit(self) -> None:
"""提交当前订阅修改事务。"""
async def rollback(self) -> None:
"""回滚当前订阅修改事务。"""
SubscribeModifiedPublisher = Callable[[dict[str, Any]], Awaitable[None]]
@dataclass(frozen=True)
class SubscriptionActor:
"""订阅写操作的权限主体。"""
@@ -41,6 +68,7 @@ class SubscriptionMutation:
old: dict[str, Any]
new: dict[str, Any]
event_published: bool = False
class SubscriptionMutationService:
@@ -50,10 +78,16 @@ class SubscriptionMutationService:
self,
repository: SubscriptionMutationRepository,
history_repository: SubscriptionHistoryMutationRepository | None = None,
unit_of_work: AsyncUnitOfWork | None = None,
outbox: AsyncOutboxTransaction | None = None,
publish_modified: SubscribeModifiedPublisher | None = None,
) -> None:
"""注入订阅和订阅历史数据端口。"""
"""注入订阅数据、事务与 durable 事件端口。"""
self._repository = repository
self._history_repository = history_repository
self._unit_of_work = unit_of_work
self._outbox = outbox
self._publish_modified = publish_modified
async def get_accessible(
self,
@@ -79,16 +113,56 @@ class SubscriptionMutationService:
payload: dict[str, Any],
actor: SubscriptionActor,
existing: Any | None = None,
scene: str = "update",
) -> SubscriptionMutation | None:
"""更新当前主体可访问的订阅并返回前后快照"""
"""更新订阅,并在同一事务暂存可恢复的 SubscribeModified 事件"""
subscribe = existing or await self.get_accessible(subscribe_id, actor)
if subscribe and not self.can_access(subscribe, actor):
return None
if not subscribe:
return None
old = subscribe.to_dict()
updated = await self._repository.async_update(subscribe_id, payload)
return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {})
if not self._unit_of_work:
updated = await self._repository.async_update(subscribe_id, payload)
return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {})
if not self._outbox or not self._publish_modified:
raise RuntimeError("订阅修改事务缺少 outbox 或事件发布端口")
try:
updated = await self._repository.async_stage_update(subscribe_id, payload)
if not updated:
return None
event_payload = SubscribeModifiedEventData(
subscribe_id=subscribe_id,
old_subscribe_info=old,
subscribe_info=updated.to_dict(),
scene=scene,
).to_dict()
event_key = _modified_event_key(subscribe_id, scene)
event_payload["idempotency_key"] = event_key
await self._outbox.stage(
OutboxIntent(
event_key=event_key,
topic="subscribe.modified",
payload=event_payload,
),
datetime.now(timezone.utc),
)
await self._unit_of_work.commit()
except Exception:
await self._unit_of_work.rollback()
raise
await self._publish_modified(event_payload)
await self._outbox.complete_by_event_key(
event_key,
datetime.now(timezone.utc),
)
return SubscriptionMutation(
old=old,
new=event_payload["subscribe_info"],
event_published=True,
)
async def update_status(
self,
@@ -97,7 +171,12 @@ class SubscriptionMutationService:
actor: SubscriptionActor,
) -> SubscriptionMutation | None:
"""更新订阅状态并返回前后快照。"""
return await self.update(subscribe_id, {"state": state}, actor)
return await self.update(
subscribe_id,
{"state": state},
actor,
scene="status",
)
async def reset(
self,
@@ -120,9 +199,13 @@ class SubscriptionMutationService:
"manual_total_episode": 0,
"state": "R",
}
old = subscribe.to_dict()
updated = await self._repository.async_update(subscribe_id, payload)
return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {})
return await self.update(
subscribe_id,
payload,
actor,
existing=subscribe,
scene="reset",
)
async def delete_history(
self,
@@ -147,3 +230,30 @@ class SubscriptionMutationService:
return True
username = getattr(subscribe, "username", None)
return bool(username) and username == actor.name
def _modified_event_key(subscribe_id: int, scene: str) -> str:
"""为一次订阅修改生成重试期间稳定且跨多次相同变更不碰撞的幂等键。"""
return f"subscribe.modified:{subscribe_id}:{scene}:{uuid4().hex}:v1"
SubscriptionMutationScope = Callable[
[],
AbstractAsyncContextManager[SubscriptionMutationService],
]
_configured_mutation_scope: SubscriptionMutationScope | None = None
def configure_subscription_mutation_scope(
provider: SubscriptionMutationScope,
) -> None:
"""由启动组合根登记 Agent 等非 HTTP 入口使用的事务作用域。"""
global _configured_mutation_scope
_configured_mutation_scope = provider
def get_subscription_mutation_scope() -> AbstractAsyncContextManager[SubscriptionMutationService]:
"""返回一次独占会话的订阅修改服务作用域。"""
if _configured_mutation_scope is None:
raise RuntimeError("订阅修改事务作用域尚未配置")
return _configured_mutation_scope()
+16
View File
@@ -496,6 +496,22 @@ class SubscribeOper(DbOper):
await subscribe.async_update(self._db, payload)
return subscribe
async def async_stage_update(
self,
sid: int,
payload: dict,
) -> Optional[Subscribe]:
"""在调用方 AsyncSession 中暂存订阅更新并 flush,不提交事务。"""
if not isinstance(self._db, AsyncSession):
raise RuntimeError("异步订阅修改需要调用方提供 AsyncSession")
subscribe = await self.async_get(sid)
if not subscribe:
return None
for key, value in _normalize_integer_flags(payload).items():
setattr(subscribe, key, value)
await self._db.flush()
return subscribe
async def async_update_filter_groups(
self, sid: int, filter_groups: List[str]
) -> Optional[Subscribe]:
+2
View File
@@ -54,6 +54,8 @@ class EventContract:
_PAYLOAD_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = {
EventType.ConfigChanged: event_schemas.ConfigChangeEventData,
EventType.AgentTokensUsage: event_schemas.AgentTokensUsageEventData,
EventType.SubscribeAdded: event_schemas.SubscribeAddedEventData,
EventType.SubscribeDeleted: event_schemas.SubscribeDeletedEventData,
EventType.SubscribeModified: event_schemas.SubscribeModifiedEventData,
ChainEventType.PluginDataReset: event_schemas.PluginDataResetEventData,
ChainEventType.AuthVerification: event_schemas.AuthCredentials,
+22 -1
View File
@@ -680,6 +680,7 @@ class SubscribeModifiedEventData(BaseEventData):
subscribe_info: Dict[str, Any] = Field(default_factory=dict, description="更新后订阅快照")
scene: str = Field(default="update", description="触发场景:update/status/reset/agent_update")
fields: List[str] = Field(default_factory=list, description="真实变更字段")
idempotency_key: Optional[str] = Field(default=None, description="宿主生成的幂等键")
@model_validator(mode="after")
def compute_fields(self):
@@ -700,13 +701,33 @@ class SubscribeModifiedEventData(BaseEventData):
"""
输出公开事件 payload,避免内部属性被未来扩展意外暴露。
"""
return {
payload = {
"subscribe_id": self.subscribe_id,
"old_subscribe_info": self.old_subscribe_info,
"subscribe_info": self.subscribe_info,
"scene": self.scene,
"fields": list(self.fields),
}
if self.idempotency_key:
payload["idempotency_key"] = self.idempotency_key
return payload
class SubscribeAddedEventData(BaseEventData):
"""SubscribeAdded 广播事件的可恢复公开 payload。"""
subscribe_id: int = Field(description="订阅 ID")
username: Optional[str] = Field(default=None, description="发起订阅的用户")
mediainfo: Dict[str, Any] = Field(default_factory=dict, description="媒体信息快照")
idempotency_key: Optional[str] = Field(default=None, description="宿主生成的幂等键")
class SubscribeDeletedEventData(BaseEventData):
"""SubscribeDeleted 广播事件的可恢复公开 payload。"""
subscribe_id: int = Field(description="订阅 ID")
subscribe_info: Dict[str, Any] = Field(default_factory=dict, description="删除前订阅快照")
idempotency_key: Optional[str] = Field(default=None, description="宿主生成的幂等键")
class SubscribeCompletionCheckEventData(ChainEventData):
+2
View File
@@ -323,7 +323,9 @@ SCHEMA_EXPORTS = {
'StorageUsage': ('app.schemas.file', 'StorageUsage'),
'SubscrbieInfo': ('app.schemas.subscribe', 'SubscrbieInfo'),
'Subscribe': ('app.schemas.workflow', 'Subscribe'),
'SubscribeAddedEventData': ('app.schemas.event', 'SubscribeAddedEventData'),
'SubscribeCompletionCheckEventData': ('app.schemas.event', 'SubscribeCompletionCheckEventData'),
'SubscribeDeletedEventData': ('app.schemas.event', 'SubscribeDeletedEventData'),
'SubscribeDownloadFileInfo': ('app.schemas.subscribe', 'SubscribeDownloadFileInfo'),
'SubscribeEpisodeInfo': ('app.schemas.subscribe', 'SubscribeEpisodeInfo'),
'SubscribeEpisodesRefreshEventData': ('app.schemas.event', 'SubscribeEpisodesRefreshEventData'),
+50
View File
@@ -8,6 +8,13 @@ from app.application.messaging.chat import (
AsyncAgentChatRepository,
AsyncUnitOfWork,
)
from app.application.outbox import AsyncOutboxTransaction
from app.application.subscription.delete import SubscribeDeletionRepository
from app.application.subscription.identity import SubscribeIdentityDeletionRepository
from app.application.subscription.mutation import (
SubscriptionHistoryMutationRepository,
SubscriptionMutationRepository,
)
class AgentChatRepositoryFactory(Protocol):
@@ -26,6 +33,37 @@ class AsyncUnitOfWorkFactory(Protocol):
...
class AsyncOutboxFactory(Protocol):
"""由请求会话构造异步 outbox 事务端口的工厂。"""
def __call__(self, session: object) -> AsyncOutboxTransaction:
"""绑定请求会话并返回 outbox 暂存与收口端口。"""
...
class SubscriptionRepositoryFactory(Protocol):
"""由请求会话构造订阅写仓储的工厂。"""
def __call__(
self,
session: object,
) -> (
SubscriptionMutationRepository
| SubscribeDeletionRepository
| SubscribeIdentityDeletionRepository
):
"""绑定请求会话并返回订阅领域仓储。"""
...
class SubscriptionHistoryRepositoryFactory(Protocol):
"""由请求会话构造订阅历史写仓储的工厂。"""
def __call__(self, session: object) -> SubscriptionHistoryMutationRepository:
"""绑定请求会话并返回订阅历史仓储。"""
...
class AsyncSessionProvider(Protocol):
"""FastAPI 请求级异步会话提供器。"""
@@ -70,9 +108,21 @@ class AgentChatRuntime:
transaction: AsyncUnitOfWorkFactory
@dataclass(frozen=True, slots=True)
class SubscriptionRuntime:
"""订阅 API 可见的请求级写事务运行时。"""
async_session: AsyncSessionProvider
repository: SubscriptionRepositoryFactory
history_repository: SubscriptionHistoryRepositoryFactory
transaction: AsyncUnitOfWorkFactory
outbox: AsyncOutboxFactory
@dataclass(frozen=True, slots=True)
class HostRuntime:
"""宿主组合根构建且在一个 FastAPI lifespan 内共享的运行时对象。"""
agent_chat: AgentChatRuntime
subscription: SubscriptionRuntime
compatibility_api_data: CompatibilityApiData
+23 -4
View File
@@ -55,7 +55,7 @@ from app.application.security.userconfig import (
)
from app.application.history import configure_transfer_history_provider
from app.application.outbox import OutboxDispatcher, configure_outbox_dispatcher
from app.startup.outbox import SqlAlchemyOutboxRepository
from app.startup.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository
from app.application.site.query import SiteQueryService, configure_site_query_service
from app.application.site.health import SiteHealthService, configure_site_health_service
from app.application.workflow import WorkflowQueryService, configure_workflow_query
@@ -103,8 +103,11 @@ from app.startup.managed_resources_initializer import (
init_managed_resources,
stop_managed_resources,
)
from app.startup.subscription import TransactionalSubscribeWriter
from app.startup.context import AgentChatRuntime, HostRuntime
from app.startup.subscription import (
TransactionalSubscribeWriter,
configure_transactional_subscription_scopes,
)
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
from app.adapters.web.security.access import set_superuser_token_payload_provider
from app.application.security.auth import build_superuser_token_payload
from app.application.image import configure_wallpaper_providers
@@ -205,7 +208,15 @@ def _build_outbox_dispatcher() -> OutboxDispatcher:
"subscribe.added": lambda message: EventManager().send_event(
EventType.SubscribeAdded,
message.payload,
)
),
"subscribe.modified": lambda message: EventManager().send_event(
EventType.SubscribeModified,
message.payload,
),
"subscribe.deleted": lambda message: EventManager().send_event(
EventType.SubscribeDeleted,
message.payload,
),
},
close=session.close,
)
@@ -455,6 +466,13 @@ async def init_modules() -> HostRuntime:
repository=AgentChatOper,
transaction=SqlAlchemyAsyncUnitOfWork,
),
subscription=SubscriptionRuntime(
async_session=get_async_db,
repository=SubscribeOper,
history_repository=SubscribeHistoryOper,
transaction=SqlAlchemyAsyncUnitOfWork,
outbox=SqlAlchemyAsyncOutboxStager,
),
compatibility_api_data=api_data,
)
configure_api_data_runtime(host_runtime.compatibility_api_data)
@@ -515,6 +533,7 @@ async def init_modules() -> HostRuntime:
async_session=async_session_scope,
)
)
configure_transactional_subscription_scopes()
# 托管资源只在这里装配声明与 adapter,具体资源仍由首个消费者显式激活。
init_managed_resources()
# 应用服务不反向依赖 Chain,由启动组合层注入壁纸来源。
+57 -1
View File
@@ -1,8 +1,9 @@
"""订阅写入事务适配器的启动装配。"""
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
@@ -14,12 +15,25 @@ from app.application.subscription.write import (
CreateSubscriptionCommand,
subscription_added_event_key,
)
from app.application.subscription.delete import (
DeleteSubscribeCommand,
configure_delete_subscribe_scope,
)
from app.application.subscription.mutation import (
SubscriptionMutationService,
configure_subscription_mutation_scope,
)
from app.adapters.external.server import MoviePilotServerHelper
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.session import async_session_scope
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
from app.startup.outbox import (
SqlAlchemyAsyncOutboxStager,
SqlAlchemyOutboxRepository,
)
from app.runtime.events import EventManager
from app.schemas.types import EventType
class TransactionalSubscribeWriter:
@@ -98,3 +112,45 @@ class TransactionalSubscribeWriter:
username,
delivered,
)
async def _publish_modified(payload: dict[str, Any]) -> None:
"""发布事务已提交的订阅修改事件。"""
await EventManager().async_send_event(EventType.SubscribeModified, payload)
async def _publish_deleted(payload: dict[str, Any]) -> None:
"""发布事务已提交的订阅删除事件。"""
await EventManager().async_send_event(EventType.SubscribeDeleted, payload)
@asynccontextmanager
async def subscription_mutation_scope():
"""为非 HTTP 入口创建独占订阅修改会话、UoW 与 outbox。"""
async with async_session_scope() as session:
yield SubscriptionMutationService(
repository=SubscribeOper(session),
history_repository=SubscribeHistoryOper(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
outbox=SqlAlchemyAsyncOutboxStager(session),
publish_modified=_publish_modified,
)
@asynccontextmanager
async def delete_subscribe_scope():
"""为非 HTTP 入口创建独占订阅删除会话、UoW 与 outbox。"""
async with async_session_scope() as session:
yield DeleteSubscribeCommand(
repository=SubscribeOper(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
publish_deleted=_publish_deleted,
report_deleted=MoviePilotServerHelper.sub_done_async,
outbox=SqlAlchemyAsyncOutboxStager(session),
)
def configure_transactional_subscription_scopes() -> None:
"""登记 Agent 等非 HTTP 入口复用的订阅事务作用域。"""
configure_subscription_mutation_scope(subscription_mutation_scope)
configure_delete_subscribe_scope(delete_subscribe_scope)
@@ -19,6 +19,10 @@ MoviePilot 保持模块化单体,不把所有后台动作迁到分布式队列
`durable-required` 是目标语义,不代表当前实现已经 durable。ARCH-251 前,Event Registry 中标记该值的
事件仍应在风险报告中说明崩溃窗口。
截至 2026-08-22,宿主正式装配的 `SubscribeAdded``SubscribeModified``SubscribeDeleted` 广播已由
业务事务内的 outbox intent 提供 at-least-once 恢复;payload 保持插件 dict ABI,并增加可选幂等键。
这不覆盖第三方插件自行发送的裸事件,也不代表订阅通知和外部统计上报已经全部 durable。
## Event 映射
Event Contract Registry 是 53 个事件的逐项机器清单。下表按相同语义分组列出每个事件,不省略事件名。
@@ -93,5 +97,6 @@ pending 状态交由下次启动,不以取消异常写成成功。
## 验证与演进
- Event Registry 的 `delivery` 字段与本 ADR 同步进入 runtime baseline。
- ARCH-251 只实现一个 E2 pilot,并通过 commit 后崩溃、重复 claim、并发 claim 和 dead-letter 测试。
- ARCH-251 一个 E2 pilot 扩展到三种订阅生命周期事件,并通过 commit 后崩溃、重复 claim、并发 claim
和 dead-letter 测试;下载与整理结果事件仍需逐条迁移。
- ARCH-252 将 Scheduler 的定义、触发和执行状态拆分,但不提升不需要 durable 的 E0 信号。
@@ -729,6 +729,21 @@ ADR 必须逐个映射当前 Event、BackgroundTasks、Scheduler job、Agent tas
- 66 个订阅/调度专项测试和 40 个数据库、迁移、Session/outbox 测试通过(1 个环境条件 skip);
fresh schema 先 create_all 再升级与重复迁移均保持幂等。
**扩展实施记录(2026-08-22**
- 宿主自有的 `SubscribeModified``SubscribeDeleted` 生产路径已扩展到同一 outbox:订阅行更新/删除与
version 1 intent 使用同一 `AsyncSession`、UoW 和 commit;即时广播失败时 intent 保持 pending,恢复
dispatcher 分别按 `subscribe.modified``subscribe.deleted` topic 重放。
- API、Agent 更新/删除工具以及按媒体身份批量删除均复用请求级或独占事务作用域。API 中保留的
`event_published=False` 分支只服务测试替身和旧依赖注入,不是正式装配路径;正式 `HostRuntime`
同时提供订阅 repository、history repository、transaction 与 outbox factory。
- `SubscribeAddedEventData``SubscribeModifiedEventData``SubscribeDeletedEventData` 已进入 Event Contract
对插件仍投递原有 dict 字段,只新增可选 `idempotency_key`,不把 Pydantic 实例传给插件。
- 保证边界只覆盖主仓可追踪的宿主生产者。运行时安装在 `app/plugins/**` 的第三方插件未被主仓改写;
插件若自行直接发送同名事件,该发送仍由插件负责,无法与插件自己的数据库写入自动组成原子事务。
- 订阅外部统计上报仍是 post-commit 副作用,不在事件 intent 的重放 handler 中;因此当前可以宣称三种
订阅事件具备宿主级 at-least-once 恢复,但不能宣称订阅通知和所有外部上报均已 durable。
**禁止**:本阶段不引入 Celery、Kafka、RabbitMQ 等新基础设施。
#### ARCH-252Scheduler 拆成声明、执行和状态
+6 -1
View File
@@ -15,5 +15,10 @@ files =
app/runtime/observability/__init__.py,
app/runtime/event/contracts.py,
app/runtime/extensions/module/contracts.py,
app/application/outbox.py,
app/application/subscription/delete.py,
app/application/subscription/identity.py,
app/application/subscription/mutation.py,
app/startup/context.py,
app/api/context.py
app/api/context.py,
app/api/dependencies/subscription.py
+39 -12
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6244,
"edge_sha256": "f187bdbb5e88ce9a6b2ff559b10e5e6cd5a60d14693484ea17663cebf438cc92",
"edge_count": 6271,
"edge_sha256": "0119519add8e4499684044fc69f3003c54cfca20bf04f4614a419374692d5a94",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -715,20 +715,16 @@
"app.agent.tools.impl.delete_rule_group -> app.runtime.log",
"app.agent.tools.impl.delete_rule_group -> app.schemas",
"app.agent.tools.impl.delete_rule_group -> app.schemas.types",
"app.agent.tools.impl.delete_subscribe -> app.adapters",
"app.agent.tools.impl.delete_subscribe -> app.adapters.external",
"app.agent.tools.impl.delete_subscribe -> app.adapters.external.server",
"app.agent.tools.impl.delete_subscribe -> app.agent",
"app.agent.tools.impl.delete_subscribe -> app.agent.tools",
"app.agent.tools.impl.delete_subscribe -> app.agent.tools.base",
"app.agent.tools.impl.delete_subscribe -> app.agent.tools.tags",
"app.agent.tools.impl.delete_subscribe -> app.application",
"app.agent.tools.impl.delete_subscribe -> app.application.agentdata",
"app.agent.tools.impl.delete_subscribe -> app.application.subscription",
"app.agent.tools.impl.delete_subscribe -> app.application.subscription.delete",
"app.agent.tools.impl.delete_subscribe -> app.application.subscription.mutation",
"app.agent.tools.impl.delete_subscribe -> app.runtime",
"app.agent.tools.impl.delete_subscribe -> app.runtime.events",
"app.agent.tools.impl.delete_subscribe -> app.runtime.log",
"app.agent.tools.impl.delete_subscribe -> app.schemas",
"app.agent.tools.impl.delete_subscribe -> app.schemas.types",
"app.agent.tools.impl.delete_transfer_history -> app.agent",
"app.agent.tools.impl.delete_transfer_history -> app.agent.tools",
"app.agent.tools.impl.delete_transfer_history -> app.agent.tools.base",
@@ -1440,12 +1436,11 @@
"app.agent.tools.impl.update_subscribe -> app.agent.tools.base",
"app.agent.tools.impl.update_subscribe -> app.agent.tools.tags",
"app.agent.tools.impl.update_subscribe -> app.application",
"app.agent.tools.impl.update_subscribe -> app.application.agentdata",
"app.agent.tools.impl.update_subscribe -> app.application.subscription",
"app.agent.tools.impl.update_subscribe -> app.application.subscription.mutation",
"app.agent.tools.impl.update_subscribe -> app.runtime",
"app.agent.tools.impl.update_subscribe -> app.runtime.events",
"app.agent.tools.impl.update_subscribe -> app.runtime.log",
"app.agent.tools.impl.update_subscribe -> app.schemas",
"app.agent.tools.impl.update_subscribe -> app.schemas.event",
"app.agent.tools.impl.update_subscribe -> app.schemas.types",
"app.agent.tools.impl.update_system_settings -> app.agent",
"app.agent.tools.impl.update_system_settings -> app.agent.tools",
@@ -1486,6 +1481,11 @@
"app.api.context -> app.application",
"app.api.context -> app.application.messaging",
"app.api.context -> app.application.messaging.chat",
"app.api.context -> app.application.outbox",
"app.api.context -> app.application.subscription",
"app.api.context -> app.application.subscription.delete",
"app.api.context -> app.application.subscription.identity",
"app.api.context -> app.application.subscription.mutation",
"app.api.context -> app.startup",
"app.api.context -> app.startup.context",
"app.api.dependencies.agent -> app.api",
@@ -1562,10 +1562,12 @@
"app.api.dependencies.subscription -> app.adapters.external",
"app.api.dependencies.subscription -> app.adapters.external.server",
"app.api.dependencies.subscription -> app.api",
"app.api.dependencies.subscription -> app.api.context",
"app.api.dependencies.subscription -> app.api.data",
"app.api.dependencies.subscription -> app.api.dependencies",
"app.api.dependencies.subscription -> app.api.dependencies.data",
"app.api.dependencies.subscription -> app.application",
"app.api.dependencies.subscription -> app.application.outbox",
"app.api.dependencies.subscription -> app.application.scheduling",
"app.api.dependencies.subscription -> app.application.servarr",
"app.api.dependencies.subscription -> app.application.subscription",
@@ -2703,11 +2705,20 @@
"app.application.subscription.contract -> app.schemas",
"app.application.subscription.contract -> app.schemas.media",
"app.application.subscription.contract -> app.schemas.types",
"app.application.subscription.delete -> app.application",
"app.application.subscription.delete -> app.application.outbox",
"app.application.subscription.delete -> app.schemas",
"app.application.subscription.delete -> app.schemas.event",
"app.application.subscription.identity -> app.application",
"app.application.subscription.identity -> app.application.outbox",
"app.application.subscription.identity -> app.application.subscription",
"app.application.subscription.identity -> app.application.subscription.delete",
"app.application.subscription.identity -> app.schemas",
"app.application.subscription.identity -> app.schemas.types",
"app.application.subscription.mutation -> app.application",
"app.application.subscription.mutation -> app.application.outbox",
"app.application.subscription.mutation -> app.schemas",
"app.application.subscription.mutation -> app.schemas.event",
"app.application.subscription.query -> app.domain",
"app.application.subscription.query -> app.domain.context",
"app.application.subscription.query -> app.domain.meta",
@@ -5836,6 +5847,11 @@
"app.startup.context -> app.application",
"app.startup.context -> app.application.messaging",
"app.startup.context -> app.application.messaging.chat",
"app.startup.context -> app.application.outbox",
"app.startup.context -> app.application.subscription",
"app.startup.context -> app.application.subscription.delete",
"app.startup.context -> app.application.subscription.identity",
"app.startup.context -> app.application.subscription.mutation",
"app.startup.database -> app.adapters",
"app.startup.database -> app.adapters.system",
"app.startup.database -> app.adapters.system.backup",
@@ -6073,13 +6089,24 @@
"app.startup.scheduler_initializer -> app.application",
"app.startup.scheduler_initializer -> app.application.scheduling",
"app.startup.scheduler_initializer -> app.scheduler",
"app.startup.subscription -> app.adapters",
"app.startup.subscription -> app.adapters.external",
"app.startup.subscription -> app.adapters.external.server",
"app.startup.subscription -> app.application",
"app.startup.subscription -> app.application.subscription",
"app.startup.subscription -> app.application.subscription.delete",
"app.startup.subscription -> app.application.subscription.mutation",
"app.startup.subscription -> app.application.subscription.write",
"app.startup.subscription -> app.db",
"app.startup.subscription -> app.db.oper",
"app.startup.subscription -> app.db.oper.subscribe",
"app.startup.subscription -> app.db.oper.subscribehistory",
"app.startup.subscription -> app.db.session",
"app.startup.subscription -> app.db.uow",
"app.startup.subscription -> app.runtime",
"app.startup.subscription -> app.runtime.events",
"app.startup.subscription -> app.schemas",
"app.startup.subscription -> app.schemas.types",
"app.startup.subscription -> app.startup",
"app.startup.subscription -> app.startup.outbox",
"app.startup.transfer_initializer -> app.chain",
+25 -13
View File
@@ -1581,10 +1581,10 @@
"EventType.SubscribeAdded": {
"delivery": "durable_required",
"error_behavior": "notify",
"legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI",
"legacy_reason": null,
"mode": "broadcast",
"ordering": "priority_queue",
"payload_contract": "legacy_dict",
"payload_contract": "SubscribeAddedEventData",
"sensitive_fields": [],
"visibility": "plugin_public"
},
@@ -1601,10 +1601,10 @@
"EventType.SubscribeDeleted": {
"delivery": "durable_required",
"error_behavior": "notify",
"legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI",
"legacy_reason": null,
"mode": "broadcast",
"ordering": "priority_queue",
"payload_contract": "legacy_dict",
"payload_contract": "SubscribeDeletedEventData",
"sensitive_fields": [],
"visibility": "plugin_public"
},
@@ -1958,11 +1958,11 @@
},
"ChainEventType.WorkflowExecution": {
"consumers": [],
"producers": [
{
"caller": "app.workflow.actions.send_event",
"count": 1
}
"producers": [
{
"caller": "app.workflow.actions.send_event",
"count": 1
}
]
},
"EventType.AgentTokensUsage": {
@@ -2200,11 +2200,15 @@
"consumers": [],
"producers": [
{
"caller": "app.agent.tools.impl.delete_subscribe",
"caller": "app.api.dependencies.subscription",
"count": 1
},
{
"caller": "app.api.dependencies.subscription",
"caller": "app.startup.modules_initializer",
"count": 1
},
{
"caller": "app.startup.subscription",
"count": 1
}
]
@@ -2213,12 +2217,20 @@
"consumers": [],
"producers": [
{
"caller": "app.agent.tools.impl.update_subscribe",
"caller": "app.api.dependencies.subscription",
"count": 1
},
{
"caller": "app.api.endpoints.subscribe",
"count": 3
},
{
"caller": "app.startup.modules_initializer",
"count": 1
},
{
"caller": "app.startup.subscription",
"count": 1
}
]
},
@@ -2308,7 +2320,7 @@
]
}
},
"producer_count": 67
"producer_count": 70
},
"module_method_specs": {
"download_file": {
+63
View File
@@ -0,0 +1,63 @@
"""Agent 删除订阅工具的事务作用域委托测试。"""
import asyncio
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.agent.tools.impl.delete_subscribe import DeleteSubscribeTool
@asynccontextmanager
async def _scope(value):
"""把测试替身包装成工具使用的异步作用域。"""
yield value
def test_agent_delete_subscribe_uses_transactional_delete_command():
"""Agent 删除必须委托带 UoW/outbox 的应用命令,不能直接调用 Oper。"""
subscribe = SimpleNamespace(id=7, name="测试订阅", year="2026")
mutation = SimpleNamespace(get_accessible=AsyncMock(return_value=subscribe))
command = SimpleNamespace(execute=AsyncMock(return_value=True))
with patch(
"app.agent.tools.impl.delete_subscribe.get_subscription_mutation_scope",
side_effect=lambda: _scope(mutation),
), patch(
"app.agent.tools.impl.delete_subscribe.get_delete_subscribe_scope",
side_effect=lambda: _scope(command),
):
result = asyncio.run(
DeleteSubscribeTool(session_id="session-1", user_id="10001").run(
subscribe_id=7
)
)
assert result == "成功删除订阅:测试订阅 (2026)"
mutation.get_accessible.assert_awaited_once()
command.execute.assert_awaited_once()
subscribe_id, actor = command.execute.await_args.args
assert subscribe_id == 7
assert actor.is_superuser is True
def test_agent_delete_subscribe_skips_command_when_record_is_missing():
"""预读未命中时保持原有不存在提示,且不创建删除副作用。"""
mutation = SimpleNamespace(get_accessible=AsyncMock(return_value=None))
delete_scope = AsyncMock()
with patch(
"app.agent.tools.impl.delete_subscribe.get_subscription_mutation_scope",
side_effect=lambda: _scope(mutation),
), patch(
"app.agent.tools.impl.delete_subscribe.get_delete_subscribe_scope",
delete_scope,
):
result = asyncio.run(
DeleteSubscribeTool(session_id="session-1", user_id="10001").run(
subscribe_id=404
)
)
assert result == "订阅 ID 404 不存在"
delete_scope.assert_not_called()
+52 -37
View File
@@ -1,9 +1,11 @@
import asyncio
import json
from unittest.mock import AsyncMock, patch
from contextlib import asynccontextmanager
from unittest.mock import patch
from app.agent.tools.impl.update_subscribe import UpdateSubscribeTool
from app.schemas.types import EventType, MediaType
from app.application.subscription.mutation import SubscriptionMutation
from app.schemas.types import MediaType
def test_agent_update_subscribe_sends_modified_event_payload_with_agent_scene():
@@ -13,13 +15,11 @@ def test_agent_update_subscribe_sends_modified_event_payload_with_agent_scene():
subscribe = _AgentSubscribe(id=9, name="旧标题", state="R", total_episode=8)
oper = _SubscribeOperStub(subscribe)
mutation = _MutationServiceStub(oper)
with patch(
"app.agent.tools.impl.update_subscribe.SubscribeOper",
return_value=oper,
), patch(
"app.agent.tools.impl.update_subscribe.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
"app.agent.tools.impl.update_subscribe.get_subscription_mutation_scope",
side_effect=lambda: _mutation_scope(mutation),
):
result = asyncio.run(
UpdateSubscribeTool(session_id="session-1", user_id="10001").run(
subscribe_id=9,
@@ -31,14 +31,7 @@ def test_agent_update_subscribe_sends_modified_event_payload_with_agent_scene():
payload = json.loads(result)
assert payload["success"] is True
assert oper.updates == [(9, {"name": "新标题", "state": "S"})]
send_event.assert_awaited_once()
event_type, event_payload = send_event.await_args.args
assert event_type == EventType.SubscribeModified
assert event_payload["subscribe_id"] == 9
assert event_payload["scene"] == "agent_update"
assert event_payload["fields"] == ["name", "state"]
assert event_payload["old_subscribe_info"]["name"] == "旧标题"
assert event_payload["subscribe_info"]["name"] == "新标题"
assert mutation.calls == [(9, {"name": "新标题", "state": "S"}, "agent_update")]
def test_agent_update_subscribe_ignores_unchanged_total_episode():
@@ -54,13 +47,11 @@ def test_agent_update_subscribe_ignores_unchanged_total_episode():
)
oper = _SubscribeOperStub(subscribe)
mutation = _MutationServiceStub(oper)
with patch(
"app.agent.tools.impl.update_subscribe.SubscribeOper",
return_value=oper,
), patch(
"app.agent.tools.impl.update_subscribe.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
"app.agent.tools.impl.update_subscribe.get_subscription_mutation_scope",
side_effect=lambda: _mutation_scope(mutation),
):
result = asyncio.run(
UpdateSubscribeTool(session_id="session-1", user_id="10001").run(
subscribe_id=160,
@@ -71,7 +62,7 @@ def test_agent_update_subscribe_ignores_unchanged_total_episode():
payload = json.loads(result)
assert payload == {"success": False, "message": "没有提供要更新的字段"}
assert oper.updates == []
send_event.assert_not_awaited()
assert mutation.calls == []
def test_agent_update_subscribe_only_updates_other_fields_with_unchanged_total_episode():
@@ -88,13 +79,11 @@ def test_agent_update_subscribe_only_updates_other_fields_with_unchanged_total_e
)
oper = _SubscribeOperStub(subscribe)
mutation = _MutationServiceStub(oper)
with patch(
"app.agent.tools.impl.update_subscribe.SubscribeOper",
return_value=oper,
), patch(
"app.agent.tools.impl.update_subscribe.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
"app.agent.tools.impl.update_subscribe.get_subscription_mutation_scope",
side_effect=lambda: _mutation_scope(mutation),
):
result = asyncio.run(
UpdateSubscribeTool(session_id="session-1", user_id="10001").run(
subscribe_id=160,
@@ -108,9 +97,7 @@ def test_agent_update_subscribe_only_updates_other_fields_with_unchanged_total_e
assert payload["updated_fields"] == ["best_version"]
assert payload["subscribe"]["manual_total_episode"] == 0
assert oper.updates == [(160, {"best_version": 1})]
send_event.assert_awaited_once()
_, event_payload = send_event.await_args.args
assert event_payload["fields"] == ["best_version"]
assert mutation.calls == [(160, {"best_version": 1}, "agent_update")]
def test_agent_update_subscribe_marks_changed_total_episode_as_manual():
@@ -126,12 +113,10 @@ def test_agent_update_subscribe_marks_changed_total_episode_as_manual():
)
oper = _SubscribeOperStub(subscribe)
mutation = _MutationServiceStub(oper)
with patch(
"app.agent.tools.impl.update_subscribe.SubscribeOper",
return_value=oper,
), patch(
"app.agent.tools.impl.update_subscribe.eventmanager.async_send_event",
new=AsyncMock(),
"app.agent.tools.impl.update_subscribe.get_subscription_mutation_scope",
side_effect=lambda: _mutation_scope(mutation),
):
result = asyncio.run(
UpdateSubscribeTool(session_id="session-1", user_id="10001").run(
@@ -186,3 +171,33 @@ class _SubscribeOperStub:
self.updates.append((subscribe_id, dict(payload)))
self.subscribe.__dict__.update(payload)
return self.subscribe
class _MutationServiceStub:
"""让 Agent 工具测试观察事务化修改服务收到的最终 payload。"""
def __init__(self, oper):
"""保存内存 Oper 与调用记录。"""
self.oper = oper
self.calls = []
async def get_accessible(self, subscribe_id, _actor):
"""模拟事务作用域内的权限读取。"""
return await self.oper.async_get(subscribe_id)
async def update(self, subscribe_id, payload, _actor, scene="update"):
"""模拟事务化更新并返回稳定快照。"""
old = self.oper.subscribe.to_dict()
updated = await self.oper.async_update(subscribe_id, payload)
self.calls.append((subscribe_id, dict(payload), scene))
return SubscriptionMutation(
old=old,
new=updated.to_dict(),
event_published=True,
)
@asynccontextmanager
async def _mutation_scope(service):
"""把测试修改服务包装成 Agent 使用的异步事务作用域。"""
yield service
+22 -1
View File
@@ -17,7 +17,7 @@ from app.api.data import (
get_api_data_ports,
)
from app.startup import lifecycle
from app.startup.context import AgentChatRuntime, HostRuntime
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
class _Repository:
@@ -42,6 +42,20 @@ class _UnitOfWork:
"""模拟回滚。"""
class _Outbox:
"""记录绑定会话的异步 outbox 替身。"""
def __init__(self, session: object) -> None:
"""保存与订阅仓储相同的请求会话。"""
self.session = session
async def stage(self, intent, now) -> None:
"""模拟暂存 durable intent。"""
async def complete_by_event_key(self, event_key, completed_at) -> None:
"""模拟收口 durable intent。"""
def _runtime() -> HostRuntime:
"""构造不加载数据库引擎或 PluginManager 的假宿主运行时。"""
async def async_session():
@@ -66,6 +80,13 @@ def _runtime() -> HostRuntime:
repository=_Repository,
transaction=_UnitOfWork,
),
subscription=SubscriptionRuntime(
async_session=async_session,
repository=_Repository,
history_repository=_Repository,
transaction=_UnitOfWork,
outbox=_Outbox,
),
compatibility_api_data=compatibility,
)
@@ -65,8 +65,9 @@ def _candidate(subscribe_id, username):
def _command(candidates, calls, commit_error=None, failing_event_id=None):
"""构造带可观察事件错误处理的批量删除用例。"""
async def publish(subscribe_id, payload):
async def publish(payload):
"""记录事件并按订阅编号注入失败。"""
subscribe_id = payload["subscribe_id"]
calls.append(("event", subscribe_id, payload))
if subscribe_id == failing_event_id:
raise RuntimeError("event failed")
+83 -9
View File
@@ -50,6 +50,25 @@ class _UnitOfWork:
self.calls.append(("rollback",))
class _Outbox:
"""记录订阅删除 intent 暂存和收口顺序的 outbox 替身。"""
def __init__(self, calls, stage_error=None):
"""保存共享调用序列与可选暂存异常。"""
self.calls = calls
self.stage_error = stage_error
async def stage(self, intent, _now):
"""记录 intent,并按需模拟持久化失败。"""
self.calls.append(("outbox_stage", intent))
if self.stage_error:
raise self.stage_error
async def complete_by_event_key(self, event_key, _completed_at):
"""记录即时事件成功后的 intent 收口。"""
self.calls.append(("outbox_complete", event_key))
def _candidate(username="alice"):
"""构造带完整事件身份字段的订阅删除候选。"""
return SubscribeDeletionCandidate(
@@ -66,11 +85,18 @@ def _candidate(username="alice"):
)
def _command(candidate, calls, commit_error=None, event_error=None, report_error=None):
def _command(
candidate,
calls,
commit_error=None,
event_error=None,
report_error=None,
outbox=None,
):
"""构造可观察事件与上报失败的订阅删除用例。"""
async def publish(subscribe_id, subscribe_info):
async def publish(payload):
"""记录删除事件并按需失败。"""
calls.append(("event", subscribe_id, subscribe_info))
calls.append(("event", payload["subscribe_id"], payload))
if event_error:
raise event_error
@@ -85,6 +111,7 @@ def _command(candidate, calls, commit_error=None, event_error=None, report_error
unit_of_work=_UnitOfWork(calls, commit_error),
publish_deleted=publish,
report_deleted=report,
outbox=outbox,
)
@@ -101,12 +128,9 @@ async def test_owner_delete_commits_before_event_and_report():
assert deleted is True
assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"]
assert calls[3][2] == _candidate().event_payload
assert calls[4][1] == {
"media_source": "tmdb",
"media_id": "123",
"season": 2,
}
assert calls[3][2]["subscribe_info"] == _candidate().event_payload
assert calls[3][2]["idempotency_key"].startswith("subscribe.deleted:7:")
assert calls[4][1] == _candidate().event_payload
@pytest.mark.asyncio
@@ -185,6 +209,56 @@ async def test_report_failure_happens_after_commit_and_event():
assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"]
@pytest.mark.asyncio
async def test_delete_stages_outbox_before_commit_and_completes_after_event():
"""订阅删除、intent 与即时事件必须按原子提交和成功收口顺序执行。"""
calls = []
command = _command(_candidate(), calls, outbox=_Outbox(calls))
assert await command.execute(
7,
SubscribeDeletionActor(username="alice", is_superuser=False),
) is True
assert [call[0] for call in calls] == [
"get",
"delete",
"outbox_stage",
"commit",
"event",
"outbox_complete",
"report",
]
intent = calls[2][1]
assert intent.topic == "subscribe.deleted"
assert intent.event_key == calls[4][2]["idempotency_key"]
assert calls[5][1] == intent.event_key
@pytest.mark.asyncio
async def test_delete_outbox_stage_failure_rolls_back_business_delete():
"""订阅删除 intent 无法暂存时不得提交业务删除。"""
calls = []
command = _command(
_candidate(),
calls,
outbox=_Outbox(calls, stage_error=RuntimeError("outbox failed")),
)
with pytest.raises(RuntimeError, match="outbox failed"):
await command.execute(
7,
SubscribeDeletionActor(username="alice", is_superuser=False),
)
assert [call[0] for call in calls] == [
"get",
"delete",
"outbox_stage",
"rollback",
]
@pytest.mark.asyncio
async def test_repository_candidate_uses_loaded_orm_snapshot(monkeypatch):
"""DB 适配器只向应用层暴露权限字段和完整列快照。"""
+185
View File
@@ -0,0 +1,185 @@
"""订阅修改 UoW 与 durable outbox 边界测试。"""
import pytest
from app.application.subscription.mutation import (
SubscriptionActor,
SubscriptionMutationService,
)
class _Subscribe:
"""提供稳定前后快照的订阅替身。"""
def __init__(self) -> None:
"""初始化可修改字段与 owner。"""
self.id = 7
self.username = "alice"
self.name = "旧标题"
def to_dict(self) -> dict:
"""返回当前订阅快照。"""
return {"id": self.id, "username": self.username, "name": self.name}
class _Repository:
"""记录订阅读取、兼容更新和事务内暂存顺序。"""
def __init__(self, subscribe: _Subscribe, calls: list) -> None:
"""保存订阅对象与共享调用序列。"""
self.subscribe = subscribe
self.calls = calls
async def async_get(self, subscribe_id: int):
"""返回指定订阅。"""
self.calls.append(("get", subscribe_id))
return self.subscribe
async def async_update(self, subscribe_id: int, payload: dict):
"""模拟旧兼容自动提交路径。"""
self.calls.append(("legacy_update", subscribe_id, payload))
for key, value in payload.items():
setattr(self.subscribe, key, value)
return self.subscribe
async def async_stage_update(self, subscribe_id: int, payload: dict):
"""模拟调用方事务内的更新暂存。"""
self.calls.append(("stage_update", subscribe_id, payload))
for key, value in payload.items():
setattr(self.subscribe, key, value)
return self.subscribe
def get(self, subscribe_id: int):
"""提供协议要求的同步读取。"""
return self.subscribe if subscribe_id == self.subscribe.id else None
class _UnitOfWork:
"""记录订阅修改事务提交和回滚。"""
def __init__(self, calls: list) -> None:
"""保存共享调用序列。"""
self.calls = calls
async def commit(self) -> None:
"""记录提交。"""
self.calls.append(("commit",))
async def rollback(self) -> None:
"""记录回滚。"""
self.calls.append(("rollback",))
class _Outbox:
"""记录修改事件 intent 暂存和完成。"""
def __init__(self, calls: list, stage_error: Exception | None = None) -> None:
"""保存共享调用序列与可选暂存异常。"""
self.calls = calls
self.stage_error = stage_error
async def stage(self, intent, _now) -> None:
"""记录 intent 并按需失败。"""
self.calls.append(("outbox_stage", intent))
if self.stage_error:
raise self.stage_error
async def complete_by_event_key(self, event_key: str, _completed_at) -> None:
"""记录即时事件成功后的完成键。"""
self.calls.append(("outbox_complete", event_key))
def _service(calls: list, *, event_error: Exception | None = None, outbox=None):
"""构造拥有请求级 UoW 和 outbox 的订阅修改服务。"""
subscribe = _Subscribe()
async def publish(payload: dict) -> None:
"""记录公开事件并按需失败。"""
calls.append(("event", payload))
if event_error:
raise event_error
return SubscriptionMutationService(
repository=_Repository(subscribe, calls),
unit_of_work=_UnitOfWork(calls),
outbox=outbox or _Outbox(calls),
publish_modified=publish,
)
@pytest.mark.asyncio
async def test_modified_event_is_staged_with_update_and_completed_after_publish():
"""订阅修改与 intent 同事务提交,事件成功后才标记完成。"""
calls = []
service = _service(calls)
change = await service.update(
7,
{"name": "新标题"},
SubscriptionActor(name="alice", is_superuser=False),
scene="update",
)
assert change is not None
assert change.event_published is True
assert change.old["name"] == "旧标题"
assert change.new["name"] == "新标题"
assert [call[0] for call in calls] == [
"get",
"stage_update",
"outbox_stage",
"commit",
"event",
"outbox_complete",
]
intent = calls[2][1]
assert intent.topic == "subscribe.modified"
assert intent.event_key.startswith("subscribe.modified:7:update:")
assert calls[4][1]["idempotency_key"] == intent.event_key
assert calls[5][1] == intent.event_key
@pytest.mark.asyncio
async def test_modified_outbox_stage_failure_rolls_back_update():
"""修改事件 intent 无法暂存时业务更新不得提交。"""
calls = []
service = _service(
calls,
outbox=_Outbox(calls, stage_error=RuntimeError("outbox failed")),
)
with pytest.raises(RuntimeError, match="outbox failed"):
await service.update(
7,
{"name": "新标题"},
SubscriptionActor(name="alice", is_superuser=False),
)
assert [call[0] for call in calls] == [
"get",
"stage_update",
"outbox_stage",
"rollback",
]
@pytest.mark.asyncio
async def test_modified_event_failure_keeps_committed_intent_pending():
"""提交后的事件失败向调用方传播,且不得错误收口待恢复 intent。"""
calls = []
service = _service(calls, event_error=RuntimeError("event failed"))
with pytest.raises(RuntimeError, match="event failed"):
await service.update(
7,
{"name": "新标题"},
SubscriptionActor(name="alice", is_superuser=False),
)
assert [call[0] for call in calls] == [
"get",
"stage_update",
"outbox_stage",
"commit",
"event",
]