Миграции при выкате: одноразовая джоба и обратная совместимость

выкладка≈ 17 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

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

Разделы темы:·

Раннер миграций — это библиотека: функция, которой дают подключение и список шагов. Вопрос выката в другом — где её позвать. Ответов три, и они не равноценны: рука в ssh работает ровно до первого выката, который делает не автор, и до первой ночи, когда команду забыли; старт приложения выглядит красивее всего; одноразовая джоба требует отдельного сервиса в compose и отдельного образа.

Старт приложения соблазняет тем, что ничего не надо заводить: пара строк перед listen, и схема всегда свежая. Беда в том, что приложение бывает не одно. Копий бывает две — потому что одного процесса стало мало; бывает три — потому что обновление идёт без простоя; бывает одна, но запущенная дважды: docker compose run api … добавляет к живому контейнеру ещё один, с тем же кодом и той же базой. А раннер читает журнал и ставит отметку двумя отдельными запросами — между ними есть щель.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 28 сентября 2026
Два раннера, успевших прочитать журнал прежде, чем первый поставил отметку, и те же два под общим замком базы
import pg from 'pg';

// адрес базы — первой строкой и без пароля: видно, куда ушли запросы
const url = new URL(process.env.DATABASE_URL);
url.password = '';
console.log('база:', url.href);

const подключить = async () => {
  const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
  await client.connect();
  return client;
};

const ИМЯ = '0001_notes';
const ШАГ = 'CREATE TABLE notes (id serial PRIMARY KEY, body text NOT NULL)';

/*
 * Раннер главы 9: прочитать журнал, применить непомеченное, поставить отметку
 * в одной транзакции с шагом. Разница одна — таких раннеров здесь два, как их
 * получается два, когда миграцию применяет старт приложения.
 */
async function выкатить(имя, client, { замок, старт, перед } = {}) {
  if (старт) await старт();
  await client.query('BEGIN');
  // общий замок базы: число — просто ключ, важно, что он один у всех копий
  if (замок) await client.query('SELECT pg_advisory_xact_lock(4242)');
  const { rowCount } = await client.query('SELECT 1 FROM __migrations WHERE name = $1', [ИМЯ]);
  console.log(`  ${имя}: отметок про ${ИМЯ} в журнале — ${rowCount}`);
  if (перед) await перед();
  if (rowCount) {
    await client.query('ROLLBACK');
    console.log(`  ${имя}: применять нечего`);
    return;
  }
  try {
    await client.query(ШАГ);
    await client.query('INSERT INTO __migrations (name) VALUES ($1)', [ИМЯ]);
    await client.query('COMMIT');
    console.log(`  ${имя}: применил ${ИМЯ}`);
  } catch (err) {
    await client.query('ROLLBACK');
    console.log(`  ${имя}: ${err.code} ${err.message}`);
  }
}

/** Веха: одна сторона её отмечает, другая на ней ждёт. */
const веха = () => {
  let отметить;
  const пройдена = new Promise((готово) => (отметить = готово));
  return { пройдена, отметить };
};

const служебный = await подключить();
const A = await подключить();
const B = await подключить();

// журнал раннер заводит сам при первом выкате (глава 9); спор идёт не о нём
await служебный.query('CREATE TABLE __migrations (name text PRIMARY KEY, at timestamptz NOT NULL DEFAULT now())');

const состояние = async () => {
  const { rows: отметки } = await служебный.query('SELECT name FROM __migrations ORDER BY name');
  const { rows: таблицы } = await служебный.query(
    "SELECT table_name AS имя FROM information_schema.tables WHERE table_schema = 'public' ORDER BY 1",
  );
  console.log('  журнал:', отметки.map((строка) => строка.name).join(', ') || 'пусто');
  console.log('  таблицы:', таблицы.map((строка) => строка.имя).join(', '));
};

/*
 * Вехами задан порядок: в проде его решает случай, а записанный вывод обязан
 * совпадать при каждом прогоне. Обе копии успевают прочитать журнал прежде,
 * чем первая применит шаг, — и только это здесь от случая и требуется.
 */
