Что осталось за кадром первой части
В первой части был инструмент, который снимает схему Firebird и раскладывает её деревом файлов: один объект — один файл. Он отлично отвечает на вопрос «как эта процедура выглядит сейчас» и совершенно беспомощен в вопросе «кто её поменял в среду вечером». Дамп — это фотография, а нам нужен ещё и вахтенный журнал.
Вторая часть — про журнал. Про то, какие факты об изменении схемы имеет смысл хранить прямо в базе, чем за это платишь и где закопаны грабли, на которые я наступил, чтобы вы могли их обойти.
Сколько правды хранить
Идеальный журнал изменений хранит всё: кто, когда, какой объект и полный текст того, что выполнили. Тогда историю можно восстановить, не выходя из базы.
От полного текста мы отказались. Причина скучная и убедительная: одну и ту же процедуру за сутки правят по десять раз, тело у неё бывает на сотню килобайт, и журнал начинает жить своей жизнью, обгоняя по размеру данные, ради которых база вообще существует. Команда проголосовала за то, чтобы текст не хранить. Демократия в инженерных спорах вещь спорная, но объём BLOB-полей она оценивает трезво.
Плата за это честная, и её надо назвать вслух: внутридневные версии по журналу не восстанавливаются. Если процедуру за день правили пять раз, в git приедет только последнее состояние, а журнал скажет, что правок было пять и кто их автор. Промежуточные тексты не сохранятся нигде.
Впрочем, «нигде» — это некоторое преувеличение. Рядом с базой работает трассировка: fbtrace пишет в лог сами операторы, включая DDL, вместе со временем, пользователем и номером транзакции. То есть текст, которого нет в журнале, чаще всего достать можно — и, что важнее, связать с записью журнала по TX_ID: номер транзакции в обоих местах один и тот же. Для разбора трейс-логов я в своё время написал себе отдельное приложение (если будет время и про него напишу, было бы интересно получить feedback от тех кому оно хотя бы как-то бы помогло, интересно развивать данный проект даже внутри своей команды), в котором это удобно отфильтровать — по транзакции, времени, пользователю или объекту — и посмотреть, что именно выполнялось.
Полагаться на трассировку как на архив всё же нельзя, и в схеме журнала она намеренно не учитывается: это отдельный механизм со своей ротацией и сроком хранения, его можно выключить, и он не обязан пережить перезапуск сервиса. Разделение обязанностей получается такое: журнал отвечает на «кто и когда» всегда, трейс — на «а что именно там было», пока лог не уехал по ротации.
Зато разделение получилось чистое: журнал отвечает на «кто, когда, что за объект», дамп — на «как это теперь выглядит». Ни один из них поодиночке историю не даёт, вместе — дают.
Таблица журнала
У нас все типы заведены доменами, но чтобы скрипт можно было выполнить у себя, привожу его на базовых типах. Заодно избавляю вас от ловушки: домен с именем BAS$INTEGER у нас на самом деле BIGINT, и я до сих пор считаю, что это была не лучшая идея того, кто его заводил, но я это так) никому не в обиду)).
CREATE TABLE DBA$DDL_LOG ( ID BIGINT NOT NULL, CHANGED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, AUTHOR VARCHAR(80) NOT NULL, EVENT_TYPE VARCHAR(30) NOT NULL, OBJECT_TYPE VARCHAR(32) NOT NULL, OBJECT_NAME VARCHAR(80), TX_ID BIGINT NOT NULL, DDL_EVENT VARCHAR(50), REMOTE_ADDR VARCHAR(255), CONNECTION_ID BIGINT, SYNCED CHAR(1) DEFAULT 'N' NOT NULL, SYNC_ATTEMPTS BIGINT DEFAULT 0 NOT NULL, LAST_ERROR BLOB SUB_TYPE TEXT ); ALTER TABLE DBA$DDL_LOG ADD CONSTRAINT PK_DBA$DDL_LOG PRIMARY KEY (ID); ALTER TABLE DBA$DDL_LOG ADD CONSTRAINT CHK_DDL_AUDIT_SYNCED CHECK (SYNCED IN ('Y', 'N')); CREATE SEQUENCE GEN_DDL$AUDIT_ID;
Колонки делятся на четыре группы, и это деление не косметическое: каждая группа отвечает своему потребителю.
Основа — то, ради чего всё затевалось. CHANGED_AT станет датой git-коммита. AUTHOR — это CURRENT_USER, то есть личный логин разработчика; ровно поэтому личные логины важнее общего SYSDBA, под которым живут во многих конторах: из общего логина корректный git blame не получится никогда. EVENT_TYPE хранит действие (CREATE, ALTER, DROP), OBJECT_TYPE — вид объекта, и он же определит каталог в дереве, а OBJECT_NAME — имя файла.
Группировка. TX_ID — идентификатор транзакции, о нём отдельно ниже. DDL_EVENT — полное имя события Firebird вида CREATE PROCEDURE или ALTER CHARACTER SET; в работе конвейера не участвует, но когда что-то идёт не так, читать журнал с ним заметно приятнее.
Следы клиента. REMOTE_ADDR и CONNECTION_ID нужны один раз в год — в тот день, когда объект изменился, автор клянётся, что это не он, и хочется посмотреть, с какого адреса пришло соединение.
Очередь выгрузки. SYNCED, SYNC_ATTEMPTS и LAST_ERROR — это классический outbox: база пишет факты, отдельный процесс их разгребает и отмечает выполненные. Никакой магии, но именно эти три колонки превращают журнал из «посмотреть глазами» в источник для автоматики.
TX_ID, или откуда берутся границы коммита
Самая полезная колонка здесь — TX_ID, значение CURRENT_TRANSACTION. Идея простая: всё, что изменено в одной транзакции, — это один коммит. Разработчик поправил процедуру, добавил столбец в таблицу и обновил представление одним скриптом — в git приедет один коммит с тремя файлами, а не три отдельных, между которыми схема не собирается.
У этой красоты есть условие, о котором надо знать заранее: всё зависит от того, как клиент обращается с автокоммитом. При SET AUTODDL ON каждый оператор получает собственную транзакцию, а значит и собственный TX_ID — и группировка вырождается в «один DDL, один коммит». Это не поломка, это просто другой режим работы, но, увидев в журнале тысячу транзакций подряд по одной строке в каждой, вы теперь знаете, куда смотреть.
Наблюдатель
Фиксирует факты один триггер. DDL-триггеры есть не везде — в MySQL, например, их нет до сих пор, — но Firebird умеет их с третьей версии, и умеет хорошо.
CREATE OR ALTER TRIGGER DBA$DDL_AUDIT ACTIVE BEFORE ANY DDL STATEMENT POSITION 0 AS DECLARE VARIABLE V_OBJ VARCHAR(63); DECLARE VARIABLE V_TYPE VARCHAR(31); BEGIN :V_OBJ = RDB$GET_CONTEXT('DDL_TRIGGER', 'OBJECT_NAME'); :V_TYPE = RDB$GET_CONTEXT('DDL_TRIGGER', 'OBJECT_TYPE'); /* Временные объекты не протоколируем: STARTING WITH — буквальный префикс, '_' в нём не подстановочный знак. */ IF (:V_OBJ STARTING WITH 'T_TORG' OR :V_OBJ STARTING WITH 'TMP_TEXT') THEN EXIT; INSERT INTO DBA$DDL_LOG (ID, CHANGED_AT, AUTHOR, EVENT_TYPE, OBJECT_TYPE, OBJECT_NAME, TX_ID, DDL_EVENT, REMOTE_ADDR, CONNECTION_ID) VALUES (NEXT VALUE FOR GEN_DDL$AUDIT_ID, CURRENT_TIMESTAMP, CURRENT_USER, RDB$GET_CONTEXT('DDL_TRIGGER', 'EVENT_TYPE'), :V_TYPE, :V_OBJ, CURRENT_TRANSACTION, RDB$GET_CONTEXT('DDL_TRIGGER', 'DDL_EVENT'), RDB$GET_CONTEXT('SYSTEM', 'CLIENT_ADDRESS'), CAST(RDB$GET_CONTEXT('SYSTEM', 'SESSION_ID') AS BIGINT) ); END
Читается он ровно так, как выглядит. BEFORE ANY DDL STATEMENT означает «на любое изменение схемы, какое бы оно ни было». Всё, что нужно знать о событии, Firebird кладёт в контекст DDL_TRIGGER, откуда мы и достаём имя объекта, его тип, действие и полное имя события. Дальше — одна вставка.
Единственное место, требующее пояснения, — фильтр. В нашей базе живут временные объекты, которые за сутки успевают родиться и умереть по паре сотен раз. Протоколировать их бессмысленно: журнал распухнет, а в git поедут коммиты про таблицы, которых уже нет. Поэтому имена с известными префиксами отсеиваются сразу. STARTING WITH сравнивает буквальный префикс, и подчёркивание внутри него — обычный символ, а не подстановочный знак, как было бы в LIKE.
Три вещи, за которые пришлось заплатить временем
SQL SECURITY DEFINER на DDL-триггере не работает. Мысль поставить триггеру права определяющего пользователя выглядит здравой ровно до попытки: Firebird отвечает Invalid variant type conversion, и никакого внятного объяснения в сообщении нет. Триггер работает от имени того, кто выполняет DDL, — и это надо просто учитывать при раздаче прав на таблицу журнала.
DDL-триггер нельзя привязать к одной таблице. Конструкция FOR <таблица> относится к DML-триггерам, у DDL-триггера области видимости нет: он видит все события выбранного типа во всей базе. Значит, фильтр по имени объекта пишется в теле — это не костыль, а штатный способ.
Неудавшийся DDL не оставляет следа. Логичный вопрос к триггеру BEFORE: он ведь срабатывает до выполнения — что будет, если сам DDL упадёт? Ничего не будет: Firebird выполняет каждый оператор в неявной точке сохранения, и при ошибке откатывается всё, что оператор успел сделать, включая вставку из триггера. Журнал не врёт и фантомных записей не содержит. Проверяется это за минуту заведомо ошибочным ALTER, и проверить стоит — просто чтобы спать спокойно.
Цена решения, о которой надо знать до внедрения
Триггер пишет в журнал в той же транзакции, что и сам DDL. Это сделано намеренно: автономная транзакция сохранила бы запись даже при откате изменения, и журнал начал бы врать в другую сторону.
Но у этого выбора есть прямое следствие, и оно суровое: если триггер упадёт, в базе перестанет выполняться любой DDL. Не «запись потеряется», а именно перестанет — потому что ошибка внутри триггера валит оператор, который его вызвал. Кончилось место, кто-то дропнул генератор, имя объекта не влезло в колонку — и разработчики хором сообщают, что база сломалась.
Поэтому две вещи стоит сделать до внедрения, а не после. Первое: заранее знать аварийную команду и держать её под рукой.
ALTER TRIGGER DBA$DDL_AUDIT INACTIVE;
Второе: держать тело триггера предельно тощим. Никаких обращений к другим таблицам, никаких вычислений, никаких проверок «на всякий случай». Одна вставка — и всё. Каждая строка, добавленная в этот триггер, — это новый способ остановить работу всей команды.
Сторож для журнала
Журнал, который может незаметно изменить любой желающий, — это не журнал, а черновик. Минимальная защита: не дать поменять структуру таблицы, на которую опирается читающий её модуль.
CREATE OR ALTER EXCEPTION DBA$DDL_LOCKED 'Изменение структуры таблицы DBA$DDL_LOG запрещено.'; CREATE OR ALTER TRIGGER DBA$DDL_GUARD ACTIVE BEFORE ALTER TABLE OR DROP TABLE POSITION 0 AS BEGIN IF (RDB$GET_CONTEXT('DDL_TRIGGER', 'OBJECT_NAME') = 'DBA$DDL_LOG') THEN EXCEPTION DBA$DDL_LOCKED; END
Тот же приём, что и с аудитом: триггер срабатывает на все ALTER TABLE и DROP TABLE в базе, а нужную таблицу отбирает по имени в теле.
Про границы этой защиты стоит сказать честно, потому что выглядит она надёжнее, чем есть. Сторож закрывает таблицу — и только её. DROP TRIGGER DBA$DDL_AUDIT или ALTER TRIGGER DBA$DDL_AUDIT INACTIVE проходят молча, и узнаете вы об этом по подозрительно пустому журналу через неделю. Закрыть и это можно тем же способом — добавить в сторожа события ALTER TRIGGER и DROP TRIGGER с проверкой имени, — но тогда появляется задача о собственной загрузке: чтобы легально обслужить аудит, сторожа придётся снимать первым. Это нормально, пока порядок записан в инструкции, а не живёт в голове одного человека.
И главное: от злого умысла это не защищает вовсе. У разработчиков есть RDB$ADMIN, а с ним снимается любой сторож. Задача этого триггера — защита от случайности, не от намерения.
Индексы и права
Индексов два, и оба под конкретные запросы, а не «на всякий случай».
CREATE INDEX IDX_DDL$AUDIT_SYNC ON DBA$DDL_LOG (SYNCED, ID); CREATE INDEX IDX_DDL$AUDIT_OBJECT ON DBA$DDL_LOG (OBJECT_TYPE, OBJECT_NAME, ID);
Первый обслуживает очередь: коммитер спрашивает «что ещё не выгружено, в порядке появления», то есть отбирает по SYNCED = 'N' и сортирует по ID — порядок колонок в индексе именно такой и именно поэтому. Второй нужен человеку: показать историю изменений конкретного объекта.
Читает журнал отдельный служебный пользователь, и прав у него ровно столько, сколько нужно для работы, — заметьте, обновлять он может три конкретные колонки, а не строку целиком.
CREATE USER SCHEMA_AUDIT_BOT PASSWORD 'куда смотришь? я пароль не выдам!'; GRANT SELECT, UPDATE (SYNCED, SYNC_ATTEMPTS, LAST_ERROR), DELETE ON DBA$DDL_LOG TO USER SCHEMA_AUDIT_BOT;
Право на DELETE здесь не для красоты: журнал придётся чистить, иначе через год он станет самой большой таблицей в базе, и вся экономия на отказ от сохранения текстов пойдёт прахом.
Чего в схеме нет намеренно
Текста DDL и его хеша. Уже обсудили: содержимое берётся выгрузчиком из живой базы, журнал хранит только факты. Цена — невосстановимые внутридневные версии.
Защиты от подделки. Соблазн выстроить роли так, чтобы журнал нельзя было подчистить, велик, но бессмыслен: пока у разработчиков RDB$ADMIN, они могут всё, включая правку журнала. Неизменяемость обеспечивается снаружи — тем, что записи уезжают в git, где история подписана и растёт только вперёд. Если когда-нибудь права разъедутся по отдельным ролям и RDB$ADMIN останется у двоих, разговор о tamper-resistance можно будет начать заново; сейчас это была бы имитация безопасности.
Что дальше
На этом месте у нас есть две половины ответа: дерево файлов со состоянием схемы и журнал фактов о том, кто и когда это состояние менял. Осталось их соединить.
Третья часть — про коммитер: процесс, который читает из журнала невыгруженные строки, группирует их по TX_ID, просит выгрузчик снять именно эти объекты, делает коммит с автором и датой из журнала и только после успешного пуша проставляет SYNCED = 'Y'. Там же выяснится, почему на этом пути столько граблей: объект успели удалить до выгрузки, автор в базе называется не так, как в Gitea, а пуш падает ровно тогда, когда пятая база решила поменять схему одновременно с первой.