Skip to content

Commit 6235892

Browse files
fix(rss): cascade-delete torrents when deleting RSSItem (#1019)
Deleting an RSSItem failed with sqlite3.IntegrityError: FOREIGN KEY constraint failed when child torrents still referenced it, leaving the sidebar entry stuck in the UI. - delete() now removes referencing torrents in the same transaction before removing the RSSItem - Adds missing session.rollback() in the exception path - rss_loop wraps each iteration to skip stale RSSItems deleted mid-loop via API - Tests enable PRAGMA foreign_keys=ON to match production and gain 5 cascade-delete regression tests Closes #1010 Closes #1017
1 parent 117c24c commit 6235892

3 files changed

Lines changed: 151 additions & 7 deletions

File tree

backend/src/module/core/sub_thread.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ async def rss_loop(self):
3131
# Analyse RSS
3232
rss_list = engine.rss.search_aggregate()
3333
for rss in rss_list:
34-
await self.analyser.rss_to_data(rss, engine)
34+
try:
35+
await self.analyser.rss_to_data(rss, engine)
36+
except Exception:
37+
# RSS 可能在遍历期间被 API 删除,跳过即可
38+
logger.debug(
39+
"[RSSThread] Skipping RSS id=%s, likely deleted",
40+
rss.id if hasattr(rss, "id") else "?",
41+
)
3542
# Run RSS Engine
3643
await engine.refresh_rss(client)
3744
if settings.bangumi_manage.eps_complete:

backend/src/module/database/rss.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from sqlmodel import Session, and_, delete, select
44

5-
from module.models import RSSItem, RSSUpdate
5+
from module.models import RSSItem, RSSUpdate, Torrent
66

77
logger = logging.getLogger(__name__)
88

@@ -107,16 +107,23 @@ def search_aggregate(self) -> list[RSSItem]:
107107
return list(result.scalars().all())
108108

109109
def delete(self, _id: int) -> bool:
110-
condition = delete(RSSItem).where(RSSItem.id == _id)
111110
try:
112-
self.session.execute(condition)
111+
# 先删除引用该 RSS 的 torrent,避免外键约束报错
112+
self.session.execute(delete(Torrent).where(Torrent.rss_id == _id))
113+
self.session.execute(delete(RSSItem).where(RSSItem.id == _id))
113114
self.session.commit()
114115
return True
115116
except Exception as e:
117+
self.session.rollback()
116118
logger.error(f"Delete RSS Item failed. Because: {e}")
117119
return False
118120

119121
def delete_all(self):
120-
condition = delete(RSSItem)
121-
self.session.execute(condition)
122-
self.session.commit()
122+
try:
123+
# 先删除所有引用 RSS 的 torrent,避免外键约束报错
124+
self.session.execute(delete(Torrent).where(Torrent.rss_id != None)) # noqa: E711
125+
self.session.execute(delete(RSSItem))
126+
self.session.commit()
127+
except Exception as e:
128+
self.session.rollback()
129+
logger.error(f"Delete all RSS Items failed. Because: {e}")

backend/src/test/test_database.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22

33
import pytest
4+
from sqlalchemy import event
45
from sqlmodel import Session, SQLModel, create_engine
56

67
from module.database.bangumi import BangumiDatabase
@@ -12,6 +13,30 @@
1213
engine = create_engine("sqlite://", echo=False)
1314

1415

16+
@event.listens_for(engine, "connect")
17+
def _enable_foreign_keys(dbapi_conn, connection_record):
18+
"""匹配生产环境行为:启用 SQLite 外键约束。"""
19+
cursor = dbapi_conn.cursor()
20+
cursor.execute("PRAGMA foreign_keys=ON")
21+
cursor.close()
22+
23+
24+
def _ensure_bangumi(session, bangumi_id: int):
25+
"""确保 bangumi 表中存在指定 id 的记录,满足外键约束。"""
26+
if session.get(Bangumi, bangumi_id) is None:
27+
session.add(Bangumi(
28+
id=bangumi_id,
29+
official_title=f"Stub Anime {bangumi_id}",
30+
title_raw=f"Stub {bangumi_id}",
31+
group_name="TestGroup",
32+
dpi="1080p",
33+
source="Web",
34+
subtitle="CHT",
35+
rss_link=f"stub_{bangumi_id}",
36+
))
37+
session.commit()
38+
39+
1540
@pytest.fixture
1641
def db_session():
1742
SQLModel.metadata.create_all(engine)
@@ -189,6 +214,9 @@ def test_torrent_with_bangumi_id(db_session):
189214
"""Test torrent with bangumi_id for offset lookup."""
190215
db = TorrentDatabase(db_session)
191216

217+
# 父记录满足外键约束
218+
_ensure_bangumi(db_session, 42)
219+
192220
# Create torrent linked to a bangumi
193221
torrent = Torrent(
194222
name="[SubGroup] Test Anime - 04 [1080p].mkv",
@@ -445,6 +473,7 @@ class TestDeleteByBangumiId:
445473

446474
def test_deletes_matching_torrents(self, db_session):
447475
db = TorrentDatabase(db_session)
476+
_ensure_bangumi(db_session, 10)
448477
for i in range(3):
449478
db.add(Torrent(name=f"torrent_{i}", url=f"https://example.com/{i}", bangumi_id=10))
450479
assert len(db.search_all()) == 3
@@ -455,6 +484,8 @@ def test_deletes_matching_torrents(self, db_session):
455484

456485
def test_leaves_other_bangumi_torrents(self, db_session):
457486
db = TorrentDatabase(db_session)
487+
_ensure_bangumi(db_session, 20)
488+
_ensure_bangumi(db_session, 30)
458489
db.add(Torrent(name="keep", url="https://example.com/keep", bangumi_id=20))
459490
db.add(Torrent(name="delete", url="https://example.com/delete", bangumi_id=30))
460491

@@ -466,6 +497,7 @@ def test_leaves_other_bangumi_torrents(self, db_session):
466497

467498
def test_no_match_returns_zero(self, db_session):
468499
db = TorrentDatabase(db_session)
500+
_ensure_bangumi(db_session, 5)
469501
db.add(Torrent(name="unrelated", url="https://example.com/1", bangumi_id=5))
470502

471503
count = db.delete_by_bangumi_id(999)
@@ -474,6 +506,7 @@ def test_no_match_returns_zero(self, db_session):
474506

475507
def test_skips_null_bangumi_id(self, db_session):
476508
db = TorrentDatabase(db_session)
509+
_ensure_bangumi(db_session, 7)
477510
db.add(Torrent(name="orphan", url="https://example.com/orphan", bangumi_id=None))
478511
db.add(Torrent(name="target", url="https://example.com/target", bangumi_id=7))
479512

@@ -486,6 +519,7 @@ def test_skips_null_bangumi_id(self, db_session):
486519
def test_check_new_finds_urls_after_cleanup(self, db_session):
487520
"""Core scenario: after deleting torrent records, check_new should treat those URLs as new."""
488521
db = TorrentDatabase(db_session)
522+
_ensure_bangumi(db_session, 42)
489523
db.add(Torrent(name="ep01", url="https://mikan.me/t/001", bangumi_id=42))
490524
db.add(Torrent(name="ep02", url="https://mikan.me/t/002", bangumi_id=42))
491525

@@ -579,3 +613,99 @@ def test_match_list_with_aliases(db_session):
579613
unmatched = db.match_list(torrents, "rss2")
580614
assert len(unmatched) == 1
581615
assert unmatched[0].name == "[OtherGroup] Different Anime - 01.mkv"
616+
617+
618+
# ============================================================
619+
# RSS Foreign Key Constraint Tests
620+
# ============================================================
621+
622+
623+
class TestRSSDeleteWithTorrents:
624+
"""Regression tests: deleting RSSItem must cascade-delete referencing torrents."""
625+
626+
def test_delete_rss_with_torrents(self, db_session):
627+
"""删除 RSSItem 时应自动清除引用它的 torrent 记录。"""
628+
rss_db = RSSDatabase(db_session)
629+
torrent_db = TorrentDatabase(db_session)
630+
631+
# 创建 RSS 和关联的 torrent
632+
rss = RSSItem(url="https://mikanani.me/RSS/test", name="Test RSS")
633+
rss_db.add(rss)
634+
635+
torrent_db.add(Torrent(name="ep01", url="https://example.com/1", rss_id=rss.id))
636+
torrent_db.add(Torrent(name="ep02", url="https://example.com/2", rss_id=rss.id))
637+
# 不关联此 RSS 的 torrent
638+
torrent_db.add(Torrent(name="other", url="https://example.com/3", rss_id=None))
639+
640+
assert len(torrent_db.search_rss(rss.id)) == 2
641+
642+
# 删除 RSS(不应报外键错误)
643+
result = rss_db.delete(rss.id)
644+
assert result is True
645+
646+
# RSS 和关联 torrent 都应被删除
647+
assert rss_db.search_id(rss.id) is None
648+
assert len(torrent_db.search_rss(rss.id)) == 0
649+
# 无关 torrent 不受影响
650+
assert len(torrent_db.search_all()) == 1
651+
652+
def test_delete_rss_without_torrents(self, db_session):
653+
"""删除没有关联 torrent 的 RSSItem 应正常工作。"""
654+
rss_db = RSSDatabase(db_session)
655+
656+
rss = RSSItem(url="https://mikanani.me/RSS/empty", name="Empty RSS")
657+
rss_db.add(rss)
658+
659+
result = rss_db.delete(rss.id)
660+
assert result is True
661+
assert rss_db.search_id(rss.id) is None
662+
663+
def test_delete_rss_cascades_in_transaction(self, db_session):
664+
"""验证删除操作在同一事务中完成,要么全成功要么全回滚。"""
665+
rss_db = RSSDatabase(db_session)
666+
torrent_db = TorrentDatabase(db_session)
667+
668+
rss = RSSItem(url="https://mikanani.me/RSS/tx", name="TX Test")
669+
rss_db.add(rss)
670+
671+
for i in range(5):
672+
torrent_db.add(
673+
Torrent(name=f"ep{i:02d}", url=f"https://example.com/{i}", rss_id=rss.id)
674+
)
675+
676+
assert len(torrent_db.search_rss(rss.id)) == 5
677+
678+
rss_db.delete(rss.id)
679+
680+
# 全部清理干净
681+
assert rss_db.search_id(rss.id) is None
682+
assert len(torrent_db.search_rss(rss.id)) == 0
683+
684+
def test_delete_all_rss_with_torrents(self, db_session):
685+
"""delete_all 应删除所有 RSS 及其关联 torrent。"""
686+
rss_db = RSSDatabase(db_session)
687+
torrent_db = TorrentDatabase(db_session)
688+
689+
rss1 = RSSItem(url="https://mikanani.me/RSS/a", name="RSS A")
690+
rss2 = RSSItem(url="https://mikanani.me/RSS/b", name="RSS B")
691+
rss_db.add(rss1)
692+
rss_db.add(rss2)
693+
694+
torrent_db.add(Torrent(name="t1", url="https://example.com/1", rss_id=rss1.id))
695+
torrent_db.add(Torrent(name="t2", url="https://example.com/2", rss_id=rss2.id))
696+
# rss_id 为 None 的 torrent 应不受影响
697+
torrent_db.add(Torrent(name="orphan", url="https://example.com/3", rss_id=None))
698+
699+
rss_db.delete_all()
700+
701+
assert len(rss_db.search_all()) == 0
702+
# 只剩 rss_id 为 None 的
703+
remaining = torrent_db.search_all()
704+
assert len(remaining) == 1
705+
assert remaining[0].name == "orphan"
706+
707+
def test_delete_nonexistent_rss(self, db_session):
708+
"""删除不存在的 RSS 不应报错。"""
709+
rss_db = RSSDatabase(db_session)
710+
result = rss_db.delete(999)
711+
assert result is True

0 commit comments

Comments
 (0)