Compare commits

..

2 Commits

Author SHA1 Message Date
briquet e3b6b7e213 ⚡️(backend) add opt-in psycopg connection pool
Each request opened and closed its own Postgres connection which added a
big overhead. The goal is to add Django's native psycopg pool behind a new
DB_PSYCOPG_POOL_ENABLED settings with explicit min/max size and keepalive.

Each pool lives in one process, and gunicorn's sync workers serve a single
request at a time which means that a given worker won't use more than one
connection. The default pool minimum size is set to 1, which keeps always
one connection open and the maximum size to 3 as headroom for code touching
the database from an extra thread.

Pooled connections can sit idle for hours, so TCP keepalives are enabled
to keep load balancers and NAT from dropping them silently, and each
connection is health checked on checkout so a broken one is replaced
instead of failing the request.
2026-10-14 23:30:02 +02:00
Roee Sivan eb422309b9 ✨(backend) allow static feature flags without an analytics backend
Feature flags are read from the analytics backend. Without one,
NoOpAnalytics returned no flag, so "summary-enabled" was always off and
a self-hosted instance could not get automatic summaries without PostHog.

NoOpAnalytics now accepts a static "feature_flags" mapping through the
existing ANALYTICS_BACKEND_SETTINGS, and get_analytics() passes the
settings to it. Nothing changes when no flag is declared.