console.log('--- два раннера, у каждого своя транзакция');
const прочитанA = веха();
const дошлаA = веха();
const первая = выкатить('A', A, { перед: прочитанA.отметить });
const вторая = выкатить('B', B, { старт: () => прочитанA.пройдена, перед: () => дошлаA.пройдена });
await первая;
дошлаA.отметить();
await вторая;
await состояние();

// вернём базу в то состояние, в котором её застали оба раннера
await служебный.query('DROP TABLE notes');
await служебный.query('DELETE FROM __migrations');

console.log('--- те же два раннера под общим замком базы');
const замокВзят = веха();
await Promise.all([
  выкатить('A', A, { замок: true, перед: замокВзят.отметить }),
  выкатить('B', B, { замок: true, старт: () => замокВзят.пройдена }),
]);
await состояние();

await служебный.end();
await A.end();
await B.end();
node runners.mjs

В первой половине обе копии увидели пустой журнал и обе пошли применять первый шаг. Второй досталось 42P07 relation "notes" already exists: имя таблицы занял тот, кто зафиксировался первым. В журнале при этом ровно одна отметка — база не дала испортить схему, потому что шаг оказался CREATE TABLE. Если бы шагом был UPDATE, вторая копия применила бы его второй раз, и никакая база этому не помешала бы.

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

Первое: отказ миграции должен остановить выкат, а не превратиться в цикл рестартов. Контейнер, упавший на миграции, docker поднимет снова, и дальше одно из двух: дело было в гонке — вторая попытка увидит отметку соседа и приложение стартует как надо; дело в самой миграции — контейнер будет падать и подниматься, пока выкат не остановят руками. Одноразовая джоба выходит с ненулевым кодом, и API, который ждёт её по условию завершения успехом, не стартует вовсе.

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

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

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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

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

И это ещё короткое окно, секунды или десятки секунд. Длинное открывает обратный выкат: образ откатывают одной командой, схему — нет. Старый код живёт на новой схеме столько, сколько длится разбор, а ночью разбор длится до утра.

Что при этом происходит, видно на модели. Таблица здесь — набор колонок и массив строк; обращение к колонке, которой в схеме нет, кончается тем же, чем в настоящей базе: отказом с кодом.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Первый момент — прод до выката: один старый код, одна колонка, всё работает. Второй — джоба уже переименовала колонку, а контейнер со старым кодом ещё отвечает: он получает 42703 и на чтении, и на записи, то есть любой запрос к заметкам кончается ошибкой. Третий — старый контейнер заменён новым, и всё снова работает. Прод отвечает отказами ровно столько, сколько длится второй момент.

Момент кончается не по таймеру. Если новый контейнер не поднялся — не собрался образ, не хватило памяти, не прошла проба, — старый код живёт на новой схеме до конца разбора.

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

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

выпускшаг в схемечто делает код
1 · расширениеADD COLUMN body, перенос значений, ALTER COLUMN text DROP NOT NULLпишет и в text, и в body, читает ту, где что-то есть
2 · переходдогоняющий перенос: UPDATE … WHERE body IS NULLпишет только в body, читает body
3 · сужениеDROP COLUMN textничего не меняется: про text он уже не знает

Каждый выпуск проверяется одним и тем же вопросом: какие версии кода сейчас живы и обе ли они работают на этой схеме. В модели это буквально цикл.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Две строки вывода здесь важнее остальных. «Перенесено строк 1» во втором выпуске — это заметка, которую старый код успел записать между выпусками: догоняющий перенос забирает именно её, и без него она осталась бы с пустым body навсегда. А «пустых 0» у каждой версии в каждом выпуске — это и есть проверка, ради которой модель написана: ни одна живая версия ни в один момент не увидела пустой клетки.

На настоящей базе те же шаги дают ещё три ответа, которых у модели быть не может.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 28 сентября 2026
Тот же сюжет на настоящем Postgres: сначала шаг в лоб, потом расширение по выпускам
import pg from 'pg';

// адрес базы — первой строкой и без пароля: видно, куда ушли запросы
const url = new URL(process.env.DATABASE_URL);
url.password = '';
console.log('база:', url.href);

const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();

/*
 * Три версии одного обработчика заметок. Старая знает колонку text; переходная
 * пишет в обе и читает ту, в которой что-то есть; новая знает только body.
 * В момент выката на одной схеме работают две версии из трёх.
 */
