Skip to content

Commit abf9e1b

Browse files
authored
Merge pull request #49 from knifecake/fix/issue-48-fork-safety
Fix supervisor fork crash loop with Django psycopg pools
2 parents da33c4a + 19049b3 commit abf9e1b

4 files changed

Lines changed: 195 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
**Fixed:**
6+
7+
- Fixed supervisor child-process crash loops (`exit code 11`) seen with
8+
Django/PostgreSQL pooling by resetting DB state before forking and clearing
9+
Django's class-level psycopg pool cache in the forking path (#48).
10+
511
## v0.1.8 - 2026-03-08
612

713
**Fixed:**

steady_queue/processes/base.py

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import logging
12
import os
23
import secrets
34
import socket
45
from typing import Any
56

67
from django.db import connections
78

9+
logger = logging.getLogger("steady_queue")
10+
811

912
class Base:
1013
name: str
@@ -51,44 +54,77 @@ def disable_connection_pooling(self):
5154
Disable connection pooling for steady_queue processes.
5255
5356
Connection pooling with psycopg doesn't work with forked processes.
54-
This method removes pool configuration from database settings to prevent
55-
pool-related errors in steady_queue workers.
57+
This method removes pool configuration from database settings and from
58+
already-instantiated Django connection wrappers.
5659
"""
57-
import logging
58-
5960
from django.conf import settings
6061

61-
logger = logging.getLogger("steady_queue")
62-
63-
# Disable pooling in database configuration
6462
if hasattr(settings, "DATABASES"):
6563
for alias, db_config in settings.DATABASES.items():
66-
if db_config.get("ENGINE") == "django.db.backends.postgresql":
67-
# Remove pool configuration if it exists
68-
options = db_config.setdefault("OPTIONS", {})
69-
if "pool" in options:
70-
logger.info(
71-
"%(name)s disabling connection pooling for database '%(alias)s'",
72-
{"name": self.name, "alias": alias},
73-
)
74-
del options["pool"]
75-
76-
# Also disable on any existing connections
64+
if db_config.get("ENGINE") != "django.db.backends.postgresql":
65+
continue
66+
67+
options = db_config.setdefault("OPTIONS", {})
68+
if "pool" in options:
69+
logger.info(
70+
"%(name)s disabling connection pooling for database '%(alias)s'",
71+
{"name": self.name, "alias": alias},
72+
)
73+
del options["pool"]
74+
75+
for alias in connections:
76+
connection = connections[alias]
77+
if (
78+
connection.settings_dict.get("ENGINE")
79+
!= "django.db.backends.postgresql"
80+
):
81+
continue
82+
83+
options = connection.settings_dict.setdefault("OPTIONS", {})
84+
if "pool" in options:
85+
logger.debug(
86+
"%(name)s removing pool option from instantiated connection '%(alias)s'",
87+
{"name": self.name, "alias": alias},
88+
)
89+
del options["pool"]
90+
91+
def close_postgresql_connection_pools(self):
92+
"""
93+
Close and clear Django's class-level psycopg pool cache.
94+
95+
Django stores psycopg pools in DatabaseWrapper._connection_pools, so we
96+
must clear those references to avoid inheriting stale pools across fork.
97+
"""
98+
closed_pool_maps: set[int] = set()
99+
77100
for alias in connections:
78101
connection = connections[alias]
79-
if hasattr(connection, "pool") and connection.pool is not None:
102+
if (
103+
connection.settings_dict.get("ENGINE")
104+
!= "django.db.backends.postgresql"
105+
):
106+
continue
107+
108+
pool_map = getattr(connection.__class__, "_connection_pools", None)
109+
if not isinstance(pool_map, dict) or id(pool_map) in closed_pool_maps:
110+
continue
111+
112+
for pool_alias, pool in list(pool_map.items()):
80113
try:
81-
connection.pool.close()
82-
connection.pool = None
114+
pool.close()
83115
logger.debug(
84-
"%(name)s removed existing pool for '%(alias)s'",
85-
{"name": self.name, "alias": alias},
116+
"%(name)s closed psycopg pool for '%(alias)s'",
117+
{"name": self.name, "alias": pool_alias},
86118
)
87119
except Exception as e:
88120
logger.debug(
89-
"%(name)s failed to close existing pool for '%(alias)s': %(e)s",
90-
{"name": self.name, "alias": alias, "e": e},
121+
"%(name)s failed to close pool for '%(alias)s': %(e)s",
122+
{"name": self.name, "alias": pool_alias, "e": e},
91123
)
124+
finally:
125+
pool_map.pop(pool_alias, None)
126+
127+
closed_pool_maps.add(id(pool_map))
92128

93129
def reset_database_connections(self):
94130
"""
@@ -98,5 +134,5 @@ def reset_database_connections(self):
98134
issues with shared connections between parent and child processes.
99135
"""
100136
self.disable_connection_pooling()
101-
102137
connections.close_all()
138+
self.close_postgresql_connection_pools()

steady_queue/processes/supervisor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ def start(self) -> None:
4343
logger.info("starting supervisor with PID %(pid)d", {"pid": self.pid})
4444
try:
4545
self.boot()
46+
# Fork only after resetting DB state (connections + psycopg pools).
47+
self.reset_database_connections()
4648
self.start_processes()
4749
self.launch_maintenance_task()
4850
except SystemExit:

tests/test_fork_safety.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import warnings
2+
from unittest.mock import patch
3+
4+
from django.conf import settings
5+
from django.test import SimpleTestCase
6+
7+
from steady_queue.configuration import Configuration
8+
from steady_queue.processes.base import Base
9+
from steady_queue.processes.supervisor import Supervisor
10+
11+
12+
class SupervisorForkSafetyTest(SimpleTestCase):
13+
def build_supervisor(self) -> Supervisor:
14+
options = Configuration.Options(
15+
workers=[],
16+
dispatchers=[],
17+
recurring_tasks=[],
18+
skip_recurring=True,
19+
)
20+
return Supervisor(Configuration(options))
21+
22+
def test_supervisor_start_resets_connections_before_forking(self):
23+
supervisor = self.build_supervisor()
24+
calls = []
25+
26+
supervisor.boot = lambda: calls.append("boot")
27+
supervisor.reset_database_connections = lambda: calls.append("reset")
28+
supervisor.start_processes = lambda: calls.append("start_processes")
29+
supervisor.launch_maintenance_task = lambda: calls.append("launch_maintenance")
30+
supervisor.supervise = lambda: calls.append("supervise")
31+
32+
supervisor.start()
33+
34+
self.assertEqual(
35+
calls,
36+
[
37+
"boot",
38+
"reset",
39+
"start_processes",
40+
"launch_maintenance",
41+
"supervise",
42+
],
43+
)
44+
45+
46+
class ResetDatabaseConnectionsTest(SimpleTestCase):
47+
def test_reset_connections_disables_and_clears_psycopg_pool_cache(self):
48+
class FakePool:
49+
def __init__(self):
50+
self.closed = False
51+
52+
def close(self):
53+
self.closed = True
54+
55+
class FakeConnection:
56+
_connection_pools = {}
57+
58+
def __init__(self, engine: str):
59+
self.settings_dict = {
60+
"ENGINE": engine,
61+
"OPTIONS": {"pool": {"min_size": 1, "max_size": 4}},
62+
}
63+
64+
class FakeConnections(dict):
65+
close_all_called = False
66+
67+
def __iter__(self):
68+
return iter(self.keys())
69+
70+
def close_all(self):
71+
self.close_all_called = True
72+
73+
pool_default = FakePool()
74+
pool_queue = FakePool()
75+
FakeConnection._connection_pools = {
76+
"default": pool_default,
77+
"queue": pool_queue,
78+
}
79+
80+
fake_connections = FakeConnections(
81+
{
82+
"default": FakeConnection("django.db.backends.postgresql"),
83+
"queue": FakeConnection("django.db.backends.postgresql"),
84+
"sqlite": FakeConnection("django.db.backends.sqlite3"),
85+
}
86+
)
87+
88+
with warnings.catch_warnings():
89+
warnings.filterwarnings(
90+
"ignore",
91+
message="Overriding setting DATABASES can lead to unexpected behavior.",
92+
category=UserWarning,
93+
)
94+
95+
with self.settings(
96+
DATABASES={
97+
"default": {
98+
"ENGINE": "django.db.backends.postgresql",
99+
"OPTIONS": {"pool": {"min_size": 1, "max_size": 4}},
100+
},
101+
"queue": {
102+
"ENGINE": "django.db.backends.postgresql",
103+
"OPTIONS": {"pool": {"min_size": 1, "max_size": 4}},
104+
},
105+
"sqlite": {
106+
"ENGINE": "django.db.backends.sqlite3",
107+
"OPTIONS": {"pool": {"min_size": 1, "max_size": 4}},
108+
},
109+
}
110+
):
111+
with patch("steady_queue.processes.base.connections", fake_connections):
112+
Base().reset_database_connections()
113+
114+
self.assertNotIn("pool", settings.DATABASES["default"]["OPTIONS"])
115+
self.assertNotIn("pool", settings.DATABASES["queue"]["OPTIONS"])
116+
self.assertIn("pool", settings.DATABASES["sqlite"]["OPTIONS"])
117+
118+
self.assertNotIn("pool", fake_connections["default"].settings_dict["OPTIONS"])
119+
self.assertNotIn("pool", fake_connections["queue"].settings_dict["OPTIONS"])
120+
self.assertIn("pool", fake_connections["sqlite"].settings_dict["OPTIONS"])
121+
122+
self.assertTrue(fake_connections.close_all_called)
123+
self.assertTrue(pool_default.closed)
124+
self.assertTrue(pool_queue.closed)
125+
self.assertEqual(FakeConnection._connection_pools, {})

0 commit comments

Comments
 (0)