Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions awx/main/tests/unit/settings/test_connection_reuse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Copyright (c) 2026 Ascender
# All Rights Reserved.
"""
Which processes keep a database connection between requests, and which do not.
"""

import sys
from types import ModuleType

import pytest

from awx.settings.connection_reuse import DEFAULT_WEB_CONN_MAX_AGE, is_web_process, set_conn_max_age


@pytest.fixture
def databases():
return {'default': {'ENGINE': 'django.db.backends.postgresql', 'NAME': 'awx'}}


@pytest.fixture(autouse=True)
def no_web_markers(monkeypatch):
"""Start every test from a process that claims to be neither web nor uwsgi."""
monkeypatch.delenv('AWX_WEB_PROCESS', raising=False)
monkeypatch.delitem(sys.modules, 'uwsgi', raising=False)


@pytest.fixture
def under_uwsgi(monkeypatch):
monkeypatch.setitem(sys.modules, 'uwsgi', ModuleType('uwsgi'))


class TestIsWebProcess:
def test_uwsgi_is_a_web_process(self, under_uwsgi):
assert is_web_process() is True

def test_the_marker_is_a_web_process(self, monkeypatch):
monkeypatch.setenv('AWX_WEB_PROCESS', '1')
assert is_web_process() is True

def test_anything_else_is_not(self):
assert is_web_process() is False


class TestWebProcess:
def test_a_web_process_keeps_its_connection(self, databases, monkeypatch):
monkeypatch.setenv('AWX_WEB_PROCESS', '1')

set_conn_max_age(databases)

assert databases['default']['CONN_MAX_AGE'] == DEFAULT_WEB_CONN_MAX_AGE

def test_uwsgi_keeps_its_connection(self, databases, under_uwsgi):
set_conn_max_age(databases)

assert databases['default']['CONN_MAX_AGE'] == DEFAULT_WEB_CONN_MAX_AGE

def test_the_setting_wins_over_the_default(self, databases, monkeypatch):
monkeypatch.setenv('AWX_WEB_PROCESS', '1')

set_conn_max_age(databases, 300)

assert databases['default']['CONN_MAX_AGE'] == 300

def test_the_setting_can_turn_reuse_off(self, databases, monkeypatch):
monkeypatch.setenv('AWX_WEB_PROCESS', '1')

set_conn_max_age(databases, 0)

assert databases['default']['CONN_MAX_AGE'] == 0


class TestEverythingElse:
def test_a_task_process_is_left_alone(self, databases):
set_conn_max_age(databases)

assert 'CONN_MAX_AGE' not in databases['default']

def test_a_task_process_still_honours_the_setting(self, databases):
set_conn_max_age(databases, 120)

assert databases['default']['CONN_MAX_AGE'] == 120


class TestMissingDatabases:
def test_no_databases_at_all(self):
databases = {}

set_conn_max_age(databases, 60)

assert databases == {}

def test_no_default_database(self):
databases = {'other': {}}

set_conn_max_age(databases, 60)

assert databases == {'other': {}}
55 changes: 55 additions & 0 deletions awx/settings/connection_reuse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import os

# Django's default is 0: open a connection for each request and close it when
# the response is sent. For a process that serves one request after another
# that is pure churn, since the next request immediately needs another one,
# and a connection costs several milliseconds to establish before it has run
# any query at all.
#
# Sixty seconds is deliberately short. A worker that goes idle holds its
# connection until its next request notices the age, so the number held at
# rest is the number of web workers, and a short life keeps a connection from
# outliving a network path that has quietly gone away.
DEFAULT_WEB_CONN_MAX_AGE = 60


def is_web_process():
'''
Whether this process serves HTTP.

Under uwsgi the module import answers it, which covers every deployment
today. Anything else says so with AWX_WEB_PROCESS, which the web
supervisor programs set, and which is what will answer it if the server
ever becomes daphne or uvicorn.
'''
if os.environ.get('AWX_WEB_PROCESS'):
return True
try:
import uwsgi # noqa: F401

return True
except ImportError:
return False


def set_conn_max_age(DATABASES, DATABASE_CONN_MAX_AGE=None):
'''
Keep a web process's database connection alive between requests.

Task workers, management commands and migrations are left alone: they hold
one connection for the life of a long process, or want it closed the moment
the command ends, and neither is helped by an age limit.

DATABASE_CONN_MAX_AGE overrides all of it, including for a task process, so
a deployment can turn reuse off with 0 or hold connections for longer.
'''
# If settings files were not properly passed DATABASES could be {} at which point there is nothing to set.
if not DATABASES or 'default' not in DATABASES:
return

if DATABASE_CONN_MAX_AGE is not None:
DATABASES['default']['CONN_MAX_AGE'] = DATABASE_CONN_MAX_AGE
return

if is_web_process():
DATABASES['default']['CONN_MAX_AGE'] = DEFAULT_WEB_CONN_MAX_AGE
6 changes: 6 additions & 0 deletions awx/settings/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@
# the harakiri value. Set this for non-uwsgi deployments or to override.
DATABASE_STATEMENT_TIMEOUT = None

# Optional manual override for how long a database connection is kept and
# reused, in seconds. Unset, a web process reuses one for a minute and every
# other process opens one per request, which is Django's default. 0 turns
# reuse off everywhere.
DATABASE_CONN_MAX_AGE = None

# Special database overrides for dispatcher connections listening to pg_notify
LISTENER_DATABASES = {
'default': {
Expand Down
6 changes: 6 additions & 0 deletions awx/settings/development.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,9 @@
set_statement_timeout(DATABASES, DATABASE_STATEMENT_TIMEOUT) # NOQA

del set_statement_timeout

from .connection_reuse import set_conn_max_age

set_conn_max_age(DATABASES, DATABASE_CONN_MAX_AGE) # NOQA

del set_conn_max_age
6 changes: 6 additions & 0 deletions awx/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@

del set_statement_timeout

from .connection_reuse import set_conn_max_age

set_conn_max_age(DATABASES, DATABASE_CONN_MAX_AGE) # NOQA

del set_conn_max_age

# Deployments that set the former name in /etc/tower/conf.d keep working for a
# release. The installers set this through the API rather than on disk, and the
# stored value is carried over by conf migration 0011, so this covers the hand
Expand Down