const версии = {
  'старый код': {
    читает: 'SELECT text AS видно FROM notes ORDER BY id',
    пишет: 'INSERT INTO notes (text) VALUES ($1)',
  },
  'переходный код': {
    читает: 'SELECT COALESCE(body, text) AS видно FROM notes ORDER BY id',
    пишет: 'INSERT INTO notes (text, body) VALUES ($1, $1)',
  },
  'новый код': {
    читает: 'SELECT body AS видно FROM notes ORDER BY id',
    пишет: 'INSERT INTO notes (body) VALUES ($1)',
  },
};

// каждый запрос сам себе транзакция: отказ одного не мешает следующему
const попытка = async (запрос, значения) => {
  try {
    const { rows } = await client.query(запрос, значения);
    return { ok: true, rows };
  } catch (err) {
    return { ok: false, код: err.code, сообщение: err.message };
  }
};

/** Шаг миграции: удачный молчит, неудачный называет код SQLSTATE. */
const шаг = async (запрос) => {
  const итог = await попытка(запрос);
  if (!итог.ok) console.log(`  миграция: ${итог.код} ${итог.сообщение}`);
};

/** Что видит и что может записать версия кода на нынешней схеме. */
async function проверить(имя, метка) {
  const { читает, пишет } = версии[имя];
  const чтение = await попытка(читает);
  const запись = await попытка(пишет, [метка]);
  if (чтение.ok) {
    const пустых = чтение.rows.filter((строка) => строка.видно === null).length;
    console.log(`  ${имя} читает: строк ${чтение.rows.length}, пустых ${пустых}`);
  } else {
    console.log(`  ${имя} читает: ${чтение.код} ${чтение.сообщение}`);
  }
  console.log(`  ${имя} пишет: ${запись.ok ? 'записал' : `${запись.код} ${запись.сообщение}`}`);
}

const колонкиИСтроки = async () => {
  const { rows } = await client.query(
    `SELECT attname AS имя FROM pg_attribute
     WHERE attrelid = 'notes'::regclass AND attnum > 0 AND NOT attisdropped ORDER BY attnum`,
  );
  const колонки = rows.map((строка) => строка.имя);
  const { rows: данные } = await client.query(`SELECT ${колонки.join(', ')} FROM notes ORDER BY id`);
  console.log(`  колонки: ${колонки.join(', ')}`);
  for (const строка of данные) console.log(`    ${колонки.map((к) => `${к}=${строка[к] ?? 'null'}`).join(' ')}`);
};

const сНачала = async () => {
  await шаг('DROP TABLE IF EXISTS notes');
  await шаг('CREATE TABLE notes (id serial PRIMARY KEY, text text NOT NULL)');
  await шаг("INSERT INTO notes (text) VALUES ('была до выката')");
};

await сНачала();
console.log('--- схема, которая в проде сейчас');
await проверить('старый код', 'старый до выката');
await проверить('новый код', 'новый до выката');

console.log('--- в лоб, попытка 1: ALTER TABLE notes ADD COLUMN body text NOT NULL');
await шаг('ALTER TABLE notes ADD COLUMN body text NOT NULL');

console.log('--- в лоб, попытка 2: ALTER TABLE notes RENAME COLUMN text TO body');
await шаг('ALTER TABLE notes RENAME COLUMN text TO body');
await проверить('старый код', 'старый после переименования');
await проверить('новый код', 'новый после переименования');

await сНачала();
console.log('--- выкат 1, расширение начато: ADD COLUMN body и перенос значений');
await шаг('ALTER TABLE notes ADD COLUMN body text');
await шаг('UPDATE notes SET body = text WHERE body IS NULL');
await проверить('новый код', 'новый на половине расширения');

console.log('--- расширение доведено: ALTER TABLE notes ALTER COLUMN text DROP NOT NULL');
await шаг('ALTER TABLE notes ALTER COLUMN text DROP NOT NULL');
await проверить('старый код', 'старый в выкат 1');
await проверить('переходный код', 'переходный в выкат 1');
await проверить('новый код', 'новый в выкат 1');
await колонкиИСтроки();

