В этой статье речь пойдёт о методике разработки в Postgres, которую можно использовать для лучшей интеграции вашего расширения с базой данных. А конкретнее - постараюсь кратко и по существу рассказать о том, как реализовать кастомные свойства объектов БД (a.k.a. custom reloptions), которые можно было бы назначать разным видам объектов БД и таким образом управлять поведением расширения.

Зачем это нужно? Например, для меня как разработчика разного рода костылей и подпорок для оптимизатора Postgres есть прямой интерес в том, чтобы дать пользователю возможность указывать приоритет (а возможно даже и понижающий cost) для индексов - это известная проблема, когда при прочих равных Postgres выбирает первый попавшийся индекс. Поэтому было бы неплохо иметь возможность при помощи расширения добавить команду наподобие:

ALTER TABLE … SET index_weight = ‘..’.

Вероятно, такой степени интеграции с SQL диалектом Postgres добиться сложно, да и едва ли нужно в интересах переносимости. И все же хочется получить возможность задавать параметр при помощи вызова вида:

SELECT my_extension.set_index_weight('index_name', 'value')

таким образом указывая расширению корректировать стоимость планов запросов, использующих даный индекс, и влиять на финальный выбор оптимизатора PostgreSQL.

Предложения о кастомизации свойств некоторых объектов уже несколько раз обсуждались в сообществе (например, см. здесь и здесь), и даже предлагались патчи ядра. Однако их код достаточно объёмен, а область применения узка (или пока это так только выглядит), а это ставит практичность их внедрения в ядро под вопрос. Более того, иногда хочется привязать свойство не к таблице, а, например, к индексу, large object или типу данных - и это вопрос времени, усложнения кода и того факта, что принятый патч изменит поведение только будущих версий ядра СУБД. Однако что делать, если такая функциональность нужна сегодня?

Чтобы понять, как это сделать, определим сперва требования к функциональности:

  1. Свойство должно иметь внутреннюю связь с объектом БД - удаляться вместе с ним по команде DROP .. CASCADE, например.

  2. Свойство должно иметь некоторую ассоциацию с нашим расширением, чтобы СУБД могла корректно обработать ситуацию, когда расширение уже удалено из системы.

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

  4. upgrade, dump/restore должны корректно переносить это свойство вместе с базой.

Конечно, хотелось бы не только транзакционного, но и сессионного поведения, наподобие GUC’ов Postgres - но, полагаю, реализовать это будет намного труднее.

Таким образом, использование простой глобальной хэш-таблицы, в которой ключом выступал бы Oid объекта, нам не подходит, поскольку и значение параметра может быть переменной длины (например, строка), и реализовать связь объекта со свойством достаточно сложно. Но главное - как обеспечить транзакционное поведение и MVCC?

Единственный простой способ удовлетворить пункт 3 - таблица в БД. Может быть, стоит создать таблицу вида <ключ><значение> как часть расширения и добавлять свойство в неё, а расширение будет извлекать из этой таблицы его значение? Вариант! Однако имея обширный опыт использования таблиц в расширениях (см, например, AQO и sr_plan), я могу сказать, что такая история очень сомнительна ввиду того, что вызывает проблемы разного свойства. И это не только задача апгрейда,  dump/restore и репликации, но и постоянной необходимости проверять, что объект существует в базе. А ещё есть оверхед на поиск…

Моя конкретная задача состояла в создании свойства 'delta_apply' для колонки произвольной таблицы. Это свойство предназначено для управления логической репликацией и должно определять механизм разрешения конфликта вида UPDATE/UPDATE для числовых значений. Если это свойство установлено, то subscriber вместо использования различных стратегий разрешения конфликта вычисляет дельту между новым и старым значением столбца и добавляет его к текущему на subscriber'e.

Для такого параметра актуальны все вышеприведенные требования. А для того, чтобы удовлетворить эти требования в PostgreSQL, нашёлся только один механизм - SECURITY LABELS.

Как это работает? Посмотрим на схему:

