Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion modules/background_tasks/background_tasks/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions modules/background_tasks/tests/test_background_tasks_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)]
1 change: 1 addition & 0 deletions modules/file_storage/file_storage/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)

Expand Down
19 changes: 19 additions & 0 deletions modules/file_storage/tests/test_file_storage_settings_bounds.py
Original file line number Diff line number Diff line change
@@ -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",)]