console.log('--- выкат 2: догоняющий перенос, дальше пишет только body');
await шаг('UPDATE notes SET body = text WHERE body IS NULL');
await проверить('переходный код', 'переходный в выкат 2');
await проверить('новый код', 'новый в выкат 2');

console.log('--- выкат 3, сужение: ALTER TABLE notes DROP COLUMN text');
await шаг('ALTER TABLE notes DROP COLUMN text');
await проверить('новый код', 'новый в выкат 3');
await проверить('старый код', 'старый в выкат 3');

console.log('--- другой способ расширения: ADD COLUMN tag text NOT NULL DEFAULT \'\' — и что легло в строки');
await шаг("ALTER TABLE notes ADD COLUMN tag text NOT NULL DEFAULT ''");
const { rows: описание } = await client.query(
  `SELECT atthasmissing, attmissingval::text AS значение FROM pg_attribute
   WHERE attrelid = 'notes'::regclass AND attname = 'tag'`,
);
console.log(`  pg_attribute про tag: atthasmissing ${описание[0].atthasmissing}, attmissingval ${описание[0].значение}`);
await проверить('новый код', 'новый не знает про tag');

console.log('--- колонка, которую считает сама база, и попытка кода в неё записать');
await шаг('ALTER TABLE notes ADD COLUMN body_mirror text GENERATED ALWAYS AS (body) STORED');
const { rows: зеркало } = await client.query('SELECT count(*)::int AS заполнено FROM notes WHERE body_mirror IS NOT NULL');
console.log(`  строк с непустым body_mirror: ${зеркало[0].заполнено}`);
await шаг("UPDATE notes SET body_mirror = 'своё'");

await client.end();
node versions.mjs

Ответ первый: шаг ADD COLUMN body text NOT NULL на непустой таблице не проходит вовсе — 23502 column "body" of relation "notes" contains null values. Обязательная колонка без значения по умолчанию — не «опасный шаг», а невыполнимый; джоба на нём просто упадёт.

Ответ второй: расширение, не доведённое до конца, ломает как раз новый код. Пока text остаётся обязательным, новый код читает нормально, а вставить не может: 23502 null value in column "text" … violates not-null constraint. Он про text не знает и ничего в него не пишет, а база требует.

Ответ третий — самый тихий. Строка «новый код читает: строк 3, пустых 1» не сопровождается никакой ошибкой: код выхода ноль, в логах чисто, а одна заметка приехала клиенту пустой. Это строка, которую записал старый код, знающий только text. Именно поэтому в первом выпуске живёт переходная версия с COALESCE — тем же «первым непустым», что ?? в модели, — а не сразу конечная: одна ошибка видна сразу, а потерянное поле находят через неделю по жалобе.

Заодно в дампе строк видно, что номера идут 1, 3, 4, 5. Отклонённая вставка успела взять номер из последовательности и не вернула его: «серийный» номер обещает возрастание, но не отсутствие дыр.

Значением по умолчанию: ADD COLUMN tag text NOT NULL DEFAULT '' проходит на непустой таблице и не переписывает её. В том же прогоне это видно прямо в описании колонки: atthasmissing у неё true, а attmissingval — {""}. Значение лежит в каталоге и подставляется при чтении тех строк, которые о колонке не знают; старый код продолжает вставлять строки без неё.

Колонкой, которую считает сама база: ADD COLUMN body_mirror text GENERATED ALWAYS AS (body) STORED заполняет все строки без единого UPDATE. Но писать в неё нельзя никому — 428C9 column "body_mirror" can only be updated to DEFAULT, — поэтому такая колонка годится для зеркала и индекса, а не для переезда: новый код в неё ничего не запишет.

Своим переносом — если значения считаются не одним выражением. На боевой таблице его делают порциями: один UPDATE на все строки держит замок до конца и оставляет после себя столько же мёртвых версий строк, сколько было живых.

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