Имплементация свойств объектов БД Postgres на базе механизма SECURITY LABELs
Имплементация свойств объектов БД Postgres на базе механизма SECURITY LABELs

Решение базируется на использовании системной таблицы pg_seclabel, записи которой by-design привязываются к объектам БД. Добавление, обновление и удаление записи в этой таблице происходит при помощи UTILITY-команды 'SECURITY LABEL ..', что даёт возможность мониторить этот процесс в расширении при помощи utility hook'a.

Инструмент SECURITY LABEL позволяет маркировать объекты многих типов, включая VIEW, и даже функции, и этого должно быть достаточно для большинства задач. Запись о метке содержит указание на Oid объекта и класс объекта - таблица, атрибут, функция и т.д. - вполне удобно. Текстовое поле метки позволяет поместить любой набор данных, которые мы хотим привязать к данному объекту, а поле 'provider' позволяет отличать метки, созданные конкретным модулем.

Для минимизации оверхеда (ходить каждый раз в таблицу дорого, а системный кэш к таблице pg_seclabel пока не добавили) добавляем кэш в виде локальной хэш-таблицы. Невалидные записи в этой таблице удаляются за счёт регистрации RelcacheCallback’a, задача которого - инвалидировать запись при любом вызове ALTER TABLE на наш объект.

Логику добавления записей в кэш можно реализовывать по-разному в зависимости от вариантов предполагаемого использования. Для моей задачи достаточно добавлять запись в этот кэш в момент обращения к объекту в хуке (если вызов приходит из ядра), либо же в UI-функции расширения (если действие инициировано пользователем).

Тонкости реализации добавления и удаления свойства объекта

Для того, чтобы локальные кэши наших свойств сохраняли консистентность, нужно при каждом изменении релевантных объектов отправлять, а в каждом бэкенде - принимать сообщение инвалидации объекта и затирать соответствующую запись в кэше. Если изменение происходит с самим объектом, то, как было сказано ранее, мы обрабатываем это событие в RelcacheCallback'е и помечаем запись как невалидную. Однако что делать, если мы изменяем само свойство - а это по факту просто DML в таблице pg_seclabel. Как вовремя сообщить об этом всем остальным процессам?

В Postgres есть инструмент для отправки invalidation message - CacheInvalidateRelcacheByRelid. Он удобен, однако ограничен объектами из pg_class. Если свойство назначается например на тип данных, данная функция не поможет. Поэтому на практике в момент добавления или удаления security label я инициирую обновление самого объекта, по факту не меняя в нём ничего, а соответствующий invalidation callback делает пометку в локальном кэше расширения.

Расширение предоставляет пользователю интерфейс в виде функции set_property(), которая назначает SECURITY LABEL на указанный объект. В описании метки указывается значение свойства объекта, например: 'delta_apply: true'. Callback seclabel_provider, имплементируемый в расширении при каждом создании метки, контролирует корректность объекта и задаваемого свойства.

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

В коде delta_apply фичи это используется следующим образом (см. для примера коммиты 67bf955, 09d5d7e). Процесс-subscriber логической репликации получает UPDATE-запись, содержащую старое и новое значение строки таблицы. Открывая таблицу, мы одновременно обращаемся к кэшу delta_apply свойства. Если таблица включает в себя один или более таких 'инкрементальных' атрибутов, для каждого из них вычисляется дельта между старым и новым пришедшими значениями и прибавляется к тому значению, что находится в базе subscriber'a. Даже если в соответствии со стратегией разрешения конфликта (например, last-update-wins) будет принято решение отвергнуть входящее обновление, изменения delta_apply колонок тем не менее будут учтены. Таким образом уменьшается вероятность появления конфликта между записями и появляется гарантия, что все обновления инкрементальной колонки будут учтены.

P.S. В ходе подготовки публикации выяснилось, что механизм security labels уже используется похожим образом в проекте AWS pgactive (и, видимо, его предшественника BDR). Изучение реализованного там варианта использования данного механизма можно начать, например, отсюда.

THE END.
Испания, Мадрид, 14 декабря 2025 года.

Комментарии (0)