Skip to content

Commit 7dfa0bb

Browse files
authored
Merge pull request #364 from netboxlabs/358-fix-connection-leak
Fixes #358: Close branch connections after CONN_MAX_AGE expires
2 parents 721fc33 + 3cffcb9 commit 7dfa0bb

File tree

3 files changed

+148
-1
lines changed

3 files changed

+148
-1
lines changed

netbox_branching/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@ class AppConfig(PluginConfig):
4545

4646
def ready(self):
4747
super().ready()
48+
from django.core.signals import request_started, request_finished
4849
from . import constants, events, search, signal_receivers, webhook_callbacks # noqa: F401
4950
from .models import Branch
50-
from .utilities import DynamicSchemaDict
51+
from .utilities import DynamicSchemaDict, close_old_branch_connections
5152

5253
# Validate required settings
5354
if type(settings.DATABASES) is not DynamicSchemaDict:
@@ -59,6 +60,14 @@ def ready(self):
5960
"netbox_branching: DATABASE_ROUTERS must contain 'netbox_branching.database.BranchAwareRouter'."
6061
)
6162

63+
# Register cleanup handler for branch connections (#358)
64+
# This ensures branch connections are closed when they exceed CONN_MAX_AGE,
65+
# preventing connection leaks. Django's built-in close_old_connections()
66+
# only handles connections in DATABASES.keys(), which doesn't include
67+
# dynamically-created branch aliases.
68+
request_started.connect(close_old_branch_connections)
69+
request_finished.connect(close_old_branch_connections)
70+
6271
# Register the "branching" model feature
6372
register_model_feature('branching', supports_branching)
6473

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import time
2+
3+
from django.conf import settings
4+
from django.contrib.auth import get_user_model
5+
from django.db import connections
6+
from django.test import tag, TransactionTestCase
7+
8+
from netbox_branching.models import Branch
9+
from netbox_branching.utilities import activate_branch, close_old_branch_connections
10+
11+
12+
@tag('regression') # netbox-branching #358
13+
class BranchConnectionLifecycleTestCase(TransactionTestCase):
14+
serialized_rollback = True
15+
16+
def setUp(self):
17+
"""Set up test environment with CONN_MAX_AGE=1."""
18+
self.original_max_age = settings.DATABASES['default'].get('CONN_MAX_AGE', 0)
19+
settings.DATABASES['default']['CONN_MAX_AGE'] = 1
20+
self.user = get_user_model().objects.create_user(username='testuser', is_superuser=True)
21+
self.branches = []
22+
23+
def tearDown(self):
24+
"""Clean up branches and restore CONN_MAX_AGE."""
25+
for branch in self.branches:
26+
try:
27+
connections[branch.connection_name].close()
28+
except Exception:
29+
pass
30+
Branch.objects.filter(pk=branch.pk).delete()
31+
settings.DATABASES['default']['CONN_MAX_AGE'] = self.original_max_age
32+
33+
def create_and_provision_branch(self, name):
34+
"""Create and provision a test branch."""
35+
branch = Branch(name=name, description=f'Test {name}')
36+
branch.save(provision=False)
37+
branch.provision(self.user)
38+
self.branches.append(branch)
39+
return branch
40+
41+
def open_branch_connection(self, branch):
42+
"""Open a connection to the branch by executing a query."""
43+
with activate_branch(branch):
44+
from django.contrib.contenttypes.models import ContentType
45+
list(ContentType.objects.using(branch.connection_name).all()[:1])
46+
47+
def test_branch_connections_close_after_max_age(self):
48+
"""Branch connections should close after CONN_MAX_AGE expires."""
49+
branch = self.create_and_provision_branch('test-conn-cleanup')
50+
self.open_branch_connection(branch)
51+
52+
conn = connections[branch.connection_name]
53+
self.assertIsNotNone(conn.connection, "Connection should be open after query")
54+
self.assertIsNotNone(conn.close_at, "close_at should be set when CONN_MAX_AGE > 0")
55+
56+
time.sleep(2)
57+
close_old_branch_connections()
58+
59+
self.assertIsNone(conn.connection, "Connection should be closed after CONN_MAX_AGE expires")
60+
61+
def test_multiple_branch_connections_cleanup(self):
62+
"""Multiple branch connections should all close after CONN_MAX_AGE."""
63+
branches = [self.create_and_provision_branch(f'test-multi-{i}') for i in range(3)]
64+
65+
for branch in branches:
66+
self.open_branch_connection(branch)
67+
68+
conns = [connections[b.connection_name] for b in branches]
69+
for conn in conns:
70+
self.assertIsNotNone(conn.connection, "Connection should be open")
71+
72+
time.sleep(2)
73+
close_old_branch_connections()
74+
75+
for i, conn in enumerate(conns):
76+
self.assertIsNone(conn.connection, f"Branch {i} connection should be closed")
77+
78+
def test_cleanup_handles_deleted_branch(self):
79+
"""Cleanup should gracefully handle connections to deleted branch schemas."""
80+
branch = self.create_and_provision_branch('test-deleted-branch')
81+
self.open_branch_connection(branch)
82+
83+
conn = connections[branch.connection_name]
84+
self.assertIsNotNone(conn.connection, "Connection should be open")
85+
86+
branch.deprovision()
87+
Branch.objects.filter(pk=branch.pk).delete()
88+
self.branches.remove(branch)
89+
90+
try:
91+
close_old_branch_connections()
92+
except Exception as e:
93+
self.fail(f"cleanup should not raise exception for deleted branch: {e}")

