diff --git a/docs/configuration.md b/docs/configuration.md index 39fc30e95f..72c10b83bb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -873,6 +873,14 @@ If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS Defaults to 1209600 (2 weeks) +#### [`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. + #### [`PAPERLESS_TOKEN_THROTTLE_RATE=`](#PAPERLESS_TOKEN_THROTTLE_RATE) {#PAPERLESS_TOKEN_THROTTLE_RATE} : Rate limit for the API token authentication endpoint (`/api/token/`), used to mitigate brute-force login attempts. diff --git a/src/documents/tasks.py b/src/documents/tasks.py index 8f0e85f165..4bb97afc18 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -12,6 +12,7 @@ from celery import Task from celery import shared_task from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.contrib.sessions.models import Session from django.db import models from django.db import transaction from django.db.models.signals import post_save @@ -925,3 +926,17 @@ 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: + """ + Remove expired database sessions in batches to limit the size of each delete. + """ + expired = Session.objects.filter(expire_date__lt=timezone.now()) + while session_keys := list( + expired.order_by("expire_date").values_list("pk", flat=True)[:500], + ): + expired.filter(pk__in=session_keys).delete() + + logger.info("Cleared expired sessions") diff --git a/src/documents/tests/test_tasks.py b/src/documents/tests/test_tasks.py index 2e2b541a9d..7af705512e 100644 --- a/src/documents/tests/test_tasks.py +++ b/src/documents/tests/test_tasks.py @@ -5,8 +5,11 @@ from unittest import mock import pytest from django.conf import settings +from django.contrib.sessions.models import Session +from django.db import connection from django.test import TestCase from django.test import override_settings +from django.test.utils import CaptureQueriesContext from django.utils import timezone from documents import tasks @@ -612,3 +615,36 @@ 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_in_batches(self) -> None: + """ + GIVEN: + - More than one batch of expired sessions and one still-valid session + WHEN: + - The clear_expired_sessions task runs + THEN: + - Only expired sessions are removed, using multiple deletes + """ + now = timezone.now() + Session.objects.bulk_create( + Session( + session_key=f"expired-{i}", + session_data="", + expire_date=now - timedelta(days=1), + ) + for i in range(501) + ) + Session.objects.create( + session_key="valid", + session_data="", + expire_date=now + timedelta(days=1), + ) + + with CaptureQueriesContext(connection) as queries: + tasks.clear_expired_sessions() + + assert set(Session.objects.values_list("session_key", flat=True)) == {"valid"} + assert sum(query["sql"].startswith("DELETE FROM") for query in queries) == 2 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",