fix(outbox): avoid reused history event keys (#6449)

This commit is contained in:
jxxghp
2026-08-26 22:47:01 +08:00
parent be8db5520b
commit ee53d6df01
2 changed files with 86 additions and 10 deletions
+5 -4
View File
@@ -8,6 +8,7 @@ from datetime import date, datetime
from enum import Enum
from pathlib import Path
from typing import Any, Protocol, cast
from uuid import uuid4
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
@@ -56,13 +57,13 @@ class TransferHistoryRef:
def download_added_event_key(history_id: int) -> str:
"""由下载历史 ID 构造本次下载事实稳定的 DownloadAdded 幂等键。"""
return f"download.added:{history_id}:v1"
"""由下载历史 ID 与本次事实标识构造 DownloadAdded 幂等键。"""
return f"download.added:{history_id}:{uuid4().hex}:v1"
def transfer_result_event_key(topic: str, history_id: int) -> str:
"""由结果 topic整理历史 ID 构造稳定幂等键。"""
return f"{topic}:{history_id}:v1"
"""由结果 topic整理历史 ID 与本次事实标识构造幂等键。"""
return f"{topic}:{history_id}:{uuid4().hex}:v1"
def snapshot_download_added(payload: dict[str, Any]) -> dict[str, Any]:
+81 -6
View File
@@ -1,16 +1,19 @@
"""下载与整理 durable 事件的原子写入和对象恢复测试。"""
import json
from uuid import UUID
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy import create_engine, delete, select
from sqlalchemy.orm import sessionmaker
from app.application.chain.durable_events import (
download_added_event_key,
restore_download_added,
restore_transfer_result,
snapshot_download_added,
snapshot_transfer_result,
transfer_result_event_key,
)
from app.db.base import Base
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
@@ -63,6 +66,19 @@ def _objects():
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():
"""outbox 只存 JSON 快照,重放时恢复插件一直收到的对象类型。"""
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 download_file.fullpath == "/downloads/Demo.mkv"
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] == [
"after_commit",
"event",
@@ -177,12 +193,71 @@ def test_download_history_and_event_intent_share_one_transaction():
assert len(histories) == 2
assert len(outboxes) == 2
assert all(message.status == "completed" for message in outboxes)
assert [message.event_key for message in outboxes] == [
f"download.added:{history.id}:v1" for history in histories
]
for history, message in zip(histories, outboxes, strict=True):
_assert_event_key(message.event_key, "download.added", history.id)
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():
"""整理历史提交后即时广播失败不得误删可供恢复的 pending intent。"""
factory = _session_factory()
@@ -226,4 +301,4 @@ def test_transfer_event_failure_leaves_committed_intent_pending():
outbox = session.execute(select(OutboxMessage)).scalar_one()
assert history.status is True
assert outbox.status == "pending"
assert outbox.event_key == f"transfer.completed:{history.id}:v1"
_assert_event_key(outbox.event_key, "transfer.completed", history.id)