Files
progcode/web/scripts/upgrade-2027-11.mjs
2026-07-31 22:26:56 +03:00

299 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const escapeHtml = (value) => String(value).replaceAll('&', '&amp;').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((cell) => `<th scope="col">${cell}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
function deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.values(value).forEach(deepFreeze);
Object.freeze(value);
}
return value;
}
function plainText(html) {
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039;)/g, ' ').replace(/\s+/g, ' ').trim();
}
function bodyText(html) {
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*$/, ''));
}
const REFERENCES = deepFreeze({
postgres: { title: 'PostgreSQL 16 Documentation — Modifying Tables', url: 'https://www.postgresql.org/docs/16/ddl-alter.html', version: 'PostgreSQL 16, раздел 5.6, документация версии 16' },
http: { title: 'RFC 9110 — HTTP Semantics', url: 'https://www.rfc-editor.org/rfc/rfc9110.html', version: 'IETF, июнь 2022 года, RFC 9110, Standards Track' },
retryStatus: { title: 'RFC 6585 — Additional HTTP Status Codes', url: 'https://www.rfc-editor.org/rfc/rfc6585.html', version: 'IETF, апрель 2012 года, RFC 6585, Standards Track' },
nistIncident: { title: 'NIST SP 800-61 Revision 2 — Computer Security Incident Handling Guide', url: 'https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-61r2.pdf', version: 'NIST, revision 2, май 2012 года, Special Publication 800-61' },
});
function sources(entries) {
return `<ul>${entries.map(({ key, use, boundary }) => {
const ref = REFERENCES[key];
return `<li><a href="${ref.url}" target="_blank" rel="noopener noreferrer">${escapeHtml(ref.title)}</a> — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)} Граница: ${escapeHtml(boundary)}</li>`;
}).join('')}</ul>`;
}
export function classifyMigrationStep({ oldReads = false, newReads = false, oldWrites = false, newWrites = false }) {
if (newWrites && !oldReads && !newReads) return { phase: 'unsafe', reason: 'new-writer-has-no-compatible-reader' };
if (!oldWrites && newWrites) return { phase: 'expand', reason: 'new-write-path-can-be-added-with-old-readers' };
if (newReads && oldReads && newWrites) return { phase: 'switch', reason: 'both-readers-and-writers-understand-format' };
if (oldReads && !newReads && !newWrites) return { phase: 'contract', reason: 'remove-format-only-after-consumers-move' };
return { phase: 'inspect', reason: 'compatibility-matrix-is-incomplete' };
}
export function calculateRetryDelay({ attempt, baseMs = 100, capMs = 5000, jitterMs = 0 }) {
if (!Number.isInteger(attempt) || attempt < 0 || ![baseMs, capMs, jitterMs].every((value) => Number.isFinite(value) && value >= 0) || baseMs > capMs) return { ok: false, reason: 'retry-input-invalid' };
const exponential = Math.min(capMs, baseMs * (2 ** attempt));
return { ok: true, delayMs: exponential + jitterMs, exponentialMs: exponential };
}
export function classifyIncidentSignal({ status, latencyMs, errorRate, latencyLimitMs = 1000, errorLimit = 0.05 }) {
if (!Number.isInteger(status) || !Number.isFinite(latencyMs) || !Number.isFinite(errorRate)) return { ok: false, reason: 'signal-invalid' };
if (status >= 500 || errorRate >= errorLimit) return { ok: true, severity: 'high', reason: 'availability-or-error-threshold' };
if (latencyMs >= latencyLimitMs) return { ok: true, severity: 'medium', reason: 'latency-threshold' };
return { ok: true, severity: 'low', reason: 'signal-below-threshold' };
}
function revision(meta, parts, referenceEntries) {
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) throw new Error(`${meta.slug}: body length ${proseLength}`);
return deepFreeze({ ...meta, contentHtml, proseLength });
}
const migrationRefs = [
{ key: 'postgres', use: 'Показывает, как операции изменения таблиц связаны с блокировками, размером данных и совместимостью чтения.', boundary: 'Не является инструкцией для конкретного кластера: версия, расширения, объём и lock policy требуют отдельной проверки.' },
{ key: 'http', use: 'Даёт HTTP-семантику методов, статусов и условных запросов, важную для совместимого API вокруг миграции.', boundary: 'Не описывает схему вашей базы, ORM и порядок выката приложения.' },
];
const practice = revision({
slug: 'editorial-2027-11-practice-mistakes-revisions',
title: 'Миграция схемы БД без простоя: expand, switch, contract',
categories: ['Базы данных', 'Надёжность'],
cover: '/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg',
excerpt: 'Разбираем совместимую миграцию таблицы: почему опасно менять запись и чтение одним шагом и как оставить обратимый переход.',
readingMinutes: 16,
}, [
p('Проблема начинается с невинной команды <code>ALTER TABLE</code>: запрос проходит на пустой базе, но на большой таблице блокирует чтение или оставляет старый код без нужной колонки. Цена ошибки — простой, очередь запросов и откат приложения, который уже не умеет читать изменённые данные.'),
p('Причина — рассматривать схему и код как один пакет. В работающей системе старый и новый binary живут одновременно, миграция может быть прервана, а несколько экземпляров переключаются не синхронно. Поэтому изменение нужно разложить на совместимые фазы: сначала добавить форму, затем переключить чтение и запись, и только потом удалить старое.'),
h2('Совместимость — это матрица чтения и записи'),
p('Представьте добавление <code>display_name</code> вместо вычисления имени из двух колонок. Старый код читает <code>first_name</code> и <code>last_name</code>, новый хочет читать <code>display_name</code>. Если сразу сделать новое поле обязательным и перевести writer, старый reader может не понять запись. Если сразу удалить старые колонки, rollback перестанет быть обратимым.'),
p('На первом шаге добавляется новая колонка без требования, чтобы старый код продолжал работать. Затем новый writer может заполнить обе формы, а reader — выбрать новую при наличии и старую как fallback. После backfill и проверки потребителей можно убрать fallback. Последняя операция должна быть отдельной и отложенной относительно первого изменения кода.'),
table('Фазы expand/switch/contract', ['Фаза', 'Чтение', 'Запись', 'Допустимое изменение'], [
['Expand', 'старое', 'старое или обе формы', 'добавить nullable колонку/индекс'],
['Dual write', 'старое с fallback', 'обе формы', 'заполнить новый формат'],
['Switch', 'новое с fallback', 'обе формы', 'перевести reader после проверки данных'],
['Contract', 'новое', 'новая форма', 'удалить старую только после сигнала'],
['Rollback', 'старое или fallback', 'совместимая запись', 'вернуть binary без потери данных'],
]),
h2('DDL — это операция с ресурсом'),
p('Документация PostgreSQL предупреждает, что изменение таблицы может зависеть от блокировок и объёма работы. В review важно смотреть не только на SQL, но и на lock mode, время ожидания, транзакцию миграции и поведение при остановке. Индекс, backfill и изменение типа имеют разную стоимость; объединять их в одну «маленькую миграцию» опасно.'),
p('Backfill лучше считать отдельной нагрузкой. Он может конкурировать с пользовательскими запросами, вызвать рост WAL и изменить порядок обновлений. Ограниченная пачка, пауза и метрика отставания полезнее одного огромного UPDATE. Если backfill прерван, повтор должен быть идемпотентным и не перезаписывать более свежую запись.'),
figure('/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg', 'Временная схема миграции: добавление совместимой формы, двойная запись, переключение чтения и удаление старой колонки разделены измеримыми контрольными точками.', 'Диаграмма показывает порядок, в котором старый и новый код могут сосуществовать. Удаление старой формы находится в конце и требует сигнала использования.'),
h2('Runnable-пример: определить безопасную фазу'),
p('Функция принимает четыре boolean-признака: умеет ли старый и новый код читать и писать новый формат. Она возвращает фазу и причину. Пример не подключается к базе и не запускает DDL; он фиксирует мысль, которую удобно проверить в review или в тесте миграционного инструмента. Если новый writer не имеет совместимого reader, результат должен быть <code>unsafe</code>.'),
code(`import { classifyMigrationStep } from './upgrade-2027-11.mjs';
const expand = classifyMigrationStep({
oldReads: false,
newReads: false,
oldWrites: false,
newWrites: true,
});
const switchPhase = classifyMigrationStep({
oldReads: true,
newReads: true,
oldWrites: true,
newWrites: true,
});
console.log(expand.phase, expand.reason);
console.log(switchPhase.phase, switchPhase.reason);
// unsafe new-writer-has-no-compatible-reader
// switch both-readers-and-writers-understand-format`),
h2('Порядок безопасной миграции'),
ol([
'Запишите старую и новую форму данных, а также кто читает и кто пишет каждую форму. Не начинайте с SQL-файла.',
'Проверьте DDL на блокировки, размер таблицы, транзакцию и план восстановления. Для production-объёма используйте копию или staging с похожими данными.',
'Добавьте новую форму без требования, которое сломает старый binary. Сборка приложения должна проходить до переключения reader.',
'Включите двойную запись или backfill с идемпотентными пачками. Сверяйте количество и контрольные значения старой и новой формы.',
'Переведите чтение на новую форму с fallback. Наблюдайте ошибки, latency, lock wait и долю чтения старой колонки.',
'Удаляйте старую форму отдельным изменением после окна наблюдения и проверяемого сигнала, что rollback-путь больше не нужен.',
]),
h2('Почему rollback не равен обратной миграции'),
p('Откат приложения возвращает код, но не обязательно возвращает схему. Если новый код записал только <code>display_name</code>, старый reader без fallback увидит пустоту. Обратная миграция DDL может быть дорогой и потерять информацию при преобразовании типа. Поэтому rollback-путь проектируют до switch: старый reader должен продолжать работать на данных, созданных новым writer.'),
p('Тестировать нужно не только финальное состояние. Нужны состояния после expand, после частичного dual write и после остановки backfill. В каждом состоянии старый и новый binary должны иметь понятное поведение. Такой набор дороже одного smoke test, но дешевле восстановления после того, как несовместимость попала в основную таблицу.'),
h2('Ограничения и следующий шаг'),
p('Пример не учитывает конкретные lock mode PostgreSQL, репликацию, триггеры, ORM, партиционирование и размер WAL. Документация версии 16 — источник терминов, а не разрешение выполнить операцию на вашей базе. Учебные имена колонок не должны копироваться без проверки нагрузки и индексов.'),
p('Следующий шаг — выбрать одну миграцию и заполнить compatibility matrix для старого/нового reader и writer, а затем проиграть остановку на каждой фазе. Если нет состояния, в котором старый код безопасно читает новую запись, сначала исправьте контракт и только потом пишите DDL.'),
], migrationRefs);
const retryRefs = [
{ key: 'http', use: 'Разделяет свойства методов и идемпотентность, необходимые для решения, допустим ли повтор запроса.', boundary: 'Не задаёт политику retry конкретного клиента, backoff и максимальное число попыток.' },
{ key: 'retryStatus', use: 'Фиксирует статус 429 Too Many Requests и место сигнала о перегрузке сервера.', boundary: 'Не говорит, какой delay выбрать и можно ли повторять конкретную операцию.' },
];
const mechanism = revision({
slug: 'editorial-2027-11-mechanism-mistakes-revisions',
title: 'Retry без шторма: backoff, jitter и идемпотентность',
categories: ['HTTP', 'Надёжность'],
cover: '/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg',
excerpt: 'Почему повтор не лечит любой timeout: связываем статус, идемпотентность, экспоненциальную задержку и предел нагрузки.',
readingMinutes: 15,
}, [
p('Проблема видна как лавина: один upstream отвечает 503 или 429, а несколько клиентов одновременно повторяют запрос. Цена — усилить перегрузку именно в момент восстановления, увеличить latency и получить каскад отказов. Без различия между безопасным чтением и записью retry превращается в генератор дублей.'),
p('Причина — считать повтор одной настройкой «три попытки». Правильное решение зависит от HTTP-метода, кода ответа, идемпотентности операции, Retry-After, текущего deadline и нагрузки. Экспоненциальная задержка уменьшает частоту, а jitter раздвигает одинаковые моменты старта; ни одна из них не делает небезопасную запись безопасной.'),
h2('Сначала право на повтор'),
p('GET обычно проектируют как идемпотентное чтение, но серверная реализация и внешние побочные эффекты всё равно требуют проверки. POST может создать ресурс повторно. Для такой операции нужен idempotency key и серверное хранение результата, либо клиент должен получить способ запросить состояние операции. Timeout означает неизвестный результат, а не подтверждённый отказ.'),
p('Статус 429 сообщает о частоте запросов, но не выбирает за клиента точный алгоритм. 503 может означать временную недоступность, но повтор с коротким интервалом усугубит проблему. 400 обычно не меняется от повтора. Политика должна принимать method, status, наличие Retry-After и остаток deadline, а не только boolean «network error».'),
table('Решение о повторе', ['Сигнал', 'Повтор', 'Задержка', 'Риск'], [
['429 + Retry-After', 'только если операция допустима', 'не раньше указанного времени', 'клиенты проснутся одновременно'],
['503 без Retry-After', 'ограниченно', 'backoff + jitter', 'перегрузить восстанавливающийся upstream'],
['timeout GET', 'возможен', 'остаток deadline', 'ответ мог быть готов на сервере'],
['timeout POST', 'только с ключом/проверкой', 'короткий controlled retry', 'дублирование записи'],
['400/401/403', 'нет', 'не нужна', 'повтор маскирует дефект входа или права'],
]),
h2('Экспонента не должна расти бесконечно'),
p('Базовая формула <code>min(cap, base × 2^attempt)</code> ограничивает задержку сверху. Jitter добавляет случайное смещение, чтобы тысячи клиентов не повторили в одну миллисекунду. Но общий deadline должен иметь приоритет: если до его конца осталось 40 мс, не имеет смысла ждать 500 мс ради следующей попытки. Операция завершается, а причина сохраняется.'),
p('Случайность нужно добавлять так, чтобы наблюдение оставалось возможным. Логируйте номер попытки, рассчитанную задержку, статус и остаток deadline, но не секреты и полное тело запроса. Для тестов используйте переданный генератор случайных чисел или фиксированный jitter. Иначе тест будет иногда падать, а реальная политика останется непроверяемой.'),
figure('/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg', 'Матрица retry: метод и статус задают право на повтор, затем применяются deadline, backoff и jitter; неизвестный результат записи требует идемпотентного ключа.', 'Схема отделяет решение о повторе от расчёта задержки. Красная граница означает, что backoff не исправляет небезопасную семантику операции.'),
h2('Runnable-пример: рассчитать ограниченную задержку'),
p('Функция получает номер попытки, базовую задержку, cap и учебный jitter. Она возвращает экспоненциальную часть и итог. В реальном клиенте jitter обычно генерируется отдельно и учитывается в deadline; здесь он передан числом, чтобы результат был воспроизводимым. На третьей попытке с base 100 и cap 1000 экспонента равна 800.'),
code(`import { calculateRetryDelay } from './upgrade-2027-11.mjs';
const third = calculateRetryDelay({
attempt: 3,
baseMs: 100,
capMs: 1000,
jitterMs: 37,
});
const invalid = calculateRetryDelay({
attempt: -1,
baseMs: 100,
capMs: 1000,
});
console.log(third.ok, third.exponentialMs, third.delayMs);
console.log(invalid.ok, invalid.reason);
// true 800 837
// false retry-input-invalid`),
h2('Порядок настройки retry'),
ol([
'Для каждого метода выпишите побочные эффекты и способ узнать результат после timeout. Без этого число попыток не имеет смысла.',
'Составьте таблицу статусов: что можно повторять, какой сигнал приходит от сервера и когда нужно остановиться.',
'Передавайте абсолютный deadline или остаток времени во все попытки. Не выдавайте каждой попытке новый полный бюджет.',
'Задайте base, cap и предел попыток, затем добавьте jitter. Проверьте формулу на нулевой, первой и предельной попытке.',
'Обработайте Retry-After как верхнюю границу политики сервера, но всё равно сравните её с deadline.',
'Нагрузочным тестом проверьте восстановление upstream: retry не должен создавать вторую волну запросов быстрее исходной.',
]),
h2('Идемпотентный ключ — это не request-id'),
p('Request-id помогает найти попытку в логах, но сам по себе не говорит серверу, что две попытки означают одну операцию. Idempotency key должен быть связан с семантикой команды, сроком хранения и результатом. Сервер обязан решить, что вернуть при повторе с тем же ключом и другим телом. Это часть контракта, а не случайное поле заголовка.'),
p('Даже при ключе остаются границы: сбой между записью и сохранением результата, истечение TTL, разные пользователи и смена версии схемы. Поэтому ключ не отменяет тесты повторной доставки и проверку состояния. Он даёт серверу возможность дедуплицировать операцию, но не обещает успешный outcome.'),
h2('Ограничения и следующий шаг'),
p('Расчёт задержки не реализует случайный генератор, circuit breaker, rate limit и очередь. RFC 6585 описывает статус 429, но не определяет вашу политику. Примеры чисел учебные и не подходят для копирования без измерения downstream и общего deadline.'),
p('Следующий шаг — выбрать один endpoint, описать повтор для каждого метода и прогнать искусственный 429/503 с фиксированным временем. Отдельно проверьте timeout POST: повтор должен либо использовать idempotency key, либо перейти к запросу статуса, а не автоматически создать вторую запись.'),
], retryRefs);
const incidentRefs = [
{ key: 'nistIncident', use: 'Даёт структуру обработки инцидента: подготовка, обнаружение/анализ, containment, eradication/recovery и post-incident activity.', boundary: 'Не задаёт вашу архитектуру, severity thresholds, on-call график и допустимое действие для конкретной системы.' },
{ key: 'http', use: 'Помогает различать HTTP-статус, метод и сетевой сбой при сборе первичного симптома.', boundary: 'Не является runbook и не заменяет метрики, логи, traces и проверку конкретного сервиса.' },
];
const field = revision({
slug: 'editorial-2027-11-field-mistakes-revisions',
title: 'Incident runbook: от симптома до rollback без догадок',
categories: ['Надёжность', 'Эксплуатация'],
cover: '/assets/editorial/2027/mistakes-revisions-2027-evidence-handoff-loop.svg',
excerpt: 'Полевой маршрут инцидента: зафиксировать симптом, отделить влияние от причины, выбрать обратимое действие и проверить восстановление.',
readingMinutes: 16,
}, [
p('Проблема во время инцидента — не отсутствие идей, а избыток неподтверждённых объяснений. «Сервис упал после релиза» смешивает время, причинность и действие. Цена — менять несколько компонентов сразу, терять baseline и не понимать, что действительно вернуло доступность.'),
p('Причина — runbook часто написан как список команд без условий остановки. В нём нет точного симптома, границы влияния, безопасного rollback и проверки результата. Рабочая инструкция начинается с наблюдаемого сигнала, запрещает опасные действия до сбора фактов и оставляет короткую петлю: измерить, изменить, проверить, зафиксировать.'),
h2('Симптом не является причиной'),
p('Статус 500, рост latency и очередь сообщений — разные наблюдения. Они могут иметь общий корень, а могут быть независимыми последствиями. Первый экран runbook должен попросить время начала, affected endpoint, долю ошибок, baseline и scope. Запись «всё медленно» не позволяет выбрать действие или оценить улучшение.'),
p('Причину формулируйте как гипотезу с проверкой: «после изменения лимита pool выросло ожидание соединения; подтверждение — метрика pool wait и сравнение с предыдущим окном». Гипотеза может не подтвердиться. Runbook должен описывать и такой исход, иначе оператор будет подгонять данные под первую версию.'),
table('Карточка первичного сигнала', ['Поле', 'Пример', 'Зачем нужно', 'Ошибка формулировки'], [
['Время', '14:05 UTC ± 5 мин', 'сопоставить deploy и метрики', '«сегодня»'],
['Scope', 'POST /payments, region EU', 'не трогать здоровый трафик', '«весь сервис»'],
['Симптом', '5xx 8%, p95 1.8s', 'измерить baseline и эффект', '«сломалось»'],
['Гипотеза', 'pool wait вырос', 'выбрать проверку', 'сразу назвать виновника'],
['Действие', 'rollback flag X', 'изменить один рычаг', 'перезапустить всё'],
['Проверка', '5xx < 1% 10 мин', 'закрыть loop', '«кажется лучше»'],
]),
h2('Сначала ограничить blast radius'),
p('Если изменение затронуло часть трафика, безопаснее уменьшить scope, чем сразу исправлять все слои. Отключение feature flag, остановка нового consumer или перевод небольшой доли на старый код дают обратимый шаг. Перезапуск без измерения может убрать симптом на минуту и стереть следы причины.'),
p('Rollback тоже имеет условия. Он безопасен, если старая версия читает текущую схему и понимает созданные события. Если недавно была миграция, сначала проверьте compatibility matrix. Во время incident нельзя полагаться на память о порядке выката: runbook должен содержать команду, ожидаемый эффект, риск и способ вернуть действие.'),
figure('/assets/editorial/2027/mistakes-revisions-2027-evidence-handoff-loop.svg', 'Петля incident runbook: симптом и scope ведут к проверке гипотезы, одному обратимому действию и измерению восстановления до закрытия инцидента.', 'Диаграмма показывает короткий рабочий цикл. Каждое действие имеет условие отката и отдельную проверку результата.'),
h2('Runnable-пример: классифицируем первичный сигнал'),
p('Функция получает HTTP status, latency и error rate, затем применяет два явных порога. Она не решает, кто виноват и какой rollback безопасен. Зато оператор может проверить, что одинаковые входы дают одинаковую срочность, а порог ошибки имеет приоритет над вторичным latency-сигналом.'),
code(`import { classifyIncidentSignal } from './upgrade-2027-11.mjs';
const signal = classifyIncidentSignal({
status: 503,
latencyMs: 820,
errorRate: 0.08,
latencyLimitMs: 1000,
errorLimit: 0.05,
});
const slow = classifyIncidentSignal({
status: 200,
latencyMs: 1400,
errorRate: 0.01,
});
console.log(signal.severity, signal.reason);
console.log(slow.severity, slow.reason);
// high availability-or-error-threshold
// medium latency-threshold`),
h2('Порядок действий во время инцидента'),
ol([
'Запишите timestamp, scope и один измеримый симптом. Сохраните ссылку на dashboard и исходное окно сравнения.',
'Проверьте, затронуты ли все регионы, методы и версии. Ограничьте воздействие, если есть безопасный flag или traffic split.',
'Сформулируйте одну гипотезу и одну проверку. Не меняйте конфигурацию до того, как знаете, какой сигнал должен измениться.',
'Выберите одно обратимое действие и запишите ожидаемый эффект, риск и условие возврата. Не запускайте пачку независимых исправлений.',
'Подождите заранее заданное окно и сравните error rate, latency, saturation и бизнес-сигнал. «Команда завершилась» не означает восстановление.',
'Зафиксируйте итог, оставшиеся риски и следующий diagnostic item. После стабилизации сохраните факты до очистки временных изменений.',
]),
h2('Rollback и восстановление — разные события'),
p('Rollback возвращает конфигурацию или binary, а recovery означает, что система снова выполняет допустимую работу и данные согласованы. Можно откатить flag, но оставить очередь сообщений, двойные записи или повреждённый кэш. Поэтому после изменения нужно проверять не только 5xx, но и отставание очереди, успешность операций и консистентность данных.'),
p('Закрывать инцидент сразу после падения error rate тоже рискованно. Ошибка могла уйти на другой endpoint, а пользовательская операция остаться незавершённой. Минимальное окно наблюдения выбирается по интервалу метрики и характеру нагрузки. В runbook лучше явно написать «не закрывать, пока X и Y не стабильны N минут», чем оставлять эту оценку оператору в самый шумный момент.'),
h2('Ограничения и следующий шаг'),
p('Классификатор не хранит timeline, не отправляет уведомления и не знает бизнес-критичность endpoint. NIST SP 800-61 даёт общую дисциплину incident handling, но не заменяет локальную матрицу severity и права на rollback. Thresholds в примере учебные; их нужно получить из SLO и baseline.'),
p('Следующий шаг — взять один частый alert и превратить его в карточку с симптомом, scope, гипотезой, одним действием и проверкой восстановления. Затем проиграть runbook на staging с намеренно созданным 503 и убедиться, что оператор может остановиться на каждом небезопасном шаге.'),
], incidentRefs);
export const revisions = deepFreeze([practice, mechanism, field]);
export function runReliabilityFixture() {
const cases = [
['migration-rejects-unsafe-writer', classifyMigrationStep({ newWrites: true }).phase, 'unsafe'],
['migration-detects-switch', classifyMigrationStep({ oldReads: true, newReads: true, oldWrites: true, newWrites: true }).phase, 'switch'],
['retry-caps-exponential', calculateRetryDelay({ attempt: 5, baseMs: 100, capMs: 1000 }).exponentialMs, 1000],
['retry-rejects-negative-attempt', calculateRetryDelay({ attempt: -1 }).reason, 'retry-input-invalid'],
['incident-prioritizes-error', classifyIncidentSignal({ status: 503, latencyMs: 50, errorRate: 0.1 }).severity, 'high'],
['incident-detects-latency', classifyIncidentSignal({ status: 200, latencyMs: 1200, errorRate: 0.01 }).severity, 'medium'],
];
const checks = cases.map(([id, actual, expected]) => ({ id, actual, expected, passed: actual === expected }));
return deepFreeze({ passed: checks.filter((item) => item.passed).length, total: checks.length, accepted: checks.every((item) => item.passed), checks });
}
export function verifyRevisionsAgainstFixture() {
const fixture = runReliabilityFixture();
const articleChecks = revisions.map((item) => {
const text = bodyText(item.contentHtml);
return text.length >= 5000 && text.length <= 15000 && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && /Проблема/.test(text.slice(0, 900));
});
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
}
if (process.argv.includes('--verify-fixture')) {
const result = verifyRevisionsAgainstFixture();
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
if (!result.accepted) process.exitCode = 1;
}
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');