шагчто с ним не таккак делают
ADD COLUMN необязательнуюничего: старый код её не видит и не мешаетодним шагом
ADD COLUMN … NOT NULL без значения23502 на непустой таблице — шаг не выполнитсяколонка, перенос значений, SET NOT NULL — и последнее лишь тогда, когда колонку заполняет весь живой код
RENAME COLUMNстарый код получает 42703 на каждом запросерасширение и сужение
DROP COLUMNто же, но незаметно до первого откатаотдельным выпуском, когда старого кода нет ни в контейнерах, ни в образах
CREATE UNIQUE INDEX23505, если дубли уже есть; а дальше старый код начнёт получать отказы на том, что раньше проходилочистка дублей, потом код, который их не делает, и только потом индекс
сужение типа, varchar(64) вместо textстарый код пишет длинное и получает отказпроверка в коде раньше, чем ограничение в схеме
ALTER TABLE, меняющий колонкиберёт замок, несовместимый даже с чтениемlock_timeout и повтор, а не «подождём, оно быстро»

Последняя строка — самая дорогая, потому что она про доступность, а не про совместимость. И видно её не на глаз, а только под нагрузкой.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 28 сентября 2026
Миграция за долгой читающей транзакцией, обычное чтение позади неё и ещё два шага, оба про построение индекса
import pg from 'pg';

// адрес базы — первой строкой и без пароля: видно, куда ушли запросы
const url = new URL(process.env.DATABASE_URL);
url.password = '';
console.log('база:', url.href);

// роль соединения в обе стороны: в выводе имена, а не pid этой машины
const кто = new Map();
const pidРоли = new Map();
const подключить = async (роль) => {
  const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
  await client.connect();
  const { rows } = await client.query('SELECT pg_backend_pid() AS pid');
  кто.set(rows[0].pid, роль);
  pidРоли.set(роль, rows[0].pid);
  return client;
};

const наблюдатель = await подключить('наблюдатель');
const отчёт = await подключить('отчёт');
const миграция = await подключить('миграция');
const читатель = await подключить('читатель');

await наблюдатель.query('CREATE TABLE notes (id serial PRIMARY KEY, body text NOT NULL, slug text)');
await наблюдатель.query("INSERT INTO notes (body, slug) VALUES ('первая', 'odna'), ('вторая', 'odna')");

// шаг миграции: удачный молчит, неудачный называет код SQLSTATE. Подробности
// отказа драйвер кладёт отдельным полем — печатаем и его, если оно есть
const шаг = async (client, запрос) => {
  try {
    await client.query(запрос);
  } catch (err) {
    const ещё = err.detail ? ` | detail: ${err.detail}` : '';
    console.log(`  миграция: ${err.code} ${err.message}${ещё}`);
  }
};

const пауза = (мс) => new Promise((готово) => setTimeout(готово, мс));

/*
 * Запрос уходит на сервер не мгновенно, а вывод обязан совпадать при каждом
 * прогоне: прежде чем идти дальше, ждём, пока база сама скажет, что этот
 * сеанс чего-то ждёт.
 */
const дождатьсяСеанса = async (роль) => {
  for (;;) {
    const { rows } = await отчёт.query('SELECT wait_event_type AS чего FROM pg_stat_activity WHERE pid = $1', [
      pidРоли.get(роль),
    ]);
    if (rows[0]?.чего === 'Lock') return;
    await пауза(20);
  }
};

const замки = async () => {
  const { rows } = await отчёт.query(
    `SELECT pid, mode, granted FROM pg_locks
     WHERE relation = 'notes'::regclass ORDER BY granted DESC, pid`,
  );
  for (const строка of rows) {
    console.log(`  ${кто.get(строка.pid) ?? 'кто-то ещё'}: ${строка.mode} ${строка.granted ? 'выдан' : 'ждёт'}`);
  }
};

console.log('--- долгий отчёт открыл транзакцию и читает таблицу');
await отчёт.query('BEGIN');
await отчёт.query('SELECT count(*) FROM notes');

console.log('--- миграция с lock_timeout = 250ms');
await миграция.query("SET lock_timeout = '250ms'");
try {
  await миграция.query('ALTER TABLE notes ADD COLUMN tag text');
  console.log('  миграция: применена');
} catch (err) {
  console.log(`  миграция: ${err.code} ${err.message}`);
}

console.log('--- та же миграция без таймаута, а сразу за ней — обычное чтение');
await миграция.query('SET lock_timeout = 0');
const ждётМиграция = миграция
  .query('ALTER TABLE notes ADD COLUMN tag text')
  .then(() => 'применена')
  .catch((err) => `${err.code} ${err.message}`);
