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

Контекст PostgreSQL внутри транзакции

RLS сравнивает строку с параметром app.organization_id. Осталось определить, кто назначает этот параметр и сколько он живёт. Соединения Npgsql возвращаются в пул, поэтому постоянное состояние сессии не должно хранить компанию текущего HTTP-запроса. После «Севера» то же физическое соединение может обслужить «Юг».

В «Пространстве» ограниченная операция всегда начинается транзакцией. Параметр задаётся с локальным временем жизни, а чтение или запись выполняется на том же соединении до commit либо rollback. Полные определения Database и Session находятся в архиве. Соединения и переходы в этом уроке не выполнялись; показываем ожидаемый контракт.

Локальный параметр заканчивается вместе с транзакцией

Функция set_config принимает имя, значение и признак локального изменения. В нашем случае третий аргумент равен true. Это означает, что состояние относится к текущей транзакции. Документация PostgreSQL 18 о параметрах описывает связь SET LOCAL и set_config.

Ниже — законченный фрагмент открытия сеанса из Database.cs. После него код проверяет организацию и членство, а затем возвращает объект, владеющий соединением и транзакцией. Значение UUID передаётся параметром SQL.

var connection = await source.OpenConnectionAsync(cancellationToken);
var transaction = await connection.BeginTransactionAsync(cancellationToken);
await using var command = new NpgsqlCommand(
    "select set_config('app.organization_id', @organization, true)",
    connection, transaction);
command.Parameters.AddWithValue("organization", organizationId.ToString());
await command.ExecuteNonQueryAsync(cancellationToken);

Если назначить локальный параметр без явной транзакции, отдельная команда завершит свою неявную транзакцию раньше следующего SELECT. В результате RLS не увидит нужного значения. Это частая причина загадочного пустого списка: значение вроде бы было установлено, но его время жизни уже закончилось.

Если назначить параметр как сессионный, он проживёт дольше одной операции. Можно надеяться на сброс состояния драйвером, но контракт приложения лучше строить на собственной явной границе. В коде курса нет No Reset On Close=true и нет зависимости от случайного порядка возвращения соединений в пул. Каждый сеанс назначает область заново.

Все команды идут по одному соединению

Объект Session содержит соединение и транзакцию, а методы QueryAsync и ExecuteAsync создают команды с этими двумя объектами. Если после set_config выполнить SELECT через новый NpgsqlDataSource.CreateCommand, источник может выбрать другое соединение. Параметр в первом соединении тогда не ограничит вторую команду.

По этой причине обработчик не смешивает прямые команды источника данных с командами сеанса. Неограниченные запросы допускаются только для технического разрешения области: найти членство или маршрут приглашения. После получения tenant-контекста прикладная операция использует единый Session. Такое правило проще увидеть при чтении кода, чем отслеживать десятки скрытых открытий соединений.

Транзакция также связывает изменение проекта с аудитом. После выполнения обеих команд обработчик вызывает CommitAsync. Если произошло исключение или ранний отказ, освобождение сеанса выполняет rollback. Возврат соединения в пул происходит после завершения транзакции, а не до него. В полном коде обработана и ошибка на этапе открытия, чтобы незавершённое соединение не осталось висеть.

Обратите внимание на CancellationToken. Отмена HTTP-запроса передаётся операциям базы и освобождению ресурсов. Она не гарантирует, что commit не успел завершиться. Если ответ потерялся после фиксации, состояние уже сохранено. Именно поэтому версия и, позднее, идемпотентность задают поведение повторов отдельно от отмены сетевого ожидания.

Повторная проверка роли рядом с данными

Первоначальный фильтр создаёт HTTP-контекст, но транзакция перечитывает текущее членство. Для этого она блокирует строку организации, проверяет её состояние и затем читает роль участника. Пишущие операции требуют активной компании, чтения допускают read_only. Deleted не допускается ни в одном обычном пользовательском сеансе.

Блокировки берутся в общем порядке. Чтение использует совместимую блокировку организации, а изменение состава и жизненного цикла — исключительную. Так отзыв членства, изменение роли и остановка записи получают согласованный порядок с прикладными действиями. Нельзя открывать исключительную блокировку каждой компании для любого списка: тогда обычные чтения начали бы необоснованно мешать друг другу.

Роль из TenantContext нужна для предварительного отображения и диагностики. Финальное разрешение операции определяется текущей строкой в транзакции. Это исключает случай, когда старый scoped-контекст продолжает считать человека editor после уже завершённого понижения. Сохранённая роль не становится долговечным пропуском только потому, что её однажды получил сервер.

Системная операция открывает другой вид сеанса. У worker нет HTTP-пользователя, поэтому он не подделывает Subject Анны. Он получает компанию из задания и проверяет допустимость задания собственным контрактом. Возможность назначить любую компанию делает этот код привилегированной частью сервиса: его нельзя вызывать публичным маршрутом, принимающим свободный UUID.

Два запроса на одном физическом соединении

Представьте пул размером один. Анна читает «Север», затем Борис читает «Юг». В правильном контракте каждая транзакция назначает свой UUID, поэтому Борис не видит старые строки. Пул влияет на число соединений и задержку, но не на смысл области. Этот мысленный сценарий полезен даже без фактического запуска.

Для ручного наблюдения можно открыть две последовательные транзакции ролью space_app, назначать разные параметры и выполнять SELECT без WHERE. Ожидаемые наборы строк должны смениться. После rollback первой транзакции второй запрос должен вести себя так же, как после commit: отмена данных не должна оставлять tenant-состояние.

Если SELECT неожиданно возвращает ноль строк, сначала проверьте одно соединение, наличие транзакции и значение параметра внутри неё. Не отключайте RLS ради получения «правильного» списка. Отключение уберёт симптом, но не восстановит контракт: следующая запись может оказаться в неверной области.

Сеанс не сохраняют для будущей задачи

Нельзя положить Session в очередь либо передать его другому потоку после возврата HTTP-ответа. Его соединение и транзакция принадлежат одной операции и будут освобождены. Фоновая работа хранит идентификаторы и открывает свои ресурсы позже. Если функция возвращает данные из сеанса, материализуйте результат до освобождения reader. Ленивое перечисление строк после Dispose не продолжает транзакцию и способно закончиться ошибкой в неожиданном месте сериализации ответа.

Теперь у контекста есть ограниченное время жизни и явный владелец ресурсов. Следующий урок переносит ту же модель в фоновые задания. Там не будет HTTP-запроса, но компания по-прежнему должна сопровождать каждую команду и результат.

Оглавление серии