rewrite 2026-09 and 2027 articles for reader-facing quality
Build and deploy / deploy (push) Successful in 18s
Build and deploy / deploy (push) Successful in 18s
This commit is contained in:
+232
-248
@@ -11,10 +11,13 @@ 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 figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + escapeHtml(alt) + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((cell) => '<th scope="col">' + cell + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
|
||||
|
||||
function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); }
|
||||
function cloneFixed(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||
Object.values(value).forEach(deepFreeze);
|
||||
@@ -22,146 +25,67 @@ function deepFreeze(value) {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function plainText(html) {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function bodyText(html) {
|
||||
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||||
}
|
||||
|
||||
const REFERENCES = deepFreeze({
|
||||
rfc9110: {
|
||||
title: 'RFC 9110 — HTTP Semantics',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc9110.html',
|
||||
version: 'IETF Standards Track, June 2022, RFC 9110 / STD 97, immutable publication',
|
||||
},
|
||||
nist800160: {
|
||||
title: 'NIST SP 800-160 Volume 2 Revision 1 — Developing Cyber-Resilient Systems',
|
||||
url: 'https://csrc.nist.gov/pubs/sp/800/160/v2/r1/final',
|
||||
version: 'NIST Special Publication 800-160 Vol. 2 Rev. 1, December 2021, dated revision',
|
||||
},
|
||||
rfc9000: {
|
||||
title: 'RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc9000.html',
|
||||
version: 'IETF Standards Track, May 2021, immutable publication',
|
||||
},
|
||||
http: { title: 'RFC 9110 — HTTP Semantics', version: 'IETF Standards Track, June 2022', url: 'https://www.rfc-editor.org/rfc/rfc9110.html' },
|
||||
quic: { title: 'RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport', version: 'IETF Standards Track, May 2021', url: 'https://www.rfc-editor.org/rfc/rfc9000.html' },
|
||||
});
|
||||
|
||||
function sources(entries) {
|
||||
return '<ul>' + entries.map(({ key, use, boundary }) => {
|
||||
const ref = REFERENCES[key];
|
||||
return '<li><a href="' + ref.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(ref.title) + '</a> — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Не доказывает: ' + escapeHtml(boundary) + '</li>';
|
||||
return '<li><a href="' + ref.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(ref.title) + '</a> — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница применимости: ' + escapeHtml(boundary) + '</li>';
|
||||
}).join('') + '</ul>';
|
||||
}
|
||||
|
||||
const FIXED_RELIABILITY_CASES = deepFreeze({
|
||||
'reliability-contract-hand-off-v1': {
|
||||
id: 'reliability-contract-hand-off-v1',
|
||||
editorialDate: '2026-07-31',
|
||||
planningIssue: '2027-07',
|
||||
sourceCutoff: '2026-07-31',
|
||||
scenario: {
|
||||
id: 'named-synthetic-dependency-unavailable-scenario',
|
||||
retry: 'explicit-not-selected',
|
||||
replica: 'explicit-not-selected',
|
||||
recovery: 'explicit-not-selected',
|
||||
},
|
||||
evidence: {
|
||||
incident: 'not-collected',
|
||||
serviceLevel: 'not-defined',
|
||||
observation: 'not-collected',
|
||||
configuration: 'not-collected',
|
||||
load: 'not-collected',
|
||||
},
|
||||
requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'missing-temporal-boundary-v1': {
|
||||
id: 'missing-temporal-boundary-v1', editorialDate: '', planningIssue: '2027-07', sourceCutoff: '2026-07-31',
|
||||
scenario: { id: 'named-synthetic-dependency-unavailable-scenario', retry: 'explicit-not-selected', replica: 'explicit-not-selected', recovery: 'explicit-not-selected' },
|
||||
evidence: { incident: 'not-collected', serviceLevel: 'not-defined', observation: 'not-collected', configuration: 'not-collected', load: 'not-collected' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'hidden-evidence-or-configuration-v1': {
|
||||
id: 'hidden-evidence-or-configuration-v1', editorialDate: '2026-07-31', planningIssue: '2027-07', sourceCutoff: '2026-07-31',
|
||||
scenario: { id: 'named-synthetic-dependency-unavailable-scenario', retry: 'explicit-not-selected', replica: 'explicit-not-selected', recovery: 'explicit-not-selected' },
|
||||
evidence: { incident: 'not-collected', serviceLevel: 'not-defined', observation: 'not-collected', configuration: 'hidden', load: 'not-collected' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'implicit-retry-v1': {
|
||||
id: 'implicit-retry-v1', editorialDate: '2026-07-31', planningIssue: '2027-07', sourceCutoff: '2026-07-31',
|
||||
scenario: { id: 'named-synthetic-dependency-unavailable-scenario', retry: 'implicit', replica: 'explicit-not-selected', recovery: 'explicit-not-selected' },
|
||||
evidence: { incident: 'not-collected', serviceLevel: 'not-defined', observation: 'not-collected', configuration: 'not-collected', load: 'not-collected' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'implicit-replica-or-recovery-v1': {
|
||||
id: 'implicit-replica-or-recovery-v1', editorialDate: '2026-07-31', planningIssue: '2027-07', sourceCutoff: '2026-07-31',
|
||||
scenario: { id: 'named-synthetic-dependency-unavailable-scenario', retry: 'explicit-not-selected', replica: 'implicit', recovery: 'assumed' },
|
||||
evidence: { incident: 'not-collected', serviceLevel: 'not-defined', observation: 'not-collected', configuration: 'not-collected', load: 'not-collected' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'claimed-evidence-v1': {
|
||||
id: 'claimed-evidence-v1', editorialDate: '2026-07-31', planningIssue: '2027-07', sourceCutoff: '2026-07-31',
|
||||
scenario: { id: 'named-synthetic-dependency-unavailable-scenario', retry: 'explicit-not-selected', replica: 'explicit-not-selected', recovery: 'explicit-not-selected' },
|
||||
evidence: { incident: 'claimed', serviceLevel: 'not-defined', observation: 'not-collected', configuration: 'not-collected', load: 'not-collected' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'claimed-operational-result-v1': {
|
||||
id: 'claimed-operational-result-v1', editorialDate: '2026-07-31', planningIssue: '2027-07', sourceCutoff: '2026-07-31',
|
||||
scenario: { id: 'named-synthetic-dependency-unavailable-scenario', retry: 'explicit-not-selected', replica: 'explicit-not-selected', recovery: 'explicit-not-selected' },
|
||||
evidence: { incident: 'not-collected', serviceLevel: 'not-defined', observation: 'not-collected', configuration: 'not-collected', load: 'not-collected' }, requestedOutput: 'reliability-improved',
|
||||
},
|
||||
get503: { method: 'GET', status: 503, retryAfter: 1 },
|
||||
get200: { method: 'GET', status: 200, retryAfter: 0 },
|
||||
post503: { method: 'POST', status: 503, retryAfter: 1 },
|
||||
timeout: { method: 'GET', status: 0, error: 'timeout' },
|
||||
});
|
||||
|
||||
export function createFixedReliabilityCase(id = 'reliability-contract-hand-off-v1') {
|
||||
const value = FIXED_RELIABILITY_CASES[id];
|
||||
return value ? deepFreeze(cloneFixed(value)) : undefined;
|
||||
}
|
||||
function stop(status, reason, nextAction) {
|
||||
return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' });
|
||||
export function createFixedReliabilityCase(id = 'get503') {
|
||||
return FIXED_RELIABILITY_CASES[id] ? deepFreeze(cloneFixed(FIXED_RELIABILITY_CASES[id])) : undefined;
|
||||
}
|
||||
|
||||
export function assessFixedReliabilityPlan(input) {
|
||||
const known = Object.values(FIXED_RELIABILITY_CASES).some((item) => JSON.stringify(item) === JSON.stringify(input));
|
||||
if (!known) return stop('stop-unknown-fixed-input', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-literal');
|
||||
if (input.editorialDate !== '2026-07-31' || input.planningIssue !== '2027-07' || input.sourceCutoff !== '2026-07-31') {
|
||||
return stop('stop-temporal-boundary-required', 'editorial-date-planning-issue-and-source-cutoff-must-be-exact', 'name-the-fixed-temporal-boundary');
|
||||
}
|
||||
if (!input.scenario?.id || input.evidence?.configuration !== 'not-collected' || input.evidence.incident !== 'not-collected' || input.evidence.serviceLevel !== 'not-defined' || input.evidence.observation !== 'not-collected' || input.evidence.load !== 'not-collected') {
|
||||
return stop('stop-hidden-evidence-or-configuration', 'evidence-and-configuration-must-remain-explicitly-uncollected', 'remove-hidden-or-claimed-material');
|
||||
}
|
||||
if (input.scenario.retry !== 'explicit-not-selected') {
|
||||
return stop('stop-implicit-retry', 'retry-cannot-be-inferred-or-defaulted', 'state-that-retry-is-not-selected');
|
||||
}
|
||||
if (input.scenario.replica !== 'explicit-not-selected' || input.scenario.recovery !== 'explicit-not-selected') {
|
||||
return stop('stop-implicit-replica-or-recovery', 'replica-and-recovery-cannot-be-assumed', 'state-that-replica-and-recovery-are-not-selected');
|
||||
}
|
||||
if (input.requestedOutput !== 'synthetic-plan-hand-off') {
|
||||
return stop('stop-disallowed-operational-result', 'a-future-scenario-cannot-claim-an-operational-result', 'use-synthetic-plan-hand-off');
|
||||
}
|
||||
return deepFreeze({
|
||||
status: 'synthetic-plan-hand-off', caseId: input.id, planningIssue: input.planningIssue,
|
||||
scenario: deepFreeze(cloneFixed(input.scenario)), evidence: deepFreeze(cloneFixed(input.evidence)),
|
||||
productionEffect: 'not-attempted', nextAction: 'open-a-separate-authorized-evidence-scope-if-a-decision-requires-it',
|
||||
});
|
||||
if (!input || typeof input !== 'object') return { status: 'stop', action: 'проверить входные поля' };
|
||||
if (input.error === 'timeout') return { status: 'bounded-timeout', action: 'закончить попытку по deadline' };
|
||||
if (input.status >= 200 && input.status < 300) return { status: 'success', action: 'вернуть результат вызывающему коду' };
|
||||
if (input.status === 503 && input.method === 'GET') return { status: 'retry-allowed', action: 'повторить с лимитом и задержкой' };
|
||||
if (input.status === 503) return { status: 'retry-denied', action: 'проверить идемпотентность операции' };
|
||||
return { status: 'stop', action: 'разобрать ответ отдельно' };
|
||||
}
|
||||
|
||||
export function inspectFailureContractLiteral() {
|
||||
const input = createFixedReliabilityCase();
|
||||
return deepFreeze({ namedScenario: input.scenario.id, retry: input.scenario.retry, outcome: assessFixedReliabilityPlan(input).status });
|
||||
return deepFreeze({ timeoutMs: 400, maxAttempts: 3, retryableMethods: ['GET', 'HEAD', 'PUT', 'DELETE'] });
|
||||
}
|
||||
|
||||
export function inspectFaultTreeLiteral() {
|
||||
const result = assessFixedReliabilityPlan(createFixedReliabilityCase());
|
||||
return deepFreeze({ topQuestion: 'named-synthetic-dependency-unavailable-scenario', branches: ['request-shape-not-collected', 'capacity-not-collected', 'recovery-not-selected'], outcome: result.productionEffect });
|
||||
return deepFreeze({ first: 'timeout', second: '503', decision: 'bounded-retry' });
|
||||
}
|
||||
|
||||
export function inspectHandoffLiteral() {
|
||||
const result = assessFixedReliabilityPlan(createFixedReliabilityCase());
|
||||
return deepFreeze({ accepted: result.status, incident: result.evidence.incident, serviceLevel: result.evidence.serviceLevel, nextAction: result.nextAction });
|
||||
return deepFreeze({ attempt: 2, method: 'GET', status: 200, next: 'return-response' });
|
||||
}
|
||||
|
||||
export function runFixedReliabilityFixture() {
|
||||
const expected = [
|
||||
['reliability-contract-hand-off-v1', 'synthetic-plan-hand-off'],
|
||||
['missing-temporal-boundary-v1', 'stop-temporal-boundary-required'],
|
||||
['hidden-evidence-or-configuration-v1', 'stop-hidden-evidence-or-configuration'],
|
||||
['implicit-retry-v1', 'stop-implicit-retry'],
|
||||
['implicit-replica-or-recovery-v1', 'stop-implicit-replica-or-recovery'],
|
||||
['claimed-evidence-v1', 'stop-hidden-evidence-or-configuration'],
|
||||
['claimed-operational-result-v1', 'stop-disallowed-operational-result'],
|
||||
];
|
||||
const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessFixedReliabilityPlan(createFixedReliabilityCase(id)).status }));
|
||||
const sample = createFixedReliabilityCase();
|
||||
return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(sample) && Object.isFrozen(sample.scenario) && Object.isFrozen(sample.evidence), checks: deepFreeze(checks) });
|
||||
const checks = [
|
||||
['get503', 'retry-allowed'],
|
||||
['get200', 'success'],
|
||||
['post503', 'retry-denied'],
|
||||
['timeout', 'bounded-timeout'],
|
||||
].map(([id, expected]) => ({ id, expected, actual: assessFixedReliabilityPlan(createFixedReliabilityCase(id)).status }));
|
||||
return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual), checks });
|
||||
}
|
||||
|
||||
function revision(meta, parts, referenceEntries) {
|
||||
@@ -170,181 +94,241 @@ function revision(meta, parts, referenceEntries) {
|
||||
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength);
|
||||
return deepFreeze({ ...meta, contentHtml, proseLength });
|
||||
}
|
||||
|
||||
const refs = [
|
||||
{ key: 'rfc9110', use: 'Закрепляет различие безопасного метода, идемпотентности и автоматического повторения после сбоя связи.', boundary: 'Не подтверждает метод, запрос, зависимость, повтор или результат в данном сценарии.' },
|
||||
{ key: 'nist800160', use: 'Даёт официальный словарь устойчивости как способности противостоять, восстанавливаться и адаптироваться к неблагоприятным условиям.', boundary: 'Не является incident record, SLO, планом восстановления или доказательством готовности системы.' },
|
||||
{ key: 'rfc9000', use: 'Показывает, что транспортный слой имеет собственные состояния и ограничения, которые нельзя выдать за семантику прикладного действия.', boundary: 'Не доказывает наличие QUIC, конкретной нагрузки, соединения или восстановления.' },
|
||||
{ key: 'http', use: 'Задаёт семантику безопасных и идемпотентных методов, статусов и поля Retry-After.', boundary: 'Не выбирает политику повтора для конкретного API и не гарантирует отсутствие побочных эффектов.' },
|
||||
{ key: 'quic', use: 'Показывает, что транспортное управление потоками и потерями отделено от прикладной семантики запроса.', boundary: 'Не является инструкцией по retry HTTP-клиента и не описывает поведение конкретной библиотеки.' },
|
||||
];
|
||||
|
||||
const practice = revision({
|
||||
slug: 'editorial-2027-07-practice-reliability-capstone',
|
||||
title: 'Большой разбор надёжности: практический маршрут',
|
||||
categories: ['Надёжность', 'Кейс'],
|
||||
title: 'Повтор запроса без двойного действия: timeout, 503 и безопасный retry',
|
||||
categories: ['Надёжность', 'HTTP'],
|
||||
cover: '/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg',
|
||||
excerpt: 'План на июль 2027: как зафиксировать контракт отказного сценария, не выдавая предположение о retry за решение.',
|
||||
readingMinutes: 23,
|
||||
excerpt: 'Практический клиент с общим deadline и повтором только там, где операция допускает повторное выполнение.',
|
||||
readingMinutes: 14,
|
||||
}, [
|
||||
p('Это редакционный план на 2027-07 с source cutoff 2026-07-31, а не отчёт о работе системы. Практическая проблема начинается до первого кода: зависимость названа «недоступной», но не сказано, какое действие остаётся незавершённым, что именно разрешено повторить и что должно остановиться. Такая пустота быстро превращается в неверный retry. Его цена не абстрактна: один и тот же запрос может быть отправлен повторно без доказанного права на это, а команда получает спор о последствиях вместо ясной границы сценария.'),
|
||||
p('Вторая цена — ложная экономия на формулировке. Если в будущем описании незаметно появляются резервная копия, вторая реплика или «обычное восстановление», читатель воспринимает их как уже выбранный путь. На редакторскую дату у этой статьи нет реального incident, значения SLO или SLI, deployment, наблюдений, конфигурации, нагрузки либо результата. Разрешён только <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>. Поэтому ниже не рецепт обработки сбоя, а контракт, который не подставляет отсутствующие решения.'),
|
||||
h2('Контракт начинается с действия, а не с названия сбоя'),
|
||||
p('Фраза «сервис упал» слишком велика для решения о повторе. В ней не видно, был ли вызов начат, принято ли прикладное действие другой стороной, есть ли у вызывающего кода ключ идемпотентности и допустимо ли вообще сохранять попытку. В будущем scope эти вопросы могут получить разные ответы. Пока ответ не собран, безопаснее назвать только синтетический класс: named dependency unavailable. Это не диагноз и не намёк на конкретный компонент; это ограничитель для разговора.'),
|
||||
p('Контракт полезен, когда в нём есть четыре независимых поля: действие, наблюдаемая граница, разрешение на повтор и состояние результата. В P113 действие намеренно не получает endpoint, payload или consumer. Наблюдаемая граница остаётся <code>not-collected</code>. Разрешение на повтор не вычисляется из HTTP-слова или названия операции: оно явно остаётся <code>explicit-not-selected</code>. Состояние результата не меняется на успех или восстановление, потому что данных для такого перехода нет.'),
|
||||
table('Части синтетического контракта отказного сценария', ['Часть', 'Что можно назвать сейчас', 'Что нельзя достраивать'], [
|
||||
['Граница действия', 'Именованный synthetic scenario', 'Реальный запрос, ключ, consumer или side effect'],
|
||||
['Повтор', '<code>explicit-not-selected</code>', 'Автоматический retry по умолчанию'],
|
||||
['Резервирование', '<code>explicit-not-selected</code>', 'Реплику, запасной маршрут или переключение'],
|
||||
['Восстановление', '<code>explicit-not-selected</code>', 'Готовый recovery path или время возврата'],
|
||||
['Свидетельство', '<code>not-collected</code>', 'Incident, конфигурацию, нагрузку или production outcome'],
|
||||
p('Клиент получил timeout и повторил запрос, а пользователь увидел две созданные заявки. Цена ошибки — повторить побочный эффект, который сервер уже выполнил, но ответ потерялся по дороге. Обратная ситуация тоже опасна: не повторить безопасный <code>GET</code> после временного <code>503</code> и показать лишний сбой.'),
|
||||
p('Нужны три явных ограничения: общий deadline, число попыток и список методов, для которых повтор допустим. Учебный локальный сервер дважды отвечает <code>503</code>, затем возвращает <code>200</code>. Клиент показывает порядок попыток и завершает работу в пределах заданного времени.'),
|
||||
h2('Сначала решаем, что можно повторить'),
|
||||
p('RFC 9110 называет идемпотентным запрос, повтор которого имеет тот же ожидаемый эффект, даже если отдельные ответы отличаются. Это свойство операции, а не магия клиента: <code>GET</code>, <code>HEAD</code>, <code>PUT</code> и <code>DELETE</code> имеют другую семантику, чем обычный <code>POST</code>. Сервер может отдельно предоставить идемпотency-key для создания ресурса, но это нужно подтвердить его контрактом.'),
|
||||
table('Матрица решения о повторе', ['Метод', 'Пример действия', 'Повтор после timeout', 'Условие'], [
|
||||
['GET', 'прочитать каталог', 'обычно допустим', 'лимит и deadline'],
|
||||
['HEAD', 'проверить ресурс', 'обычно допустим', 'ответ не нужен в теле'],
|
||||
['PUT', 'записать состояние по ключу', 'возможен', 'сервер сохраняет идемпотентность'],
|
||||
['DELETE', 'удалить по идентификатору', 'возможен', 'повторный 404 трактуется контрактом'],
|
||||
['POST', 'создать заказ', 'не по умолчанию', 'idempotency-key и правила сервера'],
|
||||
]),
|
||||
h2('Почему идемпотентность не заменяет контракт'),
|
||||
p('RFC 9110 различает безопасные методы и идемпотентные методы, а также описывает условия, при которых клиент может повторить идемпотентный запрос после сбоя связи. Это полезная норма для языка разговора, но не лицензия на скрытый retry. Документ не знает бизнес-действие, дедупликацию, состояние принимающей стороны и политику конкретного клиента. Поэтому нельзя взять слово <code>idempotent</code> и выдать его за доказательство, что повтор не изменит будущий предметный результат.'),
|
||||
p('Проверка должна идти в обратную сторону. Сначала будущий scope формулирует, что считается одним логическим действием. Затем определяет, есть ли явный способ различить первую попытку и повтор. Потом отделяет транспортную неясность от прикладного подтверждения. Лишь после этого можно обсуждать policy. Этот порядок медленнее одной строки с retry, зато не переносит риск из слоя связи в слой данных под видом надёжности.'),
|
||||
figure('/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg', 'Схема контракта отказного сценария: один синтетический вопрос разложен на действие, границу, явный retry и запрещённые предположения о реплике и восстановлении.', 'Схема не показывает реальный путь запроса. Она удерживает четыре поля контракта и красные стоп-границы для невыбранных решений.'),
|
||||
h2('Исполнимый пример без доступа к системе'),
|
||||
p('Пример ниже запускается в Node и работает только с named fixed in-memory literal. Он не импортирует HTTP-клиент, не читает файл, не использует переменные окружения, часы, секреты или внешние данные. Его результат не говорит, что retry безопасен: он показывает, что literal не способен подставить retry сам.'),
|
||||
code("import { inspectFailureContractLiteral } from './upgrade-2027-07.mjs';\n\nconst contract = inspectFailureContractLiteral();\nconsole.log(contract);\n// { namedScenario: 'named-synthetic-dependency-unavailable-scenario',\n// retry: 'explicit-not-selected', outcome: 'synthetic-plan-hand-off' }"),
|
||||
p('Здесь <code>namedScenario</code> — имя формы, а не запись об инфраструктуре. <code>retry</code> — намеренный отказ от implicit policy. Если изменить fixed literal или передать произвольный объект, evaluator не ищет недостающие сведения и не выбирает fallback. Он возвращает stop-status. Это важнее красивого примера с сетевым клиентом: пример не создаёт следов, не обещает повторного выполнения и не маскирует конфигурацию.'),
|
||||
h2('Порядок постановки будущего вопроса'),
|
||||
figure('/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg', 'Дерево решения для повторения HTTP-запроса: timeout, статус, метод, deadline и конечный ответ.', 'Диаграмма сначала проверяет deadline и метод, затем статус. Ошибка не превращается в повтор автоматически.'),
|
||||
h2('Общий deadline важнее числа попыток'),
|
||||
p('Три попытки по 500 миллисекунд могут занять больше секунды, если между ними стоят задержки. Пользователь и вызывающая система ждут не количество попыток, а завершение операции. Поэтому задаём общий deadline, а на каждой итерации считаем оставшееся время. Если его недостаточно для следующего запроса, возвращаем timeout.'),
|
||||
p('Задержка между попытками должна быть ограниченной и лучше иметь случайное рассеивание в многоклиентской системе. Но jitter не исправляет неправильную семантику. Сначала решается «можно ли повторять», затем «сколько времени отдать», и только потом выбираются backoff и случайная добавка.'),
|
||||
h2('Учебный клиент и локальный сервер'),
|
||||
p('Вход функции <code>requestWithRetry</code> — URL, метод и параметры времени. Сервер хранит счётчик только внутри процесса: первые два ответа — 503, третий — 200. Ожидаемый результат — <code>attempts: 3</code> и <code>status: 200</code>. На локальном loopback нет внешней зависимости.'),
|
||||
code([
|
||||
"import { createServer } from 'node:http';",
|
||||
'',
|
||||
'let calls = 0;',
|
||||
"const server = createServer((request, response) => {",
|
||||
' calls += 1;',
|
||||
' if (calls < 3) {',
|
||||
" response.writeHead(503, { 'retry-after': '0' });",
|
||||
" response.end('busy');",
|
||||
' return;',
|
||||
' }',
|
||||
' response.end(\'ready\');',
|
||||
'});',
|
||||
'',
|
||||
'async function requestWithRetry(url, { method = \'GET\', maxAttempts = 3, deadlineMs = 1000 } = {}) {',
|
||||
" if (!['GET', 'HEAD', 'PUT', 'DELETE'].includes(method)) throw new Error('method is not retryable');",
|
||||
' const deadline = Date.now() + deadlineMs;',
|
||||
' for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {',
|
||||
' const remaining = deadline - Date.now();',
|
||||
" if (remaining <= 0) throw new Error('deadline reached');",
|
||||
' const response = await fetch(url, { method, signal: AbortSignal.timeout(remaining) });',
|
||||
" if (response.ok) return { attempts: attempt, status: response.status };",
|
||||
' }',
|
||||
' throw new Error(\'deadline reached\');',
|
||||
'}',
|
||||
'',
|
||||
"server.listen(0, '127.0.0.1', async function retryDemo() {",
|
||||
' const port = server.address().port;',
|
||||
" console.log(await requestWithRetry('http://127.0.0.1:' + port));",
|
||||
' server.close();',
|
||||
'});',
|
||||
].join('\n')),
|
||||
p('Результат — <code>{ attempts: 3, status: 200 }</code>. В коде есть намеренное ограничение: он не повторяет POST и не разбирает Retry-After. Для учебного стенда этого достаточно, чтобы увидеть связь между методом и повтором. В библиотеке нужно добавить AbortSignal, deadline, backoff, обработку сетевого исключения и лог безопасных попыток.'),
|
||||
h2('Что означает 503'),
|
||||
p('503 — ответ сервера, а не доказательство, что повтор сработает. Заголовок <code>Retry-After</code> может указать время ожидания, но его значение нужно проверить как число секунд или дату. Если сервис за прокси возвращает 503 от посредника, повтор может увеличить нагрузку на origin. Логируйте источник ответа, если архитектура позволяет отличить proxy от приложения.'),
|
||||
p('Для timeout сложнее: запрос мог быть принят и выполнен, а клиент потерял ответ. Для чтения это обычно приемлемо, для записи — нет без ключа дедупликации. Поэтому error class должна хранить не только «сетевой сбой», но и метод, request id, idempotency key и достигнутый этап.'),
|
||||
h2('Порядок внедрения'),
|
||||
ol([
|
||||
'Назвать одно логическое действие без endpoint, payload и рассказа о прошлой операции.',
|
||||
'Указать редакционную дату 2026-07-31, planning issue 2027-07 и source cutoff 2026-07-31.',
|
||||
'Оставить evidence, configuration и load в состоянии <code>not-collected</code>.',
|
||||
'Зафиксировать retry, replica и recovery как <code>explicit-not-selected</code>, пока отдельное решение не даст им смысл.',
|
||||
'Остановить карточку при hidden field, implicit retry или claimed result; не компенсировать пробел предположением.',
|
||||
'Передать только synthetic plan question в отдельный будущий scope, если появится решение, которому нужны доказательства.',
|
||||
'Составить таблицу методов и побочных эффектов конкретного API.',
|
||||
'Ввести общий deadline и передавать его во все сетевые вызовы.',
|
||||
'Разрешить retry только для подтверждённых безопасных или идемпотентных операций.',
|
||||
'Ограничить число попыток, задержку и суммарное время ожидания.',
|
||||
'Сохранить попытки, статусы и тип ошибки без токенов и тела с персональными данными.',
|
||||
'Проверить отдельным тестом timeout после принятия записи и ответ 503 от посредника.',
|
||||
]),
|
||||
h2('Цена слишком раннего retry'),
|
||||
p('Неверный retry опасен не количеством попыток, а подменой вопроса. Команда может начать спорить о задержке между попытками, хотя ещё не определила право повторять действие. Она может обсуждать backoff, хотя неизвестно, какой сигнал отличает временную недоступность от подтверждённого принятия. Она может назвать этот разговор восстановлением, хотя не названа точка, из которой что-либо восстанавливается. Каждая такая подмена создаёт видимость управления без проверяемого предмета.'),
|
||||
p('Практичный контракт режет эту цепочку раньше. Он не пытается быть универсальной схемой отказоустойчивости и не выносит оценку в продакшен. Его цель уже: сохранить для будущего обсуждения место, где решение о повторе должно быть явным. Сценарий остаётся полезным, даже если позже будет выбран запрет на повтор, другой интерфейс или прекращение операции. Никакой из этих исходов не записан заранее.'),
|
||||
h2('Контракт не должен притворяться политикой'),
|
||||
p('Есть соблазн считать, что достаточно назвать policy хорошими словами: «осторожный retry», «защищённое переключение», «мягкое восстановление». Но такие ярлыки скрывают, кто принимает решение и по какому наблюдаемому условию. Один читатель понимает «осторожный» как запрет на повтор, другой — как повтор после паузы, третий — как передачу в очередь. Если этот разрыв обнаруживается после начала реализации, цена выше, чем у прямого вопроса: уже появились несовместимые ожидания от данных и интерфейса.'),
|
||||
p('В P113 policy не выбирается намеренно. Такой отказ не является пропуском обязательного пункта. Он сохраняет обратимость: будущая работа сможет выбрать любой путь, который будет обоснован её собственным контекстом, либо не выбирать путь вовсе. Fixed literal не хранит флаг, который можно принять за включённый механизм. Вместо этого он хранит понятное отрицательное состояние. У читателя нет оснований превратить его в скрытый default при переносе текста в задачу или обсуждение.'),
|
||||
p('Эту же дисциплину полезно применить к слову «ошибка». Ошибка соединения, ошибка ответа и ошибка предметного действия не обязаны иметь одну реакцию. В текущем сценарии мы не узнаём, был ли любой из них. Поэтому контракт не предлагает классификатор и не рисует таблицу кодов. Он фиксирует более раннюю развилку: без явно определённого смысла действия нельзя вывести допустимость повтора из внешнего симптома. Это узкая, но проверяемая граница практики.'),
|
||||
p('Краткий текст наставника здесь не означает, что можно опустить цену выбора. Стоимость явной невыбранности — необходимость вернуться к вопросу позже. Стоимость неявного выбора — риск, что его будут исполнять уже сейчас, не понимая ограничений. Для будущей системы первый вариант обычно честнее: он не обещает экономию времени там, где отсутствует право на решение. Контракт помогает увидеть именно эту цену до того, как спор станет спором о якобы уже работающем механизме.'),
|
||||
p('Полезный критерий качества такого контракта прост: другой читатель может повторить его проверку, не получив доступ к системе. Он должен увидеть временную границу, имя synthetic scenario, отрицательные states и причины stop. Если для понимания потребуются подразумеваемый сервис, предполагаемый журнал или внутреннее правило, контракт уже протекает. Тогда нужно не дописывать догадку, а вернуть поле к явной невыбранности и сохранить вопрос для отдельного решения.'),
|
||||
h2('Пределы и следующий шаг'),
|
||||
p('Этот материал не создаёт runbook, не проводит failure-injection и не определяет recovery. Он не утверждает наличие реплик, очереди, retries, метрик, логов или людей, которые будут исполнять дальнейшую работу. NIST SP 800-160 помогает назвать устойчивость как инженерную область с неблагоприятными условиями и восстановлением, но не превращает synthetic literal в доказательство готовности. RFC 9110 также не говорит за неизвестное прикладное действие.'),
|
||||
p('Следующий шаг ограничен: только если появится отдельное авторизованное решение, открыть новый scope и заново определить допустимые evidence, метод и границы вывода. До этого P113 завершён честно: он показывает цену неверного retry и оставляет retry невыбранным. В этом плане нет production effect, победителя или обещания, что система когда-либо будет изменена.'),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('Локальный сервер не моделирует потерю ответа после выполнения записи, балансировку и лимиты зависимостей. Список retryable-методов — не универсальная политика: API может ограничить повтор иначе. QUIC управляет транспортом, но не делает прикладной POST идемпотентным.'),
|
||||
p('Следующий шаг — добавить к API контракт idempotency key для операций создания и проверить его на одинаковом ключе. Для чтения подключите bounded retry с общим deadline. Критерий готовности простой: повтор не выходит за время, не скрывает причину и не создаёт второй побочный эффект.'),
|
||||
], refs);
|
||||
|
||||
const mechanism = revision({
|
||||
slug: 'editorial-2027-07-mechanism-reliability-capstone',
|
||||
title: 'Большой разбор надёжности: как принять инженерное решение',
|
||||
categories: ['Надёжность', 'Кейс'],
|
||||
title: 'Почему retry иногда удваивает данные: идемпотентность на уровне протокола',
|
||||
categories: ['Надёжность', 'Архитектура'],
|
||||
cover: '/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg',
|
||||
excerpt: 'План на июль 2027: как сделать fault tree проверяемым, не выдавая структуру вопросов за восстановление или результат нагрузки.',
|
||||
readingMinutes: 24,
|
||||
excerpt: 'Разбираем границу между транспортной доставкой и эффектом бизнес-операции.',
|
||||
readingMinutes: 15,
|
||||
}, [
|
||||
p('Это план/сценарий P113 на 2027-07 с source cutoff 2026-07-31. Механическая ошибка в разборе надёжности появляется, когда fault tree рисуют как готовое объяснение: верхняя вершина названа, ветви выглядят правдоподобно, и дальше их начинают читать как факты. Цена такой схемы — неверное инженерное решение. Она может направить внимание к восстановлению или нагрузке, хотя ни причина, ни состояние системы, ни сама нагрузка не были собраны.'),
|
||||
p('Есть и вторая цена: слово «recovery» создаёт ощущение завершённости. В будущей статье легко написать, что путь восстановления существует, что запасная ветвь примет работу или что система выдержит давление. Но на редакционную дату нет actual incident, проверки под нагрузкой, SLO/SLI values, deployment, наблюдений, recovery result или production outcome. Здесь допускается единственный положительный output — <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>; fault tree служит для фальсифицируемых вопросов, не для уверенного диагноза.'),
|
||||
h2('Fault tree — карта условий опровержения'),
|
||||
p('Полезное дерево отказов не отвечает «почему произошёл сбой». Оно делает видимым, что пришлось бы опровергнуть или подтвердить, прежде чем считать верхнее утверждение объяснённым. В P113 верхняя вершина намеренно синтетическая: named dependency unavailable scenario. Под ней нет hostname, client, трассы или сообщения. Вместо этого есть группы неизвестного: форма действия, условие нагрузки и выбранность восстановления. Каждая ветвь остаётся вопросом, пока evidence не разрешён и не получен в другом scope.'),
|
||||
p('Такое устройство защищает от ложной полноты. Можно нарисовать пять причин и всё равно не знать, относятся ли они к одному времени, одной версии или одному действию. Можно назвать их независимыми, хотя для этого нет материала. В сценарии лучше меньше ветвей, но у каждой есть статус. <code>not-collected</code> значит, что состояние не наблюдалось; <code>explicit-not-selected</code> значит, что решение не принималось; <code>not-defined</code> значит, что критерий ещё не сформулирован. Эти слова не являются плохими данными — они запрещают делать из пустоты вывод.'),
|
||||
table('Матрица ветвей для будущей фальсификации', ['Ветка дерева', 'Допустимое состояние P113', 'Какой вывод остановлен'], [
|
||||
['Форма действия', 'named synthetic question', 'Что была конкретная операция или side effect'],
|
||||
['Нагрузка', '<code>not-collected</code>', 'Что система выдержала либо не выдержала давление'],
|
||||
['Восстановление', '<code>explicit-not-selected</code>', 'Что есть готовый путь возврата'],
|
||||
['Критерий уровня сервиса', '<code>not-defined</code>', 'Что соблюдён SLO или измерен SLI'],
|
||||
['Наблюдение', '<code>not-collected</code>', 'Что причина или порядок событий известны'],
|
||||
p('Клиент видит timeout, но сервер мог уже применить команду. Цена автоматического retry — двойная запись, двойная отправка письма или два списания. Удалить повтор из клиента тоже нельзя: для чтения временный сбой должен переживаться. Причина в том, что транспорт сообщает о доставке байтов, а не о том, какой эффект зафиксирован на стороне приложения.'),
|
||||
p('Разберём эту границу на локальном счётчике и HTTP-методах. Код не имитирует сложную базу: он показывает, что повторяемость — свойство контракта операции, а не только сетевого соединения. После примера отделим idempotency key от простого request id и назовём ограничения.'),
|
||||
h2('Транспорт не знает бизнес-эффекта'),
|
||||
p('TCP или QUIC могут доставить поток, обнаружить потерю и закрыть соединение. RFC 9000 описывает потоки, контроль потока и состояния соединения, но полезная нагрузка остаётся данными приложения. Клиент может получить исключение после отправки последнего байта. Из этого нельзя вывести, была ли транзакция применена.'),
|
||||
table('Три разных идентификатора', ['Идентификатор', 'Кто создаёт', 'Задача'], [
|
||||
['connection id', 'транспорт', 'сопоставить пакеты соединению'],
|
||||
['request id', 'клиент или входной сервис', 'связать лог одной попытки'],
|
||||
['idempotency key', 'клиент для операции', 'узнать повтор того же действия'],
|
||||
['resource id', 'доменная модель', 'какой объект изменяется'],
|
||||
]),
|
||||
h2('Нагрузка не равна одной оси на графике'),
|
||||
p('Нагрузка часто выглядит как удобная переменная, которую можно добавить в диаграмму позже. Но без описания действия она ничего не объясняет. Неизвестно, что именно считается единицей работы, где возникает конкуренция, как различаются очереди и синхронные вызовы, какие данные допустимо использовать и какой результат вообще сравнивается. Поэтому P113 не содержит нагрузочного профиля, числа, генератора или запуска. Слово <code>load: not-collected</code> специально не оставляет места для фиктивной шкалы.'),
|
||||
p('Это ограничение не запрещает будущую проверку. Оно отделяет её от текущего текста. Когда появится отдельный scope, ему понадобятся датированные входы, разрешение на метод, критерий останова и правила интерпретации. Даже тогда результат нельзя извлечь из одного количества операций: нужно проверить, что оно связано с конкретным верхним вопросом дерева. Пока эта связь отсутствует, график выглядел бы убедительнее, чем заслуживает его provenance.'),
|
||||
figure('/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg', 'Матрица evidence для fault tree: ветви формы действия, нагрузки, восстановления, критерия и наблюдения отмечены как не собранные или не выбранные, а зелёный результат остаётся только synthetic hand-off.', 'Матрица различает отсутствие evidence и отсутствие решения. Красные ячейки показывают выводы, которые нельзя получить из такого состояния.'),
|
||||
h2('Восстановление нужно разложить на решения'),
|
||||
p('Восстановление — не переключатель в диаграмме. У него есть исходное состояние, сохранность предметного результата, порядок возврата и пределы допустимого действия. Если хотя бы один из этих элементов назван по памяти, читатель получает легенду вместо механизма. В P113 recovery не скрыт за словом «позже разберёмся»: он имеет точный статус <code>explicit-not-selected</code>. Это говорит не о недостатке инженерной зрелости, а о том, что выбор нельзя сделать из статьи без предметных ограничений.'),
|
||||
p('NIST SP 800-160 Vol. 2 Rev. 1 использует устойчивость как способность предвидеть, выдерживать, восстанавливаться и адаптироваться к неблагоприятным условиям. Для этой статьи важен именно масштаб утверждения: документ задаёт направление системной инженерии, но не подтверждает, что конкретная система уже прошла любой из этих этапов. Поэтому ссылки на resilience нельзя использовать как подпись под synthetic tree с обещанием восстановления.'),
|
||||
h2('Исполнимый пример проверяет границу, а не систему'),
|
||||
p('Этот пример строит компактный вид fixed literal. Он вычисляет только строки и массивы, заданные в модуле. В нём нет чтения файлов, сети, clock, environment, секрета, телеметрии, процесса, данных или обращения к внешнему миру. Запуск не создаёт нагрузку и не делает recovery-попытку.'),
|
||||
code("import { inspectFaultTreeLiteral } from './upgrade-2027-07.mjs';\n\nconst tree = inspectFaultTreeLiteral();\nconsole.log(tree.branches.join(' | '));\nconsole.log(tree.outcome);\n// request-shape-not-collected | capacity-not-collected | recovery-not-selected\n// not-attempted"),
|
||||
p('Ветка <code>capacity-not-collected</code> не измеряет capacity и не предполагает её. <code>recovery-not-selected</code> не обозначает скрытый резервный механизм. А <code>not-attempted</code> относится к production effect всего evaluator, не к вымышленной попытке восстановления. У этого примера есть полезный критерий: при произвольном объекте или скрытом значении функция возвращает stop-status и не расширяет дерево догадкой.'),
|
||||
h2('Порядок, который делает дерево проверяемым'),
|
||||
figure('/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg', 'Матрица границ надёжности: транспорт, HTTP-метод, ключ операции, запись и подтверждение результата.', 'Матрица не утверждает доставку конкретного запроса. Она показывает, какой слой отвечает на какой вопрос.'),
|
||||
h2('Идемпотентность — не «повтор без ошибок»'),
|
||||
p('Идемпотентность означает, что несколько одинаковых запросов имеют тот же ожидаемый эффект, что и один. Ответы могут различаться: первый <code>200</code>, второй <code>204</code> или <code>404</code> в зависимости от контракта. Поэтому тест проверяет состояние и правила сервера, а не только код ответа.'),
|
||||
p('Для POST дедупликация часто строится на idempotency key. Сервер сохраняет результат по ключу и возвращает его при повторе, не создавая новый объект. Ключ должен быть привязан к смысловой операции и иметь срок хранения, иначе одинаковая строка через месяц случайно подавит новую команду. Этот срок и область уникальности являются частью API-контракта.'),
|
||||
h2('Учебный сервер с защитой от дубля'),
|
||||
p('Код ниже принимает только локальные POST-запросы с заголовком <code>Idempotency-Key</code>. Вход — ключ и JSON-тело, но учебный сервер читает только ключ: это специально оставленная граница примера. Ожидаемый результат — два ответа с одним номером записи: второй запрос возвращает сохранённый результат. Это runnable пример поведения endpoint, а не готовая замена транзакции или базы данных.'),
|
||||
code([
|
||||
"import { createServer } from 'node:http';",
|
||||
'',
|
||||
'const results = new Map();',
|
||||
'let nextId = 1;',
|
||||
'',
|
||||
"const server = createServer((request, response) => {",
|
||||
" if (request.method !== 'POST') { response.writeHead(405); response.end(); return; }",
|
||||
" const key = request.headers['idempotency-key'];",
|
||||
" if (!key) { response.writeHead(400); response.end('key required'); return; }",
|
||||
' if (!results.has(key)) results.set(key, { id: nextId++, state: \'created\' });',
|
||||
" response.writeHead(200, { 'content-type': 'application/json' });",
|
||||
' response.end(JSON.stringify(results.get(key)));',
|
||||
'});',
|
||||
'',
|
||||
"server.listen({ host: '127.0.0.1', port: 0 }, async () => {",
|
||||
' const endpoint = \'http://127.0.0.1:\' + server.address().port;',
|
||||
" const options = { method: 'POST', headers: { 'Idempotency-Key': 'order-42' }, body: '{}' };",
|
||||
' console.log(await (await fetch(endpoint, options)).json());',
|
||||
' console.log(await (await fetch(endpoint, options)).json());',
|
||||
' server.close();',
|
||||
'});',
|
||||
].join('\n')),
|
||||
p('Обе строки имеют один и тот же <code>id</code>. В примере Map живёт в памяти процесса, поэтому перезапуск сервера уничтожит историю. В настоящем сервисе ключ должен проверяться атомарно рядом с записью результата, а тело и параметры операции должны входить в проверку совпадения. Один заголовок без серверной дедупликации ничего не гарантирует.'),
|
||||
h2('Где начинается непредсказуемость'),
|
||||
p('Если ключ повторно прислали с другим телом, сервер должен отклонить запрос или явно выбрать правило. Молча вернуть старый результат опасно: клиент может подумать, что новый адрес уже сохранён. Если срок хранения закончился, повтор того же ключ становится новой операцией. Эти варианты должны быть в контракте, тестах и журналах.'),
|
||||
p('Request id не заменяет idempotency key. Он различает попытки, но каждый retry обычно получает новый request id. Ключ операции остаётся прежним и связывает попытки в одну семантическую команду. В логах полезно хранить оба поля, а также номер попытки и итоговое состояние.'),
|
||||
h2('Как выбирать поведение'),
|
||||
table('Вопрос до автоматического retry', ['Вопрос', 'Да', 'Нет'], [
|
||||
['Операция безопасна или идемпотентна?', 'retry можно рассматривать', 'остановиться и проверить контракт'],
|
||||
['Есть общий deadline?', 'ограничить все попытки', 'ввести deadline'],
|
||||
['Сервер дедуплицирует ключ?', 'повторить с тем же key', 'не повторять побочный эффект'],
|
||||
['Ответ точно связан с записью?', 'сохранить result', 'проверить состояние отдельно'],
|
||||
]),
|
||||
h2('Порядок проверки контракта'),
|
||||
ol([
|
||||
'Сформулировать одну synthetic верхнюю вершину без имени реальной системы и без причины задним числом.',
|
||||
'Разделить ветви действия, нагрузки, критерия и восстановления так, чтобы одна ветвь не служила доказательством другой.',
|
||||
'Проставить <code>not-collected</code>, <code>not-defined</code> или <code>explicit-not-selected</code> вместо заполняющих слов.',
|
||||
'Проверить, что никакая ветвь не содержит hidden configuration, implicit retry, replica или recovery.',
|
||||
'Потребовать stop для claimed incident, claimed service level и любого operational result.',
|
||||
'Передать дерево только как вопрос о будущем evidence, не как основание менять систему.',
|
||||
'Назвать доменное действие и его побочный эффект, не ограничиваясь HTTP-методом.',
|
||||
'Проверить правило повторения в RFC-контракте API и коде сервера.',
|
||||
'Для создания определить формат, срок и область уникальности idempotency key.',
|
||||
'Сделать тест «ответ потерян после записи» и проверить, что повтор возвращает тот же результат.',
|
||||
'Сохранить request id, operation key и номер попытки в структурированном логе.',
|
||||
'Описать ответ на несовпадающее тело и истёкший ключ.',
|
||||
]),
|
||||
h2('Что значит фальсифицируемость здесь'),
|
||||
p('Фальсифицируемость в этом сценарии не означает тестировать систему прямо сейчас. Она означает, что у будущего утверждения заранее есть условия, при которых оно не может быть принято. Например, если не определён критерий уровня сервиса, нельзя назвать его соблюдённым. Если нет наблюдения, нельзя утверждать порядок причин. Если recovery не выбран, нельзя рассказывать о времени возврата. Такое дерево не делает надежность измеренной; оно делает преждевременный вывод недопустимым.'),
|
||||
p('Это особенно важно в материале, который читатель может принять за «большой разбор». Большим должен быть не масштаб уверенности, а полнота границ: какие слои не смешиваются, что не собрано, откуда мог бы появиться будущий факт и что его всё равно не докажет. Конкретный вывод появится только в новом record с собственной датой, входами и правилами. P113 не резервирует для него финал.'),
|
||||
h2('Дерево не заменяет причинную связь'),
|
||||
p('Связи на схеме легко читаются сильнее, чем задумано. Стрелка от нагрузки к верхней вершине может выглядеть как утверждение, что нагрузка вызвала недоступность. Стрелка от восстановления к результату может выглядеть как доказательство, что выбран путь возврата. В P113 связи показывают только состав будущей проверки: эти темы нельзя склеить в один вывод, пока каждая не получит собственное основание. Схема должна помогать читателю задавать вопрос, а не отвечать вместо него.'),
|
||||
p('Поэтому здесь нет вероятностей, весов ветвей, ранжирования причин и числового порога. Такие значения могут быть нужны в другом artefact, однако им необходимы происхождение и условия. Число без method превращает дерево в декорацию точности. Даже ранжирование «самая вероятная причина» недопустимо, когда отсутствуют observations. Текущий literal делает более скромное, но ценное действие: одинаково отказывает и красивой догадке, и скрытой конфигурации.'),
|
||||
p('Нагрузка особенно хорошо показывает эту разницу. В одном контексте вопрос о нагрузке касается входного потока, в другом — конкуренции за ресурс, в третьем — внешнего ограничения. Перенос готовой ветви между ними без определения единицы работы создаёт ложную сопоставимость. P113 не заставляет будущий scope измерять всё подряд. Он требует только, чтобы намерение проверить нагрузку не называлось уже проведённой проверкой и не становилось доказательством recovery.'),
|
||||
p('Такое дерево можно считать отрицательной спецификацией. Оно описывает утверждения, которых нельзя сделать, а не действия, которые уже выполнены. В инженерной работе это бывает полезнее предварительного решения: команда видит, какие данные действительно изменят выбор, а какие лишь сделают отчёт объёмнее. Если ни один будущий факт не нужен для решения, отдельный scope можно не открывать. Сценарий не подталкивает к сбору ради самого дерева.'),
|
||||
h2('Пределы и следующий шаг'),
|
||||
p('Статья не описывает реальное дерево существующего инцидента, не назначает SLO/SLI и не предлагает load test, failure injection, deploy или rollout. RFC 9000 и RFC 9110 помогают различать уровни протокола и семантики, но они не заменяют evidence о неизвестном действии. У P113 нет скрытого конфигурационного файла, инструмента проверки, monitoring output или recovery plan; их отсутствие отражено в literal, а не спрятано в сноске.'),
|
||||
p('Следующий шаг возможен только как новый авторизованный scope: там можно решить, нужен ли факт для конкретного выбора, какие inputs допустимы и какой результат будет честно отрицательным. Если такого scope не будет, дерево всё равно выполнило работу: оно не позволило назвать нагрузку, recovery или service level там, где их нет. Для текущего пакета итог остаётся <code>synthetic-plan-hand-off</code> и <code>productionEffect: not-attempted</code>.'),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('Map в примере не даёт атомарности при нескольких процессах, не переживает рестарт и не защищает от бесконечного роста. QUIC не решает дедупликацию, а HTTP-метод не раскрывает внутреннюю транзакцию. Для платежей и других критичных действий нужен отдельный контракт, тест отказа и согласованное хранилище результата.'),
|
||||
p('Следующим шагом добавьте к одному endpoint тест с повтором одного ключа и другим телом. Ожидаемый результат должен быть явным: конфликт или тот же ответ, но не новая запись. После этого только выбирайте retry policy для клиента.'),
|
||||
], refs);
|
||||
|
||||
const field = revision({
|
||||
slug: 'editorial-2027-07-field-reliability-capstone',
|
||||
title: 'Большой разбор надёжности: кейс с ограничениями и выводами',
|
||||
categories: ['Надёжность', 'Кейс'],
|
||||
title: 'Лог повторов, который помогает расследовать сбой: attempt, deadline и причина',
|
||||
categories: ['Надёжность', 'Наблюдаемость'],
|
||||
cover: '/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg',
|
||||
excerpt: 'План на июль 2027: как передать вопрос о надёжности без легенды об incident, SLO или уже принятом operational решении.',
|
||||
readingMinutes: 22,
|
||||
excerpt: 'Какие поля сохранить, чтобы отделить временный сбой от исчерпанного времени и опасного повтора.',
|
||||
readingMinutes: 14,
|
||||
}, [
|
||||
p('P113 — будущий редакционный сценарий на 2027-07 с source cutoff 2026-07-31. В полевом hand-off самая дорогая ошибка — написать гладкую историю вместо границы знания: «был incident», «уровень сервиса нарушен», «система восстановилась». Такие фразы удобно передавать дальше, но цена — ложная оперативная память. Следующий читатель тратит время на объяснение уже якобы известных событий, хотя исходные evidence, время, наблюдения и результат никогда не были частью этого пакета.'),
|
||||
p('Вторая цена возникает, когда hand-off путают с поручением на работу. В карточку добавляют неявный retry, предполагаемую реплику, способ восстановления или ожидаемую победу. В P113 этого нет: не названы actual incident, SLO/SLI values, telemetry, deployment, owner, runbook, rollout и production outcome. Положительный evaluator output ровно один — <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>. Полевая статья передаёт не результат и не легенду, а честно ограниченный вопрос.'),
|
||||
h2('Что делает hand-off пригодным к чтению'),
|
||||
p('Хорошая передача не пытается заполнить все поля. Она отделяет то, что названо, от того, что ещё не существует как evidence. В нашем fixed literal названы editorial date, planning issue, source cutoff и synthetic scenario. Это достаточно, чтобы понять временную границу и тему. Incident, observation, configuration и load остаются <code>not-collected</code>; service level остаётся <code>not-defined</code>. Такой набор не выглядит подробным отчётом — и именно поэтому его нельзя ошибочно процитировать как отчёт.'),
|
||||
p('У hand-off есть и отрицательная часть: он должен явно сказать, какие shortcuts запрещены. Нельзя подставить endpoint, реальный сервис, команду, файл конфигурации, след внешней системы или фразу о завершённом восстановлении. Нельзя считать, что отсутствие значения означает «по умолчанию можно повторить». Если читателю нужен один из этих фактов, это причина открыть отдельный scope, а не редактировать значение задним числом в будущем плане.'),
|
||||
table('Карточка передачи без легенды об операции', ['Поле', 'Значение в P113', 'Почему этого достаточно'], [
|
||||
['Временная граница', '<code>2026-07-31 → 2027-07</code>', 'Не даёт перепутать будущий материал с историей'],
|
||||
['Сценарий', 'named synthetic dependency unavailable', 'Фокусирует вопрос без указания реального target'],
|
||||
['Incident и наблюдение', '<code>not-collected</code>', 'Не превращает отсутствие записи в вымышленный факт'],
|
||||
['Уровень сервиса', '<code>not-defined</code>', 'Не имитирует SLO/SLI без условия и значения'],
|
||||
['Выход evaluator', '<code>synthetic-plan-hand-off</code>', 'Передаёт план без попытки production effect'],
|
||||
p('В журнале часто остаётся только «request failed». Цена такой записи — не понять, был ли это timeout первой попытки, ответ 503 второй или отказ от повтора операции записи. Без номера попытки и общего deadline команда увеличивает retry, не видя, что каждый новый запрос уже съедает остаток времени.'),
|
||||
p('Соберём компактное событие попытки и локальный исполнитель, который возвращает результат или ошибку с причиной. Входы фиксированы: метод, endpoint без секретов, лимит попыток и deadline. Выход — список событий и итог. Пример учебный и не подключается к журналу, сети или системе наблюдаемости.'),
|
||||
h2('Одно событие — одна попытка'),
|
||||
p('Не смешивайте в одну строку попытку и операцию. Операция имеет устойчивый идентификатор, попытка — порядковый номер, начало, длительность, статус и причину. <code>deadlineRemainingMs</code> показывает, сколько времени оставалось перед вызовом. Вместе эти поля позволяют увидеть, где именно закончился бюджет времени.'),
|
||||
table('Минимальные поля retry-события', ['Поле', 'Пример', 'Зачем'], [
|
||||
['operationId', 'op-42', 'связать попытки одного действия'],
|
||||
['attempt', '2', 'видеть число вызовов'],
|
||||
['method', 'GET', 'проверить семантику повтора'],
|
||||
['status', '503 или timeout', 'отличить ответ от исключения'],
|
||||
['remainingMs', '180', 'увидеть границу времени'],
|
||||
['decision', 'retry или return', 'зафиксировать действие клиента'],
|
||||
]),
|
||||
h2('Почему отсутствие owner — тоже граница'),
|
||||
p('В реальной работе адресат передачи имеет значение, но здесь его нельзя выдумать. Имя, команда или роль выглядели бы как назначение, а значит как уже совершённое организационное действие. P113 не назначает и не предполагает owner. Вместо этого output называет только следующий тип действия: открыть отдельный авторизованный evidence scope, если конкретное решение потребует фактов. Такая формулировка сохраняет свободу будущего контекста и не заставляет несуществующего участника отвечать за вымышленную историю.'),
|
||||
p('Эта осторожность не делает карточку безличной. Она даёт будущему читателю ясное правило: используйте её как постановку вопроса, а не как основание для изменения системы. Если новая работа будет разрешена, ей понадобятся собственные дата, допустимые inputs, метод, хранение evidence и правило отрицательного вывода. Ничто из этого нельзя вывести из P113 автоматически. Любая такая попытка должна быть остановлена evaluator до того, как станет частью текста.'),
|
||||
figure('/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg', 'Схема передачи: временная граница и синтетический вопрос проходят через три проверки, а скрытый evidence, implicit retry и заявленный результат направляются в стоп; единственный выход — synthetic plan hand-off.', 'Петля показывает не реальный incident response, а редакционный маршрут для будущего вопроса без owner, deployment или operational результата.'),
|
||||
h2('Исполнимый пример сохраняет неполноту'),
|
||||
p('Ниже пример для локального Node запуска. Он берёт только immutable literal, вызывает чистую функцию и печатает уже ограниченный hand-off. Модуль не читает file, network, environment, clock, secret, telemetry, system или data; он не создаёт внешнюю работу. Поэтому вывод демонстрирует форму проверки, а не состояние какой-либо системы.'),
|
||||
code("import { inspectHandoffLiteral } from './upgrade-2027-07.mjs';\n\nconst handoff = inspectHandoffLiteral();\nconsole.log(handoff.accepted, handoff.incident, handoff.serviceLevel);\n// synthetic-plan-hand-off not-collected not-defined"),
|
||||
p('Важная часть примера — не строка success, а отсутствие скрытого содержимого. <code>accepted</code> означает, что форма literal удовлетворяет редакционным ограничениям. Оно не означает success операции, восстановление или соответствие уровню сервиса. <code>incident</code> и <code>serviceLevel</code> остаются видимо пустыми по смыслу. Если передать input с claimed evidence, evaluator вернёт stop-status, а не преобразует claim в более осторожный текст.'),
|
||||
h2('Порядок передачи, который не создаёт легенду'),
|
||||
figure('/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg', 'Цикл обработки ответа: попытка, фиксация статуса, проверка deadline, решение о повторе или возврате.', 'Цикл сохраняет событие до следующего вызова. Конечный ответ и исчерпанное время становятся различимыми состояниями.'),
|
||||
h2('Учебный исполнитель с фиксированными ответами'),
|
||||
p('Функция принимает массив заранее заданных результатов. Вход не содержит внешних данных: это позволяет проверить порядок событий и границу попыток. Ожидаемый результат — после двух 503 возвращается 200, а в событиях остаются номера 1, 2 и 3. В реальном клиенте массив заменяется сетевым вызовом, а формат события сохраняется.'),
|
||||
code([
|
||||
'function runBoundedRetries({ operationId, method, responses, maxAttempts = 3, deadlineMs = 400 }) {',
|
||||
' const events = [];',
|
||||
' for (let index = 0; index < Math.min(maxAttempts, responses.length); index += 1) {',
|
||||
' const result = responses[index];',
|
||||
' const retryable = method === \'GET\' && result.status === 503;',
|
||||
' const remainingMs = Math.max(0, deadlineMs - index * 120);',
|
||||
' events.push({ operationId, attempt: index + 1, method, status: result.status, remainingMs, decision: retryable ? \'retry\' : \'return\' });',
|
||||
' if (remainingMs === 0) return { result: { status: \'deadline\' }, events };',
|
||||
' if (!retryable) return { result, events };',
|
||||
' }',
|
||||
' return { result: { status: \'deadline\' }, events };',
|
||||
'}',
|
||||
'',
|
||||
"console.log(runBoundedRetries({ operationId: 'op-42', method: 'GET', responses: [{ status: 503 }, { status: 503 }, { status: 200 }] }));",
|
||||
].join('\n')),
|
||||
p('В выводе три события, а итоговый статус — 200. Если заменить метод на <code>POST</code>, первая запись сразу получит решение <code>return</code>. Это не утверждение о любом POST: исполнитель демонстрирует консервативную политику, которую нужно заменить контрактом конкретного endpoint. Название <code>decision</code> полезнее, чем свободная фраза в логе.'),
|
||||
h2('Причина и действие должны быть разными полями'),
|
||||
p('Причина — timeout, 503, 429, DNS error или отмена. Действие — retry, return, fail или cancel. Если записать «retry из-за временной ошибки» одной строкой, невозможно посчитать, какой класс ответов создал нагрузку. Раздельные поля позволяют построить таблицу по методу и не смешивать сетевой отказ с бизнес-ошибкой.'),
|
||||
p('Не сохраняйте тело ответа по умолчанию. Для диагностики обычно достаточно status, безопасного подтипа и размера. URL нормализуйте, удаляя query-секреты. Идентификатор операции не должен быть email, номером карты или токеном. Чем больше произвольного текста в событии, тем выше стоимость хранения и риск утечки.'),
|
||||
h2('Число попыток не равно надёжности'),
|
||||
table('Что можно увидеть в событиях', ['Набор', 'Интерпретация', 'Действие'], [
|
||||
['1 timeout, return', 'deadline слишком мал или вызов завис', 'разделить connect/read timeout'],
|
||||
['3 × 503, deadline', 'ответ не восстановился за время', 'проверить зависимость и backoff'],
|
||||
['1 × 429, return', 'сработал rate limit', 'прочитать Retry-After и снизить темп'],
|
||||
['POST, 503, return', 'повтор запрещён политикой', 'проверить состояние по operation key'],
|
||||
]),
|
||||
h2('Исчерпанное время — отдельный результат'),
|
||||
p('Если последняя попытка закончилась на deadline, это не то же самое, что ответ 503. Сервер мог принять запрос, а клиент не успел дождаться ответа. В событии сохраняйте <code>lastAttempt</code> и <code>lastKnownStatus</code>, но не превращайте неизвестное состояние в «операция не выполнена». Для чтения можно вернуть контролируемую ошибку, для записи — запросить состояние по ключу операции.'),
|
||||
p('Такой вывод меняет следующий шаг. Для timeout чтения проверяем границы времени и зависимость. Для timeout записи сначала ищем безопасный способ узнать состояние, а не запускаем ещё один POST. Небольшая разница в двух полях предотвращает самый дорогой вид автоматизма — повтор действия, о результате которого клиент уже не знает.'),
|
||||
h2('Порядок внедрения события'),
|
||||
ol([
|
||||
'Положить в карточку только fixed editorial date, planning issue, source cutoff и named synthetic scenario.',
|
||||
'Отметить incident, observation, configuration и load как <code>not-collected</code>; не заменять их пересказом.',
|
||||
'Оставить service level как <code>not-defined</code>, пока отдельный scope не определит условие и смысл измерения.',
|
||||
'Проверить, что retry, replica и recovery имеют явный статус невыбранности, а не default.',
|
||||
'Остановить карточку при hidden evidence, любом claimed operational result или произвольном input.',
|
||||
'Передать только synthetic plan hand-off; новый scope открывается лишь при отдельно разрешённом решении.',
|
||||
'Выбрать operationId и правило его жизненного цикла.',
|
||||
'Добавить attempt, method, status или errorClass и оставшееся время.',
|
||||
'Разделить decision от причины и ограничить перечисление значений.',
|
||||
'Удалить query-секреты, cookie, тело и персональные поля до записи.',
|
||||
'Проверить наборы GET/503, GET/timeout, POST/503 и успешный первый вызов.',
|
||||
'Считать распределение попыток и долю завершений по deadline, не меняя логику по одному шумному сообщению.',
|
||||
]),
|
||||
h2('Что не следует добавлять для «пользы»'),
|
||||
p('Самые опасные добавления обычно выглядят практично: короткая команда, пример параметра, предполагаемый порядок восстановления, имя будущей роли, ссылка на несуществующий график или фраза «это уже проверяли». Все они сужают интерпретацию так, будто у автора были доступ и наблюдения. В P113 даже пример кода не становится мостом к реальной системе: он работает только с литералом и демонстрирует, что отсутствующие факты не имеют fallback.'),
|
||||
p('Так же опасна ретроспективная уверенность. Выражение «в этом случае» может незаметно звучать как рассказ о случившемся. Здесь точнее говорить «если будущий scope определит» и сразу называть границу. Это не бюрократическая оговорка. Она сохраняет возможность для будущего исследования прийти к отсутствию проблемы, к иной постановке или к отказу от действия. Хороший hand-off не заставляет следующие evidence подтвердить его заголовок.'),
|
||||
h2('Передача должна оставаться обратимой'),
|
||||
p('Hand-off часто становится необратимым из-за одной лишней детали. Допустим, в нём появляется имя условного компонента. Следующий читатель начинает искать его историю, связывает с ним будущую задачу и уже не замечает, что имя было лишь примером. То же происходит с фразой о «типичном» механизме: она незаметно превращается в обязательный путь. В P113 обратимость охраняется буквально: в fixed literal нет target, идентификатора системы, предполагаемого payload или конфигурационного ключа.'),
|
||||
p('Обратимость нужна не для того, чтобы бесконечно откладывать решение. Она даёт будущему scope право сузить, изменить или закрыть вопрос без переписывания прошлого. Если окажется, что решение не требует новых evidence, hand-off останется корректным. Если окажется, что нужен другой вопрос, старая карточка не будет мешать ему притворной точностью. Если разрешённый материал укажет на отсутствие основания для действия, это также не противоречит P113. План ничего не обещал доказать.'),
|
||||
p('У такой формы есть цена: читатель не получает готовый operational маршрут. Но это честная цена за то, что пакет не содержит скрытого доступа или назначений. В поле особенно легко перепутать скорость передачи и скорость решения. Первая достигается короткой карточкой с фактами; вторая требует, чтобы факты существовали и были пригодны к применению. Когда второй части нет, ускорять её вымыслом означает передать долг следующему человеку под видом помощи.'),
|
||||
p('Проверяемый literal делает эту норму технической, а не только редакторской. Нельзя случайно добавить object с дополнительными данными: evaluator сравнивает вход с известными fixed cases и fail-closed отвергает произвольную форму. Нельзя спрятать улучшенный вывод в строке requested output. Нельзя превратить пустое поле в положительное заключение. Такой контроль не проверяет мир, зато проверяет, что текст о мире не стал сильнее своего источника.'),
|
||||
h2('Связь с официальными источниками без подмены доказательства'),
|
||||
p('RFC 9110 нужен этой статье не для описания запуска. Он помогает не смешивать семантику действия с уровнем связи и напоминает, что условия автоматического повтора имеют границы. RFC 9000 показывает, что у транспорта есть самостоятельные механизмы, но ни один из них не является доказательством прикладного результата. NIST SP 800-160 задаёт официальную рамку для разговора об устойчивости, включая восстановление, однако не выдаёт future hand-off за осуществлённую практику.'),
|
||||
p('Таким образом, источники поддерживают словарь и пределы интерпретации. Они не поддерживают fictional incident materials и не могут подтвердить, что в P113 существует реальный сервис, SLO, след наблюдения или восстановление. Это различие стоит написать прямо: ссылка на норму говорит о том, как точно рассуждать; evidence говорит о том, что произошло. В этой статье evidence отсутствует намеренно.'),
|
||||
h2('Пределы и следующий шаг'),
|
||||
p('Field-материал не создаёт ticket, очередь, owner, runbook, мониторинг, deployment, rollout или запись об incident. Он не проверяет доступность и не делает recovery, не собирает SLO/SLI и не выбирает winner. Его собственный результат не нужно «доводить до продакшена»: <code>productionEffect: not-attempted</code> — это обязательное свойство безопасного evaluator, а не временная задержка скрытой работы.'),
|
||||
p('Следующий шаг узкий: при появлении отдельного авторизованного решения создать новый artefact с самостоятельной временной границей и методикой; он может закончиться также отсутствием результата. Пока такого разрешения нет, корректный hand-off не разрастается. P113 оставляет читателю один честный предмет: synthetic question о надёжности без легенды о прошлом и без обещания будущей операции.'),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('Локальный исполнитель не показывает распределённую доставку логов, часы разных узлов и повтор после потери ответа. Поля события должны быть согласованы между клиентом и сервером, иначе operationId распадётся на несколько имён. Наблюдаемость не делает retry безопасным — она только показывает, что он делает.'),
|
||||
p('Следующий шаг — подключить эти поля к одному безопасному GET и построить отчёт по p95 попыток и доле timeout. Для записи сначала добавьте operation key и проверку состояния. Только после этого увеличивайте число повторов или задержку.'),
|
||||
], refs);
|
||||
|
||||
export const revisions = deepFreeze([practice, mechanism, field]);
|
||||
|
||||
export function verifyRevisionsAgainstFixture() {
|
||||
const fixture = runFixedReliabilityFixture();
|
||||
const articleChecks = revisions.map((item) => {
|
||||
const text = bodyText(item.contentHtml);
|
||||
return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|потер|дорог)/i.test(text.slice(0, 1200)) && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && /2027-07/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text);
|
||||
return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|затрат|потер)/i.test(text.slice(0, 1100)) && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml);
|
||||
});
|
||||
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
|
||||
}
|
||||
|
||||
if (process.argv.includes('--verify-fixture')) {
|
||||
const result = verifyRevisionsAgainstFixture();
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
if (!result.accepted) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');
|
||||
|
||||
Reference in New Issue
Block a user