589 lines
79 KiB
JavaScript
589 lines
79 KiB
JavaScript
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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, ' ')
|
||
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function bodyText(content) {
|
||
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||
}
|
||
|
||
const sources = [
|
||
{
|
||
title: 'Martin Fowler: Original Strangler Fig Application, 29.06.2004',
|
||
url: 'https://martinfowler.com/bliki/OriginalStranglerFigApplication.html',
|
||
note: 'Первичный текст автора метафоры: критическое cut-over переписывание оказывается сложнее ожидаемого и рискованно; постепенное вытеснение может раньше дать ценность. Это не готовая инструкция для чужого домена, базы данных или маршрутизатора.',
|
||
},
|
||
{
|
||
title: 'OpenAPI Specification v3.1.0, 15.02.2021',
|
||
url: 'https://spec.openapis.org/oas/v3.1.0.html',
|
||
note: 'Официальная спецификация описывает language-agnostic интерфейс HTTP API, paths, operations и Schema Object. Она помогает фиксировать форму интерфейса, но не доказывает runtime parity, порядок эффектов или поведение неизвестного legacy-модуля.',
|
||
},
|
||
{
|
||
title: 'Google SRE Workbook: Canarying Releases, copyright 2018',
|
||
url: 'https://sre.google/workbook/canarying-releases/',
|
||
note: 'Официальная глава определяет canary как частичное и ограниченное по времени развёртывание с оценкой перед продолжением, требует сравнивать canary и control и называет границы synthetic load. Она не задаёт процент, метрики, полномочия или rollback для этого пакета.',
|
||
},
|
||
];
|
||
|
||
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 < 9000 || proseLength > 11000) {
|
||
throw new Error(meta.slug + ': основной текст должен занимать 9 000–11 000 знаков, сейчас ' + proseLength);
|
||
}
|
||
return Object.freeze({ ...meta, contentHtml, proseLength });
|
||
}
|
||
|
||
const MODEL_LIMIT = 'fixed-explicit-synthetic-records-in-memory; no-legacy-code-or-history-read; no-parity-test-ci-network-or-http; no-coverage-or-performance-measurement; no-real-behavior-preservation-claim';
|
||
const SYNTHETIC_KIND = 'synthetic-legacy-modernization-input-v1';
|
||
const SYNTHETIC_SEAM = 'synthetic-quote-preview-seam';
|
||
const SYNTHETIC_OWNER = 'synthetic-modernization-owner';
|
||
|
||
/**
|
||
* Fixture создаёт только фиксированные, явно помеченные synthetic records в
|
||
* памяти Node. Он не читает legacy code/history, файлы, переменные окружения,
|
||
* часы, базу, CI, сеть или HTTP. Он не запускает parity tests, не измеряет
|
||
* coverage/performance и не заявляет, что поведение реального legacy-модуля
|
||
* сохранено. Результат описывает только форму decision record.
|
||
*/
|
||
export const syntheticModernizationInput = Object.freeze({
|
||
kind: SYNTHETIC_KIND,
|
||
synthetic: true,
|
||
seam: Object.freeze({
|
||
id: SYNTHETIC_SEAM,
|
||
route: 'synthetic-quote-preview',
|
||
unit: 'one-request-one-response-contract',
|
||
owner: SYNTHETIC_OWNER,
|
||
legacyBoundary: 'synthetic-legacy-adapter',
|
||
newBoundary: 'synthetic-modern-adapter',
|
||
state: 'not-read-or-observed',
|
||
}),
|
||
contract: Object.freeze({
|
||
kind: 'synthetic-compatibility-contract-v1',
|
||
rules: Object.freeze([
|
||
Object.freeze({ id: 'synthetic-status', observation: 'response-status-category', requirement: 'preserve-declared-category' }),
|
||
Object.freeze({ id: 'synthetic-payload', observation: 'declared-payload-fields', requirement: 'preserve-declared-semantics' }),
|
||
Object.freeze({ id: 'synthetic-effect', observation: 'declared-effect-marker', requirement: 'do-not-duplicate-declared-effect' }),
|
||
]),
|
||
cases: Object.freeze([
|
||
Object.freeze({ id: 'synthetic-case-valid', input: 'synthetic-valid-request', expected: 'synthetic-success-category', observed: 'not-run' }),
|
||
Object.freeze({ id: 'synthetic-case-invalid', input: 'synthetic-invalid-request', expected: 'synthetic-validation-category', observed: 'not-run' }),
|
||
Object.freeze({ id: 'synthetic-case-repeat', input: 'synthetic-repeat-request', expected: 'synthetic-repeat-effect-category', observed: 'not-run' }),
|
||
]),
|
||
realParity: 'not-run-or-claimed',
|
||
}),
|
||
rollout: Object.freeze({
|
||
kind: 'synthetic-rollout-plan-v1',
|
||
owner: SYNTHETIC_OWNER,
|
||
stages: Object.freeze([
|
||
Object.freeze({ id: 'synthetic-stage-0', route: 'legacy-only', gate: 'manual-seam-review', state: 'not-executed' }),
|
||
Object.freeze({ id: 'synthetic-stage-1', route: 'limited-new-path-proposed', gate: 'manual-contract-evidence-review', state: 'not-executed' }),
|
||
Object.freeze({ id: 'synthetic-stage-2', route: 'expand-after-owner-approval', gate: 'manual-rollout-decision', state: 'not-executed' }),
|
||
]),
|
||
releaseAuthority: 'not-granted',
|
||
}),
|
||
rollback: Object.freeze({
|
||
kind: 'synthetic-rollback-plan-v1',
|
||
route: 'restore-legacy-only-proposal',
|
||
owner: SYNTHETIC_OWNER,
|
||
state: 'not-executed',
|
||
dataRecovery: 'not-planned-or-attempted',
|
||
}),
|
||
evidence: Object.freeze({
|
||
source: 'fixed-synthetic-records',
|
||
realLegacyRead: 'not-performed',
|
||
realParityRun: 'not-performed',
|
||
coverage: 'not-measured',
|
||
performance: 'not-measured',
|
||
}),
|
||
});
|
||
|
||
function rejectSyntheticPlan(reason) {
|
||
return Object.freeze({ accepted: false, reason, syntheticOnly: true, modelLimit: MODEL_LIMIT });
|
||
}
|
||
|
||
function onlyKeys(value, keys) {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||
const prototype = Object.getPrototypeOf(value);
|
||
return (prototype === Object.prototype || prototype === null)
|
||
&& Object.keys(value).length === keys.length
|
||
&& keys.every((key) => Object.hasOwn(value, key));
|
||
}
|
||
|
||
function sameValues(actual, expected) {
|
||
return Array.isArray(actual) && actual.length === expected.length
|
||
&& Object.keys(actual).length === expected.length
|
||
&& expected.every((value, index) => Object.hasOwn(actual, index) && actual[index] === value);
|
||
}
|
||
|
||
function exactObject(actual, expected) {
|
||
return onlyKeys(actual, Object.keys(expected))
|
||
&& Object.entries(expected).every(([key, value]) => actual[key] === value);
|
||
}
|
||
|
||
function exactRecords(actual, expected) {
|
||
return Array.isArray(actual) && actual.length === expected.length
|
||
&& Object.keys(actual).length === expected.length
|
||
&& expected.every((record, index) => Object.hasOwn(actual, index) && exactObject(actual[index], record));
|
||
}
|
||
|
||
function validSeam(value) {
|
||
return exactObject(value, {
|
||
id: SYNTHETIC_SEAM,
|
||
route: 'synthetic-quote-preview',
|
||
unit: 'one-request-one-response-contract',
|
||
owner: SYNTHETIC_OWNER,
|
||
legacyBoundary: 'synthetic-legacy-adapter',
|
||
newBoundary: 'synthetic-modern-adapter',
|
||
state: 'not-read-or-observed',
|
||
});
|
||
}
|
||
|
||
function validContract(value) {
|
||
const rules = [
|
||
{ id: 'synthetic-status', observation: 'response-status-category', requirement: 'preserve-declared-category' },
|
||
{ id: 'synthetic-payload', observation: 'declared-payload-fields', requirement: 'preserve-declared-semantics' },
|
||
{ id: 'synthetic-effect', observation: 'declared-effect-marker', requirement: 'do-not-duplicate-declared-effect' },
|
||
];
|
||
const cases = [
|
||
{ id: 'synthetic-case-valid', input: 'synthetic-valid-request', expected: 'synthetic-success-category', observed: 'not-run' },
|
||
{ id: 'synthetic-case-invalid', input: 'synthetic-invalid-request', expected: 'synthetic-validation-category', observed: 'not-run' },
|
||
{ id: 'synthetic-case-repeat', input: 'synthetic-repeat-request', expected: 'synthetic-repeat-effect-category', observed: 'not-run' },
|
||
];
|
||
return onlyKeys(value, ['kind', 'rules', 'cases', 'realParity'])
|
||
&& value.kind === 'synthetic-compatibility-contract-v1'
|
||
&& value.realParity === 'not-run-or-claimed'
|
||
&& exactRecords(value.rules, rules)
|
||
&& exactRecords(value.cases, cases);
|
||
}
|
||
|
||
function validRollout(value) {
|
||
const stages = [
|
||
{ id: 'synthetic-stage-0', route: 'legacy-only', gate: 'manual-seam-review', state: 'not-executed' },
|
||
{ id: 'synthetic-stage-1', route: 'limited-new-path-proposed', gate: 'manual-contract-evidence-review', state: 'not-executed' },
|
||
{ id: 'synthetic-stage-2', route: 'expand-after-owner-approval', gate: 'manual-rollout-decision', state: 'not-executed' },
|
||
];
|
||
return onlyKeys(value, ['kind', 'owner', 'stages', 'releaseAuthority'])
|
||
&& value.kind === 'synthetic-rollout-plan-v1'
|
||
&& value.owner === SYNTHETIC_OWNER
|
||
&& value.releaseAuthority === 'not-granted'
|
||
&& exactRecords(value.stages, stages);
|
||
}
|
||
|
||
function validRollback(value) {
|
||
return exactObject(value, {
|
||
kind: 'synthetic-rollback-plan-v1',
|
||
route: 'restore-legacy-only-proposal',
|
||
owner: SYNTHETIC_OWNER,
|
||
state: 'not-executed',
|
||
dataRecovery: 'not-planned-or-attempted',
|
||
});
|
||
}
|
||
|
||
function validEvidence(value) {
|
||
return exactObject(value, {
|
||
source: 'fixed-synthetic-records',
|
||
realLegacyRead: 'not-performed',
|
||
realParityRun: 'not-performed',
|
||
coverage: 'not-measured',
|
||
performance: 'not-measured',
|
||
});
|
||
}
|
||
|
||
function planSnapshot(input) {
|
||
return Object.freeze({
|
||
seam: Object.freeze({ ...input.seam }),
|
||
contract: Object.freeze({
|
||
kind: input.contract.kind,
|
||
rules: Object.freeze(input.contract.rules.map((item) => Object.freeze({ ...item }))),
|
||
cases: Object.freeze(input.contract.cases.map((item) => Object.freeze({ ...item }))),
|
||
realParity: input.contract.realParity,
|
||
}),
|
||
rollout: Object.freeze({
|
||
kind: input.rollout.kind,
|
||
owner: input.rollout.owner,
|
||
stages: Object.freeze(input.rollout.stages.map((item) => Object.freeze({ ...item }))),
|
||
releaseAuthority: input.rollout.releaseAuthority,
|
||
}),
|
||
rollback: Object.freeze({ ...input.rollback }),
|
||
});
|
||
}
|
||
|
||
function isSyntheticSnapshot(value) {
|
||
return onlyKeys(value, ['seam', 'contract', 'rollout', 'rollback'])
|
||
&& validSeam(value.seam)
|
||
&& validContract(value.contract)
|
||
&& validRollout(value.rollout)
|
||
&& validRollback(value.rollback);
|
||
}
|
||
|
||
/**
|
||
* Проверяет только форму учебного plan record. В ней нет source path, истории,
|
||
* реального ответа, duration, coverage и execution. Ограничение намеренно не
|
||
* позволяет принять design record за наблюдение настоящего legacy-поведения.
|
||
*/
|
||
export function createSyntheticModernizationPlan(input) {
|
||
if (!input || input.synthetic !== true || input.kind !== SYNTHETIC_KIND) {
|
||
return rejectSyntheticPlan('synthetic-kind-required');
|
||
}
|
||
if (!onlyKeys(input, ['kind', 'synthetic', 'seam', 'contract', 'rollout', 'rollback', 'evidence'])) {
|
||
return rejectSyntheticPlan('unexpected-top-level-field');
|
||
}
|
||
if (!validSeam(input.seam)) return rejectSyntheticPlan('measurable-seam-contract-rejected');
|
||
if (!validContract(input.contract)) return rejectSyntheticPlan('compatibility-contract-rejected');
|
||
if (!validRollout(input.rollout)) return rejectSyntheticPlan('rollout-gate-contract-rejected');
|
||
if (!validRollback(input.rollback)) return rejectSyntheticPlan('rollback-contract-rejected');
|
||
if (!validEvidence(input.evidence)) return rejectSyntheticPlan('synthetic-evidence-boundary-rejected');
|
||
|
||
return Object.freeze({
|
||
accepted: true,
|
||
syntheticOnly: true,
|
||
kind: 'synthetic-legacy-modernization-plan-v1',
|
||
modelLimit: MODEL_LIMIT,
|
||
seam: Object.freeze({
|
||
id: input.seam.id,
|
||
route: input.seam.route,
|
||
unit: input.seam.unit,
|
||
owner: input.seam.owner,
|
||
oldBoundary: input.seam.legacyBoundary,
|
||
newBoundary: input.seam.newBoundary,
|
||
realLegacyBehavior: 'not-read-or-claimed',
|
||
}),
|
||
compatibility: Object.freeze({
|
||
ruleIds: Object.freeze(input.contract.rules.map((item) => item.id)),
|
||
caseIds: Object.freeze(input.contract.cases.map((item) => item.id)),
|
||
parity: 'not-run-or-claimed',
|
||
result: 'not-observed',
|
||
}),
|
||
rollout: Object.freeze({
|
||
stageIds: Object.freeze(input.rollout.stages.map((item) => item.id)),
|
||
proposal: 'manual-review-before-any-real-route-change',
|
||
releaseAuthority: 'not-granted',
|
||
realRollout: 'not-attempted',
|
||
}),
|
||
rollback: Object.freeze({
|
||
proposal: input.rollback.route,
|
||
owner: input.rollback.owner,
|
||
realRouteChange: 'not-attempted',
|
||
dataRecovery: 'not-planned-or-attempted',
|
||
}),
|
||
evidence: Object.freeze({
|
||
source: input.evidence.source,
|
||
legacyRead: input.evidence.realLegacyRead,
|
||
parityRun: input.evidence.realParityRun,
|
||
coverage: input.evidence.coverage,
|
||
performance: input.evidence.performance,
|
||
}),
|
||
snapshot: planSnapshot(input),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Возвращает только immutable snapshot synthetic decision record. Функция не
|
||
* переключает route, не пишет в базу и не запускает rollback procedure.
|
||
*/
|
||
export function rollbackSyntheticModernizationPlan(plan) {
|
||
if (!plan
|
||
|| plan.accepted !== true
|
||
|| plan.kind !== 'synthetic-legacy-modernization-plan-v1'
|
||
|| plan.syntheticOnly !== true
|
||
|| plan.modelLimit !== MODEL_LIMIT
|
||
|| !isSyntheticSnapshot(plan.snapshot)) {
|
||
return Object.freeze({ restored: false, syntheticOnly: true, reason: 'accepted-synthetic-plan-required' });
|
||
}
|
||
return Object.freeze({
|
||
restored: true,
|
||
syntheticOnly: true,
|
||
reason: 'synthetic-plan-snapshot-restored',
|
||
snapshot: plan.snapshot,
|
||
legacyCode: 'not-read',
|
||
legacyHistory: 'not-read',
|
||
parityTests: 'not-run',
|
||
ci: 'not-touched',
|
||
network: 'not-used',
|
||
routeChange: 'not-attempted',
|
||
dataRecovery: 'not-attempted',
|
||
});
|
||
}
|
||
|
||
export function runLegacyModernizationFixture() {
|
||
const valid = createSyntheticModernizationPlan(syntheticModernizationInput);
|
||
const nonSynthetic = createSyntheticModernizationPlan({ ...syntheticModernizationInput, synthetic: false });
|
||
const networkLike = createSyntheticModernizationPlan({ ...syntheticModernizationInput, networkUrl: 'https://example.invalid' });
|
||
const broadSeam = createSyntheticModernizationPlan({ ...syntheticModernizationInput, seam: { ...syntheticModernizationInput.seam, unit: 'whole-system-rewrite' } });
|
||
const noOwner = createSyntheticModernizationPlan({ ...syntheticModernizationInput, seam: { ...syntheticModernizationInput.seam, owner: 'synthetic-unknown-owner' } });
|
||
const shortRules = createSyntheticModernizationPlan({ ...syntheticModernizationInput, contract: { ...syntheticModernizationInput.contract, rules: syntheticModernizationInput.contract.rules.slice(0, 2) } });
|
||
const claimedParity = createSyntheticModernizationPlan({ ...syntheticModernizationInput, contract: { ...syntheticModernizationInput.contract, realParity: 'executed-and-passed' } });
|
||
const realTestClaim = createSyntheticModernizationPlan({
|
||
...syntheticModernizationInput,
|
||
contract: { ...syntheticModernizationInput.contract, cases: [...syntheticModernizationInput.contract.cases.slice(0, 2), { ...syntheticModernizationInput.contract.cases[2], observed: 'real-test-passed' }] },
|
||
});
|
||
const automaticGate = createSyntheticModernizationPlan({ ...syntheticModernizationInput, rollout: { ...syntheticModernizationInput.rollout, releaseAuthority: 'automatic-release' } });
|
||
const reorderedStages = createSyntheticModernizationPlan({
|
||
...syntheticModernizationInput,
|
||
rollout: { ...syntheticModernizationInput.rollout, stages: [syntheticModernizationInput.rollout.stages[1], syntheticModernizationInput.rollout.stages[0], syntheticModernizationInput.rollout.stages[2]] },
|
||
});
|
||
const destructiveRollback = createSyntheticModernizationPlan({ ...syntheticModernizationInput, rollback: { ...syntheticModernizationInput.rollback, route: 'delete-legacy-data' } });
|
||
const coverageClaim = createSyntheticModernizationPlan({ ...syntheticModernizationInput, evidence: { ...syntheticModernizationInput.evidence, coverage: '91-percent' } });
|
||
const restored = rollbackSyntheticModernizationPlan(valid);
|
||
const rejectedRestore = rollbackSyntheticModernizationPlan(destructiveRollback);
|
||
const fabricatedRestore = rollbackSyntheticModernizationPlan({
|
||
accepted: true,
|
||
syntheticOnly: true,
|
||
kind: 'synthetic-legacy-modernization-plan-v1',
|
||
modelLimit: MODEL_LIMIT,
|
||
snapshot: {},
|
||
});
|
||
const lengthOnlySnapshotRestore = rollbackSyntheticModernizationPlan({
|
||
accepted: true,
|
||
syntheticOnly: true,
|
||
kind: 'synthetic-legacy-modernization-plan-v1',
|
||
modelLimit: MODEL_LIMIT,
|
||
snapshot: {
|
||
seam: { ...syntheticModernizationInput.seam },
|
||
contract: {
|
||
kind: 'synthetic-compatibility-contract-v1',
|
||
rules: [{}, {}, {}],
|
||
cases: [{}, {}, {}],
|
||
realParity: 'not-run-or-claimed',
|
||
},
|
||
rollout: {
|
||
kind: 'synthetic-rollout-plan-v1',
|
||
owner: SYNTHETIC_OWNER,
|
||
stages: [{}, {}, {}],
|
||
releaseAuthority: 'not-granted',
|
||
},
|
||
rollback: { ...syntheticModernizationInput.rollback },
|
||
},
|
||
});
|
||
|
||
return Object.freeze({
|
||
assertions: Object.freeze({
|
||
acceptsMarkedSyntheticRecord: valid.accepted === true && valid.kind === 'synthetic-legacy-modernization-plan-v1',
|
||
keepsOneMeasurableSeam: valid.seam.id === SYNTHETIC_SEAM && valid.seam.unit === 'one-request-one-response-contract',
|
||
keepsBoundariesAndOwner: valid.seam.oldBoundary === 'synthetic-legacy-adapter' && valid.seam.newBoundary === 'synthetic-modern-adapter' && valid.seam.owner === SYNTHETIC_OWNER,
|
||
keepsThreeBehaviorRules: sameValues(valid.compatibility.ruleIds, ['synthetic-status', 'synthetic-payload', 'synthetic-effect']),
|
||
keepsThreeSyntheticCases: sameValues(valid.compatibility.caseIds, ['synthetic-case-valid', 'synthetic-case-invalid', 'synthetic-case-repeat']),
|
||
doesNotClaimLegacyBehavior: valid.seam.realLegacyBehavior === 'not-read-or-claimed' && valid.compatibility.result === 'not-observed',
|
||
doesNotRunOrClaimParity: valid.compatibility.parity === 'not-run-or-claimed' && valid.evidence.parityRun === 'not-performed',
|
||
doesNotMeasureCoverageOrPerformance: valid.evidence.coverage === 'not-measured' && valid.evidence.performance === 'not-measured',
|
||
preservesManualRolloutBoundary: valid.rollout.proposal === 'manual-review-before-any-real-route-change' && valid.rollout.releaseAuthority === 'not-granted' && valid.rollout.realRollout === 'not-attempted',
|
||
keepsOrderedStages: sameValues(valid.rollout.stageIds, ['synthetic-stage-0', 'synthetic-stage-1', 'synthetic-stage-2']),
|
||
keepsRollbackAsProposalOnly: valid.rollback.proposal === 'restore-legacy-only-proposal' && valid.rollback.realRouteChange === 'not-attempted' && valid.rollback.dataRecovery === 'not-planned-or-attempted',
|
||
rejectsNonSyntheticInput: nonSynthetic.accepted === false && nonSynthetic.reason === 'synthetic-kind-required',
|
||
rejectsNetworkLikeInput: networkLike.accepted === false && networkLike.reason === 'unexpected-top-level-field',
|
||
rejectsWholeSystemRewrite: broadSeam.accepted === false && broadSeam.reason === 'measurable-seam-contract-rejected',
|
||
rejectsUnknownOwner: noOwner.accepted === false && noOwner.reason === 'measurable-seam-contract-rejected',
|
||
rejectsIncompleteRules: shortRules.accepted === false && shortRules.reason === 'compatibility-contract-rejected',
|
||
rejectsClaimedParity: claimedParity.accepted === false && claimedParity.reason === 'compatibility-contract-rejected',
|
||
rejectsRealTestClaim: realTestClaim.accepted === false && realTestClaim.reason === 'compatibility-contract-rejected',
|
||
rejectsAutomaticRelease: automaticGate.accepted === false && automaticGate.reason === 'rollout-gate-contract-rejected',
|
||
rejectsStageReorder: reorderedStages.accepted === false && reorderedStages.reason === 'rollout-gate-contract-rejected',
|
||
rejectsDestructiveRollback: destructiveRollback.accepted === false && destructiveRollback.reason === 'rollback-contract-rejected',
|
||
rejectsCoverageClaim: coverageClaim.accepted === false && coverageClaim.reason === 'synthetic-evidence-boundary-rejected',
|
||
restoresOnlySnapshot: restored.restored === true && restored.reason === 'synthetic-plan-snapshot-restored' && restored.routeChange === 'not-attempted' && restored.network === 'not-used',
|
||
rejectsRestoreForRejectedPlan: rejectedRestore.restored === false && rejectedRestore.reason === 'accepted-synthetic-plan-required',
|
||
rejectsFabricatedSnapshot: fabricatedRestore.restored === false && fabricatedRestore.reason === 'accepted-synthetic-plan-required',
|
||
rejectsSnapshotWithOnlyLengths: lengthOnlySnapshotRestore.restored === false && lengthOnlySnapshotRestore.reason === 'accepted-synthetic-plan-required',
|
||
}),
|
||
});
|
||
}
|
||
|
||
const fixtureCommand = 'node web/scripts/upgrade-2024-01.mjs --verify-fixture';
|
||
|
||
const practice = revision({
|
||
slug: 'editorial-2024-01-practice-legacy-modernization',
|
||
title: 'Модернизация legacy-системы без потери поведения: выбрать измеримый шов',
|
||
categories: ['Архитектура', 'Legacy', 'Практика'],
|
||
cover: '/assets/editorial/2024/legacy-modernization-2024-strangler-map.svg',
|
||
excerpt: 'Как остановить переписывание всего сразу: выбрать один пользовательский шов, сделать его наблюдаемым, назначить владельца и не выдавать учебную проверку за сохранённое поведение реального legacy-модуля.',
|
||
readingMinutes: 13,
|
||
}, [
|
||
p('Симптом выглядит знакомо: в backlog лежит задача «переписать старый расчёт», но у неё нет границы. Внутри смешаны HTTP-обработчик, правила скидок, запись статуса, письмо и два неочевидных вызова соседних модулей. Команда начинает с нового сервиса, а через месяц не может честно ответить, какое поведение уже перенесено и что будет потеряно при переключении. Цена не только в задержке релиза. Пока работа идёт в стороне, legacy получает новые правила, а большой merge собирает разные риски в одну точку cut-over.'),
|
||
p('Полное переписывание кажется безопаснее потому, что его проще нарисовать: старый блок слева, новый справа, дата переключения внизу. Но объект для поставки не равен архитектурной картинке. Для начала нужен один измеримый шов: вход, один наблюдаемый результат, известный владелец и отдельный план возврата. Такой шов не обещает сохранить всю систему. Он уменьшает blast radius первого изменения и даёт команде проверяемый вопрос: этот конкретный запрос продолжает выполнять объявленный контракт или нет.'),
|
||
h2('Не кусок кода, а граница, на которой можно спорить предметно'),
|
||
p('Шов не равен папке, классу или новому слою. Он существует, когда можно назвать четыре вещи: кто инициирует действие, какой вход допускается, какой ответ или эффект имеет значение и где остаётся старый владелец состояния. Для API это может быть один route и одна операция. Для фоновой задачи — один тип сообщения с ключом идемпотентности. Для интерфейса — одно действие пользователя, за которым стоит конкретный command. Если шов описан фразой «вся корзина» или «весь расчёт», его ещё нельзя отдавать в новую реализацию: это тема исследования, а не единица rollout.'),
|
||
p('У первого шва есть стоимость. Понадобится адаптер, журнал решения, отдельная проверка и временное сосуществование двух путей. Это дороже, чем удалить старый вызов в первой ветке. Но цена видима: она относится к одной операции и одному owner. Цена большого переписывания скрыта до поздней интеграции: неявные правила, редкие ошибки и побочные эффекты находят тогда, когда откат уже затрагивает весь новый контур. Fowler в исходном тексте 2004 года описывает риск критического cut-over и ценность постепенного вытеснения, а не универсальный рецепт маршрутизации.'),
|
||
table('Как отличить измеримый шов от лозунга о миграции', ['Наблюдение', 'Почему это ещё не шов', 'Проверка до реализации', 'Действие'], [
|
||
['«Перенесём расчёт заказа»', 'Не названы вход, ответ и боковые эффекты', 'Выписать одну операцию и её consumer', 'Сузить до preview или confirm, но не брать оба сразу'],
|
||
['Новый сервис получил копию схемы', 'Схема не говорит о статусе, порядке и повторе', 'Назвать valid, invalid и repeat outcomes', 'Сделать compatibility record для этих случаев'],
|
||
['У маршрута нет owner', 'Никто не принимает конфликт старого и нового пути', 'Указать owner решения и owner состояния', 'Не открывать rollout без обоих имён'],
|
||
['Rollback означает «вернёмся назад»', 'Неясно, что менять и что уже необратимо', 'Отделить route return от data recovery', 'Начать с обратимого route change или остановиться'],
|
||
['Тест зелёный локально', 'Он не подтверждает traffic и скрытые зависимости', 'Записать, какие данные и среда нужны', 'Оставить локальный тест в его границе'],
|
||
]),
|
||
figure('/assets/editorial/2024/legacy-modernization-2024-strangler-map.svg', 'Карта одного шва: client отправляет synthetic quote preview в router; router оставляет legacy adapter владельцем старого пути и направляет предложенный новый путь через modern adapter. Между ними обозначены контракт, owner и обратимый route, а база и неизвестные эффекты стоят за границей схемы.', 'Схема описывает форму выбора шва. Она не считывает конкретный legacy-код, не показывает реальный трафик, базу, доли rollout или подтверждённую совместимость.'),
|
||
h2('Карта до кода: потребитель, состояние, эффект'),
|
||
p('Перед созданием адаптера стоит завести короткую карту из пяти строк. Первая — consumer: браузер, job или другой сервис. Вторая — вход: параметры, обязательные значения, повтор. Третья — наблюдаемый ответ: категория статуса, поля и порядок, если он значим. Четвёртая — effect: запись, сообщение, cache invalidation либо честная отметка unknown. Пятая — владелец: кто отвечает за решение при расхождении. Такая карта не заменяет чтение кода. Она делает чтение направленным: искать надо доказательство пяти строк, а не пытаться понять весь монолит за один проход.'),
|
||
p('Не прячьте неизвестное под словом parity. Если команда ещё не знает, повторяет ли запрос effect, это отдельный риск контракта. Сначала ставится состояние unknown, затем выбирается способ получить evidence в разрешённой среде: трасса, лог, тестовый стенд, история инцидента или ручной сценарий. До этого новый путь может существовать только как предложение. Переезд без знания effect опасен тем, что обычный успешный ответ выглядит одинаково в двух вариантах, а дубликат письма, списания или задачи появляется после того, как клиент получил 200.'),
|
||
p('OpenAPI 3.1.0, выпущенный 15 февраля 2021 года, полезен как язык HTTP-границы: он фиксирует operations, paths и форму Schema Object. Но спецификация не знает, что означает поле для бизнеса и какой порядок эффектов ожидает legacy. Поэтому OpenAPI-документ — один артефакт шва, не сертификат поведенческой совместимости. Рядом остаётся таблица инвариантов: что сравниваем, кто её утверждает и где она перестаёт быть достаточной.'),
|
||
h2('Сначала один маршрут, потом новый внутренний мир'),
|
||
p('Маршрутизатор полезен не потому, что он современный, а потому что в нём можно удержать решение о пути. У маршрута должны быть явные варианты: legacy-only, новый путь как предложение после review и возвращение к legacy-only. Здесь важнее свойство: переключатель живёт на границе операции, а не размазан по десяти вызовам внутри старого кода. Тогда owner может показать, какой запрос затронут, а не объяснять, почему новый сервис иногда получил половину работы.'),
|
||
p('Первый новый adapter не обязан содержать всю доменную логику. Его задача — принять контракт шва, преобразовать известный вход и вернуть объявленную форму. Когда adapter вынужден читать глобальную переменную, напрямую обновлять общую базу и отправлять письмо, это не глубина интеграции. Это сигнал, что выбранный шов слишком широк или скрытые эффекты требуют отдельного этапа. В такой ситуации честнее уменьшить scope, чем написать ещё один универсальный gateway и потерять видимость границы.'),
|
||
h2('Учебный fixture: проверить форму решения, не legacy'),
|
||
p('Ниже запускается минимальный fixture этой статьи. Он создаёт fixed synthetic plan с одним условным route, тремя exact правилами контракта и тремя непроведёнными cases. Положительная ветка проверяет, что plan не расширился до whole-system rewrite, owner назван, а автоматическое полномочие на release отсутствует. Отрицательные ветки отвергают claims о реальном parity, coverage, выполненном rollout и snapshot, похожий только по длине массивов. Это полезно для ревью формы decision record, но не для оценки вашего модуля.'),
|
||
code(fixtureCommand),
|
||
p('Запуск не читает legacy code или history, не запускает test runner, CI, сеть либо HTTP и не измеряет coverage или performance. Он не видит настоящие ответы, базу, сообщения, пользователей и прошлые инциденты. Поэтому PASS не означает «поведение legacy сохранено». Он означает более скромную вещь: учебная запись не потеряла один шов, три правила, owner, ручной gate и неразрушительный rollback proposal. Реальное evidence собирается отдельно и хранится рядом с решением, а не подменяется строкой в terminal.'),
|
||
h2('Маршрут: симптом → причина → проверка → действие'),
|
||
ol([
|
||
'<strong>Симптом.</strong> Задача описывает замену модуля целиком, но никто не показывает один вход и один ожидаемый результат первого релиза.',
|
||
'<strong>Причина.</strong> Архитектурную границу смешали с планом разработки: новый сервис стал единицей работы раньше, чем была выделена операция и её владелец.',
|
||
'<strong>Проверка scope.</strong> Для candidate route зафиксируйте consumer, input, response category, declared effect, legacy boundary и new boundary. Если одна строка не названа, шов не готов.',
|
||
'<strong>Проверка обратимости.</strong> Отделите возврат route к legacy от восстановления данных. Если данных уже нельзя вернуть, не называйте шаг обратимым.',
|
||
'<strong>Действие.</strong> Оставьте legacy-only путём по умолчанию, подготовьте adapter и decision record для одного шва. Новый путь не включается без owner evidence.',
|
||
'<strong>Повтор.</strong> После первого evidence сравните только объявленные инварианты. Расхождение расширяет contract или уменьшает scope, а не оправдывает rewrite всего модуля.',
|
||
]),
|
||
h2('Rollback начинается до первой доли rollout'),
|
||
p('В схеме rollback не равен удалению новой реализации. Сначала нужно решить, что возвращают: rule маршрута, версию конфигурации или входной adapter. Затем — кто выполнит действие, как подтвердить возврат и какие данные не покрываются этим действием. Если новый путь уже оставляет необратимые записи, возврат route не восстанавливает мир автоматически. Тогда plan обязан назвать migration или reconciliation отдельно и не пользоваться словом rollback как утешением для review.'),
|
||
p('Google SRE Workbook 2018 определяет canary как частичное и ограниченное по времени развёртывание с оценкой перед продолжением. Из этого не следует, что любой процент безопасен или что synthetic traffic моделирует state. Та же глава предупреждает: artificial load может увеличить code coverage, но плохо представляет state coverage в изменяемых системах. Практический вывод: сначала согласуйте, какие сигналы можно сравнить и какой route действительно можно вернуть, затем обсуждайте размер первой волны.'),
|
||
p('Если первый шов не имеет обратимого route, это не обязательно стоп всей программы. Можно выбрать read-only operation, вынести side effect или подготовить data plan с отдельным owner. Неправильное действие — назвать непроверенную операцию обратимой ради даты. Надёжная модернизация растёт из таких малых отказов: команда видит конкретное ограничение, меняет scope и сохраняет возможность вернуться к работе без широкой компенсации.'),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Этот материал не выбирает ваш domain boundary, не читает историю модуля, не строит dependency graph и не доказывает, что adapter сохранит данные, latency, ошибки или правила. Synthetic names, cases и маршруты не являются шаблоном URL, схемы или policy. Fowler даёт историческое объяснение риска cut-over; OpenAPI — язык описания HTTP-интерфейса; Google — рамку частичного rollout. Ни один источник не сообщает, какой шов безопасен в неизвестной системе.'),
|
||
p('Следующий шаг короткий: выберите одну операцию, для которой команда может заполнить карту из consumer, input, response, effect, owner и rollback boundary за один рабочий сеанс. Приложите ссылку на доступное evidence и отдельно отметьте unknown. Если карта выросла до десятка операций, это не повод ускорять rewrite. Это доказательство, что первую поставку надо сузить. M7 здесь не магия: техлид делает цену и границы видимыми, чтобы команда изменила их до production-риска.'),
|
||
h2('Историческая граница января 2024'),
|
||
p('К концу января 2024 были доступны исходная статья Fowler 2004 года, OAS 3.1.0 от 15 февраля 2021 года и Google SRE Workbook 2018. Здесь не используются поздние платформенные практики и не сочиняются результаты миграции. Уровень M7 проявляется в выборе scope, владельца, стоимости сосуществования и способа повторить решение; он не выдаёт учебный fixture за parity-проверку настоящей legacy-системы.'),
|
||
]);
|
||
|
||
const mechanism = revision({
|
||
slug: 'editorial-2024-01-mechanism-legacy-modernization',
|
||
title: 'Совместимость при модернизации legacy: contract и parity без ложного равенства',
|
||
categories: ['Архитектура', 'API', 'Legacy'],
|
||
cover: '/assets/editorial/2024/legacy-modernization-2024-compatibility-matrix.svg',
|
||
excerpt: 'Почему одинаковый JSON не означает сохранённое поведение: собрать contract совместимости, отделить форму ответа от effect и проверять parity на заранее названных cases.',
|
||
readingMinutes: 14,
|
||
}, [
|
||
p('Симптом после первой замены обманчив: старый и новый endpoint возвращают одинаковый status, JSON похож, demo проходит. Через неделю клиент повторяет запрос, а в новом пути появляется второй effect, теряется категория validation-ошибки или меняется порядок значимых полей. Команда спорит, достаточно ли похоже, потому что до разработки не зафиксировала, что означает parity. Цена — регрессия, которую нельзя локализовать: ответ уже ушёл consumer, а причина спрятана между transport, domain rule и побочным действием.'),
|
||
p('Parity не требует сравнивать каждый байт. Он требует заранее выбрать свойства, для которых различие недопустимо. У операции preview важно, что invalid input не выглядит как successful quote, обязательные поля не исчезают и repeat не создаёт объявленный effect дважды. Для другой операции важнее порядок событий или конкретная кодировка ошибки. Список выбирает владелец доменного контракта, а не test framework. Если свойства названы после расхождения, это расследование, а не доказательство совместимости.'),
|
||
h2('Форма API — полезная, но неполная граница'),
|
||
p('OAS 3.1.0 определяет способ описать HTTP-интерфейс без доступа к исходному коду и трафику: paths, operations, request/response structures, schemas. Такой документ помогает сделать видимыми обязательные поля, статусы и версию. Для перехода это сильный первый слой: consumer и implementer перестают угадывать форму. Но OAS не обещает, что два одинаковых документа выполняют одинаковую бизнес-операцию. Schema Object описывает структуру, а значение статуса, порядок effects, правило повтора и момент чтения состояния остаются в договоре команды.'),
|
||
p('Compatibility contract лучше разделить на четыре слоя. Transport — method, path, status category и headers, если они значимы. Payload — обязательные поля, type, семантика null/absence и порядок там, где consumer зависит от него. Effect — что объявлено как запись, публикация сообщения, cache invalidation или запрет повтора. Time — что значит тот же input: идентичный request, idempotency key, business key или момент состояния. Один test snapshot почти всегда покрывает только часть transport и payload.'),
|
||
table('Матрица parity: что сравнивать и что не обещать', ['Слой', 'Инвариант', 'Evidence для реальной проверки', 'Опасная подмена'], [
|
||
['Transport', 'success и validation не меняют категорию status', 'recorded request/response из согласованного контура', 'сравнить только HTTP 200'],
|
||
['Payload', 'обязательные поля и их смысл сохранены', 'contract case с expected result', 'сравнить размер JSON или порядок всех ключей'],
|
||
['Effect', 'repeat не дублирует объявленное действие', 'трасса, журнал или тест с доступом к effect', 'решить по одинаковому ответу'],
|
||
['Time', 'состояние читается в оговорённый момент', 'scenario с известной подготовкой данных', 'назвать любой repeat тем же input'],
|
||
['Ownership', 'изменение правила утверждает domain owner', 'decision record и review', 'дать parser-у схемы право решать semantics'],
|
||
]),
|
||
figure('/assets/editorial/2024/legacy-modernization-2024-compatibility-matrix.svg', 'Матрица совместимости сопоставляет три synthetic cases с четырьмя слоями contract: transport, payload, effect и time. В каждой клетке требуется declared evidence; строка unknown не превращается в зелёную parity-отметку.', 'Диаграмма не содержит ответов старого или нового сервиса и не запускает parity test. Она показывает, какие вопросы должны иметь отдельное доказательство.'),
|
||
h2('Сначала классифицировать различие, затем выбирать реакцию'),
|
||
p('Не всякое различие означает дефект, но любое различие должно получить класс. Cosmetic — пробел, порядок незначимых ключей, новый optional label, если consumer действительно от него не зависит. Compatible extension — дополнительное поле, которое old consumer игнорирует по документированному правилу. Behavioral mismatch — изменился status, обязательное поле, validation, amount, idempotency или effect. Unknown — наблюдение есть, но команда пока не знает, значимо ли поле или порядок. Только первые два случая можно принять без расширения scope, и то после evidence о consumer.'),
|
||
p('Классификатор защищает и от обратной крайности — попытки скопировать legacy bug без вопроса. Иногда старое поведение выглядит ошибкой, но клиент уже построил вокруг него workflow. Тогда существуют три решения: сохранить bug временно ради compatibility, изменить contract с версией и migration path или остановить замену до согласования. Нельзя решить это выражением expected equals actual. Оно отвечает на синтаксическое равенство и ничего не говорит о том, кто понесёт цену изменения. M7 здесь — не больше тестов, а явная развилка с owner и стоимостью каждого варианта.'),
|
||
h2('Три case лучше ста невыбранных'),
|
||
p('Для первого шва достаточно трёх cases. Valid case показывает основной результат с объявленными полями. Invalid case проверяет, что новый путь не превращает ошибку в success или generic 500. Repeat case покрывает повтор с тем же ключом или input и объясняет ожидаемый effect. Это не test strategy всей системы. Это минимальный набор, который заставляет команду назвать поведение в местах, где одинаковый JSON маскирует риск. После реального расхождения добавляется ещё один case с источником evidence; не нужно заранее строить каталог из сотни случайных fixtures.'),
|
||
p('Каждый case содержит не только input и expected output. Добавьте precondition, источник данных, критерий сопоставления и границу. Если outcome зависит от времени, курса или внешнего provider, отметьте это. Если effect нельзя наблюдать без доступа к журналу, не пишите «эффект совпадает». Напишите unknown и спланируйте доступ или изоляцию. Неприятная ячейка unknown полезнее зелёного чекбокса: она показывает, что новый путь не готов к этому классу трафика, а не прячет отсутствие прав за термином parity.'),
|
||
h2('Почему snapshot и schema не спасают от side effect'),
|
||
p('Snapshot полезен как evidence формы, если в нём есть версия contract и понятная подготовка данных. Он плохо отвечает на вопрос, что произошло после ответа. Два обработчика могут вернуть один payload, но один записывает только preview, а второй публикует событие. Здесь parity не в копировании всей базы, а в конкретном инварианте effect: в preview нет publication, repeat с тем же ключом не создаёт вторую запись, failed validation не меняет status. Для каждого инварианта нужен способ наблюдения в реальном test/staging контуре; fixture этой статьи специально такого способа не имеет.'),
|
||
p('То же относится к error mapping. Новый язык, library или gateway может упростить исключения до internal error. Для consumer это не техническая деталь, если он различает validation, retryable и forbidden. Сначала фиксируется внешняя категория и необходимый payload, затем внутренняя реализация может меняться. Это дешевле, чем сохранять stack trace и текст legacy-ошибки. Но нельзя назвать категории совместимыми только потому, что они логичнее. Нужны owners и evidence, что потребитель не зависит от старой формы.'),
|
||
h2('Учебный fixture запрещает ложное доказательство'),
|
||
p('Fixture пакета создаёт три synthetic cases: valid, invalid и repeat. У каждого observed равно not-run; у contract realParity равно not-run-or-claimed. Код отвергает case с real-test-passed, попытку назвать parity executed, урезанный список effect-rules и claim coverage. Это не mock endpoint и не contract test framework. Его задача — показать, что decision record не должен объявлять green parity, пока он не содержит отдельного запуска и evidence из доступной среды.'),
|
||
code(fixtureCommand),
|
||
p('PASS означает, что immutable synthetic record удерживает три вида rules, три case и границы доказательства. Он не читает legacy code/history, не вызывает API, не запускает test runner, CI или сеть и не смотрит на coverage или latency. У него нет customer data, database state, времени и очередей. Он не сообщает, сохранён ли порядок вызовов, как consumer обрабатывает new error или совпали ли реальные результаты. Для этой статьи это защита от красивого, но пустого слова parity.'),
|
||
h2('Маршрут: симптом → причина → проверка → действие'),
|
||
ol([
|
||
'<strong>Симптом.</strong> Новый путь возвращает похожий payload, но команда не объясняет разницу при invalid input или repeat.',
|
||
'<strong>Причина.</strong> Contract зафиксировал schema, но не semantics, effect и time. Проверка сравнивает сериализацию вместо обещания consumer-у.',
|
||
'<strong>Проверка contract.</strong> Разложите операцию на transport, payload, effect и time. Для слоя оставьте различия, от которых зависит известный consumer.',
|
||
'<strong>Проверка evidence.</strong> Для valid, invalid и repeat укажите источник подготовки, место наблюдения и owner. Unknown остаётся unknown до проверки.',
|
||
'<strong>Действие.</strong> Behavioral mismatch блокирует расширение шва; compatible extension требует record; cosmetic отличие фиксируется вместе с причиной независимости consumer.',
|
||
'<strong>Повтор.</strong> После расхождения добавляйте один case и один критерий, а не переписывайте contract задним числом под текущий implementation.',
|
||
]),
|
||
h2('Как не превратить parity в бесконечный проект'),
|
||
p('Scope parity заканчивается на выбранном шве. Если для него понадобилось доказать поведение десяти соседних команд, это сигнал отступить и выбрать более тонкую границу или сначала формализовать dependency contract. Замерять «процент parity coverage» без определения веса cases бессмысленно: одно число поставит рядом основной платёжный сценарий и косметическую подпись. Вместо этого полезнее назвать незакрытые классы: time-dependent, effectful, external-provider, access-denied. Каждый получает решение: взять следующим, оставить на legacy или согласовать отдельную migration.'),
|
||
p('Parity не отменяет эволюцию. Когда продукту нужна новая semantics, старая и новая операции расходятся явно: новый version contract, новая operation или назначенный период coexistence. Самый дорогой вариант — спрятать изменение внутри migration и надеяться, что все consumers воспримут его как bugfix. Тогда команда не может ни проверить, что сохранила legacy, ни объяснить, почему изменила его. Contract ценен тем, что делает выбор проверяемым и обсуждаемым до rollout.'),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Эта статья не утверждает, что OpenAPI покрывает business behavior, что трёх cases достаточно для всех доменов или что существующий legacy bug надо сохранять. В ней нет production data, readiness CI, отчёта coverage, замера performance и результата parity test. Источники доступны к январю 2024: OAS 3.1.0 описывает interface, Fowler — риск cut-over, Google — оценку ограниченного rollout. Они не поставляют готовую матрицу вашей команды и не дают автоматическую власть над архитектурным решением.'),
|
||
p('Следующий шаг: для одного шва заполните матрицу из четырёх слоёв и трёх cases. В каждой строке добавьте owner, evidence source и одно из значений compatible, mismatch, unknown. Не пишите общий verdict parity passed, пока unknown свойство может изменить effect или категорию ошибки. Когда карта готова, она позволяет выбрать цену: сохранить старое временно, версионировать новое или отложить rollout. Это точнее, чем спорить о близости двух JSON.'),
|
||
h2('Историческая граница января 2024'),
|
||
p('К январю 2024 OAS 3.1.0 уже была стабильной спецификацией 2021 года, исходная заметка Fowler датирована 2004 годом, а Workbook Google — изданием 2018 года. Текст не ссылается на поздние contract-платформы и не имитирует результаты чужих parity runs. M7 проявляется в разделении стоимости compatibility, owner и критериев решения; прагматичный тон не заменяет реальное evidence схемой.'),
|
||
]);
|
||
|
||
const field = revision({
|
||
slug: 'editorial-2024-01-field-legacy-modernization',
|
||
title: 'Модернизация legacy: rollout и rollback без «переключим, если что»',
|
||
categories: ['Архитектура', 'Релизы', 'Legacy'],
|
||
cover: '/assets/editorial/2024/legacy-modernization-2024-rollout-gate.svg',
|
||
excerpt: 'Как превратить migration rollout в ряд ручных решений: один шов, evidence, ограниченная волна, чёткий rollback-route и отдельная граница данных.',
|
||
readingMinutes: 13,
|
||
}, [
|
||
p('Симптом в день переключения простой: новый путь готов, но никто не называет условие, при котором его надо остановить. В chat появляются предложения «включим на десять процентов» и «если что, откатим». Ни доля, ни слово rollback не объясняют, какой traffic затронут, какие сигналы сравнят, кто может остановить волну и что случится с данными. Цена — широкий blast radius и спор после факта: команда не знает, проблема в новой реализации, общей зависимости или способе измерения.'),
|
||
p('Rollout для legacy-модернизации начинается не с процента. Он начинается с decision gate: до какого действия разрешён current evidence, какой owner отвечает за следующий переход и какое обратимое действие доступно. В первой стадии может быть только legacy-only route и review шва. Во второй — предложение ограниченного нового пути, но не автоматическое включение. В третьей — расширение после отдельно зафиксированного evidence. Это медленнее одного toggle, но дешевле, чем воспринимать production как инструмент обнаружения неизвестных правил.'),
|
||
h2('Gate отвечает на один вопрос и не выдаёт себе чужую власть'),
|
||
p('У каждого gate есть объект проверки. Gate совместимости спрашивает, определены ли cases и evidence. Gate доставки спрашивает, какая версия adapters и routing rule попадёт в контур. Gate наблюдения спрашивает, какие признаки можно сравнить между control и новой частью. Gate rollback спрашивает, что реально вернуть одним обратимым действием. Нельзя склеить их в один status green: тогда непонятно, проверено ли поведение, сборка или доступ к кнопке возврата. Любой gate может сказать unknown; это результат, а не ошибка отчёта.'),
|
||
p('Google SRE Workbook 2018 определяет canary как частичное и ограниченное по времени развёртывание с оценкой, которая помогает решить, продолжать ли rollout. В главе есть техническая деталь: canary signals нужно сопоставлять с control signals, а окно метрики не должно размывать короткую волну. Для нас это не формула «выберите 5 процентов». Это дисциплина: до числа процентов назвать population, control, duration, signal, source и owner. Если хотя бы один пункт неизвестен, процент станет декорацией и не защитит от ложного вывода.'),
|
||
table('Gate для одного шва: вход, выход и граница полномочий', ['Gate', 'Что проверяется', 'Решение при PASS', 'Чего PASS не доказывает'], [
|
||
['Seam review', 'one route, owner, old/new boundary', 'подготовить contract', 'что legacy уже понятно целиком'],
|
||
['Compatibility review', 'valid/invalid/repeat cases и evidence plan', 'разрешить ограниченный proposal', 'реальное parity без запуска'],
|
||
['Delivery review', 'версия adapter и rule маршрута', 'согласовать окно изменения', 'отсутствие внешней деградации'],
|
||
['Observation review', 'control, population, duration и signal source', 'выбрать manual decision после волны', 'причину любого расхождения'],
|
||
['Rollback review', 'return route и data boundary', 'разрешить обратимое изменение', 'восстановление необратимых данных'],
|
||
]),
|
||
figure('/assets/editorial/2024/legacy-modernization-2024-rollout-gate.svg', 'Петля rollout: synthetic legacy-only stage переходит к ограниченному предложению нового пути только после manual compatibility review; затем owner сопоставляет declared evidence, выбирает расширение или возвращение к legacy-only. Линия данных отмечена отдельно и не названа автоматическим rollback.', 'Схема показывает порядок решения, а не production pipeline. В ней нет процентов, traffic, метрик, CI-результатов, пользователей или выполненного возврата маршрута.'),
|
||
h2('Ограниченная волна имеет смысл только рядом с control'),
|
||
p('Просто отправить часть запросов в новый adapter недостаточно. Нужна сопоставимость: тот же тип request, известная population boundary и signal, чей смысл не меняется между путями. Если новый путь получает только пользователей без скидок, а legacy — остальных, разница может описывать population, не implementation. Если duration меньше окна агрегации, старые ошибки могут попасть в вывод о новом пути. Google разбирает такую ошибку с короткой canary-волной и часовым aggregation. В реальном plan это повод записать окно и источник signal рядом с решением, а не угадывать после alert.'),
|
||
p('Для stateful операций control особенно коварен. Cache, affinity, retry и shared database могут связать старый и новый путь. Synthetic load помогает проверить code branch, но не обязательно представляет state coverage; Google отдельно предупреждает об этом ограничении. Поэтому нельзя заявлять: новый путь прошёл synthetic traffic, значит он безопасен. Умнее ограничить первый шов до операции, в которой state boundary наблюдаема, или оставить stateful effect на legacy до появления data plan. Без этого rollout может создать состояние, которое нельзя честно откатить.'),
|
||
h2('Rollback-route и восстановление данных — разные работы'),
|
||
p('Rollback-route отвечает на один вопрос: как направить следующий допустимый запрос обратно в legacy-only path. Это может быть версия routing configuration, proxy rule или выключение выделенного adapter. Он должен иметь owner, known prior state и подтверждение, что правило вернулось. Data recovery отвечает на другой вопрос: как поступить с уже созданными или изменёнными данными. Иногда он невозможен без reconciliation. Называть первое вторым опасно: route может вернуться за минуты, а созданное событие или изменённый баланс останется и потребует business decision.'),
|
||
p('Перед rollout стоит спросить пять раздельных вещей: что возвращаем, кто делает действие, какие новые requests перестанут идти в новый путь, какие записи уже не отменяются и как будет видно, что маршрут вернулся. Если ответа нет, plan честно записывает rollback unavailable for data. Это может блокировать волну или требовать другого scope, но не является поводом добавить delete-script. Обратимость — свойство конкретного действия, не обещание команды. M7-тон нужен, чтобы назвать цену необратимости до того, как она станет пользовательской.'),
|
||
p('Существует отдельная граница для внешнего provider. Даже если route возвращён, запрос мог уже уйти в payment gateway, email service или partner API. Для такого effect нужен record id, reconciliation owner и правило общения с business. Нельзя проверить это переключением маршрута. Если данная операция входит в первый шов, gate обязан прямо признать, что rollback-route не покрывает provider effect. Часто безопаснее начать с preview или read model, чем добывать мнимую обратимость через агрессивную компенсацию.'),
|
||
h2('Не отдавайте решение fixture или CI-иконке'),
|
||
p('Учебный fixture хранит synthetic stages: legacy-only, limited-new-path-proposed и expand-after-owner-approval. У всех state равно not-executed; у rollout releaseAuthority равно not-granted. Код отвергает попытку переставить stages, назвать automatic release, удалить legacy-data в rollback или записать coverage как измеренный факт. Поэтому fixture подходит как машинная проверка, что decision record не потерял ручную границу. Он не умеет включить route, сравнить metrics, прочитать deployment history или сказать, что user traffic обработан корректно.'),
|
||
code(fixtureCommand),
|
||
p('У fixture нет I/O: он не читает legacy code/history, не запускает parity tests, CI, сеть или HTTP, не измеряет coverage/performance и не создаёт records вне памяти процесса. Synthetic names помечены намеренно. PASS не означает, что canary готов, monitoring достаточен или real rollback работает. Он означает, что plan не перепутал выбор человека с execution системы и не выдал route proposal за data recovery. Это небольшой технический барьер против ложной уверенности в документах rollout.'),
|
||
h2('Маршрут: симптом → причина → проверка → действие'),
|
||
ol([
|
||
'<strong>Симптом.</strong> Команда предлагает процент rollout, но не называет control, duration, source сигнала и условие остановки.',
|
||
'<strong>Причина.</strong> Процент приняли за стратегию, а rollback-route смешали с восстановлением данных. Gate не владеют вопросами, поэтому каждый status выглядит одинаково зелёным.',
|
||
'<strong>Проверка подготовленности.</strong> Для шва выпишите owner, version, population, control, duration, signal, decision rule и rollback-route. Отсутствующее значение остаётся unknown.',
|
||
'<strong>Проверка данных.</strong> Отдельно назовите immutable или append-only effects и того, кто решает reconciliation. Не объявляйте их автоматически восстановимыми.',
|
||
'<strong>Действие.</strong> Разрешите только волну, для которой можно вернуться к known legacy route без необъявленного изменения данных; остальной scope остаётся legacy-only.',
|
||
'<strong>Повтор.</strong> После волны owner сравнивает выбранные evidence и выбирает expand, pause или return. Новый signal без control открывает расследование, не автоматический вывод.',
|
||
]),
|
||
h2('Небольшой rollout не равен маленькому риску'),
|
||
p('Даже одна операция имеет большой impact, если запускает платёж, уведомление, доступ или перезапись общей записи. Тогда правильный первый шаг — не процент traffic, а более узкий contract: read-only preview, отдельный tenant с соглашением или staging с представительным состоянием. Если таких условий нет, команде не обязательно делать canary ради ритуала. Можно оставить legacy path, подготовить evidence и вернуться к migration позже. Отложенный rollout с ясной причиной дешевле быстрого rollout с необъяснимым effect.'),
|
||
p('Есть и противоположный риск: остановить модернизацию навсегда, потому что идеальный safety case недостижим. Выход — не отменять требования, а делить их по месту. Route return проверяем до rollout. Contract cases — на test контуре. Data reconciliation — отдельная работа с owner. Observability — checklist с доступом к реальным данным. Когда эти части названы, команда выбирает первый обратимый шаг и его цену. Необязательно знать всё о монолите, чтобы честно не трогать то, что нельзя контролировать.'),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('В статье нет настоящего pipeline, feature flag, traffic split, dashboard, latency, error rate, production population или исполнения rollback. Она не назначает универсальные проценты и не обещает, что canary заменяет testing. Google Workbook описывает принципы и ограничения; Fowler говорит о риске cut-over; OAS описывает форму interface. Эти источники не знают вашу модель данных, policy доступа, допустимый impact или договор с consumer. Реальная волна требует собственных owners, прав и evidence.'),
|
||
p('Следующий шаг: заполните одну gate-card до изменения. В ней должны быть seam id, версия adapter/rule, owner, population/control, duration, signal source, decision rule, rollback-route и data boundary. Проведите tabletop-review без включения traffic: кто может сделать return, что он изменит и чего не изменит. Если карточка не даёт ответ, уменьшите scope. Такой результат полезнее запуска rollout по дате. Он сохраняет возможность развивать legacy без выдуманного обещания, что всё поведение уже известно.'),
|
||
h2('Историческая граница января 2024'),
|
||
p('Все источники существовали до конца января 2024: Fowler — с 2004 года, OAS 3.1.0 — с 2021-го, Google SRE Workbook — из издания 2018 года. Материал не подтягивает поздние инструменты и не сочиняет production rollout. M7 проявляется в цене сосуществования, владельце решения и отделении обратимого route change от данных; это развитие инженерного автора, не декларация безрисковой миграции.'),
|
||
]);
|
||
|
||
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
|
||
|
||
function verifyFixture() {
|
||
const report = runLegacyModernizationFixture();
|
||
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
|
||
if (failed.length) {
|
||
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
const count = Object.keys(report.assertions).length;
|
||
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
|
||
}
|
||
|
||
if (process.argv.includes('--verify-fixture')) verifyFixture();
|
||
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');
|