8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"index": 233,
|
||
"slug": "editorial-2021-07-mechanism-search-indexing",
|
||
"title": "Индексация и поиск: почему сохранённая запись ещё не видна",
|
||
"excerpt": "Запись уже открывается по прямой ссылке, но пропала из поиска. Разбираем путь source → ingest → index → refresh → query, проверяем устаревшую версию и выбираем безопасное действие.",
|
||
"contentHtml": "<p>В июле 2021 года разработчик выкатывает новую карточку: форма отвечает успешно, а поиск не возвращает её по заголовку. Прямая ссылка открывает запись, поэтому первая гипотеза звучит просто: «поиск сломан». Наблюдаемый симптом ещё не объясняет, на какой границе возникла проблема. Это учебная сцена, а не отчёт о конкретном production-инциденте. Её цена понятна: повторная запись, полный reindex или безусловный refresh меняют систему до того, как сохранено исходное evidence.</p>\n<p>Сохранение и видимость в поиске — разные события. Source of truth хранит доменные данные, ingest принимает работу, индексатор строит поисковую проекцию, refresh открывает подготовленные сегменты для search, а query применяет поле, фильтр, область и права. Пока неизвестно, на какой границе остановилась нужная версия, исправлять запрос или запускать массовую индексацию рано.</p>\n<h2>Механизм: одна запись проходит несколько состояний</h2>\n<p>Успешная запись доказывает только результат write-path. Для фоновой обработки нужен принятый event с идентификатором и версией. Для поиска нужен документ в индексе и видимый снимок этого индекса. В небольшом приложении несколько состояний могут жить в одном процессе, но границы всё равно должны быть различимы в коде и диагностике.</p>\n<div class=\"table-scroll\"><table><caption>Состояние документа и доказательство готовности</caption><thead><tr><th scope=\"col\">Состояние</th><th scope=\"col\">Что уже доказано</th><th scope=\"col\">Чего ещё нет</th></tr></thead><tbody><tr><td>source of truth</td><td>По <code>id</code> читается актуальный текст и <code>version</code></td><td>Search ещё не обязан видеть запись</td></tr><tr><td>ingest queue</td><td>Есть работа для пары <code>id:version</code></td><td>Индексатор ещё не применил её</td></tr><tr><td>pending index</td><td>Проекция подготовлена</td><td>Видимый снимок может оставаться старым</td></tr><tr><td>visible index</td><td>Документ доступен конкретному маршруту поиска</td><td>Это не доказывает работу другого alias, фильтра или окружения</td></tr></tbody></table></div>\n<p>Такая модель не требует четырёх сервисов. Она требует четырёх проверяемых фактов. Если их свести к флагу <code>saved</code>, команда принимает задержку за потерю данных или лечит ошибку фильтра повторной индексацией. Одна запись с одним <code>id</code> обычно обновляет одну проекцию; дубликат появляется только при отдельной ошибке идентичности, например при разных ключах для одного доменного объекта.</p>\n<h2>Версия защищает от запоздалой работы</h2>\n<p>Один идентификатор не описывает порядок изменений. Источник получает версию 6, затем версию 7, а событие для версии 6 задерживается в очереди. Если индексатор без проверки применит его после версии 7, поиск снова покажет старый текст. Поэтому event должен переносить как минимум <code>id</code> и <code>version</code>, а обработчик — сравнивать их с текущим источником.</p>\n<pre><code>import assert from 'node:assert/strict';\n\nconst sourceOfTruth = new Map([\n ['article-42', { id: 'article-42', version: 7, title: 'Контракт свежести выдачи', body: 'refresh' }],\n]);\nconst pendingIndex = new Map();\nconst visibleIndex = new Map();\nconst acceptedKeys = new Set();\n\nfunction enqueue(event) {\n const key = event.id + ':' + event.version;\n if (acceptedKeys.has(key)) return { state: 'duplicate-suppressed', key };\n acceptedKeys.add(key);\n return { state: 'accepted', key };\n}\n\nfunction consume(event) {\n const current = sourceOfTruth.get(event.id);\n if (!current || current.version !== event.version) {\n return { state: 'stale-skipped', event };\n }\n pendingIndex.set(event.id, { ...current, indexedVersion: event.version });\n return { state: 'indexed-pending-refresh', version: event.version };\n}\n\nfunction refreshSearch() {\n for (const [id, document] of pendingIndex) visibleIndex.set(id, document);\n pendingIndex.clear();\n}\n\nfunction search(query) {\n const needle = query.toLowerCase();\n return [...visibleIndex.values()].filter((document) =>\n (document.title + ' ' + document.body).toLowerCase().includes(needle),\n );\n}\n\nconst stale = { id: 'article-42', version: 6 };\nconst current = { id: 'article-42', version: 7 };\nassert.equal(enqueue(stale).state, 'accepted');\nassert.equal(consume(stale).state, 'stale-skipped');\nassert.equal(enqueue(current).state, 'accepted');\nassert.equal(consume(current).state, 'indexed-pending-refresh');\nassert.equal(search('свежести').length, 0); // pending ещё не виден\nrefreshSearch();\nassert.equal(search('свежести')[0].indexedVersion, 7);\nassert.equal(enqueue(current).state, 'duplicate-suppressed');</code></pre>\n<p>Этот фрагмент запускается в Node.js и проверяет только учебную модель: устаревшее событие отбрасывается, актуальное попадает в pending, до refresh поиск пуст, после refresh видна версия 7, повтор того же ключа подавляется. <code>acceptedKeys</code> — in-memory граница примера, а не гарантия брокера. В рабочей системе нужны долговечное хранение receipt, политика повторов и атомарное решение о порядке применения.</p>\n<p>Проверка версии в приложении также не равна optimistic concurrency control. Elasticsearch поддерживает собственные варианты versioning и sequence numbers, но проект должен выбрать один источник порядка и один контракт записи. Нельзя одновременно молча полагаться на номер события, внутреннюю версию индекса и время доставки.</p>\n<h2>Refresh меняет видимость, а не источник</h2>\n<p>Документ может уже находиться в pending index, пока запрос читает предыдущий видимый снимок. Refresh делает недавние операции доступными для search; он не исправляет неправильный <code>id</code>, не добавляет отсутствующее поле и не отменяет фильтр доступа. Поэтому «сделали refresh — стало видно» доказывает только границу видимости, но не исправность всей цепочки.</p>\n<pre><code>const pending = new Map([\n ['article-42', { title: 'Контракт свежести выдачи', version: 7 }],\n]);\nconst visible = new Map();\n\nfunction refresh() {\n for (const [id, document] of pending) visible.set(id, { ...document });\n pending.clear();\n}\n\nconsole.log(visible.has('article-42')); // false\nrefresh();\nconsole.log(visible.get('article-42').version); // 7</code></pre>\n<p>В учебной модели refresh синхронен и бесплатен. В Elasticsearch это отдельный механизм near-real-time поиска: изменения становятся видимыми после refresh, а не обязательно в момент ответа API записи. Параметр <code>refresh=true</code> запрашивает немедленную видимость и может увеличить нагрузку. Параметр <code>refresh=wait_for</code> ждёт обычного refresh; он не является универсальной кнопкой «починить поиск».</p>\n<p>Если продукту нужно дождаться видимости только что записанного документа, сначала зафиксируйте контракт конкретного API и окружения. Для одного пользовательского запроса ожидание может быть оправдано, а на массовом write-path — слишком дорогим. Частоту refresh, размер пакета и допустимую задержку нельзя переносить из документации или учебной модели без измерения.</p>\n<figure><img src=\"/assets/editorial/2021/search-indexing-lag-budget-2021.svg\" alt=\"Временная шкала пути документа: source сохранён, event поставлен, pending index подготовлен, refresh сделал версию видимой для поиска; условные точки времени не являются SLA\" loading=\"lazy\" /><figcaption>Одна учебная запись проходит четыре границы. Точки показывают порядок проверки, а не обещают задержку в production.</figcaption></figure>\n<h2>Прямое чтение и search проверяют разные маршруты</h2>\n<p>Когда карточка открывается по <code>id</code>, это подтверждает путь чтения источника или realtime get. Когда текстовый запрос возвращает карточку, он подтверждает query path. Между маршрутами могут отличаться индекс, alias, анализ текста, фильтры, права и момент обновления. Поэтому отчёт «запись существует» не закрывает инцидент с пустой выдачей.</p>\n<p>Для диагностики запишите точный запрос: слово, поле, alias или индекс, фильтр, область, окружение и время. Учебная функция <code>search</code> проверяет подстроку и не моделирует токенизацию, stemming, synonyms, routing, permissions или кэш. Её успешный результат доказывает причинную цепочку fixture, а не поведение настоящего анализатора.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Безопасный маршрут от наблюдения к следующей проверке</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Вероятная причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>По <code>id</code> нет записи</td><td>Ошибка записи или неверный идентификатор</td><td>Сверить ответ write, <code>id</code> и <code>version</code> в источнике</td><td>Остановиться и не создавать копию</td></tr><tr><td>Источник есть, event ждёт</td><td>Задержка или отказ ingest</td><td>Найти ключ <code>id:version</code> и время постановки</td><td>Сохранить evidence и проверить consumer</td></tr><tr><td>Pending есть, search пуст</td><td>Refresh ещё не открыл проекцию</td><td>Сравнить время подготовки и видимости</td><td>Применить согласованную policy, не blind reindex</td></tr><tr><td>Search возвращает старую version</td><td>Запоздалый event или нарушение порядка</td><td>Сравнить source, event и visible version</td><td>Повторить только актуальную работу идемпотентно</td></tr><tr><td>Visible version актуальна, hit пуст</td><td>Ошибка query contract</td><td>Проверить поле, alias, analyzer, scope, filter и права</td><td>Исправлять запрос или mapping точечно</td></tr></tbody></table></div>\n<p>Таблица задаёт порядок, а не угадывает причину по одному симптому. Сначала докажите наличие источника. Затем найдите судьбу конкретной пары <code>id:version</code>. После этого проверяйте видимость. Только при актуальной видимой версии переходите к query. Если начать с reindex, можно потратить ресурсы и не заметить, что consumer получает старое событие.</p>\n<h2>Отрицательный путь: документ есть, но не виден</h2>\n<p>Рассмотрим тот же учебный сценарий. Источник сохранил <code>article-42</code> с version 7. Event принят и обработан, индексатор положил version 7 в pending. До refresh query возвращает ноль попаданий. Это не доказывает потерю документа: evidence указывает на состояние <code>awaiting-refresh</code>. После refresh тот же query возвращает один документ с version 7.</p>\n<pre><code>function diagnoseVisibility(source, pending, visible, query) {\n if (!source) return { stage: 'missing-source', destructiveAction: false };\n if (!pending) return { stage: 'ingest-or-index', destructiveAction: false };\n if (!visible) return { stage: 'awaiting-refresh', destructiveAction: false };\n return visible.title.includes(query)\n ? { stage: 'query-hit', version: visible.version, destructiveAction: false }\n : { stage: 'query-contract', version: visible.version, destructiveAction: false };\n}\n\nconst diagnosis = diagnoseVisibility(\n { id: 'article-42', version: 7 },\n { id: 'article-42', version: 7 },\n null,\n 'свежести',\n);\nconsole.log(diagnosis.stage); // awaiting-refresh\n// Не удаляем source и не создаём второй документ.</code></pre>\n<p>Теперь отрицательная ветка воспроизводима: функция получает явные состояния, возвращает безопасный диагноз и не маскирует отсутствие входных данных. В настоящей системе вместо объектов понадобятся журналы записи, метрики ingest, сведения о target индексе и запрос к тестовому документу. Условные этапы не являются названиями API и не заменяют проверку конкретного движка.</p>\n<h2>Порядок действий</h2>\n<ol><li>Сохраните <code>id</code>, source version, точный текстовый query, alias или индекс, фильтры, область и время наблюдения. Не меняйте данные до появления этого минимального следа.</li><li>Проверьте источник по <code>id</code>. Если записи нет, расследуйте write-path и идентификатор. Не создавайте дубликат «для проверки».</li><li>Найдите event с ключом <code>id:version</code>. Уточните, принят ли он, обработан ли, повторён ли или отброшен как устаревший.</li><li>Если проекция pending, измерьте промежуток до видимости. Применяйте policy своего движка; локальный refresh не объявляет production исправленным.</li><li>Если видимая версия старая, сравните порядок событий и текущую version. Повторите только актуальную работу с идемпотентным ключом и сохраните старое evidence.</li><li>Если видимая версия актуальна, проверьте query contract: поле, analyzer, scope, alias, фильтр, права и окружение.</li><li>Повторите исходный запрос после точечной правки. Запишите результат и добавьте тот же отрицательный путь в интеграционный тест или наблюдение.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Эта модель не описывает шарды, реплики, alias, durable queue, конкурентные записи, mapping, analyzer, права, кэш, сетевые сбои и восстановление после отказа. Она не задаёт допустимую задержку для конкретного продукта. Решения зависят от движка, версии, нагрузки, схемы данных и пользовательского контракта.</p>\n<p>Решение готово, когда для одного безопасного тестового документа можно показать цепочку evidence: источник содержит ожидаемую version; event имеет ключ <code>id:version</code>; индексатор не принимает запоздалую версию; момент видимости измерен; исходный query после refresh возвращает нужный документ; при актуальной видимой версии проверен query contract. Отдельный отрицательный тест должен показать: до видимости поиск не выдаёт документ, а диагностика не предлагает удалить источник или создать копию.</p>\n<p>Такой критерий не обещает мгновенный поиск. Он показывает границу ответственности и безопасное следующее действие. Сохранение записи остаётся фактом источника, а видимость в поиске становится отдельным проверяемым фактом.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.elastic.co/docs/manage-data/data-store/near-real-time-search\" target=\"_blank\" rel=\"noopener noreferrer\">Elastic Docs: Near real-time search</a> — описание сегментов, refresh и задержки между записью документа и его видимостью для поиска.</li><li><a href=\"https://www.elastic.co/docs/reference/elasticsearch/rest-apis/refresh-parameter\" target=\"_blank\" rel=\"noopener noreferrer\">Elastic Docs: The refresh parameter</a> — официальный контракт значений <code>true</code>, <code>false</code> и <code>wait_for</code>, включая оговорки о нагрузке.</li><li><a href=\"https://www.elastic.co/guide/en/elasticsearch/reference/7.13/docs-index_.html\" target=\"_blank\" rel=\"noopener noreferrer\">Elasticsearch 7.13 Index API</a> — историческая документация по <code>refresh</code>, versioning и порядку применения операций, подходящая для рамки июля 2021 года.</li></ul>"
|
||
}
|