Введение
Большой текст пугает: часть читателей уходит на середине, часть пролистывает не читая. Поэтому дальше — только факты, действия и последствия: что изменилось в метаданных, как это изменение попало в production и что в итоге получилось — результат, который до сих пор помогает находить источник проблем.
Наблюдение
Первые два звена работают независимо друг от друга, и это не побочный эффект, а условие, которое я держал в голове с самого начала. Выгрузчик схемы полезен сам по себе: он нужен любому, кто держит схему в git, сравнивает базы между собой или переносит объекты. О конвейере он не знает ничего и прекрасно без него обходится. Журнал фактов — даже не инструмент, а паттерн: таблица и DDL-триггер. Он тоже ценен отдельно. Ответ на вопрос «кто и когда менял схему» нужен дежурному при разборе инцидента независимо от того, есть в конторе git-конвейер или нет. Третье звено устроено иначе: оно существует только затем, чтобы соединить первые два.
Задача
Нужен инструмент, который получает список того, что предстоит зафиксировать, и берёт на себя всю работу с git: собрать файлы, сделать коммиты, отправить их в репозиторий. В ответ — судьба фиксации: прошло или сломалось. Чего он делать не должен: ходить в базу, знать расписание и решать, повторять ли попытку. Строки приходят к нему на вход, флаги в журнале проставляет вызывающая сторона, а с ошибкой разбирается разработчик.
Контракты общения с инструментом
У инструмента два контракта: что он принимает и что возвращает. Оба — JSON. Вход — строки из журнала фактов, которому была посвящена вторая часть. Читать её необязательно: в каждой строке сказано, какой объект изменили, каким действием, кто и когда это сделал и в какой транзакции.
{ "rows": [ { "id": 1005, "changed_at": "2026-06-15T15:24:10.005", "author": "USER-IN-DATABASE", "event_type": "ALTER", "object_type": "PROCEDURE", "object_name": "SOMETHING_PROCEDURE", "tx_id": 1205422 } ]}
Выход — то, что нужно вызывающей стороне, чтобы закрыть цикл: что именно попало в репозиторий и какие строки журнала теперь можно пометить выгруженными.
{ "status": "ok", "mode": "incremental", "pushed": true, "commits": [ {"tx_id": 1205422, "sha": "…", "author_login": "USER-IN-DATABASE", "date": "2026-06-15T15:24:10", "object_count": 1, "files": ["08_PROCEDURES/SOMETHING_PROCEDURE.sql", "…"]} ], "synced_ids": [1004, 1005], "batch_max_id": 1005, "skipped": [{"object_type": "FUNCTION", "object_name": "OLD_UDF", "reason": "файл не найден в дереве"}]}
Большинство полей говорят сами за себя, разберу три. mode — режим прогона. full: выгружается и фиксируется вся схема, нужен при инициализации репозитория. incremental: изменилось несколько объектов, полный дамп не требуется — обычный рабочий режим. refresh: обновляются сквозные вещи — права, комментарии, файл уровня базы. batch_max_id — максимальный id обработанной пачки. synced_ids — строки, которые дошли до репозитория. Возвращаются только при pushed: true: пометить выгруженным то, чего в git нет, означает потерять изменение навсегда.
Как это работает
Шесть шагов: от строк журнала до пуша.
-
Читаем очередь.
WHERE SYNCED = ‘N’ ORDER BY ID. Не по ID > watermark: генератор выдаёт номера вне транзакций, поэтому длинная транзакция коммитится позже короткой, но с меньшим id. При выборке по watermark такие строки теряются навсегда. -
Группируем по tx_id. Одна транзакция — один коммит. Внутри коммита несколько строк по одному объекту схлопываются в последнюю:
CREATE+ALTERдают один файл,ALTER+DROP— удаление. Между коммитами не схлопываем, иначе потеряем историю. -
Определяем судьбу файла.
CREATE/ALTER— выгрузить объект иgit add;DROP—git rm, молча пропустив, если файла в дереве нет. -
Берём содержимое. В журнале его нет — там только факты. Выгружаем точечно те объекты, что названы в строках:
fb-dump -d DSN SOMETHING_PROCEDURE --type procedure -o schemaСекунды вместо полного дампа. Объект могли удалить уже после записи в журнал — это штатная ситуация, а не сбой: строка уходит в skipped. -
Коммитим. Автор и дата берутся из журнала, а не из системного времени:
git commit --author=“ivanovivanov@reid21.ru” --date=“2026-06-15T15:24:10”Пустой diff коммита не создаёт: если объект уже зафиксирован в этом виде, шаг просто пропускается. Отсюда идемпотентность — повторный прогон той же пачки ничего не портит. -
Пушим и отвечаем. Пуш один на всю пачку. synced_ids возвращаются только при pushed: true — иначе вызывающая сторона пометит
SYNCED = ‘Y’то, чего в git нет, и изменение пропадёт. Не прошло — строки остаются в очереди, растёт sync_attempts, текст ошибки ложится в last_error. Инструмент ничего не знает ни проFirebird, ни про расписание: на входе строки, на выходе отчёт. Читать журнал и ставить флаги — дело вызывающей стороны.
Что пошло не так
Объект успел исчезнуть. В журнале «ALTER PROCEDURE X», а к моменту выгрузки процедуры уже нет — за час её удалили. Содержимое брать неоткуда. Это не сбой, а нормальный ход событий: такая строка уходит в skipped, факт остаётся в журнале, коммита не будет.
Автор коммита не тот. В базе логин ANY-USER, в Gitea — any-user. Без таблицы соответствий git заводит нового автора на каждое написание, и git blame показывает призраков вместо людей.
Факт есть, изменений нет. CREATE OR ALTER с тем же телом: событие в журнале появилось, а diff пустой. Коммитить нечего, но строку надо закрыть, иначе она будет всплывать в каждом прогоне. Пустой коммит не делаем — помечаем выгруженной и идём дальше.
Первый прогон. В режиме full тринадцать тысяч объектов приезжают одним коммитом. С этим ничего не поделать, это точка отсчёта, но выглядит устрашающе.
Пуш упал после коммитов. Коммиты уже локальные, а synced_ids возвращать нельзя. Значит инструмент должны быть готовы запустить заново на тех же строках — и он не должен наплодить дублей.
Время. CHANGED_AT — TIMESTAMP без зоны, git ждёт смещение. Пока не договорились считать всё в зоне сервера, даты коммитов разъезжались на несколько часов.
Что получилось
Раньше история схемы выглядела так: один коммит в сутки, автор — служебная учётная запись, внутри вперемешку всё, что за день сделали несколько человек. Вопрос «кто поменял процедуру» упирался в «спросить всех, кто мог».
Теперь так:
Три вещи, которых не было раньше. Автор — живой человек, а не сервисная учётка. Время — момент изменения, а не момент ночного прогона. Транзакция — видно, что процедуру и таблицу правили одним заходом, то есть связанно. Разбор инцидента свёлся к двум командам: git log по файлу объекта и git blame по строке. Дальше идёшь не по людям, а по истории.
У нас принято подписывать правки прямо в коде: рядом с изменённой строкой комментарий, кто её тронул и зачем. Правило хорошее, но держится на дисциплине — а история держится сама.
Недавно перестал работать один механизм. Известно было немного: примерное время, после которого он сломался. Раньше с этого места начинался обход разработчиков — кто что делал на той неделе, кто мог зацепить. Хуже всего, что симптом проявляется не сразу: если между правкой и поломкой прошла неделя, автор уже честно не помнит, что именно менял.
В этот раз хватило истории. Отобрали коммиты по объекту за нужное окно:
$ git log --since=‘2026-06-08’ --until=‘2026-06-10’ – 08_PROCEDURES/CALC_TOTAL.sqlВ одном из них нашлись лишние условия — из-за них механизм и вставал. Имя автора было в самом коммите, спрашивать никого не пришлось. Разбор занял минуты вместо часов.
Обещание из введения закрыто: изменения перестали быть анонимными. Не потому, что кто-то стал аккуратнее заполнять задачи, а потому, что фиксация происходит сама, в момент изменения, и обойти её сложнее, чем не обойти.
Итог
Три звена: (1) выгрузчик снимает состояние, (2) журнал фиксирует факты, (3) коммитер превращает одно в другое и кладёт в git. Каждое работает отдельно и ничего не знает о соседях. Дисциплины это стоило, зато любое из них можно заменить, не трогая остальные.
Вместе они дают то, ради чего всё затевалось: у каждого изменения схемы есть автор, время и границы транзакции. Нерешённое тоже осталось: изменения прав журнал не видит, их по-прежнему ловит только полный дамп или обновление которое происходит в тоже время что и изменения.
На этом серия заканчивается — переход к неанонимным изменениям состоялся. Остался долг: в комментариях к первой части я обещал рассказать про применятор, инструмент, который поднимает схему из дерева в пустую базу. Он будет, но отдельной статьёй. Дерево описывает состояние, а порядок применения в нём не записан — это задача про восстановление, а не про авторство, и в эту серию она не помещается.
ссылка на оригинал статьи https://habr.com/ru/articles/1076518/