Skip to content

Conversation

@jnovinger
Copy link
Contributor

Fixes: #358

Branch-specific database connections never close after CONN_MAX_AGE expires, causing connection exhaustion when users visit multiple branches. Django's close_old_connections() iterates DATABASES.keys() to discover aliases/names for connections to cleanup, but branch aliases are served dynamically via DynamicSchemaDict.__getitem__() without being yielded by __iter__(), so they're never checked for expiry. They're essentially invisible to Django's CONN_MAX_AGE machinery.

Solution Approaches

  1. Caching aliases in DATABASES dict: Fixed cleanup but broke all 43 existing tests with "Database connections not allowed in this test." Django's test framework checks DATABASES.keys() during setup and blocks non-default connections. The issue wasn't just about adding databases = 'all' to existing tests (or to some base test class), which this would have required. The deeper concern was that by making branch aliases visible in DATABASES.keys() during test setup, we'd be changing the fundamental isolation guarantees. Tests that never needed to know about branch databases would suddenly have them in scope, potentially causing cross-contamination between tests or allowing tests to accidentally depend on branch state.

  2. Custom __iter__ using private API: Used connections._connections._storage.__dict__ to discover cached aliases. Fixed cleanup and all tests passed, but relied on undocumented Django internals. Rejected for maintenance concerns.

  3. (what I ended up with) Track branch aliases in our own thread-local storage when first accessed, register cleanup handlers on request_started and request_finished signals. Uses only public APIs (asgiref.Local, Django signals, connections.close_if_unusable_or_obsolete). Matches Django's pattern where DATABASES.keys() is static and implements thread-specific tracking in the same manner as Django.

Django's close_old_connections() only handles database aliases found in
DATABASES.keys(). Branch aliases (schema_*) are served dynamically via
DynamicSchemaDict.__getitem__ but aren't yielded by __iter__, so they
were never cleaned up, causing connection leaks.

Solution: Track branch connection aliases in thread-local storage when
they're first accessed. Register cleanup handler on request_started and
request_finished signals to close expired branch connections using the
same timing as Django's built-in cleanup.

Implementation uses public APIs only (asgiref.Local, Django signals,
connections.close_if_unusable_or_obsolete) and matches Django's pattern
where DATABASES.keys() is effectively static - aliases are tracked once
and never removed.

Tests verify connections close after CONN_MAX_AGE for single branch,
multiple branches, and deleted branch schemas.
@jnovinger jnovinger requested a review from a team November 11, 2025 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Database connections increase over time, leading to errors

2 participants