revise February 2026 resilience articles
Build and deploy / deploy (push) Successful in 17s

This commit is contained in:
2026-07-31 18:59:19 +03:00
parent fd381cd2f5
commit 9812aa0c63
7 changed files with 804 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 289 из 358 созданных материалов. Остальные 69 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 292 из 358 созданных материалов. Остальные 66 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
+90
View File
@@ -0,0 +1,90 @@
# P96 · 2026-02 · Устойчивость к отказам — трёхпроходное self-review
## Граница пакета
Проверен только изолированный overlay для трёх slug:
- editorial-2026-02-practice-resilience
- editorial-2026-02-mechanism-resilience
- editorial-2026-02-field-resilience
Все компоненты, маршруты, попытки, budgets, fallback, replica names, latency units, failure injection и trace — fixed synthetic JavaScript literals в памяти. Пакет не использует сеть, filesystem, clock, telemetry, Git, CI, PII, клиентов, реальную инфраструктуру или production data. Положительный статус строго равен synthetic resilience review hand-off.
## Проход 1 — problem, cost, голос и самостоятельность
Проверено 2026-07-31.
- Practice начинает с частичного сбоя и цены лишней работы в ослабленном маршруте. Механизм статьи — карта расширения logical work, один retry owner и ранний limit point.
- Mechanism начинает с ошибки настройки timeout без модели ширины работы. Механизм статьи — различение attempts, fan-out, fallback и limit как разных осей, плюс консервативная верхняя граница fixed записи.
- Field начинает словами «Проблема каскадного сбоя» и ценой неясной точки остановки. Механизм статьи — допуск к сравнению по comparison key и review trace до вывода о репликации.
- Каждая статья имеет отдельный problem/cost, таблицу, последовательность, executable example, safe SVG и отдельное ограничение метода. Нет общей вводной, общего тела либо общего финального совета.
- Голос М9: короткая прагматичная техническая речь; декларации эффекта заменены условиями, границами и следующими действиями.
- Первый запуск audit выявил конкретный дефект field: в раннем фрагменте не было явного слова о проблеме. Введено «Проблема каскадного сбоя», затем audit повторён успешно.
Итоговые объёмы основного текста без раздела источников:
| Статья | Знаков | Решение |
| --- | ---: | --- |
| practice | 9 230 | PASS, внутри диапазона 5 000–15 000 |
| mechanism | 9 177 | PASS, внутри диапазона 5 000–15 000 |
| field | 9 252 | PASS, внутри диапазона 5 000–15 000 |
## Проход 2 — источники, историческая граница и исполнимость
Проверено 2026-07-31, историческая граница источников: не позднее 2026-02-28.
| Pin | Проверенный факт | Применение | Явная граница |
| --- | --- | --- | --- |
| [Google SRE Book, архивный capture 2025-01-23](https://web.archive.org/web/20250123164219id_/https://sre.google/sre-book/addressing-cascading-failures/) | Глава описывает каскад как положительную обратную связь, retry budget и риск произведения попыток на нескольких уровнях. | Контекст для обсуждения каскада и одного владельца retry. | Это исторический текст, не trace и не capacity-доказательство fixture. |
| [gRPC A6, immutable commit dc9fd4fe5b94b90b82fe2833ad1d80938e6a49c1](https://raw.githubusercontent.com/grpc/proposal/dc9fd4fe5b94b90b82fe2833ad1d80938e6a49c1/A6-client-retries.md) | Commit authored 2024-08-29. A6 определяет maxAttempts, throttling, token_count и ограничения retry/hedging. | Термины и boundary retry policy. | Proposal не запускается в модуле и не доказывает эффект synthetic сценария. |
| [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html) | Неизменяемая RFC publication, June 2022; sections 10.2.3 и 15.6.4 задают семантику Retry-After и 503. | Узкая трактовка временной недоступности сообщения. | RFC не определяет владельца retry, idempotency операции или fan-out данного record. |
Точечные fetch-проверки подтвердили capture, Git SHA и дату, а также именно те утверждения, которые пересказаны в source lists. В каждой статье видимы version/date и immutable URL. Стандарт и документация нигде не выданы за измерение эффекта fixture.
Все три видимых snippets использовали только public exports и были исполнены буквально из каталога web/scripts:
- accepted record: synthetic-resilience-ready, handoff synthetic-resilience-review;
- negative record: stop-retry-amplification с причиной одного владельца и конечной границы;
- comparison: stop-incomparable-scenario.
Fixture намеренно fail-closed для retry amplification, anonymous fallback, unbounded replication, отсутствующего limit point, несопоставимого scenario и unknown input. Никакой output не содержит production effect: значение равно not-attempted.
## Проход 3 — визуалы, mobile, структура и команды
Проверено 2026-07-31.
- Проверены три оригинальные SVG: каскадный граф, матрица деградации и loop review.
- XML валиден. Safety scan не нашёл script, foreignObject, javascript URI, data:image или inline handler.
- Sharp создал PNG-рендеры всех трёх SVG при ширине 375 px; каждый затем был просмотрен визуально.
- Первый mobile review нашёл обрезанные заголовки у матрицы и feedback loop, а также переполненную подпись stop. Подписи сокращены; повторный 375 px render читаем и не обрезан.
- Cross-article scan по основному тексту без source lists: 0 общих 12-словных фрагментов во всех трёх парах.
Фактические команды:
| Команда | Результат |
| --- | --- |
| node --check web/scripts/upgrade-2026-02.mjs | PASS |
| node web/scripts/upgrade-2026-02.mjs --verify-fixture | PASS, 16/16 assertions |
| npm run audit:draft -- scripts/upgrade-2026-02.mjs | PASS для всех трёх slug |
| xmllint --noout для трёх SVG | PASS |
| rg safety scan для трёх SVG | PASS, 0 matches |
| Sharp render 375 px | PASS, 3 PNG; повторный PASS для двух исправленных SVG |
| git diff --check | PASS |
Npm вывел только существующие предупреждения о неизвестных user config store-dir, cache-dir и public-hoist-pattern; проверка пакета завершилась с кодом 0.
## Итог draft-прохода
На момент передачи draft-пакет был готов к независимой приёмке; registry, README, app files, articles.json, очередь, чужие изменения, staging, commit и push не были затронуты.
## Независимая приёмка основного редактора
Основания пакета сверены повторно по закреплённым первоисточникам. Wayback capture Google SRE Book от 23 января 2025 года прямо определяет каскад как positive feedback, показывает перегрузку оставшихся реплик и отдельно предупреждает, что retry на нескольких уровнях может перемножить попытки. GitHub API подтвердил exact gRPC A6 SHA `dc9fd4fe5b94b90b82fe2833ad1d80938e6a49c1` и author date `2024-08-29T15:14:35Z`; pinned text содержит retry policy, hedging и `maxAttempts`. Immutable RFC 9110 подтверждает разделы Retry-After и 503 Service Unavailable. Ни одно утверждение о synthetic карточках не выдано за факт этих источников.
Все три snippets исполнены из public exports: accepted record вернул `synthetic-resilience-ready` с owner `edge` и конечными границами; негативный record вернул `stop-retry-amplification`; сравнение разных ключей вернуло `stop-incomparable-scenario`. Fixture содержит **16** истинных assertions и во всех положительных и отрицательных путях оставляет `productionEffect: not-attempted`.
После подключения overlay production-аудит подтвердил 9 230 / 9 177 / 9 252 знака, по одной figure, table и code example. В `editorialRevisions` теперь **283** записи; каждый февральский slug присутствует ровно один раз. Строгая cross-article проверка полного body, включая code и без source list, дала 0 одинаковых абзацев длиной от 160 знаков и 0 общих 12-словных фрагментов во всех трёх парах.
XML и safety SVG повторно прошли, три mobile render на 375 px визуально просмотрены. `git diff --check` чист; `npm run build` успешно сгенерировал **374/374** статические страницы.
**Итог независимой приёмки: PASS.** Пакет готов к отдельному commit и push; сторонние незавершённые изменения в него не входят.
+2
View File
@@ -92,6 +92,7 @@ import { revisions as october2025Revisions } from '../scripts/upgrade-2025-10.mj
import { revisions as november2025Revisions } from '../scripts/upgrade-2025-11.mjs';
import { revisions as december2025Revisions } from '../scripts/upgrade-2025-12.mjs';
import { revisions as january2026Revisions } from '../scripts/upgrade-2026-01.mjs';
import { revisions as february2026Revisions } from '../scripts/upgrade-2026-02.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -189,4 +190,5 @@ export const editorialRevisions = [
...november2025Revisions,
...december2025Revisions,
...january2026Revisions,
...february2026Revisions,
];
@@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="840" viewBox="0 0 1200 840" role="img" aria-labelledby="title desc">
<title id="title">Матрица fixed режимов деградации</title>
<desc id="desc">Таблица показывает четыре synthetic состояния: временный timeout, отсутствие необязательной части, неограниченный fan-out и исчерпанный бюджет. Для каждого состояния названы разрешённое действие, добавленная работа и граница.</desc>
<rect width="1200" height="840" fill="#f8fafc"/>
<rect x="34" y="28" width="1132" height="92" rx="18" fill="#102a43"/>
<text x="68" y="79" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">Матрица: исход не даёт право на новый маршрут</text>
<text x="68" y="105" fill="#cfe8ff" font-family="Arial, sans-serif" font-size="22">Все значения — fixed synthetic literals, а не данные о сервисе.</text>
<rect x="42" y="164" width="288" height="74" fill="#1f3b5b"/>
<rect x="330" y="164" width="330" height="74" fill="#1f3b5b"/>
<rect x="660" y="164" width="252" height="74" fill="#1f3b5b"/>
<rect x="912" y="164" width="246" height="74" fill="#1f3b5b"/>
<text x="186" y="210" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="28" font-weight="700">fixed symptom</text>
<text x="495" y="210" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="28" font-weight="700">permitted action</text>
<text x="786" y="210" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="28" font-weight="700">work added</text>
<text x="1035" y="210" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="28" font-weight="700">named boundary</text>
<rect x="42" y="238" width="1116" height="126" fill="#fff7d6" stroke="#d69e2e" stroke-width="2"/>
<text x="68" y="288" fill="#744210" font-family="Arial, sans-serif" font-size="28" font-weight="700">temporary timeout</text>
<text x="68" y="322" fill="#744210" font-family="Arial, sans-serif" font-size="23">one named injection</text>
<text x="356" y="288" fill="#5d4500" font-family="Arial, sans-serif" font-size="28">edge performs one retry</text>
<text x="356" y="322" fill="#5d4500" font-family="Arial, sans-serif" font-size="23">after a fixed backoff slot</text>
<text x="686" y="288" fill="#5d4500" font-family="Arial, sans-serif" font-size="28">+1 attempt</text>
<text x="686" y="322" fill="#5d4500" font-family="Arial, sans-serif" font-size="23">fan-out remains one</text>
<text x="938" y="288" fill="#5d4500" font-family="Arial, sans-serif" font-size="27">retry budget</text>
<text x="938" y="322" fill="#5d4500" font-family="Arial, sans-serif" font-size="23">max attempts: 2</text>
<rect x="42" y="364" width="1116" height="126" fill="#eef2ff" stroke="#6b6bdf" stroke-width="2"/>
<text x="68" y="414" fill="#3730a3" font-family="Arial, sans-serif" font-size="28" font-weight="700">optional part absent</text>
<text x="68" y="448" fill="#3730a3" font-family="Arial, sans-serif" font-size="23">fixed route outcome</text>
<text x="356" y="414" fill="#3730a3" font-family="Arial, sans-serif" font-size="28">named-summary output</text>
<text x="356" y="448" fill="#3730a3" font-family="Arial, sans-serif" font-size="23">no nested retry</text>
<text x="686" y="414" fill="#3730a3" font-family="Arial, sans-serif" font-size="28">+1 work unit</text>
<text x="686" y="448" fill="#3730a3" font-family="Arial, sans-serif" font-size="23">no new route search</text>
<text x="938" y="414" fill="#3730a3" font-family="Arial, sans-serif" font-size="27">named fallback</text>
<text x="938" y="448" fill="#3730a3" font-family="Arial, sans-serif" font-size="23">one fixed output</text>
<rect x="42" y="490" width="1116" height="126" fill="#fff1f0" stroke="#d84a3f" stroke-width="2"/>
<text x="68" y="540" fill="#7a271a" font-family="Arial, sans-serif" font-size="28" font-weight="700">fan-out is unbounded</text>
<text x="68" y="574" fill="#7a271a" font-family="Arial, sans-serif" font-size="23">any number is not a limit</text>
<text x="356" y="540" fill="#7a271a" font-family="Arial, sans-serif" font-size="28">stop review</text>
<text x="356" y="574" fill="#7a271a" font-family="Arial, sans-serif" font-size="23">set a finite integer bound</text>
<text x="686" y="540" fill="#7a271a" font-family="Arial, sans-serif" font-size="28">+0 accepted</text>
<text x="686" y="574" fill="#7a271a" font-family="Arial, sans-serif" font-size="23">expansion is refused</text>
<text x="938" y="540" fill="#7a271a" font-family="Arial, sans-serif" font-size="27">stop-unbounded</text>
<text x="938" y="574" fill="#7a271a" font-family="Arial, sans-serif" font-size="23">replication</text>
<rect x="42" y="616" width="1116" height="126" fill="#e7f8ef" stroke="#21865b" stroke-width="2"/>
<text x="68" y="666" fill="#135d3d" font-family="Arial, sans-serif" font-size="28" font-weight="700">budget exhausted</text>
<text x="68" y="700" fill="#135d3d" font-family="Arial, sans-serif" font-size="23">limit reads before expansion</text>
<text x="356" y="666" fill="#135d3d" font-family="Arial, sans-serif" font-size="28">return degraded result</text>
<text x="356" y="700" fill="#135d3d" font-family="Arial, sans-serif" font-size="23">do not choose another path</text>
<text x="686" y="666" fill="#135d3d" font-family="Arial, sans-serif" font-size="28">+0 attempts</text>
<text x="686" y="700" fill="#135d3d" font-family="Arial, sans-serif" font-size="23">cascade terminates</text>
<text x="938" y="666" fill="#135d3d" font-family="Arial, sans-serif" font-size="27">terminal action</text>
<text x="938" y="700" fill="#135d3d" font-family="Arial, sans-serif" font-size="23">fixed output only</text>
<line x1="330" y1="164" x2="330" y2="742" stroke="#9fb3c8" stroke-width="2"/>
<line x1="660" y1="164" x2="660" y2="742" stroke="#9fb3c8" stroke-width="2"/>
<line x1="912" y1="164" x2="912" y2="742" stroke="#9fb3c8" stroke-width="2"/>
<text x="42" y="796" fill="#486581" font-family="Arial, sans-serif" font-size="22">Matrix checks contracts of a fixed model, not availability, latency or capacity of a production system.</text>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

@@ -0,0 +1,56 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800" role="img" aria-labelledby="title desc">
<title id="title">Каскад fixed сценария и ранняя точка ограничения</title>
<desc id="desc">Схема показывает один логический запрос, временный timeout, единственного владельца retry, выбор одной реплики, именованный fallback и terminal action после исчерпания бюджета.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 Z" fill="#355070"/>
</marker>
<marker id="stop-arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 Z" fill="#b42318"/>
</marker>
</defs>
<rect width="1200" height="800" fill="#f8fafc"/>
<rect x="38" y="32" width="1124" height="84" rx="18" fill="#102a43"/>
<text x="72" y="82" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">Fixed каскад: limit стоит до расширения маршрута</text>
<text x="72" y="105" fill="#cfe8ff" font-family="Arial, sans-serif" font-size="22">Одна дополнительная попытка не превращается в новую волну retry, fallback и fan-out.</text>
<rect x="60" y="190" width="240" height="128" rx="16" fill="#e6f4ff" stroke="#2b6cb0" stroke-width="3"/>
<text x="180" y="236" text-anchor="middle" fill="#102a43" font-family="Arial, sans-serif" font-size="29" font-weight="700">logical request</text>
<text x="180" y="270" text-anchor="middle" fill="#334e68" font-family="Arial, sans-serif" font-size="24">fixed input</text>
<text x="180" y="300" text-anchor="middle" fill="#334e68" font-family="Arial, sans-serif" font-size="24">2 units of work</text>
<rect x="390" y="190" width="250" height="128" rx="16" fill="#fff1f0" stroke="#b42318" stroke-width="3"/>
<text x="515" y="236" text-anchor="middle" fill="#7a271a" font-family="Arial, sans-serif" font-size="29" font-weight="700">primary-a</text>
<text x="515" y="270" text-anchor="middle" fill="#7a271a" font-family="Arial, sans-serif" font-size="24">fixed timeout</text>
<text x="515" y="300" text-anchor="middle" fill="#7a271a" font-family="Arial, sans-serif" font-size="24">named injection</text>
<rect x="730" y="174" width="300" height="160" rx="16" fill="#fff7d6" stroke="#b7791f" stroke-width="4"/>
<text x="880" y="220" text-anchor="middle" fill="#744210" font-family="Arial, sans-serif" font-size="31" font-weight="700">limit point</text>
<text x="880" y="258" text-anchor="middle" fill="#744210" font-family="Arial, sans-serif" font-size="25">edge owns retry</text>
<text x="880" y="290" text-anchor="middle" fill="#744210" font-family="Arial, sans-serif" font-size="25">budget: 2 extra attempts</text>
<text x="880" y="318" text-anchor="middle" fill="#744210" font-family="Arial, sans-serif" font-size="22">before route expansion</text>
<rect x="850" y="470" width="280" height="128" rx="16" fill="#e7f8ef" stroke="#21865b" stroke-width="3"/>
<text x="990" y="516" text-anchor="middle" fill="#135d3d" font-family="Arial, sans-serif" font-size="29" font-weight="700">primary-b</text>
<text x="990" y="550" text-anchor="middle" fill="#135d3d" font-family="Arial, sans-serif" font-size="24">one selected replica</text>
<text x="990" y="580" text-anchor="middle" fill="#135d3d" font-family="Arial, sans-serif" font-size="24">fixed complete</text>
<rect x="410" y="470" width="290" height="128" rx="16" fill="#eef2ff" stroke="#5b5bd6" stroke-width="3"/>
<text x="555" y="516" text-anchor="middle" fill="#3730a3" font-family="Arial, sans-serif" font-size="29" font-weight="700">named-summary</text>
<text x="555" y="550" text-anchor="middle" fill="#3730a3" font-family="Arial, sans-serif" font-size="24">one work unit</text>
<text x="555" y="580" text-anchor="middle" fill="#3730a3" font-family="Arial, sans-serif" font-size="24">no nested retry</text>
<rect x="62" y="612" width="340" height="116" rx="16" fill="#fff1f0" stroke="#b42318" stroke-width="3"/>
<text x="232" y="657" text-anchor="middle" fill="#7a271a" font-family="Arial, sans-serif" font-size="29" font-weight="700">budget exhausted</text>
<text x="232" y="691" text-anchor="middle" fill="#7a271a" font-family="Arial, sans-serif" font-size="24">return fixed degraded result</text>
<path d="M300 254 H370" fill="none" stroke="#355070" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M640 254 H708" fill="none" stroke="#355070" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M880 334 V422 H990 V450" fill="none" stroke="#355070" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M760 334 V422 H555 V450" fill="none" stroke="#5b5bd6" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M730 280 C590 365, 270 420, 232 590" fill="none" stroke="#b42318" stroke-width="5" stroke-dasharray="13 10" marker-end="url(#stop-arrow)"/>
<text x="1005" y="392" text-anchor="middle" fill="#334e68" font-family="Arial, sans-serif" font-size="22">max fan-out: 1</text>
<text x="570" y="392" text-anchor="middle" fill="#4c3a9b" font-family="Arial, sans-serif" font-size="22">optional result unavailable</text>
<text x="390" y="640" fill="#7a271a" font-family="Arial, sans-serif" font-size="22">no new retry, no broad fan-out</text>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1,56 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800" role="img" aria-labelledby="title desc">
<title id="title">Контур review для fixed сценария устойчивости</title>
<desc id="desc">Последовательность начинается с именованного synthetic failure injection, проходит через trace и четыре проверки ограничений. Красные ветви останавливают review, зелёная ветвь передаёт карточку только на synthetic resilience review.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 Z" fill="#355070"/>
</marker>
<marker id="red-arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 Z" fill="#b42318"/>
</marker>
</defs>
<rect width="1200" height="800" fill="#f8fafc"/>
<rect x="38" y="30" width="1124" height="86" rx="18" fill="#102a43"/>
<text x="72" y="81" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">Контур review: сначала ограничить работу</text>
<text x="72" y="106" fill="#cfe8ff" font-family="Arial, sans-serif" font-size="22">Зелёный выход — synthetic hand-off; красный — stop без нового маршрута.</text>
<circle cx="160" cy="314" r="100" fill="#fff1f0" stroke="#b42318" stroke-width="4"/>
<text x="160" y="292" text-anchor="middle" fill="#7a271a" font-family="Arial, sans-serif" font-size="28" font-weight="700">1. named</text>
<text x="160" y="326" text-anchor="middle" fill="#7a271a" font-family="Arial, sans-serif" font-size="27">failure</text>
<text x="160" y="358" text-anchor="middle" fill="#7a271a" font-family="Arial, sans-serif" font-size="27">injection</text>
<circle cx="405" cy="314" r="100" fill="#e6f4ff" stroke="#2b6cb0" stroke-width="4"/>
<text x="405" y="292" text-anchor="middle" fill="#102a43" font-family="Arial, sans-serif" font-size="28" font-weight="700">2. read</text>
<text x="405" y="326" text-anchor="middle" fill="#102a43" font-family="Arial, sans-serif" font-size="27">fixed</text>
<text x="405" y="358" text-anchor="middle" fill="#102a43" font-family="Arial, sans-serif" font-size="27">trace</text>
<circle cx="650" cy="314" r="100" fill="#fff7d6" stroke="#b7791f" stroke-width="4"/>
<text x="650" y="276" text-anchor="middle" fill="#744210" font-family="Arial, sans-serif" font-size="27" font-weight="700">3. check</text>
<text x="650" y="310" text-anchor="middle" fill="#744210" font-family="Arial, sans-serif" font-size="25">retry owner</text>
<text x="650" y="342" text-anchor="middle" fill="#744210" font-family="Arial, sans-serif" font-size="25">fan-out</text>
<text x="650" y="374" text-anchor="middle" fill="#744210" font-family="Arial, sans-serif" font-size="25">fallback name</text>
<circle cx="895" cy="314" r="100" fill="#e7f8ef" stroke="#21865b" stroke-width="4"/>
<text x="895" y="276" text-anchor="middle" fill="#135d3d" font-family="Arial, sans-serif" font-size="27" font-weight="700">4. check</text>
<text x="895" y="310" text-anchor="middle" fill="#135d3d" font-family="Arial, sans-serif" font-size="25">limit point</text>
<text x="895" y="342" text-anchor="middle" fill="#135d3d" font-family="Arial, sans-serif" font-size="25">comparison key</text>
<text x="895" y="374" text-anchor="middle" fill="#135d3d" font-family="Arial, sans-serif" font-size="25">terminal action</text>
<path d="M260 314 H285" fill="none" stroke="#355070" stroke-width="6" marker-end="url(#arrow)"/>
<path d="M505 314 H530" fill="none" stroke="#355070" stroke-width="6" marker-end="url(#arrow)"/>
<path d="M750 314 H775" fill="none" stroke="#355070" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="842" y="536" width="316" height="122" rx="18" fill="#135d3d"/>
<text x="1000" y="580" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="29" font-weight="700">synthetic review</text>
<text x="1000" y="616" text-anchor="middle" fill="#d4f5df" font-family="Arial, sans-serif" font-size="24">human hand-off only</text>
<path d="M895 414 V516" fill="none" stroke="#21865b" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="48" y="534" width="496" height="126" rx="18" fill="#fff1f0" stroke="#b42318" stroke-width="4"/>
<text x="296" y="578" text-anchor="middle" fill="#7a271a" font-family="Arial, sans-serif" font-size="30" font-weight="700">stop and repair record</text>
<text x="296" y="614" text-anchor="middle" fill="#7a271a" font-family="Arial, sans-serif" font-size="24">two retry owners, unnamed fallback,</text>
<text x="296" y="642" text-anchor="middle" fill="#7a271a" font-family="Arial, sans-serif" font-size="24">wide fan-out or mismatch</text>
<path d="M650 414 C620 492, 535 530, 550 568" fill="none" stroke="#b42318" stroke-width="6" stroke-dasharray="14 10" marker-end="url(#red-arrow)"/>
<path d="M895 414 C830 485, 610 516, 544 584" fill="none" stroke="#b42318" stroke-width="6" stroke-dasharray="14 10" marker-end="url(#red-arrow)"/>
<text x="58" y="726" fill="#486581" font-family="Arial, sans-serif" font-size="24">No network, service, clock, telemetry or production claim exists inside this loop.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

+537
View File
@@ -0,0 +1,537 @@
function escapeHtml(value) {
return 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((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>|$)/, ''));
}
function deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.values(value).forEach(deepFreeze);
Object.freeze(value);
}
return value;
}
function cloneFixed(value) {
return JSON.parse(JSON.stringify(value));
}
const REFERENCES = deepFreeze({
sre: {
url: 'https://web.archive.org/web/20250123164219id_/https://sre.google/sre-book/addressing-cascading-failures/',
},
grpc: {
url: 'https://raw.githubusercontent.com/grpc/proposal/dc9fd4fe5b94b90b82fe2833ad1d80938e6a49c1/A6-client-retries.md',
},
http: {
url: 'https://www.rfc-editor.org/rfc/rfc9110.html',
},
});
function sourceList(entries) {
return '<ul>' + entries.map((entry) => '<li><a href="' + entry.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(entry.title) + '</a> — версия: ' + escapeHtml(entry.version) + '. ' + escapeHtml(entry.use) + ' Граница: ' + escapeHtml(entry.boundary) + '</li>').join('') + '</ul>';
}
const FIXED_RESILIENCE_SCENARIOS = deepFreeze({
'bounded-cascade-v1': {
id: 'bounded-cascade-v1',
scenarioFamily: 'fixed-resilience-cascade-v1',
logicalRequests: 2,
latencyUnits: [
{ step: 'primary-a', units: 3 },
{ step: 'primary-b', units: 2 },
{ step: 'named-summary', units: 1 },
],
failureInjection: {
id: 'fixed-primary-a-temporary-timeout',
target: 'primary-a',
outcome: 'temporary-timeout',
scope: 'one named synthetic route in memory',
},
retryPlan: [
{
owner: 'edge',
enabled: true,
maxAttemptsPerLogicalRequest: 2,
retryableOutcomes: ['temporary-timeout'],
schedule: ['fixed-backoff-slot-1'],
},
],
fallback: {
name: 'named-summary',
enabled: true,
trigger: 'fixed-optional-result-unavailable',
addsRetryLayer: false,
workUnits: 1,
output: 'fixed-degraded-summary',
},
replication: {
replicaNames: ['primary-a', 'primary-b', 'primary-c'],
maxFanout: 1,
selectedPerAttempt: 1,
selection: 'one fixed name per attempt',
},
limit: {
point: 'edge-before-route-expansion',
maxExtraAttempts: 2,
appliesBeforeFallback: true,
onExhaustion: 'return-fixed-degraded-result',
},
comparison: {
baselineScenarioId: 'bounded-cascade-v1',
comparable: true,
key: 'same-logical-load-and-fixed-injection-v1',
},
trace: [
'logical-01 -> primary-a -> temporary-timeout',
'edge retry budget: 2 -> 1',
'logical-01 -> primary-b -> fixed-complete',
'logical-02 -> primary-c -> fixed-optional-result-unavailable',
'named-summary -> fixed-degraded-summary',
],
boundary: 'fixed synthetic literals only; no service, client, network, filesystem, clock, telemetry, production data or side effect',
},
'retry-amplification-v1': {
id: 'retry-amplification-v1',
scenarioFamily: 'fixed-resilience-cascade-v1',
logicalRequests: 2,
latencyUnits: [{ step: 'synthetic-route', units: 3 }],
failureInjection: { id: 'fixed-temporary-timeout', target: 'primary-a', outcome: 'temporary-timeout', scope: 'one fixed route' },
retryPlan: [
{ owner: 'edge', enabled: true, maxAttemptsPerLogicalRequest: 2, retryableOutcomes: ['temporary-timeout'], schedule: ['fixed-backoff-slot-1'] },
{ owner: 'adapter', enabled: true, maxAttemptsPerLogicalRequest: 2, retryableOutcomes: ['temporary-timeout'], schedule: ['fixed-backoff-slot-2'] },
],
fallback: { name: 'named-summary', enabled: true, trigger: 'fixed-optional-result-unavailable', addsRetryLayer: false, workUnits: 1, output: 'fixed-degraded-summary' },
replication: { replicaNames: ['primary-a', 'primary-b'], maxFanout: 1, selectedPerAttempt: 1, selection: 'one fixed name per attempt' },
limit: { point: 'edge-before-route-expansion', maxExtraAttempts: 2, appliesBeforeFallback: true, onExhaustion: 'return-fixed-degraded-result' },
comparison: { baselineScenarioId: 'bounded-cascade-v1', comparable: true, key: 'same-logical-load-and-fixed-injection-v1' },
trace: ['logical-01 -> edge retry', 'adapter retries a retry'],
boundary: 'fixed synthetic literal for a negative review path',
},
'anonymous-fallback-v1': {
id: 'anonymous-fallback-v1',
scenarioFamily: 'fixed-resilience-cascade-v1',
logicalRequests: 2,
latencyUnits: [{ step: 'synthetic-route', units: 2 }],
failureInjection: { id: 'fixed-optional-unavailable', target: 'primary-c', outcome: 'fixed-optional-result-unavailable', scope: 'one fixed route' },
retryPlan: [{ owner: 'edge', enabled: true, maxAttemptsPerLogicalRequest: 2, retryableOutcomes: ['temporary-timeout'], schedule: ['fixed-backoff-slot-1'] }],
fallback: { name: '', enabled: true, trigger: 'fixed-optional-result-unavailable', addsRetryLayer: false, workUnits: 1, output: 'fixed-unknown-output' },
replication: { replicaNames: ['primary-a', 'primary-b'], maxFanout: 1, selectedPerAttempt: 1, selection: 'one fixed name per attempt' },
limit: { point: 'edge-before-route-expansion', maxExtraAttempts: 2, appliesBeforeFallback: true, onExhaustion: 'return-fixed-degraded-result' },
comparison: { baselineScenarioId: 'bounded-cascade-v1', comparable: true, key: 'same-logical-load-and-fixed-injection-v1' },
trace: ['logical-02 -> unnamed fallback'],
boundary: 'fixed synthetic literal for a negative review path',
},
'unbounded-replication-v1': {
id: 'unbounded-replication-v1',
scenarioFamily: 'fixed-resilience-cascade-v1',
logicalRequests: 2,
latencyUnits: [{ step: 'synthetic-route', units: 2 }],
failureInjection: { id: 'fixed-primary-timeout', target: 'primary-a', outcome: 'temporary-timeout', scope: 'one fixed route' },
retryPlan: [{ owner: 'edge', enabled: true, maxAttemptsPerLogicalRequest: 2, retryableOutcomes: ['temporary-timeout'], schedule: ['fixed-backoff-slot-1'] }],
fallback: { name: 'named-summary', enabled: true, trigger: 'fixed-optional-result-unavailable', addsRetryLayer: false, workUnits: 1, output: 'fixed-degraded-summary' },
replication: { replicaNames: ['primary-a', 'primary-b', 'primary-c'], maxFanout: null, selectedPerAttempt: 1, selection: 'any number of names may be selected' },
limit: { point: 'edge-before-route-expansion', maxExtraAttempts: 2, appliesBeforeFallback: true, onExhaustion: 'return-fixed-degraded-result' },
comparison: { baselineScenarioId: 'bounded-cascade-v1', comparable: true, key: 'same-logical-load-and-fixed-injection-v1' },
trace: ['logical-01 -> unspecified replica fan-out'],
boundary: 'fixed synthetic literal for a negative review path',
},
'missing-limit-point-v1': {
id: 'missing-limit-point-v1',
scenarioFamily: 'fixed-resilience-cascade-v1',
logicalRequests: 2,
latencyUnits: [{ step: 'synthetic-route', units: 2 }],
failureInjection: { id: 'fixed-primary-timeout', target: 'primary-a', outcome: 'temporary-timeout', scope: 'one fixed route' },
retryPlan: [{ owner: 'edge', enabled: true, maxAttemptsPerLogicalRequest: 2, retryableOutcomes: ['temporary-timeout'], schedule: ['fixed-backoff-slot-1'] }],
fallback: { name: 'named-summary', enabled: true, trigger: 'fixed-optional-result-unavailable', addsRetryLayer: false, workUnits: 1, output: 'fixed-degraded-summary' },
replication: { replicaNames: ['primary-a', 'primary-b'], maxFanout: 1, selectedPerAttempt: 1, selection: 'one fixed name per attempt' },
limit: null,
comparison: { baselineScenarioId: 'bounded-cascade-v1', comparable: true, key: 'same-logical-load-and-fixed-injection-v1' },
trace: ['logical-01 -> retry with no named stop'],
boundary: 'fixed synthetic literal for a negative review path',
},
'incomparable-scenario-v1': {
id: 'incomparable-scenario-v1',
scenarioFamily: 'fixed-resilience-cascade-v1',
logicalRequests: 7,
latencyUnits: [{ step: 'synthetic-route', units: 9 }],
failureInjection: { id: 'fixed-different-failure', target: 'primary-z', outcome: 'permanent-contract-error', scope: 'different fixed route and outcome' },
retryPlan: [{ owner: 'edge', enabled: true, maxAttemptsPerLogicalRequest: 2, retryableOutcomes: ['temporary-timeout'], schedule: ['fixed-backoff-slot-1'] }],
fallback: { name: 'named-summary', enabled: true, trigger: 'fixed-optional-result-unavailable', addsRetryLayer: false, workUnits: 1, output: 'fixed-degraded-summary' },
replication: { replicaNames: ['primary-a', 'primary-b'], maxFanout: 1, selectedPerAttempt: 1, selection: 'one fixed name per attempt' },
limit: { point: 'edge-before-route-expansion', maxExtraAttempts: 2, appliesBeforeFallback: true, onExhaustion: 'return-fixed-degraded-result' },
comparison: { baselineScenarioId: 'bounded-cascade-v1', comparable: false, key: 'different-logical-load-and-injection-v1' },
trace: ['logical-07 -> permanent-contract-error'],
boundary: 'fixed synthetic literal that must not be compared with the bounded scenario',
},
});
function isKnownFixedScenario(value) {
return Object.values(FIXED_RESILIENCE_SCENARIOS).some((candidate) => JSON.stringify(candidate) === JSON.stringify(value));
}
function stopped(status, reason, nextAction) {
return deepFreeze({
accepted: false,
status,
reasons: [reason],
nextAction,
handoff: 'not-eligible-for-synthetic-resilience-review',
productionEffect: 'not-attempted',
});
}
export function createFixedResilienceScenario(id = 'bounded-cascade-v1') {
if (!Object.hasOwn(FIXED_RESILIENCE_SCENARIOS, id)) {
return deepFreeze({ id, boundary: 'unknown input is not a fixture scenario' });
}
return deepFreeze(cloneFixed(FIXED_RESILIENCE_SCENARIOS[id]));
}
export function assessFixedResilienceScenario(scenario) {
if (!isKnownFixedScenario(scenario)) {
return stopped('stop-unknown-fixed-scenario', 'scenario-is-not-a-known-fixed-literal', 'select-a-named-fixture-scenario');
}
const enabledRetries = scenario.retryPlan.filter((plan) => plan.enabled === true);
if (enabledRetries.length !== 1 || enabledRetries[0].maxAttemptsPerLogicalRequest !== 2) {
return stopped('stop-retry-amplification', 'retry-must-have-one-owner-and-a-fixed-two-attempt-bound', 'collapse-retry-to-one-named-owner');
}
if (!scenario.fallback.enabled || !scenario.fallback.name || scenario.fallback.addsRetryLayer || scenario.fallback.workUnits !== 1) {
return stopped('stop-anonymous-fallback', 'fallback-must-have-a-name-one-work-unit-and-no-second-retry-layer', 'name-and-bound-the-fallback');
}
const replication = scenario.replication;
if (!Number.isInteger(replication.maxFanout) || replication.maxFanout < 1 || replication.maxFanout > replication.replicaNames.length || replication.selectedPerAttempt > replication.maxFanout) {
return stopped('stop-unbounded-replication', 'replica-fan-out-is-not-a-finite-named-bound', 'set-one-finite-replica-fan-out');
}
const limit = scenario.limit;
if (!limit || !limit.point || !Number.isInteger(limit.maxExtraAttempts) || limit.maxExtraAttempts < 0 || limit.appliesBeforeFallback !== true || !limit.onExhaustion) {
return stopped('stop-missing-limit-point', 'no-early-named-limit-controls-route-expansion', 'place-a-finite-limit-before-retry-and-fallback');
}
if (!scenario.comparison || scenario.comparison.comparable !== true || scenario.comparison.key !== 'same-logical-load-and-fixed-injection-v1') {
return stopped('stop-incomparable-scenario', 'logical-load-or-failure-injection-does-not-match-the-declared-baseline', 'align-the-fixed-scenario-or-refuse-comparison');
}
return deepFreeze({
accepted: true,
status: 'synthetic-resilience-ready',
reasons: [],
bound: {
retryOwner: enabledRetries[0].owner,
maxAttemptsPerLogicalRequest: enabledRetries[0].maxAttemptsPerLogicalRequest,
maxReplicaFanout: replication.maxFanout,
maxExtraAttempts: limit.maxExtraAttempts,
limitPoint: limit.point,
},
trace: scenario.trace.slice(),
handoff: 'synthetic-resilience-review',
productionEffect: 'not-attempted',
});
}
export function compareFixedResilienceScenarios(leftId, rightId) {
const leftScenario = createFixedResilienceScenario(leftId);
const rightScenario = createFixedResilienceScenario(rightId);
const left = assessFixedResilienceScenario(leftScenario);
const right = assessFixedResilienceScenario(rightScenario);
if (!left.accepted || !right.accepted) {
return deepFreeze({
decision: 'stop-incomparable-scenario',
reasons: [left.status, right.status].filter((status) => status !== 'synthetic-resilience-ready'),
handoff: 'not-eligible-for-synthetic-comparison',
productionEffect: 'not-attempted',
});
}
if (leftScenario.comparison.key !== rightScenario.comparison.key) {
return deepFreeze({
decision: 'stop-incomparable-scenario',
reasons: ['fixed-comparison-keys-differ'],
handoff: 'not-eligible-for-synthetic-comparison',
productionEffect: 'not-attempted',
});
}
return deepFreeze({
decision: 'synthetic-comparison-ready',
reasons: [],
handoff: 'synthetic-resilience-review',
productionEffect: 'not-attempted',
});
}
export function runFixedResilienceFixture() {
const ready = assessFixedResilienceScenario(createFixedResilienceScenario('bounded-cascade-v1'));
const amplified = assessFixedResilienceScenario(createFixedResilienceScenario('retry-amplification-v1'));
const anonymous = assessFixedResilienceScenario(createFixedResilienceScenario('anonymous-fallback-v1'));
const unlimited = assessFixedResilienceScenario(createFixedResilienceScenario('unbounded-replication-v1'));
const missingLimit = assessFixedResilienceScenario(createFixedResilienceScenario('missing-limit-point-v1'));
const incomparable = assessFixedResilienceScenario(createFixedResilienceScenario('incomparable-scenario-v1'));
const comparison = compareFixedResilienceScenarios('bounded-cascade-v1', 'incomparable-scenario-v1');
const bounded = createFixedResilienceScenario('bounded-cascade-v1');
return deepFreeze({
assertions: deepFreeze({
boundedAccepted: ready.accepted === true,
handoffIsSyntheticOnly: ready.handoff === 'synthetic-resilience-review',
noProductionClaim: ready.productionEffect === 'not-attempted',
oneRetryOwner: ready.bound.retryOwner === 'edge',
retryBoundIsTwoAttempts: ready.bound.maxAttemptsPerLogicalRequest === 2,
finiteReplicaFanout: ready.bound.maxReplicaFanout === 1,
namedEarlyLimit: ready.bound.limitPoint === 'edge-before-route-expansion',
fixedInjectionIsVisible: bounded.failureInjection.id === 'fixed-primary-a-temporary-timeout',
retryAmplificationStops: amplified.status === 'stop-retry-amplification',
anonymousFallbackStops: anonymous.status === 'stop-anonymous-fallback',
unboundedReplicationStops: unlimited.status === 'stop-unbounded-replication',
missingLimitStops: missingLimit.status === 'stop-missing-limit-point',
incomparableScenarioStops: incomparable.status === 'stop-incomparable-scenario',
comparisonFailsClosed: comparison.decision === 'stop-incomparable-scenario',
unknownInputStops: assessFixedResilienceScenario({ id: 'made-up' }).status === 'stop-unknown-fixed-scenario',
fixtureIsFrozen: Object.isFrozen(FIXED_RESILIENCE_SCENARIOS),
}),
});
}
function revision(meta, parts, sources) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + sourceList(sources);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': body length ' + proseLength);
}
return deepFreeze({ ...meta, contentHtml, proseLength });
}
const practice = revision({
slug: 'editorial-2026-02-practice-resilience',
title: 'Как разорвать каскад: один бюджет раньше retry, fallback и реплик',
categories: ['Надёжность', 'Архитектура'],
cover: '/assets/editorial/2026/resilience-2026-failure-cascade-graph.svg',
excerpt: 'Практический маршрут для fixed сценария отказа: назвать полезную работу, выбрать единственного владельца retry, ограничить fan-out и сделать fallback отдельным режимом, а не новым запуском каскада.',
readingMinutes: 16,
}, [
p('Частичный сбой редко начинается с драматичного падения. Один маршрут отвечает хуже, а три разумные защиты начинают помогать одновременно: край повторяет запрос, код подставляет запасной путь, выбор реплики расширяется. Цена ошибки не в самом timeout. Она в лишней работе, которую система направляет в уже ослабленную часть, и в потере ясного ответа на вопрос: какой именно механизм должен был остановить расширение. Пока этот ответ размазан между слоями, добавление ещё одной защиты повышает риск, а не запас прочности.'),
p('Ниже нет реального сервиса, замера или нагрузки. Имена primary-a, edge и named-summary — fixed synthetic JS literals. Units латентности — только подписи внутри модели, не миллисекунды. Проверка не открывает сеть, не читает файлов, не обращается к клиенту и не делает production-вывод. Её единственный положительный результат — узкий hand-off на synthetic resilience review: у карточки есть один владелец retry, конечный fan-out, именованный fallback и точка, где дальнейшая работа прекращается.'),
figure('/assets/editorial/2026/resilience-2026-failure-cascade-graph.svg', 'Граф fixed каскада: логический запрос проходит через единственного владельца retry, выбирает только одну реплику на попытку и при исчерпании бюджета останавливается в именованном деградированном результате.', 'Схема показывает не настоящую топологию, а порядок контроля: limit стоит перед расширением маршрута, поэтому fallback не становится вторым retry-слоем.'),
h2('Сначала посчитать не запросы, а добавленную работу'),
p('Полезная единица здесь — логический запрос, а не каждое исходящее действие. Для него нужно выписать все способы создать дополнительную работу: повтор после временного исхода, параллельный либо последовательный выбор другой реплики, переход на альтернативный ответ, повтор уже внутри альтернативы. Если таблица не различает эти причины, она скрывает главный риск. Два действия с одинаковым именем retry могут иметь разный смысл: одно возвращает тот же маршрут после разрешённого исхода, другое запускает новый fan-out и фактически удваивает давление.'),
table('Карта расширения fixed маршрута', ['Защита', 'Когда добавляет работу', 'Конечная граница', 'Что остаётся в trace'], [
['retry edge', 'только temporary-timeout', 'две попытки на логический запрос', 'уменьшение retry budget'],
['выбор реплики', 'перед каждой разрешённой попыткой', 'ровно одно имя за попытку', 'primary-a или primary-b'],
['named-summary', 'только fixed optional result unavailable', 'одна известная работа, без своего retry', 'имя fallback и degraded output'],
['limit point', 'до route expansion', 'два дополнительных запуска всего', 'return fixed degraded result'],
['failure injection', 'один названный synthetic исход', 'не является наблюдением о мире', 'fixed-primary-a-temporary-timeout'],
]),
h2('Минимальная карточка до обсуждения инфраструктуры'),
p('Хороший review начинается не с выбора библиотеки и не с перечня доступных реплик. Нужна карточка с одной семейством сценария: одинаковое число logical requests, одно фиксированное injection-событие, один владелец retry и понятный отказ после лимита. В рабочем варианте два logical request. Первый встречает temporary-timeout на primary-a и получает разрешённую вторую попытку на primary-b. Второй не расширяет маршрут при optional-result-unavailable, а получает named-summary. Так видно, какой work added каждой защитой и почему сумма не растёт сама от себя.'),
code("import { createFixedResilienceScenario, assessFixedResilienceScenario } from './upgrade-2026-02.mjs';\n\nconst scenario = createFixedResilienceScenario('bounded-cascade-v1');\nconst report = assessFixedResilienceScenario(scenario);\nconsole.log({ status: report.status, bound: report.bound, handoff: report.handoff });\n// { status: 'synthetic-resilience-ready', bound: { retryOwner: 'edge', maxAttemptsPerLogicalRequest: 2, maxReplicaFanout: 1, maxExtraAttempts: 2, limitPoint: 'edge-before-route-expansion' }, handoff: 'synthetic-resilience-review' }"),
p('Фрагмент импортирует только публичные exports. Он сравнивает заранее зашитую карточку с правилами fixture и возвращает структуру review. Здесь нет таймера backoff, настоящего обращения к primary-b и выбора по здоровью. Именно поэтому результат нельзя читать как обещание latency или availability. Он доказывает лишь то, что синтетическая карточка не потеряла четыре ограничения во время обсуждения: retry принадлежит edge, максимум попыток назван, fan-out конечен, а лимит расположен до fallback.'),
h2('Порядок разрыва каскада'),
ol([
'Назовите logical request и перечислите, какие действия считаются дополнительной работой именно для него.',
'Оставьте одного владельца retry; другие слои получают исход, но не запускают второй цикл попыток.',
'Запишите максимальный fan-out числом и именами, а не формулой вида «все доступные реплики».',
'Дайте fallback собственное имя, входной исход, фиксированную стоимость и запрет на вложенный retry.',
'Поставьте один limit point перед расширением route и определите дешёвый результат после исчерпания бюджета.',
'Зафиксируйте injection и trace, затем откажитесь сравнивать карточку с другим объёмом logical work или другим типом сбоя.',
]),
h2('Почему fallback нельзя считать бесплатной добротой'),
p('Fallback часто называют защитой, хотя он меняет контракт результата. Это нормально, если изменение явно названо. Плохо, когда запасной путь скрывает новую работу: например, сам делает повтор, выбирает несколько реплик или пытается восстановить необязательную часть ответа до возврата пользователю. В таком виде команда видит слово fallback и предполагает деградацию, а каскад получает ещё один производитель попыток. В fixed карточке named-summary имеет один work unit и не содержит retry plan. Это не универсальная архитектурная норма; это минимальная граница, которую reviewer может проверить без догадки.'),
p('Отдельно проверьте семантику результата. Фраза «вернули ответ» слишком широка. Для одного сценария полезно определить, что именно сохраняется: fixed degraded summary, причина ограничена, а попытка восстановить необязательный фрагмент не продолжается. Тогда сохранённый trace говорит не только о том, что остановились, но и почему остановка произошла. Если fallback не может описать выход коротким именем, он ещё не готов быть отдельным режимом и не должен автоматически включаться в каскаде.'),
h2('Точка ограничения — это контракт, а не счётчик где-нибудь рядом'),
p('Счётчик полезен только тогда, когда ясно, что он ограничивает и когда читается. Для данного fixture point называется edge-before-route-expansion. Он срабатывает до retry, выбора следующей реплики и fallback. Поэтому исчерпанный budget не допускает ситуацию, где последний retry уже породил два новых маршрута, а лимит узнали постфактум. Контракту нужны четыре части: место, конечное значение, область действия и terminal action. Уберите одну — и reviewer не может доказать, что такая же проверка действительно разрывает цепочку.'),
p('Не подменяйте эту точку общим лимитом параллелизма, TTL или длиной очереди. Они могут быть важны, но отвечают на другие вопросы. Здесь требуется граница для создания дополнительной работы после failure outcome. Разделение делает обсуждение конкретным: retry budget защищает от повторов, replica fan-out ограничивает ширину, fallback cost ограничивает альтернативный путь. В зрелом дизайне эти цифры могут зависеть от домена; в этой статье они fixed, чтобы не создать видимость реальной capacity-модели.'),
h2('Как убедиться, что limit поставлен достаточно рано'),
p('Проверяйте не наличие слова budget в конфигурации, а порядок переходов в trace. После fixed timeout сначала должна появиться запись о расходе разрешённого retry, и лишь затем — новый выбранный маршрут. Если trace сначала показывает выбор нескольких реплик или вход в fallback, а ограничение приходит последней строкой, point стоит после расширения. Такой счётчик может хорошо выглядеть на панели, но не ограничивать первую волну лишней работы. Для review достаточно простого вопроса: какой конкретный переход запрещён, когда budget стал нулём? В карточке ответ должен быть одним terminal action, а не перечнем надежд.'),
p('Есть и вторая проверка: граница обязана пережить смену режима. Нельзя списывать retry token на primary, а затем считать fallback независимым жестом без стоимости. Иначе budget контролирует только часть каскада. В fixed маршруте named-summary указан до запуска, имеет одну synthetic единицу и не расширяет replica selection. Это делает его видимым исключением, а не лазейкой. Если альтернативный ответ требует отдельного поиска, считайте его самостоятельной веткой с собственным ограничением и не называйте её бесплатным fallback.'),
h2('Граница метода и следующий безопасный шаг'),
p('Модель не оценивает идемпотентность, корректность компенсации, распределённое состояние, реальную доступность реплик или пользовательский ущерб. Она не заменяет load test, failure exercise, incident review и решение владельца системы. Официальные документы ниже дают полезный язык: несколько уровней retry могут перемножать попытки, а политика повтора может иметь предел и throttling. Но ни RFC, ни design proposal не подтверждают, что конкретный fixture переживёт отказ. Здесь даже не существует реального отказа.'),
p('Следующий шаг не должен быть «включить все защиты». Возьмите одну учебную карточку с тем же числом logical requests и одним именованным injection. Добавьте один trace transition на каждую дополнительную попытку. Если нельзя показать, где эта попытка списывает budget, сократите маршрут. Если fallback нельзя назвать и оценить как отдельную работу, оставьте его выключенным. Только после такого narrow review имеет смысл создавать отдельный work item для настоящего теста с правами, данными, наблюдением и отдельной безопасностной проверкой.'),
], [
{
title: 'Архивная глава Google SRE о положительной обратной связи',
url: REFERENCES.sre.url,
version: 'Internet Archive snapshot 2025-01-23; chapter from the Google SRE Book, 2017 edition',
use: 'Источник задаёт термин каскада и показывает риск произведения попыток при retry на нескольких уровнях.',
boundary: 'Архивная страница не измеряет этот fixture и не подтверждает его capacity или recovery.',
},
{
title: 'gRPC A6: fixed revision retry and hedging design',
url: REFERENCES.grpc.url,
version: 'immutable commit dc9fd4fe5b94b90b82fe2833ad1d80938e6a49c1, authored 2024-08-29',
use: 'Документ позволяет точно назвать maxAttempts, backoff и throttling как параметры политики попыток.',
boundary: 'Design proposal не является требованием к произвольному стеку и не доказывает эффект synthetic проверки.',
},
]);
const mechanism = revision({
slug: 'editorial-2026-02-mechanism-resilience',
title: 'Механика устойчивости: считать ширину каскада до настройки timeout',
categories: ['Надёжность', 'Системный дизайн'],
cover: '/assets/editorial/2026/resilience-2026-degradation-mode-matrix.svg',
excerpt: 'Инженерская модель каскада в fixed literals: различить последовательный retry, fan-out, fallback и limit point, затем отказать любому сценарию, который не сохраняет конечную ширину работы.',
readingMinutes: 17,
}, [
p('Настройка timeout без модели работы часто создаёт ложное чувство контроля. При частичном отказе система может быстрее сдаться, но всё ещё отправить несколько копий одной логической задачи в разные ветви. Цена ошибки — не только задержка в карточке. Это ширина незавершённой работы: сколько разрешённых попыток одновременно и последовательно появится до terminal result. Если считать лишь успешные ответы, каскад остаётся невидимым до момента, когда оставшиеся реплики уже заняты лишними действиями.'),
p('В этой статье числа, outcomes, trace и latency units существуют только как fixed JS literals. Они не описывают сервис, сетевой протокол, реальный deadline или телеметрию. Функция fixture не моделирует очередь и не ждёт времени. Она проверяет форму: один owner retry, конечный выбор реплики, конкретный fallback, limit до расширения и сопоставимый baseline. Статус ready означает лишь право передать synthetic карточку человеку; он не означает, что какая-либо система стала устойчивой.'),
figure('/assets/editorial/2026/resilience-2026-degradation-mode-matrix.svg', 'Матрица fixed режимов деградации: временный timeout допускает одну ограниченную попытку, отсутствие необязательной части ведёт к именованному summary, исчерпанный budget завершает маршрут без нового fan-out.', 'Матрица отделяет исход, разрешённое действие, добавленную synthetic работу и стоп-условие. Это компактнее и проверяемее, чем список «включённых защит».'),
h2('Четыре оси, которые нельзя складывать в одно слово retry'),
p('Первое различие — между числом попыток и числом направлений. Последовательный retry добавляет новый запуск после определённого исхода. Fan-out создаёт несколько направлений одной попытки. Репликация в такой модели — не магическое слово про запас, а правило выбора: сколько имён может получить один запуск. Fallback — не третий вид retry, а смена результата и, возможно, работы. Limit point — отдельная ось: он должен прекратить расширение до того, как следующая защита начнёт действовать.'),
table('Разложение каскада на проверяемые величины', ['Величина', 'Fixed значение', 'Что запрещает', 'Почему отдельно'], [
['logicalRequests', '2', 'сравнение с другой базой нагрузки', 'задаёт единицу расчёта'],
['maxAttemptsPerLogicalRequest', '2', 'бесконечный retry', 'принадлежит одному owner'],
['maxFanout', '1', 'широкий поиск всех реплик', 'не равен числу реплик в списке'],
['fallback workUnits', '1', 'скрытый дорогой обход', 'описует цену смены режима'],
['maxExtraAttempts', '2', 'расширение после stop', 'принадлежит limit point'],
['comparison key', 'same logical load and injection', 'ложную причинную аналогию', 'фиксирует допустимый baseline'],
]),
h2('Граница выше, чем отдельная настройка'),
p('У bounded-cascade-v1 есть три реплики, но это не означает fan-out три. На одну попытку выбирается одно имя, и максимум запуска ограничен ещё раньше. В допустимом верхнем приближении можно прочитать модель так: два logical request умножаются максимум на две попытки и на fan-out один. Получается не прогноз нагрузки, а видимый предел synthetic work: четыре route attempts до учета named-summary. Summary описан отдельно, потому что смена ответа не должна тайно наследовать retry-политику первичного маршрута.'),
p('В реальной инженерной схеме формула была бы недостаточна: появились бы очереди, дедлайны, отмена, side effects, разные классы запросов, idempotency и распределённое состояние. Здесь их нет специально. Узкая арифметика полезна не тем, что подменяет систему, а тем, что ловит продукт настроек: два retry owner уже не складываются, а потенциально перемножают разрешённые пути. Поэтому fixture отвергает даже красивый сценарий, если edge и adapter оба считают себя владельцем повторной попытки.'),
code("import { createFixedResilienceScenario, assessFixedResilienceScenario } from './upgrade-2026-02.mjs';\n\nconst candidate = createFixedResilienceScenario('retry-amplification-v1');\nconst result = assessFixedResilienceScenario(candidate);\nconsole.log({ status: result.status, reason: result.reasons[0], handoff: result.handoff });\n// { status: 'stop-retry-amplification', reason: 'retry-must-have-one-owner-and-a-fixed-two-attempt-bound', handoff: 'not-eligible-for-synthetic-resilience-review' }"),
p('Этот негативный фрагмент выполняется буквально и останавливается до любых внешних действий. В candidate два enabled retry plan: edge и adapter. Проверка не пытается угадать, какой из них «лучше», и не симулирует задержку. Она отказывает по более сильному правилу: в карточке должен остаться ровно один владелец повтора с конечным числом попыток. Такое fail-closed решение полезно для review, потому что следующее действие ясно — убрать дублирование или вернуть сценарий на уточнение.'),
h2('Как отличить ограничение от наблюдения'),
p('Trace сам по себе не является ограничением. Строка edge retry budget: 2 -> 1 полезна только потому, что рядом есть правило: после нуля limit point возвращает fixed degraded result и не открывает следующий маршрут. Аналогично, latency units не доказывают быстроту. Они помогают различить шаги в fixed sequence, но не заменяют измерение. Наблюдение говорит, что в карточке записано; ограничение разрешает или запрещает дальнейший переход. Смешение этих двух уровней рождает отчёты, где есть красивые следы, но нет механизма остановки.'),
p('Смысл named fallback также проверяется не его наличием, а тем, что он не создаёт вторую политику попыток. В fixture fallback активируется по fixed optional result unavailable, имеет workUnits ровно один и выдаёт fixed degraded summary. Если имя пустое, outcome становится неаудируемым: невозможно понять, что именно разрешили вернуть и какую работу добавили. Если fallback добавляет retry layer, он превращается в скрытый amplifier. Обе формы выполняются как отдельные отрицательные records, а не объясняются словами в review.'),
h2('Консервативная верхняя граница без имитации нагрузки'),
p('Даже учебной модели полезно выписать предел, но нельзя выдавать его за прогноз. Для bounded-cascade-v1 он получается из трёх фиксированных множителей: два logical request, максимум две попытки на каждый и fan-out один. Это даёт четыре допустимых route attempts как верхнюю границу записи, а не QPS, latency или расход CPU. named-summary расположен рядом с этой формулой, а не внутри неё: он меняет вид результата и имеет собственный workUnits. Такое разделение удерживает разговор от привычной ошибки, когда всё, что не primary, превращают в одну неразличимую «дополнительную нагрузку».'),
p('Предел становится полезным только рядом с отрицательным примером. В retry-amplification-v1 те же два logical request встречают два owners. Если просто сложить их настройки, можно не увидеть проблему. Если проследить право каждого владельца запускать следующую попытку, возникают вложенные пути, и прежний верхний предел больше не применим. Fixture не пытается вычислить все комбинации: он отказывает раньше. Для design review это правильная экономия точности. Сначала сохраните однозначное право расширять работу, потом решайте, нужна ли более богатая модель с очередями и отменой.'),
h2('Последовательность механической проверки'),
ol([
'Проверьте, что input совпадает с одним named fixed scenario, иначе остановите разбор как unknown.',
'Отделите enabled retry plan от всех остальных действий и потребуйте одного owner с конечной попыткой.',
'Проверьте имя fallback, его единичную synthetic стоимость и запрет на собственный retry.',
'Потребуйте integer maxFanout и убедитесь, что selectedPerAttempt не шире этого предела.',
'Найдите limit point до fallback и route expansion, затем проверьте terminal action.',
'Сопоставьте logical load и injection с baseline; если ключ различается, не усредняйте результаты.',
]),
h2('Почему 503 и policy не заменяют решение о каскаде'),
p('RFC 9110 фиксирует смысл 503 как временную неспособность обработать запрос и допускает Retry-After как подсказку ожидания. Это полезная граница протокольного сообщения: она помогает не путать overload с успешным ответом. Но HTTP-статус не выбирает владельца retry за архитектуру и не подтверждает безопасность повтора любого действия. Так же gRPC A6 описывает maxAttempts, throttling и pushback в конкретном дизайне. Эти документы дают точные имена полям, а не теорему о поведении произвольной системы.'),
p('Поэтому модель не читает 503 и не реализует gRPC. Она использует fixed outcome temporary-timeout и явное разрешение retryableOutcomes. Это ограничение устраняет ложную точность. Если реальная интеграция получит другой код или иной контракт, она не может «пройти» этот fixture за счёт похожего слова. Ей потребуется другой record, другой источник и отдельная проверка того, безопасна ли работа для повторения.'),
h2('Граница модели и следующий шаг'),
p('Этот механизм не ищет оптимальный budget, не выбирает число реплик, не измеряет утилизацию и не отвечает, какой режим деградации приемлем для продукта. Порог два здесь не рекомендация, а literal, сделанный достаточно маленьким, чтобы review увидел все переходы. Также модель ничего не знает о попытках, уже начатых вне её области; у неё нет сети, clock, cancellation, clients или telemetry. Поэтому любой положительный output остаётся synthetic resilience review hand-off, а не рекомендацией выкатывать конфигурацию.'),
p('Следующий безопасный шаг — взять один настоящий класс работы отдельно от этого пакета и описать его contract: какие outcomes retryable, что считается side effect, где находится владелец retry и какое наблюдение докажет расход budget. Не переносите значения из fixture. Сначала сохраните сравнимую базу и способ получить trace законно и безопасно. Если хотя бы один элемент не определён, правильное решение не расширять модель, а оставить stop и сформулировать недостающий факт как самостоятельный вопрос.'),
], [
{
title: 'gRPC proposal A6 с закреплёнными правилами retry',
url: REFERENCES.grpc.url,
version: 'raw commit dc9fd4fe5b94b90b82fe2833ad1d80938e6a49c1, publication timestamp 29 August 2024',
use: 'Первичный design text различает maxAttempts, retry throttling, hedging и server pushback.',
boundary: 'Его параметры не запускаются в JavaScript fixture и не превращаются в доказательство устойчивости.',
},
{
title: 'RFC 9110, HTTP Semantics, разделы Retry-After и 503',
url: REFERENCES.http.url,
version: 'RFC publication dated June 2022, immutable RFC text',
use: 'Стандарт ограничивает трактовку 503 и Retry-After как семантику сообщения о временной недоступности.',
boundary: 'RFC не определяет retry owner, idempotency конкретного действия или предел fan-out в этом сценарии.',
},
]);
const field = revision({
slug: 'editorial-2026-02-field-resilience',
title: 'Review устойчивости: доказать остановку раньше, чем спорить о репликах',
categories: ['Надёжность', 'Инженерная практика'],
cover: '/assets/editorial/2026/resilience-2026-limit-feedback-loop.svg',
excerpt: 'Полевой формат review для synthetic каскада: проверить именованный injection, одну точку ограничения, трассу и сопоставимость сценариев, не выдавая метрики или production-обещания.',
readingMinutes: 16,
}, [
p('Проблема каскадного сбоя на review часто начинается с вопроса, сколько реплик добавить. При каскаде это поздний вопрос. Сначала нужно показать, где лишняя работа перестаёт рождаться. Retry, fallback и репликация могут быть уместны по отдельности, но без единой точки ограничения они создают конкурирующие версии «ещё одной попытки». Цена такой неопределённости — невозможность быстро понять trace, лишняя нагрузка на ослабленный маршрут и спор о спасении, который начинается после того, как budget уже перестал быть видимым.'),
p('Это учебный field-проход над fixed literals. В нём нет реального клиента, организации, endpoint, метрик, telemetry, сети, файлов, Git, CI, clock или PII. Failure injection задан как строка, latency как units, trace как массив строк. Функции не производят side effect. Результат accepted ограничен фразой synthetic resilience review hand-off; он не означает, что реальная система выдержит отказ, что fallback полезен пользователю или что репликация безопасна для данных.'),
figure('/assets/editorial/2026/resilience-2026-limit-feedback-loop.svg', 'Контур fixed review: named failure injection переходит в trace, затем проверяются retry owner, replica fan-out, fallback и limit point; при нарушении карточка останавливается, а при полном наборе ограничений передаётся только на synthetic review.', 'Контур показывает работу reviewer-а, не систему наблюдаемости. Красная ветка заканчивается stop, а не новой попыткой исправить сценарий словами.'),
h2('Review начинается с вопроса о праве сравнивать'),
p('Сценарии похожи, пока их не положили рядом. Один может иметь два logical request и temporary-timeout на primary-a, другой — семь запросов и permanent-contract-error на другом имени. Оба могут содержать retry, fallback и две реплики, но сравнение их итогов не скажет ничего о выбранной защите. Оно перемешает различную базовую работу и разные failure modes. Поэтому в карточке есть comparison key. Он фиксирует, что baseline имеет тот же logical load и тот же named injection. Если ключ не совпал, review не строит рейтинг и не выбирает лучший вариант.'),
table('Поля hand-off, которые reviewer читает до вывода', ['Поле', 'Вопрос к записи', 'Допустимый fixed ответ', 'Причина stop'], [
['failureInjection', 'какой исход введён?', 'один названный temporary timeout', 'исход без имени или со скрытой областью'],
['retryPlan', 'кто создаёт повтор?', 'edge, две попытки', 'два enabled owner'],
['replication', 'какая ширина маршрута?', 'одно имя на попытку', 'any number или maxFanout без числа'],
['fallback', 'что меняется в результате?', 'named-summary, один work unit', 'пустое имя либо новый retry'],
['limit', 'когда прекращается расширение?', 'до route expansion, fixed terminal action', 'нет места, бюджета или выхода'],
['comparison', 'с чем сопоставляют?', 'тот же fixed baseline key', 'другая нагрузка или injection'],
]),
h2('Trace полезен, если из него можно восстановить решение'),
p('В accepted карточке trace короткий: logical-01 получает timeout на primary-a, edge списывает token, следующая попытка идёт на primary-b; logical-02 получает fixed optional result unavailable и переходит в named-summary. Этот список не является метрикой и не показывает время. Его назначение скромнее: reviewer может связать каждый переход с одним разрешением из модели. Если в trace появляется переход, которому нет места в retry plan, fallback или replication, это не «деталь реализации», а повод остановить hand-off.'),
p('Особенно важен момент terminal action. После исчерпания maxExtraAttempts модель возвращает fixed degraded result. Она не ищет очередную реплику, не переключает fallback на «что-нибудь другое» и не просит человека вручную продолжить цепочку. Такое завершение может выглядеть менее героическим, чем бесконечная попытка восстановить полный ответ, но именно оно делает цену отказа ограниченной. В настоящем продукте выбор результата потребует отдельного решения. Здесь мы проверяем лишь наличие конечной ветки, а не её ценность для пользователя.'),
h2('Что именно вернуть после stop'),
p('Stop без результата часто только переносит каскад на другой слой. Поэтому field-record хранит terminal action вместе с limit: return-fixed-degraded-result. Это не обещание, что пользователь доволен деградацией, и не типовой ответ для любой операции. Это маркировка завершения модели: после этой строки больше не разрешены retry, другой выбор реплики или новая безымянная альтернатива. Reviewer может сопоставить action с trigger и сказать, что на конкретной ветке добавочная synthetic работа закончилась. Без такого выхода budget выглядит как наблюдение, которое забыли применить.'),
p('Важно не превращать terminal output в ловушку для данных. Если реальная операция меняет состояние, требует строгого подтверждения или несёт финансовый риск, fixed degraded result из этой статьи неприменим. Для неё потребуется другой контракт и возможно отказ без результата. Учебная карточка намеренно не скрывает эту неопределённость: она не знает предметной области и не пытается выдать универсальный fallback. Зато она заставляет явно назвать последствия исчерпания лимита вместо того, чтобы незаметно расширять маршрут под видом заботы о доступности.'),
h2('Исполняемый отказ от ложного сравнения'),
code("import { compareFixedResilienceScenarios } from './upgrade-2026-02.mjs';\n\nconst comparison = compareFixedResilienceScenarios(\n 'bounded-cascade-v1',\n 'incomparable-scenario-v1',\n);\nconsole.log({ decision: comparison.decision, reasons: comparison.reasons });\n// { decision: 'stop-incomparable-scenario', reasons: ['stop-incomparable-scenario'] }"),
p('Здесь нет скрытого бенчмарка. Первая карточка и вторая заданы в памяти, а вторая заранее сообщает, что comparison key другой. Public function сначала оценивает обе записи и только затем отказывает в сравнении. Это намеренно строже, чем попытка нормализовать разные числа и исходы красивой формулой. Пока команда не выровняла logical work и injection, любая разница в trace может происходить от базового сценария, а не от механизма устойчивости.'),
h2('Проверка в шесть коротких остановок'),
ol([
'Сверьте id с known fixture; произвольный объект не получает частично положительный статус.',
'Прочитайте failure injection как контракт области, а не как доказательство настоящего инцидента.',
'Убедитесь, что retry имеет одного named owner и фиксированное число попыток.',
'Проверьте, что fallback именован, имеет одну synthetic стоимость и не запускает собственный retry.',
'Потребуйте целочисленный fan-out и limit point, который расположен до расширения маршрута.',
'Сверьте comparison key; несовпадение ведёт в stop-incomparable-scenario, а не в обсуждение среднего результата.',
]),
h2('Репликация — не замена границе'),
p('Список из трёх реплик полезен только после ответа, сколько из них может участвовать в одной попытке. Без maxFanout слово «репликация» маскирует ширину поиска. В accepted record три имена существуют, однако selectedPerAttempt равен одному. Это делает trace читаемым: каждая дополнительная попытка получает ровно одно направление, которое можно связать с расходом retry budget. Если maxFanout равен null или правило говорит «любое число», fixture останавливается. Не потому что широкое распределение всегда неверно, а потому что карточка не умеет доказать его предел.'),
p('То же относится к fallback. В review нельзя считать его пустой стрелкой после primary. Нужны trigger, name, workUnits и output. Это превращает «попробуем запасное» в наблюдаемое решение: переход с fixed optional result unavailable на fixed degraded summary. Если для альтернативы нужно несколько шагов, они должны быть отдельной карточкой с собственным limit и trace, а не быть спрятаны под общим словом. Иначе reviewer рассматривает одну цепочку, а система получает две.'),
h2('Откуда брать язык, но не гарантию'),
p('Архивная глава Google SRE описывает каскад как положительную обратную связь и прямо предупреждает о retry на нескольких уровнях. Это сильный исторический контекст для вопроса «где умножаются попытки». RFC 9110 даёт более узкую опору: 503 обозначает временную неспособность обработать запрос, а Retry-After может сообщить ожидаемую задержку. Ни один источник не знает names и numbers этого fixture. Поэтому ссылки помогают сформулировать границу, но не могут заменить evidence из реальной системы или сделать synthetic trace фактом об эксплуатации.'),
p('Проверяющий должен отделить три предложения: что закреплено внешним источником; что заранее задано в учебной карточке; что ещё неизвестно. Например, «503 может сопровождаться Retry-After» относится к RFC, «maxExtraAttempts равно двум» — к fixed literal, а «этого достаточно для восстановления конкретного маршрута» остаётся неизвестным. Такая маркировка кажется педантичной, пока не приходит incident review. Тогда она экономит время: люди видят, что можно проверить сейчас, что нужно измерить отдельно и где нельзя честно делать вывод.'),
h2('Граница field-прохода и следующий шаг'),
p('Пакет не является runbook, тестом отказоустойчивости, анализом capacity или шаблоном для production. Он не умеет отправлять запросы, отменять in-flight работу, учитывать финансовую цену, определять важность данных или принимать решение за владельца продукта. Нельзя переносить fixed units, имена или terminal output в настоящую конфигурацию. Даже положительный fixture не избавляет от угрозы, что реальный retry небезопасен для операции, а fallback меняет семантику сильнее, чем допустимо.'),
p('Следующий шаг — подготовить отдельный, авторизованный и наблюдаемый review для одного реального класса операции. Его входом будут явно разрешённые данные и источник trace, а не этот модуль. В нём надо проверить idempotency, контракт деградации, владельца limit и критерий остановки. Если эти факты не готовы, удержите работу на уровне synthetic hand-off. Честный stop дешевле, чем необъяснимое расширение маршрута во время сбоя.'),
], [
{
title: 'Google SRE Book, глава 22 в историческом захвате',
url: REFERENCES.sre.url,
version: 'Wayback immutable capture from 23 January 2025 of the official 2017 book chapter',
use: 'Глава служит контекстом для положительной обратной связи, reduced capacity и опасности многоуровневых попыток.',
boundary: 'Текст не является trace этого пакета и не устанавливает допустимый budget для чужого маршрута.',
},
{
title: 'HTTP 503 и Retry-After в закреплённой RFC 9110',
url: REFERENCES.http.url,
version: 'static RFC 9110 publication, June 2022, sections 10.2.3 and 15.6.4',
use: 'Документ отделяет временную недоступность сообщения от успешного результата и называет Retry-After.',
boundary: 'Protocol semantics не подтверждает, что повтор конкретной операции безопасен, полезен или приведёт к recovery.',
},
]);
export const revisions = deepFreeze([practice, mechanism, field].map(({ proseLength, ...item }) => item));
if (process.argv.includes('--verify-fixture')) {
const assertions = runFixedResilienceFixture().assertions;
const failed = Object.entries(assertions).filter(([, value]) => value !== true).map(([key]) => key);
if (failed.length) {
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
process.exitCode = 1;
} else {
process.stdout.write('PASS fixture: ' + Object.keys(assertions).length + '/' + Object.keys(assertions).length + ' assertions\n');
}
}
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions) + '\n');
}