Что осталось за кадром первой части

В первой части был инструмент, который снимает схему 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, а пуш падает ровно тогда, когда пятая база решила поменять схему одновременно с первой.

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