await дождатьсяСеанса('миграция');
const ждётЧитатель = читатель
  .query('SELECT count(*) FROM notes')
  .then(() => 'прочитал')
  .catch((err) => `${err.code} ${err.message}`);
await дождатьсяСеанса('читатель');
await замки();

console.log('--- отчёт закрыл транзакцию');
await отчёт.query('COMMIT');
console.log(`  миграция: ${await ждётМиграция}`);
console.log(`  читатель: ${await ждётЧитатель}`);

console.log('--- CREATE INDEX CONCURRENTLY внутри транзакции, как её открывает раннер');
await миграция.query('BEGIN');
await шаг(миграция, 'CREATE INDEX CONCURRENTLY notes_slug_idx ON notes (slug)');
await миграция.query('ROLLBACK');

console.log('--- уникальный индекс на колонке, куда старый код успел положить дубли');
await шаг(миграция, 'CREATE UNIQUE INDEX notes_slug_key ON notes (slug)');

await наблюдатель.end();
await отчёт.end();
await миграция.end();
await читатель.end();
node queue.mjs

Отчёт держит открытую транзакцию и с ней AccessShareLock — обычный замок чтения. Миграция просит AccessExclusiveLock, они несовместимы, и она ждёт. Самое важное дальше: обычный SELECT, пришедший после неё, тоже ждёт. С отчётом он не конфликтует ни на копейку, но встаёт в очередь за миграцией. Одна незакрытая транзакция плюс один ALTER TABLE — и таблица недоступна всем, кто пришёл позже. Открытые транзакции читатель уже встречал у версий строк; здесь у них другая цена.

Отсюда lock_timeout: миграция, которая не смогла взять замок за четверть секунды, отваливается с 55P03 canceling statement due to lock timeout, и это лучший из исходов. Упавшую джобу перезапускают; таблицу, недоступную три минуты в час пик, не отменишь.

Два последних шага прогона база не даёт сделать вовсе. CREATE INDEX CONCURRENTLY — тот самый способ построить индекс, не блокируя запись, — не работает внутри транзакции: 25001 CREATE INDEX CONCURRENTLY cannot run inside a transaction block. А раннер миграций транзакцию как раз открывает. И CREATE UNIQUE INDEX на колонке с дублями даёт 23505 could not create unique index: в подробностях отказа база называет одно повторившееся значение и ни одной строки. Сколько всего дублей и где они, из отказа не узнать — их ищут запросом заранее. Все эти ответы — коды SQLSTATE, и ветвиться в коде надо по ним, а не по тексту: код на проводе одинаков на любом языке сервера.

Отдельным шагом вне транзакции: своей джобой с командой psql -c или раннером, который для этой миграции транзакцию не открывает. У drizzle-kit второго пути нет, поэтому такой индекс у автора не поедет его миграцией вовсе.

И проверкой после. Построение CONCURRENTLY может упасть на середине — например, на дубле, — и тогда в схеме остаётся индекс, помеченный негодным: indisvalid у него false. Такой индекс не используется планировщиком, но занимает место и мешает создать одноимённый. Его удаляют и строят заново, а не «пробуют ещё раз».

Остался вопрос, почему джобе нужен свой образ, а не образ API с другой командой. Ответ короткий: в образе API нет drizzle-kit. Файлы миграций там есть — пакет @jsp/db попадает в срез вместе с каталогом migrations, — а сам инструмент нет, потому что он и tsx объявлены в devDependencies этого пакета, а рантайм собирается срезом боевых зависимостей. Проверено в собранном образе 16.09.2026: /app/node_modules/@jsp/db/migrations/0000_fantastic_puck.sql на месте, drizzle-kit отсутствует.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Три детали этого файла стоит прочитать внимательно. Установка идёт без --prod — ровно наоборот к образу API, и это не оплошность: нужное лежит именно в dev-зависимостях. Версии не перечисляются в Dockerfile, а берутся из pnpm-lock.yaml: прежний вариант с npm install без lock ставил drizzle-kit 0.31.4 при 0.31.10 в lock, и транзитивные зависимости плавали от сборки к сборке. А WORKDIR указан на пакет базы, потому что drizzle.config.ts называет схему и каталог миграций относительными путями — из корня образа он их не найдёт.

