diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 9459845a1d..b3dc203a3d 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -48,6 +48,8 @@ app.config["JOB_THRESHOLD"] = 1 app.config["JOB_TIME"] = 600 # maximum time in seconds since last activity for a room to be hosted app.config["MAX_ROOM_TIMEOUT"] = 259200 +# minimum time in days since last activity for a room to be deleted. 0 to disable. +app.config["ROOM_AUTO_DELETE"] = 0 # memory limit for generator processes in bytes app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296 diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 1a61564500..165e08a103 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -100,13 +100,18 @@ def init_generator(config: dict[str, Any]) -> None: db.generate_mapping() -def cleanup(): - """delete unowned user-content""" +def cleanup(config: dict[str, Any]): + """delete unowned or old user-content""" + auto_delete: int = config.get("ROOM_AUTO_DELETE", 0) with db_session: # >>> bool(uuid.UUID(int=0)) # True rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True) seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True) + if auto_delete > 0: + cutoff = utcnow() - timedelta(days=auto_delete) + rooms += Room.select(lambda room: room.last_activity < cutoff).delete(bulk=True) + seeds += Seed.select(lambda seed: not seed.rooms and seed.creation_time < cutoff).delete(bulk=True) slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True) # Command gets deleted by ponyorm Cascade Delete, as Room is Required if rooms or seeds or slots: @@ -118,7 +123,7 @@ def autohost(config: dict): stop_event = _stop_event try: with Locker("autohost"): - cleanup() + cleanup(config) hosters = [] for x in range(config["HOSTERS"]): hoster = MultiworldInstance(config, x) diff --git a/test/webhost/__init__.py b/test/webhost/__init__.py index 2eb340722a..f6181516ce 100644 --- a/test/webhost/__init__.py +++ b/test/webhost/__init__.py @@ -33,4 +33,9 @@ class TestBase(unittest.TestCase): cls.app = raw_app def setUp(self) -> None: + from WebHostLib.models import db + from pony.orm import db_session + with db_session: + for entity in db.entities.values(): + entity.select().delete(bulk=True) self.client = self.app.test_client() diff --git a/test/webhost/test_cleanup.py b/test/webhost/test_cleanup.py new file mode 100644 index 0000000000..fc121983c9 --- /dev/null +++ b/test/webhost/test_cleanup.py @@ -0,0 +1,107 @@ +from datetime import timedelta +from uuid import UUID, uuid4 +from pony.orm import db_session, commit + +from Utils import utcnow +from WebHostLib.autolauncher import cleanup +from WebHostLib.models import Room, Seed, Slot +from . import TestBase + + +class TestCleanup(TestBase): + def test_cleanup_unowned(self) -> None: + with db_session: + s1 = Seed(id=uuid4(), multidata=b"", owner=UUID(int=0)) + Room(id=uuid4(), owner=UUID(int=0), seed=s1) + + s2 = Seed(id=uuid4(), multidata=b"", owner=uuid4()) # Owned + Room(id=uuid4(), owner=UUID(int=0), seed=s2) # Unowned room of owned seed + + Seed(id=uuid4(), multidata=b"", owner=UUID(int=0)) # Unowned seed with no rooms + + commit() + + cleanup({"ROOM_AUTO_DELETE": 0}) + + with db_session: + self.assertEqual(Room.select().count(), 0) # Both rooms were unowned + self.assertEqual(Seed.select().count(), 1) # s2 is owned + self.assertIsNotNone(Seed.get(id=s2.id)) + + def test_cleanup_auto_delete(self) -> None: + now = utcnow() + old_time = now - timedelta(days=10) + recent_time = now - timedelta(days=2) + + with db_session: + # Case 1: Old room, owned + s1 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time) + r1 = Room(id=uuid4(), owner=uuid4(), seed=s1, last_activity=old_time) + + # Case 2: Recent room, owned + s2 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time) + r2 = Room(id=uuid4(), owner=uuid4(), seed=s2, last_activity=recent_time) + + # Case 3: Old seed, no rooms, owned + s3 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time) + + # Case 4: Recent seed, no rooms, owned + s4 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=recent_time) + + # Case 5: Old seed with recent room (should not be deleted) + s5 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time) + r5 = Room(id=uuid4(), owner=uuid4(), seed=s5, last_activity=recent_time) + + commit() + + # Delete items older than 5 days + cleanup({"ROOM_AUTO_DELETE": 5}) + + with db_session: + self.assertIsNone(Room.get(id=r1.id), "Old room should be deleted") + self.assertIsNotNone(Room.get(id=r2.id), "Recent room should NOT be deleted") + self.assertIsNone(Seed.get(id=s3.id), "Old seed without rooms should be deleted") + self.assertIsNotNone(Seed.get(id=s4.id), "Recent seed without rooms should NOT be deleted") + self.assertIsNotNone(Seed.get(id=s5.id), "Old seed with recent room should NOT be deleted") + self.assertIsNotNone(Room.get(id=r5.id), "Recent room for old seed should NOT be deleted") + + # Seeds are deleted if they have NO rooms AND are old. + # After r1 is deleted, s1 has no rooms. Since it's old, it should be deleted. + self.assertIsNone(Seed.get(id=s1.id), "Old seed whose only room was deleted should be deleted") + + def test_cleanup_disabled(self) -> None: + now = utcnow() + old_time = now - timedelta(days=10) + + with db_session: + s1 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time) + r1 = Room(id=uuid4(), owner=uuid4(), seed=s1, last_activity=old_time) + commit() + + cleanup({"ROOM_AUTO_DELETE": 0}) + + with db_session: + self.assertIsNotNone(Room.get(id=r1.id), "Room should NOT be deleted when auto-delete is 0") + self.assertIsNotNone(Seed.get(id=s1.id), "Seed should NOT be deleted when auto-delete is 0") + + def test_cleanup_slots(self) -> None: + now = utcnow() + old_time = now - timedelta(days=10) + + with db_session: + s1 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time) + slot1 = Slot(player_id=1, player_name="P1", seed=s1, game="TestGame") + + s2 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=now) + slot2 = Slot(player_id=2, player_name="P2", seed=s2, game="TestGame") + + commit() + + # Delete items older than 5 days + cleanup({"ROOM_AUTO_DELETE": 5}) + + with db_session: + self.assertIsNone(Seed.get(id=s1.id), "Old seed should be deleted") + self.assertIsNone(Slot.get(id=slot1.id), "Slot of deleted seed should be deleted") + self.assertIsNotNone(Seed.get(id=s2.id), "Recent seed should NOT be deleted") + self.assertIsNotNone(Slot.get(id=slot2.id), "Slot of recent seed should NOT be deleted")