Перейти к содержанию

Схема на арендатора

При схеме на арендатора (PostgreSQL) таблицы части моделей у каждого арендатора лежат в его собственной схеме базы — tenant_acme.invoice, tenant_7.invoice, — а остальные модели держат одну таблицу на всех арендаторов в собственной схеме подключения (public). Запрос не несёт фильтра по арендатору: схему выбирает активная Tenancy.scope(tenant), а подключение попадает в неё через путь поиска схем PostgreSQL (search_path). Это другой способ, чем Meta.tenant_field (колонка арендатора в общей таблице): строки арендаторов разделены на уровне базы, данные арендатора удаляются одним DROP SCHEMA, а каждый индекс и каждое ограничение — свои у каждого арендатора.

Настройка

Подключение называет схемы арендаторов шаблоном tenant_schema_template — строчные латинские буквы, цифры и _ вокруг одного {tenant}:

config = {
    "connections": {
        "default": {
            "engine": "postgresql",
            "credentials": {
                "host": "localhost", "database": "app", "user": "app", "password": "...",
                "tenant_schema_template": "tenant_{tenant}",
            },
        },
    },
    "apps": {"billing": {"models": ["billing.models"], "default_connection": "default"}},
}

В адресе подключения это параметр с закодированными скобками: postgresql://app@localhost/app?tenant_schema_template=tenant_%7Btenant%7D. Любой другой шаблон (без подстановки, с двумя подстановками, с заглавными буквами, с -) даёт ConfigurationError при настройке подключения. В SQLite и ClickHouse схем на арендатора нет — Features.supports_tenant_schemas равен False, и TenantSchemas даёт UnSupportedError до отправки SQL.

Модель, таблица которой лежит в схеме каждого арендатора, объявляет Meta.tenant_schema = True:

class Customer(Model):          # общая — одна таблица в "public"
    id = fields.IntField(primary_key=True)
    name = fields.CharField(max_length=100)


class Invoice(Model):           # своя таблица в схеме каждого арендатора
    id = fields.IntField(primary_key=True)
    customer = fields.ForeignKeyField("billing.Customer", related_name="invoices")
    total = fields.DecimalField(max_digits=12, decimal_places=2)

    class Meta:
        tenant_schema = True
  • Его нельзя задать вместе с Meta.schema (ConfigurationError): схема таблицы — схема активного арендатора.
  • Модель арендатора может ссылаться на общую модель (Invoice.customer выше): её внешний ключ в схеме каждого арендатора ссылается на общую таблицу. У общей модели не может быть ForeignKeyField, OneToOneField или ManyToManyField на модель арендатора — на таблицу какого арендатора она бы ссылалась? — ConfigurationError называет поле; объявите связь на модели арендатора.
  • Удаление общей строки — delete(), hard_delete(), QuerySet.delete(), в области арендатора или вне любой — выполняет on_delete строк арендаторов, ссылающихся на неё, в схеме каждого арендатора, в одной транзакции: их CASCADE и мягкое удаление, SET_NULL, а строка PROTECT любого арендатора даёт ProtectedError до того, как что-либо записано. restore(cascade=True) возвращает строки, удалённые вместе с ней, в схеме каждого арендатора, а delete_preview() считает и перечисляет их все. Транзакция обходит схемы по очереди (SET LOCAL search_path), поэтому удаление читает список схем арендаторов и стоит нескольких запросов на каждого арендатора. Жёсткое удаление, которое во всех схемах выполняет собственный ON DELETE базы, не заходит ни в одну — только ради связи PROTECT, связи db_constraint=False, захвата изменений или переопределённого delete() модели арендатора. Схема без таблицы модели (созданная до её миграции) не хранит её строк и пропускается.
  • Модель с Meta.tenant_schema на подключении без tenant_schema_template даёт ConfigurationError на первом запросе и при создании её таблиц.

Значения арендатора

Схема арендатора — шаблон, в котором {tenant} заменён значением арендатора: int (7 → tenant_7) или строкой из строчных латинских букв, цифр и _ ("acme" → tenant_acme). Любое другое значение — "Acme", "acme-1", "", UUID (передавайте uuid.hex), float — даёт ValidationError, как и имя схемы длиннее 63 байт PostgreSQL. Значение не приводится к другому виду: "Acme" отклоняется, а не превращается в схему другого арендатора. 7 и "7" — одна схема.