Чего этот образ не делает, так это не выходит «лёгким». Холодная сборка 16.09.2026 дала 392 359 352 байта против 379 545 107 у образа API: джоба миграций тяжелее приложения. Кроме drizzle-kit и tsx установка тянет все остальные dev-зависимости пакета базы, а корневые turbo и prettier pnpm ставит при любом --filter. Это нормальная цена: образ собирается один раз на выкат и живёт секунды. Но обещать «маленький образ только с инструментом миграций» не стоит — такого здесь нет.

Сид — вторая одноразовая джоба на том же образе: tsx в нём уже есть, а команда переопределяется в compose. Профиль держит её в стороне от обычного выката: сервис с profiles не стартует вместе с остальными и запускается отдельной командой.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Одна тонкость на будущее: обеим командам нужен записываемый /tmp — tsx кладёт туда свой каталог. Образ с файловой системой только для чтения и без tmpfs на /tmp роняет и сид, и миграцию.

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

Второй вопрос выката — что происходит, когда джоба падает. Посмотрим на случай, который разобран выше: во второй миграции стоит CREATE INDEX CONCURRENTLY, и в транзакции он невозможен.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 28 сентября 2026
Джоба на двух непримененных миграциях, вторая из которых не выполняется в транзакции, и она же после правки каталога
import { execFileSync } from 'node:child_process';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import pg from 'pg';

// адрес базы — первой строкой и без пароля: видно, куда ушли запросы
const url = new URL(process.env.DATABASE_URL!);
url.password = '';
console.log('база:', url.href);

const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();

// так одноразовая джоба и запускается: CMD образа, код выхода — приговор выкату
const джоба = () => {
  try {
    execFileSync('drizzle-kit', ['migrate'], { stdio: 'inherit' });
    // drizzle-kit не переводит строку после последнего сообщения
    console.log();
    console.log('  код выхода 0');
  } catch (err) {
    console.log();
    console.log(`  код выхода ${(err as { status?: number }).status}`);
  }
};

const состояние = async () => {
  const { rows: таблицы } = await client.query(
    `SELECT table_schema || '.' || table_name AS имя FROM information_schema.tables
     WHERE table_schema IN ('public', 'drizzle') ORDER BY 1`,
  );
  console.log('  таблицы базы:', таблицы.map((строка) => строка.имя).join(', ') || 'ни одной');
  const { rows: журнал } = await client.query(
    `SELECT to_regclass('drizzle.__drizzle_migrations') IS NOT NULL AS есть`,
  );
  if (журнал[0].есть) {
    const { rows: отметки } = await client.query('SELECT count(*)::int AS сколько FROM drizzle.__drizzle_migrations');
    console.log('  отметок в журнале drizzle:', отметки[0].сколько);
  } else {
    console.log('  журнала drizzle в базе нет');
  }
  const { rows: индексы } = await client.query(
    `SELECT indexname AS имя FROM pg_indexes WHERE tablename = 'notes' ORDER BY 1`,
  );
  console.log('  индексы notes:', индексы.map((строка) => строка.имя).join(', ') || 'таблицы нет');
};

console.log('--- две непримененные миграции, во второй CREATE INDEX CONCURRENTLY');
джоба();
await состояние();

console.log('--- второй шаг убран из каталога миграций');
rmSync('drizzle/0001_notes_slug_idx.sql');
const путь = 'drizzle/meta/_journal.json';
const журнал = JSON.parse(readFileSync(путь, 'utf8'));
журнал.entries = журнал.entries.filter((запись: { tag: string }) => запись.tag !== '0001_notes_slug_idx');
writeFileSync(путь, JSON.stringify(журнал, null, 2));
джоба();
await состояние();

await client.end();
node job.ts

Код выхода 1 — и ни слова о причине: drizzle-kit migrate печатает крутилку и молча уходит. Дальше интереснее: журнал в базе заведён, отметок в нём ноль, а таблицы notes нет вовсе — хотя первый шаг был безобиден и должен был пройти. Так и задумано: drizzle-kit migrate зовёт миграционную функцию drizzle-orm, а та гоняет все непримененные шаги одной транзакцией (session.transaction в pg-core/dialect 0.45.2), поэтому падение второго откатывает первый. Для выката это хорошо — половины схемы не бывает. Для разбора плохо: причину придётся искать в журнале базы или повторяя шаг руками.

