revise July 2021 search indexing articles
Build and deploy / deploy (push) Successful in 15s

This commit is contained in:
2026-07-31 12:57:26 +03:00
parent 2da66c5725
commit 2a30c8816c
7 changed files with 1014 additions and 1 deletions
+2
View File
@@ -37,6 +37,7 @@ import { revisions as march2021Revisions } from '../scripts/upgrade-2021-03.mjs'
import { revisions as april2021Revisions } from '../scripts/upgrade-2021-04.mjs';
import { revisions as may2021Revisions } from '../scripts/upgrade-2021-05.mjs';
import { revisions as june2021Revisions } from '../scripts/upgrade-2021-06.mjs';
import { revisions as july2021Revisions } from '../scripts/upgrade-2021-07.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -79,4 +80,5 @@ export const editorialRevisions = [
...april2021Revisions,
...may2021Revisions,
...june2021Revisions,
...july2021Revisions,
];
@@ -0,0 +1,68 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="1540" viewBox="0 0 720 1540" role="img" aria-labelledby="title desc">
<title id="title">Дерево безопасной диагностики сохранённого, но не найденного документа</title>
<desc id="desc">Диагностика начинает с source и version, затем проверяет ingest, pending index, refresh и контракт query. На всех ветках сначала сохраняется evidence, а source не удаляется без подтверждённой причины.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0 0 L12 6 L0 12 Z" fill="#78dce8"/>
</marker>
<style>
.title { font: 700 30px system-ui, sans-serif; fill: #f8fafc; }
.subtitle { font: 400 20px system-ui, sans-serif; fill: #b6c4d6; }
.question { font: 700 24px system-ui, sans-serif; fill: #f8fafc; }
.body { font: 400 20px system-ui, sans-serif; fill: #d7e1ee; }
.small { font: 600 18px system-ui, sans-serif; fill: #d7e1ee; }
.yes { font: 700 19px system-ui, sans-serif; fill: #66d9a0; }
.no { font: 700 19px system-ui, sans-serif; fill: #ffc857; }
</style>
</defs>
<rect width="720" height="1540" fill="#0b1220"/>
<rect x="24" y="24" width="672" height="82" rx="18" fill="#121d31" stroke="#273755" stroke-width="2"/>
<text x="48" y="58" class="title">Документ сохранён, но query пуст</text>
<text x="48" y="86" class="subtitle">сначала evidence, затем обратимое действие</text>
<rect x="54" y="140" width="612" height="124" rx="18" fill="#172842" stroke="#4a85c5" stroke-width="3"/>
<text x="86" y="184" class="question">0. Записать evidence</text>
<text x="86" y="216" class="body">id · source version · event key · query · время</text>
<text x="86" y="245" class="small">не удалять source до ответа на следующий вопрос</text>
<path d="M360 276 L360 319" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="54" y="333" width="612" height="107" rx="18" fill="#172842" stroke="#7a6ff0" stroke-width="3"/>
<text x="86" y="376" class="question">1. Source существует по id?</text>
<text x="86" y="408" class="body">и version совпадает с ожидаемой?</text>
<path d="M360 452 L360 495" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<text x="382" y="480" class="yes">да</text>
<rect x="54" y="509" width="612" height="112" rx="18" fill="#172842" stroke="#7a6ff0" stroke-width="3"/>
<text x="86" y="553" class="question">2. Есть ingest key id:version?</text>
<text x="86" y="585" class="body">повтор должен быть различим как duplicate</text>
<path d="M360 633 L360 676" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<text x="382" y="661" class="yes">да</text>
<rect x="54" y="690" width="612" height="112" rx="18" fill="#2d2530" stroke="#ffc857" stroke-width="3"/>
<text x="86" y="734" class="question">3. Version лежит в pending index?</text>
<text x="86" y="766" class="body">query может быть stale до перехода видимости</text>
<path d="M360 814 L360 857" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<text x="382" y="842" class="yes">да</text>
<rect x="54" y="871" width="612" height="134" rx="18" fill="#2d2530" stroke="#ffc857" stroke-width="3"/>
<text x="86" y="914" class="question">Действие: awaiting-refresh</text>
<text x="86" y="946" class="body">измерить gap и применить project policy</text>
<text x="86" y="976" class="small">controlled local test допустим, delete source — нет</text>
<path d="M360 1017 L360 1060" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="54" y="1074" width="612" height="112" rx="18" fill="#173b3a" stroke="#66d9a0" stroke-width="3"/>
<text x="86" y="1118" class="question">4. Visible version актуальна?</text>
<text x="86" y="1150" class="body">source.version равна visible.version?</text>
<path d="M360 1198 L360 1241" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<text x="382" y="1226" class="yes">да</text>
<rect x="54" y="1255" width="612" height="140" rx="18" fill="#173b3a" stroke="#66d9a0" stroke-width="3"/>
<text x="86" y="1298" class="question">5. Проверить контракт query</text>
<text x="86" y="1330" class="body">scope · filter · поле · анализ текста · права</text>
<text x="86" y="1361" class="small">точечная правка и повтор исходного query</text>
<rect x="54" y="1425" width="612" height="94" rx="16" fill="#3a2730" stroke="#f06b8a" stroke-width="3"/>
<text x="86" y="1457" class="question">На любой ветке «нет»</text>
<text x="86" y="1486" class="body">сохранить evidence; source не удалять</text>
<text x="86" y="1514" class="body">не создавать копию до подтверждения причины</text>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,59 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="1420" viewBox="0 0 720 1420" role="img" aria-labelledby="title desc">
<title id="title">Учебная временная шкала задержки между сохранением и видимостью поиска</title>
<desc id="desc">Диаграмма показывает четыре условные точки времени: source сохранён на 100, ingest поставлен на 110, pending index подготовлен на 125, refresh даёт видимость на 160. Наблюдаемая разница 60 условных миллисекунд не является SLA.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0 0 L12 6 L0 12 Z" fill="#78dce8"/>
</marker>
<style>
.title { font: 700 31px system-ui, sans-serif; fill: #f8fafc; }
.subtitle { font: 400 20px system-ui, sans-serif; fill: #b6c4d6; }
.card-title { font: 700 25px system-ui, sans-serif; fill: #f8fafc; }
.body { font: 400 21px system-ui, sans-serif; fill: #d7e1ee; }
.small { font: 600 19px system-ui, sans-serif; fill: #d7e1ee; }
.label { font: 700 21px system-ui, sans-serif; fill: #0b1220; }
</style>
</defs>
<rect width="720" height="1420" fill="#0b1220"/>
<rect x="24" y="24" width="672" height="82" rx="18" fill="#121d31" stroke="#273755" stroke-width="2"/>
<text x="48" y="59" class="title">Как разложить наблюдаемую задержку</text>
<text x="48" y="87" class="subtitle">четыре учебные точки времени, не обещание SLA</text>
<rect x="54" y="137" width="612" height="94" rx="16" fill="#283d5d"/>
<text x="84" y="176" class="card-title">100 · savedAtMs</text>
<text x="84" y="207" class="body">доменная version 7 сохранена</text>
<path d="M360 243 L360 286" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="54" y="300" width="612" height="94" rx="16" fill="#433a75"/>
<text x="84" y="339" class="card-title">110 · enqueuedAtMs</text>
<text x="84" y="370" class="body">один key id:version ждёт ingest</text>
<path d="M360 406 L360 449" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="54" y="463" width="612" height="94" rx="16" fill="#624d2a"/>
<text x="84" y="502" class="card-title">125 · indexedAtMs</text>
<text x="84" y="533" class="body">pending index готов, query ещё stale</text>
<rect x="74" y="594" width="572" height="162" rx="18" fill="#2d2530" stroke="#ffc857" stroke-width="3"/>
<text x="104" y="636" class="card-title">Промежуток ожидания видимости</text>
<text x="104" y="670" class="body">query «свежести» → 0 попаданий</text>
<text x="104" y="701" class="body">диагноз: awaiting-refresh</text>
<text x="104" y="732" class="small">собрать evidence, не удалять source</text>
<path d="M360 768 L360 811" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="54" y="825" width="612" height="94" rx="16" fill="#245449"/>
<text x="84" y="864" class="card-title">160 · visibleAtMs</text>
<text x="84" y="895" class="body">refresh делает version 7 видимой</text>
<path d="M360 931 L360 974" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="54" y="988" width="612" height="121" rx="16" fill="#1b4238" stroke="#66d9a0" stroke-width="3"/>
<text x="84" y="1029" class="card-title">Query после refresh</text>
<text x="84" y="1061" class="body">1 hit · current version 7</text>
<text x="84" y="1091" class="small">дальше проверяем именно контракт query</text>
<rect x="54" y="1150" width="612" height="199" rx="18" fill="#142033" stroke="#4a85c5" stroke-width="3"/>
<text x="84" y="1194" class="card-title">Как читать этот бюджет</text>
<text x="84" y="1230" class="body">visibleAtMs − savedAtMs = 60</text>
<text x="84" y="1261" class="body">60 — вход fixture для проверки арифметики</text>
<text x="84" y="1292" class="body">проект сам выбирает policy и порог реакции</text>
<text x="84" y="1323" class="small">не переносим число в production без измерения</text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,79 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="1480" viewBox="0 0 720 1480" role="img" aria-labelledby="title desc">
<title id="title">Учебный путь документа от source до поисковой выдачи</title>
<desc id="desc">Вертикальная схема показывает сохранение документа version 7, идемпотентную постановку ingest, pending index, stale query до refresh, явный refresh и успешный query после перехода видимости.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0 0 L12 6 L0 12 Z" fill="#78dce8"/>
</marker>
<style>
.title { font: 700 31px system-ui, sans-serif; fill: #f8fafc; }
.subtitle { font: 400 20px system-ui, sans-serif; fill: #b6c4d6; }
.card-title { font: 700 25px system-ui, sans-serif; fill: #f8fafc; }
.body { font: 400 21px system-ui, sans-serif; fill: #d7e1ee; }
.small { font: 600 19px system-ui, sans-serif; fill: #d7e1ee; }
.good { fill: #66d9a0; }
.warn { fill: #ffc857; }
.muted { fill: #a8b6c8; }
</style>
</defs>
<rect width="720" height="1480" fill="#0b1220"/>
<rect x="24" y="24" width="672" height="82" rx="18" fill="#121d31" stroke="#273755" stroke-width="2"/>
<text x="48" y="59" class="title">Путь одного учебного документа</text>
<text x="48" y="87" class="subtitle">version 7 · локальная fixture · не схема кластера</text>
<rect x="54" y="136" width="612" height="158" rx="18" fill="#172842" stroke="#4a85c5" stroke-width="3"/>
<circle cx="91" cy="183" r="20" fill="#4a85c5"/>
<text x="84" y="190" class="small">1</text>
<text x="126" y="179" class="card-title">Source-of-truth</text>
<text x="126" y="213" class="body">id article-2021-07-42</text>
<text x="126" y="244" class="body">version 7 сохранена в модели</text>
<text x="126" y="275" class="small muted">сохранение не равно search visibility</text>
<path d="M360 306 L360 340" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<text x="382" y="329" class="small muted">id + version</text>
<rect x="54" y="358" width="612" height="194" rx="18" fill="#172842" stroke="#7a6ff0" stroke-width="3"/>
<circle cx="91" cy="406" r="20" fill="#7a6ff0"/>
<text x="84" y="413" class="small">2</text>
<text x="126" y="402" class="card-title">Ingest-постановка</text>
<text x="126" y="436" class="body">key: article-2021-07-42:7</text>
<text x="126" y="467" class="body">первый вызов: ingest-enqueued</text>
<text x="126" y="498" class="body">повтор: duplicate-ingest-suppressed</text>
<text x="126" y="529" class="small muted">в модели остаётся одна задача</text>
<path d="M360 564 L360 598" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="54" y="616" width="612" height="183" rx="18" fill="#172842" stroke="#d08c3f" stroke-width="3"/>
<circle cx="91" cy="664" r="20" fill="#d08c3f"/>
<text x="84" y="671" class="small">3</text>
<text x="126" y="660" class="card-title">Pending index</text>
<text x="126" y="694" class="body">event.version сверена с source.version</text>
<text x="126" y="725" class="body">документ подготовлен, но не видим</text>
<text x="126" y="756" class="small warn">query до refresh: 0 попаданий</text>
<path d="M360 811 L360 846" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="54" y="864" width="612" height="136" rx="18" fill="#173b3a" stroke="#3bbd9c" stroke-width="3"/>
<circle cx="91" cy="912" r="20" fill="#3bbd9c"/>
<text x="84" y="919" class="small">4</text>
<text x="126" y="908" class="card-title">Refresh</text>
<text x="126" y="942" class="body">явный переход pending → visible</text>
<text x="126" y="973" class="small muted">в fixture нет HTTP, shard или interval</text>
<path d="M360 1012 L360 1048" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="54" y="1066" width="612" height="179" rx="18" fill="#173b3a" stroke="#66d9a0" stroke-width="3"/>
<circle cx="91" cy="1114" r="20" fill="#66d9a0"/>
<text x="84" y="1121" class="small">5</text>
<text x="126" y="1110" class="card-title">Visible index</text>
<text x="126" y="1144" class="body">version 7 получила visibleAt</text>
<text x="126" y="1175" class="body">sourceSavedAt → visibleAt = 60</text>
<text x="126" y="1206" class="small good">условные мс для assertion, не SLA</text>
<path d="M360 1257 L360 1292" stroke="#78dce8" stroke-width="5" marker-end="url(#arrow)"/>
<rect x="54" y="1310" width="612" height="128" rx="18" fill="#162e29" stroke="#66d9a0" stroke-width="3"/>
<text x="88" y="1360" class="card-title">Query «свежести»</text>
<text x="88" y="1394" class="body">1 hit · current version 7</text>
<text x="88" y="1422" class="small muted">дальше проверяем scope, filter и анализ текста</text>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+620
View File
@@ -0,0 +1,620 @@
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 elasticNrt715 = {
title: 'Elasticsearch 7.13: Near real-time search',
url: 'https://www.elastic.co/guide/en/elasticsearch/reference/7.13/near-real-time.html',
note: 'историческая документация линии 7.13: refresh делает операции с индексом доступными для поиска; описание near-real-time не является SLA учебной модели',
};
const elasticIndex715 = {
title: 'Elasticsearch 7.13: Index API',
url: 'https://www.elastic.co/guide/en/elasticsearch/reference/7.13/docs-index_.html',
note: 'источник для параметра refresh и раздела versioning; fixture не вызывает API и не задаёт настройки движка',
};
const elasticGet715 = {
title: 'Elasticsearch 7.13: Get API',
url: 'https://www.elastic.co/guide/en/elasticsearch/reference/7.13/docs-get.html',
note: 'документация различает realtime GET по умолчанию и момент, когда данные видны search; это граница для диагностики, а не модель хранилища в статье',
};
const elasticRefresh715 = {
title: 'Elasticsearch 7.13: The refresh parameter',
url: 'https://www.elastic.co/guide/en/elasticsearch/reference/7.13/docs-refresh.html',
note: 'историческое описание значений false, true и wait_for и их влияния на видимость операции для search; решение о режиме зависит от нагрузки и контракта проекта',
};
export const searchTrainingDocument = Object.freeze({
id: 'article-2021-07-42',
version: 7,
title: 'Контракт свежести выдачи',
body: 'Сохранённый документ проходит ingest, индексирование, refresh и только затем становится виден учебному search.',
});
function makeEventKey(id, version) {
return id + ':' + version;
}
function normalizeQuery(value) {
return String(value).trim().toLocaleLowerCase('ru');
}
/**
* Это детерминированная модель четырёх состояний в памяти.
* Map не является БД, broker, Elasticsearch, Lucene или клиентом API.
* Временные точки назначены внутри fixture только для проверки порядка и
* арифметики наблюдаемой задержки; они не описывают реальную платформу.
*/
export function runSearchIndexingFixture() {
const sourceOfTruth = new Map();
const ingestQueue = new Map();
const acceptedIngestKeys = new Set();
const pendingIndex = new Map();
const visibleIndex = new Map();
const timeline = [];
const stages = [];
function persistSource(document, atMs) {
const saved = Object.freeze({ ...document, savedAtMs: atMs });
sourceOfTruth.set(saved.id, saved);
timeline.push({ stage: 'source-saved', id: saved.id, version: saved.version, atMs });
stages.push('source-saved');
return saved;
}
function enqueueIngest(id, version, atMs) {
const key = makeEventKey(id, version);
if (acceptedIngestKeys.has(key)) {
timeline.push({ stage: 'ingest-duplicate-suppressed', id, version, atMs });
return { state: 'duplicate-ingest-suppressed', key, atMs };
}
const event = Object.freeze({ key, id, version, enqueuedAtMs: atMs });
ingestQueue.set(key, event);
acceptedIngestKeys.add(key);
timeline.push({ stage: 'ingest-enqueued', id, version, atMs });
stages.push('ingest-enqueued');
return { state: 'ingest-enqueued', event };
}
function consumeIngest(key, atMs) {
const event = ingestQueue.get(key);
if (!event) throw new Error('training ingest event is missing');
ingestQueue.delete(key);
const source = sourceOfTruth.get(event.id);
if (!source) {
return { state: 'source-missing', id: event.id, version: event.version, atMs };
}
if (source.version !== event.version) {
timeline.push({ stage: 'stale-ingest-skipped', id: event.id, version: event.version, atMs });
return { state: 'stale-ingest-skipped', id: event.id, version: event.version, atMs };
}
const indexed = Object.freeze({
id: source.id,
version: source.version,
title: source.title,
body: source.body,
sourceSavedAtMs: source.savedAtMs,
indexedAtMs: atMs,
});
pendingIndex.set(indexed.id, indexed);
timeline.push({ stage: 'indexed-pending-refresh', id: indexed.id, version: indexed.version, atMs });
stages.push('indexed-pending-refresh');
return { state: 'indexed-pending-refresh', document: indexed };
}
function refreshSearch(atMs) {
const refreshed = [];
for (const document of pendingIndex.values()) {
visibleIndex.set(document.id, Object.freeze({ ...document, visibleAtMs: atMs }));
refreshed.push(document.id);
}
pendingIndex.clear();
timeline.push({ stage: 'refresh-completed', documentIds: refreshed, atMs });
stages.push('refresh-completed');
return { state: 'refresh-completed', documentIds: refreshed, atMs };
}
function searchVisible(query) {
const needle = normalizeQuery(query);
const hits = [...visibleIndex.values()]
.filter((document) => normalizeQuery(document.title + ' ' + document.body).includes(needle))
.map((document) => ({
id: document.id,
version: document.version,
title: document.title,
visibleAtMs: document.visibleAtMs,
}));
return { query, hitCount: hits.length, hits };
}
function diagnoseVisibility(id) {
const source = sourceOfTruth.get(id);
const pending = pendingIndex.get(id);
const visible = visibleIndex.get(id);
const queued = [...ingestQueue.values()].find((event) => event.id === id);
if (!source) {
return {
stage: 'source-missing',
destructiveAction: false,
action: 'stop-and-check-source-write-before-creating-any-new-document',
};
}
if (queued) {
return {
stage: 'ingest-queued',
sourceVersion: source.version,
queuedVersion: queued.version,
destructiveAction: false,
action: 'record-event-key-and-observe-ingest-before-recreating-the-source',
};
}
if (pending) {
return {
stage: 'awaiting-refresh',
sourceVersion: source.version,
pendingVersion: pending.version,
destructiveAction: false,
action: 'measure-the-gap-and-use-the-project-refresh-policy-or-a-controlled-local-test',
};
}
if (!visible) {
return {
stage: 'not-visible-after-index-step',
sourceVersion: source.version,
destructiveAction: false,
action: 'compare-index-target-and-refresh-evidence-before-any-reindex',
};
}
if (visible.version < source.version) {
return {
stage: 'visible-version-stale',
sourceVersion: source.version,
visibleVersion: visible.version,
destructiveAction: false,
action: 'enqueue-the-current-source-version-idempotently-and-preserve-the-old-evidence',
};
}
return {
stage: 'query-contract',
sourceVersion: source.version,
visibleVersion: visible.version,
destructiveAction: false,
action: 'inspect-query-scope-filter-and-text-analysis-before-reindexing',
};
}
const source = persistSource(searchTrainingDocument, 100);
const firstEnqueue = enqueueIngest(source.id, source.version, 110);
const duplicateEnqueue = enqueueIngest(source.id, source.version, 111);
const queuedCountBeforeConsume = ingestQueue.size;
const beforeIndexingSearch = searchVisible('свежести');
const indexed = consumeIngest(firstEnqueue.event.key, 125);
const pendingCountBeforeRefresh = pendingIndex.size;
const retryAfterConsume = enqueueIngest(source.id, source.version, 126);
const beforeRefreshSearch = searchVisible('свежести');
const beforeRefreshDiagnosis = diagnoseVisibility(source.id);
const refresh = refreshSearch(160);
const afterRefreshSearch = searchVisible('свежести');
const afterRefreshDiagnosis = diagnoseVisibility(source.id);
const visible = visibleIndex.get(source.id);
const observedLagMs = visible.visibleAtMs - source.savedAtMs;
const assertions = {
sourceOfTruthContainsDocument: sourceOfTruth.get(source.id)?.version === 7,
duplicateEnqueueIsSuppressed: firstEnqueue.state === 'ingest-enqueued'
&& duplicateEnqueue.state === 'duplicate-ingest-suppressed'
&& retryAfterConsume.state === 'duplicate-ingest-suppressed'
&& queuedCountBeforeConsume === 1,
searchIsStaleBeforeIndexing: beforeIndexingSearch.hitCount === 0,
indexedDocumentWaitsForRefresh: indexed.state === 'indexed-pending-refresh'
&& pendingCountBeforeRefresh === 1
&& beforeRefreshSearch.hitCount === 0,
preRefreshDiagnosisIsNonDestructive: beforeRefreshDiagnosis.stage === 'awaiting-refresh'
&& beforeRefreshDiagnosis.destructiveAction === false,
refreshMakesTrainingDocumentVisible: refresh.documentIds.includes(source.id)
&& afterRefreshSearch.hitCount === 1,
visibleDocumentKeepsCurrentVersion: afterRefreshSearch.hits[0]?.version === source.version,
observedLagIsArithmeticNotSla: observedLagMs === 60
&& source.savedAtMs === 100
&& visible.visibleAtMs === 160,
postRefreshDiagnosisMovesToQueryContract: afterRefreshDiagnosis.stage === 'query-contract'
&& afterRefreshDiagnosis.destructiveAction === false,
stagesStayOrdered: stages.join('>') === 'source-saved>ingest-enqueued>indexed-pending-refresh>refresh-completed',
};
if (!Object.values(assertions).every(Boolean)) {
throw new Error('search indexing training fixture violated a documented invariant');
}
return {
model: 'deterministic in-memory source, ingest, pending-index, refresh and query model; not an engine, database or broker',
document: source,
timeline,
firstEnqueue,
duplicateEnqueue,
retryAfterConsume,
beforeIndexingSearch,
beforeRefreshSearch,
beforeRefreshDiagnosis,
refresh,
afterRefreshSearch,
afterRefreshDiagnosis,
observedLagMs,
assertions,
};
}
const sourceContractCode = [
'const sourceDocument = {',
' id: "article-2021-07-42",',
' version: 7,',
' title: "Контракт свежести выдачи",',
'};',
'',
'// source-of-truth отвечает за содержимое и версию.',
'// search-индекс отвечает только за отдельную проекцию для запроса.',
].join('\n');
const freshnessContractCode = [
'const freshnessContract = {',
' object: "карточка статьи",',
' searchAudience: "обычная выдача",',
' startsAt: "source-saved",',
' endsAt: "visible-to-query",',
' evidence: ["sourceVersion", "eventKey", "savedAtMs", "indexedAtMs", "visibleAtMs"],',
' policy: "проект выбирает ожидание, уведомление или controlled refresh",',
'};',
'',
'// Это структура разговора о свежести, а не значение SLA.',
].join('\n');
const fixtureCode = [
'const fixture = runSearchIndexingFixture();',
'if (!Object.values(fixture.assertions).every(Boolean)) {',
' throw new Error("search fixture failed");',
'}',
'',
'fixture.beforeRefreshSearch.hitCount; // 0: source уже сохранён, search ещё stale',
'fixture.afterRefreshSearch.hitCount; // 1: тот же version стал видимым',
'fixture.observedLagMs; // 60: учебная арифметика, не SLA',
].join('\n');
const ingestCode = [
'function enqueueIngest(queue, seenKeys, document) {',
' const key = document.id + ":" + document.version;',
' if (seenKeys.has(key)) return { state: "duplicate-ingest-suppressed", key };',
' queue.set(key, { id: document.id, version: document.version });',
' seenKeys.add(key);',
' return { state: "ingest-enqueued", key };',
'}',
'',
'// seenKeys задаёт учебную границу идемпотентности после consume.',
].join('\n');
const versionCode = [
'function applyCurrentSourceVersion(source, event, pendingIndex) {',
' if (source.version !== event.version) {',
' return { state: "stale-ingest-skipped", sourceVersion: source.version };',
' }',
' pendingIndex.set(source.id, { ...source, indexedAtMs: 125 });',
' return { state: "indexed-pending-refresh", version: source.version };',
'}',
'',
'// Проверяем актуальную версию до изменения проекции.',
].join('\n');
const refreshCode = [
'function refreshVisibleIndex(pendingIndex, visibleIndex, atMs) {',
' for (const document of pendingIndex.values()) {',
' visibleIndex.set(document.id, { ...document, visibleAtMs: atMs });',
' }',
' pendingIndex.clear();',
'}',
'',
'// В fixture refresh — явный переход состояния, не HTTP-вызов.',
].join('\n');
const diagnosisCode = [
'const report = diagnoseVisibility("article-2021-07-42");',
'',
'if (report.stage === "awaiting-refresh") {',
' // Сохраняем version и точки времени; не удаляем source и не создаём копию.',
' console.log(report.action);',
'}',
'',
'if (report.stage === "query-contract") {',
' // Проверяем scope, filter и анализ текста до повторной индексации.',
' console.log(report.action);',
'}',
].join('\n');
const evidenceCode = [
'const evidence = {',
' documentId: "article-2021-07-42",',
' sourceVersion: 7,',
' eventKey: "article-2021-07-42:7",',
' query: "свежести",',
' observedAt: ["saved", "enqueued", "indexed", "visible"],',
' destructiveAction: false,',
'};',
'',
'// Без этой записи reindex легко стирает различие между причиной и следствием.',
].join('\n');
const practiceArticle = createRevision(
{
slug: 'editorial-2021-07-practice-search-indexing',
title: 'Индексация и поиск: как договориться о свежести выдачи',
categories: ['Поиск', 'Данные', 'Практика'],
cover: '/assets/editorial/2021/search-indexing-pipeline-2021.svg',
excerpt: 'Сохранённая карточка ещё не обязана быть результатом поиска. Разбираем договор свежести: источник данных, ingest, версия, refresh, наблюдаемая задержка и безопасный маршрут для оператора.',
readingMinutes: 16,
},
[
paragraph('Симптом знакомый: карточка уже открывается по прямой ссылке, а поиск по её заголовку возвращает пустую выдачу или старый текст. Цена ошибки не сводится к неудобному поиску. Пользователь повторяет действие, редактор начинает создавать дубликат, а разработчик может запустить повторную индексацию, не зная, была ли исходная запись сохранена и на каком участке она перестала быть видимой. После такой спешки история изменения становится хуже исходного сбоя.'),
paragraph('В июле 2021 года я бы начал не с параметра конкретного поискового движка, а с договора для одной выдачи. Нужно назвать момент, от которого считаем свежесть, момент, когда запись должна участвовать в запросе, и данные, по которым можно отличить обычное ожидание от потери события. Ниже используется один локальный сценарий на JavaScript. Он держит source-of-truth, ingest, pending index и видимую проекцию в памяти. Это не Elasticsearch, не база, не broker и не результат замера в production.'),
heading('Свежесть выдачи — отдельный результат, а не побочный эффект записи'),
paragraph('Сохранение источника и видимость в поиске отвечают на разные вопросы. Источник хранит корректную версию карточки. Поисковая проекция хранит форму, удобную для запроса. Между ними появляется работа: сформировать ingest-задачу, принять нужную версию, обновить индексную проекцию и открыть её для query. Если назвать все эти шаги словом «сохранили», невозможно понять, где искать причину: в записи, в постановке, в обработчике, в refresh или в самом запросе.'),
paragraph('Договор свежести полезно писать рядом с пользовательским сценарием. Например: «после сохранения статьи обычная текстовая выдача должна получить либо видимую текущую версию, либо понятный статус ожидания; редактор не создаёт вторую статью вместо первой». Это не SLA и не числовое обещание по умолчанию. Здесь важнее граница: какую выдачу обсуждаем, какая версия источника является актуальной, какой сигнал подтверждает видимость и кто принимает решение при отставании.'),
dataTable(
'Минимальный договор свежести для одной поисковой выдачи',
['Часть договора', 'Что фиксируем', 'Чем проверяем', 'Чего не обещаем'],
[
['Источник', 'id и доменная version сохранённой карточки', 'чтение source-of-truth по id', 'что query уже видит эту версию'],
['Ingest', 'ключ id:version и состояние постановки', 'одна запись задачи для одинакового ключа', 'что любая повторная постановка создаст новую работу'],
['Индексирование', 'version, принятая в pending-проекцию', 'сравнение event.version с source.version', 'что старая задача может переписать новую версию'],
['Видимость', 'момент refresh и version в visible-проекции', 'query плюс visibleAtMs', 'мгновенную видимость после сохранения'],
['Реакция', 'допустимое действие при задержке', 'зафиксированный маршрут диагностики', 'что delete и reindex всегда безопасны'],
],
),
paragraph('У этой таблицы есть практическая польза: она запрещает спорить о «медленном поиске» без объекта наблюдения. Если проблема относится к фильтру категории, это уже договор query. Если событие не поставлено, это ingest. Если pending-версия есть, а visible ещё нет, это переход видимости. Каждая ветка требует своей проверки и своего владельца. Нельзя лечить их одинаковым повтором сохранения страницы.'),
heading('Сначала отмечаем границу источника и проекции'),
paragraph('Для одной учебной статьи источник содержит устойчивые id, version, title и body. Его version принадлежит доменной записи, а не поисковой строке. Проекция может менять форму текста, поля и стратегию запроса, но не должна изобретать новую версию содержимого. Поэтому ingest получает id и version. Обработчик сверяет их с текущим источником до того, как положит данные в pending index. Эта проверка не делает систему распределённо согласованной; она только не даёт старому учебному событию молча выдать себя за новую карточку.'),
codeBlock(sourceContractCode),
paragraph('Стабильный ключ постановки строится из id и version. Fixture сохраняет принятый ключ отдельно от самой очереди: повтор до consume и повтор после consume получают <code>duplicate-ingest-suppressed</code>, а не создают вторую учебную работу. Это узкий инвариант. Он не доказывает идемпотентность реального producer, очереди или API, потому что в модели нет сети, базы и конкурирующих процессов. Но именно такой маленький тест помогает заранее назвать ключ и границу его хранения, которые в проекте придётся сохранять и наблюдать.'),
codeBlock(ingestCode),
figure(
'/assets/editorial/2021/search-indexing-pipeline-2021.svg',
'Вертикальная схема учебного пути документа: source-of-truth сохраняет version 7, ingest подавляет повторный ключ, pending index ещё не участвует в query, refresh переносит документ в visible index, после чего query находит одну версию',
'Учебный путь записи: сохранение источника и видимость в выдаче разделены явным переходом refresh.',
),
heading('Бюджет задержки строится из наблюдаемых точек, а не из чужого значения'),
paragraph('Вместо формулы «поиск должен обновляться быстро» полезно записать четыре точки: <code>savedAtMs</code>, <code>enqueuedAtMs</code>, <code>indexedAtMs</code> и <code>visibleAtMs</code>. Их разность показывает, в каком отрезке находится отставание. В fixture источник сохранён на 100, ingest поставлен на 110, проекция подготовлена на 125, а refresh сделан на 160. Разница между сохранением и видимостью равна 60 условным миллисекундам. Это арифметика теста, не реальная задержка и не целевой бюджет для какого-либо кластера.'),
paragraph('Когда эти точки известны, команда может договориться о реакции без ложной точности. Для обычной выдачи допустимо показать состояние ожидания и измерить следующую проверку. Для сценария, который обязан читать свою запись, потребуется другой путь: например, чтение источника или явно выбранный режим ожидания. Выбирать его надо по цене ожидания, нагрузке и поведению конкретной версии движка. Историческая документация Elasticsearch 7.13 описывает refresh как переход, делающий операции доступными search, и отдельно предупреждает, что search работает near-real-time. Это описание механизма, а не переносимый SLA.'),
codeBlock(freshnessContractCode),
paragraph('В реальном проекте сама единица бюджета тоже зависит от вопроса. Иногда нужно измерить время от публикации до первой видимости. Иногда важнее число текущих pending-версий или доля запросов, где карточка не прошла фильтр. Нельзя смешивать их в один «лаг поиска». Один показатель отвечает на вопрос о pipeline, другой — о запросе, третий — о содержимом. Сначала выбираем один пользовательский случай, затем оставляем у него минимальный набор времени, version и ключа события.'),
heading('Fixture показывает stale выдачу без настоящего движка'),
paragraph('Сквозная fixture сначала сохраняет документ в source-of-truth и ставит ingest. Затем второй вызов постановки с тем же id и version подавляется. После обработки document лежит в pending index, но <code>searchVisible</code> ещё возвращает ноль попаданий. Это и есть контролируемая stale выдача: источник существует, проекция подготовлена, но переход видимости не выполнен. Только после явного <code>refreshSearch</code> тот же запрос получает одну карточку с version 7.'),
paragraph('Такой пример важен именно своей скромностью. Он не строит индекс, не анализирует русский текст так же, как поисковый движок, не вызывает HTTP и не имитирует shard. Его результат проверяем: assertions фиксируют сохранение источника, подавление дубликата, пустой поиск до refresh, видимый текущий version после refresh, арифметику 60 и неразрушающий диагноз. Если один из этих результатов перестать выполняться после правки модели, Node завершится ошибкой.'),
codeBlock(fixtureCode),
heading('Маршрут для обычной выдачи'),
paragraph('Маршрут остаётся линейным: симптом — source уже сохранён, а query пуст; причина ищется в одном из переходов id:version; проверка фиксирует version и точки времени; действие выбирается только для найденной границы. Такой порядок не подменяет отставание видимости повторным сохранением карточки.'),
orderedList([
'Выбрать одну выдачу и один объект: например, поиск опубликованной статьи по заголовку. Не начинать с общего слова «индекс».',
'Записать source id и version, затем определить, какой ключ представляет постановку для этой версии.',
'Сохранить точки savedAtMs, enqueuedAtMs, indexedAtMs и visibleAtMs там, где они реально доступны. Не подставлять значения из fixture в рабочие логи.',
'Проверить один отрицательный путь: source уже существует, а query до refresh не находит документ. Это отличает ожидание видимости от потери source.',
'Для одинакового id:version повторить постановку и убедиться, что наблюдаемое действие не создаёт второй независимый ingest.',
'Выбрать реакцию для превышения проектного бюджета: ждать следующего планового перехода, показывать статус или запускать заранее согласованную проверку. Не начинать с удаления источника.',
'После исправления снова проверить query, version и время видимости. Если запрос всё ещё пустой, перейти к фильтру, области поиска и анализу текста, а не повторять refresh бесконечно.',
]),
heading('Что остаётся за границей этой заметки'),
paragraph('Elasticsearch 7.13 документирует, что refresh управляет видимостью для search, а Index API имеет собственные параметры refresh и versioning. Эти сведения нужны, чтобы не считать успешный index request универсальным признаком видимости. Но учебная Map не является проекцией внутреннего устройства Elastic и не может подтвердить поведение любого индекса, alias, реплики или настройки interval. Значения 100, 110, 125 и 160 выбраны только для детерминированного теста.'),
paragraph('В пакете не запускаются Elasticsearch, база, брокер, HTTP, браузер, CI и deployment. Здесь нет настоящего SLA, лога нагрузки, данных пользователей или обещания мгновенного поиска. Следующий проверяемый шаг в своём проекте — взять один безопасный документ, записать его id и version, сравнить момент сохранения с моментом поиска и отдельно проверить политику refresh выбранной исторической версии движка. Тогда симптом станет маршрутом, а не поводом создавать дубликаты.'),
],
[elasticNrt715, elasticIndex715, elasticRefresh715],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2021-07-mechanism-search-indexing',
title: 'Индексация и поиск: почему запись не означает видимость',
categories: ['Поиск', 'Данные', 'Механизм'],
cover: '/assets/editorial/2021/search-indexing-lag-budget-2021.svg',
excerpt: 'Разбираем четыре состояния одной карточки — source, ingest, pending index и visible index. Показываем, где появляется stale выдача, как version защищает учебную проекцию и почему refresh не равен сохранению.',
readingMinutes: 17,
},
[
paragraph('Ошибка начинается с неверного вывода: запрос на сохранение завершился успешно, значит пользователь уже увидит новую запись в текстовом поиске. Когда этот вывод не срабатывает, команда добавляет повторный запрос, принудительный refresh или второй кеш, не зная, на каком переходе пропала ожидаемая версия. Цена — не только лишняя нагрузка. Появляются два объяснения одной карточки: source считает актуальной version 7, а выдача показывает version 6 или ничего.'),
paragraph('Разложим механизм на четыре простых состояния. Source-of-truth владеет содержимым и version. Ingest хранит намерение построить проекцию. Pending index содержит уже подготовленный документ, который ещё не участвует в учебном query. Visible index — снимок, из которого query возвращает попадания. Все четыре состояния в этой статье — <code>Map</code> внутри Node. Они специально не являются ни базой, ни поисковым движком, ни broker, ни договором доставки. Их задача — сделать спор о видимости проверяемым.'),
heading('У одного документа несколько моментов готовности'),
paragraph('Документ может быть сохранён и при этом не готов для всех читателей. Для карточки по прямой ссылке достаточно источника. Для фонового обработчика может быть достаточно id и version в ingest. Для полнотекстового поиска нужна готовая видимая проекция. Эти результаты нельзя свести к одному булеву <code>saved</code>, потому что у них разные владельцы и разные доказательства. Если интерфейс обещает «опубликовано», а поиск живёт отдельным переходом, это нужно назвать прямо в контракте.'),
dataTable(
'Состояния учебной проекции и их границы',
['Состояние', 'Владелец в модели', 'Что уже доказано', 'Чего ещё нет'],
[
['source-of-truth', 'доменная запись', 'id, version и текст сохранены', 'нет признака, что search видел документ'],
['ingest queue', 'постановка проекции', 'есть одна задача id:version', 'нет признака, что проекция применена'],
['pending index', 'индексатор', 'актуальная версия подготовлена для перехода', 'query ещё возвращает старый visible snapshot'],
['visible index', 'контур поиска', 'query может вернуть version в данном учебном снимке', 'нет доказательства о другом фильтре, alias или среде'],
],
),
paragraph('Эта схема не означает, что в каждом проекте нужны четыре отдельные технологии. Иногда source и ingestion живут в одном приложении, иногда данные приходят из другого сервиса. Важно другое: у каждого перехода есть наблюдаемое условие. Если source.version равна 7, а event.version равна 6, обработчик не должен выдавать старую задачу за актуальную. Если pending.version равна 7, а query пустой, надо проверять видимость, а не содержимое источника.'),
heading('Ingest должен передавать не только id, но и версию'),
paragraph('Один id показывает, о какой карточке идёт речь, но не отвечает на вопрос о порядке обновлений. Поэтому учебное событие содержит id и version. Ключ <code>id:version</code> даёт простую границу повтору: две одинаковые постановки становятся одним наблюдаемым намерением. Это не защита от всех гонок. Например, в модели нет параллельного producer и нет durable queue. Но если код не может объяснить, что будет при втором вызове с тем же ключом, он уже не готов к реальному переходу между компонентами.'),
codeBlock(ingestCode),
paragraph('После извлечения события индексатор читает текущий source и сравнивает version. Если источник уже изменился, старое событие получает состояние <code>stale-ingest-skipped</code> и не кладёт устаревший текст в pending index. У этой ветки одна цель: не перепутать текущую доменную запись с задержавшейся работой. Она не заменяет optimistic concurrency control или внешнее versioning настоящего движка. В Elasticsearch 7.13 Index API действительно документирует собственную модель versioning; переносить её параметры в этот пример было бы ложным сходством.'),
codeBlock(versionCode),
heading('Refresh меняет видимость, а не историю источника'),
paragraph('После успешного индексирования fixture не копирует документ сразу в visible index. Она оставляет его в pending index и запускает тот же query. Результат нулевой, хотя source существует и event был обработан. Это намеренное место stale выдачи. Оно показывает, почему фраза «документ в индексе» может быть недостаточной: нужно уточнить, в каком именно состоянии и для какого вида чтения.'),
paragraph('Затем <code>refreshSearch</code> переносит pending-документ в видимую проекцию. В учебной модели этот вызов явный, синхронный и не имеет стоимости. В реальном Elastic refresh — понятие конкретного движка и конкретной версии: документация 7.13 описывает его как механизм, который делает операции с индексом доступными search, а параметры Index API отдельно различают отсутствие действий, ожидание refresh и его запрос. Из этого не следует, что explicit refresh нужно ставить в каждый write-path или что он даёт одинаковую цену на любой нагрузке.'),
codeBlock(refreshCode),
figure(
'/assets/editorial/2021/search-indexing-lag-budget-2021.svg',
'Схема учебного бюджета задержки: источник сохранён в точке 100, ingest поставлен в 110, pending index подготовлен в 125, refresh завершён в 160, query до refresh видит ноль попаданий, после него — текущую version; подпись отмечает, что 60 условных миллисекунд не являются SLA',
'Временная шкала fixture: наблюдаемая задержка складывается из переходов, а не называется «лагом поиска» без доказательств.',
),
heading('Search и прямое чтение отвечают на разные вопросы'),
paragraph('Иногда расследование осложняет то, что один способ чтения уже видит обновление, а другой ещё нет. В документации Elasticsearch 7.13 Get API по умолчанию обозначен как realtime и не зависит от момента, когда данные становятся видимыми search. Это полезная историческая граница: успешное чтение по id и успешный текстовый query могут требовать разного доказательства. Но нельзя переносить этот факт в любую архитектуру. В fixture source Map не эмулирует Get API, а visible Map не эмулирует индекс Elastic; модель лишь делает различие явным.'),
paragraph('Практический вывод короткий: в отчёте о сбое нужно указать, каким именно чтением найден документ. «Карточка открылась» и «поиск вернул карточку с нужным фильтром» — два разных факта. Первый проверяет source path. Второй проверяет query path, который включает видимость, область поиска, поля, анализ текста и фильтры. Когда эти факты склеены, повторная индексация маскирует проблему вместо того, чтобы найти её участок.'),
heading('Fixture проверяет переходы, а не рисует счастливую схему'),
paragraph('В <code>runSearchIndexingFixture()</code> один документ с version 7 сначала сохраняется в source. Первая постановка создаёт ingest-задачу, вторая с тем же ключом подавляется. До index и до refresh поиск по слову «свежести» пуст. После обработки pending index содержит документ, а безопасный диагноз говорит <code>awaiting-refresh</code> и запрещает destructive action. После refresh query возвращает ровно один hit с version 7; диагноз перемещается в <code>query-contract</code>.'),
codeBlock(fixtureCode),
paragraph('Assertions не проверяют скорость Elastic, устойчивость storage или поведение production. Они проверяют только то, что обещает сама модель: порядок этапов, один ingest-key, отсутствие попадания до refresh, одно попадание после него, сохранение version и арифметику 60 условных миллисекунд. Если добавить вторую версию, alias или анализатор, нужно сначала расширить fixture и назвать новый инвариант. Нельзя выдать текущий маленький прогон за тест распределённой поисковой системы.'),
heading('Маршрут от записи к видимому query'),
paragraph('Симптом здесь один: search не подтверждает ожидаемую version. Причина может быть только в source, ingest, pending-проекции, переходе видимости или самом query. Проверка идёт в этом порядке, а действие относится к найденной границе. Так forced refresh не становится универсальным ответом на любое пустое попадание.'),
orderedList([
'Зафиксировать id, version и конкретный запрос, который должен найти документ. «Он где-то не ищется» не является проверяемым симптомом.',
'Проверить source-of-truth: нужная версия действительно сохранена и доступна тому пути чтения, который заявлен контрактом.',
'Проверить ingest key id:version. Повторный вызов должен быть различим как duplicate или как новая версия, а не превращаться в анонимную вторую задачу.',
'Проверить, какую version принял индексатор. При несоответствии source.version и event.version не продолжать с устаревшим payload.',
'Проверить, находится ли документ в промежуточном состоянии ожидания видимости. Сохранить точки времени, прежде чем менять refresh policy.',
'После согласованного перехода видимости повторить тот же query и сравнить id, version, scope и фильтры. Одного найденного текста недостаточно, если запрос пользователя другой.',
'Если версия стала видимой, но query пуст, перейти к контракту запроса. Если нет — исправлять участок ingest или refresh с обратимым планом, а не удалять источник.',
]),
heading('Ограничения и историческая рамка'),
paragraph('Эта модель намеренно не хранит documents на диске, не создаёт сегменты, не выбирает refresh interval и не знает ничего о репликах. Она не обещает near-real-time как точную задержку. Значения времени — только входные данные для assertion. Исторические источники Elastic 7.13 нужны, чтобы правильно разделить index request, refresh и search visibility, но фактическое поведение проекта зависит от версии, настройки индекса, нагрузки, прав, routing и способа запроса.'),
paragraph('Следующий шаг — не включить опцию по чужой рекомендации, а проверить один поток собственной системы. Нужны id, version, timestamp и один фиксированный query. Затем можно решить, где хранить evidence, кто владеет свежестью и какая реакция допустима при отставании. Такая последовательность оставляет границу между источником и поиском понятной и не заставляет авторизованный write-path отвечать за всё поведение выдачи.'),
],
[elasticNrt715, elasticIndex715, elasticGet715, elasticRefresh715],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2021-07-field-search-indexing',
title: 'Документ сохранён, но не найден: безопасная диагностика поиска',
categories: ['Поиск', 'Данные', 'Разбор'],
cover: '/assets/editorial/2021/search-indexing-diagnosis-2021.svg',
excerpt: 'Пошаговый разбор случая, когда source существует, а поисковая выдача пуста или устарела: какие доказательства собрать, как отличить ожидание refresh от ошибки query и почему безопаснее не удалять документ первым действием.',
readingMinutes: 16,
},
[
paragraph('Проблема выглядит так: документ сохранён, карточка открывается, но поиск не находит его по ожидаемому слову. Самая дорогая реакция в этот момент — сразу удалить запись, создать её заново или запустить широкую повторную индексацию. Так можно потерять исходную version, сделать новый event неотличимым от старого и стереть доказательство того, что источник вообще был исправен. Сначала нужен короткий отчёт: какой id, какая version, какой query, где именно документ уже виден и в какой момент это наблюдалось.'),
paragraph('Ниже — полевой маршрут для одного учебного документа. Он не запускает Elasticsearch, БД, broker, HTTP или реальный reindex. Сквозная fixture хранит source, ingest, pending index и visible index в памяти. До refresh source уже содержит version 7, а query пуст. Диагностика возвращает <code>awaiting-refresh</code> и действие без удаления. После refresh тот же query находит version 7, и следующий вопрос меняется: не «где документ», а «совпадает ли контракт запроса с тем, что ищет пользователь».'),
heading('Сначала собираем доказательство, которое переживёт исправление'),
paragraph('Минимальная карточка инцидента должна содержать document id, source version, ключ постановки, текст и параметры query, время наблюдения и точку pipeline, где сделана проверка. Если есть доступ к источнику, записываем именно version, а не только текст заголовка: одинаковый заголовок может принадлежать двум разным состояниям. Если есть event, сохраняем id:version, а не только строку «поставили в очередь». Такой набор позволяет повторить проверку после изменения настройки и не спутать новую работу с исходным симптомом.'),
codeBlock(evidenceCode),
paragraph('Доказательство не должно содержать персональные данные, секреты или полный пользовательский запрос, если они не нужны для причины. Для учебной fixture достаточно id, version, одного слова поиска и четырех моментов времени. В реальном проекте к ним добавляются только те поля, которые разрешено собирать и которые отвечают на конкретную ветку: index target, alias, filter, rights, analyzer или timestamp. Чем шире бессмысленный лог, тем труднее увидеть отличие source path от query path.'),
dataTable(
'Диагностика «сохранён, но не найден»: симптом не равен причине',
['Наблюдение', 'Вероятная граница', 'Контрольная проверка', 'Rollback-safe действие'],
[
['source отсутствует по id', 'write path или неверный id', 'сверить id, version и результат сохранения', 'остановиться; не создавать копию до подтверждения источника'],
['source есть, event ожидает', 'ingest', 'найти key id:version и время постановки', 'сохранить evidence и наблюдать обработку, не менять source'],
['pending version есть, query пуст', 'переход видимости', 'сравнить indexedAtMs с visibleAtMs или признаком refresh', 'следовать проектной policy либо локальному controlled test'],
['visible version старая', 'порядок версий', 'сравнить source.version и visible.version', 'поставить текущую version идемпотентно, сохранив старый след'],
['visible version текущая, query пуст', 'контракт query', 'проверить scope, filter, поле и анализ текста', 'изменять запрос или проекцию точечно и с обратимым шагом'],
],
),
paragraph('Таблица нужна не для угадывания причины по одному признаку. Она удерживает порядок: сначала доказываем, что source существует; затем выясняем судьбу id:version; только после этого обсуждаем refresh; и лишь потом меняем query или mapping. Если начать с последнего пункта, можно создать новый индекс и всё равно не заметить, что event не был принят. Если начать с удаления, можно лишиться единственной версии, с которой можно сравнить результат.'),
heading('Когда source существует, а выдача stale, не подменяем диагноз reindex'),
paragraph('В fixture после обработки ingest документ лежит в pending index. Source уже подтверждён, ключ постановки был один, version совпала. Тем не менее поиск по слову «свежести» возвращает ноль. Этот факт не доказывает, что индекс сломан. Он доказывает только то, что visible snapshot ещё не получил документ. <code>diagnoseVisibility</code> возвращает <code>awaiting-refresh</code>, отмечает version и запрещает destructive action. Так оператор может измерить промежуток и применить заранее выбранную policy, не изобретая новую запись.'),
codeBlock(diagnosisCode),
paragraph('Здесь важно отделить контролируемый эксперимент от рабочего решения. В модели explicit refresh — одна функция и она всегда завершает переход. В реальной системе тот же термин имеет цену, версионные особенности и границы охвата. Документация Elasticsearch 7.13 пишет, что refresh делает операции доступными search, но не превращает source read и text query в один маршрут. Поэтому безопасный вопрос звучит так: «какой evidence показывает, что нужная version должна была стать видимой именно для этого query?»'),
figure(
'/assets/editorial/2021/search-indexing-diagnosis-2021.svg',
'Дерево безопасной диагностики: сначала проверить source и version, затем key ingest, pending index и evidence refresh; если текущая version уже видима, перейти к scope, filter и текстовому анализу; на всех ветках запрещено удалять source без подтверждённой причины',
'Маршрут расследования: каждое действие сохраняет исходный документ и оставляет следующий проверяемый факт.',
),
heading('Проверяем контракт query после доказанной видимости'),
paragraph('Если visible index уже содержит current version, а поиск пуст, повторять ingest бессмысленно. Нужно зафиксировать фактический query: в каком поле ищем, какой filter ограничивает набор, в каком scope находится документ, как нормализуется текст и не исключают ли права запись из выдачи. В fixture query упрощён до поиска подстроки по title и body. Он не моделирует stemming, токенизацию, synonyms, routing, alias или permissions. Поэтому его успешный hit не является тестом реального анализатора.'),
paragraph('Историческая документация Elasticsearch 7.13 полезна здесь ещё одной границей: Get API по умолчанию realtime и не зависит от момента, когда данные становятся видимы search. В движке это позволяет различать «документ можно прочитать по id» и «документ найден обычным search». Но нельзя использовать это как оправдание для пропуска проверки query. Пользователь обычно видит именно выдачу с её фильтрами, а не внутреннее чтение по id. В отчёте должны быть оба факта, если оба важны.'),
heading('Одна fixture, два безопасных состояния диагностики'),
paragraph('Сквозной тест создаёт один source document с version 7. Первая постановка ingest возвращает <code>ingest-enqueued</code>; повторная до consume и повторная после него с тем же ключом возвращают <code>duplicate-ingest-suppressed</code>. До refresh diagnosis указывает на pending state и не предлагает delete. После refresh query возвращает один hit с current version, а diagnosis переводит расследование в <code>query-contract</code>. Это не означает, что любой пропавший документ нужно ждать до refresh. Это означает, что модель не смешивает два вопроса в один.'),
codeBlock(fixtureCode),
paragraph('Assertions фиксируют каждый заявленный вывод. Есть source document, duplicate enqueue подавлен до и после consume, запрос до индексирования и до refresh пуст, refresh показывает одну текущую version, задержка вычислена как 60 условных миллисекунд, а обе диагностические ветки неразрушающие. Если кто-то изменит порядок и перенесёт pending-документ в visible раньше refresh, assertion stale выдачи станет ложным. Так review получает не только текстовый вывод, но и маленькую проверку причинной цепочки.'),
heading('Rollback-safe маршрут для оператора'),
paragraph('Симптом — сохранённая запись отсутствует в заданной выдаче. Причину не угадываем: сначала source, затем id:version и видимость, после этого query. Каждая проверка оставляет evidence, а действие обратимо: источник не удаляется, повторная постановка привязана к текущей version, а изменение запроса проверяется исходным запросом.'),
orderedList([
'Сохранить id, source version, event key, точный query и время наблюдения. Не исправлять систему до появления этого минимального следа.',
'Проверить source-of-truth по id. Если его нет, остановить дальнейшие поисковые действия и выяснить write path; не создавать дубликат «для проверки».',
'Если source есть, проверить состояние постановки id:version. Повторная постановка допустима только как явно идемпотентный шаг, а не как новый анонимный event.',
'Если проекция ждёт видимости, зафиксировать pending version и точки времени. Использовать только согласованную project policy или локальный controlled test; не распространять его на все записи.',
'Если visible version отстаёт, сравнить version источника и event, затем поставить именно текущую version с сохранением старого evidence. Не стирать прежнюю запись до проверки результата.',
'Если visible version актуальна, проверить scope, filter, поле, нормализацию текста и права query. Не возвращаться к refresh, пока этот контракт не проверен.',
'После точечной правки повторить исходный query, записать результат и добавить сценарий в fixture или интеграционный тест проекта. Откатить изменение можно по сохранённому evidence, а не по памяти о симптоме.',
]),
heading('Границы, версия и следующий шаг'),
paragraph('Сама по себе фраза «документ проиндексирован» не даёт права менять источник или объявлять инцидент закрытым. В Elastic 7.13 есть отдельные механизмы Index API, refresh и Get API; их реальные параметры, стоимость и поведение определяются развернутой версией и настройками. Наша fixture намеренно не заявляет ничего о shard, replica, alias, interval, persistence, concurrency, permissions или продуктивном логе. Она делает один безопасный вывод: сначала найти участок между source и query, затем применять обратимое действие.'),
paragraph('Следующий шаг для своего проекта — выбрать один тестовый документ без чувствительных данных, пройти его по той же карточке evidence и сравнить source version с результатом одного фиксированного search. Если между ними есть промежуток, зафиксируйте владельца и реакцию. Если проекция уже актуальна, переключите расследование на query contract. Такой порядок бережёт источник, не обещает мгновенную видимость и оставляет после исправления воспроизводимый способ проверки.'),
],
[elasticNrt715, elasticIndex715, elasticGet715, elasticRefresh715],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
.map(({ proseLength, ...revision }) => revision);
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
} else if (process.argv.includes('--verify-fixture')) {
process.stdout.write(JSON.stringify(runSearchIndexingFixture(), null, 2) + '\n');
}