Создание, удаление и список арендаторов

hare.models.tenancy.tenant_schemas.TenantSchemas:

from hare.models.tenancy.tenant_schemas import TenantSchemas

await TenantSchemas.create("acme")                      # CREATE SCHEMA tenant_acme + таблицы всех моделей с tenant_schema
await TenantSchemas.create(7, create_tables=False)       # только схема — таблицы создаст `migrate`
await TenantSchemas.get_tenants()                       # ["7", "acme"] — схемы, имена которых даёт шаблон, текстом
await TenantSchemas.drop("acme")                        # DROP SCHEMA tenant_acme CASCADE — все таблицы и строки арендатора

Каждый метод принимает using= — имя подключения, необязательное при одном подключении; create() возвращает имя схемы. Они работают вне области арендатора и вне транзакции — внутри дают QueryError (схема, созданная в транзакции, ещё не видна собственному подключению арендатора). create() с create_tables=True (по умолчанию) создаёт таблицы по текущим моделям, как generate_schemas(), — для проекта без миграций и для тестов. Проект с миграциями создаёт схему с create_tables=False и запускает migrate, который доводит до актуального состояния схему каждого арендатора (Миграции). get_tenants() перечисляет схемы, имя которых даёт шаблон, поэтому схема tenant_beta, созданная вручную, считается арендатором "beta". Запрос в области арендатора, схемы которого нет, даёт ConfigurationError с её именем, пока схема не создана.

Запросы

Внутри Tenancy.scope(tenant) одного арендатора каждый запрос подключения выполняется клиентом схемы этого арендатора: у его соединений путь поиска tenant_<tenant>, public (вместо public — собственная настройка schema подключения, если она задана). Модель арендатора читает и пишет таблицу своего арендатора; общая модель находится в public через тот же путь поиска — соединения таблиц двух видов (select_related("customer")) обычные.

with Tenancy.scope("acme"):
    await Invoice.objects.create(id=1, customer=customer, total=10)
    await Invoice.objects.select_related("customer").all()      # tenant_acme.invoice JOIN public.customer
with Tenancy.scope(7):
    await Invoice.objects.count()                                # tenant_7.invoice — 0
  • Область, заданная по моделям, работает в схеме того единственного арендатора, которого она даёт моделям подключения с Meta.tenant_schema, — Tenancy.scope({Invoice: "acme", Order: Tenancy.any_of("msk", "kzn")}) читает tenant_acme.invoice, а общую таблицу Order — по её собственной записи. Модель арендатора берёт там одно значение (несколько или Tenancy.ALL дают QueryError при входе в область); значения, называющие одну схему (7 и "7"), и здесь означают одного арендатора. Область, называющая разные схемы для моделей арендатора одного подключения, даёт QueryError на запросе этого подключения — оно работает в одной схеме.
  • Модель арендатора без области, в области нескольких арендаторов (Tenancy.any_of(...)), в Tenancy.ALL или в области по моделям, не называющей ни одной модели арендатора подключения, даёт QueryError — её таблица доступна только в области одного арендатора. Общая модель работает в любой из них через собственного клиента подключения.
  • QuerySet выполняется в схеме арендатора, область которого действует в момент его ожидания (await), — так же, как QuerySet модели с Meta.tenant_field фильтрует по этому арендатору: построенный внутри Tenancy.scope("acme") и дождавшийся внутри Tenancy.scope(7), он читает tenant_7, а без области даёт QueryError. Запросы, которые он выполняет при этом (prefetch_related(), подзапрос), идут в ту же схему, как и у маршрутизатора.
  • Клиент схемы каждого арендатора — отдельный пул соединений, открывается при первом запросе и закрывается вместе с подключением (Hare.close_connections()). Его соединение, не использованное дольше max_inactive_connection_lifetime подключения (по умолчанию 300 секунд), закрывается, поэтому сервер держит сессии арендаторов, к которым обращались недавно, а не всех, к которым обращались когда-либо. Внутри области его даёт Connections.get(connection_alias); ConnectionHandler.get_main(connection_alias) всегда даёт само подключение.
  • При tenant_schema_template значение Tenancy.scope() должно быть допустимым значением арендатора этого подключения даже для запроса к общей модели — область "Acme" даёт ValidationError на первом запросе подключения.
  • Meta.tenant_field работает рядом: модель арендатора может объявить и колонку арендатора — она фильтруется внутри её схемы как обычно.

