This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
const p = (text) => '<p>' + text + '</p>';
|
||||
const h2 = (text) => '<h2>' + text + '</h2>';
|
||||
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
|
||||
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
|
||||
|
||||
function plainText(content) {
|
||||
return content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function bodyText(content) {
|
||||
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user