feat: make subscribe reports durable

This commit is contained in:
jxxghp
2026-08-23 00:25:23 +08:00
parent dc9433f0de
commit a59f1b928a
8 changed files with 117 additions and 28 deletions
+30
View File
@@ -10,6 +10,7 @@ from app.runtime.config import settings
from app.domain.context import MediaInfo, MusicInfo from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase from app.domain.meta.metabase import MetaBase
from app.runtime.log import logger from app.runtime.log import logger
from app.runtime.observability import observe_compat_facade
from app.schemas.types import ( from app.schemas.types import (
MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_RECORDING,
MediaType, MediaType,
@@ -38,6 +39,7 @@ def configure_server_application_services(
_server_sharing_service = sharing_service _server_sharing_service = sharing_service
@observe_compat_facade("MoviePilotServerHelper")
class MoviePilotServerHelper: class MoviePilotServerHelper:
""" """
MoviePilot 服务端请求辅助工具。 MoviePilot 服务端请求辅助工具。
@@ -870,6 +872,20 @@ class MoviePilotServerHelper:
res = await cls.async_subscribe_add(payload) res = await cls.async_subscribe_add(payload)
return bool(res is not None and res.status_code == 200) return bool(res is not None and res.status_code == 200)
@classmethod
def sub_reg_durable(cls, sub: dict) -> bool:
"""同步上报新增统计;明确禁用时视为无需投递。"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return True
return cls.sub_reg(sub)
@classmethod
async def async_sub_reg_durable(cls, sub: dict) -> bool:
"""异步上报新增统计;明确禁用时视为无需投递。"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return True
return await cls.async_sub_reg(sub)
@classmethod @classmethod
def sub_done(cls, sub: dict) -> bool: def sub_done(cls, sub: dict) -> bool:
""" """
@@ -894,6 +910,20 @@ class MoviePilotServerHelper:
res = await cls.async_subscribe_done(payload) res = await cls.async_subscribe_done(payload)
return bool(res is not None and res.status_code == 200) return bool(res is not None and res.status_code == 200)
@classmethod
def sub_done_durable(cls, sub: dict) -> bool:
"""同步上报完成统计;明确禁用时视为无需投递。"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return True
return cls.sub_done(sub)
@classmethod
async def async_sub_done_durable(cls, sub: dict) -> bool:
"""异步上报完成统计;明确禁用时视为无需投递。"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return True
return await cls.async_sub_done(sub)
@classmethod @classmethod
def sub_reg_async(cls, sub: dict) -> bool: def sub_reg_async(cls, sub: dict) -> bool:
""" """
+1 -1
View File
@@ -62,7 +62,7 @@ def get_delete_subscribe_command(
repository=cast(SubscribeDeletionRepository, repository_port), repository=cast(SubscribeDeletionRepository, repository_port),
unit_of_work=cast(DeleteUnitOfWork, unit_of_work), unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
publish_deleted=_publish_subscribe_deleted, publish_deleted=_publish_subscribe_deleted,
report_deleted=MoviePilotServerHelper.async_sub_done, report_deleted=MoviePilotServerHelper.async_sub_done_durable,
outbox=outbox, outbox=outbox,
) )
+39 -19
View File
@@ -159,10 +159,13 @@ class CreateSubscriptionCommand:
staged = self._repository.stage_add(identity, payload, username) staged = self._repository.stage_add(identity, payload, username)
if staged.created: if staged.created:
if self._outbox: if self._outbox:
self._outbox.stage( now = datetime.now(timezone.utc)
_subscribe_added_intent(staged.subscribe_id, payload, username), for intent in _subscribe_added_intents(
datetime.now(timezone.utc), staged.subscribe_id,
) payload,
username,
):
self._outbox.stage(intent, now)
self._unit_of_work.commit() self._unit_of_work.commit()
except Exception: except Exception:
self._unit_of_work.rollback() self._unit_of_work.rollback()
@@ -202,10 +205,13 @@ class AsyncCreateSubscriptionCommand:
) )
if staged.created: if staged.created:
if self._outbox: if self._outbox:
await self._outbox.stage( now = datetime.now(timezone.utc)
_subscribe_added_intent(staged.subscribe_id, payload, username), for intent in _subscribe_added_intents(
datetime.now(timezone.utc), staged.subscribe_id,
) payload,
username,
):
await self._outbox.stage(intent, now)
await self._unit_of_work.commit() await self._unit_of_work.commit()
except Exception: except Exception:
await self._unit_of_work.rollback() await self._unit_of_work.rollback()
@@ -215,20 +221,29 @@ class AsyncCreateSubscriptionCommand:
return staged.subscribe_id, staged.message return staged.subscribe_id, staged.message
def _subscribe_added_intent( def _subscribe_added_intents(
subscribe_id: int, subscribe_id: int,
payload: dict, payload: dict,
username: str | None, username: str | None,
) -> OutboxIntent: ) -> tuple[OutboxIntent, OutboxIntent]:
"""构造版本化订阅新增事件event key 同时作为 handler 幂等键""" """构造订阅新增事件与外部统计的同事务 durable intents"""
return OutboxIntent( event_key = subscription_added_event_key(subscribe_id, payload)
event_key=subscription_added_event_key(subscribe_id, payload), event_payload = {
topic="subscribe.added", "subscribe_id": subscribe_id,
payload={ "username": username,
"subscribe_id": subscribe_id, "mediainfo": dict(payload),
"username": username, }
"mediainfo": dict(payload), return (
}, OutboxIntent(
event_key=event_key,
topic="subscribe.added",
payload=event_payload,
),
OutboxIntent(
event_key=subscription_added_report_key(subscribe_id, payload),
topic="subscribe.added.report",
payload={"subscribe_info": dict(payload)},
),
) )
@@ -241,6 +256,11 @@ def subscription_added_event_key(subscribe_id: int, payload: dict) -> str:
) )
def subscription_added_report_key(subscribe_id: int, payload: dict) -> str:
"""返回与新增事件身份一致但可独立重试的统计幂等键。"""
return f"{subscription_added_event_key(subscribe_id, payload)}:report"
_configured_subscribe_writer: Callable[[], SubscribeWriter] | None = None _configured_subscribe_writer: Callable[[], SubscribeWriter] | None = None
+6 -4
View File
@@ -958,9 +958,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
"username": context.username, "username": context.username,
"mediainfo": context.mediainfo.to_dict(), "mediainfo": context.mediainfo.to_dict(),
}) })
MoviePilotServerHelper.sub_reg_async( if not MoviePilotServerHelper.sub_reg_durable(
self.__subscribe_report_payload(context) self.__subscribe_report_payload(context)
) ):
raise RuntimeError("订阅新增统计上报未确认")
async def __async_post_subscribe_added( async def __async_post_subscribe_added(
self, self,
@@ -993,9 +994,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
"username": context.username, "username": context.username,
"mediainfo": context.mediainfo.to_dict(), "mediainfo": context.mediainfo.to_dict(),
}) })
await MoviePilotServerHelper.async_sub_reg( if not await MoviePilotServerHelper.async_sub_reg_durable(
self.__subscribe_report_payload(context) self.__subscribe_report_payload(context)
) ):
raise RuntimeError("订阅新增统计上报未确认")
@staticmethod @staticmethod
def __build_subscribe_create_context( def __build_subscribe_create_context(
+9 -1
View File
@@ -237,11 +237,18 @@ def _build_outbox_dispatcher() -> OutboxDispatcher:
"""创建一次恢复批次独占的 Session、Repository 和事件 handler。""" """创建一次恢复批次独占的 Session、Repository 和事件 handler。"""
def dispatch_subscribe_deleted_report(message) -> None: def dispatch_subscribe_deleted_report(message) -> None:
"""重放订阅删除统计;未确认时抛错以进入有限重试。""" """重放订阅删除统计;未确认时抛错以进入有限重试。"""
if not MoviePilotServerHelper.sub_done( if not MoviePilotServerHelper.sub_done_durable(
message.payload.get("subscribe_info") or {} message.payload.get("subscribe_info") or {}
): ):
raise RuntimeError("订阅删除统计上报未确认") raise RuntimeError("订阅删除统计上报未确认")
def dispatch_subscribe_added_report(message) -> None:
"""重放订阅新增统计;未确认时抛错以进入有限重试。"""
if not MoviePilotServerHelper.sub_reg_durable(
message.payload.get("subscribe_info") or {}
):
raise RuntimeError("订阅新增统计上报未确认")
session = SessionFactory() session = SessionFactory()
return OutboxDispatcher( return OutboxDispatcher(
repository=SqlAlchemyOutboxRepository(session), repository=SqlAlchemyOutboxRepository(session),
@@ -250,6 +257,7 @@ def _build_outbox_dispatcher() -> OutboxDispatcher:
EventType.SubscribeAdded, EventType.SubscribeAdded,
message.payload, message.payload,
), ),
"subscribe.added.report": dispatch_subscribe_added_report,
"subscribe.modified": lambda message: EventManager().send_event( "subscribe.modified": lambda message: EventManager().send_event(
EventType.SubscribeModified, EventType.SubscribeModified,
message.payload, message.payload,
+10 -1
View File
@@ -14,6 +14,7 @@ from app.application.subscription.write import (
AsyncCreateSubscriptionCommand, AsyncCreateSubscriptionCommand,
CreateSubscriptionCommand, CreateSubscriptionCommand,
subscription_added_event_key, subscription_added_event_key,
subscription_added_report_key,
) )
from app.application.subscription.delete import ( from app.application.subscription.delete import (
DeleteSubscribeCommand, DeleteSubscribeCommand,
@@ -76,6 +77,10 @@ class TransactionalSubscribeWriter:
subscription_added_event_key(subscribe_id, payload), subscription_added_event_key(subscribe_id, payload),
datetime.now(timezone.utc), datetime.now(timezone.utc),
) )
outbox.complete_by_event_key(
subscription_added_report_key(subscribe_id, payload),
datetime.now(timezone.utc),
)
return command.execute(identity, payload, username, delivered) return command.execute(identity, payload, username, delivered)
finally: finally:
@@ -105,6 +110,10 @@ class TransactionalSubscribeWriter:
subscription_added_event_key(subscribe_id, payload), subscription_added_event_key(subscribe_id, payload),
datetime.now(timezone.utc), datetime.now(timezone.utc),
) )
await outbox.complete_by_event_key(
subscription_added_report_key(subscribe_id, payload),
datetime.now(timezone.utc),
)
return await command.execute( return await command.execute(
identity, identity,
@@ -145,7 +154,7 @@ async def delete_subscribe_scope():
repository=SubscribeOper(session), repository=SubscribeOper(session),
unit_of_work=SqlAlchemyAsyncUnitOfWork(session), unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
publish_deleted=_publish_deleted, publish_deleted=_publish_deleted,
report_deleted=MoviePilotServerHelper.async_sub_done, report_deleted=MoviePilotServerHelper.async_sub_done_durable,
outbox=SqlAlchemyAsyncOutboxStager(session), outbox=SqlAlchemyAsyncOutboxStager(session),
) )
+5 -2
View File
@@ -31,10 +31,13 @@ def test_subscription_and_outbox_intent_commit_together() -> None:
result = command.execute({}, {"name": "demo"}, "user") result = command.execute({}, {"name": "demo"}, "user")
assert result == (42, "ok") assert result == (42, "ok")
assert calls == ["subscription", "outbox", "commit"] assert calls == ["subscription", "outbox", "outbox", "commit"]
intent = outbox.stage.call_args.args[0] intent = outbox.stage.call_args_list[0].args[0]
assert intent.event_key == "subscribe.added:42:unknown:unknown:v1" assert intent.event_key == "subscribe.added:42:unknown:unknown:v1"
assert intent.payload["subscribe_id"] == 42 assert intent.payload["subscribe_id"] == 42
report_intent = outbox.stage.call_args_list[1].args[0]
assert report_intent.topic == "subscribe.added.report"
assert report_intent.event_key.endswith(":report")
def test_outbox_stage_failure_rolls_back_business_transaction() -> None: def test_outbox_stage_failure_rolls_back_business_transaction() -> None:
+17
View File
@@ -9,6 +9,7 @@ from app.adapters.external.server import (
) )
from app.application.server.report import ServerReportService from app.application.server.report import ServerReportService
from app.application.server.share import ServerSharingService from app.application.server.share import ServerSharingService
from app.runtime.config import settings
from app.schemas.types import MediaSource from app.schemas.types import MediaSource
@@ -237,3 +238,19 @@ class MoviePilotServerHelperTests(unittest.TestCase):
"tmdbid": 99, "tmdbid": 99,
}) })
) )
def test_durable_subscribe_report_treats_disabled_sharing_as_success(self):
"""用户关闭统计分享时,durable intent 应视为无需远端投递。"""
with patch.object(settings, "SUBSCRIBE_STATISTIC_SHARE", False), patch.object(
MoviePilotServerHelper,
"sub_reg",
) as reporter:
self.assertTrue(MoviePilotServerHelper.sub_reg_durable({"media_id": "1"}))
reporter.assert_not_called()
with patch.object(settings, "SUBSCRIBE_STATISTIC_SHARE", False), patch.object(
MoviePilotServerHelper,
"sub_done",
) as reporter:
self.assertTrue(MoviePilotServerHelper.sub_done_durable({"media_id": "1"}))
reporter.assert_not_called()