Fixes #1610
2026-10-14 00:19:55 +02:00
16 changed files with 416 additions and 264 deletions
+2 -1
View File
@@ -11,7 +11,6 @@ and this project adheres to
### Added
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
- 🔒️(backend) throttle the start recording endpoint
## [1.35.0] - 2026-10-13
@@ -20,6 +19,8 @@ and this project adheres to
- ✨(frontend) let users pick the recording encoding
- ✨(backend) introduce a token exchange endpoint for iframe embeds
- ⚡️(backend) send recording notification emails from a Celery task
- ✨(backend) allow static feature flags without an analytics backend #1809
- ⚡️(backend) add an opt-in psycopg connection pool
### Changed
+20
View File
@@ -120,6 +120,26 @@ DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Nu
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
```
### Feature flags without an analytics backend
Some features, such as automatic meeting summaries (`summary-enabled`), are gated by a feature flag read from the analytics backend. You do not need an analytics backend to enable them: leave `ANALYTICS_BACKEND` unset and declare the flags in `env.d/common`. No data is sent anywhere.
```env
ANALYTICS_BACKEND_SETTINGS={"feature_flags": {"summary-enabled": True}}
```
You can also set it directly in `compose.yaml`:
```yaml
services:
backend:
environment:
ANALYTICS_BACKEND_SETTINGS: '{"feature_flags": {"summary-enabled": True}}'
```
> [!NOTE]
> The value is a Python dictionary, not JSON: write `True`, not `true`. Set it on every container running the backend image.
## Step 3: Configure your firewall
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
+8 -3
View File
@@ -318,12 +318,17 @@ These are the environmental options available on meet backend.
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_ENGINE | Database engine used | django.db.backends.postgresql |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| DB_PSYCOPG_POOL_ENABLED | Enable the psycopg connection pool | false |
| DB_PSYCOPG_POOL_MIN_SIZE | Connections each pool keeps open | 1 |
| DB_PSYCOPG_POOL_MAX_SIZE | Maximum connections per pool | 3 |
| DB_PSYCOPG_POOL_TIMEOUT | Seconds a request waits for a free pool connection before failing | 30 |
| DB_PSYCOPG_POOL_MAX_IDLE | Seconds an idle connection above the minimum stays open | 600 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
@@ -335,8 +340,6 @@ These are the environmental options available on meet backend.
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| ROOM_CREATION_THROTTLE_RATES | Room creation throttle rate per authenticated user | 50/minute | 50/minute |
| ROOM_CREATION_DAILY_THROTTLE_RATES | Daily room creation cap per authenticated user | 1000/day |
| START_RECORDING_THROTTLE_RATES | Recording start throttle rate per authenticated user | 10/minute |
| START_RECORDING_DAILY_THROTTLE_RATES | Daily recording start cap per authenticated user | 100/day |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
@@ -418,6 +421,8 @@ These are the environmental options available on meet backend.
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| ANALYTICS_BACKEND | Analytics backend class, e.g. `core.analytics.posthog.PostHogAnalytics`. Nothing is sent when unset | |
| ANALYTICS_BACKEND_SETTINGS | Analytics backend settings. Without a backend, `{"feature_flags": {"summary-enabled": True}}` sets feature flags statically | {} |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
+2 -1
View File
@@ -8,4 +8,5 @@ DB_HOST=postgresql
DB_NAME=meet
DB_USER=dinum
DB_PASSWORD=pass
DB_PORT=5432
DB_PORT=5432
DB_PSYCOPG_POOL_ENABLED=True
+1 -1
View File
@@ -39,7 +39,7 @@ def get_analytics() -> AnalyticsBackend:
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
if not dotted_path:
return NoOpAnalytics()
return NoOpAnalytics(**options)
backend_class = import_string(dotted_path)
return backend_class(**options)
+24 -1
View File
@@ -53,7 +53,23 @@ class AnalyticsBackend(ABC):
class NoOpAnalytics(AnalyticsBackend):
"""Default backend: silently discards everything."""
"""
Default backend: discards events and serves static feature flags.
Feature flags can be declared without any analytics provider, e.g.:
ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
"""
def __init__(
self,
*,
feature_flags: Mapping[str, bool | str | None] | None = None,
**_kwargs: Any,
) -> None:
# Other settings are ignored, so that settings meant for another
# backend never break an instance running without one.
self._feature_flags = dict(feature_flags or {})
def identify(self, user: User, properties=None) -> None:
"""No-op: discards identify calls."""
@@ -63,3 +79,10 @@ class NoOpAnalytics(AnalyticsBackend):
def shutdown(self) -> None:
"""No-op: nothing to flush."""
def get_user_feature_flags(
self,
user: User,
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Return the feature flags declared in settings, for any user."""
return self._feature_flags
-12
View File
@@ -47,18 +47,6 @@ class RoomCreationDailyUserRateThrottle(RoomCreationUserRateThrottle):
scope = "room_creation_daily"
class StartRecordingUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle recording starts per authenticated user."""
scope = "start_recording"
class StartRecordingDailyUserRateThrottle(StartRecordingUserRateThrottle):
"""Cap recording starts per authenticated user over a day."""
scope = "start_recording_daily"
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated user requesting room entry"""
-4
View File
@@ -406,10 +406,6 @@ class RoomViewSet(
permission_classes=[
permissions.HasPrivilegesOnRoom,
],
throttle_classes=[
throttling.StartRecordingUserRateThrottle,
throttling.StartRecordingDailyUserRateThrottle,
],
)
@FeatureFlag.require("recording")
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
@@ -0,0 +1,124 @@
"""
Unit tests for NoOpAnalytics and the analytics backend loader.
"""
# pylint: disable=redefined-outer-name,unused-argument
import pytest
from core.analytics import get_analytics
from core.analytics.base import NoOpAnalytics
from core.analytics.user_feature_flags import UserFeatureFlag
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_analytics_cache():
"""Clear the cached backend before and after each test."""
get_analytics.cache_clear()
yield
get_analytics.cache_clear()
# ==============================
# NoOpAnalytics
# ==============================
def test_noop_has_no_feature_flags_by_default():
"""Should keep every feature flag disabled when none is declared."""
backend = NoOpAnalytics()
user = UserFactory()
assert not backend.get_user_feature_flags(user)
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is False
)
def test_noop_enables_declared_feature_flags():
"""Should enable a feature flag declared as True."""
backend = NoOpAnalytics(feature_flags={"summary-enabled": True})
user = UserFactory()
assert backend.get_user_feature_flags(user) == {"summary-enabled": True}
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
@pytest.mark.parametrize("value", [False, None, "true"])
def test_noop_only_enables_feature_flags_set_to_true(value):
"""Should keep a feature flag disabled unless its value is exactly True."""
backend = NoOpAnalytics(feature_flags={"summary-enabled": value})
user = UserFactory()
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is False
)
def test_noop_ignores_unrelated_settings():
"""Should accept settings meant for another backend without failing."""
backend = NoOpAnalytics(api_key="my-key", host="https://eu.i.posthog.com")
assert not backend.get_user_feature_flags(UserFactory())
# ==============================
# get_analytics
# ==============================
def test_get_analytics_defaults_to_noop_without_feature_flags(settings):
"""Should return a no-op backend with no feature flag by default."""
settings.ANALYTICS_BACKEND = None
settings.ANALYTICS_BACKEND_SETTINGS = {}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert not backend.get_user_feature_flags(UserFactory())
def test_get_analytics_passes_feature_flags_to_noop(settings):
"""Should forward the feature flags when no backend is configured."""
settings.ANALYTICS_BACKEND = None
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert (
backend.is_user_feature_enabled(
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
def test_get_analytics_instantiates_configured_backend_with_settings(settings):
"""Should instantiate the configured backend with its settings."""
settings.ANALYTICS_BACKEND = "core.analytics.base.NoOpAnalytics"
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert (
backend.is_user_feature_enabled(
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
@@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor
from urllib.parse import parse_qs, urlparse
from uuid import uuid4
from django.db import connection
from django.utils import timezone
import pytest
@@ -361,18 +362,21 @@ def test_api_files_create_file_race_condition():
"""
def create_item(title):
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
return client.post(
"/api/v1.0/files/",
{
"title": title,
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
try:
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
return client.post(
"/api/v1.0/files/",
{
"title": title,
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
finally:
connection.close()
with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(create_item, "my item 1")
@@ -5,6 +5,7 @@ from concurrent.futures import ThreadPoolExecutor
from io import BytesIO
from django.core.files.storage import default_storage
from django.db import connection
import pytest
from rest_framework.test import APIClient
@@ -295,9 +296,12 @@ def test_api_file_upload_ended_concurrent_calls_are_serialized(settings):
default_storage.save(file.temporary_file_key, BytesIO(b"my prose"))
def call_upload_ended():
client = APIClient()
client.force_login(user)
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
try:
client = APIClient()
client.force_login(user)
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
finally:
connection.close()
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
@@ -1,212 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: start recording throttling.
"""
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...api.throttling import (
StartRecordingDailyUserRateThrottle,
StartRecordingUserRateThrottle,
)
from ...factories import RoomFactory, UserFactory
from ...models import Recording
pytestmark = pytest.mark.django_db
@pytest.fixture
def start_recording_throttle(monkeypatch):
"""Lower the start recording rates."""
rates = StartRecordingUserRateThrottle.THROTTLE_RATES
monkeypatch.setitem(rates, "start_recording", "2/minute")
monkeypatch.setitem(rates, "start_recording_daily", "100/day")
@pytest.fixture
def daily_start_recording_throttle(monkeypatch):
"""Lower the daily start recording rate and control the clock."""
rates = StartRecordingDailyUserRateThrottle.THROTTLE_RATES
monkeypatch.setitem(rates, "start_recording", "100/minute")
monkeypatch.setitem(rates, "start_recording_daily", "3/day")
now = [1_000_000.0]
monkeypatch.setattr(StartRecordingUserRateThrottle, "timer", lambda self: now[0])
return now
@mock.patch("core.api.viewsets.WorkerServiceMediator")
@mock.patch("core.api.viewsets.get_worker_service")
def test_start_recording_throttled(
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
):
"""Starting a recording should return 429 when exceeding the per-minute rate."""
settings.RECORDING_ENABLE = True
user = UserFactory()
client = APIClient()
client.force_login(user)
for _ in range(2):
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 201
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 429
assert 0 < int(response["Retry-After"]) <= 60
assert Recording.objects.count() == 2
assert mock_mediator_class.return_value.start.call_count == 2
@mock.patch("core.api.viewsets.WorkerServiceMediator")
@mock.patch("core.api.viewsets.get_worker_service")
def test_start_recording_throttle_counts_failed_attempts(
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
):
"""Failed recording starts should count toward the rate."""
settings.RECORDING_ENABLE = True
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
Recording.objects.create(room=room, mode="screen_recording", status="active")
client = APIClient()
client.force_login(user)
for _ in range(2):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 409
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 429
@mock.patch("core.api.viewsets.WorkerServiceMediator")
@mock.patch("core.api.viewsets.get_worker_service")
def test_start_recording_throttle_per_user(
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
):
"""Starting a recording should succeed when another user is throttled."""
settings.RECORDING_ENABLE = True
client = APIClient()
user = UserFactory()
client.force_login(user)
for _ in range(2):
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 201
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 429
other_user = UserFactory()
client.force_login(other_user)
room = RoomFactory(users=[(other_user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 201
@mock.patch("core.api.viewsets.WorkerServiceMediator")
@mock.patch("core.api.viewsets.get_worker_service")
def test_start_recording_throttle_does_not_limit_other_actions(
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
):
"""Other room actions should succeed when starting a recording is throttled."""
settings.RECORDING_ENABLE = True
user = UserFactory()
client = APIClient()
client.force_login(user)
rooms = RoomFactory.create_batch(3, users=[(user, "owner")])
for room in rooms[:2]:
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 201
response = client.post(
f"/api/v1.0/rooms/{rooms[2].id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 429
Recording.objects.filter(room=rooms[0]).update(status="active")
response = client.post(f"/api/v1.0/rooms/{rooms[0].id}/stop-recording/")
assert response.status_code == 200
response = client.get("/api/v1.0/rooms/")
assert response.status_code == 200
response = client.post("/api/v1.0/rooms/", {"name": "New room"})
assert response.status_code == 201
@mock.patch("core.api.viewsets.WorkerServiceMediator")
@mock.patch("core.api.viewsets.get_worker_service")
def test_start_recording_daily_throttled(
mock_get_worker_service,
mock_mediator_class,
daily_start_recording_throttle,
settings,
):
"""Starting a recording should return 429 when exceeding the daily rate."""
settings.RECORDING_ENABLE = True
now = daily_start_recording_throttle
user = UserFactory()
client = APIClient()
client.force_login(user)
for _ in range(3):
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 201
now[0] += 120
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 429
assert 60 < int(response["Retry-After"]) <= 86400
now[0] += 86400
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 201
@@ -0,0 +1,130 @@
"""Unit tests for the opt-in psycopg connection pool settings."""
# pylint: disable=protected-access
from django.core.exceptions import ImproperlyConfigured
import pytest
from meet import settings
def make_configuration(**overrides):
"""Build a throwaway configuration with plain values and its own DATABASES."""
attributes = {
"DATABASES": {
"default": {
"ENGINE": "django.db.backends.postgresql",
"OPTIONS": {"sslmode": "require"},
}
},
"DB_PSYCOPG_POOL_ENABLED": True,
"DB_PSYCOPG_POOL_MIN_SIZE": 1,
"DB_PSYCOPG_POOL_MAX_SIZE": 3,
"DB_PSYCOPG_POOL_TIMEOUT": 30.0,
"DB_PSYCOPG_POOL_MAX_IDLE": 600.0,
**overrides,
}
return type("PoolConfiguration", (settings.Test,), attributes)
def test_database_pool_disabled_leaves_options_untouched():
"""Without the flag, no connection pool is configured."""
configuration = make_configuration(DB_PSYCOPG_POOL_ENABLED=False)
configuration._configure_database_pool()
assert configuration.DATABASES["default"]["OPTIONS"] == {"sslmode": "require"}
assert "CONN_HEALTH_CHECKS" not in configuration.DATABASES["default"]
def test_database_pool_enabled_merges_pool_options():
"""The pool and keepalive options are added next to the existing ones."""
configuration = make_configuration()
configuration._configure_database_pool()
assert configuration.DATABASES["default"]["OPTIONS"] == {
"sslmode": "require",
"keepalives_idle": 60,
"keepalives_interval": 10,
"keepalives_count": 5,
"pool": {"min_size": 1, "max_size": 3, "timeout": 30.0, "max_idle": 600.0},
}
def test_database_pool_enabled_keeps_explicit_keepalives():
"""Keepalive values already set, e.g. from DATABASE_URL, are not overridden."""
configuration = make_configuration(
DATABASES={
"default": {
"ENGINE": "django.db.backends.postgresql",
"OPTIONS": {"keepalives_idle": "30", "keepalives": "0"},
}
}
)
configuration._configure_database_pool()
options = configuration.DATABASES["default"]["OPTIONS"]
assert options["keepalives_idle"] == "30"
assert options["keepalives"] == "0"
assert options["keepalives_interval"] == 10
@pytest.mark.parametrize("health_checks", [None, False])
def test_database_pool_enabled_forces_health_checks(health_checks):
"""Pooled connections are checked on checkout, even if DATABASE_URL said no."""
database = {"ENGINE": "django.db.backends.postgresql"}
if health_checks is not None:
database["CONN_HEALTH_CHECKS"] = health_checks
configuration = make_configuration(DATABASES={"default": database})
configuration._configure_database_pool()
assert configuration.DATABASES["default"]["CONN_HEALTH_CHECKS"] is True
def test_database_pool_enabled_without_options():
"""A database without OPTIONS, as built from DATABASE_URL, gets them created."""
configuration = make_configuration(
DATABASES={"default": {"ENGINE": "django.db.backends.postgresql_psycopg2"}}
)
configuration._configure_database_pool()
assert configuration.DATABASES["default"]["OPTIONS"]["pool"]["max_size"] == 3
@pytest.mark.parametrize(
"overrides,match",
[
(
{"DATABASES": {"default": {"ENGINE": "django.db.backends.sqlite3"}}},
"requires the django.db.backends.postgresql engine",
),
(
{
"DATABASES": {
"default": {
"ENGINE": "django.db.backends.postgresql",
"CONN_MAX_AGE": 60,
}
}
},
"cannot be combined with persistent connections",
),
(
{"DB_PSYCOPG_POOL_MIN_SIZE": 6},
"MIN_SIZE cannot be greater than",
),
({"DB_PSYCOPG_POOL_TIMEOUT": 0.0}, "must be positive"),
({"DB_PSYCOPG_POOL_MAX_IDLE": -1.0}, "must be positive"),
],
)
def test_database_pool_invalid_configuration(overrides, match):
"""An invalid pool setup fails at boot."""
configuration = make_configuration(**overrides)
with pytest.raises(ImproperlyConfigured, match=match):
configuration._configure_database_pool()
+64 -11
View File
@@ -17,6 +17,7 @@ import warnings
from os import path
from socket import gethostbyname, gethostname
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext_lazy as _
import dj_database_url
@@ -139,7 +140,7 @@ class Base(Configuration):
)
else {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
"django.db.backends.postgresql",
environ_name="DB_ENGINE",
environ_prefix=None,
),
@@ -154,6 +155,21 @@ class Base(Configuration):
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
}
}
DB_PSYCOPG_POOL_ENABLED = values.BooleanValue(
False, environ_name="DB_PSYCOPG_POOL_ENABLED", environ_prefix=None
)
DB_PSYCOPG_POOL_MIN_SIZE = values.PositiveIntegerValue(
1, environ_name="DB_PSYCOPG_POOL_MIN_SIZE", environ_prefix=None
)
DB_PSYCOPG_POOL_MAX_SIZE = values.PositiveIntegerValue(
3, environ_name="DB_PSYCOPG_POOL_MAX_SIZE", environ_prefix=None
)
DB_PSYCOPG_POOL_TIMEOUT = values.FloatValue(
30.0, environ_name="DB_PSYCOPG_POOL_TIMEOUT", environ_prefix=None
)
DB_PSYCOPG_POOL_MAX_IDLE = values.FloatValue(
600.0, environ_name="DB_PSYCOPG_POOL_MAX_IDLE", environ_prefix=None
)
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Static files (CSS, JavaScript, Images)
@@ -395,16 +411,6 @@ class Base(Configuration):
environ_name="ROOM_CREATION_DAILY_THROTTLE_RATES",
environ_prefix=None,
),
"start_recording": values.Value(
default="10/minute",
environ_name="START_RECORDING_THROTTLE_RATES",
environ_prefix=None,
),
"start_recording_daily": values.Value(
default="100/day",
environ_name="START_RECORDING_DAILY_THROTTLE_RATES",
environ_prefix=None,
),
"request_entry": values.Value(
default="150/minute",
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
@@ -1456,6 +1462,52 @@ class Base(Configuration):
stacklevel=2,
)
@classmethod
def _configure_database_pool(cls):
"""Inject the psycopg pool options into the default database when enabled."""
if not cls.DB_PSYCOPG_POOL_ENABLED:
return
database = cls.DATABASES["default"]
if database.get("ENGINE") not in (
"django.db.backends.postgresql",
"django.db.backends.postgresql_psycopg2",
):
raise ImproperlyConfigured(
"DB_PSYCOPG_POOL_ENABLED requires the django.db.backends.postgresql "
f"engine, got {database.get('ENGINE')!r}."
)
if database.get("CONN_MAX_AGE"):
raise ImproperlyConfigured(
"DB_PSYCOPG_POOL_ENABLED cannot be combined with persistent "
"connections; set CONN_MAX_AGE to 0."
)
if cls.DB_PSYCOPG_POOL_MIN_SIZE > cls.DB_PSYCOPG_POOL_MAX_SIZE:
raise ImproperlyConfigured(
"DB_PSYCOPG_POOL_MIN_SIZE cannot be greater than "
"DB_PSYCOPG_POOL_MAX_SIZE."
)
if cls.DB_PSYCOPG_POOL_TIMEOUT <= 0 or cls.DB_PSYCOPG_POOL_MAX_IDLE <= 0:
raise ImproperlyConfigured(
"DB_PSYCOPG_POOL_TIMEOUT and DB_PSYCOPG_POOL_MAX_IDLE must be positive."
)
database["OPTIONS"] = {
"keepalives_idle": 60,
"keepalives_interval": 10,
"keepalives_count": 5,
**database.get("OPTIONS", {}),
"pool": {
"min_size": cls.DB_PSYCOPG_POOL_MIN_SIZE,
"max_size": cls.DB_PSYCOPG_POOL_MAX_SIZE,
"timeout": cls.DB_PSYCOPG_POOL_TIMEOUT,
"max_idle": cls.DB_PSYCOPG_POOL_MAX_IDLE,
},
}
# The pool does not watch idle connections: check each one on checkout
# so a broken connection is replaced instead of failing the request.
database["CONN_HEALTH_CHECKS"] = True
@classmethod
def post_setup(cls):
"""Post setup configuration.
@@ -1470,6 +1522,7 @@ class Base(Configuration):
)
cls._check_recording_encoding_maps()
cls._configure_database_pool()
if (
cls.SUMMARY_SERVICE_VERSION == 1
+1 -1
View File
@@ -51,7 +51,7 @@ dependencies = [
"markdown==3.10.3",
"nested-multipart-parser==1.6.0",
"posthog==7.44.0",
"psycopg[binary]==3.3.4",
"psycopg[binary,pool]==3.3.4",
"pydantic==2.13.4",
"PyJWT==2.14.0",
"python-frontmatter==1.3.0",
+17 -2
View File
@@ -1331,7 +1331,7 @@ dependencies = [
{ name = "nested-multipart-parser" },
{ name = "phonenumbers" },
{ name = "posthog" },
{ name = "psycopg", extra = ["binary"] },
{ name = "psycopg", extra = ["binary", "pool"] },
{ name = "pydantic" },
{ name = "pyjwt" },
{ name = "python-frontmatter" },
@@ -1396,7 +1396,7 @@ requires-dist = [
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "phonenumbers", specifier = "==9.0.37" },
{ name = "posthog", specifier = "==7.44.0" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.4" },
{ name = "psycopg", extras = ["binary", "pool"], specifier = "==3.3.4" },
{ name = "pydantic", specifier = "==2.13.4" },
{ name = "pyjwt", specifier = "==2.14.0" },
{ name = "python-frontmatter", specifier = "==1.3.0" },
@@ -1875,6 +1875,9 @@ wheels = [
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
pool = [
{ name = "psycopg-pool" },
]
[[package]]
name = "psycopg-binary"
@@ -1905,6 +1908,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
]
[[package]]
name = "psycopg-pool"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/5e/c0664b968b102ff68b811d999c728546c48d5c1eec03e3bbaf88c0cb4472/psycopg_pool-3.3.3.tar.gz", hash = "sha256:df87b5d9d0ad7db37f6cdad4fa8ce113d250f5997f6db38e9a99192fb67f9e1d", size = 32006, upload-time = "2026-09-22T15:53:24.947Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/b4/452c6607a0f479465cd8a9b0d9956919fcb150050c1f83f9f11e6b8ee8dc/psycopg_pool-3.3.3-py3-none-any.whl", hash = "sha256:9b9cd6a4fcec47a410f7e82d408540e7f77b478509e91b44c1a5457a13e5ff37", size = 40304, upload-time = "2026-09-22T15:53:23.712Z" },
]
[[package]]
name = "ptyprocess"
version = "0.7.0"