С чего всё началось

Когда я пришёл на должность помощника DBA, я довольно быстро обнаружил, как в компании фиксируются изменения схемы боевых баз. Каждую ночь в 00:00 запускался скрипт: он выгружал всю схему Firebird одним файлом через isql -x, а затем разрезал этот файл на части. Кусок, создающий таблицу, попадал в каталог 01_TABLES отдельным файлом, названным по имени таблицы; процедуры — в свой каталог, триггеры — в свой. Получалось дерево, которое затем отправлялось в систему контроля версий.

На первый взгляд, это работает: схема лежит в git, изменения видны диффами. Проблема оказалась в другом. Разработчиков много, правки в схему вносятся вручную, а в истории остаётся один коммит в сутки от служебной учётной записи. Кто изменил процедуру, зачем и в каком порядке шли изменения внутри дня — по такой истории не восстановить. Диффы есть, а ответственности и контекста нет: изменения фактически анонимны.

Разбираться я решил не с середины, а с начала — с того, чем схема вообще снимается с базы.

Почему разрезание одного большого файла рассыпается

Прежде чем писать что‑то своё, я выписал, что именно ломается в подходе «выгрузить монолит и нарезать».

Права и комментарии оказываются не там, где объект. isql -x выдаёт гранты и комментарии единым потоком в конце скрипта. При нарезке они попадают в общие файлы — GRANTS.sql и COMMENTS.sql на всю базу. В результате выдача права на таблицу выглядит в истории как изменение строки в сорокакилобайтном файле, а не как изменение самой таблицы. При ревью такое не заметишь, а именно права чаще всего и меняют «тихо».

Нарезка текста хрупкая по своей природе. Разделитель приходится угадывать по тексту DDL. Тело процедуры с кириллицей в комментариях, вложенные блоки, ключевые слова внутри строковых литералов — каждый такой случай приходится обходить заново. Это парсер SQL, который никто не собирался писать, но который всё равно приходится поддерживать.

Нет атомарности. Если выгрузка оборвалась на середине, в каталоге остаётся неполное дерево. Коммитер это дерево послушно коммитит, и в истории появляются удаления объектов, которых никто не удалял. Ложная запись в журнале изменений хуже отсутствующей: на неё можно опереться в разборе инцидента и прийти к неверному выводу.

Нельзя выгрузить один объект. Чтобы посмотреть актуальный текст одной процедуры в том же формате, что в репозитории, нужно снять схему целиком.

Смешанные кодировки убивают весь дамп. Базам, которые ведут родословную от InterBase, лет больше, чем мне на этой должности. Метаданные в них лежат в однобайтовой кодировке, но отдельные объекты содержат символы, которых в этой кодировке нет. Один такой объект — и монолитная выгрузка падает целиком.

Что я искал и чего не нашёл

Мне нужен был инструмент, который снимает схему поштучно, а не режет текст. Из того, что я нашёл, одни варианты выгружают монолит и оставляют нарезку на потом, другие не умеют точечной выгрузки, третьи по устройству предполагают, что вокруг них уже есть конвейер: они пишут журналы, ходят в git, знают про расписание. Мне же нужен был кирпич, а не дом.

Так появился fb-dump — сокращение от firebird‑dump.

Требование, которое определило всё остальное

Я выбрал микро‑архитектуру: каждый инструмент делает одну вещь и ничего не знает о соседях. Дампер снимает схему, коммитер кладёт дерево в git, планировщик решает, когда всё это запускать, применятор поднимает схему в пустую базу. Любым из них можно пользоваться отдельно, не разворачивая остальные.

Из этого требования вытекли остальные:

  • одна ответственность: база на входе, дерево файлов на выходе, и ничего больше;

  • никаких скрытых побочных эффектов: инструмент не создаёт журналов рядом с собой, не трогает .git, не читает .env;

  • детерминированный вывод: две выгрузки неизменной схемы дают одинаковые байты, иначе диффы забиваются шумом;

  • всё или ничего: неполного результата на диске быть не должно;

  • никакого знания о потребителе: инструменту всё равно, что с деревом произойдёт дальше.

Дисциплина полезная и неудобная одновременно. Каждый раз, когда хочется добавить «ещё маленькую удобную функцию», приходится спрашивать, чья это на самом деле работа. Половину идей я так и отложил в отдельные инструменты.

Как инструмент устроен

fb-dump написан на Python, из зависимостей — только firebird-driver для соединения и firebird-lib для доступа к схеме. isql не используется вовсе.

Ключевая деталь: firebird-lib даёт объектный доступ к системному каталогу. У соединения есть свойство schema, у схемы — коллекции tables, procedures, triggers и прочие, а у каждого объекта — метод get_sql_for, который возвращает его собственный DDL. Тип и имя объекта берутся из каталога, системные объекты отфильтровываются по флагу, а не по списку имён, который пришлось бы поддерживать руками.

