Настройка схемы PostgreSQL для стандартной Django DB, работающей с пулом соединений PgBouncer

Мне нужно установить схему БД по умолчанию для проекта Django, чтобы все таблицы всех приложений (включая приложения сторонних разработчиков) хранили свои таблицы в настроенной схеме PostgreSQL.

Одним решением является использование опции подключения к БД, например, такой:

# in Django settings module add "OPTIONS" to default DB, specifying "search_path" for the connection

DB_DEFAULT_SCHEMA = os.environ.get('DB_DEFAULT_SCHEMA', 'public')  # use postgresql default "public" if not overwritten

DATABASES['default']['OPTIONS'] = {'options': f'-c search_path={DB_DEFAULT_SCHEMA}'}

Это работает при прямом подключении к PostgreSQL, но не работает при подключении к PgBouncer (для использования пулов соединений), сбой с OperatonalError: unsupported startup parameter: options". Похоже, что PgBouncer не распознает options как параметр запуска (на данный момент).

Другим решением для установки схемы без использования параметров запуска является префикс всех таблиц со схемой . Чтобы убедиться, что это работает и для встроенных и сторонних приложений (а не только для моего собственного приложения), решение состоит в том, чтобы вводить имя схемы в атрибут db_table всех моделей, когда они загружаются Django, используя сигнал class_prepared и AppConfig. Этот подход близок к тому, что используют проекты типа django-db-prefix, только нужно убедиться, что имя схемы хорошо закавычено:

from django.conf import settings
from django.db.models.signals import class_prepared


def set_model_schema(sender, **kwargs):
    schema = getattr(settings, "DB_DEFAULT_SCHEMA", "")
    db_table = sender._meta.db_table
    if schema and not db_table[1:].startswith(schema):
        sender._meta.db_table = '"{}"."{}"'.format(schema, db_table)


class_prepared.connect(set_model_schema)

Это работает и для пулов соединений, однако это не очень хорошо работает с миграциями Django. Используя это решение, python manage.py migrate не работает, потому что команда миграции проверяет существование django_migrations таблицы, путем интроспекции существующих таблиц , на которые db_table префикс models не влияет.

Мне интересно, каким может быть правильный способ решения этой проблемы.

Вот решение, к которому я пришел. Смешивая оба решения выше, используя 2 отдельных подключения к БД.

  • Использование параметров запуска соединения (что хорошо работает для приложений и миграций), но только для запуска миграций, а не сервера приложений. Это означает, что миграции Django должны подключаться к PostgreSQL напрямую, а не через PgBouncer, что для моего случая вполне подходит.

    .
  • Префиксация таблиц БД со схемой с помощью обработчика сигнала class_prepared, но исключая django_migrations таблицу. Обработчик регистрируется в приложении Django (скажем, django_dbschema) с помощью метода AppConfig.__init__(), который является первым этапом процесса инициализации проекта , поэтому все остальные приложения оказываются затронутыми. Для отметки обхода этой регистрации используется переменная окружения, которая устанавливается при запуске миграций. Таким образом, когда приложение запускается для обслуживания запросов, оно может подключиться к PgBouncer так же хорошо, но Django migrations не знает о префиксах схемы.

    .

Две переменные окружения (используемые модулем настроек Django) будут использоваться для настройки этого поведения: DB_DEFAULT_SCHEMA - имя схемы, и DB_SCHEMA_NO_PREFIX - флаг отключения регистрации обработчика сигналов. Это будет выглядеть следующим образом:

Структура приложения django_dbschema (в корне проекта)

django_dbschema/
├── apps.py
├── __init__.py

где apps.py определяет обработчик сигнала и AppConfig для его регистрации:

from django.apps import AppConfig
from django.conf import settings
from django.db.models.signals import class_prepared


def set_model_schema(sender, **kwargs):
    """Prefix the DB table name for the model with the configured DB schema.
    Excluding Django migrations table itself (django_migrations).
    Because django migartion command directly introspects tables in the DB, looking
    for eixsting "django_migrations" table, prefixing the table with schemas won't work
    so Django migrations thinks, the table doesn't exist, and tries to create it.

    So django migrations can/should not use this app to target a schema.
    """

    schema = getattr(settings, "DB_DEFAULT_SCHEMA", "")
    if schema == "":
        return
    db_table = sender._meta.db_table
    if db_table != "django_migrations" and not db_table[1:].startswith(schema):
        # double quotes are important to target a schema
        sender._meta.db_table = '"{}"."{}"'.format(schema, db_table)


