revise November 2021 load testing articles
Build and deploy / deploy (push) Successful in 14s

This commit is contained in:
2026-07-31 13:18:55 +03:00
parent f6058fbbca
commit d6f5767f2a
7 changed files with 1029 additions and 1 deletions
+2
View File
@@ -41,6 +41,7 @@ import { revisions as july2021Revisions } from '../scripts/upgrade-2021-07.mjs';
import { revisions as august2021Revisions } from '../scripts/upgrade-2021-08.mjs';
import { revisions as september2021Revisions } from '../scripts/upgrade-2021-09.mjs';
import { revisions as october2021Revisions } from '../scripts/upgrade-2021-10.mjs';
import { revisions as november2021Revisions } from '../scripts/upgrade-2021-11.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -87,4 +88,5 @@ export const editorialRevisions = [
...august2021Revisions,
...september2021Revisions,
...october2021Revisions,
...november2021Revisions,
];
@@ -0,0 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 700" role="img" aria-labelledby="title desc">
<title id="title">Диагностический маршрут для signal при нагрузочном тестировании</title>
<desc id="desc">Вертикальный маршрут начинается с error-signal или latency-signal как диагностической метки. Затем проверяет evidence packet, environment drift, пробел сценария и synthetic bottleneck. Каждая карточка содержит обратимое действие. Fixture не измеряет latency.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#486581"/>
</marker>
<style>
.bg { fill: #f8fafc; }
.title { fill: #102a43; font: 700 25px Arial, sans-serif; }
.subtitle { fill: #486581; font: 15px Arial, sans-serif; }
.start { fill: #ebf8ff; stroke: #3182ce; stroke-width: 2; }
.question { fill: #ffffff; stroke: #829ab1; stroke-width: 2; }
.drift { fill: #fffaf0; stroke: #dd6b20; stroke-width: 2; }
.gap { fill: #fff5f5; stroke: #c53030; stroke-width: 2; }
.boundary { fill: #f0fff4; stroke: #2f855a; stroke-width: 2; }
.action { fill: #edf2f7; stroke: #486581; stroke-width: 1.5; }
.label { fill: #102a43; font: 700 17px Arial, sans-serif; }
.small { fill: #334e68; font: 14px Arial, sans-serif; }
.tiny { fill: #486581; font: 13px Arial, sans-serif; }
.arrow { fill: none; stroke: #486581; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect class="bg" width="480" height="700" rx="22"/>
<text class="title" x="30" y="44">Signal не выбирает причину сам</text>
<text class="subtitle" x="30" y="70">Сначала сохранить evidence, затем сузить гипотезу.</text>
<text class="tiny" x="30" y="94">Fixture: latency не collected; нет HTTP, tool, server, network или benchmark.</text>
<rect class="start" x="30" y="118" width="420" height="70" rx="14"/>
<text class="label" x="74" y="148">error-signal / latency-signal</text>
<text class="small" x="94" y="173">диагностическая метка, не verdict</text>
<path class="arrow" d="M 240 188 L 240 218"/>
<rect class="question" x="30" y="224" width="420" height="78" rx="14"/>
<text class="label" x="78" y="254">Evidence packet complete?</text>
<text class="small" x="60" y="280">endpoint · environment · data · profile · stop criterion</text>
<path class="arrow" d="M 240 302 L 240 332"/>
<rect class="drift" x="30" y="338" width="420" height="82" rx="14"/>
<text class="label" x="54" y="368">1. Environment drift?</text>
<text class="small" x="54" y="393">Identity или constraint отличаются от declared envelope.</text>
<text class="tiny" x="54" y="412">Rollback-safe: вернуться к manifest; не менять endpoint.</text>
<path class="arrow" d="M 240 420 L 240 450"/>
<rect class="gap" x="30" y="456" width="420" height="82" rx="14"/>
<text class="label" x="54" y="486">2. Scenario gap?</text>
<text class="small" x="54" y="511">Нет route, data identity, segment или stop criterion.</text>
<text class="tiny" x="54" y="530">Rollback-safe: восстановить карточку и evidence packet.</text>
<path class="arrow" d="M 240 538 L 240 568"/>
<rect class="boundary" x="30" y="574" width="420" height="92" rx="14"/>
<text class="label" x="54" y="604">3. Synthetic boundary?</text>
<text class="small" x="54" y="629">Flag в step и units согласованы с planned slots.</text>
<text class="tiny" x="54" y="650">Rollback-safe: изменить один field; сохранить packet.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 680" role="img" aria-labelledby="title desc">
<title id="title">Как профиль workload превращается в evidence packet</title>
<desc id="desc">Вертикальная схема показывает карточку workload, четыре сегмента и evidence packet с endpoint, средой, данными и критерием остановки. Красная карточка отклоняет общее количество запросов без сценария. Схема не содержит HTTP, генератора или измерений latency.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#486581"/>
</marker>
<marker id="warn-arrow" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#c53030"/>
</marker>
<style>
.bg { fill: #f8fafc; }
.title { fill: #102a43; font: 700 25px Arial, sans-serif; }
.subtitle { fill: #486581; font: 15px Arial, sans-serif; }
.card { fill: #ffffff; stroke: #829ab1; stroke-width: 2; }
.packet { fill: #ebf8ff; stroke: #3182ce; stroke-width: 2; }
.reject-card { fill: #fff5f5; stroke: #c53030; stroke-width: 2; }
.label { fill: #102a43; font: 700 17px Arial, sans-serif; }
.small { fill: #334e68; font: 14px Arial, sans-serif; }
.tiny { fill: #486581; font: 13px Arial, sans-serif; }
.arrow { fill: none; stroke: #486581; stroke-width: 3; marker-end: url(#arrow); }
.warn { fill: none; stroke: #c53030; stroke-width: 3; stroke-dasharray: 8 6; marker-end: url(#warn-arrow); }
.slot { fill: #4299e1; }
.accept { fill: #2f855a; }
.reject { fill: #c53030; }
</style>
</defs>
<rect class="bg" width="480" height="680" rx="22"/>
<text class="title" x="30" y="44">Evidence начинается до tool</text>
<text class="subtitle" x="30" y="70">План и наблюдение остаются рядом.</text>
<rect class="card" x="30" y="108" width="420" height="132" rx="14"/>
<text class="label" x="54" y="140">1. Карточка workload</text>
<text class="small" x="54" y="170">endpoint intent · data setup identity</text>
<text class="small" x="54" y="196">environment + constraint · stop criterion</text>
<text class="tiny" x="54" y="222">GET /fixture/neutral-resource · in-memory only · no HTTP</text>
<path class="arrow" d="M 240 240 L 240 276"/>
<rect class="card" x="30" y="282" width="420" height="150" rx="14"/>
<text class="label" x="54" y="314">2. Профиль: порядок visible</text>
<g transform="translate(54 324)">
<rect class="slot" x="0" y="0" width="18" height="18" rx="3"/>
<text class="small" x="28" y="15">warm-up: slots</text>
<rect class="accept" x="0" y="30" width="18" height="18" rx="3"/>
<text class="small" x="28" y="45">steady: accepted units</text>
<rect class="reject" x="0" y="60" width="18" height="18" rx="3"/>
<text class="small" x="28" y="75">step: flag + rejected units</text>
<rect class="slot" x="0" y="90" width="18" height="18" rx="3"/>
<text class="small" x="28" y="105">recovery: stop criterion can close</text>
</g>
<path class="arrow" d="M 240 432 L 240 476"/>
<rect class="packet" x="30" y="482" width="420" height="126" rx="14"/>
<text class="label" x="54" y="514">3. Evidence packet</text>
<text class="small" x="54" y="542">endpoint + data · environment identity · profile records</text>
<text class="small" x="54" y="568">accepted/rejected · stop state · diagnostic labels</text>
<text class="tiny" x="54" y="592">Synthetic units only: not latency, not throughput.</text>
<rect class="reject-card" x="30" y="626" width="420" height="32" rx="10"/>
<text class="small" x="46" y="647">Total без scenario → отклонить: не workload model.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,90 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 640" role="img" aria-labelledby="title desc">
<title id="title">Учебный профиль нагрузочного тестирования из четырёх сегментов</title>
<desc id="desc">Вертикальная диаграмма показывает warm-up, steady, step и recovery. У каждого сегмента есть planned request slots, accepted и rejected synthetic units. Только step получает synthetic bottleneck flag. Все единицы явно исключены из latency, throughput и RPS.</desc>
<defs>
<style>
.bg { fill: #f8fafc; }
.title { fill: #102a43; font: 700 25px Arial, sans-serif; }
.subtitle { fill: #486581; font: 15px Arial, sans-serif; }
.label { fill: #102a43; font: 700 16px Arial, sans-serif; }
.small { fill: #334e68; font: 14px Arial, sans-serif; }
.tiny { fill: #486581; font: 13px Arial, sans-serif; }
.axis { stroke: #829ab1; stroke-width: 2; }
.grid { stroke: #d9e2ec; stroke-width: 1; }
.card { fill: #ffffff; stroke: #bcccdc; stroke-width: 1.5; }
.accept { fill: #2f855a; }
.reject { fill: #c53030; }
.plan { fill: #4299e1; }
.step { fill: #f6ad55; }
.note { fill: #fffaf0; stroke: #dd6b20; stroke-width: 1.5; }
</style>
</defs>
<rect class="bg" width="480" height="640" rx="22"/>
<text class="title" x="30" y="44">Профиль — порядок, не total</text>
<text class="subtitle" x="30" y="70">In-memory модель: slots не отправляются в сеть.</text>
<line class="axis" x1="42" y1="394" x2="440" y2="394"/>
<line class="axis" x1="42" y1="126" x2="42" y2="394"/>
<line class="grid" x1="42" y1="327" x2="440" y2="327"/>
<line class="grid" x1="42" y1="260" x2="440" y2="260"/>
<line class="grid" x1="42" y1="193" x2="440" y2="193"/>
<text class="tiny" x="15" y="141">slots</text>
<g>
<rect class="card" x="56" y="282" width="76" height="112" rx="9"/>
<rect class="plan" x="66" y="318" width="18" height="46" rx="3"/>
<rect class="accept" x="91" y="318" width="18" height="46" rx="3"/>
<rect class="accept" x="116" y="318" width="8" height="46" rx="3"/>
<text class="label" x="56" y="262">warm-up</text>
<text class="tiny" x="56" y="382">2 slots</text>
</g>
<g>
<rect class="card" x="150" y="238" width="76" height="156" rx="9"/>
<rect class="plan" x="160" y="274" width="16" height="90" rx="3"/>
<rect class="accept" x="182" y="274" width="16" height="90" rx="3"/>
<rect class="accept" x="204" y="274" width="14" height="90" rx="3"/>
<text class="label" x="159" y="218">steady</text>
<text class="tiny" x="160" y="382">3 accepted</text>
</g>
<g>
<rect class="card" x="244" y="128" width="100" height="266" rx="9"/>
<rect class="step" x="254" y="146" width="80" height="34" rx="5"/>
<text class="tiny" x="263" y="167">synthetic flag</text>
<rect class="plan" x="258" y="208" width="14" height="126" rx="3"/>
<rect class="accept" x="278" y="208" width="14" height="126" rx="3"/>
<rect class="accept" x="298" y="208" width="14" height="126" rx="3"/>
<rect class="accept" x="318" y="208" width="14" height="126" rx="3"/>
<rect class="reject" x="258" y="342" width="36" height="22" rx="3"/>
<rect class="reject" x="300" y="342" width="36" height="22" rx="3"/>
<text class="label" x="272" y="108">step</text>
<text class="tiny" x="254" y="382">5 slots · 2 rejected</text>
</g>
<g>
<rect class="card" x="362" y="282" width="76" height="112" rx="9"/>
<rect class="plan" x="372" y="318" width="18" height="46" rx="3"/>
<rect class="accept" x="397" y="318" width="18" height="46" rx="3"/>
<rect class="accept" x="422" y="318" width="8" height="46" rx="3"/>
<text class="label" x="361" y="262">recovery</text>
<text class="tiny" x="372" y="382">2 accepted</text>
</g>
<g transform="translate(30 430)">
<rect class="card" width="420" height="78" rx="12"/>
<rect class="plan" x="18" y="18" width="18" height="18" rx="3"/>
<text class="small" x="45" y="32">planned request slot</text>
<rect class="accept" x="218" y="18" width="18" height="18" rx="3"/>
<text class="small" x="245" y="32">accepted unit</text>
<rect class="reject" x="18" y="48" width="18" height="18" rx="3"/>
<text class="small" x="45" y="62">rejected synthetic unit</text>
</g>
<g transform="translate(30 530)">
<rect class="note" width="420" height="76" rx="12"/>
<text class="label" x="18" y="29">Граница этой диаграммы</text>
<text class="small" x="18" y="53">Synthetic units only: не latency, не throughput,</text>
<text class="small" x="18" y="69">не RPS, не users и не capacity benchmark.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+584
View File
@@ -0,0 +1,584 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
}
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + proseLength);
}
return { ...meta, contentHtml, proseLength };
}
const k6Release035 = {
title: 'Grafana k6 v0.35.0: versioned release record',
url: 'https://github.com/grafana/k6/releases/tag/v0.35.0',
note: 'релиз опубликован 17 ноября 2021 года; он задаёт историческую верхнюю границу для упоминания инструмента и не является результатом прогона этой fixture',
};
const k6Thresholds035 = {
title: 'Grafana k6 v0.35.0: fixed source snapshot samples/thresholds.js',
url: 'https://github.com/grafana/k6/blob/1c44b2d0ecc16cbe788e773a1db019b6cbdad725/samples/thresholds.js',
note: 'точный commit, на который указывает тег v0.35.0; пример связывает порог с именованной метрикой, а пакет не импортирует k6 и не исполняет его',
};
const openTelemetryMetrics100 = {
title: 'OpenTelemetry Specification v1.0.0: fixed Metrics API snapshot',
url: 'https://github.com/open-telemetry/opentelemetry-specification/blob/f228a83e652e5cd3ba96b9f780b704ee7a7daa4c/specification/metrics/api.md',
note: 'снимок от февраля 2021 года помечает Metrics API как experimental и отдельно описывает смысл измерения, instrument и aggregation; fixture не реализует OpenTelemetry',
};
const openTelemetryTrace100 = {
title: 'OpenTelemetry Specification v1.0.0: fixed Trace API snapshot',
url: 'https://github.com/open-telemetry/opentelemetry-specification/blob/f228a83e652e5cd3ba96b9f780b704ee7a7daa4c/specification/trace/api.md',
note: 'исторический документ для терминов trace и span; evidence packet в этой статье является локальным object, а не экспортированной трассой',
};
const rfc2330 = {
title: 'RFC 2330: Framework for IP Performance Metrics',
url: 'https://www.rfc-editor.org/rfc/rfc2330.html',
note: 'нормативная рамка для аккуратного определения измеряемого свойства; RFC относится к IP-метрикам и не превращает учебный endpoint в capacity benchmark',
};
const profileExample = [
'import { runLoadTestingFixture } from "./upgrade-2021-11.mjs";',
'',
'const fixture = runLoadTestingFixture();',
'console.table(fixture.evidencePacket.profile.map((segment) => ({',
' segment: segment.name,',
' plannedSlots: segment.plannedRequestSlots.length,',
' acceptedUnits: segment.acceptedUnits,',
' rejectedUnits: segment.rejectedUnits,',
' syntheticBottleneck: segment.syntheticBottleneckFlag,',
'})));',
'',
'if (!fixture.assertions.stoppedByDeclaredCriterion) {',
' throw new Error("fixture did not reach its declared stop criterion");',
'}',
'',
'// Synthetic units are not latency, throughput, RPS, users or capacity.',
].join('\n');
const observationExample = [
'const fixture = runLoadTestingFixture();',
'const packet = fixture.evidencePacket;',
'',
'console.log(packet.endpoint.path);',
'// /fixture/neutral-resource — intent only; no HTTP call exists in the fixture',
'console.log(packet.environment.explicitConstraint);',
'// at most three accepted synthetic units in one planned segment',
'console.log(packet.stop);',
'// stopped-by-criterion after recovery evidence is present',
'',
'if (!fixture.assertions.badAggregateRejected) {',
' throw new Error("a count without scenario was accepted as a model");',
'}',
].join('\n');
const diagnosisExample = [
'const fixture = runLoadTestingFixture();',
'const diagnosis = fixture.evidencePacket.diagnosticModel;',
'',
'console.table(diagnosis.candidates.map((candidate) => ({',
' candidate: candidate.name,',
' evidenceNeeded: candidate.evidenceNeeded,',
' rollbackSafeAction: candidate.rollbackSafeAction,',
'})));',
'',
'if (diagnosis.observedSignals.latencySignal !== "not-collected-in-fixture") {',
' throw new Error("the training model was accidentally presented as latency");',
'}',
].join('\n');
/**
* Детерминированная учебная модель профиля для одного нейтрального endpoint.
*
* Она работает только с локальными arrays и objects. plannedRequestSlots —
* именованные места плана, acceptedUnits/rejectedUnits — synthetic units
* модели. В ней нет HTTP request, сокета, таймера, clock, load generator,
* базы, сервера, файла, процесса, сети, пользователя, RPS, latency,
* throughput, capacity или real benchmark.
*/
export function runLoadTestingFixture() {
const measurementBoundary = 'synthetic schedule units only; not latency, throughput, RPS, users, capacity or benchmark';
const requiredSequence = Object.freeze(['warm-up', 'steady', 'step', 'recovery']);
const endpoint = Object.freeze({
method: 'GET',
path: '/fixture/neutral-resource',
intent: 'neutral read contract for a training model',
invocation: 'not-performed',
});
const environment = Object.freeze({
id: 'isolated-training-envelope-v1',
execution: 'in-memory-object-evaluation',
explicitConstraint: 'at most three accepted synthetic units in one planned segment',
syntheticAcceptanceLimitUnits: 3,
externalSystem: 'not-present',
driftCheck: 'compare this declared envelope before interpreting a later real observation',
});
const dataSetup = Object.freeze({
dataset: 'fixed-neutral-fixture-record',
mutation: 'not-performed',
identity: 'training-data-v1',
purpose: 'hold request intent and data identity constant across segments',
});
const stopCriterion = Object.freeze({
id: 'recovery-evidence-after-synthetic-bottleneck',
requires: Object.freeze([
'warm-up, steady, step and recovery are recorded in order',
'a synthetic bottleneck flag is visible in the step segment',
'accepted and rejected units reconcile with planned request slots',
'the recovery segment and its evidence are present',
]),
meaning: 'stop the in-memory model after the declared evidence packet is complete',
});
const profilePlan = Object.freeze([
Object.freeze({
name: 'warm-up',
plannedRequestSlots: Object.freeze(['warm-up/1', 'warm-up/2']),
syntheticBottleneckFlag: false,
purpose: 'check that the declared envelope and fixture data are visible before the steady segment',
}),
Object.freeze({
name: 'steady',
plannedRequestSlots: Object.freeze(['steady/1', 'steady/2', 'steady/3']),
syntheticBottleneckFlag: false,
purpose: 'keep one repeatable plan inside the declared synthetic constraint',
}),
Object.freeze({
name: 'step',
plannedRequestSlots: Object.freeze(['step/1', 'step/2', 'step/3', 'step/4', 'step/5']),
syntheticBottleneckFlag: true,
purpose: 'make a deliberately modelled boundary visible without claiming a real bottleneck',
}),
Object.freeze({
name: 'recovery',
plannedRequestSlots: Object.freeze(['recovery/1', 'recovery/2']),
syntheticBottleneckFlag: false,
purpose: 'record the planned return to the small model before the stop criterion is evaluated',
}),
]);
function materializeSegment(segment) {
const plannedRequestSlots = Object.freeze([...segment.plannedRequestSlots]);
const acceptedUnits = Math.min(plannedRequestSlots.length, environment.syntheticAcceptanceLimitUnits);
const rejectedUnits = plannedRequestSlots.length - acceptedUnits;
return Object.freeze({
name: segment.name,
plannedRequestSlots,
acceptedUnits,
rejectedUnits,
syntheticBottleneckFlag: segment.syntheticBottleneckFlag,
purpose: segment.purpose,
measurementBoundary,
endpointInvocation: 'not-performed',
});
}
const profile = Object.freeze(profilePlan.map(materializeSegment));
const segmentNames = profile.map((segment) => segment.name);
const step = profile.find((segment) => segment.name === 'step');
const recovery = profile.find((segment) => segment.name === 'recovery');
const allUnitsReconcile = profile.every((segment) => (
segment.plannedRequestSlots.length === segment.acceptedUnits + segment.rejectedUnits
));
const sequenceIsComplete = JSON.stringify(segmentNames) === JSON.stringify(requiredSequence);
const syntheticBottleneckObserved = step?.syntheticBottleneckFlag === true
&& step.rejectedUnits > 0
&& profile.filter((segment) => segment.syntheticBottleneckFlag).length === 1;
const recoveryRecorded = recovery?.plannedRequestSlots.length > 0
&& recovery.syntheticBottleneckFlag === false;
const stop = Object.freeze({
criterion: stopCriterion.id,
state: sequenceIsComplete && syntheticBottleneckObserved && recoveryRecorded && allUnitsReconcile
? 'stopped-by-criterion'
: 'not-stopped',
evaluatedAfterSegment: segmentNames.at(-1),
reason: 'the fixture never expands the plan beyond its declared recovery evidence',
});
const diagnosticModel = Object.freeze({
observedSignals: Object.freeze({
errorSignal: 'synthetic-rejected-units-visible-in-step',
latencySignal: 'not-collected-in-fixture',
scope: 'diagnostic labels for ordering an investigation, not measured service telemetry',
}),
candidates: Object.freeze([
Object.freeze({
name: 'environment-drift',
evidenceNeeded: 'declared environment identity differs from the environment attached to a later observation',
rollbackSafeAction: 'restore the declared environment manifest before changing application code',
}),
Object.freeze({
name: 'scenario-gap',
evidenceNeeded: 'the plan lacks endpoint intent, data setup, segment order or a stop criterion',
rollbackSafeAction: 'return to the last explicit scenario card; do not compare a bare total with it',
}),
Object.freeze({
name: 'synthetic-bottleneck',
evidenceNeeded: 'the step segment carries the explicit model flag and rejected synthetic units reconcile with slots',
rollbackSafeAction: 'change one training constraint or one planned segment, then rebuild the evidence packet',
}),
]),
selectedForThisFixture: 'synthetic-bottleneck',
verdict: 'training-model-only; no claim about a real latency or error cause',
});
const evidencePacket = Object.freeze({
kind: 'in-memory-load-test-training-evidence',
endpoint,
environment,
dataSetup,
profile,
stopCriterion,
stop,
diagnosticModel,
measurementBoundary,
externalWorkPerformed: false,
});
const badAggregate = Object.freeze({
label: 'many-requests-without-scenario',
declaredSyntheticUnits: 12,
endpoint: null,
environment: null,
dataSetup: null,
profile: Object.freeze([]),
stopCriterion: null,
});
const badAggregateAssessment = Object.freeze({
acceptedAsLoadModel: false,
missing: Object.freeze([
'endpoint intent',
'environment and explicit constraint',
'data setup',
'warm-up, steady, step, recovery sequence',
'stop criterion',
'evidence packet',
]),
reason: 'a total alone cannot explain what changed, what was accepted or why to stop',
});
const assertions = Object.freeze({
segmentSequenceIsWarmupSteadyStepRecovery: sequenceIsComplete,
environmentAndConstraintAreExplicit: environment.execution === 'in-memory-object-evaluation'
&& environment.syntheticAcceptanceLimitUnits === 3
&& environment.explicitConstraint.includes('accepted synthetic units'),
plannedSlotsReconcileWithAcceptedAndRejectedUnits: allUnitsReconcile,
syntheticBottleneckFlagIsVisibleOnlyInStep: syntheticBottleneckObserved,
stoppedByDeclaredCriterion: stop.state === 'stopped-by-criterion'
&& stop.evaluatedAfterSegment === 'recovery'
&& stop.criterion === stopCriterion.id,
syntheticUnitsAreNotLatencyOrThroughput: profile.every((segment) => (
segment.measurementBoundary === measurementBoundary
&& !Object.hasOwn(segment, 'latency')
&& !Object.hasOwn(segment, 'throughput')
&& !Object.hasOwn(segment, 'rps')
)) && diagnosticModel.observedSignals.latencySignal === 'not-collected-in-fixture',
evidencePacketIsComplete: evidencePacket.endpoint.invocation === 'not-performed'
&& evidencePacket.environment.id === 'isolated-training-envelope-v1'
&& evidencePacket.dataSetup.identity === 'training-data-v1'
&& evidencePacket.profile.length === 4
&& evidencePacket.stop.state === 'stopped-by-criterion',
badAggregateRejected: badAggregateAssessment.acceptedAsLoadModel === false
&& badAggregate.profile.length === 0
&& badAggregateAssessment.missing.length === 6,
noExternalWorkWasPerformed: evidencePacket.externalWorkPerformed === false
&& endpoint.invocation === 'not-performed'
&& environment.externalSystem === 'not-present',
});
return Object.freeze({
scenario: Object.freeze({
description: 'one neutral endpoint intent with four planned segments in a deterministic in-memory model',
measurementBoundary,
noNetworkOrBenchmark: true,
}),
evidencePacket,
badAggregate,
badAggregateAssessment,
assertions,
});
}
const commonSources = [
k6Release035,
k6Thresholds035,
openTelemetryMetrics100,
openTelemetryTrace100,
rfc2330,
];
const practiceArticle = createRevision(
{
slug: 'editorial-2021-11-practice-load-testing',
title: 'Нагрузочное тестирование: сначала профиль, среда и критерий остановки',
categories: ['Нагрузка', 'Надёжность'],
cover: '/assets/editorial/2021/load-test-profile-2021.svg',
excerpt: 'Практическая карточка workload до инструмента: один нейтральный endpoint, четыре сегмента профиля, явная среда, подготовка данных и критерий остановки без выдуманного benchmark.',
readingMinutes: 14,
},
[
paragraph('Задача «давайте нагрузим endpoint» выглядит короткой, пока через неделю не приходится объяснять, что именно проверяли. Один человек помнит счётчик запросов, другой — иной набор данных, третий — другую конфигурацию. Цена такой экономии не в плохом графике. Команда получает число без причины: по нему нельзя решить, менять код, окружение или сам сценарий.'),
paragraph('Перед инструментом я бы зафиксировал workload на одной карточке. В этой заметке это учебная in-memory модель для <code>GET /fixture/neutral-resource</code>. Она не отправляет HTTP, не запускает generator, сервер, базу или сеть. planned request slots, accepted units и rejected units здесь — только заданные единицы плана. Они не означают RPS, миллисекунды, пользователей, latency, throughput или реальную ёмкость.'),
heading('Карточка workload отвечает на пять вопросов'),
paragraph('Первый вопрос — какой контракт проверяем. Не «каталог вообще», а нейтральное намерение чтения, метод, путь и ожидаемая граница результата. Второй — кто владеет данными: откуда берётся fixture record, изменяется ли он и как узнать его версию. Третий — где выполняется проверка. Слово «стенд» бесполезно, пока не записаны конфигурация, версия, внешние зависимости и ограничение, которое сценарий обязуется не пересекать молча.'),
paragraph('Четвёртый вопрос — как профиль меняется по сегментам. Warm-up, steady, step и recovery нужны не как красивые английские подписи. У каждого есть роль: до основного шага проверить карточку, удержать повторяемый план, показать заранее смоделированную границу и вернуть модель к малому плану. Пятый вопрос — когда остановиться. Если критерий не объявлен заранее, остановка выбирается после того, как кому-то понравилось или не понравилось число.'),
dataTable(
'Что записать до выбора load tool',
['Поле карточки', 'Пример в учебной fixture', 'Проверка', 'Не является'],
[
['Endpoint intent', '<code>GET /fixture/neutral-resource</code>', 'путь и метод совпадают во всех сегментах', 'HTTP-вызовом или существующим сервисом'],
['Среда', '<code>isolated-training-envelope-v1</code>', 'есть identity, режим и явное ограничение', 'описанием production или capacity'],
['Данные', '<code>training-data-v1</code>', 'fixture record фиксирован, mutation отсутствует', 'копией пользовательских данных'],
['Профиль', 'warm-up → steady → step → recovery', 'последовательность и slots сохранены в packet', 'одним общим количеством запросов'],
['Критерий остановки', 'recovery evidence after synthetic bottleneck', 'модель останавливается только после recovery', 'таймаутом или сигналом настоящей системы'],
],
),
heading('Профиль — это переходы, а не один большой счётчик'),
paragraph('Внутри fixture каждый сегмент хранит массив planned request slots. Это полезнее одного total: можно увидеть, какой слот принадлежит warm-up, какой — step, и где появились rejected synthetic units. При declared limit в три accepted units step содержит пять slots; два из них модель помечает rejected. Такое расхождение не доказывает перегрузку. Оно заранее создано, чтобы увидеть, что evidence packet умеет хранить и план, и результат его учебной границы.'),
paragraph('Steady не обязан быть «нормой», а step не обязан быть «пиком» настоящей нагрузки. Это названия ролей в документе. Если в реальном тесте их смысл другой, карточку переименовывают и рядом объясняют почему. Хуже оставить привычные слова, но не записать вход, data setup и переход. Тогда reader видит форму графика, а автор не может ответить, изменилось ли приложение, данные или только generator.'),
figure(
'/assets/editorial/2021/load-test-profile-2021.svg',
'Схема учебного профиля из четырёх сегментов: warm-up, steady, step и recovery. В каждом показаны planned request slots, accepted и rejected synthetic units; в step отмечен искусственный bottleneck flag. Подпись на схеме прямо исключает RPS, latency и throughput.',
'Профиль — это последовательность намерений и границ модели. Красная отметка в step означает только заданное условие fixture, а не найденное узкое место.',
),
heading('Среда и data setup — часть результата'),
paragraph('Среда в карточке — не колонка «dev» или «stage». Нужны identity и ограничение: например, «in-memory evaluation, внешней системы нет, за один segment принимается не больше трёх synthetic units». Такое условие не похоже на реальный лимит, и это хорошо: его нельзя случайно перенести в конфигурацию. Оно заставляет автора назвать, что именно сравнивается, и запрещает потом объяснять результат неизвестным сервером.'),
paragraph('Data setup тоже не прячется за словом «тестовые данные». Для операции чтения достаточно назвать fixture record, его версию и правило неизменности. Для операции изменения понадобились бы начальное состояние, очистка, idempotency и проверка результата, но их нельзя домыслить к нейтральному чтению. Если данные меняются между сегментами, это отдельная гипотеза и отдельный evidence packet, а не параметр, затерянный в shell history.'),
heading('Критерий остановки защищает расследование'),
paragraph('В учебной модели stop criterion требует четыре факта: сегменты прошли по порядку, только step получил synthetic bottleneck flag, accepted плюс rejected units равны количеству slots, а recovery записан в packet. До этого состояния модель не называет себя завершённой. В реальном инструменте критерий может быть другим, но ему всё равно нужна наблюдаемая формулировка: какая граница остановит сценарий, какие данные будут сохранены и кто решит, что запуск повторять нельзя.'),
paragraph('Критерий не заменяет проверку качества. Он отделяет два вопроса. Сначала: «достаточно ли evidence, чтобы прочитать именно этот сценарий?» Потом: «соответствует ли наблюдение договорённости?» Если первый вопрос не закрыт, нельзя честно переходить ко второму. Поэтому bare counter «много запросов» в fixture отклонён: у него нет endpoint intent, среды, данных, последовательности, criterion и packet.'),
heading('Минимальный пример: прочитать модель, не запускать benchmark'),
paragraph('Ниже пример не обращается к адресу из endpoint intent. Он импортирует функцию из revision-модуля, выводит already-materialized objects и проверяет объявленный criterion. Такой пример нужен для редакционной проверки: любой change в profile, который спутал порядок сегментов или назвал units latency, должен разрушить assertion до интеграции статьи.'),
codeBlock(profileExample),
paragraph('Важная деталь — comment в конце. Она не декоративная: exact boundary повторяется в объекте каждого segment и в assertions. Если в будущем кто-то добавит поле <code>latency</code>, <code>throughput</code> или <code>rps</code> в эту модель, assertion станет false. Для реального измерения лучше создать другой артефакт с источником времени, версией инструмента и условиями запуска, чем тихо расширить учебную fixture.'),
heading('Evidence packet должен пережить разговор через неделю'),
paragraph('После описания профиля сохраняем не только результат. Packet несёт endpoint intent, environment, data setup, profile records, stop criterion, stop state и diagnostic labels. Этого достаточно, чтобы коллега проверил порядок размышления: почему step виден отдельно, почему recovery обязателен и почему rejected units нельзя выдать за error rate. Это ещё не raw result инструмента. Но это минимальный договор, без которого raw result тоже быстро теряет смысл.'),
paragraph('Историческая рамка здесь намеренно скромная. В релизе k6 <code>v0.35.0</code> от 17 ноября 2021 года есть работа с stage tags и сценариями; sample того же тега показывает пороги, связанные с именованными метриками. Из этого следует только практическое правило: инструмент и метрика должны быть названы вместе. Fixture не использует k6 и не делает вывод о его executor semantics. OpenTelemetry <code>v1.0.0</code> полезен как напоминание, что измерению дают явный смысл; в том снимке Metrics API всё ещё experimental.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> В обсуждении есть лишь «много запросов» или один total, а по нему уже предлагают менять endpoint.',
'<strong>Причина.</strong> Workload не отделён от среды, data setup и условия завершения; разные запуски невозможно сравнить по одному договору.',
'<strong>Проверка.</strong> Выпишите endpoint intent, среду с ограничением, версию данных, четыре сегмента и criterion. Проверьте, что planned slots раскладываются на accepted/rejected units.',
'<strong>Действие.</strong> Сначала исправьте карточку и сохраните evidence packet. Инструмент выбирайте после того, как стало понятно, какую реальную величину и на какой версии среды он должен собирать.',
'<strong>Повторная проверка.</strong> Если изменились среда или данные, дайте новому запуску новую identity. Не сравнивайте его с прежним только по форме графика.',
'<strong>Ожидаемый результат.</strong> У команды остаётся сценарий, который можно прочитать и воспроизвести как договорённость, не выдавая учебные units за benchmark.',
]),
heading('Ограничения и следующий проверяемый шаг'),
paragraph('Эта fixture не моделирует HTTP status, DNS, TLS, соединения, очередь процесса, scheduler, clock, нагрузочный generator, database, cache, данные пользователя, concurrency, latency, throughput, error rate, capacity, пользователей или production incident. Rejected unit означает только, что planned slot не прошёл заданный synthetic limit. Он не говорит, почему реальная система могла бы вернуть ошибку и не обещает, что реальная среда выдержит иной сценарий.'),
paragraph('Следующий проверяемый шаг в своём проекте — не копировать числа из модели, а оформить такую же карточку для одного настоящего endpoint: выбрать инструмент версии, существовавшей на дату работы, записать его конфигурацию, среду, data setup, вход, критерий остановки и место хранения raw output. Лишь после этого имеет смысл запускать отдельный реальный тест. Учебная модель помогает не потерять вопрос до такого запуска, но не заменяет его.'),
],
commonSources,
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2021-11-mechanism-load-testing',
title: 'Почему «много запросов» не является нагрузочной моделью',
categories: ['Нагрузка', 'Надёжность'],
cover: '/assets/editorial/2021/load-test-observation-2021.svg',
excerpt: 'Разбор механизма workload: arrival intent, профиль сегментов, evidence packet и synthetic bottleneck. Почему aggregate без среды и stop criterion не объясняет ни причину, ни следующее действие.',
readingMinutes: 14,
},
[
paragraph('Проблема фразы «мы дали много запросов» в том, что она описывает объём шума, но не модель нагрузки. В ней не видно, когда возникал input, какой endpoint он представлял, какой набор данных использовался, что считалось завершением и кто наблюдал последствия. Цена — ложная причинность: aggregate меняется, а команда приписывает его очереди, базе или коду, хотя могла измениться сама среда или сценарий.'),
paragraph('Для механизма достаточно одной строгой границы. В этой статье arrival означает намерение поставить planned request slot в сегмент профиля. Это не отправка запроса и не скорость. Fixture материализует slots в accepted/rejected synthetic units внутри local objects. Она не создаёт clock, HTTP, сеть, процесс, нагрузочный инструмент или telemetry. Поэтому слова latency-signal и error-signal ниже — диагностические ярлыки, а не измеренные метрики.'),
heading('Модель начинается с формы входа'),
paragraph('Полезная запись выглядит так: endpoint intent + data setup + environment constraint + ordered segments + stop criterion + evidence packet. Каждый элемент отвечает на отдельный вопрос. Endpoint ограничивает предмет. Data setup не даёт одному и тому же имени скрывать разные записи. Environment объясняет, при каком договоре существует наблюдение. Segments показывают переход. Criterion определяет, когда доказательство достаточно. Packet держит все поля рядом, чтобы вывод не зависел от памяти автора.'),
paragraph('Если убрать любой элемент, получаем другой тип неопределённости. Без endpoint нельзя отличить чтение от изменения. Без данных нельзя повторить branch. Без среды невозможно увидеть drift. Без сегментов total не показывает порядок. Без criterion нельзя понять, почему модель закончилась именно здесь. Без packet остаётся пересказ, который невозможно проверить. Это не бюрократия вокруг теста: это минимальная структура причинной связи.'),
dataTable(
'Профиль и плохой aggregate отвечают на разные вопросы',
['Свойство', 'Профиль fixture', '«Много запросов без сценария»', 'Инженерское последствие'],
[
['Endpoint intent', 'один нейтральный путь и метод', 'не указан', 'нельзя проверить контракт входа'],
['Arrival intent', 'slots принадлежат named segment', 'есть только total', 'нельзя увидеть переходы'],
['Среда', 'identity и synthetic constraint записаны', 'не указана', 'любой drift маскируется под результат'],
['Accepted / rejected', 'разложены по segment', 'не определены', 'неясно, что именно не прошло'],
['Stop criterion', 'evidence после recovery', 'нет', 'конец наблюдения произволен'],
['Interpretation', 'только training model', 'обычно звучит как verdict', 'риск выдать счётчик за throughput'],
],
),
heading('Arrival не равен completed work'),
paragraph('В реальном инструменте способ моделировать arrival зависит от executor, версии и конфигурации. Нельзя переносить значение одного параметра между tool без чтения его документации. В k6 <code>v0.35.0</code> release notes отдельно связывают stage tags с конкретными executors; это исторический факт о версии, а не лицензия назвать любой массив slots её сценарием. Наша fixture специально не повторяет API инструмента. Она показывает только вопрос, который нужно сформулировать до выбора API: что означает появление следующего planned slot и как эта попытка будет отделена от результата обработки.'),
paragraph('Разделение полезно и для закрытой модели пользователей, и для открытой модели arrival. В первом случае нужно назвать, кто ждёт завершения предыдущей итерации. Во втором — как tool ведёт себя при невозможности начать следующую работу. Но оба случая остаются неполными без data setup и environment. Число на оси не заменяет эту информацию. Поэтому article не предлагает универсальный executor и не выдаёт synthetic acceptance limit за реальную настройку генератора.'),
heading('Профиль хранит намерение и результат рядом'),
paragraph('У каждого segment fixture есть именованные slots, accepted units, rejected units, synthetic bottleneck flag и boundary text. Наличие flag не превращает rejection в ошибку приложения. Это самопроверка модели: step был объявлен как учебная граница до materialization, а не задним числом назван узким местом после просмотра результата. Такая разница особенно важна в текстах про производительность, где красивый график часто даёт больше уверенности, чем его исходные условия.'),
paragraph('Проверка reconciliation предельно простая: длина <code>plannedRequestSlots</code> должна равняться <code>acceptedUnits + rejectedUnits</code>. Она не измеряет полезную работу и не заменяет server-side evidence. Зато она ловит редакционную ошибку: если часть plan исчезла между профилем и таблицей, автор больше не может честно сказать, что сравнил одно и то же. Именно такие мелкие несовпадения потом превращают нагрузочную заметку в набор несвязанных сигналов.'),
heading('Observability начинается с семантики данных'),
paragraph('Наблюдаемость здесь не означает автоматически подключённый dashboard. Сначала нужны хорошо названные поля: endpoint intent, environment identity, data identity, segment name, planned slots, accepted/rejected units и stop state. Затем выбирается настоящий инструмент, который умеет сохранить требуемый raw signal и его контекст. OpenTelemetry Metrics API в historical tag <code>v1.0.0</code> подчёркивает, что instrument задаёт смысл measurement, а не только форму числа. При этом документ помечен experimental; он не может быть основанием приписать старой системе готовую телеметрию.'),
paragraph('Evidence packet полезен потому, что связывает две шкалы: plan и observation. План отвечает, что хотели проверить. Observation отвечает, какие учебные units были materialized. Если packet не содержит environment или criterion, визуализация всё равно может существовать, но reader уже не знает, какую именно гипотезу она проверяет. Поэтому packet не экспортируется как trace и не изображает работу настоящего мониторинга: это object для детерминированной проверки редакционной модели.'),
figure(
'/assets/editorial/2021/load-test-observation-2021.svg',
'Схема наблюдения учебного workload: сценарий с endpoint, средой, данными и stop criterion проходит через четыре сегмента в evidence packet. Отдельная красная ветка показывает, что один total «много запросов» отклоняется как не-модель. Подписи отделяют synthetic units от latency и throughput.',
'Слева остаётся замысел workload, справа — packet для проверки. Между ними нет HTTP-клиента, генератора, сервера или telemetry pipeline.',
),
heading('Synthetic bottleneck нужен для отрицательной проверки'),
paragraph('Step в fixture получил пять slots при явном limit в три accepted synthetic units. Модель оставляет два rejected units и поднимает единственный <code>syntheticBottleneckFlag</code>. Это намеренно скучный результат. Его задача — проверить четыре свойства: rejected units видны, flag находится в правильном segment, recovery идёт после step, stop criterion не срабатывает раньше. Если бы модель всегда принимала всё, она не проверяла бы путь, в котором author обязан объяснить границу.'),
paragraph('Неправильный вывод звучал бы так: «мы нашли bottleneck и latency выросла». У fixture нет времени, сервера или наблюдаемой очереди, поэтому такой вывод нельзя получить. Правильный вывод уже: «план содержит заранее отмеченную synthetic boundary; для реального расследования нужны environment manifest, raw output выбранного tool и отдельный источник latency-signal». Скромная формулировка оставляет место для следующего эксперимента, а не подменяет его.'),
heading('Минимальный пример: проверить packet и отклонить bare count'),
paragraph('Этот пример читает mechanism, а не запускает testing software. Он выводит путь как intent, явное ограничение среды и stop object. Последняя assertion возвращает false для aggregate, где есть только synthetic total. Так мы проверяем не способность «создать много», а способность не называть нагрузочной моделью то, что не содержит сценария.'),
codeBlock(observationExample),
paragraph('В коде нет <code>fetch</code>, <code>http</code>, <code>setTimeout</code>, файла или child process. Это не ограничение языка и не рекомендация для production. Это защита смысла fixture: добавление реального вызова сделало бы её зависимой от внешнего состояния и позволило бы принять случайный ответ за доказательство. Реальный инструмент нужно запускать отдельной операцией с отдельным пакетом условий, а не прятать в редакционный self-check.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> В отчёте есть aggregate или один график, но невозможно назвать endpoint, среду, data setup и момент остановки.',
'<strong>Причина.</strong> Total принят за workload model; intention, completed work и наблюдение смешаны в одном числе.',
'<strong>Проверка.</strong> Разделите карточку на endpoint, данные, environment, named segments, accepted/rejected units и criterion. Проверьте порядок warm-up → steady → step → recovery.',
'<strong>Проверка смысла.</strong> Для каждого поля назовите единицу и запрет. Если unit не имеет времени, не называйте его latency, throughput или RPS.',
'<strong>Действие.</strong> Отклоните bare count, пока в нём нет scenario и evidence packet. Затем выберите historical version конкретного tool и сверяйте его semantics с его документацией.',
'<strong>Ожидаемый результат.</strong> Следующая диаграмма объясняет не только, что было нарисовано, но и какой вопрос она вправе помогать расследовать.',
]),
heading('Ограничения и следующий проверяемый шаг'),
paragraph('Fixture не сообщает, как ведут себя executors k6, как рассчитываются thresholds, как именно агрегирует метрики OpenTelemetry или какие свойства имеет конкретный server. Ссылки на k6 <code>v0.35.0</code> и его <code>samples/thresholds.js</code> зафиксированы, чтобы не ссылаться на mutable current documentation. Они нужны только для historical boundary и для требования называть metric вместе с порогом. Пакет не исполняет k6, не создаёт VU и не использует его API.'),
paragraph('Не моделируются HTTP, сеть, TLS, DNS, scheduler, queue, CPU, память процесса, база, cache, browser, пользователь, latency, throughput, error rate, capacity, production incident или результат benchmark. RFC 2330 добавлен как внешняя рамка аккуратного определения метрик, но он относится к IP performance metrics и не определяет готовность application endpoint. Следующий шаг — описать один реальный tool-run отдельно, не смешивая его raw output с объектами этой учебной модели.'),
],
commonSources,
);
const fieldArticle = createRevision(
{
slug: 'editorial-2021-11-field-load-testing',
title: 'Разбор signal при нагрузке: среда, сценарий или узкое место',
categories: ['Нагрузка', 'Надёжность'],
cover: '/assets/editorial/2021/load-test-diagnosis-2021.svg',
excerpt: 'Полевой маршрут для error-signal и latency-signal без выдуманного инцидента: собрать evidence, отличить environment drift, пробел сценария и synthetic boundary, затем выбрать обратимое действие.',
readingMinutes: 15,
},
[
paragraph('После теста часто появляется короткое сообщение: «ошибок стало больше» или «latency ухудшилась». Сам сигнал ещё не говорит, где причина. Цена поспешного диагноза — поменять лимит, базу или код на основании наблюдения, которое пришло из другой среды, другого сценария или вообще не было измерением. Сначала нужно сохранить то, что именно видел автор, а не выбирать виновника по знакомому слову.'),
paragraph('Здесь error-signal и latency-signal — только диагностическая модель. Fixture показывает synthetic rejected units в step и прямо хранит <code>latencySignal: not-collected-in-fixture</code>. В ней нет clock, HTTP, server, load tool, сети, базы, telemetry или production incident. Поэтому она не может сказать, что latency росла, а rejection не равен реальной ошибке. Её смысл — разложить проверку причин до того, как появятся данные настоящего запуска.'),
heading('Сначала сохраняем evidence, потом меняем систему'),
paragraph('Минимальный evidence packet состоит из endpoint intent, environment identity, data setup, профиля, stop criterion, записи accepted/rejected units и diagnostic card. Это не бюллетень для руководителя и не dashboard. Это порядок чтения фактов. Если signal нельзя привязать к endpoint и environment, он ещё не готов для вывода о приложении. Если неизвестен data setup, нельзя отличить изменение входа от изменения реализации. Если отсутствует criterion, нельзя доказать, что наблюдение завершилось по тому же правилу, по которому началось.'),
paragraph('Перед изменением полезно добавить два отрицательных вопроса. Что могло поменяться вне кода? И что в самом сценарии может создавать видимость проблемы? Первый вопрос ведёт к environment drift: иной build, настройка, зависимость или manifest. Второй — к scenario gap: total без сегментов, другой endpoint intent, данные без identity или пропущенный recovery. Только после этих проверок можно обсуждать локальную границу, и даже тогда фиксировать её как hypothesis, а не как уже найденный bottleneck.'),
dataTable(
'Как не перепутать три причины диагностического signal',
['Кандидат', 'Что увидеть в evidence', 'Чего пока нет', 'Rollback-safe действие'],
[
['Environment drift', 'identity среды не совпадает с declared envelope', 'доказательства проблемы приложения', 'вернуть сравнение к зафиксированному manifest; не менять endpoint'],
['Scenario gap', 'нет сегмента, data identity, criterion или endpoint intent', 'сопоставимого workload', 'восстановить последнюю полную карточку и повторить чтение packet'],
['Synthetic bottleneck', 'flag в step и reconciliation slots с units', 'доказательства очереди или latency реального сервиса', 'менять только один model constraint или segment, затем собрать packet заново'],
['Неизвестная реальная причина', 'есть raw signal, но не хватает context', 'достаточной связи между source и effect', 'остановить широкий change и назначить один измеримый следующий шаг'],
],
),
heading('Environment drift: сравниваем договор, а не название стенда'),
paragraph('Environment drift не надо сводить к «стенд сломан». Сначала сравнивают identity, configuration boundary и присутствие внешних систем с тем, что записано в запуске. В fixture среда называется <code>isolated-training-envelope-v1</code>, а external system помечена <code>not-present</code>. Если кто-то позже читает packet как результат серверного запроса, уже есть явное противоречие: такая интерпретация нарушает договор модели.'),
paragraph('Безопасное действие здесь обычно скучное: остановить изменение приложения и вернуть исследование к declared manifest. Это rollback-safe, потому что меняется не рабочая система, а вывод о ней. Нельзя чинить connection pool или добавлять cache, пока не доказано, что наблюдение пришло из той среды, для которой эти действия вообще имеют смысл. Такой шаг не решает настоящую проблему, но он убирает ложную ветку расследования.'),
heading('Scenario gap: total не заменяет путь пользователя'),
paragraph('Если packet содержит только total, ситуация ещё хуже: нельзя выяснить, был ли warm-up, как выглядел steady, что именно менялось на step и был ли recovery. Агрегат может совпасть с другим запуском случайно, но это не делает их сравнимыми. Правильная реакция — не усреднять ещё сильнее, а восстановить пропавшую структуру: endpoint intent, data setup, segment order, planned slots, правило accepted/rejected и criterion остановки.'),
paragraph('В fixture bad aggregate называется <code>many-requests-without-scenario</code> и сразу получает verdict <code>acceptedAsLoadModel: false</code>. Это не осуждение коротких тестов. Коротким может быть и корректный сценарий, если у него ясная граница. Отклоняется другое: попытка назвать диагностикой число, для которого невозможно рассказать, откуда оно взялось и какое действие оно вправе изменить.'),
heading('Synthetic bottleneck: видим границу, не придумываем причину'),
paragraph('Step fixture содержит больше planned slots, чем разрешает declared synthetic constraint. Поэтому два units отмечены rejected, а flag включён только у этого segment. Это достаточное evidence, чтобы проверить согласованность модели: slots не исчезли, rejection не попал в warm-up, recovery записан после step. Но этого недостаточно, чтобы говорить о thread pool, блокировке, network saturation или ответе базы. Все эти слова относятся к системам, которых здесь нет.'),
paragraph('Полезный вопрос после flag звучит так: «какая реальная граница могла бы соответствовать этой гипотезе и каким независимым сигналом её проверить?» Ответ должен быть один. Например, сначала зафиксировать environment и вход, затем выбрать инструмент и raw signal, затем проверить конкретную зависимость. Не надо одновременно менять retry, таймаут, SQL и число генераторов. Широкое изменение уничтожит связь между гипотезой и результатом, даже если итоговое число станет приятнее.'),
figure(
'/assets/editorial/2021/load-test-diagnosis-2021.svg',
'Диагностическое дерево для signal при нагрузке: сначала сохранить evidence packet, затем проверить environment drift, completeness сценария и synthetic bottleneck flag. Каждая ветка ведёт к обратимому действию; отдельная подпись предупреждает, что latency-signal в fixture не измеряется.',
'Дерево не выбирает виновника автоматически. Оно уменьшает область поиска и запрещает переход от учебной метки к claim о production-причине.',
),
heading('Минимальный JS: получить кандидатов, не выполнить изменение'),
paragraph('Пример ниже выводит кандидаты из local diagnostic card. Он не собирает новую телеметрию и не запускает correction. Это удобно для review: читатель видит, какое evidence потребуется для каждой ветки и какое действие можно откатить без вмешательства в неизвестную инфраструктуру. Проверка latency-signal в конце намеренно требует <code>not-collected-in-fixture</code>.'),
codeBlock(diagnosisExample),
paragraph('Rollback-safe здесь означает обратимость расследования. Для drift мы возвращаемся к declared manifest. Для scenario gap — к последней полной карточке. Для synthetic boundary — к одному изменённому model constraint или segment. Все три действия сохраняют original packet и не требуют менять runtime системы. Если настоящая среда уже изменилась, её откат — отдельная операция с owner, правами и планом; из учебного object такой команды не получить.'),
heading('Как выбрать одно действие после классификации'),
paragraph('Если environment identity различается, следующее действие — зафиксировать разницу и не сравнивать numbers между envelope. Если отсутствует сценарий, действие — восстановить route и проверить assertions, не запускать ещё один «большой» total. Если synthetic flag единственный, порядок segments корректен, а slots reconcile, можно изменить одну переменную модели и посмотреть, остаётся ли packet читаемым. Если ни одна ветка не подтверждена, verdict должен остаться «недостаточно evidence». Это нормальный результат расследования, а не провал.'),
paragraph('После появления настоящего raw output порядок не меняется. Нужно добавить точную версию инструмента, command/configuration, окружение, data setup, окно наблюдения и способ связать signal с endpoint. И только потом сопоставлять observed error или latency с конкретной гипотезой. Документация k6 <code>v0.35.0</code> и historical OpenTelemetry tag помогают не спутать название инструмента со свойством модели, но ни один из этих источников не содержит результат вашего endpoint.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Появился error-signal или latency-signal, но его origin, среда или сценарий не приложены к сообщению.',
'<strong>Причина-гипотеза.</strong> Сначала разделите environment drift, scenario gap и явную synthetic boundary. Не называйте реальный bottleneck до независимого evidence.',
'<strong>Проверка среды.</strong> Сверьте environment identity и constraint с declared packet. При несовпадении остановите вывод о коде.',
'<strong>Проверка сценария.</strong> Проверьте endpoint, data setup, порядок warm-up → steady → step → recovery, criterion и reconciliation slots с units.',
'<strong>Проверка границы.</strong> Убедитесь, что flag находится только в объявленном step и diagnostic model не называет units latency или throughput.',
'<strong>Действие.</strong> Выберите одно обратимое действие для подтверждённой ветки; сохраните исходный packet. Для реальной причины подготовьте отдельный измеримый запуск.',
'<strong>Ожидаемый результат.</strong> Следующий change связан с проверяемой гипотезой, а не с самым тревожным словом на графике.',
]),
heading('Историческая граница и ограничения'),
paragraph('Использованные ссылки намеренно закреплены на версиях до конца ноября 2021 года. k6 <code>v0.35.0</code> опубликован 17 ноября; его sample объясняет, что threshold связывается с выбранной метрикой внутри инструмента. OpenTelemetry <code>v1.0.0</code> Metrics API в historical snapshot имеет experimental status, а Trace API задаёт терминологию trace/span. Эти документы помогают формулировать вопросы к настоящей телеметрии, но не делают packet реализацией k6 или OpenTelemetry.'),
paragraph('Не было реального прогона, latency, RPS, error rate, пользователей, capacity, production incident или raw result. Fixture не выполняет HTTP, сеть, tool, server, database, browser, clock, filesystem или process. Synthetic rejected units не равны HTTP error; latency-signal зафиксирован как not collected. Следующий проверяемый шаг — подготовить отдельный real-test protocol с владельцем, разрешённой средой, rollback plan и способом хранить raw evidence. До него честнее оставить verdict узким: модель проверила собственный порядок, не систему.'),
],
commonSources,
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
.map(({ proseLength, ...revision }) => revision);
const isMainModule = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
} else if (process.argv.includes('--verify-fixture')) {
const fixture = runLoadTestingFixture();
if (!Object.values(fixture.assertions).every(Boolean)) {
throw new Error('fixture assertions must all be true');
}
process.stdout.write(JSON.stringify(fixture, null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2021-11.mjs --print-revisions | --verify-fixture\n');
}
}