diff --git a/modules/background_tasks/background_tasks/settings.py b/modules/background_tasks/background_tasks/settings.py index 966bad4bd..6efd9f3eb 100644 --- a/modules/background_tasks/background_tasks/settings.py +++ b/modules/background_tasks/background_tasks/settings.py @@ -124,8 +124,9 @@ class BackgroundTasksSettings(BaseSettings): default=None, ge=1, json_schema_extra=_CELERY_RESTART ) task_reject_on_worker_lost: bool | None = Field(default=None, json_schema_extra=_CELERY_RESTART) - task_soft_time_limit: int | None = Field(default=None, ge=1, json_schema_extra=_CELERY_RESTART) + # Hard limit first: the soft-limit validator reads it from ``info.data``. task_time_limit: int | None = Field(default=None, ge=1, json_schema_extra=_CELERY_RESTART) + task_soft_time_limit: int | None = Field(default=None, ge=1, json_schema_extra=_CELERY_RESTART) worker_concurrency: int | None = Field(default=None, ge=1, json_schema_extra=_CELERY_RESTART) # Raw Celery conf (``SM_BG_TASKS_CELERY_OVERRIDES``, JSON) is deliberately NOT # a field here: every field is stored in and hydrated from the database, and @@ -160,6 +161,22 @@ def _vet_task_routes(cls, value: object) -> object: # sanitises, as a defence for values stored before this check. return parse_task_routes(value) + @field_validator("task_soft_time_limit") + @classmethod + def _soft_limit_below_hard(cls, soft: int | None, info: ValidationInfo) -> int | None: + """A soft limit at or above the hard one can never fire. + + A field validator, not a model one, so the error carries a ``loc``: + when the soft limit is a DB row, lenient hydration drops that row — + keeping the hard kill — instead of failing boot. + """ + hard = info.data.get("task_time_limit") + if soft is not None and hard is not None and soft >= hard: + raise ValueError( + f"task_soft_time_limit ({soft}) must be below task_time_limit ({hard})" + ) + return soft + # ── Cross-process cache invalidation (GH #318) ────────── # This module owns the Redis connection, so it installs the transport that # carries the framework InvalidationBus between workers. Off means every diff --git a/modules/background_tasks/tests/test_background_tasks_settings.py b/modules/background_tasks/tests/test_background_tasks_settings.py index d488fa410..2c3d1b1a8 100644 --- a/modules/background_tasks/tests/test_background_tasks_settings.py +++ b/modules/background_tasks/tests/test_background_tasks_settings.py @@ -25,3 +25,19 @@ def test_max_retries_accepts_supported_boundaries(max_retries: int) -> None: def test_max_retries_rejects_values_outside_supported_range(max_retries: int) -> None: with pytest.raises(ValidationError): BackgroundTasksSettings(max_retries=max_retries) + + +@pytest.mark.parametrize(("soft", "hard"), [(30, 60), (None, 60), (100, None), (None, None)]) +def test_time_limits_accept_a_soft_limit_below_the_hard_one( + soft: int | None, hard: int | None +) -> None: + cfg = BackgroundTasksSettings(task_soft_time_limit=soft, task_time_limit=hard) + assert (cfg.task_soft_time_limit, cfg.task_time_limit) == (soft, hard) + + +@pytest.mark.parametrize(("soft", "hard"), [(100, 50), (60, 60)]) +def test_time_limits_reject_a_soft_limit_that_could_never_fire(soft: int, hard: int) -> None: + with pytest.raises(ValidationError) as exc: + BackgroundTasksSettings(task_soft_time_limit=soft, task_time_limit=hard) + # Located on the soft field, so lenient hydration drops that row and keeps the hard kill. + assert [e["loc"] for e in exc.value.errors()] == [("task_soft_time_limit",)] diff --git a/modules/file_storage/file_storage/settings.py b/modules/file_storage/file_storage/settings.py index e85b7535f..c256f9479 100644 --- a/modules/file_storage/file_storage/settings.py +++ b/modules/file_storage/file_storage/settings.py @@ -87,6 +87,7 @@ class FileStorageSettings(DbBackedSettings): # bounded by ``max_file_size_bytes`` alone as before. quota_bytes: int | None = Field( default=None, + gt=0, description="Total storage the browse screen measures usage against. None = unmeasured.", ) diff --git a/modules/file_storage/tests/test_file_storage_settings_bounds.py b/modules/file_storage/tests/test_file_storage_settings_bounds.py new file mode 100644 index 000000000..a6333aefc --- /dev/null +++ b/modules/file_storage/tests/test_file_storage_settings_bounds.py @@ -0,0 +1,19 @@ +"""``quota_bytes`` is either unset (unmeasured) or a positive total (GH #548).""" + +from __future__ import annotations + +import pytest +from file_storage.settings import FileStorageSettings +from pydantic import ValidationError + + +@pytest.mark.parametrize("quota", [None, 1, 5 * 1024**3]) +def test_quota_bytes_accepts_unset_or_positive(quota: int | None) -> None: + assert FileStorageSettings(quota_bytes=quota).quota_bytes == quota + + +@pytest.mark.parametrize("quota", [0, -1]) +def test_quota_bytes_rejects_non_positive(quota: int) -> None: + with pytest.raises(ValidationError) as exc: + FileStorageSettings(quota_bytes=quota) + assert [e["loc"] for e in exc.value.errors()] == [("quota_bytes",)]