netbox_branching/utilities.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from dataclasses import dataclass
66
from functools import cached_property
77

8+
from asgiref.local import Local
89
from django.contrib import messages
910
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
11+
from django.db import connections
1012
from django.db.models import ForeignKey, ManyToManyField
1113
from django.http import HttpResponseBadRequest
1214
from django.urls import reverse
@@ -16,13 +18,19 @@
1618
from .constants import BRANCH_HEADER, COOKIE_NAME, EXEMPT_MODELS, INCLUDE_MODELS, QUERY_PARAM
1719
from .contextvars import active_branch
1820

21+
# Thread-local storage for tracking branch connection aliases (matches Django's approach)
22+
# Note: Aliases are tracked once and never removed, matching Django's pattern where
23+
# DATABASES.keys() is static. Memory overhead is negligible (string references only).
24+
_branch_connections_tracker = Local(thread_critical=False)
25+
1926
__all__ = (
2027
'BranchActionIndicator',
2128
'ChangeSummary',
2229
'DynamicSchemaDict',
2330
'ListHandler',
2431
'ActiveBranchContextManager',
2532
'activate_branch',
33+
'close_old_branch_connections',
2634
'deactivate_branch',
2735
'get_active_branch',
2836
'get_branchable_object_types',
@@ -31,10 +39,23 @@
3139
'is_api_request',
3240
'record_applied_change',
3341
'supports_branching',
42+
'track_branch_connection',
3443
'update_object',
3544
)
3645

3746

47+
def _get_tracked_branch_aliases():
48+
"""Get set of tracked branch aliases for current thread."""
49+
if not hasattr(_branch_connections_tracker, 'aliases'):
50+
_branch_connections_tracker.aliases = set()
51+
return _branch_connections_tracker.aliases
52+
53+
54+
def track_branch_connection(alias):
55+
"""Register a branch connection alias for cleanup tracking."""
56+
_get_tracked_branch_aliases().add(alias)
57+
58+
3859
class DynamicSchemaDict(dict):
3960
"""
4061
Behaves like a normal dictionary, except for keys beginning with "schema_". Any lookup for
@@ -47,6 +68,8 @@ def main_schema(self):
4768
def __getitem__(self, item):
4869
if type(item) is str and item.startswith('schema_'):
4970
if schema := item.removeprefix('schema_'):
71+
track_branch_connection(item)
72+
5073
default_config = super().__getitem__('default')
5174
return {
5275
**default_config,
@@ -62,6 +85,28 @@ def __contains__(self, item):
6285
return super().__contains__(item)
6386

6487

88+
def close_old_branch_connections(**kwargs):
89+
"""
90+
Close branch database connections that have exceeded their maximum age.
91+
92+
This function complements Django's close_old_connections() by handling
93+
dynamically-created branch connections. It tracks branch connection aliases
94+
in thread-local storage and closes them when they exceed CONN_MAX_AGE.
95+
96+
Django's close_old_connections() only closes connections for database aliases
97+
found in DATABASES.keys(). Since branch aliases are generated dynamically and
98+
not present in that iteration (to avoid test isolation issues), they would never
99+
be cleaned up, causing connection leaks.
100+
101+
This function is connected to request_started and request_finished signals,
102+
matching Django's cleanup timing.
103+
"""
104+
105+
for alias in _get_tracked_branch_aliases():
106+
conn = connections[alias]
107+
conn.close_if_unusable_or_obsolete()
108+
109+
65110
@contextmanager
66111
def activate_branch(branch):
67112
"""

0 commit comments

Comments
 (0)