mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat: persist subscription side-effect outbox
This commit is contained in:
+21
-3
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6208,
|
||||
"edge_sha256": "a91776a358fc4820d1d6f6dc300b3432491dc6b87a0423a99acd0e6c1366526b",
|
||||
"edge_count": 6223,
|
||||
"edge_sha256": "f64e5083ab697022127780800617474d12f10b05a6fea26787e751a3f8a00421",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -2711,6 +2711,8 @@
|
||||
"app.application.subscription.search -> app.application",
|
||||
"app.application.subscription.search -> app.application.subscription",
|
||||
"app.application.subscription.search -> app.application.subscription.delete",
|
||||
"app.application.subscription.write -> app.application",
|
||||
"app.application.subscription.write -> app.application.outbox",
|
||||
"app.application.subscription.write -> app.domain",
|
||||
"app.application.subscription.write -> app.domain.context",
|
||||
"app.application.subscription.write -> app.schemas",
|
||||
@@ -3436,6 +3438,8 @@
|
||||
"app.db.models.message -> app.db",
|
||||
"app.db.models.message -> app.db.base",
|
||||
"app.db.models.message -> app.db.decorators",
|
||||
"app.db.models.outbox -> app.db",
|
||||
"app.db.models.outbox -> app.db.base",
|
||||
"app.db.models.passkey -> app.db",
|
||||
"app.db.models.passkey -> app.db.base",
|
||||
"app.db.models.passkey -> app.db.decorators",
|
||||
@@ -5536,6 +5540,7 @@
|
||||
"app.scheduler -> app.application.image",
|
||||
"app.scheduler -> app.application.messaging",
|
||||
"app.scheduler -> app.application.messaging.message",
|
||||
"app.scheduler -> app.application.outbox",
|
||||
"app.scheduler -> app.application.scheduling",
|
||||
"app.scheduler -> app.application.site",
|
||||
"app.scheduler -> app.chain",
|
||||
@@ -5916,6 +5921,7 @@
|
||||
"app.startup.modules_initializer -> app.application.messaging.chat",
|
||||
"app.startup.modules_initializer -> app.application.messaging.message",
|
||||
"app.startup.modules_initializer -> app.application.module",
|
||||
"app.startup.modules_initializer -> app.application.outbox",
|
||||
"app.startup.modules_initializer -> app.application.plugin",
|
||||
"app.startup.modules_initializer -> app.application.plugin.runtime",
|
||||
"app.startup.modules_initializer -> app.application.security",
|
||||
@@ -5986,8 +5992,15 @@
|
||||
"app.startup.modules_initializer -> app.startup.context",
|
||||
"app.startup.modules_initializer -> app.startup.database",
|
||||
"app.startup.modules_initializer -> app.startup.managed_resources_initializer",
|
||||
"app.startup.modules_initializer -> app.startup.outbox",
|
||||
"app.startup.modules_initializer -> app.startup.subscription",
|
||||
"app.startup.monitor_initializer -> app.monitor",
|
||||
"app.startup.outbox -> app.application",
|
||||
"app.startup.outbox -> app.application.outbox",
|
||||
"app.startup.outbox -> app.db",
|
||||
"app.startup.outbox -> app.db.base",
|
||||
"app.startup.outbox -> app.db.models",
|
||||
"app.startup.outbox -> app.db.models.outbox",
|
||||
"app.startup.plugins_initializer -> app.adapters",
|
||||
"app.startup.plugins_initializer -> app.adapters.external",
|
||||
"app.startup.plugins_initializer -> app.adapters.external.market",
|
||||
@@ -6046,6 +6059,8 @@
|
||||
"app.startup.subscription -> app.db.oper",
|
||||
"app.startup.subscription -> app.db.oper.subscribe",
|
||||
"app.startup.subscription -> app.db.uow",
|
||||
"app.startup.subscription -> app.startup",
|
||||
"app.startup.subscription -> app.startup.outbox",
|
||||
"app.startup.transfer_initializer -> app.chain",
|
||||
"app.startup.transfer_initializer -> app.chain.transfer",
|
||||
"app.startup.workflow_initializer -> app.workflow",
|
||||
@@ -6225,7 +6240,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 773,
|
||||
"module_count": 776,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -6504,6 +6519,7 @@
|
||||
"app.application.music",
|
||||
"app.application.music.catalog",
|
||||
"app.application.notification",
|
||||
"app.application.outbox",
|
||||
"app.application.plugin",
|
||||
"app.application.plugin.catalog",
|
||||
"app.application.plugin.config",
|
||||
@@ -6605,6 +6621,7 @@
|
||||
"app.db.models.downloadhistory",
|
||||
"app.db.models.mediaserver",
|
||||
"app.db.models.message",
|
||||
"app.db.models.outbox",
|
||||
"app.db.models.passkey",
|
||||
"app.db.models.plugindata",
|
||||
"app.db.models.site",
|
||||
@@ -6973,6 +6990,7 @@
|
||||
"app.startup.managed_resources_initializer",
|
||||
"app.startup.modules_initializer",
|
||||
"app.startup.monitor_initializer",
|
||||
"app.startup.outbox",
|
||||
"app.startup.plugins_initializer",
|
||||
"app.startup.routers_initializer",
|
||||
"app.startup.scheduler_initializer",
|
||||
|
||||
@@ -2180,6 +2180,10 @@
|
||||
{
|
||||
"caller": "app.chain.subscribe",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"caller": "app.startup.modules_initializer",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2304,7 +2308,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"producer_count": 66
|
||||
"producer_count": 67
|
||||
},
|
||||
"module_method_specs": {
|
||||
"download_file": {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""durable side-effect outbox 原子性、认领、重试与幂等测试。"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.outbox import ClaimedOutboxMessage, OutboxDispatcher
|
||||
from app.application.subscription.write import CreateSubscriptionCommand
|
||||
|
||||
|
||||
class _Staged:
|
||||
"""测试用新订阅暂存结果。"""
|
||||
|
||||
subscribe_id = 42
|
||||
message = "ok"
|
||||
created = True
|
||||
|
||||
|
||||
def test_subscription_and_outbox_intent_commit_together() -> None:
|
||||
"""业务行与 intent 均 stage 成功后才允许同一次 commit。"""
|
||||
calls = []
|
||||
repository = MagicMock()
|
||||
repository.stage_add.side_effect = lambda *_args: calls.append("subscription") or _Staged()
|
||||
outbox = MagicMock()
|
||||
outbox.stage.side_effect = lambda *_args: calls.append("outbox")
|
||||
unit_of_work = MagicMock()
|
||||
unit_of_work.commit.side_effect = lambda: calls.append("commit")
|
||||
command = CreateSubscriptionCommand(repository, unit_of_work, outbox=outbox)
|
||||
|
||||
result = command.execute({}, {"name": "demo"}, "user")
|
||||
|
||||
assert result == (42, "ok")
|
||||
assert calls == ["subscription", "outbox", "commit"]
|
||||
intent = outbox.stage.call_args.args[0]
|
||||
assert intent.event_key == "subscribe.added:42:unknown:unknown:v1"
|
||||
assert intent.payload["subscribe_id"] == 42
|
||||
|
||||
|
||||
def test_outbox_stage_failure_rolls_back_business_transaction() -> None:
|
||||
"""intent 无法持久化时订阅行不得单独提交。"""
|
||||
repository = MagicMock()
|
||||
repository.stage_add.return_value = _Staged()
|
||||
outbox = MagicMock()
|
||||
outbox.stage.side_effect = RuntimeError("outbox unavailable")
|
||||
unit_of_work = MagicMock()
|
||||
command = CreateSubscriptionCommand(repository, unit_of_work, outbox=outbox)
|
||||
|
||||
with pytest.raises(RuntimeError, match="outbox unavailable"):
|
||||
command.execute({}, {"name": "demo"})
|
||||
|
||||
unit_of_work.rollback.assert_called_once_with()
|
||||
unit_of_work.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_dispatcher_retries_then_dead_letters_with_stable_key() -> None:
|
||||
"""同一幂等键有限指数退避,达到上限后进入 dead letter。"""
|
||||
now = datetime(2026, 8, 21, tzinfo=timezone.utc)
|
||||
repository = MagicMock()
|
||||
repository.claim.side_effect = [
|
||||
ClaimedOutboxMessage(1, "subscribe.added:42:v1", "subscribe.added", {}, 1, 1),
|
||||
ClaimedOutboxMessage(1, "subscribe.added:42:v1", "subscribe.added", {}, 1, 2),
|
||||
]
|
||||
handler = MagicMock(side_effect=RuntimeError("temporary"))
|
||||
dispatcher = OutboxDispatcher(
|
||||
repository,
|
||||
{"subscribe.added": handler},
|
||||
max_attempts=2,
|
||||
clock=lambda: now,
|
||||
)
|
||||
|
||||
assert dispatcher.dispatch_one() is True
|
||||
assert repository.retry.call_args.kwargs["dead"] is False
|
||||
assert dispatcher.dispatch_one() is True
|
||||
assert repository.retry.call_args.kwargs["dead"] is True
|
||||
assert [call.args[0].event_key for call in handler.call_args_list] == [
|
||||
"subscribe.added:42:v1",
|
||||
"subscribe.added:42:v1",
|
||||
]
|
||||
|
||||
|
||||
def test_dispatcher_marks_success_and_closes_owned_resource() -> None:
|
||||
"""成功 handler 收口消息,批次结束释放 Session 所有权。"""
|
||||
now = datetime(2026, 8, 21, tzinfo=timezone.utc)
|
||||
repository = MagicMock()
|
||||
message = ClaimedOutboxMessage(7, "key", "subscribe.added", {}, 1, 1)
|
||||
repository.claim.return_value = message
|
||||
close = MagicMock()
|
||||
dispatcher = OutboxDispatcher(
|
||||
repository,
|
||||
{"subscribe.added": MagicMock()},
|
||||
clock=lambda: now,
|
||||
close=close,
|
||||
)
|
||||
|
||||
assert dispatcher.dispatch_one() is True
|
||||
repository.complete.assert_called_once_with(7, now)
|
||||
dispatcher.close()
|
||||
close.assert_called_once_with()
|
||||
Reference in New Issue
Block a user