Files
progcode/editorial/agent-rewrites/233.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
21 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 233,
"slug": "editorial-2021-07-mechanism-search-indexing",
"title": "Индексация и поиск: почему сохранённая запись ещё не видна",
"excerpt": "Запись уже открывается по прямой ссылке, но пропала из поиска. Разбираем путь source → ingest → index → refresh → query, проверяем устаревшую версию и выбираем безопасное действие.",
"contentHtml": "<p>Новая карточка открывается по прямой ссылке, но поиск её не возвращает. Или поиск показывает старый текст, хотя форма сохранения ответила успешно. Это наблюдаемый симптом, а не одна причина. Если сразу повторить запись, запустить полный reindex или включить принудительный refresh, система получит лишнюю нагрузку, а расследование потеряет исходную версию. В худшем случае появятся дубликаты: источник содержит одну запись, а индекс — старую и новую проекции.</p>\n<p>Главный тезис простой: сохранение и видимость в поиске — разные события. Source of truth отвечает за доменные данные. Индексатор строит поисковую проекцию. Refresh открывает подготовленные данные для search. Запрос проверяет ещё и поле, фильтр, область поиска и права. Пока мы не знаем, на каком переходе остановилась нужная версия, исправлять запрос рано.</p>\n<h2>Механизм: одна запись проходит несколько состояний</h2>\n<p>Для карточки достаточно успешной записи в источнике. Для фоновой обработки нужен принятый event с идентификатором и версией. Для полнотекстового поиска нужен документ в индексе и момент, когда его сегмент стал видимым для search. Эти состояния нельзя заменить одним флагом <code>saved</code>. У них разные владельцы и разные проверки.</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>Документ доступен конкретному query</td><td>Это не доказывает работу другого alias, фильтра или окружения</td></tr></tbody></table></div>\n<p>Такая схема не требует четырёх отдельных сервисов. В маленьком приложении несколько состояний могут жить в одном процессе. Граница всё равно существует: код должен различать «данные сохранены», «работа принята», «проекция подготовлена» и «поиск может вернуть документ». Если граница скрыта, команда принимает задержку за потерю данных или лечит фильтр повторной индексацией.</p>\n<h2>Версия защищает от запоздалой работы</h2>\n<p>Один идентификатор не описывает порядок изменений. Представим запись <code>article-42</code>. Сначала источник получает версию 6, затем версию 7. Event для версии 6 задержался в очереди. Если индексатор обработает его после версии 7 и не проверит номер, поиск снова покажет старый текст. Поэтому event должен нести как минимум <code>id</code> и <code>version</code>, а обработчик должен сравнивать event с текущим источником.</p>\n<pre><code>const source = {\n id: 'article-42',\n version: 7,\n title: 'Контракт свежести выдачи',\n};\n\nconst event = {\n key: `${source.id}:${source.version}`,\n id: source.id,\n version: source.version,\n};\n\nconst current = sourceOfTruth.get(event.id);\nif (!current || current.version !== event.version) {\n return { state: 'stale-ingest-skipped', event };\n}\n\nreturn {\n state: 'indexed-pending-refresh',\n document: { ...current, indexedVersion: event.version },\n};</code></pre>\n<p>Код выше — учебный пример. В нём нет брокера, транзакции, конкурентных consumer-ов и долговечной очереди. Он показывает только порядок проверки: сначала найти источник, затем сравнить версию, затем подготовить проекцию. В реальном движке нужна его собственная политика конфликтов и повторов. Простая проверка в приложении не заменяет optimistic concurrency control, если несколько писателей меняют один документ одновременно.</p>\n<p>Ключ <code>article-42:7</code> также делает повтор заметным. Повторная доставка того же события не должна создавать новую смысловую запись. Это идемпотентность на границе ingest. Она не гарантирует порядок всех событий, поэтому version check остаётся обязательным. Нельзя считать повторный вызов доказательством исправления: сначала нужно выяснить, было ли исходное событие принято, обработано или отклонено как устаревшее.</p>\n<h2>Refresh меняет видимость, а не источник</h2>\n<p>Подготовленный документ может находиться в состоянии pending. Запрос к visible index в этот момент вернёт ноль результатов или старую версию. Refresh переносит подготовленные изменения в структуру, которую использует search. Он не исправляет неправильный <code>id</code>, не добавляет отсутствующее поле и не отменяет фильтр доступа.</p>\n<pre><code>// Учебная модель: здесь Map заменяет поисковый индекс.\nconst pendingIndex = new Map();\nconst visibleIndex = new Map();\n\nfunction refreshSearch() {\n for (const [id, document] of pendingIndex) {\n visibleIndex.set(id, { ...document, visibleAt: 'refresh-1' });\n }\n pendingIndex.clear();\n}\n\nfunction search(query) {\n return [...visibleIndex.values()].filter((document) =&gt;\n `${document.title} ${document.body}`.toLowerCase().includes(query.toLowerCase()),\n );\n}</code></pre>\n<p>В учебной модели refresh синхронен и бесплатен. В рабочем Elasticsearch это отдельный механизм. Текущая документация Elastic описывает near-real-time поиск: изменения становятся видимыми после refresh, а не в тот же момент, когда API записи вернул ответ. Поэтому параметр <code>refresh=true</code> нельзя превращать в безусловную кнопку на каждом write-path. Частые принудительные refresh увеличивают работу индекса и могут ухудшить пропускную способность.</p>\n<p>Если пользовательский сценарий требует дождаться появления только что записанного документа, обычно проверяют контракт конкретного API и окружения. В Elasticsearch параметр <code>refresh=wait_for</code> ждёт обычного refresh и не обязан запускать немедленный 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 и окружение важнее общего сообщения «не находится». В учебной функции выше поиск — простая проверка подстроки. Он не моделирует токенизацию, 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> и version в источнике</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 или локальный тест</td></tr><tr><td>Search возвращает старую version</td><td>Запоздалый event или конфликт порядка</td><td>Сравнить source.version, event.version и visible.version</td><td>Поставить текущую версию идемпотентно, сохранив след</td></tr><tr><td>Visible version актуальна, hit пуст</td><td>Ошибка query contract</td><td>Проверить scope, alias, filter, поле, анализатор и права</td><td>Исправлять запрос или mapping точечно</td></tr></tbody></table></div>\n<p>Таблица задаёт порядок, а не угадывает причину по одному симптому. Сначала докажите наличие источника. Затем найдите судьбу конкретной пары <code>id:version</code>. После этого проверяйте видимость. Только при актуальной видимой версии переходите к запросу. Если начать с reindex, можно потратить ресурсы и не заметить, что источник отсутствует или consumer получает старое событие.</p>\n<h2>Отрицательный путь: документ есть, но не виден</h2>\n<p>Рассмотрим учебный сценарий. Источник сохранил <code>article-42</code> с version 7. Event принят один раз. Индексатор подготовил version 7. До refresh query возвращает 0 hits. Это не доказывает потерю документа. Доказательства указывают на состояние <code>awaiting-refresh</code>. После refresh тот же query возвращает одну version 7.</p>\n<pre><code>const report = diagnoseVisibility('article-42');\n\nif (report.stage === 'awaiting-refresh') {\n // Не удаляем source и не создаём второй документ.\n console.log({\n sourceVersion: report.sourceVersion,\n pendingVersion: report.pendingVersion,\n action: 'measure-refresh-gap',\n });\n}\n\nif (report.stage === 'query-contract') {\n // Видимость доказана. Проверяем область и условия запроса.\n console.log('inspect scope, filter and analyzer');\n}</code></pre>\n<p>У этого примера нет production-результата. Значения времени, количество попаданий и имя состояния нужны, чтобы проверить порядок переходов в тесте. В настоящей системе вместо <code>Map</code> понадобятся журналы записи, метрики ingest, сведения о target индекса и безопасный запрос по тестовому документу. Не подставляйте условные миллисекунды в SLA.</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, измерьте промежуток до видимости. Применяйте только политику своего движка; локальный принудительный 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/docs/api/doc/elasticsearch/v8/operation/operation-indices-refresh\" target=\"_blank\" rel=\"noopener noreferrer\">Elastic API documentation: Refresh an index</a> — официальный API-контракт refresh и рекомендация проверять сценарий записи с последующим поиском.</li></ul>"
}