Отдельно оговорю то, что сам поначалу считал плюсом: выигрыша в скорости здесь нет. firebird-lib читает метаданные объектов лениво, по одному, так что полная выгрузка базы на тринадцать тысяч объектов занимает у меня около двух минут — isql -x быстрее. Выигрыш в другом: в структуре вывода, в отсутствии парсера текста и в том, что один объект выгружается за секунды, без полного прогона.

Один объект — один файл

Главное решение по формату: файл содержит полное определение объекта. Не только CREATE TABLE, а всё, что к таблице относится. Вот реальный файл из выгрузки:

CREATE TABLE ZAKAZ_SPEC (  DAT_ BAS$DATE NOT NULL,  CARDINDEX BAS$ID,  QUANTITY BAS$SUMMA NOT NULL,  WEEK_NUMBERS BAS$VAR_255,  INROAD BAS$SUMMA
);
ALTER TABLE ZAKAZ_SPEC ADD CONSTRAINT U_ZAKAZ_SPEC_DAT_CARDINDEX  UNIQUE (DAT_,CARDINDEX)  USING ASCENDING INDEX U_ZAKAZ_SPEC_DAT_CARDINDEX;
COMMENT ON COLUMN ZAKAZ_SPEC.DAT_ IS 'date_type=timestamp';
GRANT SELECT ON ZAKAZ_SPEC TO USER AA;

Разберу по частям, потому что здесь несколько намеренных решений.

Первое: столбцы описаны через домены (BAS$DATE, BAS$ID) — так, как они заданы в базе, без разворачивания в базовые типы. Домены выгружаются в свой каталог и остаются отдельными объектами со своей историей.

Второе: ограничения вынесены из CREATE TABLE в именованные ALTER TABLE ... ADD CONSTRAINT. Если завтра ограничение переименуют или изменят набор столбцов, дифф покажет одну инструкцию, а не переписанную с нуля таблицу. NOT NULL при этом остаётся частью описания столбца — в Firebird это его свойство, а не отдельный объект.

Третье, и для меня самое важное: комментарий и грант лежат здесь же. Именно этого не хватало в старой схеме с общими GRANTS.sql и COMMENTS.sql. Теперь выдача права на таблицу — это дифф файла таблицы. Обратная сторона решения: файл перестаёт быть «чистым DDL» и становится описанием объекта целиком. Меня это устраивает, потому что читает файл человек, а применяет — отдельный инструмент.

Грантополучатель, кстати, всегда указан с ключевым словом: TO USER AA, а не TO AA. Firebird при разборе неуточнённого имени сначала ищет роль, и если в базе окажутся пользователь и роль с одинаковым именем, воспроизведение дампа выдало бы права не тому. Мелочь, которая проявляется один раз в жизни и очень некстати.

Каталоги — это данные, а не код

В старом дереве имена каталогов вида 01_TABLES были прошиты в скрипт нарезки. Мне это не нравилось: у разных потребителей разные привычки, кому‑то нужны номера для порядка применения, кому‑то — русские имена, кому‑то плоский каталог без вложенности.

Поэтому раскладка задаётся данными. Есть три готовых набора (с номерами, без номеров, плоский), а если ни один не подходит — небольшой файл TOML:

base = "plain"
[dirs]
table = "Таблицы"
index = "Таблицы/Индексы"
procedure = "Процедуры"

Здесь base — набор, от которого отталкиваемся; категории, которые не упомянуты, берут имена из него. Каталоги можно вкладывать друг в друга, имена — любые, какие принимает файловая система.

Дополнительно каждое дерево несёт файл .fb-dump.toml со своей действующей раскладкой. Это даёт три вещи сразу. Дерево описывает само себя: потребителю не нужно догадываться, где лежат процедуры. Точечная выгрузка в существующее дерево читает этот файл и кладёт объект туда, куда положено, без дополнительных ключей. И он же служит признаком «это дерево сделал я»: в непустой каталог без такого файла инструмент писать откажется, чтобы случайный --out ~/work не стоил вам содержимого каталога.

Всё или ничего

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

Если хотя бы один объект прочитать не удалось — нет прав, странные метаданные, что угодно — инструмент не пишет ничего и возвращает код 3. Логика простая: неполное дерево коммитер превратит в удаления объектов, то есть в ложные записи в истории. Тот, кому неполный результат всё‑таки нужен, просит его явным ключом.

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

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

Одна стрелка против двух тысяч процедур

Самая показательная история вышла с кодировками — на той самой копии боевой базы, ради которой всё и затевалось.

Под UTF-8 чтение падает сразу: метаданные хранятся в WIN1251, и первый же кириллический байт даёт ошибку декодирования. Указываю WIN1251 — и получаю другую ошибку: Cannot transliterate character between character sets. То есть часть метаданных в этой кодировке не представима.