Транзакции

Транзакция работает в схеме арендатора, активного при её начале: все её запросы идут через одно соединение. Внутри неё запрос в области другого арендатора или без арендатора, если она началась в схеме арендатора (и наоборот), даёт QueryError, а не выполняется молча в схеме транзакции:

with Tenancy.scope("acme"):
    async with Transactions.atomic():
        await Invoice.objects.create(...)              # tenant_acme
        with Tenancy.scope("beta"):
            await Invoice.objects.count()              # QueryError — транзакция работает в tenant_acme

Транзакция, начатая в области по моделям, работает в схеме арендатора, которого область даёт моделям арендатора; запрос в ней в области другой схемы так же даёт QueryError. Transactions.autonomous() внутри области открывает своё соединение в схеме того же арендатора; каскад удаления по схемам всех арендаторов, изменение, записанное в outbox схемы арендатора, и migrate работают в области по моделям так же, как в области одного арендатора.

Миграции

migrate на подключении с tenant_schema_template работает в два шага:

  1. Общая схема — с журналом public.hare_migrations. Операции над таблицами общих моделей выполняются, над таблицами моделей с Meta.tenant_schema — пропускаются.
  2. Схема каждого арендатора (схемы из TenantSchemas.get_tenants()), по порядку имён схем — каждая со своим журналом tenant_<tenant>.hare_migrations, внутри Tenancy.scope(tenant) (значение арендатора текстом). Операции над таблицами моделей арендатора выполняются, над таблицами общих моделей — пропускаются.

Поэтому арендатор, добавленный позже (create(..., create_tables=False)), получает все миграции при следующем migrate, а остальные — только недостающие. Состояние миграций одно для всех схем — CreateModel(..., options={"tenant_schema": True}) записывает опцию. Python API MigrationExecutor.migrate(targets) принимает и одноразовые итераторы: цели сохраняются, чтобы каждая схема мигрировала до одних и тех же запрошенных миграций.

  • Операция над таблицей модели (CreateModel, AddField, AddIndex, представление или политика, объявленные на модели, ...) следует Meta.tenant_schema модели.
  • Операция, не привязанная к модели, — RunSQL, RunPython, CreateExtension, CreateSchema, тип-перечисление — выполняется в общей схеме. RunSQL(..., tenant_schema=True) и RunPython(..., tenant_schema=True) вместо этого выполняются в схеме каждого арендатора; функция такого RunPython вызывается по разу на арендатора, в его области, поэтому её запросы к моделям арендатора попадают в его таблицу.
  • Внутри Tenancy.scope(tenant) migrate доводит до цели только схему этого арендатора — общая схема и остальные арендаторы не меняются. Область по моделям мигрирует схему арендатора, которого она даёт моделям подключения с Meta.tenant_schema; если ни одну из них не называет, migrate идёт как без области.
  • migrate до app.zero откатывает и общую схему, и схему каждого арендатора.
  • sqlmigrate показывает SQL всех операций — и общих, и арендаторских — как для одной схемы.
  • Смену Meta.tenant_schema у существующей модели makemigrations отклоняет (ConfigurationError): её строки переехали бы между общей схемой и схемами всех арендаторов. Напишите миграцию вручную — новая модель, RunPython, копирующий строки, затем удаление старой.
  • hare drift сравнивает общие модели в общей схеме; detect_drift() с клиентом арендатора (Connections.get("default").get_schema_client("tenant_acme")) сравнивает модели с Meta.tenant_schema в схеме этого арендатора. Ни тот, ни другой не считают таблицы другой стороны отсутствующими, а внешний ключ модели арендатора на общую модель читается, в какой бы схеме после схемы арендатора она ни была.

Колонка арендатора или схема на арендатора

Meta.tenant_field Meta.tenant_schema
Где строки арендатора одна общая таблица, колонка арендатора таблица в схеме арендатора
Разделение фильтр ORM база: таблицы другого арендатора нет на пути поиска
Запросы по нескольким арендаторам Tenancy.any_of(...), Tenancy.ALL не через ORM — одна область, один арендатор
Удаление арендатора DELETE по колонке арендатора TenantSchemas.drop(tenant)
Много арендаторов одна таблица на всех свой набор таблиц и индексов и свой пул соединений у каждого арендатора
Базы данных все диалекты PostgreSQL