From 49189465d2900bdf2cd61f074684f91978aa2d4d Mon Sep 17 00:00:00 2001 From: nagelm <10207686+nagelm@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:00:29 +1200 Subject: [PATCH] fix(db): pin session timezone to UTC via libpq startup packet DB sessions were never actually pinned to UTC. Three stacked failures: 1. The connection_created receiver (_force_utc0 in core/apps.py) was a nested closure connected with Django's default weak reference. It was garbage-collected as soon as CoreConfig.ready() returned, so SET TIME ZONE 'UTC0' fired into a dead weakref and never executed - in any version since it landed (verified: the signal's receivers list on a live 0.27.2 shows a dead weakref). 2. Sessions were UTC on older stacks anyway because Django's own init_connection_state configured the timezone. Since native psycopg-pool support (Django 5.1+), that path is gated on `not self.pool` - and the geventpool mixin's `pool` property is always truthy, so Django silently skips timezone (and role) configuration for this backend on every connection. This is what actually regressed at the psycopg2->3 / Django upgrade: the masking layer disappeared, not the (already dead) signal. 3. psycopg3 logs "unknown PostgreSQL timezone: 'UTC0'" because the POSIX spec is unresolvable in Python zoneinfo (cosmetic, but it means 'UTC0' buys nothing on psycopg3). Net effect: every session ran at the server-default timezone (verified live: SHOW TimeZone through the pool returns 'Etc/UTC'). Deployments whose Postgres default is non-UTC (e.g. /etc/localtime bind-mounts, the original issue 651 report) get the EPG offset corruption back. Fix: pin the GUC in the libpq startup packet in the pool backend's get_connection_params(): - covers every connection the gevent pool creates; no signal, GC, or Django-flow dependency (same pattern as the client_encoding pin the pool already applies) - startup-packet GUCs are the session default: they survive ROLLBACK, and RESET TimeZone returns to UTC rather than the server default - 'UTC' resolves cleanly in psycopg3's zoneinfo lookup, and PostgreSQL resolves it against its own bundled tzdata, not host-mounted files The dead signal is removed. Regression tests exercise the session timezone through the real pool backend explicitly (the test runner's default engine is the vanilla Django backend), and assert the RESET-returns-to-UTC session-default property. Tested: live 0.27.2 AIO (psycopg 3.3.4, PostgreSQL 17) - unpatched backend+pool session shows 'Etc/UTC'; patched shows 'UTC' incl. after RESET and ROLLBACK. Full A/B against the dev image with the server default forced to Europe/Zurich: stock renders a 12:00Z source programme as start="20260715140000 +0000" in /output/epg; patched renders it correctly; the regression tests fail on stock and pass when patched. --- core/apps.py | 8 --- core/tests/test_db_session_timezone.py | 49 +++++++++++++++++++ .../db/backends/postgresql_psycopg3/base.py | 3 ++ 3 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 core/tests/test_db_session_timezone.py diff --git a/core/apps.py b/core/apps.py index 4b7fed5c..ee182e89 100644 --- a/core/apps.py +++ b/core/apps.py @@ -25,14 +25,6 @@ class CoreConfig(AppConfig): import core.signals from dispatcharr.app_initialization import should_skip_initialization - # Force UTC0 on every new DB connection. - from django.db.backends.signals import connection_created - - def _force_utc0(sender, connection, **kwargs): - connection.cursor().execute("SET TIME ZONE 'UTC0'") - - connection_created.connect(_force_utc0, dispatch_uid='force_db_utc0') - # Sync developer notifications and check for version updates on startup # Only run in the main process (not in management commands, migrations, or workers) if should_skip_initialization(): diff --git a/core/tests/test_db_session_timezone.py b/core/tests/test_db_session_timezone.py new file mode 100644 index 00000000..bae5373a --- /dev/null +++ b/core/tests/test_db_session_timezone.py @@ -0,0 +1,49 @@ +"""DB sessions must run in UTC regardless of the server default timezone.""" + +import copy +import unittest + +from django.db import connection, connections +from django.test import TestCase + + +@unittest.skipUnless( + connection.vendor == "postgresql", + "PostgreSQL-only: exercises the psycopg3 gevent pool backend", +) +class DatabaseSessionTimezoneTests(TestCase): + def _pool_backend_wrapper(self): + from dispatcharr.db.backends.postgresql_psycopg3.base import DatabaseWrapper + + settings_dict = copy.deepcopy(connections.databases["default"]) + settings_dict["ENGINE"] = "dispatcharr.db.backends.postgresql_psycopg3" + settings_dict.setdefault("OPTIONS", {}) + return DatabaseWrapper(settings_dict, alias="tz_probe") + + @staticmethod + def _teardown_wrapper(wrapper): + wrapper.close() + pool = wrapper._connection_pools.pop("tz_probe", None) + if pool is not None: + pool.close() + + def test_pool_session_timezone_pinned_to_utc(self): + wrapper = self._pool_backend_wrapper() + try: + wrapper.connect() + with wrapper.connection.cursor() as cursor: + cursor.execute("SHOW TimeZone") + self.assertEqual(cursor.fetchone()[0], "UTC") + finally: + self._teardown_wrapper(wrapper) + + def test_pool_session_timezone_is_session_default(self): + wrapper = self._pool_backend_wrapper() + try: + wrapper.connect() + with wrapper.connection.cursor() as cursor: + cursor.execute("RESET TimeZone") + cursor.execute("SHOW TimeZone") + self.assertEqual(cursor.fetchone()[0], "UTC") + finally: + self._teardown_wrapper(wrapper) diff --git a/dispatcharr/db/backends/postgresql_psycopg3/base.py b/dispatcharr/db/backends/postgresql_psycopg3/base.py index 098a01f4..c3bca122 100644 --- a/dispatcharr/db/backends/postgresql_psycopg3/base.py +++ b/dispatcharr/db/backends/postgresql_psycopg3/base.py @@ -41,6 +41,9 @@ class DatabaseWrapper(DatabaseWrapperMixin, OriginalDatabaseWrapper): conn_params = super().get_connection_params() conn_params["application_name"] = db_application_name() + # Force UTC on every new DB connection. + existing_options = conn_params.get("options", "") + conn_params["options"] = f"{existing_options} -c TimeZone=UTC".strip() for attr in ("MAX_CONNS", "REUSE_CONNS", "CONN_MAX_LIFETIME"): if attr in self.settings_dict["OPTIONS"]: conn_params[attr] = self.settings_dict["OPTIONS"][attr]