Виновника я нашёл, сравнив выгрузку с исходником: в теле одной процедуры был комментарий с символом . В WIN1251 такого символа нет. А firebird-lib загружает коллекцию одним запросом, целиком — поэтому единственная стрелка в одном комментарии делала нечитаемыми все 2985 процедур базы.

Отсюда появился запасной канал: основная кодировка задаётся ключом, а для коллекции, которая на ней не прочиталась, инструмент лениво открывает второе соединение с другой кодировкой и дочитывает её оттуда, сообщая об этом предупреждением. Ничего не зашито: обе кодировки задаёт пользователь, потому что «WIN1251 против UTF-8» — это моя конкретная база, а не общее правило.

За честность приходится доплачивать оговоркой: строки, дочитанные вторым соединением, прочитаны в другой транзакции и в другой момент. Для дампа схемы это допустимо, но знать об этом нужно, поэтому предупреждение и печатается.

Проверка на живой базе

Офлайн‑тесты — это хорошо, но настоящую проверку даёт только реальная база. Взял копию боевой: 13 315 объектов — 1014 таблиц, 2985 процедур, 2821 триггер, 5540 генераторов, 812 индексов, 78 доменов, 35 исключений, 24 представления, 4 функции, 2 роли.

Полная выгрузка заняла 2 минуты 9 секунд, ни один объект не пропущен, на выходе 13 316 файлов — по одному на объект плюс файл уровня базы с диалектом, кодировкой и правами на уровне базы данных. Три выгрузки подряд дали побайтово одинаковые деревья.

Отдельно я сверил результат с деревом, которое строит наш внутренний инструмент по метаданным: DDL таблицы совпало символ в символ, а в моём файле вдобавок оказались комментарий столбца и грант, которых в старом дереве не было — они лежали в общих файлах.

А потом произошло то, ради чего вообще всё это затевалось. Между двумя выгрузками, сделанными с разницей в четверть часа, дерево изменилось. Из 13 316 файлов различался ровно один:

-         and oh.id = 17515837 --17509513
+         and oh.id = 17515841 -- 17515837 --17509513

Кто‑то правил процедуру прямо в базе: подставил новый идентификатор заказа, а прежний оставил комментарием — рядом с тем, который был до него. Ни задачи, ни автора, ни причины. Именно эти правки я и хочу перестать терять, и именно так они выглядят, когда их наконец видно.

Границы применимости

Чтобы не создавать ложных ожиданий, перечислю, чего инструмент не делает.

Выгрузка не является снимком на один момент времени. Метаданные читаются в транзакции уровня read committed, и правка, закоммиченная посреди прогона, может попасть в одни коллекции и не попасть в другие. Для дампа схемы это приемлемо, но выгружать лучше тогда, когда схему не правят. Выбор изоляции здесь не случаен: у нас действует правило снимать метаданные без ожидания на блокировках, чтобы читатель никогда не подвешивал процесс.

Вывод не совпадает байт в байт с isql -x: firebird-lib расставляет отступы и порядок предложений иначе, оставаясь семантически эквивалентным. Первая выгрузка поверх дерева, сделанного старым способом, даст один большой дифф, и это нормально.

Комментарии к параметрам функций не выгружаются — firebird-lib не предоставляет для них соответствующей операции. Комментарии к параметрам процедур, к столбцам и к самим объектам выгружаются.

Настройка SQL SECURITY снимается только для таблиц; для процедур, функций, триггеров и пакетов библиотека её не читает. Системные привилегии ролей тоже пока не выгружаются.

Теневые копии, BLOB‑фильтры, пользователи и сопоставления имён не считаются здесь объектами схемы. Пользователи вообще живут в отдельной базе безопасности, а не в схеме.

Firebird 2.5 и более ранние версии вне охвата: там другой драйвер и другой системный каталог.

Что дальше

На выходе первой части — самостоятельный инструмент, который снимает схему целиком или поштучно, кладёт её деревом в нужном мне формате и не знает ничего о том, что будет дальше. Помимо конвейера он уже пригодился в другом месте: наш внутренний MCP‑инструмент ищет по метаданным базы, и выгруженное дерево — как раз то, по чему удобно искать.

Во второй части — то, чего дампер принципиально не умеет: он показывает состояние, но не отвечает на вопрос «кто и когда». Речь пойдёт о механизмах внутри самой базы, которые фиксируют изменения схемы в момент, когда они происходят, и становятся тем источником правды, который потом опрашивает следующий инструмент.

Код инструмента открыт, лицензия MIT: fb‑dump. Замечаниям по делу буду рад, в том числе критическим — я здесь скорее в начале пути, чем в конце.