А что при этом с сайтом? API ждёт джобу по условию успешного завершения и не стартует — это правильно: лучше отказ прокси, чем приложение на схеме, которой оно не знает. Сайт же у автора от API не зависит вовсе, и зависимость эта убрана намеренно.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Проверка после выката спрашивает не контейнеры, а адреса: страницу сайта и ручку API. То, что джоба вышла с нулём, ещё не значит, что схема получилась та, которую ждёт код.

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

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

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

  • Миграцию применяет одноразовая джоба, а не старт приложения: копий приложения бывает больше одной, а чтение журнала и отметка о применении — разные запросы. Общий замок базы гонку закрывает, но отказ миграции всё равно должен останавливать выкат, а не превращаться в цикл рестартов.
  • Новый код не может начать работать раньше миграции, поэтому на новой схеме всегда есть окно со старым кодом: короткое при обычном выкате и сколь угодно длинное при обратном.
  • Схему меняют так, чтобы её понимали обе живые версии кода: расширение, переход, сужение. Переименование и удаление колонки этому правилу не подчиняются и разбиваются на три выпуска.
  • Во втором выпуске обязателен догоняющий перенос: строки, записанные старым кодом после первого переноса, иначе останутся пустыми. Это отказ без ошибки — самый дорогой из возможных.
  • Обязательная колонка без значения по умолчанию на непустой таблице не добавляется вовсе, уникальный индекс не создаётся при дублях, а CREATE INDEX CONCURRENTLY не работает внутри транзакции, которую открывает раннер.
  • ALTER TABLE, меняющий колонки, берёт замок, несовместимый даже с чтением, и запросы, пришедшие после него, ждут вместе с ним. Поэтому у миграции ставят lock_timeout: упавшую джобу перезапускают, а недоступную таблицу не отменяют.

1/4Почему миграцию не применяют в старте приложения?

2/4Джоба переименовала колонку, а контейнер со старым кодом ещё отвечает. Что он получает?

3/4Зачем во втором выпуске нужен догоняющий перенос UPDATE … WHERE body IS NULL?

4/4Миграция ждёт замок за долгой читающей транзакцией. Что происходит с обычным SELECT, который пришёл позже?

Переживёт ли шаг момент

Шаг миграции описан объектом: вид, подробности и подпись для вывода. Напишите переживёт так, чтобы она возвращала true ровно для тех шагов, которые можно выкатить одним куском — то есть которые не ломают код, ничего о них не знающий. Список шагов и печать не трогайте.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Правил всего два, и оба про код, который о шаге не знает. Необязательная колонка ему не мешает; обязательная — мешает, если база не умеет подставить значение сама, то есть если у колонки нет DEFAULT. Обычный индекс не меняет того, что код может записать; уникальный меняет, поэтому он едет только после чистки дублей и кода, который их не делает. Всё остальное — переименование и удаление — отнимает у старого кода имя, которое он называет.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: переживут три шага из семи — необязательная колонка, обязательная со значением по умолчанию и обычный индекс. Обратите внимание на порядок проверок в первой строке: поУмолчанию: "" — пустая строка, и проверять её на правдивость нельзя, сравнение идёт с undefined.

Два раннера по одному

Две копии раннера стартуют одновременно — так бывает, когда миграцию применяет старт приложения. Сейчас завестиЗамок возвращает функцию, которая просто зовёт дело, и обе копии применяют шаг. Сделайте так, чтобы дела шли по одному: следующий начинается только после того, как закончился предыдущий. Переменная очередь для этого уже объявлена; остальной код не меняйте.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

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

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь вторая копия читает журнал уже после того, как первая поставила отметку, и видит единицу вместо нуля — «применять нечего». Ровно это делает в настоящей базе pg_advisory_xact_lock, только очередь там одна на все процессы, а не на один.

Перенос порциями

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

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

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Выходит две порции: 2, 3 и 5. В настоящем переносе к этому добавляется пауза между порциями и повтор всего прохода до тех пор, пока порций не останется: пока идёт перенос, старый код успевает записать новые пустые строки.