diff --git a/docs/configuration.md b/docs/configuration.md index 39fc30e95f..eab89449b7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1871,6 +1871,14 @@ processing. This only has an effect if Defaults to `0 1 * * *`, once per day. +#### [`PAPERLESS_SESSION_CLEANUP_TASK_CRON=`](#PAPERLESS_SESSION_CLEANUP_TASK_CRON) {#PAPERLESS_SESSION_CLEANUP_TASK_CRON} + +: Configures the schedule to remove expired sessions from the session store. +Django does not prune these automatically, so without this they would +accumulate indefinitely. + + Defaults to `0 3 * * *`, once per day at 03:00. + ## Share links #### [`PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON=`](#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON) {#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON} diff --git a/src/documents/tasks.py b/src/documents/tasks.py index 8f0e85f165..eecc6ee009 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -4,6 +4,7 @@ import shutil import uuid import zipfile from collections.abc import Callable +from importlib import import_module from pathlib import Path from tempfile import TemporaryDirectory from tempfile import mkstemp @@ -925,3 +926,19 @@ def cleanup_expired_share_link_bundles() -> None: ) if count: logger.info("Deleted %s expired share link bundle(s)", count) + + +@shared_task +def clear_expired_sessions() -> None: + """ + Removes expired sessions from the session store. Django does not prune these automatically. + """ + engine = import_module(settings.SESSION_ENGINE) + try: + engine.SessionStore.clear_expired() + logger.info("Cleared expired sessions") + except NotImplementedError: + logger.warning( + "Session engine '%s' does not support clearing expired sessions", + settings.SESSION_ENGINE, + ) diff --git a/src/documents/tests/test_tasks.py b/src/documents/tests/test_tasks.py index 2e2b541a9d..6bfd006243 100644 --- a/src/documents/tests/test_tasks.py +++ b/src/documents/tests/test_tasks.py @@ -612,3 +612,57 @@ class TestApplyAISuggestionsTask(DirectoriesMixin, TestCase): apply_suggestions.assert_not_called() self.assertIn("no longer exists", "".join(cm.output)) + + +@pytest.mark.django_db +class TestClearExpiredSessions: + def test_clears_only_expired_sessions(self) -> None: + """ + GIVEN: + - One expired and one still-valid session in the session store + WHEN: + - The clear_expired_sessions task runs + THEN: + - Only the expired session is removed + """ + from django.contrib.sessions.models import Session + + now = timezone.now() + Session.objects.create( + session_key="expired", + session_data="", + expire_date=now - timedelta(days=1), + ) + Session.objects.create( + session_key="valid", + session_data="", + expire_date=now + timedelta(days=1), + ) + + tasks.clear_expired_sessions() + + assert set(Session.objects.values_list("session_key", flat=True)) == {"valid"} + + def test_unsupported_engine_logs_warning( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """ + GIVEN: + - A session engine that does not support clearing expired sessions + WHEN: + - The clear_expired_sessions task runs + THEN: + - The error is swallowed and a warning is logged + """ + fake_engine = mock.Mock() + fake_engine.SessionStore.clear_expired.side_effect = NotImplementedError + + with ( + mock.patch("documents.tasks.import_module", return_value=fake_engine), + override_settings(SESSION_ENGINE="some.unsupported.engine"), + caplog.at_level("WARNING", logger="paperless.tasks"), + ): + tasks.clear_expired_sessions() + + assert "does not support clearing expired sessions" in caplog.text diff --git a/src/paperless/settings/custom.py b/src/paperless/settings/custom.py index 7b5b5a7494..c6057e41a2 100644 --- a/src/paperless/settings/custom.py +++ b/src/paperless/settings/custom.py @@ -165,6 +165,17 @@ def parse_beat_schedule() -> dict: "expires": 23.0 * 60.0 * 60.0, }, }, + { + "name": "Clear expired sessions", + "env_key": "PAPERLESS_SESSION_CLEANUP_TASK_CRON", + # Default daily at 03:00 + "env_default": "0 3 * * *", + "task": "documents.tasks.clear_expired_sessions", + "options": { + # 1 hour before default schedule sends again + "expires": 23.0 * 60.0 * 60.0, + }, + }, ] for task in tasks: # Either get the environment setting or use the default diff --git a/src/paperless/tests/settings/test_custom_parsers.py b/src/paperless/tests/settings/test_custom_parsers.py index 162f8b8ff1..8faf76f075 100644 --- a/src/paperless/tests/settings/test_custom_parsers.py +++ b/src/paperless/tests/settings/test_custom_parsers.py @@ -182,6 +182,7 @@ def make_expected_schedule( workflow_expire = 59.0 * 60.0 llm_index_expire = 23.0 * 60.0 * 60.0 share_link_cleanup_expire = 23.0 * 60.0 * 60.0 + session_cleanup_expire = 23.0 * 60.0 * 60.0 schedule: dict[str, Any] = { "Check all e-mail accounts": { @@ -248,6 +249,14 @@ def make_expected_schedule( "headers": {"trigger_source": "scheduled"}, }, }, + "Clear expired sessions": { + "task": "documents.tasks.clear_expired_sessions", + "schedule": crontab(minute=0, hour="3"), + "options": { + "expires": session_cleanup_expire, + "headers": {"trigger_source": "scheduled"}, + }, + }, } overrides = overrides or {} @@ -298,6 +307,7 @@ class TestParseBeatSchedule: "PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON": "disable", "PAPERLESS_LLM_INDEX_TASK_CRON": "disable", "PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON": "disable", + "PAPERLESS_SESSION_CLEANUP_TASK_CRON": "disable", }, {}, id="all-disabled",