class DjangoDbschemaConfig(AppConfig):
    """Django app to register a signal handler for model class preparation
    signal, to prefix all models' DB tables with the schema name from "DB_DEFAULT_SCHEMA"
    in settings.

    This is better than specifying "search_path" as "options" of the connection,
    because this approach works both for direct connections AND connection pools (where
    the "options" connection parameter is not accepted by PGBouncer)

    NOTE: This app defines __init__(), to register class_prepared signal.
    Make sure no models are imported in __init__. see
    https://docs.djangoproject.com/en/3.2/ref/signals/#class-prepared

    NOTE: The signal handler for this app excludes django migrations,
    So django migrations can/should not use this app to target a schema.
    This means with this enabled, when starting the app server, Django thinks
    migrations are missing and always warns with:
    You have ... unapplied migration(s). Your project may not work properly until you apply the migrations for ...
    To actually run migrations (python manage.py migrate) use another way to set the schema
    """

    name = "django_dbschema"
    verbose_name = "Configure DB schema for Django models"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        schema = getattr(settings, "DB_DEFAULT_SCHEMA", "")
        if schema and not getattr(
            settings, "DB_SCHEMA_NO_PREFIX", False
        ):  # don't register signal handler if no schema or disabled
            class_prepared.connect(set_model_schema)

Это приложение зарегистрировано в списке INSTALLED_APPS (мне пришлось использовать полный путь к классу в конфиге приложения, иначе Django не загрузил бы мое определение AppConfig).

Также модуль настроек Django (скажем settings.py), определит 1 дополнительное соединение с БД (копия default), но с опциями соединения:

# ...

INSTALLED_APPS = [
    'django_dbschema.apps.DjangoDbschemaConfig',  # has to be full path to class otherwise django won't load local app
    'django.contrib.admin',
    # ...
]

# 2 new settings to control the schema and prefix
DB_DEFAULT_SCHEMA = os.environ.get('DB_DEFAULT_SCHEMA', '')
DB_SCHEMA_NO_PREFIX = os.environ.get('DB_SCHEMA_NO_PREFIX', False)  # if should force disable prefixing DB tables with schema

DATABASES = {
    'default': {  # default DB connection definition, used by app not migrations, to work with PgBouncer no connection options
        # ...
    }
}


# default_direct: the default DB connection, but a direct connection NOT A CONNECTION POOL, so can have connection options

DATABASES['default_direct'] = deepcopy(DATABASES['default'])

# allow overriding connection parameters if necessary
if os.environ.get('DIRECT_DB_HOST'):
    DATABASES['default_direct']['HOST'] = os.environ.get('DIRECT_DB_HOST')
if os.environ.get('DIRECT_DB_PORT'):
    DATABASES['default_direct']['PORT'] = os.environ.get('DIRECT_DB_PORT')
if os.environ.get('DIRECT_DB_NAME'):
    DATABASES['default_direct']['NAME'] = os.environ.get('DIRECT_DB_NAME')

if DB_DEFAULT_SCHEMA:
    DATABASES['default_direct']['OPTIONS'] = {'options': f'-c search_path={DB_DEFAULT_SCHEMA}'}

# ...

Теперь установим переменную окружения DB_DEFAULT_SCHEMA=myschema и настроим схему. Для запуска миграций мы установим соответствующую переменную окружения и явно используем прямое подключение к БД:

env DB_SCHEMA_NO_PREFIX=True python manage.py migrate --database default_direct

Когда сервер приложений запустится, он будет использовать соединение с БД по умолчанию, которое работает с PgBouncer.

Минусом является то, что поскольку Django migrations исключен из обработчика сигналов, он будет думать, что миграции не были запущены, поэтому он всегда предупреждает об этом:

"У вас есть ... непримененные миграции. Ваш проект может работать неправильно, пока вы не примените миграции для ..."

Что не так, если мы убедимся, что мы действительно запускаем миграции всегда перед запуском сервера приложений.

Побочное замечание об этом решении заключается в том, что теперь проект Django имеет несколько настроек подключения к БД (если раньше не имел). Поэтому, например, DB миграции должны были быть написаны для работы с явным соединением, а не полагаться на default соединение. Например, если в миграции используется RunPython, она должна передавать соединение (schema_editor.connection.alias) менеджеру объектов при запросе. Например:

my_model.save(using=schema_editor.connection.alias)
# or
my_model.objects.using(schema_editor.connection.alias).all()
Вернуться на верх