revise April 2024 data migration articles
Build and deploy / deploy (push) Successful in 14s

This commit is contained in:
2026-07-31 15:46:48 +03:00
parent a93936ecbf
commit b28d2c14cf
7 changed files with 1044 additions and 1 deletions
+876
View File
@@ -0,0 +1,876 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const sources = [
{
title: 'Stripe Engineering: Online migrations at scale, 02.02.2017',
url: 'https://stripe.com/blog/online-migrations',
note: 'Первичный инженерный разбор Stripe, опубликованный задолго до апреля 2024: четыре фазы — dual write, переключение чтений, переключение записей и удаление старого — применены к их subscriptions. Это наблюдение Stripe для конкретной инфраструктуры и объёма данных, а не обещание, что любая БД выдержит такой маршрут без собственных лимитов и проверки.',
},
{
title: 'PostgreSQL 16: ALTER TABLE',
url: 'https://www.postgresql.org/docs/16/sql-altertable.html',
note: 'Первичная документация PostgreSQL 16. Она описывает PostgreSQL-специфичные свойства ADD COLUMN, NOT VALID и VALIDATE CONSTRAINT, включая разные блокировки и проверку старых строк. Эти детали нельзя переносить на другую СУБД, ORM или managed service без её собственной документации и rehearsal.',
},
{
title: 'PostgreSQL 16 released, 14.09.2023',
url: 'https://www.postgresql.org/about/news/postgresql-16-released-2715/',
note: 'Официальное сообщение PostgreSQL Global Development Group фиксирует историческую доступность версии 16 до апреля 2024. Оно подтверждает дату версии, но не доказывает версию, настройки, размер таблиц или lock-поведение чьей-либо системы.',
},
{
title: 'Google SRE Book: Testing for Reliability',
url: 'https://sre.google/sre-book/testing-reliability/',
note: 'Первичный материал Google SRE Book, доступный до апреля 2024: тест снижает неопределённость, но проход теста не доказывает надёжность; рискованные инструменты требуют отдельного барьера. Это общий принцип release engineering, не руководство по синтаксису или нагрузке конкретной базы данных.',
},
];
function sourceList() {
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function revision(meta, parts) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength);
}
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const MODEL_LIMIT = 'fixed-marked-synthetic-in-memory-data-migration-model-no-database-no-sql-no-network-no-files-no-clock-no-ci-no-migration-no-load-measurement-no-production-claim';
const SYNTHETIC_SCOPE = 'synthetic-data-migration-2024-04';
const INPUT_KIND = 'synthetic-data-migration-input-v1';
const REPORT_KIND = 'synthetic-data-migration-report-v1';
const PLAN_KIND = 'synthetic-data-migration-plan-v1';
const GATE_KIND = 'synthetic-data-migration-rehearsal-gate-v1';
const FIXED_CASES = Object.freeze({
'fixed-expand-compatible': Object.freeze({
phase: 'migrate',
label: 'old and new application versions can use the expanded shape',
compatibility: Object.freeze({
oldReader: 'reads-old-representation',
oldWriter: 'writes-old-representation',
newReader: 'reads-new-or-old-representation',
newWriter: 'writes-old-and-new-representation',
oldRepresentation: 'retained',
newRepresentation: 'available',
}),
backfill: Object.freeze({
route: 'bounded-synthetic-batches',
stopCondition: 'declared-before-start',
observedLoad: 'not-measured',
}),
rehearsal: Object.freeze({
versionMix: 'declared',
rollbackDraft: 'declared',
dataShape: 'declared',
result: 'not-run',
}),
}),
'fixed-schema-ahead-of-old-writer': Object.freeze({
phase: 'expand',
label: 'new representation rejects an old writer before compatible code is declared',
compatibility: Object.freeze({
oldReader: 'reads-old-representation',
oldWriter: 'rejected-by-new-representation',
newReader: 'reads-new-or-old-representation',
newWriter: 'writes-old-and-new-representation',
oldRepresentation: 'retained',
newRepresentation: 'available',
}),
backfill: Object.freeze({
route: 'not-started',
stopCondition: 'declared-before-start',
observedLoad: 'not-measured',
}),
rehearsal: Object.freeze({
versionMix: 'declared',
rollbackDraft: 'declared',
dataShape: 'declared',
result: 'not-run',
}),
}),
'fixed-unbounded-backfill': Object.freeze({
phase: 'migrate',
label: 'compatible versions exist, but the synthetic backfill has no stop boundary',
compatibility: Object.freeze({
oldReader: 'reads-old-representation',
oldWriter: 'writes-old-representation',
newReader: 'reads-new-or-old-representation',
newWriter: 'writes-old-and-new-representation',
oldRepresentation: 'retained',
newRepresentation: 'available',
}),
backfill: Object.freeze({
route: 'unbounded-synthetic-sweep',
stopCondition: 'missing',
observedLoad: 'not-measured',
}),
rehearsal: Object.freeze({
versionMix: 'declared',
rollbackDraft: 'declared',
dataShape: 'declared',
result: 'not-run',
}),
}),
'fixed-contract-with-mixed-fleet': Object.freeze({
phase: 'contract',
label: 'old representation is proposed for removal while an old version is still declared',
compatibility: Object.freeze({
oldReader: 'reads-old-representation',
oldWriter: 'writes-old-representation',
newReader: 'reads-new-representation',
newWriter: 'writes-new-representation',
oldRepresentation: 'proposed-remove',
newRepresentation: 'available',
}),
backfill: Object.freeze({
route: 'bounded-synthetic-batches',
stopCondition: 'declared-before-start',
observedLoad: 'not-measured',
}),
rehearsal: Object.freeze({
versionMix: 'old-version-still-declared',
rollbackDraft: 'declared',
dataShape: 'declared',
result: 'not-run',
}),
}),
'fixed-rehearsal-without-rollback': Object.freeze({
phase: 'rehearsal',
label: 'version compatibility is declared, but the reversible response is absent',
compatibility: Object.freeze({
oldReader: 'reads-old-representation',
oldWriter: 'writes-old-representation',
newReader: 'reads-new-or-old-representation',
newWriter: 'writes-old-and-new-representation',
oldRepresentation: 'retained',
newRepresentation: 'available',
}),
backfill: Object.freeze({
route: 'bounded-synthetic-batches',
stopCondition: 'declared-before-start',
observedLoad: 'not-measured',
}),
rehearsal: Object.freeze({
versionMix: 'declared',
rollbackDraft: 'missing',
dataShape: 'declared',
result: 'not-run',
}),
}),
});
const FIXED_CASE_IDS = Object.freeze(Object.keys(FIXED_CASES));
const INPUT_KEYS = Object.freeze(['synthetic', 'kind', 'scope', 'mode', 'caseId']);
const REPORT_KEYS = Object.freeze([
'kind',
'syntheticOnly',
'accepted',
'caseId',
'scope',
'modelLimit',
'phase',
'verdict',
'reasons',
'compatibility',
'backfill',
'rehearsal',
'nextDraftAction',
'evidence',
'productionEffect',
]);
const PLAN_KEYS = Object.freeze([
'kind',
'syntheticOnly',
'accepted',
'caseId',
'reportFingerprint',
'verdict',
'actions',
'authority',
'modelLimit',
'productionEffect',
]);
const GATE_KEYS = Object.freeze([
'kind',
'syntheticOnly',
'accepted',
'caseId',
'state',
'checks',
'reason',
'modelLimit',
'productionEffect',
]);
function hasExactKeys(value, expected) {
return Boolean(value)
&& typeof value === 'object'
&& !Array.isArray(value)
&& Object.keys(value).length === expected.length
&& expected.every((key) => Object.hasOwn(value, key));
}
function hasDenseArray(value) {
return Array.isArray(value)
&& Object.keys(value).length === value.length
&& Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean);
}
function hasSameCanonicalJson(actual, expected) {
try {
return JSON.stringify(actual) === JSON.stringify(expected);
} catch {
return false;
}
}
function cloneFixed(value) {
return JSON.parse(JSON.stringify(value));
}
function fingerprint(caseId, fixed) {
return [
caseId,
fixed.phase,
fixed.compatibility.oldReader,
fixed.compatibility.oldWriter,
fixed.compatibility.newReader,
fixed.compatibility.newWriter,
fixed.compatibility.oldRepresentation,
fixed.backfill.route,
fixed.backfill.stopCondition,
fixed.rehearsal.versionMix,
fixed.rehearsal.rollbackDraft,
].join('|');
}
function rejectInput(reason) {
return Object.freeze({
kind: REPORT_KIND,
syntheticOnly: true,
accepted: false,
reason,
modelLimit: MODEL_LIMIT,
database: 'not-opened',
sql: 'not-run',
filesystem: 'not-read',
network: 'not-used',
clock: 'not-read',
ci: 'not-touched',
migration: 'not-started',
productionEffect: 'not-attempted',
});
}
function reasonsFor(fixed) {
const reasons = [];
if (fixed.phase === 'expand' && fixed.compatibility.oldWriter !== 'writes-old-representation') {
reasons.push('expand-rejects-declared-old-writer');
}
if (fixed.phase === 'migrate' && fixed.compatibility.newWriter !== 'writes-old-and-new-representation') {
reasons.push('migrate-has-no-declared-dual-write');
}
if (fixed.backfill.route !== 'bounded-synthetic-batches' || fixed.backfill.stopCondition !== 'declared-before-start') {
reasons.push('backfill-has-no-declared-bounded-stop-condition');
}
if (fixed.phase === 'contract' && (
fixed.compatibility.oldRepresentation !== 'retired'
|| fixed.rehearsal.versionMix !== 'no-old-version-declared'
)) {
reasons.push('contract-while-old-version-or-old-representation-remains');
}
if (fixed.rehearsal.versionMix !== 'declared' && fixed.rehearsal.versionMix !== 'no-old-version-declared') {
reasons.push('rehearsal-version-mix-not-declared');
}
if (fixed.rehearsal.rollbackDraft !== 'declared') {
reasons.push('rehearsal-has-no-declared-rollback-draft');
}
if (fixed.rehearsal.dataShape !== 'declared') {
reasons.push('rehearsal-data-shape-not-declared');
}
return Object.freeze(reasons);
}
function nextDraftAction(fixed, reasons) {
if (reasons.includes('expand-rejects-declared-old-writer')) {
return Object.freeze(['restore-compatible-expand-draft', 'keep-old-writer-path', 'review-schema-contract-before-code-rollout']);
}
if (reasons.includes('backfill-has-no-declared-bounded-stop-condition')) {
return Object.freeze(['write-bounded-synthetic-backfill-draft', 'name-stop-condition', 'review-with-data-owner']);
}
if (reasons.includes('contract-while-old-version-or-old-representation-remains')) {
return Object.freeze(['keep-old-representation', 'retire-old-version-by-evidence', 'repeat-compatibility-review']);
}
if (reasons.includes('rehearsal-has-no-declared-rollback-draft')) {
return Object.freeze(['write-reversible-response-draft', 'name-owner-and-stop-signal', 'repeat-synthetic-gate']);
}
return Object.freeze(['record-compatible-synthetic-state', 'prepare-human-rehearsal-question', 'do-not-authorize-real-migration']);
}
/**
* Returns one marked, fixed in-memory record selector. It does not contain a
* database connection, SQL text, path, clock, file reference, CI result or
* operational command. Unknown selectors deliberately lose the synthetic mark.
*/
export function createFixedSyntheticMigrationInput(caseId) {
if (!FIXED_CASE_IDS.includes(caseId)) {
return Object.freeze({
synthetic: false,
kind: 'unknown-synthetic-data-migration-input',
scope: SYNTHETIC_SCOPE,
mode: 'fixed-memory-only',
caseId,
});
}
return Object.freeze({
synthetic: true,
kind: INPUT_KIND,
scope: SYNTHETIC_SCOPE,
mode: 'fixed-memory-only',
caseId,
});
}
/**
* Classifies only five fixed training records embedded in this module. It does
* not inspect a repository, table, database, SQL migration, log, metric, clock,
* queue, network endpoint, CI output or production environment. A successful
* report says only that this exact in-memory model has a coherent draft route.
*/
export function inspectSyntheticDataMigration(input) {
if (!hasExactKeys(input, INPUT_KEYS)) return rejectInput('closed-synthetic-input-contract-required');
if (input.synthetic !== true) return rejectInput('synthetic-marker-required');
if (input.kind !== INPUT_KIND) return rejectInput('synthetic-kind-required');
if (input.scope !== SYNTHETIC_SCOPE) return rejectInput('synthetic-scope-required');
if (input.mode !== 'fixed-memory-only') return rejectInput('fixed-memory-mode-required');
if (!FIXED_CASE_IDS.includes(input.caseId)) return rejectInput('fixed-synthetic-case-required');
const fixed = FIXED_CASES[input.caseId];
const reasons = reasonsFor(fixed);
const verdict = reasons.length === 0 ? 'synthetic-gate-open' : 'synthetic-stop-and-review';
const result = {
kind: REPORT_KIND,
syntheticOnly: true,
accepted: true,
caseId: input.caseId,
scope: SYNTHETIC_SCOPE,
modelLimit: MODEL_LIMIT,
phase: fixed.phase,
verdict,
reasons,
compatibility: Object.freeze(cloneFixed(fixed.compatibility)),
backfill: Object.freeze(cloneFixed(fixed.backfill)),
rehearsal: Object.freeze(cloneFixed(fixed.rehearsal)),
nextDraftAction: nextDraftAction(fixed, reasons),
evidence: Object.freeze({
source: 'embedded-fixed-marked-synthetic-record-only',
fingerprint: fingerprint(input.caseId, fixed),
database: 'not-opened',
sql: 'not-run',
filesystem: 'not-read',
network: 'not-used',
clock: 'not-read',
ci: 'not-touched',
migration: 'not-started',
loadMeasurement: 'not-performed',
}),
productionEffect: 'not-attempted',
};
return Object.freeze(result);
}
function isCanonicalReport(report) {
if (!hasExactKeys(report, REPORT_KEYS)
|| report.kind !== REPORT_KIND
|| report.syntheticOnly !== true
|| report.accepted !== true
|| !FIXED_CASE_IDS.includes(report.caseId)
|| report.scope !== SYNTHETIC_SCOPE
|| report.modelLimit !== MODEL_LIMIT
|| report.productionEffect !== 'not-attempted'
|| !hasDenseArray(report.reasons)
|| !hasDenseArray(report.nextDraftAction)) {
return false;
}
const canonical = inspectSyntheticDataMigration(createFixedSyntheticMigrationInput(report.caseId));
return hasSameCanonicalJson(report, canonical);
}
function rejectPlan(reason) {
return Object.freeze({
kind: PLAN_KIND,
syntheticOnly: true,
accepted: false,
reason,
modelLimit: MODEL_LIMIT,
database: 'not-opened',
sql: 'not-run',
filesystem: 'not-read',
network: 'not-used',
clock: 'not-read',
ci: 'not-touched',
migration: 'not-started',
productionEffect: 'not-attempted',
});
}
/**
* Converts a canonical fixture report into a decision draft. The return value
* is data for a human discussion, not an instruction to deploy code, alter a
* schema, start a backfill, schedule work or call a database.
*/
export function planSyntheticDataMigration(report) {
if (!isCanonicalReport(report)) return rejectPlan('canonical-synthetic-report-required');
const actions = report.verdict === 'synthetic-gate-open'
? Object.freeze(['record-rehearsal-questions', 'confirm-human-owner', 'retain-old-representation-until-contract-evidence'])
: report.nextDraftAction;
return Object.freeze({
kind: PLAN_KIND,
syntheticOnly: true,
accepted: true,
caseId: report.caseId,
reportFingerprint: report.evidence.fingerprint,
verdict: report.verdict,
actions,
authority: 'human-review-required-no-real-migration-authorized',
modelLimit: MODEL_LIMIT,
productionEffect: 'not-attempted',
});
}
function canonicalPlanFor(caseId) {
return planSyntheticDataMigration(inspectSyntheticDataMigration(createFixedSyntheticMigrationInput(caseId)));
}
function isCanonicalPlan(plan) {
if (!hasExactKeys(plan, PLAN_KEYS)
|| plan.kind !== PLAN_KIND
|| plan.syntheticOnly !== true
|| plan.accepted !== true
|| !FIXED_CASE_IDS.includes(plan.caseId)
|| plan.modelLimit !== MODEL_LIMIT
|| plan.productionEffect !== 'not-attempted'
|| !hasDenseArray(plan.actions)) {
return false;
}
return hasSameCanonicalJson(plan, canonicalPlanFor(plan.caseId));
}
/**
* Creates a fixed rehearsal gate from a canonical report. No rehearsal is run:
* the gate contains only the questions that a real owner must answer before
* touching a real environment.
*/
export function evaluateSyntheticRehearsalGate(report) {
if (!isCanonicalReport(report)) {
return Object.freeze({
kind: GATE_KIND,
syntheticOnly: true,
accepted: false,
reason: 'canonical-synthetic-report-required',
modelLimit: MODEL_LIMIT,
productionEffect: 'not-attempted',
});
}
const open = report.verdict === 'synthetic-gate-open'
&& report.rehearsal.versionMix === 'declared'
&& report.rehearsal.rollbackDraft === 'declared'
&& report.rehearsal.dataShape === 'declared';
return Object.freeze({
kind: GATE_KIND,
syntheticOnly: true,
accepted: open,
caseId: report.caseId,
state: open ? 'questions-ready-for-human-rehearsal' : 'stop-before-human-rehearsal',
checks: Object.freeze([
'version-mix-is-declared',
'data-shape-is-declared',
'stop-condition-is-declared',
'rollback-draft-is-declared',
]),
reason: open ? 'fixed-synthetic-prerequisites-present' : 'fixed-synthetic-prerequisites-missing',
modelLimit: MODEL_LIMIT,
productionEffect: 'not-attempted',
});
}
/**
* Restores only the canonical in-memory decision draft. It cannot restore
* data, schema, code, files, CI state, traffic, backups or any production
* system. A forged plan is rejected before a snapshot is returned.
*/
export function rollbackSyntheticDataMigrationDraft(plan) {
if (!isCanonicalPlan(plan)) {
return Object.freeze({
restored: false,
syntheticOnly: true,
reason: 'canonical-synthetic-plan-required',
modelLimit: MODEL_LIMIT,
productionEffect: 'not-attempted',
});
}
return Object.freeze({
restored: true,
syntheticOnly: true,
reason: 'fixed-synthetic-decision-draft-restored',
snapshot: Object.freeze({
caseId: plan.caseId,
verdict: plan.verdict,
actions: Object.freeze([...plan.actions]),
reportFingerprint: plan.reportFingerprint,
}),
database: 'not-opened',
sql: 'not-run',
filesystem: 'not-read',
network: 'not-used',
clock: 'not-read',
ci: 'not-touched',
migration: 'not-started',
productionEffect: 'not-attempted',
});
}
function assertFixture(condition, label, assertions) {
if (!condition) throw new Error('fixture assertion failed: ' + label);
assertions.push(label);
}
export function runDataMigrationFixture() {
const assertions = [];
const compatibleInput = createFixedSyntheticMigrationInput('fixed-expand-compatible');
const compatible = inspectSyntheticDataMigration(compatibleInput);
const compatiblePlan = planSyntheticDataMigration(compatible);
const compatibleGate = evaluateSyntheticRehearsalGate(compatible);
const compatibleRollback = rollbackSyntheticDataMigrationDraft(compatiblePlan);
assertFixture(compatible.syntheticOnly === true, 'valid report stays synthetic', assertions);
assertFixture(compatible.accepted === true, 'fixed selector passes closed input contract', assertions);
assertFixture(compatible.verdict === 'synthetic-gate-open', 'compatible record opens only synthetic gate', assertions);
assertFixture(compatible.reasons.length === 0, 'compatible record has no synthetic blocker', assertions);
assertFixture(compatible.compatibility.oldWriter === 'writes-old-representation', 'old writer remains compatible in expand route', assertions);
assertFixture(compatible.compatibility.newWriter === 'writes-old-and-new-representation', 'new writer declares dual representation', assertions);
assertFixture(compatible.backfill.route === 'bounded-synthetic-batches', 'backfill is bounded only in model', assertions);
assertFixture(compatible.backfill.observedLoad === 'not-measured', 'fixture does not measure load', assertions);
assertFixture(compatible.evidence.database === 'not-opened', 'fixture does not open database', assertions);
assertFixture(compatible.evidence.sql === 'not-run', 'fixture does not run SQL', assertions);
assertFixture(compatiblePlan.accepted === true, 'canonical report creates decision draft', assertions);
assertFixture(compatiblePlan.authority === 'human-review-required-no-real-migration-authorized', 'plan withholds migration authority', assertions);
assertFixture(compatibleGate.accepted === true, 'complete fixed prerequisites accept only the synthetic gate', assertions);
assertFixture(compatibleGate.state === 'questions-ready-for-human-rehearsal', 'complete fixed prerequisites open human-question gate', assertions);
assertFixture(compatibleRollback.restored === true, 'canonical plan restores only synthetic draft', assertions);
assertFixture(compatibleRollback.migration === 'not-started', 'synthetic rollback starts no migration', assertions);
const schemaAhead = inspectSyntheticDataMigration(createFixedSyntheticMigrationInput('fixed-schema-ahead-of-old-writer'));
assertFixture(schemaAhead.verdict === 'synthetic-stop-and-review', 'incompatible expand stops draft', assertions);
assertFixture(schemaAhead.reasons.includes('expand-rejects-declared-old-writer'), 'old writer incompatibility has specific reason', assertions);
assertFixture(schemaAhead.nextDraftAction[0] === 'restore-compatible-expand-draft', 'schema-before-code proposes compatible draft instead of execution', assertions);
const unbounded = inspectSyntheticDataMigration(createFixedSyntheticMigrationInput('fixed-unbounded-backfill'));
assertFixture(unbounded.verdict === 'synthetic-stop-and-review', 'unbounded backfill stops draft', assertions);
assertFixture(unbounded.reasons.includes('backfill-has-no-declared-bounded-stop-condition'), 'missing stop condition is explicit', assertions);
assertFixture(planSyntheticDataMigration(unbounded).actions.includes('name-stop-condition'), 'backfill plan names stop condition', assertions);
const earlyContract = inspectSyntheticDataMigration(createFixedSyntheticMigrationInput('fixed-contract-with-mixed-fleet'));
const earlyContractGate = evaluateSyntheticRehearsalGate(earlyContract);
assertFixture(earlyContract.reasons.includes('contract-while-old-version-or-old-representation-remains'), 'mixed fleet blocks contract', assertions);
assertFixture(earlyContractGate.accepted === false && earlyContractGate.state === 'stop-before-human-rehearsal', 'blocked contract does not open rehearsal question gate', assertions);
const missingRollback = inspectSyntheticDataMigration(createFixedSyntheticMigrationInput('fixed-rehearsal-without-rollback'));
assertFixture(missingRollback.reasons.includes('rehearsal-has-no-declared-rollback-draft'), 'missing rollback is explicit', assertions);
assertFixture(missingRollback.nextDraftAction[0] === 'write-reversible-response-draft', 'missing rollback gets draft action', assertions);
const unexpectedDatabase = inspectSyntheticDataMigration({
...compatibleInput,
databaseUrl: 'not-a-real-connection',
});
assertFixture(unexpectedDatabase.accepted === false && unexpectedDatabase.reason === 'closed-synthetic-input-contract-required', 'database-like extra field is rejected', assertions);
const unexpectedFile = inspectSyntheticDataMigration({
...compatibleInput,
migrationFile: 'not-read',
});
assertFixture(unexpectedFile.accepted === false, 'file-like extra field is rejected', assertions);
const wrongMode = inspectSyntheticDataMigration({
...compatibleInput,
mode: 'run-real-migration',
});
assertFixture(wrongMode.accepted === false && wrongMode.reason === 'fixed-memory-mode-required', 'real execution mode is rejected', assertions);
const forgedReport = {
...compatible,
verdict: 'synthetic-stop-and-review',
};
assertFixture(planSyntheticDataMigration(forgedReport).accepted === false, 'forged report is rejected before planning', assertions);
const forgedPlan = {
...compatiblePlan,
actions: Object.freeze(['pretend-to-run-a-migration']),
};
assertFixture(rollbackSyntheticDataMigrationDraft(forgedPlan).restored === false, 'forged plan cannot produce rollback snapshot', assertions);
const cyclicReport = {
...compatible,
evidence: { ...compatible.evidence },
};
cyclicReport.evidence.self = cyclicReport.evidence;
assertFixture(planSyntheticDataMigration(cyclicReport).accepted === false, 'cyclic external report is rejected instead of throwing', assertions);
const sparsePlan = {
...compatiblePlan,
actions: new Array(compatiblePlan.actions.length),
};
assertFixture(rollbackSyntheticDataMigrationDraft(sparsePlan).restored === false, 'sparse plan actions cannot produce rollback snapshot', assertions);
const mutablePlan = {
...compatiblePlan,
actions: [...compatiblePlan.actions],
};
const mutableRollback = rollbackSyntheticDataMigrationDraft(mutablePlan);
mutablePlan.actions[0] = 'pretend-to-run-a-migration';
assertFixture(mutableRollback.restored === true && mutableRollback.snapshot.actions[0] !== 'pretend-to-run-a-migration', 'rollback snapshot does not retain caller action array', assertions);
const unknown = inspectSyntheticDataMigration(createFixedSyntheticMigrationInput('not-a-fixed-case'));
assertFixture(unknown.accepted === false && unknown.reason === 'synthetic-marker-required', 'unknown selector loses synthetic trust', assertions);
const expectedCount = 36;
assertFixture(assertions.length + 1 === expectedCount, 'fixture assertion count is stable', assertions);
return Object.freeze({ assertions: assertions.length, expectedCount, status: 'pass', modelLimit: MODEL_LIMIT });
}
const fixtureExample = [
"import {",
" createFixedSyntheticMigrationInput,",
" inspectSyntheticDataMigration,",
" planSyntheticDataMigration,",
"} from './upgrade-2024-04.mjs';",
'',
"const input = createFixedSyntheticMigrationInput('fixed-contract-with-mixed-fleet');",
'const report = inspectSyntheticDataMigration(input);',
'const plan = planSyntheticDataMigration(report);',
'',
'console.log({',
' verdict: report.verdict,',
' reasons: report.reasons,',
' draftActions: plan.actions,',
'});',
'',
'// Работает только с embedded fixed synthetic records в памяти.',
'// Не открывает БД, не исполняет SQL и не запускает миграцию.',
].join('\n');
const practice = revision({
slug: 'editorial-2024-04-practice-data-migrations',
title: 'Безопасная миграция данных: expand–migrate–contract без ложного отката',
categories: ['Данные', 'Миграции'],
cover: '/assets/editorial/2024/data-migrations-2024-expand-contract-timeline.svg',
excerpt: 'Практический маршрут schema change: сохранить совместимость старого и нового кода, ограничить backfill и не называть удаление столбца откатом.',
readingMinutes: 12,
}, [
p('Симптом появляется в релизном плане: сначала предлагают добавить обязательное поле, затем выкатить код, который его заполняет. Но в кластере ещё живёт старая версия сервиса, она пишет старую форму записи, а новая схема уже отказывается её принять. Цена не сводится к одному падению запроса. Запись может оказаться частично преобразованной, очередь ретраев увеличит нагрузку, а команда потеряет безопасный путь назад: удалённые или перезаписанные данные нельзя вернуть обычным rollback приложения.'),
p('Второй знакомый сценарий выглядит спокойнее. Поле добавили nullable, новый код умеет читать обе формы, и команда запускает backfill «до конца». Процесс начинает конкурировать с обычными запросами за те же ресурсы. Когда латентность растёт, его останавливают, но уже не знают, какие записи изменены и может ли старый код жить с новой формой. Здесь важен не красивый термин migration. Нужен договор о совместимости версий, границе нагрузки и обратимом действии на каждом этапе.'),
h2('Маршрут: symptom → cause → check → action'),
ol([
'<strong>Симптом.</strong> Схема требует новое значение раньше, чем все writers умеют его сформировать, либо backfill не имеет точки остановки.',
'<strong>Причина.</strong> Один change-set пытается одновременно расширить схему, переписать данные, переключить чтения и удалить старый путь. В нём нет периода, где old и new версия допустимы вместе.',
'<strong>Проверка.</strong> Для каждого этапа выпишите четыре участника: old reader, old writer, new reader, new writer. Отдельно зафиксируйте, что сохраняется после остановки backfill и кто принимает решение продолжить.',
'<strong>Действие.</strong> Разделите работу на expand, migrate и contract. Удаление старого представления разрешайте только после отдельного доказательства, а не после green build нового кода.',
]),
h2('Expand — добавить поверхность, не отобрать старую'),
p('Expand означает, что новая форма данных уже существует, но старая ещё разрешена. В простом случае это новое nullable-поле или новая таблица, рядом с прежним представлением. Смысл не в конкретном типе DDL. Смысл в контракте: старая версия должна прочитать и записать то, что умела вчера; новая — прочитать старое и новое, а writer новой версии не должен ломать consumer, который ещё ждёт старую форму. Если это не удаётся сформулировать, схему нельзя выпускать отдельно от кода.'),
p('Отсюда следует неприятный, но полезный вывод: фраза «схема уже выкачена» ничего не говорит о готовности. Для одного движка добавление поля может быть дешёвым, для другого изменение типа может переписать большую часть таблицы. В PostgreSQL 16 документация отдельно описывает, что добавление колонки с non-volatile default обходится без rewrite, а volatile default или изменение типа могут потребовать rewrite таблицы и индексов. Это факт о PostgreSQL 16, не переносимое обещание для выбранной вами СУБД.'),
table('Минимальный контракт совместимости до запуска работы с данными', ['Участник', 'В expand', 'В migrate', 'Что запрещено'], [
['Old reader', 'читает старое представление', 'читает старое представление', 'требовать новое поле только потому, что оно уже добавлено'],
['Old writer', 'пишет старое представление', 'продолжает писать старое', 'получать отказ от новой schema contract'],
['New reader', 'понимает новое или старое', 'проверяет заполненное новое', 'считать пустое поле ошибкой до завершения migration route'],
['New writer', 'может писать обе формы', 'сохраняет dual write до switch', 'тихо перестать поддерживать старый consumer'],
['Data owner', 'фиксирует смысл нового поля', 'подтверждает правило заполнения', 'объявлять completion без evidence и rollback draft'],
]),
figure('/assets/editorial/2024/data-migrations-2024-expand-contract-timeline.svg', 'Временная шкала expand, migrate, switch и contract. На первых трёх этапах старое представление сохраняется; возле migrate отмечены ограниченный backfill и стоп-сигнал. Удаление старого представления допускается только после отдельной проверки совместимости и recovery plan.', 'Схема показывает порядок решений, а не последовательность SQL-команд. Стрелка rollback возвращает только к совместимому приложению и сохранённому представлению, не обещает воскресить удалённые данные.'),
h2('Migrate — отдельно от изменения схемы'),
p('Migrate отвечает на другой вопрос: как привести старые записи к новому представлению, не превратив фоновую работу в неограниченный второй production-трафик. Backfill полезно назвать отдельным процессом с owner, входной областью, idempotency-правилом, маленькой партией, наблюдаемым стоп-сигналом и действием после остановки. Без этого слово «batch» не защищает. Партия может быть маленькой, но бесконечной; запрос может быть корректен, но выполняться в момент, когда основная нагрузка уже заняла бюджет.'),
p('Stripe в разборе Online migrations at scale описывает свой четырёхфазный подход: dual write, переключение readers, затем writers, после чего удаление старого. Там же они выделяют риск дополнительной записи и говорят о постепенном наращивании доли при наблюдении за operational metrics. Это хороший вопрос к собственному проекту, а не готовая настройка. В статье Stripe другой storage, свои сервисы и инструменты. Нельзя заменить этим текстом расчёт concurrency, лимитов и lock-поведения вашей БД.'),
h2('Учебный пример без базы и SQL'),
p('В sidecar-пакете есть только fixed marked synthetic records. Пример ниже не имеет строки подключения, времени, таблицы, SQL, файла миграции или внешнего вызова. Он различает пять заранее заданных ситуаций: совместимый expand, schema ahead of old writer, unbounded backfill, ранний contract при mixed fleet и rehearsal без draft rollback. Так проще увидеть, что разные блокировки требуют разных следующих действий, а не одного ответа «повторить миграцию».'),
code(fixtureExample),
p('Для case с ранним contract report вернёт <code>synthetic-stop-and-review</code> и причину <code>contract-while-old-version-or-old-representation-remains</code>. Это не 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 документация описывает <code>NOT VALID</code>, а затем <code>VALIDATE CONSTRAINT</code>: новые записи уже проверяются, а старые валидируются отдельно. Это не универсальный 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([
'<strong>Симптом.</strong> Новый reader ждёт new shape, старый writer продолжает создавать old shape, а schema change уже считается готовым.',
'<strong>Причина.</strong> Совместимость описали как свойство колонки, а не как четыре роли: old/new reader и old/new writer.',
'<strong>Проверка.</strong> Составить матрицу версий, определить значение отсутствующего поля и явно назвать, какой consumer ещё требует old representation.',
'<strong>Действие.</strong> Оставить совместимый период, сделать 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 добавить <code>databaseUrl</code>, <code>migrationFile</code> или режим реального запуска, функция отклоняет его до классификации. Так fixture не создаёт впечатление, что умеет проверить реальные connection string, таблицы, SQL или deployments. В рабочем инструменте договор может быть шире, но тогда его входы и права должны быть предметом отдельного security review.'),
code(fixtureExample),
p('Case <code>fixed-unbounded-backfill</code> возвращает специальную причину <code>backfill-has-no-declared-bounded-stop-condition</code>. Он не говорит «база перегружена» — никаких метрик не читалось. Это принципиально. Симптом реальной перегрузки нуждается в настоящем 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 сразу и путь <code>NOT VALID</code> с последующей <code>VALIDATE CONSTRAINT</code>; для старых строк существует отдельная проверка. Это помогает увидеть, что «схема приняла команду» и «старые данные доказанно соответствуют новому инварианту» — разные события. Но точные locks и доступные формы commands относятся к PostgreSQL 16. Не используйте этот пример как совет выполнять те же операции в другой БД или как обход обязательного data review.'),
h2('Backfill не должен маскировать отсутствие решения'),
p('Когда backfill начинает давить на БД, команда обычно видит только один симптом: основная нагрузка стала дороже. Но причина может быть разной: широкая область, неверное условие отбора, слишком параллельный consumer, конкурирующий индекс, retry без дедупликации, или отсутствие stop signal. Без различения причин «замедлить джобу» становится неопределённым действием. Оно может ослабить симптом, но не даёт ответа, что делать после следующей остановки и как долго dual write надо сохранять.'),
p('В этом кейсе фиксированная модель намеренно не называет число запросов, размер порции или latency. Такие цифры без среды выглядят точными, но не дают переносимого решения. Вместо них record требует <code>bounded-synthetic-batches</code> и <code>declared-before-start</code>. Это минимальная граница: у работы есть владелец и заранее названный момент остановки. Реальный порог получают только из собственных измерений и согласованных SLO; fixture их не подменяет.'),
h2('Исполнимый пример: фиксированные branches, а не database tool'),
p('Пример создаёт только selector одного embedded case, затем строит in-memory report и decision draft. В case <code>fixed-unbounded-backfill</code> report должен остановить route, а в <code>fixed-contract-with-mixed-fleet</code> — запретить переход к 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');
}