diff --git a/editorial/production/README.md b/editorial/production/README.md index fcb42d6..9072c0c 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 223 из 358 созданных материалов. Остальные 135 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 226 из 358 созданных материалов. Остальные 132 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2024-04-draft.md b/editorial/reviews/2024-04-draft.md new file mode 100644 index 0000000..de452d8 --- /dev/null +++ b/editorial/reviews/2024-04-draft.md @@ -0,0 +1,44 @@ +# P74 · 2024-04 · Безопасная миграция данных — три самостоятельных прохода + +## Рамка sidecar-пакета + +- Slug: editorial-2024-04-practice-data-migrations, editorial-2024-04-mechanism-data-migrations, editorial-2024-04-field-data-migrations. +- Голос: M7, апрель 2024. Автор не обещает «zero downtime» и не пересказывает инструменты. Он начинает с цены несовместимой схемы и невосстановимого contract, затем коротко ведёт через «симптом → причина → проверка → действие», owner, границу доказательства и следующий шаг. +- Созданы ровно пять sidecar-файлов: этот review, один import-safe script и три локальных SVG. Overlay, README, articles.json, очередь, Git, staging, commit/push и чужие файлы не менялись. Пакет не интегрирован. +- Fixture работает только с fixed marked synthetic JS records в памяти. Он не открывает database, не исполняет SQL, не читает файлы, repository, environment, часы, CI output, сеть или HTTP. Он не запускает migration, rehearsal, backfill или нагрузочное измерение и не делает production claim. PASS говорит только о согласованности учебного контракта. + +## Проход 1 — факты, источники и модель + +- Первичные источники проверены 31.07.2026 и исторически доступны к апрелю 2024. [Stripe Engineering: Online migrations at scale](https://stripe.com/blog/online-migrations) опубликован 02.02.2017 и описывает их четыре фазы: dual write, переключение readers, переключение writers, удаление старого. Это case Stripe с их storage, Hadoop и Scientist experiments, а не default-параметры чужой базы. +- [PostgreSQL 16: ALTER TABLE](https://www.postgresql.org/docs/16/sql-altertable.html) документирует для PostgreSQL 16 разницу между non-volatile/volatile default, table rewrite, NOT VALID и VALIDATE CONSTRAINT; [официальный релиз PostgreSQL 16](https://www.postgresql.org/about/news/postgresql-16-released-2715/) датирован 14.09.2023. В статьях эти факты названы PostgreSQL-specific: они не переносятся на другую СУБД, ORM, managed service или реальные locks без её документации. +- [Google SRE Book: Testing for Reliability](https://sre.google/sre-book/testing-reliability/) — первичный Google source, книга существовала до апреля 2024 (официальный [preface](https://sre.google/sre-book/preface/) указывает copyright 2016 Google). Из него взято узкое правило: test уменьшает неопределённость, но pass не доказывает reliability; рискованный инструмент нуждается в барьере. Это не источник SQL-синтаксиса, capacity или database permissions. +- createFixedSyntheticMigrationInput() создаёт только один из пяти фиксированных selector-ов: compatible expand, schema ahead of old writer, unbounded backfill, contract при mixed fleet, rehearsal без rollback draft. Он не принимает произвольную базу, таблицу, URL, SQL, время, файл или scope реального проекта. +- inspectSyntheticDataMigration() использует exact closed input contract из пяти полей. Лишние databaseUrl и migrationFile, не-synthetic marker, неизвестный case и run-real-migration mode отвергаются до классификации. Результат содержит явные границы: database=not-opened, sql=not-run, filesystem=not-read, network=not-used, clock=not-read, ci=not-touched, migration=not-started, loadMeasurement=not-performed. +- Модель разделяет причины: early expand блокируется несовместимым old writer; unbounded backfill — отсутствием declared stop condition; early contract — ещё declared old version/old representation; rehearsal — отсутствием rollback draft. Это не единый verdict «миграция плохая». +- Во время независимого модельного прохода найдены и исправлены две проблемы. Сначала fixture печатал 32/31 из-за финальной assertion счётчика; затем gate с заблокированным rehearsal сохранял accepted=true, хотя state был stop. Теперь accepted означает открытый synthetic gate; смешанный fleet получает accepted=false и stop-before-human-rehearsal. +- Главная приёмка добавила безопасное canonical JSON-сравнение и требование плотных массивов для reasons/actions. Циклический внешний report отклоняется вместо исключения, разрежённый plan не даёт snapshot, а snapshot копирует actions, поэтому caller не может изменить его после возврата. Это делает fixture устойчивее, не расширяя его за пределы fixed in-memory модели. +- planSyntheticDataMigration() доверяет только canonical report, повторно сверенный с fixed record; подменённый verdict отвергается. rollbackSyntheticDataMigrationDraft() принимает только canonical plan; подменённый action не даёт snapshot. Rollback возвращает только synthetic decision draft и не обещает восстановить schema, data, files, CI, traffic, backups или production. +- Проверки модели: node --check scripts/upgrade-2024-04.mjs — PASS; node scripts/upgrade-2024-04.mjs --verify-fixture — PASS fixture: 36/36 assertions. Это не database test и не release approval. + +## Проход 2 — язык, объём и голос + +- Practice отвечает на вопрос, как разделить expand, migrate, switch и contract; mechanism объясняет matrix old/new reader-writer и границу between data compatibility and traffic switch; field разбирает fixed case, где backfill не имеет stop condition, а contract ещё нельзя разрешить. Тексты не маскируют один материал тремя заголовками. +- В первых двух абзацах каждой статьи названы ситуация и цена: schema ahead of code, нагрузка от неограниченного backfill, mixed fleet и rollback, который не возвращает удалённые данные. Затем в каждой статье явно проходит цепочка «симптом → причина → проверка → действие». +- В каждой ревизии есть недекоративная таблица: contract ролей и этапов, compatibility matrix либо матрица полевого разбора. Таблица отвечает на действие читателя, а не повторяет соседний текст. Example запускает только фиксированную in-memory модель и рядом называет, что она не доказывает. +- npm run audit:draft -- scripts/upgrade-2024-04.mjs зафиксировал основной текст без источников: practice — **10 439**, mechanism — **11 348**, field — **9 948** знаков. Все три статьи внутри обязательного диапазона 5 000–15 000 и целевого 9–12 тыс. +- Убраны ложные operational claims: synthetic-gate-open не назван разрешением на реальную миграцию; rehearsal не выдан за production measurement; rollback binary не приравнен к восстановлению данных; PostgreSQL и Stripe не объявлены универсальным законом для другой СУБД. +- Речь остаётся короткой и предметной для 2024 года: reader/writer, owner, evidence, stop condition и rollback boundary появляются только там, где задают проверяемую ответственность. Каждый материал заканчивается отдельным следующим шагом и ограничениями. + +## Проход 3 — visual, безопасность и выпуск + +- data-migrations-2024-expand-contract-timeline.svg показывает, что old representation живёт до contract и что возврат к compatible code path не обещает вернуть удалённые данные. data-migrations-2024-version-compatibility.svg отделяет v1, transition v2 и v3 по reader/writer. data-migrations-2024-rehearsal-gate.svg показывает, какие отсутствующие условия ведут в stop and review, а не в contract. +- У каждого рисунка есть независимые title и desc, а в статье — содержательные alt и caption с явной границей модели. В SVG нет внешних URL, пользовательского ввода, script, foreignObject, javascript:, data:image или event-handler attributes. +- Первый Sharp-render на ширине 375 px обнаружил два дефекта: длинный title timeline обрезался справа, а matrix вместила слишком плотные вторые строки. Title сокращён до «Миграция: старая форма живёт до contract»; matrix перепроектирована на короткие крупные labels (v1/v2/v3, old/new, expand/migrate/contract). Повторный render показывает все заголовки, стрелки, стоп-ветку и подписи без обрезания. +- xmllint --noout для трёх SVG — PASS. SVG safety scan — clean. Все три SVG прошли Sharp-render 375 px и открыты вручную после финальной корректировки. Визуалы не являются graph, snapshot или measurement реальной среды. +- Выпуск намеренно не выполнялся: overlay/README/queue не обновлялись, production build не запускался, Git/staging/commit/push отсутствуют. Это ограничение sidecar-пакета, а не пропуск проверки. + +## Выпуск после трёх проходов + +- Главный редактор подключил ровно три апрельские ревизии в `web/data/editorial-revisions.mjs`, не меняя архивный `articles.json`, и обновил счётчик производства до 226 из 358 материалов. +- После подключения `npm run audit:articles -- <три slug>` подтвердил объём, figure, таблицу и пример каждой статьи; registry содержит 217 уникальных ревизий без повторов slug. +- `npm run build` завершился успешно: Next.js сгенерировал 374 статические страницы. В выпуск не включены пользовательские правки и неинтегрированные sidecar-пакеты мая, июня и июля. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 5341d77..43ec6ad 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -70,6 +70,7 @@ import { revisions as december2023Revisions } from '../scripts/upgrade-2023-12.m import { revisions as january2024Revisions } from '../scripts/upgrade-2024-01.mjs'; import { revisions as february2024Revisions } from '../scripts/upgrade-2024-02.mjs'; import { revisions as march2024Revisions } from '../scripts/upgrade-2024-03.mjs'; +import { revisions as april2024Revisions } from '../scripts/upgrade-2024-04.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -145,4 +146,5 @@ export const editorialRevisions = [ ...january2024Revisions, ...february2024Revisions, ...march2024Revisions, + ...april2024Revisions, ]; diff --git a/web/public/assets/editorial/2024/data-migrations-2024-expand-contract-timeline.svg b/web/public/assets/editorial/2024/data-migrations-2024-expand-contract-timeline.svg new file mode 100644 index 0000000..0bcb1e1 --- /dev/null +++ b/web/public/assets/editorial/2024/data-migrations-2024-expand-contract-timeline.svg @@ -0,0 +1,46 @@ + diff --git a/web/public/assets/editorial/2024/data-migrations-2024-rehearsal-gate.svg b/web/public/assets/editorial/2024/data-migrations-2024-rehearsal-gate.svg new file mode 100644 index 0000000..d03d6a9 --- /dev/null +++ b/web/public/assets/editorial/2024/data-migrations-2024-rehearsal-gate.svg @@ -0,0 +1,34 @@ + diff --git a/web/public/assets/editorial/2024/data-migrations-2024-version-compatibility.svg b/web/public/assets/editorial/2024/data-migrations-2024-version-compatibility.svg new file mode 100644 index 0000000..979de4c --- /dev/null +++ b/web/public/assets/editorial/2024/data-migrations-2024-version-compatibility.svg @@ -0,0 +1,41 @@ + diff --git a/web/scripts/upgrade-2024-04.mjs b/web/scripts/upgrade-2024-04.mjs new file mode 100644 index 0000000..099b6ba --- /dev/null +++ b/web/scripts/upgrade-2024-04.mjs @@ -0,0 +1,876 @@ +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +const p = (text) => '
' + text + '
'; +const h2 = (text) => '' + escapeHtml(text) + '';
+const ol = (items) => '| ' + item + ' | ').join('') + '
|---|
| ' + item + ' | ').join('') + '
synthetic-stop-and-review и причину contract-while-old-version-or-old-representation-remains. Это не verdict о настоящем сервисе. Fixture не читает версии в кластере и не знает, какие записи существуют. Его проверка ограничена тем, что подменённый report или лишнее поле вроде database-like selector отклоняются closed input contract. PASS подтверждает форму учебного договора, не readiness реального релиза.'),
+ h2('Switch — менять читателя после совместимого состояния'),
+ p('Switch часто недооценивают: «новая колонка заполнена» превращается в «теперь все читают только её». Но факт заполнения и право удалить fallback — разные доказательства. Читатель переключают после того, как договорены правила для null, старой записи, нового writer-а, повторного запуска и ошибочного значения. Если новый reader не умеет объяснить, что делает при старой форме, compatibility period закончился слишком рано.'),
+ p('Полезно разделить machine-safe и human evidence. Machine-safe может подтвердить только конкретный контракт: example input принимает old/new form, data-mapping выдаёт ожидаемую нормализованную форму, guard останавливает route по объявленному сигналу. Human evidence отвечает на другие вопросы: где сейчас живут старые версии, кто владеет трафиком, достаточно ли проверки выбранной выборки, имеет ли owner право на следующий шаг. Их нельзя заменить одним другим.'),
+ h2('Contract — не обратимый этап'),
+ p('Contract удаляет старое поле, старый индекс, fallback или dual write. Это полезное упрощение, но по природе оно хуже откатывается. Rollback приложения может вернуть reader, однако не восстановит данные, которые уже перестали записываться в старую форму, и не вернёт удалённую историю. Поэтому настоящий rollback для contract начинается раньше: сохранить совместимое представление, иметь чёткий cutover record и заранее назвать, что команда сделает при divergence. Если такого маршрута нет, правильное действие — не ускорить contract, а продлить совместимый период.'),
+ p('PostgreSQL 16 полезен как пример ограниченной операции. Для check и foreign-key constraints документация описывает NOT VALID, а затем VALIDATE CONSTRAINT: новые записи уже проверяются, а старые валидируются отдельно. Это не универсальный expand–migrate–contract API. В частности, команды, типы constraint и locks зависят от PostgreSQL версии и объекта. Сначала проверить собственный engine и миграционный инструмент, затем строить route вокруг подтверждённого поведения.'),
+ h2('Упорядоченный выпуск'),
+ ol([
+ 'Записать data contract: старое и новое представление, owner, readers, writers, допустимые null и условие удаления fallback.',
+ 'Подготовить expand, который не отвергает declared old writer. Проверить lock и version behavior именно для своей СУБД; не выводить их из примера.',
+ 'Выпустить совместимый code path: new reader понимает обе формы, new writer сохраняет нужную старую форму до switch.',
+ 'Сделать draft backfill: область, idempotency, маленькая управляемая порция, stop signal, owner решения и ответ на partial progress.',
+ 'Провести rehearsal на согласованной изолированной среде. Проверить mixed-version route и остановку; не называть rehearsal production measurement.',
+ 'Собрать evidence, переключить reader только по agreed criterion и оставить fallback до завершения периода наблюдения.',
+ 'Отдельным решением выполнить contract. Если нет доказательства отсутствия old consumer или нет recovery plan, не удалять старое представление.',
+ ]),
+ h2('Ограничения и следующий шаг'),
+ p('Этот маршрут не выбирает transaction isolation, batch size, lock timeout, формат journal, график traffic или retention. Он не заменяет policy для PII, юридические требования к хранению, backup restore и disaster recovery. PostgreSQL 16, Stripe и Google SRE Book говорят о разных системах: их факты нельзя склеивать в вымышленный универсальный SLA. Особенно опасно обещать, что nullable column всегда безопасна или что dual write автоматически согласован: семантика default, trigger, replication, clock skew и ошибок записи может менять вывод.'),
+ p('Следующий шаг — оформить на одну страницу migration record для одного реального изменения. В нём должны быть old/new shape, owner, allowed version mix, dual-write rule, stop condition, recovery boundary, rehearsal question и контракт удаления. Пока эти строки не готовы, work item остаётся design, а не migration. Это короткая задержка перед выпуском, которая дешевле поиска невосстановимой записи после contract.'),
+ h2('Историческая граница апреля 2024'),
+ p('К апрелю 2024 были доступны Stripe Online migrations at scale 2017 и PostgreSQL 16, выпущенный 14 сентября 2023. В материале они используются только в пределах своих утверждений. Голос M7 не продаёт «zero downtime»: он делит изменение на совместимые этапы, называет цену contract и оставляет человеку проверяемый следующий вопрос.'),
+]);
+
+const mechanism = revision({
+ slug: 'editorial-2024-04-mechanism-data-migrations',
+ title: 'Безопасная миграция данных: модель совместимости версий и цена contract',
+ categories: ['Данные', 'Миграции'],
+ cover: '/assets/editorial/2024/data-migrations-2024-version-compatibility.svg',
+ excerpt: 'Как описать schema change через совместимость old/new reader и writer, не принять backfill за доказательство готовности и не потерять границу обратимости.',
+ readingMinutes: 13,
+}, [
+ p('Симптом миграционной ошибки часто скрыт до тех пор, пока rollout не становится смешанным. Новая версия reader-а уже ждёт новое представление, старая версия writer-а ещё пишет старое, а schema change объявлен завершённым, потому что команда DDL прошла. Через несколько минут часть записей видна одному сервису и неполна для другого. Цена — не только data mismatch. Команда не может ответить, какую версию нужно откатить, потому что изменение формы данных и изменение кода уже смешались в одном факте.'),
+ p('Причина — неверная единица рассуждения. Мы обсуждаем колонку, таблицу или mapper, хотя реальная единица — пара reader/writer во времени. Schema — общий протокол между версиями приложения. Пока в сервисе есть хотя бы две версии, необходимо явно описать, какую форму каждая читает и пишет. Тогда dual write перестаёт быть фразой из runbook и становится ограниченным правилом: кто пишет две формы, сколько оно живёт и какой evidence снимает обязанность.'),
+ h2('Симптом → причина → проверка → действие'),
+ ol([
+ 'Симптом. Новый reader ждёт new shape, старый writer продолжает создавать old shape, а schema change уже считается готовым.',
+ 'Причина. Совместимость описали как свойство колонки, а не как четыре роли: old/new reader и old/new writer.',
+ 'Проверка. Составить матрицу версий, определить значение отсутствующего поля и явно назвать, какой consumer ещё требует old representation.',
+ 'Действие. Оставить совместимый период, сделать backfill отдельной управляемой работой и вынести contract в следующее решение с evidence.',
+ ]),
+ h2('Четыре роли вместо одного слова «совместимость»'),
+ p('В модели достаточно четырех ролей: old reader, old writer, new reader, new writer. Reader отвечает, какую форму он примет; writer — какую создаст. Из этого получаются четыре направления, и каждое нужно назвать. New reader, который умеет old/new, защищает от неполного backfill. New writer, который продолжает писать old/new, защищает old reader в mixed fleet. Если новую форму выпускают так, что old writer получает отказ, expand уже не expand: схема стала зависеть от порядка rollout.'),
+ p('Нельзя заменять эту таблицу тезисом «мы разворачиваем быстро». Даже быстрый rollout имеет границы: rollback, retries, long-lived worker, отдельный consumer, вручную запущенная утилита, batch job. Не требуется inventarize весь мир заранее. Требуется назвать область, на которую вы опираетесь, и сделать опасные неизвестности blockers. Если владелец не знает, есть ли old writer, условие contract не выполнено. Это не бюрократия, а честная причина не стирать старую форму.'),
+ table('Матрица версий и форм, которую стоит согласовать до schema change', ['Версия', 'Читает', 'Пишет', 'Допустимый этап', 'Риск без контракта'], [
+ ['v1 / old', 'old shape', 'old shape', 'expand и migrate', 'новая schema может отвергнуть ещё живой writer'],
+ ['v2 / transition', 'new or old shape', 'old and new shape', 'migrate и switch', 'неявный fallback скрывает неполный backfill'],
+ ['v3 / new', 'new shape', 'new shape', 'после evidence contract', 'ранний выпуск может отрезать v1 consumer'],
+ ['backfill draft', 'old record как вход', 'new representation как результат', 'только в миграционном окне', 'неограниченная работа конкурирует с пользовательским потоком'],
+ ['rollback action', 'совместимое представление', 'не восстанавливает удалённые факты', 'до contract', 'возврат бинарника ошибочно принимают за data recovery'],
+ ]),
+ figure('/assets/editorial/2024/data-migrations-2024-version-compatibility.svg', 'Матрица совместимости: v1 читает и пишет старое представление, v2 читает обе формы и пишет обе, v3 использует новое. Зелёные клетки обозначают разрешённые пары в переходном периоде, красная клетка показывает удаление старой формы при всё ещё живом v1.', 'Матрица — контракт рассуждения, не снимок deployment. Она не говорит, какие версии реально запущены и не измеряет качество или полноту данных.'),
+ h2('Expand: почему «nullable» не равно «безопасно»'),
+ p('Nullable field часто выбирают как быстрый expand. Это может быть разумно: old writer не обязан сразу знать новое поле, а new reader может трактовать отсутствие как старую форму. Но безопасность возникает только после двух дополнительных договорённостей. Во-первых, отсутствие должно иметь точную семантику: «ещё не мигрировано», «не применимо» и «значение потеряно» не одно и то же. Во-вторых, new writer не может одним release убрать старую форму, если old reader всё ещё возможен. Nullable устраняет один вид schema rejection, но не выбирает semantics и не завершает миграцию.'),
+ p('PostgreSQL 16 показывает, почему полезно читать документацию движка до плана. В ней указано, что добавление колонки с non-volatile default использует сохранённое в metadata значение для existing rows, тогда как volatile default или изменение типа могут требовать rewrite. Там же описаны scans и locks при constraint operations. Из этого не следует, что нужно копировать PostgreSQL path в другой движок. Следует более скромное правило: contract изменения должен содержать версию СУБД и конкретную операцию, а не слово «легковесная миграция».'),
+ h2('Migrate: backfill — это поток с бюджетом'),
+ p('Backfill не является фоновым шумом. Он читает старые записи, пишет новое представление, может повторяться, соперничать за индекс, соединение, журнал и capacity. Поэтому полезно писать не «запустим джобу», а небольшую спецификацию: идентификатор области, правило отбора, idempotency key или equivalent invariants, диапазон параллелизма как проектная настройка, stop condition и действие после stop. Значения нельзя брать из чужой статьи. Их получает owner через измерение и rehearsal выбранной среды.'),
+ p('Важна разница между guard и результатом. Guard может остановить synthetic route, если нет declared stop condition. Но guard не говорит, что выбранная порция безопасна под реальной нагрузкой. Google SRE Book формулирует это широко: пройденный test не доказывает reliability, а рискованный инструмент должен быть изолирован барьером. В миграции барьером может быть scope, ограниченный доступ, отдельная среда и явное право owner-а. Не стоит превращать эту общую мысль в готовую схему database permissions.'),
+ h2('Пример: closed input contract вместо свободного migration object'),
+ p('Ниже фиксированная учебная модель принимает не описание реальной базы, а один из embedded case id. Это нарочно узко. Если в input добавить databaseUrl, migrationFile или режим реального запуска, функция отклоняет его до классификации. Так fixture не создаёт впечатление, что умеет проверить реальные connection string, таблицы, SQL или deployments. В рабочем инструменте договор может быть шире, но тогда его входы и права должны быть предметом отдельного security review.'),
+ code(fixtureExample),
+ p('Case fixed-unbounded-backfill возвращает специальную причину backfill-has-no-declared-bounded-stop-condition. Он не говорит «база перегружена» — никаких метрик не читалось. Это принципиально. Симптом реальной перегрузки нуждается в настоящем signal, но уже в design можно запретить запуск процесса без того, что будет этот signal интерпретировать и останавливать. Модель отделяет проверяемую форму плана от эмпирического ответа о capacity.'),
+ h2('Switch: совместимость данных не равна переключению трафика'),
+ p('После backfill возникает соблазн сделать switch одним флагом. Но data compatibility и traffic exposure не совпадают. У новой формы может быть заполнено 100% записей в известной области, а новый reader может всё ещё получать старую запись из retry, реплики, очереди или временной интеграции. Поэтому switch требует своих критериев: какой reader переключаем, по какому input, что считается divergence, кто останавливает rollout и остаётся ли старый representation записываемым.'),
+ p('Это также место для честной границы наблюдения. Rehearsal способен показать заранее выбранный mixed-version scenario и stop path. Он не гарантирует, что production обладает той же формой данных, распределением нагрузки или внешними consumer-ами. Google SRE Book отличает hermetic testing от production tests и подчёркивает, что поведение реальной среды не исчерпывается одним test result. Для автора M7 это означает простую речь: называть rehearsal доказательством конкретного маршрута, не доказательством отсутствия всех рисков.'),
+ h2('Contract: условие удаления формулируется отрицательно'),
+ p('Слабое условие contract звучит так: «новый код уже раскатан». Сильнее звучит отрицательное: «не осталось declared old reader/writer, которому требуется old representation; data owner подтвердил результат; recovery boundary описана». Такое условие труднее подделать красивым дашбордом. Оно заставляет спросить о batch job, failed rollout, законсервированном consumer и rollback path до удаления.'),
+ p('Если старое поле или таблица уже удалены, возврат application binary не обещает старые данные. Поэтому cleanup не следует приклеивать к switch как автоматический хвост. В Stripe case удаление устаревших данных и прекращение старых writes были отдельной финальной фазой после перехода. Это исторический пример их системы, не срок для вашей команды. Его ценность в разделении обязательств: прежде чем убрать старое, убедиться, что источник истины действительно сменился, а не только один экран показывает новый ответ.'),
+ h2('Порядок проектирования механизма'),
+ ol([
+ 'Определить old и new representation без терминов «как-нибудь nullable». Зафиксировать значение отсутствия, default и ошибки преобразования.',
+ 'Составить матрицу v1/v2/v3 для reader и writer. Если клетка неизвестна, обозначить её blocker, а не предположением.',
+ 'Выбрать expand, который сохраняет declared old writer. Сверить операцию с документацией именно используемой версии database engine.',
+ 'Задать migration route: scope, повторяемость, owner, bounded work, stop signal и что произойдёт с partial progress.',
+ 'Проверить rehearsal только против сформулированного scenario: version mix, data shape, stop route и rollback draft.',
+ 'Переключить reader по evidence, а не по дате. Сохранить compatible path до результата review.',
+ 'Вынести contract в отдельное решение. При неизвестном old consumer или recovery gap оставить старое представление.',
+ ]),
+ h2('Ограничения и следующий шаг'),
+ p('Модель не описывает replication lag, foreign keys, triggers, generated columns, ORM caching, timezone, encryption, archival policy, data residency и audit trail. Она не определяет, можно ли делать dual write атомарно: это зависит от границы транзакции и выбранных систем. Не путайте synthetic route со схемой production permissions. PostgreSQL 16 documentation не даёт поведению другого хранилища, Stripe не даёт ваш traffic profile, а Google SRE Book не вычисляет database capacity.'),
+ p('Следующий шаг — взять один ожидаемый schema change и написать compatibility matrix из пяти строк: v1 reader, v1 writer, v2 reader, v2 writer, contract criterion. Если хотя бы одна строка требует предположения, не запускать backfill «на пробу». Сначала закрыть вопрос owner-ом, логикой старой формы или ограниченной rehearsal. В этом и есть практичный рост автора: не искать универсальный migration tool, а сделать несовместимость видимой до того, как она станет данными.'),
+ h2('Историческая граница апреля 2024'),
+ p('Материал ограничен источниками, доступными к апрелю 2024: Stripe case 2017, PostgreSQL 16 от сентября 2023 и Google SRE Book. Термины expand, migrate и contract здесь — способ вести инженерный разговор, а не стандарт SQL. Для неизвестного движка каждое поведение нужно перепроверить по его официальной документации и собственной rehearsal.'),
+]);
+
+const field = revision({
+ slug: 'editorial-2024-04-field-data-migrations',
+ title: 'Безопасная миграция данных: rehearsal, stop gate и случай раннего contract',
+ categories: ['Данные', 'Миграции'],
+ cover: '/assets/editorial/2024/data-migrations-2024-rehearsal-gate.svg',
+ excerpt: 'Полевой разбор учебного кейса: схема уже расширена, backfill просит больше ресурса, а удалить старую форму ещё нельзя. Как поставить stop gate и не выдать тест за production-доказательство.',
+ readingMinutes: 12,
+}, [
+ p('Ситуация: команда добавила новое представление заказа и выпустила v2, которая читает обе формы. Старый v1 writer ещё объявлен допустимым, поэтому v2 временно пишет old и new representation. Затем появляется срочная просьба завершить migration: backfill нужно «просто догнать», а старое поле хочется удалить до конца спринта. Симптом тревожный: план одновременно предполагает mixed fleet, неограниченную обработку старых записей и contract после одного удачного прогона. Цена — непонятный partial state и откат, который возвращает код, но не форму данных.'),
+ p('Этот разбор не выдаёт выдуманный incident за факт. Ниже только fixed synthetic case в памяти Node: он не открывает базу, не запускает SQL, не измеряет нагрузку и не узнаёт реальные версии сервиса. Его польза в другом: он заставляет назвать тот момент, где надо остановиться. Если backfill просит больше ресурса, нет права решать вопрос фразой «давайте увеличим batch». Сначала проверяют совместимость old/new, stop condition и recovery boundary.'),
+ h2('Кейс в одной строке: симптом → причина → проверка → действие'),
+ table('Разбор фиксированного учебного кейса', ['Шаг', 'Наблюдение', 'Причина', 'Проверка', 'Действие'], [
+ ['1. Expand', 'v1 ещё пишет old form', 'v1 не знает новую форму', 'old writer должен остаться accepted', 'не вводить schema rule, которая отвергнет v1'],
+ ['2. Migrate', 'backfill не имеет конца', 'нет declared stop condition', 'scope, bounded unit и owner не записаны', 'остановить draft и оформить управляемый route'],
+ ['3. Switch', 'v2 читает new form', 'старые записи могут остаться old', 'v2 reader обязан принять old/new', 'сохранить fallback до evidence'],
+ ['4. Contract', 'старое хотят удалить', 'v1 ещё declared', 'нет доказательства отсутствия old consumer', 'не удалять old representation'],
+ ['5. Rehearsal', 'один проход зеленый', 'test приняли за production proof', 'что именно было проверено и как остановиться', 'сформировать gate вопросов и rollback draft'],
+ ]),
+ p('Первое действие в таком кейсе не техническое. Нужно разложить «готовность» на наблюдаемые части. Есть ли old writer? Может ли v2 reader прочитать old record? Подтверждено ли, что new writer продолжает создавать форму для old reader? Что считается остановкой backfill? Что останется после остановки? Слова «кажется, старых уже нет» не являются evidence. Они могут быть гипотезой для rehearsal, но не условием contract.'),
+ figure('/assets/editorial/2024/data-migrations-2024-rehearsal-gate.svg', 'Схема rehearsal gate: сначала зафиксированы version mix и data shape, затем проверены совместимость old/new, ограниченный backfill и stop signal, после чего требуется rollback draft. При отсутствии любого пункта стрелка ведёт к stop and review, а не к contract.', 'Gate представляет вопросы к владельцу изменения. Он не выполняет rehearsal, не читает метрики и не подтверждает готовность production-среды.'),
+ h2('Почему schema ahead of code — не только DDL-ошибка'),
+ p('Пусть новое schema rule объявляет new representation обязательным до того, как v1 writer перестал существовать. У v1 нет возможности записать нужный факт. Это несовместимость контракта, даже если operation сама по себе поддерживается выбранной СУБД. В реальном мире outcome зависит от конкретного default, constraint, trigger, deployment order и ошибок. Но проектное решение можно принять раньше: expand не имеет права переводить declared old writer в отказ без согласованного cutover.'),
+ p('PostgreSQL 16 даёт полезный, ограниченный пример. Его документация различает add constraint сразу и путь NOT VALID с последующей VALIDATE CONSTRAINT; для старых строк существует отдельная проверка. Это помогает увидеть, что «схема приняла команду» и «старые данные доказанно соответствуют новому инварианту» — разные события. Но точные locks и доступные формы commands относятся к PostgreSQL 16. Не используйте этот пример как совет выполнять те же операции в другой БД или как обход обязательного data review.'),
+ h2('Backfill не должен маскировать отсутствие решения'),
+ p('Когда backfill начинает давить на БД, команда обычно видит только один симптом: основная нагрузка стала дороже. Но причина может быть разной: широкая область, неверное условие отбора, слишком параллельный consumer, конкурирующий индекс, retry без дедупликации, или отсутствие stop signal. Без различения причин «замедлить джобу» становится неопределённым действием. Оно может ослабить симптом, но не даёт ответа, что делать после следующей остановки и как долго dual write надо сохранять.'),
+ p('В этом кейсе фиксированная модель намеренно не называет число запросов, размер порции или latency. Такие цифры без среды выглядят точными, но не дают переносимого решения. Вместо них record требует bounded-synthetic-batches и declared-before-start. Это минимальная граница: у работы есть владелец и заранее названный момент остановки. Реальный порог получают только из собственных измерений и согласованных SLO; fixture их не подменяет.'),
+ h2('Исполнимый пример: фиксированные branches, а не database tool'),
+ p('Пример создаёт только selector одного embedded case, затем строит in-memory report и decision draft. В case fixed-unbounded-backfill report должен остановить route, а в fixed-contract-with-mixed-fleet — запретить переход к contract. Вход закрыт: подмена report, дополнительное database-like поле или просьба включить real-execution mode попадают в отрицательные ветки. Так сохраняется граница между article fixture и настоящим migration automation.'),
+ code(fixtureExample),
+ p('У fixture есть ещё один важный отказ: rollback принимает только canonical synthetic plan. Если кто-то заменит список действий на «сделать миграцию», передаст разрежённый список или циклический report, функция не вернёт snapshot и не выбросит наружу непроверяемый объект. Snapshot получает собственную копию действий, а не ссылку на массив caller-а. Это не защита реальной базы. Это защита смысла учебного примера: нельзя назвать rollback-ом строку, которая не была получена из проверенного fixed report. В реальном release rollback должен быть описан намного точнее — от кода и feature gate до data repair и момента, после которого старую форму уже не восстановить.'),
+ h2('Rehearsal: что она доказывает, а что нет'),
+ p('Rehearsal полезна, когда повторяет выбранный путь с заранее известными вопросами. Для migration это обычно version mix, старая и новая форма записи, expected fallback, ограниченный старт/stop и реакция на divergence. Результат может показать, что договорённый сценарий прошёл или что он развалился на конкретной границе. Он не показывает, что в production больше нет старых worker-ов, что real data имеет ту же форму или что нагрузка будет той же.'),
+ p('Google SRE Book здесь особенно трезв: passing test не является доказательством reliability, а тесты снижают неопределённость по конкретным изменениям. Поэтому rehearsal gate не должен выпускать contract автоматически. Его задача — удержать условия рядом: compatible version mix, declared data shape, stop signal, owner, rollback draft. Если чего-то нет, правильный результат — stop and review. Это успешная работа gate, а не неудача команды.'),
+ h2('Когда rollback уже невозможен'),
+ p('Самая опасная ошибка — назвать обратимым удаление старого representation. Пока dual write и old path существуют, можно вернуть reader к совместимой ветке и прекратить switch. После удаления, агрессивной очистки или преобразования с потерей значения риск меняется: вам может понадобиться data repair, restore из backup или решение владельца данных, а не rollback binary. Эту границу следует записать прямо в runbook.'),
+ p('Stripe в case 2017 описывает final removal после того, как code перестал зависеть от old store. Это поддерживает идею отдельного этапа cleanup, но не гарантирует ваш recovery. Их миграция, storage, MapReduce и Scientist experiments не являются вашими инструментами. Урок переносим осторожно: не встраивать contract в начало работы, а сделать его последним решением, опирающимся на evidence конкретного проекта.'),
+ h2('Практический rehearsal gate'),
+ ol([
+ 'Назвать exact scope rehearsal: какая версия reader/writer, какая форма данных и какой fallback участвуют. Не писать «проверить миграцию целиком».',
+ 'Проверить expand-условие: declared old writer не отвергнут, new reader имеет определённый ответ на old/new record.',
+ 'Описать backfill как управляемый draft: owner, bounded work, idempotency assumption, stop signal и expected partial state.',
+ 'Определить switch evidence: что показывает readiness новой формы и какое наблюдение остановит переход.',
+ 'Записать rollback boundary отдельно: что обратимо в code path, что остаётся в данных и когда нужен data repair вместо rollback.',
+ 'Провести rehearsal в разрешённой среде и сохранить только тот вывод, который действительно проверялся. Не переносить его на production без новых evidence.',
+ 'Решить contract отдельной записью. При unknown old consumer, missing rollback или unbounded backfill оставить old representation и продолжить review.',
+ ]),
+ h2('Ограничения и следующий шаг'),
+ p('Кейс не моделирует базу данных, очереди, транзакции, retries, locks, резервные копии, мониторинг, feature flags, scheduling или CI. Он не советует SQL и не даёт load settings. Данные в fixture синтетические и фиксированные; слова «compatible» и «stop» относятся только к заранее записанным объектам. Даже реальный rehearsal не заменит review privacy, security, retention и влияния на другие команды.'),
+ p('Следующий шаг — выбрать один ближайший migration change и провести короткое совместное review в форме этой таблицы. Принести четыре версии ролей, форму old/new, переходный writer, backfill stop condition и rollback boundary. Если ответ хотя бы на один пункт отсутствует, не прятать риск за очередной параметр batch. Оставить change в expand/migrate phase, пока факт не появится. Это прагматичнее, чем закончить sprint чистой схемой и неясными данными.'),
+ h2('Историческая граница апреля 2024'),
+ p('В апреле 2024 уже существовали используемые здесь источники: Stripe engineering case 2017, PostgreSQL 16 2023 и Google SRE Book. Статья не переносит их operational details на неизвестный проект. Автор M7 формулирует узкий вопрос, называет цену ошибки и оставляет visible stop gate вместо обещания универсального zero-downtime migration.'),
+]);
+
+export const revisions = Object.freeze([practice, mechanism, field]);
+
+if (process.argv.includes('--verify-fixture')) {
+ const result = runDataMigrationFixture();
+ process.stdout.write('PASS fixture: ' + result.assertions + '/' + result.expectedCount + ' assertions\n');
+}
+
+if (process.argv.includes('--print-revisions')) {
+ process.stdout.write(JSON.stringify(revisions) + '\n');
+}