mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
fix(outbox): avoid reused history event keys (#6449)
This commit is contained in:
@@ -8,6 +8,7 @@ from datetime import date, datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Protocol, cast
|
from typing import Any, Protocol, cast
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
|
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
|
||||||
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
|
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
|
||||||
@@ -56,13 +57,13 @@ class TransferHistoryRef:
|
|||||||
|
|
||||||
|
|
||||||
def download_added_event_key(history_id: int) -> str:
|
def download_added_event_key(history_id: int) -> str:
|
||||||
"""由下载历史 ID 构造本次下载事实稳定的 DownloadAdded 幂等键。"""
|
"""由下载历史 ID 与本次事实标识构造 DownloadAdded 幂等键。"""
|
||||||
return f"download.added:{history_id}:v1"
|
return f"download.added:{history_id}:{uuid4().hex}:v1"
|
||||||
|
|
||||||
|
|
||||||
def transfer_result_event_key(topic: str, history_id: int) -> str:
|
def transfer_result_event_key(topic: str, history_id: int) -> str:
|
||||||
"""由结果 topic 与整理历史 ID 构造稳定幂等键。"""
|
"""由结果 topic、整理历史 ID 与本次事实标识构造幂等键。"""
|
||||||
return f"{topic}:{history_id}:v1"
|
return f"{topic}:{history_id}:{uuid4().hex}:v1"
|
||||||
|
|
||||||
|
|
||||||
def snapshot_download_added(payload: dict[str, Any]) -> dict[str, Any]:
|
def snapshot_download_added(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
"""下载与整理 durable 事件的原子写入和对象恢复测试。"""
|
"""下载与整理 durable 事件的原子写入和对象恢复测试。"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine, select
|
from sqlalchemy import create_engine, delete, select
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from app.application.chain.durable_events import (
|
from app.application.chain.durable_events import (
|
||||||
|
download_added_event_key,
|
||||||
restore_download_added,
|
restore_download_added,
|
||||||
restore_transfer_result,
|
restore_transfer_result,
|
||||||
snapshot_download_added,
|
snapshot_download_added,
|
||||||
snapshot_transfer_result,
|
snapshot_transfer_result,
|
||||||
|
transfer_result_event_key,
|
||||||
)
|
)
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
|
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
|
||||||
@@ -63,6 +66,19 @@ def _objects():
|
|||||||
return meta, media, context, fileitem, transferinfo
|
return meta, media, context, fileitem, transferinfo
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_event_key(
|
||||||
|
event_key: str,
|
||||||
|
topic: str,
|
||||||
|
history_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""校验事件键保留业务关联,并以合法 UUID 区分每次事实。"""
|
||||||
|
key_topic, key_history_id, occurrence_id, version = event_key.split(":")
|
||||||
|
assert key_topic == topic
|
||||||
|
assert key_history_id == str(history_id)
|
||||||
|
assert UUID(occurrence_id).hex == occurrence_id
|
||||||
|
assert version == "v1"
|
||||||
|
|
||||||
|
|
||||||
def test_durable_snapshots_are_json_and_restore_plugin_runtime_objects():
|
def test_durable_snapshots_are_json_and_restore_plugin_runtime_objects():
|
||||||
"""outbox 只存 JSON 快照,重放时恢复插件一直收到的对象类型。"""
|
"""outbox 只存 JSON 快照,重放时恢复插件一直收到的对象类型。"""
|
||||||
meta, media, context, fileitem, transferinfo = _objects()
|
meta, media, context, fileitem, transferinfo = _objects()
|
||||||
@@ -144,7 +160,7 @@ def test_download_history_and_event_intent_share_one_transaction():
|
|||||||
assert history.download_hash == "hash-2"
|
assert history.download_hash == "hash-2"
|
||||||
assert download_file.fullpath == "/downloads/Demo.mkv"
|
assert download_file.fullpath == "/downloads/Demo.mkv"
|
||||||
assert outbox.status == "completed"
|
assert outbox.status == "completed"
|
||||||
assert outbox.event_key == f"download.added:{history.id}:v1"
|
_assert_event_key(outbox.event_key, "download.added", history.id)
|
||||||
assert [call if isinstance(call, str) else call[0] for call in calls] == [
|
assert [call if isinstance(call, str) else call[0] for call in calls] == [
|
||||||
"after_commit",
|
"after_commit",
|
||||||
"event",
|
"event",
|
||||||
@@ -177,12 +193,71 @@ def test_download_history_and_event_intent_share_one_transaction():
|
|||||||
assert len(histories) == 2
|
assert len(histories) == 2
|
||||||
assert len(outboxes) == 2
|
assert len(outboxes) == 2
|
||||||
assert all(message.status == "completed" for message in outboxes)
|
assert all(message.status == "completed" for message in outboxes)
|
||||||
assert [message.event_key for message in outboxes] == [
|
for history, message in zip(histories, outboxes, strict=True):
|
||||||
f"download.added:{history.id}:v1" for history in histories
|
_assert_event_key(message.event_key, "download.added", history.id)
|
||||||
]
|
|
||||||
assert outboxes[0].event_key != outboxes[1].event_key
|
assert outboxes[0].event_key != outboxes[1].event_key
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_keys_distinguish_reused_history_ids():
|
||||||
|
"""历史主键被数据库复用时,每次业务事实仍获得不同的幂等键。"""
|
||||||
|
download_keys = {download_added_event_key(7) for _ in range(2)}
|
||||||
|
transfer_keys = {
|
||||||
|
transfer_result_event_key("transfer.completed", 7)
|
||||||
|
for _ in range(2)
|
||||||
|
}
|
||||||
|
assert len(download_keys) == 2
|
||||||
|
assert len(transfer_keys) == 2
|
||||||
|
for event_key in download_keys:
|
||||||
|
_assert_event_key(event_key, "download.added", 7)
|
||||||
|
for event_key in transfer_keys:
|
||||||
|
_assert_event_key(event_key, "transfer.completed", 7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_succeeds_when_history_id_is_reused_with_retained_outbox():
|
||||||
|
"""整理历史删除而 outbox 保留时,复用主键不得阻断新整理记录。"""
|
||||||
|
factory = _session_factory()
|
||||||
|
writer = TransactionalChainDurableEventWriter(factory)
|
||||||
|
|
||||||
|
def transfer(src: str):
|
||||||
|
"""写入一条最小成功整理事实并完成即时事件。"""
|
||||||
|
return writer.transfer_result(
|
||||||
|
topic="transfer.completed",
|
||||||
|
stage_history=lambda repository: repository.add_force(
|
||||||
|
src=src,
|
||||||
|
src_storage="local",
|
||||||
|
status=1,
|
||||||
|
),
|
||||||
|
event_payload={},
|
||||||
|
publish=lambda _payload: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
first_history = transfer("/downloads/first.mkv")
|
||||||
|
with factory() as session:
|
||||||
|
session.execute(delete(TransferHistory))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
second_history = transfer("/downloads/second.mkv")
|
||||||
|
|
||||||
|
with factory() as session:
|
||||||
|
histories = session.execute(select(TransferHistory)).scalars().all()
|
||||||
|
outboxes = session.execute(
|
||||||
|
select(OutboxMessage).order_by(OutboxMessage.id)
|
||||||
|
).scalars().all()
|
||||||
|
assert first_history is not None
|
||||||
|
assert second_history is not None
|
||||||
|
assert first_history.id == second_history.id
|
||||||
|
assert len(histories) == 1
|
||||||
|
assert len(outboxes) == 2
|
||||||
|
assert all(message.status == "completed" for message in outboxes)
|
||||||
|
assert outboxes[0].event_key != outboxes[1].event_key
|
||||||
|
for message in outboxes:
|
||||||
|
_assert_event_key(
|
||||||
|
message.event_key,
|
||||||
|
"transfer.completed",
|
||||||
|
second_history.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_transfer_event_failure_leaves_committed_intent_pending():
|
def test_transfer_event_failure_leaves_committed_intent_pending():
|
||||||
"""整理历史提交后即时广播失败不得误删可供恢复的 pending intent。"""
|
"""整理历史提交后即时广播失败不得误删可供恢复的 pending intent。"""
|
||||||
factory = _session_factory()
|
factory = _session_factory()
|
||||||
@@ -226,4 +301,4 @@ def test_transfer_event_failure_leaves_committed_intent_pending():
|
|||||||
outbox = session.execute(select(OutboxMessage)).scalar_one()
|
outbox = session.execute(select(OutboxMessage)).scalar_one()
|
||||||
assert history.status is True
|
assert history.status is True
|
||||||
assert outbox.status == "pending"
|
assert outbox.status == "pending"
|
||||||
assert outbox.event_key == f"transfer.completed:{history.id}:v1"
|
_assert_event_key(outbox.event_key, "transfer.completed", history.id)
|
||||||
|
|||||||
Reference in New Issue
Block a user